From d0797f18d552372880b29b6d028f13a3380370ba Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 11 Jul 2025 20:23:59 -0400 Subject: [PATCH 01/28] Add Test Plans --- .github/workflows/VerifyChanges.yaml | 8 +++----- .../xcshareddata/xcschemes/DevConfiguration.xcscheme | 9 ++------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index 6764a03..62d251d 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -39,7 +39,7 @@ jobs: env: DEV_BUILDS: DevBuilds/Sources XCCOV_PRETTY_VERSION: 1.2.0 - XCODE_SCHEME: DevConfiguration + XCODE_SCHEME: DevConfiguration-Package XCODE_DESTINATION: ${{ matrix.xcode_destination }} XCODE_TEST_PLAN: DevConfiguration steps: @@ -68,9 +68,7 @@ jobs: xcode-cache-deriveddata- deriveddata-directory: .build/DerivedData sourcepackages-directory: .build/DerivedData/SourcePackages - swiftpm-package-resolved-file: | - **/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved - Package.resolved + swiftpm-package-resolved-file: Package.resolved verbose: true - name: Select Xcode 16.4 run: | @@ -85,7 +83,7 @@ jobs: - name: Log Code Coverage if: github.event_name != 'push' run: | - xcrun xccov view --report .build/DevConfiguration_test.xcresult --json \ + xcrun xccov view --report .build/DevConfiguration-Package_test.xcresult --json \ | ./xccovPretty --github-comment \ > .build/xccovPretty-${{ matrix.platform }}.output - name: Upload Logs diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme index 3848aeb..9a1cd48 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme @@ -27,13 +27,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> From 8f29fbfd31feafe4fdaa1a09ad998dd4793711b0 Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 11 Jul 2025 20:33:07 -0400 Subject: [PATCH 02/28] Fix scheme naming in VerifyChanges.yaml --- .github/workflows/VerifyChanges.yaml | 4 ++-- .gitignore | 1 + .../xcshareddata/xcschemes/DevConfiguration.xcscheme | 9 +++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index 62d251d..d9fdb73 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -39,7 +39,7 @@ jobs: env: DEV_BUILDS: DevBuilds/Sources XCCOV_PRETTY_VERSION: 1.2.0 - XCODE_SCHEME: DevConfiguration-Package + XCODE_SCHEME: DevConfiguration XCODE_DESTINATION: ${{ matrix.xcode_destination }} XCODE_TEST_PLAN: DevConfiguration steps: @@ -83,7 +83,7 @@ jobs: - name: Log Code Coverage if: github.event_name != 'push' run: | - xcrun xccov view --report .build/DevConfiguration-Package_test.xcresult --json \ + xcrun xccov view --report .build/DevConfiguration_test.xcresult --json \ | ./xccovPretty --github-comment \ > .build/xccovPretty-${{ matrix.platform }}.output - name: Upload Logs diff --git a/.gitignore b/.gitignore index 0023a53..4b9d331 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +"Open Sourcing"/ diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme index 9a1cd48..3848aeb 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/DevConfiguration.xcscheme @@ -27,8 +27,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + From fa30361aada42c846b5b2f5b82d72cafd1934c70 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 31 Dec 2025 15:25:25 -0500 Subject: [PATCH 03/28] Fix gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4b9d331..9ae82d8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc -"Open Sourcing"/ +Open Sourcing/ From e6e8512dd7c8b68add2772bb3d1511b6c1574df2 Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 2 Jan 2026 10:58:27 -0500 Subject: [PATCH 04/28] Update to the latest build scripts, github workflows, and documentation from DevFoundation --- .github/workflows/VerifyChanges.yaml | 133 ++++--- Documentation/DependencyInjection.md | 233 +++++++++++++ Documentation/MarkdownStyleGuide.md | 2 +- Documentation/TestMocks.md | 102 ++++-- Documentation/TestingGuidelines.md | 328 ++++++++++++++++++ Scripts/install-git-hooks | 33 ++ .../DevConfiguration/DevConfiguration.swift | 7 +- 7 files changed, 747 insertions(+), 91 deletions(-) create mode 100644 Documentation/DependencyInjection.md create mode 100644 Documentation/TestingGuidelines.md diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index d9fdb73..d9a7d72 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -1,5 +1,4 @@ name: Verify Changes - on: merge_group: pull_request: @@ -7,62 +6,81 @@ on: push: branches: ["main"] +env: + XCODE_VERSION: 26.0.1 + jobs: lint: name: Lint - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 16.4 - run: | - sudo xcode-select -s /Applications/Xcode_16.4.0.app + - name: Select Xcode ${{ env.XCODE_VERSION }} + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app - name: Lint run: | Scripts/lint + build-and-test: name: Build and Test (${{ matrix.platform }}) needs: lint - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: include: - - platform: iOS - xcode_destination: "platform=iOS Simulator,name=iPhone 16 Pro" +# - platform: iOS +# xcode_destination: "platform=iOS Simulator,name=GitHub_Actions_Simulator" +# simulator_device_type: "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro" +# simulator_runtime: "com.apple.CoreSimulator.SimRuntime.iOS-26-0" - platform: macOS xcode_destination: "platform=macOS,arch=arm64" - - platform: tvOS - xcode_destination: "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" - - platform: watchOS - xcode_destination: "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" +# simulator_device_type: "" +# simulator_runtime: "" +# - platform: tvOS +# xcode_destination: "platform=tvOS Simulator,name=GitHub_Actions_Simulator" +# simulator_device_type: "com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K" +# simulator_runtime: "com.apple.CoreSimulator.SimRuntime.tvOS-26-0" +# - platform: watchOS +# xcode_destination: "platform=watchOS Simulator,name=GitHub_Actions_Simulator" +# simulator_device_type: "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-10-46mm" +# simulator_runtime: "com.apple.CoreSimulator.SimRuntime.watchOS-26-0" + env: DEV_BUILDS: DevBuilds/Sources + OTHER_XCBEAUTIFY_FLAGS: --renderer github-actions XCCOV_PRETTY_VERSION: 1.2.0 - XCODE_SCHEME: DevConfiguration + XCODE_SCHEME: DevFoundation-Package XCODE_DESTINATION: ${{ matrix.xcode_destination }} - XCODE_TEST_PLAN: DevConfiguration + XCODE_TEST_PLAN: AllTests + XCODE_TEST_PRODUCTS_PATH: .build/DevFoundation.xctestproducts + steps: + - name: Select Xcode ${{ env.XCODE_VERSION }} + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app + - name: Checkout uses: actions/checkout@v4 + - name: Checkout DevBuilds uses: actions/checkout@v4 with: repository: DevKitOrganization/DevBuilds path: DevBuilds - - name: Download xccovPretty + + - name: Restore XCTestProducts if: github.event_name != 'push' - run: | - gh release download ${{ env.XCCOV_PRETTY_VERSION }} \ - --repo DevKitOrganization/xccovPretty \ - --pattern "xccovPretty-macos.tar.gz" \ - -O - | tar -xz - chmod +x xccovPretty - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + id: cache-xctestproducts-restore + uses: actions/cache/restore@v4 + with: + path: ${{ env.XCODE_TEST_PRODUCTS_PATH }} + key: cache-xctestproducts-${{ github.workflow }}-${{ matrix.platform }}-${{ env.XCODE_VERSION }}-${{ github.sha }} + - uses: irgaly/xcode-cache@v1 + if: steps.cache-xctestproducts-restore.outputs.cache-hit != 'true' with: - key: xcode-cache-deriveddata-${{ github.workflow }}-${{ matrix.platform }}-${{ github.sha }} + key: xcode-cache-deriveddata-${{ github.workflow }}-${{ matrix.platform }}-${{ env.XCODE_VERSION }}-${{ github.sha }} restore-keys: | xcode-cache-deriveddata-${{ github.workflow }}-${{ matrix.platform }}- xcode-cache-deriveddata- @@ -70,36 +88,50 @@ jobs: sourcepackages-directory: .build/DerivedData/SourcePackages swiftpm-package-resolved-file: Package.resolved verbose: true - - name: Select Xcode 16.4 - run: | - sudo xcode-select -s /Applications/Xcode_16.4.0.app + - name: Build for Testing - run: | - "$DEV_BUILDS"/build_and_test.sh --action build-for-testing - - name: Test + id: build-for-testing + if: steps.cache-xctestproducts-restore.outputs.cache-hit != 'true' + run: ${{ env.DEV_BUILDS }}/build_and_test.sh --action build-for-testing + + - name: Test Without Building + id: test-without-building if: github.event_name != 'push' - run: | - "$DEV_BUILDS"/build_and_test.sh --action test + run: ${{ env.DEV_BUILDS }}/build_and_test.sh --action test-without-building + + - name: Save XCTestProducts + if: failure() && steps.cache-xctestproducts-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ env.XCODE_TEST_PRODUCTS_PATH }} + key: ${{ steps.cache-xctestproducts-restore.outputs.cache-primary-key }} + - name: Log Code Coverage if: github.event_name != 'push' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - xcrun xccov view --report .build/DevConfiguration_test.xcresult --json \ - | ./xccovPretty --github-comment \ - > .build/xccovPretty-${{ matrix.platform }}.output - - name: Upload Logs - uses: actions/upload-artifact@v4 + gh release download ${{ env.XCCOV_PRETTY_VERSION }} \ + --repo DevKitOrganization/xccovPretty \ + --pattern "xccovPretty-macos.tar.gz" \ + -O - \ + | tar -xz + chmod +x xccovPretty + + xcrun xccov view --report .build/${XCODE_SCHEME}_test-without-building.xcresult --json \ + | ./xccovPretty --github-comment \ + > .build/xccovPretty-${{ matrix.platform }}.output + + - name: Upload Logs and XCResults if: success() || failure() - with: - name: Logs-${{ matrix.platform }} - path: .build/*.log - include-hidden-files: true - - name: Upload XCResults uses: actions/upload-artifact@v4 - if: success() || failure() with: - name: XCResults-${{ matrix.platform }} - path: .build/*.xcresult + name: Logs_and_XCResults-${{ matrix.platform }} + path: | + .build/*.log + .build/*.xcresult include-hidden-files: true + - name: Upload xccovPretty output if: github.event_name != 'push' uses: actions/upload-artifact@v4 @@ -107,20 +139,23 @@ jobs: name: xccovPrettyOutput-${{ matrix.platform }} path: .build/xccovPretty-${{ matrix.platform }}.output include-hidden-files: true + post-pr-comments: name: Post PR Comments needs: build-and-test + if: ${{ github.event_name == 'pull_request' }} permissions: pull-requests: write runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} + steps: - name: Download xccovPretty output uses: actions/download-artifact@v4 with: - name: xccovPrettyOutput-iOS + name: xccovPrettyOutput-macOS + - name: Post Code Coverage Comment uses: thollander/actions-comment-pull-request@v3 with: - file-path: xccovPretty-iOS.output - comment-tag: codeCoverage-iOS + file-path: xccovPretty-macOS.output + comment-tag: codeCoverage-macOS diff --git a/Documentation/DependencyInjection.md b/Documentation/DependencyInjection.md new file mode 100644 index 0000000..478bf7c --- /dev/null +++ b/Documentation/DependencyInjection.md @@ -0,0 +1,233 @@ +# Dependency Injection + +This document outlines the dependency injection patterns and conventions that I like to use in my +Swift code. + +After reading this doc, take a look at the [Test Mocks guide](TestMocks.md) for specific guidance +about how to write mocks. + + +## When to Use Dependency Injection + +Dependency injection should be used for types that exhibit **significant non-deterministic +behavior**. The goal is to enable testing by making unpredictable behavior controllable and +observable. + + +## Dependencies vs. Parameters + +When designing initializers, distinguish between **dependencies** (which should be injected) and +**parameters** (which should be passed directly): + +### Dependencies + +Dependencies are anything you would want to mock for testing: + + - Domain models and business logic collaborators + - App services and cross-cutting concerns (networking, logging, analytics) + - External systems and OS services + + +### Parameters + +Parameters are typically configuration, input values, or behavioral modifiers + + - **Data and configuration**: Value types, strings, numbers, and configuration types + - **Communication patterns**: Delegates and callback protocols + - **Dependency injection constructs**: The containers that hold your dependencies + +### Example + + init( + // Dependencies (injected) + dependencies: Dependencies, + + // Parameters (passed directly) + delegate: UserServiceDelegate, + initialUserID: String + ) + +### Parameter Ordering + +**The dependency injection construct (`dependencies` or `dependencyProvider`) must always be the +first parameter** in an initializer. This creates consistency across the codebase and makes the +dependency injection pattern immediately recognizable. + + +## Dependency Lifecycle Patterns + +Dependencies fall into two categories based on their lifecycle: + +### 1. Instance Dependencies + +These are dependencies that are instantiated once and reused throughout the lifetime of the +consuming type. Examples include: + + - Network clients + - App services + - Domain models + +### 2. Transient Dependencies + +These are dependencies that need to be created fresh each time they’re used. Examples include: + + - View models created on-demand for efficiency (only when navigating to a view) + - Domain models created with specific runtime inputs that change over time + + +## Dependency Injection Patterns + +We use three dependency injection patterns in this codebase. The **Dependencies Struct** and +**Dependency Provider** patterns are useful when your type is more complex, has many dependencies, +or has a more fluidly defined scope. **Direct injection** is useful for simple types with stable +dependencies. View models must use either the **Dependencies Struct** or **Dependency Provider** +patterns since their functionality tends to be more fluid and change over time. Other types can use +any of the three patterns based on your best judgment. + + +### Dependencies Struct + +Use this pattern when **all dependencies are instance dependencies**. + +Create a nested `Dependencies` struct within your type that holds all required dependencies: + + final class UserService { + struct Dependencies { + let networkClient: any NetworkClient + let telemetryEventLogger: any TelemetryEventLogging + let userInfoProvider: any UserInfoProvider + } + + + private let dependencies: Dependencies + + + init(dependencies: Dependencies) { + self.dependencies = dependencies + } + } + + +### Dependency Providers + +Use this pattern when you have **any transient dependencies**. + +Create a nested `DependencyProviding` protocol that declares: + + - **Properties** for instance dependencies + - **Factory functions** (prefixed with `make`) for transient dependencies + + extension UserService { + protocol DependencyProviding { + var networkClient: any NetworkClient { get } + var telemetryEventLogger: any TelemetryEventLogging { get } + + func makeProfileViewModel() -> ProfileViewModel + func makeUserSession(userID: String) -> any UserSession + } + } + +Implement a nested `DependencyProvider` type for in-app use: + + extension UserService { + struct DependencyProvider: DependencyProviding { + let networkClient: any NetworkClient + let telemetryEventLogger: any TelemetryEventLogging + + + func makeProfileViewModel() -> ProfileViewModel { + return ProfileViewModel( + dependencies: .init( + networkClient: networkClient, + telemetryEventLogger: telemetryEventLogger + ) + ) + } + + + func makeUserSession(userID: String) -> any UserSession { + return StandardUserSession(userID: userID, networkClient: networkClient) + } + } + } +Consume the provider in your type: + + final class UserService { + private let dependencyProvider: any DependencyProviding + + + init(dependencyProvider: any DependencyProviding) { + self.dependencyProvider = dependencyProvider + } + + + func showProfile() { + let viewModel = dependencyProvider.makeProfileViewModel() + // Navigate to profile view with viewModel… + } + + + func performUserAction(for userID: String) async { + let session = dependencyProvider.makeUserSession(userID: userID) + // Use session… + } + } + +#### Testing Support + +Create a mock provider for testing, using the patterns from [TestMocks.md](TestMocks.md): + + final class MockUserServiceDependencyProvider: UserService.DependencyProviding { + nonisolated(unsafe) var networkClientStub: Stub! + nonisolated(unsafe) var telemetryEventLoggerStub: Stub! + nonisolated(unsafe) var makeProfileViewModelStub: Stub! + nonisolated(unsafe) var makeUserSessionStub: Stub! + + + var networkClient: any NetworkClient { + return networkClientStub() + } + + + var telemetryEventLogger: any TelemetryEventLogging { + return telemetryEventLoggerStub() + } + + + func makeProfileViewModel() -> ProfileViewModel { + return makeProfileViewModelStub() + } + + + func makeUserSession(userID: String) -> any UserSession { + return makeUserSessionStub(userID) + } + } + + +### Direct Injection + +Use this pattern for simple types with stable dependencies. Direct injection **must not be used for +view models**. + +Pass dependencies directly as individual parameters: + + final class ImageProcessor { + private let imageCache: any ImageCaching + private let telemetryEventLogger: any TelemetryEventLogging + + + init( + imageCache: any ImageCaching, + telemetryEventLogger: any TelemetryEventLogging + ) { + self.imageCache = imageCache + self.telemetryEventLogger = telemetryEventLogger + } + } + +Direct injection works well for: + + - Types with stable, well-defined responsibilities + - Components where dependency relationships are unlikely to change + - Utility types with few dependencies diff --git a/Documentation/MarkdownStyleGuide.md b/Documentation/MarkdownStyleGuide.md index 9eb9615..d94f75c 100644 --- a/Documentation/MarkdownStyleGuide.md +++ b/Documentation/MarkdownStyleGuide.md @@ -1,6 +1,6 @@ # Markdown Style Guide -This document defines the Markdown formatting standards for documentation in the DevConfiguration +This document defines the Markdown formatting standards for documentation in the Shopper iOS codebase. diff --git a/Documentation/TestMocks.md b/Documentation/TestMocks.md index 2647cb1..9d79a46 100644 --- a/Documentation/TestMocks.md +++ b/Documentation/TestMocks.md @@ -1,13 +1,38 @@ # Test Mock Documentation -This document outlines the patterns and conventions for writing test mocks in the DevConfiguration -codebase. +This document outlines the patterns and conventions for writing test mocks in Swift. + +Its helpful to read the [Dependency Injection guide](DependencyInjection.md) before reading this +guide, as it introduces core principles for how we think about dependency injection. ## Overview -The codebase uses a consistent approach to mocking based on the DevTesting framework's `Stub` type. -All mocks follow standardized patterns that make them predictable, testable, and maintainable. +We use a consistent approach to mocking based on the DevTesting package’s `Stub` and `ThrowingStub` +types. All mocks follow standardized patterns that make them predictable, testable, and +maintainable. + + +## When to Mock vs. Use Types Directly + +Create mock protocols when + + - The type has **non-deterministic behavior** (network calls, file I/O, time-dependent operations) + - You need to **control or observe the behavior** in tests + - The type’s behavior **varies across environments** + +Use types directly when + + - The type has **deterministic, predictable behavior** + - Testing with the real implementation provides **sufficient coverage** + - Creating abstractions adds **complexity without testing benefits** + +It’s worth pointing out that the following foundational types should be used directly. + + - **`NotificationCenter`**: Posting and observing notifications is predictable + - **`UserDefaults`**: Simple key-value storage with consistent behavior + - **`Bundle`**: Resource loading behavior is consistent and testable + - **`EventBus`**: Synchronous event dispatching with deterministic outcomes ## Core Mock Patterns @@ -21,8 +46,7 @@ for function and property implementations: final class MockService: ServiceProtocol { - nonisolated(unsafe) - var performActionStub: Stub! + nonisolated(unsafe) var performActionStub: Stub! func performAction(_ input: String) -> Bool { @@ -48,8 +72,7 @@ When functions have multiple parameters, create dedicated argument structures: } - nonisolated(unsafe) - var logErrorStub: Stub! + nonisolated(unsafe) var logErrorStub: Stub! func logError(_ error: some Error, attributes: [String : any Encodable]) { @@ -70,11 +93,8 @@ For services that only expose properties (like `MockAppServices`), each property stub: final class MockAppServices: PlatformAppServices { - nonisolated(unsafe) - var stylesheetStub: Stub! - - nonisolated(unsafe) - var telemetryEventLoggerStub: Stub! + nonisolated(unsafe) var stylesheetStub: Stub! + nonisolated(unsafe) var telemetryEventLoggerStub: Stub! var stylesheet: Stylesheet { @@ -98,6 +118,7 @@ For protocols with associated types, create generic mocks: var eventData: EventData } + extension MockTelemetryEvent: Equatable where EventData: Equatable { } extension MockTelemetryEvent: Hashable where EventData: Hashable { } @@ -128,20 +149,27 @@ For testing error scenarios, use simple enum-based errors: ## Mock Organization -### File Structure +### File Structure and Organization +#### Directory Structure: Tests/ - ├── AppPlatformTests/ - │ └── Testing Support/ - │ ├── MockAppServices.swift - │ ├── MockBootstrapper.swift - │ └── MockSubapp.swift - └── TelemetryTests/ - └── Testing Support/ - ├── MockTelemetryDestination.swift - ├── MockTelemetryEvent.swift - └── MockError.swift - + ├── [PackageName]Tests/ # Package-specific tests + │ ├── Unit Tests/ + │ │ └── [ModuleName] # Feature-specific tests + │ │ └── [ProtocolName]Tests.swift + │ └── Testing Support/ # Mock objects and test utilities + │ ├── Mock[ProtocolName].swift # Mock implementations + │ ├── MockError.swift # Test-specific error types + │ └── RandomValueGenerating+[ModuleName].swift # Random value extensions + +#### File Placement Guidelines: + +- **Unit Test files**: Place in `Tests/[PackageName]/Unit Tests/`, matching the path of the source file in + the directory structure under `Sources/` +- **Mock objects**: Always place in `Tests/[PackageName]/Testing Support/` directories +- **One mock per file**: Each protocol should have its own dedicated mock file +- **Sharing mocks**: Do not share mocks between Packages. If the same Mock is needed across + multiple packages, duplicate it. ### Naming Conventions @@ -164,8 +192,7 @@ When mocking types with custom initializers, use static stubs: } - nonisolated(unsafe) - static var initStub: Stub! + nonisolated(unsafe) static var initStub: Stub! init(appConfiguration: AppConfiguration, subappServices: any SubappServices) async { @@ -179,9 +206,11 @@ When mocking types with custom initializers, use static stubs: For functions that might not be called in every test, provide default stub values: final class MockSubapp: Subapp { - // Initialize to non-nil to avoid crashes in tests that don't configure this stub - nonisolated(unsafe) - var installTelemetryBusEventHandlersStub: Stub = .init() + // Initialize to non-nil to avoid crashes in tests that don’t configure this stub + nonisolated(unsafe) var installTelemetryBusEventHandlersStub: Stub< + TelemetryBusEventObserver, + Void + > = .init() } @@ -256,12 +285,11 @@ Import protocols under test with `@testable` when accessing internal details: 1. **Always configure stubs**: Force-unwrapped stubs will crash if not configured 2. **Use argument structures**: Simplifies complex parameter verification - 3. **Maintain protocol fidelity**: Mocks should behave like real implementations - 4. **Leverage DevTesting**: Use the framework's call tracking and verification capabilities - 5. **Keep mocks simple**: Avoid complex logic in mock implementations - 6. **Group related mocks**: Place mocks in appropriate Testing Support directories - 7. **Follow naming conventions**: Consistent naming improves maintainability - 8. **Use Swift Testing**: Leverage `@Test`, `#expect()`, and `#require()` for assertions + 3. **Leverage DevTesting**: Use the package’s call tracking and verification capabilities + 4. **Keep mocks simple**: Avoid complex logic in mock implementations + 5. **Group related mocks**: Place mocks in appropriate Testing Support directories + 6. **Follow naming conventions**: Consistent naming improves maintainability + 7. **Use Swift Testing**: Leverage `@Test`, `#expect()`, and `#require()` for assertions ## Thread Safety @@ -270,6 +298,6 @@ All mocks use `nonisolated(unsafe)` markings for Swift 6 compatibility. This ass - Tests run on a single thread or properly synchronize access - Stub configuration happens during test setup before concurrent access - - Mock usage patterns don't require additional synchronization + - Mock usage patterns don’t require additional synchronization When mocking concurrent code, consider additional synchronization mechanisms if needed. diff --git a/Documentation/TestingGuidelines.md b/Documentation/TestingGuidelines.md new file mode 100644 index 0000000..81c5877 --- /dev/null +++ b/Documentation/TestingGuidelines.md @@ -0,0 +1,328 @@ +# Testing Guidelines for Claude Code + +This file provides specific guidance for Claude Code when creating, updating, and maintaining +Swift tests. + +## Swift Testing Framework + +**IMPORTANT**: This project uses **Swift Testing framework**, NOT XCTest. Do not apply XCTest +patterns or conventions. + +### Key Differences from XCTest + + - **Use `@Test` attribute** instead of function name conventions + - **Use `#expect()` and `#require()`** instead of `XCTAssert*()` functions + - **Use `#expect(throws:)`** for testing error conditions instead of `XCTAssertThrows` + - **No "test" prefixes** required on function names + - **Struct-based test organization** instead of class-based + +### Test Naming Conventions + + - **No "test" prefixes**: Swift Testing doesn't require "test" prefixes for function names + - **Descriptive names**: Use clear, descriptive names like `initialLoadingState()` instead of + `testInitialLoadingState()` + - **Protocol-specific naming**: For protocols with concrete implementations, name tests after + the concrete type (e.g., `StandardAuthenticationRemoteNotificationHandlerTests`) + +### Unit Test Structure Conventions + +Unit tests typically have 3 portions; setup, exercise, and expect. + + - **Setup**: Create the inputs or mocks necessary to exercise the unit under test + - **Exercise**: Exercise the unit under test, usually by invoking a function using the inputs prepared during "Setup". + - **Expect**: Expect one or more results to be true, using Swift Testing expressions. + - More complicated tests may repeat the "exercise" and "expect" steps. + - The beginning of each step should be clearly marked with a code comment, like: + - // set up the test by preparing a mock authenticator + - // exercise the test by initializing the data source + - // expect that the loadUser stub is invoked once + - If two sections overlap, only mention the most relevant information. + +### Mock Testing Strategy + + - **Focus on verification**: Test that mocks are called correctly, not custom mock + implementations + - **Use standard mocks**: All tests with injectable dependencies should use the same mock + types, not custom mock implementations. + - **Always mock dependencies**: Even when not testing the mocked behavior, always use mocks + to supply dependencies to objects under test. + - **Minimal Stubbing**: Only stub functions that are relevant to the code under test, or + required for the test to execute successfully. + - Do *NOT* leave comments when stubs are omitted because they are irrelevant. + +### ThrowingStub Usage + +**CRITICAL**: DevTesting's `ThrowingStub` has very specific initialization patterns that +differ from regular `Stub`. Using incorrect initializers will cause compilation errors. + +#### Correct ThrowingStub Patterns: + + // For success cases: + ThrowingStub(defaultReturnValue: value) + + // For error cases: + ThrowingStub(defaultError: error) + + // For cases where the value could be success or error: + ThrowingStub(defaultResult: result) + + // For cases where the return type is Void and you don’t want to throw an error: + ThrowingStub(defaultError: nil) + +#### Common Mistakes to Avoid: + + - ❌ `ThrowingStub(throwingError: error)` - This doesn't exist + - ❌ `ThrowingStub()` with separate configuration - Must provide default in initializer + +### Mock Object Patterns + +Follow established patterns from `@Documentation/TestMocks.md`: + + - **Stub-based architecture**: Use `Stub` and `ThrowingStub` + - **Thread safety**: Mark stub properties with `nonisolated(unsafe)` + - **Protocol conformance**: Mock the protocol, not the concrete implementation + - **Argument structures**: For complex parameters, create dedicated argument structures + +Example mock structure: + + final class MockProtocolName: ProtocolName { + nonisolated(unsafe) var methodStub: Stub! + + func method(input: InputType) -> OutputType { + methodStub(input) + } + + nonisolated(unsafe) var throwingMethodStub: ThrowingStub! + + func throwingMethod(input: InputType) throws -> OutputType { + try throwingMethodStub(input) + } + } + + +### Random Value Generation with Swift Testing + +**IMPORTANT**: Swift Testing uses immutable test structs, but `RandomValueGenerating` requires +`mutating` functions. This creates a specific pattern that must be followed. + +#### Correct Pattern for Random Value Generation: + + @MainActor + struct MyTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + @Test + mutating func myTest() throws { + let randomValue = randomAlphanumericString() + // ... test logic + } + } + +#### Key Requirements: + + - **Test struct must conform to `RandomValueGenerating`** + - **Include `var randomNumberGenerator = makeRandomNumberGenerator()` property** + - **Mark test functions as `mutating`** when using random value generation + - **Test struct can be immutable** for tests that don't use random values + +#### Dedicated Random Value Extensions: + + - **Dedicated files**: Create `RandomValueGenerating+[ModuleName].swift` files for random value + generation + - **Centralized functions**: Move random value creation functions to these dedicated extension + files + - **Consistent patterns**: Follow existing patterns from other modules (e.g., + `RandomValueGenerating+AppPlatform.swift`) + - **Proper imports**: Include necessary `@testable import` statements for modules being + extended + +Example structure: + + import DevTesting + import Foundation + + @testable import ModuleName + + extension RandomValueGenerating { + mutating func randomModuleSpecificType() -> ModuleType { + return ModuleType( + property: randomAlphanumericString() + ) + } + } + +## File Organization + +### Test Files + + - **Naming pattern**: `[ClassName]Tests.swift` in corresponding Tests directories + - **Location**: Place in `Tests/[ModuleName]/[Category]/` directories + - **One test file per class**: Each class should have its own dedicated test file + - **Organize tests by function**: The tests for the function under test should be organized + together, preceded by a `// MARK: - [FunctionName]` comment. + +### Mock Files + + - **Naming pattern**: `Mock[ProtocolName].swift` + - **Location**: Place in `Tests/[ModuleName]/Testing Support/` directories + - **Protocol-based**: Mock the protocol interface, not concrete implementations + +### Random Value Extensions + + - **Naming pattern**: `RandomValueGenerating+[ModuleName].swift` + - **Location**: Place in `Tests/[ModuleName]/Testing Support/` directories + - **Module-specific**: Create extensions for each module's unique types + +### Import Patterns + + - **Testable imports**: Use `@testable import ModuleName` for modules under test + - **Regular imports**: Use regular imports for testing frameworks and utilities + - **Specific imports**: Import only what's needed to keep dependencies clear + +## Test Coverage Guidelines + +### Function Coverage + - **Each function/getter**: Should have at least one corresponding test function + - **Multiple scenarios**: Create additional test functions to cover edge cases and error + conditions + - **Error paths**: Test both success and failure scenarios for throwing functions + +### Error Handling in Tests + +**CRITICAL**: Test functions that use `#expect(throws:)` must be marked with `throws`, +otherwise you'll get "Errors thrown from here are not handled" compilation errors. + +#### Correct Pattern: + + @Test + func myTestThatExpectsErrors() throws { + #expect(throws: SomeError.self) { + try somethingThatThrows() + } + } + +#### Common Mistake: + + // ❌ This will cause compilation error + @Test + func myTestThatExpectsErrors() { + #expect(throws: SomeError.self) { + try somethingThatThrows() + } + } + +### Main Actor Considerations + - **Test isolation**: Mark test structs and methods with `@MainActor` when testing + MainActor-isolated code + - **Mock conformance**: Ensure mocks properly handle MainActor isolation requirements + - **Async testing**: Use proper async/await patterns for testing async code + +### Dependency Injection Testing + - **Mock all dependencies**: Create mocks for all injected dependencies to ensure proper + isolation + - **Verify interactions**: Test that dependencies are called with correct parameters + - **State verification**: Check both mock call counts and state changes in the system under + test + +## Common Testing Patterns + +### Testing Initialization + + @Test + func initializationSetsCorrectDefaults() { + let instance = ClassUnderTest() + + #expect(instance.property == expectedDefault) + } + +### Testing Dependency Calls + + @Test + mutating func methodCallsDependency() { + let mock = MockDependency() + mock.methodStub = Stub() + + let instance = ClassUnderTest(dependency: mock) + instance.performAction() + + #expect(mock.methodStub.calls.count == 1) + } + +### Testing Error Scenarios + + @Test + mutating func methodThrowsWhenDependencyFails() { + let mock = MockDependency() + let error = MockError(description: "Test error") + mock.methodStub = ThrowingStub(defaultError: error) + + let instance = ClassUnderTest(dependency: mock) + + #expect(throws: MockError.self) { + try instance.performAction() + } + } + +### Testing Async Operations + + @Test + mutating func asyncMethodCompletesSuccessfully() async throws { + let mock = MockDependency() + mock.asyncMethodStub = Stub(defaultReturnValue: expectedResult) + + let instance = ClassUnderTest(dependency: mock) + let result = await instance.performAsyncAction() + + #expect(result == expectedResult) + #expect(mock.asyncMethodStub.calls.count == 1) + } + +### Testing Async State Changes with Confirmations + +When testing async state changes that occur through observation (like SwiftUI's `withObservationTracking`), use Swift Testing's `confirmation` API to properly wait for and verify the changes: + + @Test @MainActor + mutating func stateChangesAsynchronously() async throws { + // set up the test by creating the object and mocked dependencies + let instance = ClassUnderTest() + let mockDependency = MockDependency() + instance.dependency = mockDependency + + // set up observation and confirmation for async state change + try await confirmation { stateChanged in + withObservationTracking { + _ = instance.observableState + } onChange: { + stateChanged() + } + + // exercise the test by triggering the state change + try instance.performActionThatChangesState() + + // allow time for async state change to occur + try await Task.sleep(for: .seconds(0.5)) + } + + // expect the final state to be correct + #expect(instance.observableState == expectedFinalState) + } + +#### Key Points for Async State Testing: + + - **Use `confirmation`**: Wrap observation tracking with `confirmation { callback in }` to properly wait for async changes + - **Call callback on change**: Invoke the confirmation callback in the `onChange` closure + - **Allow processing time**: Use `Task.sleep(for:)` after triggering the action to allow async processing + - **Mark test as async**: Use `async throws` and `await` for the confirmation + - **Verify final state**: Check the final state after the confirmation completes + +## Integration with Existing Documentation + +This testing documentation supplements the main project documentation: + + - **Test Mocks**: See `@Documentation/TestMocks.md` for detailed mock object patterns + - **Dependency Injection**: See `@Documentation/DependencyInjection.md` for dependency + patterns + - **MVVM Testing**: See `@Documentation/MVVMForSwiftUI.md` for view model testing approaches + +When in doubt, follow existing patterns from similar tests in the codebase and reference the +established documentation for architectural guidance. diff --git a/Scripts/install-git-hooks b/Scripts/install-git-hooks index 670ea93..e29caa6 100755 --- a/Scripts/install-git-hooks +++ b/Scripts/install-git-hooks @@ -43,8 +43,41 @@ EOF echo "Pre-commit hook installed successfully!" } +# Function to install the pre-push hook +install_pre_push_hook() { + local pre_push_hook="$REPO_ROOT/.git/hooks/pre-push" + + echo "Installing pre-push hook..." + + cat > "$pre_push_hook" << 'EOF' +#!/bin/bash + +# Get the directory where this hook is located +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Go to the repository root (two levels up from .git/hooks) +REPO_ROOT="$(dirname "$(dirname "$HOOK_DIR")")" + +# Run the test-all-platforms script +echo "Running tests on all platforms..." +if ! "$REPO_ROOT/Scripts/test-all-platforms"; then + echo "Platform tests failed. Please fix issues before pushing." + exit 1 +fi + +echo "All platform tests passed." +EOF + + chmod +x "$pre_push_hook" + echo "Pre-push hook installed successfully!" +} + # Install the pre-commit hook install_pre_commit_hook +# Install the pre-push hook +install_pre_push_hook + echo "All git hooks installed successfully!" echo "The pre-commit hook will run 'Scripts/lint' before each commit." +echo "The pre-push hook will run 'Scripts/test-all-platforms' before each push." diff --git a/Sources/DevConfiguration/DevConfiguration.swift b/Sources/DevConfiguration/DevConfiguration.swift index c3cefb9..2c15b89 100644 --- a/Sources/DevConfiguration/DevConfiguration.swift +++ b/Sources/DevConfiguration/DevConfiguration.swift @@ -7,11 +7,10 @@ import Foundation -/// Prepends the specified string with `"com.gauriar.devconfiguration."`. +/// Prepends the specified string with `"devconfiguration."`. /// -/// - Parameter suffix: The string that will have DevConfiguration’s reverse DNS prefix prepended -/// to it. +/// - Parameter suffix: The string that will have DevConfiguration’s reverse DNS prefix prepended to it. @usableFromInline func reverseDNSPrefixed(_ suffix: String) -> String { - return "com.gauriar.devconfiguration.\(suffix)" + return "devconfiguration.\(suffix)" } From ed365cb955da057a83c430fc9624441b922ad117 Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 2 Jan 2026 16:21:03 -0500 Subject: [PATCH 05/28] Add DevFoundation dependency, update DevTesting dependency --- Package.resolved | 42 +++++++++++++++++++++++++++++++++++++++--- Package.swift | 18 ++++++++++-------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/Package.resolved b/Package.resolved index f811c42..9250bc7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,49 @@ { - "originHash" : "222f113614d29faab34495fcc0b3117443743f1bed9ee6a27b0e343febfa0c14", + "originHash" : "97c9db3dea570820c16c6a119d295cf8c594561ef39006d90705aabc179b50e6", "pins" : [ + { + "identity" : "devfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DevKitOrganization/DevFoundation.git", + "state" : { + "revision" : "946a8bed082a2f657b7f1f215097f2bbb4e47fab", + "version" : "1.7.0" + } + }, { "identity" : "devtesting", "kind" : "remoteSourceControl", "location" : "https://github.com/DevKitOrganization/DevTesting", "state" : { - "revision" : "32dd16262b17b291279adda9cfc7dd683ed7e6ee", - "version" : "1.0.0-beta.7" + "revision" : "9dea13f09c19c0521e9ff7b9f14fedb977423b99", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } } ], diff --git a/Package.swift b/Package.swift index 5380ca1..eadfd1d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,19 +1,20 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 import PackageDescription let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("ExistentialAny") + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("MemberImportVisibility"), ] let package = Package( name: "DevConfiguration", platforms: [ - .iOS(.v18), - .macOS(.v15), - .tvOS(.v18), - .visionOS(.v2), - .watchOS(.v11), + .iOS(.v26), + .macOS(.v26), + .tvOS(.v26), + .visionOS(.v26), + .watchOS(.v26), ], products: [ .library( @@ -22,7 +23,8 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.0.0-beta.7"), + .package(url: "https://github.com/DevKitOrganization/DevFoundation.git", from: "1.7.0"), + .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"), ], targets: [ .target( From c297f4d15d7eeff3e187eee73862c386bd45fd61 Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 2 Jan 2026 20:24:50 -0500 Subject: [PATCH 06/28] Add Architecture & Implementation plan for guiding agents --- Architecture Plan.md | 396 +++++++++++++++++++++++++++++++++++++++++ Implementation Plan.md | 124 +++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 Architecture Plan.md create mode 100644 Implementation Plan.md diff --git a/Architecture Plan.md b/Architecture Plan.md new file mode 100644 index 0000000..7f59e7c --- /dev/null +++ b/Architecture Plan.md @@ -0,0 +1,396 @@ +# DevConfiguration Architecture + +Typesafe configuration wrapper on Apple's swift-configuration. + +--- + +## 1. Variable Definitions + +Variables defined anywhere by consumers; encouraged pattern is static properties on a shared type: + +```swift +enum Config { + static let darkMode = ConfigVariable( + key: "feature.darkMode", + fallback: false + ) +} + +// Access: config.value(for: Config.darkMode) +``` + +**Key format**: Plain `String`, passed directly to swift-config. Key transformation is provider-specific: +- JSON/YAML: `feature.darkMode` → nested lookup `{ "feature": { "darkMode": ... } }` +- Environment: `feature.darkMode` → `FEATURE_DARKMODE` +- Custom providers: define their own transformation + +### Core Types + +```swift +@dynamicMemberLookup +public struct ConfigVariable { + public let key: String + public let fallback: Value + private var metadata: VariableMetadata + + /// Builder-style metadata setter + public func metadata(_ keyPath: WritableKeyPath, _ value: M) -> Self + + /// Dynamic member access to metadata values + public subscript(dynamicMember keyPath: WritableKeyPath) -> M +} +``` + +### Metadata System + +Extensible via SwiftUI Environment-style key pattern: + +```swift +public protocol VariableMetadataKey { + associatedtype Value + static var defaultValue: Value { get } + + /// Display name for editor UI + static var keyDisplayText: String { get } + + /// Value formatting for editor UI + static func displayText(for value: Value) -> String? +} + +public struct VariableMetadata { + public subscript(key: K.Type) -> K.Value { get set } +} +``` + +Consumer-defined metadata: + +```swift +// Define key +private struct ExpirationDateKey: VariableMetadataKey { + static var defaultValue: Date? { nil } + static var keyDisplayText: String { "Expiration" } + static func displayText(for value: Date?) -> String? { value?.formatted() } +} + +// Extend VariableMetadata +extension VariableMetadata { + var expirationDate: Date? { + get { self[ExpirationDateKey.self] } + set { self[ExpirationDateKey.self] = newValue } + } +} + +// Usage +let flag = ConfigVariable(key: "feature.x", fallback: false) + .metadata(\.expirationDate, Date.now.addingTimeInterval(5 * 86400)) + +// Reading +let expires = flag.expirationDate +``` + +### Supported Value Types + +| Type | Resolution | +|------|------------| +| `Bool` | `.bool(forKey:default:)` | +| `String` | `.string(forKey:default:)` | +| `Int` | `.int(forKey:default:)` | +| `Double` | `.double(forKey:default:)` | +| `T: Codable` | String → JSON decode | + +No `Float` support — use `Double`. Rich types require `Codable` (not just `Decodable`) to support registration. + +--- + +## 2. Variable Access + +- Always synchronous +- Never fails — fallback returned on error +- Method overloads for compile-time dispatch + +```swift +public protocol ConfigurationReading { + func value(for variable: ConfigVariable) -> Bool + func value(for variable: ConfigVariable) -> String + func value(for variable: ConfigVariable) -> Int + func value(for variable: ConfigVariable) -> Double + func value(for variable: ConfigVariable) -> T +} +``` + +Resolution dispatches to swift-config's typed accessors internally, catches errors, returns fallback. + +--- + +## 3. Telemetry + +Telemetry emitted via `EventBus` (passed at init). Errors don't propagate to callers — fallback returned, event posted. + +Example events: +- `DidAccessVariableBusEvent` — variable accessed (key, value, source, usedFallback) +- `DidAccessUnregisteredVariableBusEvent` — accessed variable not in registry +- `VariableResolutionFailedBusEvent` — error during resolution (key, error, fallback used) + +--- + +## 4. Relationship to swift-configuration + +**Uses**: `ConfigReader`, `ConfigProvider` protocol, provider precedence, built-in providers + +**Abstracts over**: Typed accessors, per-call defaults, async patterns + +**Adds**: `ConfigVariable`, generic access, guaranteed returns, error observation, registration, caching, editor UI + +--- + +## Open Questions + +- Consumer-facing update signal: How does `StructuredConfigReader` notify consumers when values may have changed? (`@Observable`, `AsyncStream`, callback, or just re-access?) +- Does `ExpressibleByConfigString` support fallthrough on init failure? (assumed yes, needs verification) + +--- + +## 5. StructuredConfigReader (Wrapper Object) + +Core wrapper that owns the swift-config `ConfigReader` and implements `ConfigurationReading`. + +```swift +public final class StructuredConfigReader: ConfigurationReading { + private let reader: ConfigReader + + public init(providers: [ConfigProvider], eventBus: EventBus) async +} +``` + +**Provider ordering**: Fixed at initialization. No `addProvider` — provider order determines precedence and should be explicit upfront for clarity. + +**Async providers**: Some providers (e.g., remote services) may not have values immediately. Pattern: + +- Providers initialize synchronously but return no values until ready +- Consumer controls lifecycle via explicit `await provider.fetch()` +- On activation: cache clears, reader emits update signal (via `@Observable` or stream) +- Multiple remote providers activate independently + +```swift +// Remote provider template +public protocol RemoteConfigProvider: ConfigProvider { + var isReady: Bool { get } + func fetch() async throws +} + +// Consumer controls lifecycle +let amplitudeProvider = AmplitudeProvider(...) +let structuredReader = await StructuredConfigReader( + providers: [amplitudeProvider, jsonFileProvider], + eventBus: eventBus +) + +// Later, when app is ready +await amplitudeProvider.fetch() // Cache clears, signal emitted +``` + +--- + +## 6. Rich Data Transformation + +For Codable types, we bridge to swift-config's `ExpressibleByConfigString` protocol via an internal wrapper. + +### Internal Bridge Type + +```swift +internal struct JSONDecodableValue: ExpressibleByConfigString { + let value: T + + init?(configString: String) { + guard let data = configString.data(using: .utf8), + let decoded = try? JSONDecoder().decode(T.self, from: data) else { + return nil + } + self.value = decoded + } +} +``` + +### Codable Access Implementation + +```swift +func value(for variable: ConfigVariable) -> T { + if let wrapped: JSONDecodableValue = reader.string( + forKey: variable.key, + as: JSONDecodableValue.self + ) { + return wrapped.value + } + return variable.fallback +} +``` + +**Benefits:** +- Consumers use `Codable` directly — no extra conformance needed +- Leverages swift-config's intended extensibility (`ExpressibleByConfigString`) +- DevConfig owns bridging logic internally + +**Limitation:** Fallthrough on transform failure depends on swift-config's behavior (unverified). If unsupported, transform failure returns fallback without trying next provider. + +--- + +## 7. Variable Registration + +Registration informs the reader of expected variables, stores fallback values as the lowest-priority provider, and enables configuration validation telemetry. + +### Registration API + +```swift +// Protocol for type-erased registration +public protocol RegistrableVariable { + var key: String { get } + var metadata: VariableMetadata { get } + func registerFallback(to provider: RegisteredFallbacksProvider) +} + +// ConfigVariable conforms when Value is registrable +extension ConfigVariable: RegistrableVariable where Value == Bool { ... } +extension ConfigVariable: RegistrableVariable where Value == String { ... } +extension ConfigVariable: RegistrableVariable where Value == Int { ... } +extension ConfigVariable: RegistrableVariable where Value == Double { ... } +extension ConfigVariable: RegistrableVariable where Value: Codable { ... } + +extension StructuredConfigReader { + // Single variable (convenience) + func register(_ variable: some RegistrableVariable) + + // Array of heterogeneous variables + func register(_ variables: [any RegistrableVariable]) +} +``` + +Usage: +```swift +structuredReader.register(Config.darkMode) +structuredReader.register([Config.darkMode, Config.timeout, Config.userSettings]) +``` + +**Note:** Rich types require `Codable` (not just `Decodable`) to support registration — fallback values must be encoded for storage in the internal provider. + +### Internal Provider + +A custom `ConfigProvider` owned by `StructuredConfigReader`, inserted at lowest precedence: + +```swift +internal final class RegisteredFallbacksProvider: ConfigProvider { + private var registeredKeys: Set = [] + private var metadata: [String: VariableMetadata] = [:] // for editor UI + private var boolValues: [String: Bool] = [:] + private var intValues: [String: Int] = [:] + private var doubleValues: [String: Double] = [:] + private var stringValues: [String: String] = [:] // includes encoded Codable + + func register(_ variable: ConfigVariable) { + registeredKeys.insert(variable.key) + metadata[variable.key] = variable.metadata + // Store in appropriate typed storage + } + + func isRegistered(_ key: String) -> Bool { + registeredKeys.contains(key) + } + + func metadata(for key: String) -> VariableMetadata? { + metadata[key] + } +} +``` + +Storage by config type: +- `Bool` → `boolValues` +- `Int` → `intValues` +- `Double` → `doubleValues` +- `String` → `stringValues` +- `T: Codable` → JSON-encoded into `stringValues` + +### Precedence + +``` +1. Provider A (e.g., remote) +2. Provider B (e.g., JSON file) +3. RegisteredFallbacksProvider ← internal, lowest priority +4. ConfigVariable.fallback ← inline, used only if all providers fail +``` + +### Registration Behavior + +- **Timing**: Not enforced. Variables can be accessed before registration; telemetry will flag this. +- **Duplicate keys**: Last registration wins; telemetry emitted for duplicate registration. +- **Distributed registration**: Subapps/modules can register their variables at app launch. + +### Telemetry + +- `DidAccessUnregisteredVariableBusEvent` — key not in `registeredKeys` +- `VariableTypeMismatchBusEvent` — decode failed using accessing type (implies registration/access type mismatch) +- `DuplicateVariableRegistrationBusEvent` — same key registered multiple times + +--- + +## 8. Variable Access Caching + +Caching avoids costly re-decoding and prevents over-emitting telemetry for variable access issues. + +### Cache Key + +```swift +struct CacheKey: Hashable, Sendable { + let variableName: String + let variableType: ObjectIdentifier + + init(_ variable: ConfigVariable) { + self.variableName = variable.key + self.variableType = ObjectIdentifier(T.self) + } +} +``` + +Different types for the same key produce different cache keys — type mismatch won't return stale cached value. + +### Cache Entry + +```swift +struct CacheEntry: Sendable { + let value: any Sendable +} +``` + +Type-erased storage; cast to expected type on access. + +### Access Pattern + +```swift +func value(for variable: ConfigVariable) -> T { + let cacheKey = CacheKey(variable) + + // Cache hit + if let entry = cache[cacheKey], + let resolved = entry.value as? T { + return resolved // No telemetry on cached access + } + + // Cache miss — resolve, emit telemetry, cache + let resolved = resolveFromProviders(variable) + cache[cacheKey] = CacheEntry(value: resolved) + emitAccessTelemetry(variable, resolved) + return resolved +} +``` + +### Cache Invalidation + +Cache clears when any provider is mutated: +- Remote provider fetch completes (`await provider.fetch()`) +- Local override via Editor UI +- Variable registration +- Any provider snapshot change (via swift-config's `watchSnapshot()`) + +### Telemetry Deduplication + +- Telemetry emitted once per key per cache lifecycle +- Cached access skips telemetry posting +- After invalidation, next access re-emits telemetry \ No newline at end of file diff --git a/Implementation Plan.md b/Implementation Plan.md new file mode 100644 index 0000000..8a75121 --- /dev/null +++ b/Implementation Plan.md @@ -0,0 +1,124 @@ +# DevConfiguration Implementation Plan + +Created by Duncan Lewis, 2026-01-02 + +--- + +## Feature Inventory + +### Sliced for Implementation +- [ ] Slice 1: ConfigVariable + ConfigurationReading + Telemetry + Standard Init +- [ ] Slice 2: Rich types (Codable) +- [ ] Slice 3: Remote provider support +- [ ] Slice 4: Access caching +- [ ] Slice 5: Registration + Metadata + RegisteredFallbacksProvider +- [ ] Slice 6: Editor UI + +### Future Features (Deferred) +- [ ] Consumer update signals (@Observable/AsyncStream) +- [ ] Configuration sets (enable/disable groups via Editor UI) + +--- + +## Implementation Slices + +### Slice 1: ConfigVariable + ConfigurationReading + Telemetry + Standard Init +**Value:** End-to-end variable access with observability + standard provider setup + +**Scope:** +- ConfigVariable struct (Bool, String, Int, Double only) +- ConfigurationReading protocol (4 method overloads) +- StructuredConfigReader (low-level init with providers + eventBus) +- Standard initializer (auto-populates: Editor UI provider, source code override provider, command line provider, registration provider) +- value(for:) implementations (dispatch to ConfigReader, catch errors, return fallback) +- DidAccessVariableBusEvent +- VariableResolutionFailedBusEvent +- Source code override provider (use swift-config's MutableInMemoryProvider) + +--- + +### Slice 2: Rich Types (Codable) +**Value:** Support complex configuration types + +**Scope:** +- JSONDecodableValue bridge type (ExpressibleByConfigString) +- ConfigVariable support +- ConfigurationReading.value(for:) overload +- VariableTypeMismatchBusEvent (decode failure telemetry) + +--- + +### Slice 3: Remote Provider Support +**Value:** Async configuration sources + +**Scope:** +- RemoteConfigProvider protocol (isReady, fetch()) +- StructuredConfigReader async init +- Provider lifecycle (fetch triggers cache clear + update signal) +- Update signal mechanism (decide: @Observable vs AsyncStream) + +--- + +### Slice 4: Access Caching +**Value:** Performance optimization, telemetry deduplication + +**Scope:** +- CacheKey (variableName + ObjectIdentifier(T.self)) +- CacheEntry (type-erased storage) +- Cache storage in StructuredConfigReader +- Cache lookup in value(for:) methods +- Cache invalidation (fetch, registration, snapshot change) + +--- + +### Slice 5: Registration + Metadata + Fallbacks +**Value:** Variable validation and extensibility + +**Scope:** +- VariableMetadataKey protocol +- VariableMetadata struct (subscript access) +- ConfigVariable metadata storage + .metadata(_:_:) builder +- ConfigVariable dynamic member lookup for metadata +- RegistrableVariable protocol +- ConfigVariable conditional conformances (Bool, String, Int, Double, Codable) +- RegisteredFallbacksProvider (internal ConfigProvider) +- StructuredConfigReader.register() methods +- DidAccessUnregisteredVariableBusEvent +- DuplicateVariableRegistrationBusEvent + +--- + +### Slice 6: Editor UI +**Value:** Runtime configuration override interface + +**Scope:** +- TBD based on architecture decisions + +--- + +## Context + +### Type System +- Primitives: Bool, String, Int, Double (no Float) +- Rich types: T: Codable (requires both Encodable + Decodable for registration) +- Type dispatch: Method overloads for compile-time resolution + +### Provider Precedence +1. User-supplied providers (in order passed to init) +2. RegisteredFallbacksProvider (internal, lowest priority) +3. ConfigVariable.fallback (inline, used if all providers fail) + +### Telemetry Behavior +- Emitted via EventBus (passed at init) +- Errors don't propagate to callers +- Access telemetry emitted once per cache lifecycle +- Cached reads skip telemetry + +### Codable Bridge Strategy +- Internal JSONDecodableValue wrapper conforms to ExpressibleByConfigString +- Consumers use Codable directly, no additional conformance +- Fallback provider stores Codable types as JSON-encoded strings + +### Open Decisions +- Update signal mechanism (Slice 3): @Observable vs AsyncStream +- ExpressibleByConfigString fallthrough on init failure (impacts Slice 2 error handling) From 245b1564ebb878d31b42d857c99ebf0528f3b067 Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 2 Jan 2026 20:45:09 -0500 Subject: [PATCH 07/28] Generate CLAUDE.md, add Scripts/format, and update README --- CLAUDE.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 10 ++++---- Scripts/format | 13 ++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Scripts/format diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..135162f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + + +## Development Commands + +### Building and Testing + + - **Build**: `swift build` + - **Test all**: `swift test` + - **Test specific target**: `swift test --filter DevConfigurationTests` + - **Test with coverage**: Use Xcode test plans in `Build Support/Test Plans/` (AllTests.xctestplan + for all tests) + +### Code Quality + + - **Lint**: `Scripts/lint` (uses `swift format lint --recursive --strict`) + - **Format**: `Scripts/format` + - **Setup git hooks**: `Scripts/install-git-hooks` (auto-formats on commit) + +### GitHub Actions + +The repository uses GitHub Actions for CI/CD with the workflow in +`.github/workflows/VerifyChanges.yaml`. The workflow: + + - Lints code on PRs using `swift format` + - Builds and tests on macOS only (other platforms disabled due to GitHub Actions stability) + - Generates code coverage reports using xccovPretty + - Requires Xcode 16.0.1 and macOS 16 runners + + +## Architecture Overview + +DevConfiguration is a type-safe configuration wrapper built on Apple's swift-configuration library. +It provides structured configuration management with telemetry, caching, and extensible metadata. + +### Key Documents + + - **Architecture Plan.md**: Complete architectural design and technical decisions + - **Implementation Plan.md**: Phased implementation roadmap broken into 6 slices + - **Documentation/TestingGuidelines.md**: Testing standards and patterns + - **Documentation/TestMocks.md**: Mock creation and usage guidelines + - **Documentation/DependencyInjection.md**: Dependency injection patterns + - **Documentation/MarkdownStyleGuide.md**: Documentation formatting standards + + +## Dependencies + +External dependencies managed via Swift Package Manager: + + - **swift-configuration** (Apple): Core configuration provider system + - **DevFoundation**: EventBus, utilities, networking + - **DevTesting**: Stub-based testing framework + + +## Development Notes + + - Follows Swift API Design Guidelines + - Uses Swift 6.2 with `ExistentialAny` and `MemberImportVisibility` features enabled + - Minimum deployment targets: iOS, macOS, tvOS, visionOS, and watchOS 26 + - All public APIs must be documented and tested + - Test coverage target: >99% + - Implementation follows phased approach in Implementation Plan.md \ No newline at end of file diff --git a/README.md b/README.md index a177220..148853c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # DevConfiguration -Description forthcoming. +DevConfiguration is a type-safe configuration wrapper built on Apple's swift-configuration library. +It provides structured configuration management with telemetry, caching, and extensible metadata. -DevConfiguration is fully documented and tested and supports iOS 18+, macOS 15+, tvOS 18+, visionOS 2+, -and watchOS 11+. +DevConfiguration is fully documented and tested and supports iOS 26+, macOS 26+, tvOS 26+, visionOS 26+, +and watchOS 26+. View our [changelog](CHANGELOG.md) to see what’s new. ## Development Requirements -DevConfiguration requires a Swift 6.1 toolchain to build. We only test on Apple platforms. We follow +DevConfiguration requires a Swift 6.2 toolchain to build. We only test on Apple platforms. We follow the [Swift API Design Guidelines][SwiftAPIDesignGuidelines]. We take pride in the fact that our public interfaces are fully documented and tested. We aim for overall test coverage over 99%. @@ -23,6 +24,7 @@ To set up the development environment: 1. Run `Scripts/install-git-hooks` to install pre-commit hooks that automatically check code formatting. 2. Use `Scripts/lint` to manually check code formatting at any time. + 3. Use `Scripts/format` to automatically format code. ## Bugs and Feature Requests diff --git a/Scripts/format b/Scripts/format new file mode 100644 index 0000000..d762974 --- /dev/null +++ b/Scripts/format @@ -0,0 +1,13 @@ +#!/bin/bash + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Go to the repository root (one level up from Scripts) +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +# Run swift format with --in-place to fix formatting issues +swift format --in-place --recursive \ + "$REPO_ROOT/Packages/" \ + "$REPO_ROOT/Sources/" \ + "$REPO_ROOT/Tests/" From 229b621625af9b4d430f95fbd7e7f85ce4ff934d Mon Sep 17 00:00:00 2001 From: dfowj Date: Sat, 3 Jan 2026 15:26:51 -0500 Subject: [PATCH 08/28] Reorganize Architecture/Implementation Plan --- Architecture Plan.md => Plans/Architecture Plan.md | 0 Implementation Plan.md => Plans/Implementation Plan.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Architecture Plan.md => Plans/Architecture Plan.md (100%) rename Implementation Plan.md => Plans/Implementation Plan.md (100%) diff --git a/Architecture Plan.md b/Plans/Architecture Plan.md similarity index 100% rename from Architecture Plan.md rename to Plans/Architecture Plan.md diff --git a/Implementation Plan.md b/Plans/Implementation Plan.md similarity index 100% rename from Implementation Plan.md rename to Plans/Implementation Plan.md From 496572f591f81cc757945e90f63b517f27bbccb8 Mon Sep 17 00:00:00 2001 From: dfowj Date: Sat, 3 Jan 2026 15:27:14 -0500 Subject: [PATCH 09/28] Introduce swift-configuration dependency --- Package.resolved | 38 +++++++++++++++++++++++++++++++++++++- Package.swift | 1 + 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 9250bc7..d2c9367 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "97c9db3dea570820c16c6a119d295cf8c594561ef39006d90705aabc179b50e6", + "originHash" : "77ad12a74a8d296251809be2f40a314368fba06ab3cf5d6d49301db109f61b97", "pins" : [ { "identity" : "devfoundation", @@ -45,6 +45,42 @@ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", "version" : "1.3.0" } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration", + "state" : { + "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index eadfd1d..67dc9cb 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,7 @@ let package = Package( ), ], dependencies: [ + .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), .package(url: "https://github.com/DevKitOrganization/DevFoundation.git", from: "1.7.0"), .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"), ], From c6ee212a9f46cab9e5c2fd013e79d7f4eb957076 Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 6 Jan 2026 10:16:04 -0500 Subject: [PATCH 10/28] Revisions to Architecture/Implementation plan. --- Plans/Architecture Plan.md | 219 +++++++---- Plans/Implementation Plan.md | 70 ++-- Plans/Slice 1 - Detailed Plan.md | 607 +++++++++++++++++++++++++++++++ 3 files changed, 801 insertions(+), 95 deletions(-) create mode 100644 Plans/Slice 1 - Detailed Plan.md diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md index 7f59e7c..6dcace4 100644 --- a/Plans/Architecture Plan.md +++ b/Plans/Architecture Plan.md @@ -6,20 +6,20 @@ Typesafe configuration wrapper on Apple's swift-configuration. ## 1. Variable Definitions -Variables defined anywhere by consumers; encouraged pattern is static properties on a shared type: +Variables defined anywhere by consumers; encouraged pattern is static properties on the `ConfigVariable` type: ```swift -enum Config { +extension ConfigVariable where Value == Bool { static let darkMode = ConfigVariable( key: "feature.darkMode", fallback: false ) } -// Access: config.value(for: Config.darkMode) +// Access: config.value(for: .darkMode) ``` -**Key format**: Plain `String`, passed directly to swift-config. Key transformation is provider-specific: +**Key format**: `ConfigKey` (from swift-configuration). Consumers can use string convenience initializer or construct ConfigKey explicitly. Key transformation is provider-specific: - JSON/YAML: `feature.darkMode` → nested lookup `{ "feature": { "darkMode": ... } }` - Environment: `feature.darkMode` → `FEATURE_DARKMODE` - Custom providers: define their own transformation @@ -29,13 +29,19 @@ enum Config { ```swift @dynamicMemberLookup public struct ConfigVariable { - public let key: String + public let key: ConfigKey // From swift-configuration public let fallback: Value private var metadata: VariableMetadata - + + // Convenience: string → ConfigKey + public init(key: String, fallback: Value) + + // Direct: explicit ConfigKey + public init(key: ConfigKey, fallback: Value) + /// Builder-style metadata setter public func metadata(_ keyPath: WritableKeyPath, _ value: M) -> Self - + /// Dynamic member access to metadata values public subscript(dynamicMember keyPath: WritableKeyPath) -> M } @@ -88,37 +94,50 @@ let flag = ConfigVariable(key: "feature.x", fallback: false) let expires = flag.expirationDate ``` -### Supported Value Types - -| Type | Resolution | -|------|------------| -| `Bool` | `.bool(forKey:default:)` | -| `String` | `.string(forKey:default:)` | -| `Int` | `.int(forKey:default:)` | -| `Double` | `.double(forKey:default:)` | -| `T: Codable` | String → JSON decode | - -No `Float` support — use `Double`. Rich types require `Codable` (not just `Decodable`) to support registration. - --- ## 2. Variable Access -- Always synchronous +- Always synchronous (async support for remote providers) - Never fails — fallback returned on error - Method overloads for compile-time dispatch ```swift -public protocol ConfigurationReading { +public protocol StructuredConfigurationReading { + // Primitives func value(for variable: ConfigVariable) -> Bool func value(for variable: ConfigVariable) -> String func value(for variable: ConfigVariable) -> Int func value(for variable: ConfigVariable) -> Double + + // Arrays + func value(for variable: ConfigVariable<[Bool]>) -> [Bool] + func value(for variable: ConfigVariable<[String]>) -> [String] + func value(for variable: ConfigVariable<[Int]>) -> [Int] + func value(for variable: ConfigVariable<[Double]>) -> [Double] + + // Rich types func value(for variable: ConfigVariable) -> T } ``` -Resolution dispatches to swift-config's typed accessors internally, catches errors, returns fallback. +Resolution dispatches to swift-configuration's typed accessors (`requiredBool()`, `requiredStringArray()`, etc.), catches errors, returns fallback. + +### Supported Value Types + +| Type | Resolution | +|------|------------| +| `Bool` | `requiredBool(forKey:)` | +| `String` | `requiredString(forKey:)` | +| `Int` | `requiredInt(forKey:)` | +| `Double` | `requiredDouble(forKey:)` | +| `[Bool]` | `requiredBoolArray(forKey:)` | +| `[String]` | `requiredStringArray(forKey:)` | +| `[Int]` | `requiredIntArray(forKey:)` | +| `[Double]` | `requiredDoubleArray(forKey:)` | +| `T: Codable` | String → JSON decode | + +No `Float` support — use `Double`. Rich types require `Codable` (not just `Decodable`) to support registration. --- @@ -150,18 +169,74 @@ Example events: --- -## 5. StructuredConfigReader (Wrapper Object) +## 5. Composed Reader Architecture + +**Design Decision:** Split into two types for separation of concerns. + +### StructuredConfigReader (Core Type) -Core wrapper that owns the swift-config `ConfigReader` and implements `ConfigurationReading`. +Core typed accessor that bridges `ConfigVariable` to swift-configuration's `ConfigReader`. ```swift -public final class StructuredConfigReader: ConfigurationReading { +public final class StructuredConfigReader: StructuredConfigurationReading { private let reader: ConfigReader - - public init(providers: [ConfigProvider], eventBus: EventBus) async + private let eventBus: EventBus + private let accessReporter: TelemetryAccessReporter + + public init(providers: [ConfigProvider], eventBus: EventBus, accessReporter: TelemetryAccessReporter) + + // Protocol conformance: 8 overloads (4 primitives + 4 arrays) + public func value(for variable: ConfigVariable) -> Bool + public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] + // ... etc } ``` +**Responsibilities:** +- Value resolution with required accessors (`requiredBool()`, `requiredStringArray()`, etc.) +- Error handling (catch all, return fallback) +- Telemetry emission via AccessReporter integration +- Caching + +**Does NOT Handle:** +- Provider stack management +- Default provider configuration +- Source code override API + other conveniences + +### ConfigurationDataSource (Convenience Type) + +High-level convenience layer with standard provider management. + +```swift +public final class ConfigurationDataSource: StructuredConfigurationReading { + private let reader: StructuredConfigReader + private let sourceOverrideProvider: MutableInMemoryProvider + + /// Standard init: auto-configured providers + public init(eventBus: EventBus) + + /// Low-level init: custom providers + public init(providers: [ConfigProvider], eventBus: EventBus) + + // Protocol delegation to StructuredConfigReader (8 overloads) + public func value(for variable: ConfigVariable) -> Bool + // ... etc +} +``` + +**Responsibilities:** +- Standard provider stack management (overrides → CLI → environment) +- Source code override API +- Provider lifecycle management +- Protocol delegation to StructuredConfigReader + +**Standard Provider Stack:** +1. Command Line Arguments (`CommandLineArgumentsProvider`) +2. Environment Variables (`EnvironmentVariablesProvider`) +3. Source Code Overrides (`MutableInMemoryProvider`) +4. Remote/Async Providers (custom type) +5. Registered Fallbacks + **Provider ordering**: Fixed at initialization. No `addProvider` — provider order determines precedence and should be explicit upfront for clarity. **Async providers**: Some providers (e.g., remote services) may not have values immediately. Pattern: @@ -180,7 +255,7 @@ public protocol RemoteConfigProvider: ConfigProvider { // Consumer controls lifecycle let amplitudeProvider = AmplitudeProvider(...) -let structuredReader = await StructuredConfigReader( +let dataSource = await ConfigurationVariableDataSource( providers: [amplitudeProvider, jsonFileProvider], eventBus: eventBus ) @@ -241,33 +316,23 @@ Registration informs the reader of expected variables, stores fallback values as ### Registration API ```swift -// Protocol for type-erased registration -public protocol RegistrableVariable { - var key: String { get } - var metadata: VariableMetadata { get } - func registerFallback(to provider: RegisteredFallbacksProvider) -} - -// ConfigVariable conforms when Value is registrable -extension ConfigVariable: RegistrableVariable where Value == Bool { ... } -extension ConfigVariable: RegistrableVariable where Value == String { ... } -extension ConfigVariable: RegistrableVariable where Value == Int { ... } -extension ConfigVariable: RegistrableVariable where Value == Double { ... } -extension ConfigVariable: RegistrableVariable where Value: Codable { ... } - extension StructuredConfigReader { - // Single variable (convenience) - func register(_ variable: some RegistrableVariable) - - // Array of heterogeneous variables - func register(_ variables: [any RegistrableVariable]) + func register(_ variable: ConfigVariable) { … } + func register(_ variable: ConfigVariable) { … } + func register(_ variable: ConfigVariable) { … } + func register(_ variable: ConfigVariable) { … } + func register(_ variable: ConfigVariable<[Bool]>) { … } + func register(_ variable: ConfigVariable<[String]>) { … } + func register(_ variable: ConfigVariable<[Int]>) { … } + func register(_ variable: ConfigVariable<[Double]>) { … } + func register(_ variable: ConfigVariable) where Value: Codable { … } } ``` Usage: ```swift -structuredReader.register(Config.darkMode) -structuredReader.register([Config.darkMode, Config.timeout, Config.userSettings]) +structuredReader.register(.darkMode) +structuredReader.register(.timeout) ``` **Note:** Rich types require `Codable` (not just `Decodable`) to support registration — fallback values must be encoded for storage in the internal provider. @@ -277,43 +342,51 @@ structuredReader.register([Config.darkMode, Config.timeout, Config.userSettings] A custom `ConfigProvider` owned by `StructuredConfigReader`, inserted at lowest precedence: ```swift -internal final class RegisteredFallbacksProvider: ConfigProvider { +internal final class RegisteredVariablesProvider: ConfigProvider { + private let provider: MutableInMemoryProvider private var registeredKeys: Set = [] private var metadata: [String: VariableMetadata] = [:] // for editor UI - private var boolValues: [String: Bool] = [:] - private var intValues: [String: Int] = [:] - private var doubleValues: [String: Double] = [:] - private var stringValues: [String: String] = [:] // includes encoded Codable - + + init() { + self.provider = MutableInMemoryProvider( + name: "registered-variables", + initialValues: [:] + ) + } + func register(_ variable: ConfigVariable) { - registeredKeys.insert(variable.key) - metadata[variable.key] = variable.metadata - // Store in appropriate typed storage + // Track registration + registeredKeys.insert(variable.key.description) + metadata[variable.key.description] = variable.metadata + + // Store value in composed provider + // (Implementation delegates to MutableInMemoryProvider's storage) } - - func isRegistered(_ key: String) -> Bool { - registeredKeys.contains(key) + + func isRegistered(_ key: ConfigKey) -> Bool { + registeredKeys.contains(key.description) } - - func metadata(for key: String) -> VariableMetadata? { - metadata[key] + + func metadata(for key: ConfigKey) -> VariableMetadata? { + metadata[key.description] } + + // ConfigProvider conformance delegates to composed provider + // (snapshot, value lookup, etc.) } ``` -Storage by config type: -- `Bool` → `boolValues` -- `Int` → `intValues` -- `Double` → `doubleValues` -- `String` → `stringValues` -- `T: Codable` → JSON-encoded into `stringValues` +**Design Benefits:** +- Composes `MutableInMemoryProvider` instead of reimplementing storage +- Registration tracking (keys + metadata) stays separate from value storage +- Leverages swift-configuration's existing provider implementation ### Precedence ``` 1. Provider A (e.g., remote) 2. Provider B (e.g., JSON file) -3. RegisteredFallbacksProvider ← internal, lowest priority +3. RegisteredVariablesProvider ← internal, lowest priority 4. ConfigVariable.fallback ← inline, used only if all providers fail ``` @@ -341,9 +414,9 @@ Caching avoids costly re-decoding and prevents over-emitting telemetry for varia struct CacheKey: Hashable, Sendable { let variableName: String let variableType: ObjectIdentifier - + init(_ variable: ConfigVariable) { - self.variableName = variable.key + self.variableName = variable.key.description self.variableType = ObjectIdentifier(T.self) } } diff --git a/Plans/Implementation Plan.md b/Plans/Implementation Plan.md index 8a75121..8364e02 100644 --- a/Plans/Implementation Plan.md +++ b/Plans/Implementation Plan.md @@ -7,11 +7,11 @@ Created by Duncan Lewis, 2026-01-02 ## Feature Inventory ### Sliced for Implementation -- [ ] Slice 1: ConfigVariable + ConfigurationReading + Telemetry + Standard Init +- [ ] Slice 1: ConfigVariable + StructuredConfigReader + ConfigurationDataSource + Telemetry - [ ] Slice 2: Rich types (Codable) - [ ] Slice 3: Remote provider support - [ ] Slice 4: Access caching -- [ ] Slice 5: Registration + Metadata + RegisteredFallbacksProvider +- [ ] Slice 5: Registration + Metadata + RegisteredVariablesProvider - [ ] Slice 6: Editor UI ### Future Features (Deferred) @@ -22,18 +22,29 @@ Created by Duncan Lewis, 2026-01-02 ## Implementation Slices -### Slice 1: ConfigVariable + ConfigurationReading + Telemetry + Standard Init +### Slice 1: ConfigVariable + StructuredConfigReader + ConfigurationDataSource + Telemetry **Value:** End-to-end variable access with observability + standard provider setup +**Composed Reader Architecture:** +- **StructuredConfigReader**: Core typed accessor with telemetry (low-level) +- **ConfigurationDataSource**: High-level convenience with standard provider management + **Scope:** -- ConfigVariable struct (Bool, String, Int, Double only) -- ConfigurationReading protocol (4 method overloads) -- StructuredConfigReader (low-level init with providers + eventBus) -- Standard initializer (auto-populates: Editor UI provider, source code override provider, command line provider, registration provider) -- value(for:) implementations (dispatch to ConfigReader, catch errors, return fallback) -- DidAccessVariableBusEvent -- VariableResolutionFailedBusEvent -- Source code override provider (use swift-config's MutableInMemoryProvider) +- ConfigVariable struct with ConfigKey storage (primitives + arrays: Bool, String, Int, Double, [Bool], [String], [Int], [Double]) +- StructuredConfigurationReading protocol (8 method overloads: 4 primitives + 4 arrays) +- StructuredConfigReader (core type): + - Low-level init with providers array + eventBus + - TelemetryAccessReporter integration (AccessReporter protocol) + - value(for:) implementations using required accessors (requiredBool(), requiredStringArray(), etc.) + - Error handling: catch errors, return fallback +- ConfigurationDataSource (convenience type): + - Standard init (auto-configures: source overrides → CLI → environment) + - Low-level init (custom providers) + - Protocol delegation to StructuredConfigReader + - Explicit source override provider creation (not by array index) +- Telemetry events using ConfigContent (from swift-configuration): + - DidAccessVariableBusEvent (via AccessReporter) + - VariableResolutionFailedBusEvent (on error) --- @@ -43,7 +54,7 @@ Created by Duncan Lewis, 2026-01-02 **Scope:** - JSONDecodableValue bridge type (ExpressibleByConfigString) - ConfigVariable support -- ConfigurationReading.value(for:) overload +- StructuredConfigurationReading.value(for:) overload - VariableTypeMismatchBusEvent (decode failure telemetry) --- @@ -79,10 +90,8 @@ Created by Duncan Lewis, 2026-01-02 - VariableMetadata struct (subscript access) - ConfigVariable metadata storage + .metadata(_:_:) builder - ConfigVariable dynamic member lookup for metadata -- RegistrableVariable protocol -- ConfigVariable conditional conformances (Bool, String, Int, Double, Codable) -- RegisteredFallbacksProvider (internal ConfigProvider) -- StructuredConfigReader.register() methods +- RegisteredVariablesProvider (internal ConfigProvider composing MutableInMemoryProvider) +- StructuredConfigReader.register() method overloads (9 total: 8 concrete + 1 generic Codable) - DidAccessUnregisteredVariableBusEvent - DuplicateVariableRegistrationBusEvent @@ -98,21 +107,38 @@ Created by Duncan Lewis, 2026-01-02 ## Context +### Composed Reader Architecture +- **StructuredConfigReader**: Core typed accessor + - Low-level init with explicit provider array + - Integrates with swift-configuration's AccessReporter for telemetry + - Implements StructuredConfigurationReading protocol +- **ConfigurationDataSource**: High-level convenience wrapper + - Composes StructuredConfigReader + - Standard init with auto-configured providers + - Delegates all value access to StructuredConfigReader + ### Type System - Primitives: Bool, String, Int, Double (no Float) +- Arrays: [Bool], [String], [Int], [Double] - Rich types: T: Codable (requires both Encodable + Decodable for registration) - Type dispatch: Method overloads for compile-time resolution +- ConfigKey storage: ConfigVariable stores ConfigKey (not String) with two initializers -### Provider Precedence -1. User-supplied providers (in order passed to init) -2. RegisteredFallbacksProvider (internal, lowest priority) -3. ConfigVariable.fallback (inline, used if all providers fail) +### Provider Precedence (Standard Stack) +1. Source Code Overrides (MutableInMemoryProvider) +2. Command Line Arguments (CommandLineArgumentsProvider) +3. Environment Variables (EnvironmentVariablesProvider) +4. RegisteredVariablesProvider (internal, lowest priority - Slice 5) +5. ConfigVariable.fallback (inline, used if all providers fail) ### Telemetry Behavior - Emitted via EventBus (passed at init) +- Success: Posted automatically via TelemetryAccessReporter (AccessReporter integration) +- Failure: Posted directly from catch blocks +- Uses ConfigContent from swift-configuration (not custom enum) - Errors don't propagate to callers -- Access telemetry emitted once per cache lifecycle -- Cached reads skip telemetry +- Access telemetry emitted once per cache lifecycle (Slice 4) +- Cached reads skip telemetry (Slice 4) ### Codable Bridge Strategy - Internal JSONDecodableValue wrapper conforms to ExpressibleByConfigString diff --git a/Plans/Slice 1 - Detailed Plan.md b/Plans/Slice 1 - Detailed Plan.md new file mode 100644 index 0000000..522d90d --- /dev/null +++ b/Plans/Slice 1 - Detailed Plan.md @@ -0,0 +1,607 @@ +# Slice 1: Detailed Implementation Plan + +Created by Duncan Lewis, 2026-01-03 +**Last Updated:** 2026-01-03 (Decisions Finalized) + +**Parent Document:** [Implementation Plan.md](./Implementation%20Plan.md) + +--- + +## Overview + +**Slice 1 Scope:** ConfigVariable + StructuredConfigurationReading + Telemetry + Standard Init + +**Value Delivered:** End-to-end variable access with observability and standard provider setup + +**Supported Types:** +- **Primitives:** `Bool`, `String`, `Int`, `Double` +- **Arrays:** `[Bool]`, `[String]`, `[Int]`, `[Double]` + +--- + +## Architecture + +**Two-Type Design:** +1. **StructuredConfigReader**: Core type for typed value access and telemetry +2. **ConfigurationVariableDataSource**: High-level convenience with default provider management + +**Division of Responsibilities:** + +| Concern | StructuredConfigReader | ConfigurationVariableDataSource | +|---------|------------------------|----------------------------------| +| Value resolution | ✅ Implements | ❌ Delegates | +| Telemetry | ✅ Emits events | ❌ Transparent | +| Caching | ✅ Manages cache (Slice 4) | ❌ Transparent | +| Provider stack | ❌ Accepts array | ✅ Configures defaults | +| Override API | ❌ Not exposed | ✅ Public methods (Slice 6) | +| swift-config integration | ✅ Direct usage | ❌ Via StructuredConfigReader | +| Standard init | ❌ No defaults | ✅ Auto-configures | + +See [Architecture Plan.md](./Architecture%20Plan.md) section 5 for architectural overview. + +--- + +## Key Design Decisions (Finalized) + +### 1. ConfigKey Storage +✅ ConfigVariable stores `ConfigKey` directly (not String) +✅ Two initializers: `init(key: String, fallback:)` and `init(key: ConfigKey, fallback:)` +✅ Consumer controls key parsing strategy + +### 2. Array Support +✅ Add array accessors for all primitive types +✅ Protocol includes 8 overloads total (4 primitives + 4 arrays) +✅ Maps to swift-configuration's array accessors + +### 3. Required Accessors + AccessReporter +✅ Use throwing `requiredBool()`, `requiredString()`, etc. +✅ AccessReporter posts `DidAccessVariableBusEvent` **directly** +✅ No need to track "last accessed provider" - AccessEvent has all info +✅ Errors captured and posted as `VariableResolutionFailedBusEvent` + +### 4. Provider Stack +✅ Source overrides created **explicitly**, not by array index +✅ CLI provider enabled via `CommandLineArgumentsSupport` trait +✅ Precedence: Source Overrides → CLI → Environment → Registration (Slice 5) + +### 5. Deferred to Later Slices +- `isSecret` parameter → Slice 5 (metadata) +- JSON file providers → Future (as "variable overlays") +- Caching → Slice 4 + +--- + +## Component Breakdown + +### 1. ConfigVariable + +**Purpose:** Type-safe variable definition with fallback value + +**Public Interface:** +```swift +public struct ConfigVariable { + public let key: ConfigKey + public let fallback: Value + + // Convenience: string → ConfigKey + public init(key: String, fallback: Value) + + // Direct: explicit ConfigKey + public init(key: ConfigKey, fallback: Value) +} +``` + +**Supported Types in Slice 1:** +- Primitives: `Bool`, `String`, `Int`, `Double` +- Arrays: `[Bool]`, `[String]`, `[Int]`, `[Double]` + +**Example Usage:** +```swift +enum AppConfig { + static let darkMode = ConfigVariable(key: "feature.darkMode", fallback: false) + static let tags = ConfigVariable(key: "feature.tags", fallback: ["default"]) + static let timeout = ConfigVariable(key: ConfigKey("network.timeout"), fallback: 30.0) +} + +// Access +let darkMode = dataSource.value(for: AppConfig.darkMode) +let tags = dataSource.value(for: AppConfig.tags) +``` + +--- + +### 2. StructuredConfigurationReading Protocol + +**Purpose:** Define contract for typed configuration access + +**Public Interface:** +```swift +public protocol StructuredConfigurationReading { + // Primitives (4 overloads) + func value(for variable: ConfigVariable) -> Bool + func value(for variable: ConfigVariable) -> String + func value(for variable: ConfigVariable) -> Int + func value(for variable: ConfigVariable) -> Double + + // Arrays (4 overloads) + func value(for variable: ConfigVariable<[Bool]>) -> [Bool] + func value(for variable: ConfigVariable<[String]>) -> [String] + func value(for variable: ConfigVariable<[Int]>) -> [Int] + func value(for variable: ConfigVariable<[Double]>) -> [Double] +} +``` + +**Key Design Decisions:** +- 8 method overloads total (4 primitives + 4 arrays) +- Compile-time dispatch, no generic constraints +- Always returns non-optional (fallback on error) +- No `throws` - errors captured in telemetry +- Synchronous only (async in Slice 3) + +--- + +### 3. TelemetryAccessReporter (Internal) + +**Purpose:** Bridge swift-configuration access reporting to EventBus telemetry + +**Implementation:** +```swift +internal final class TelemetryAccessReporter: AccessReporter, Sendable { + private let eventBus: EventBus + + init(eventBus: EventBus) { + self.eventBus = eventBus + } + + func reportAccess(_ event: AccessEvent) { + // Extract ConfigContent from AccessEvent result + guard case .success(let configValue) = event.result, + let configValue = configValue else { + return // Don't post event for failed access (TODO: check whether we can get the necessary failure info here) + } + + eventBus.post(DidAccessVariableBusEvent( + key: event.metadata.key.description, + value: configValue.content, // ConfigContent from ConfigValue + source: event.providerResults.first?.providerName ?? "unknown", + usedFallback: false // Successful access from provider + )) + } +} +``` + +**Key Design Decisions:** +- **Posts events directly** - no "last source" tracking needed +- Extracts `ConfigContent` from `AccessEvent.result.success.content` +- Uses swift-configuration's `ConfigContent` type directly +- Sendable for thread-safety +- Owned by StructuredConfigReader +- Converts `AccessEvent` to `DidAccessVariableBusEvent` + +--- + +### 4. StructuredConfigReader (Core Type) + +**Purpose:** Core typed configuration accessor with telemetry + +**Public Interface:** +```swift +public final class StructuredConfigReader: StructuredConfigurationReading { + public init(providers: [any ConfigProvider], eventBus: EventBus) + + // Protocol conformance (8 overloads) + public func value(for variable: ConfigVariable) -> Bool + public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] + // ... etc for all 8 types +} +``` + +**Internal Implementation:** +```swift +public final class StructuredConfigReader: StructuredConfigurationReading { + private let reader: ConfigReader + private let eventBus: EventBus + private let accessReporter: TelemetryAccessReporter + + public init(providers: [any ConfigProvider], eventBus: EventBus) { + self.eventBus = eventBus + self.accessReporter = TelemetryAccessReporter(eventBus: eventBus) + self.reader = ConfigReader( + providers: providers, + accessReporter: accessReporter // Install reporter + ) + } + + // Primitive example + public func value(for variable: ConfigVariable) -> Bool { + do { + // Required accessor throws if not found or type mismatch + let resolved = try reader.requiredBool(forKey: variable.key) + // AccessReporter already posted DidAccessVariableBusEvent + return resolved + } catch { + // Error: post failure event + eventBus.post(VariableResolutionFailedBusEvent( + key: variable.key.description, + error: error, + fallback: .bool(variable.fallback) + )) + return variable.fallback + } + } + + // Array example + public func value(for variable: ConfigVariable<[String]>) -> [String] { + do { + let resolved = try reader.requiredStringArray(forKey: variable.key) + // AccessReporter already posted event + return resolved + } catch { + eventBus.post(VariableResolutionFailedBusEvent( + key: variable.key.description, + error: error, + fallback: .stringArray(variable.fallback) + )) + return variable.fallback + } + } + + // ... 6 more overloads +} +``` + +**Key Design Decisions:** +- Use `requiredBool()`, `requiredStringArray()`, etc. (throwing accessors) +- AccessReporter posts success telemetry **automatically** +- Only post failure telemetry in catch block +- Fallback always returned on error +- No manual source tracking - AccessEvent has provider name + +--- + +### 5. ConfigurationVariableDataSource (Convenience Type) + +**Purpose:** High-level convenience with standard provider management + +**Public Interface:** +```swift +public final class ConfigurationVariableDataSource: StructuredConfigurationReading { + /// Standard init: auto-configured providers + public init(eventBus: EventBus) + + /// Low-level init: custom providers + public init(providers: [any ConfigProvider], eventBus: EventBus) + + // Protocol conformance: delegates to StructuredConfigReader (8 overloads) + public func value(for variable: ConfigVariable) -> Bool + public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] + // ... etc + + // Source override API (Slice 6 - deferred) + // public func setOverride(_ value: T, for variable: ConfigVariable) + // public func clearOverride(for variable: ConfigVariable) +} +``` + +**Standard Init Implementation:** +```swift +public init(eventBus: EventBus) { + // Create source override provider EXPLICITLY (not by array index) + let sourceOverrideProvider = MutableInMemoryProvider( + name: "source-overrides", + initialValues: [:] + ) + + // Build provider array + let providers: [any ConfigProvider] = [ + sourceOverrideProvider, // Highest precedence + CommandLineArgumentsProvider(), + EnvironmentVariablesProvider(), + // RegisteredVariablesProvider() - Slice 5 + ] + + // Create core reader + self.reader = StructuredConfigReader( + providers: providers, + eventBus: eventBus + ) + + // Store provider reference for Editor UI (Slice 6) + self.sourceOverrideProvider = sourceOverrideProvider +} + +// Protocol delegation +public func value(for variable: ConfigVariable) -> Bool { + reader.value(for: variable) +} +// ... etc for all 8 overloads +``` + +**Key Design Decisions:** +- Source override provider created **explicitly** before array +- Stored directly (not via array index) +- Delegates all `value(for:)` calls to StructuredConfigReader +- Both classes (not structs) for mutable state +- Low-level init available for advanced use cases + +--- + +### 6. Telemetry Events + +**DidAccessVariableBusEvent:** +```swift +public struct DidAccessVariableBusEvent: BusEvent { + public let key: String + public let value: ConfigContent // From swift-configuration + public let source: String // Provider name from AccessEvent + public let usedFallback: Bool + + public init(key: String, value: ConfigContent, source: String, usedFallback: Bool) +} +``` + +**VariableResolutionFailedBusEvent:** +```swift +public struct VariableResolutionFailedBusEvent: BusEvent { + public let key: String + public let error: any Error // Sendable in Swift 6 + public let fallback: ConfigContent // From swift-configuration + + public init(key: String, error: any Error, fallback: ConfigContent) +} +``` + +**Key Design Decisions:** +- Uses `ConfigContent` from swift-configuration (not custom enum) +- `ConfigContent` has all needed cases: bool, string, int, double, plus array variants +- Posted via AccessReporter for successful accesses +- Posted directly for failures +- `any Error` is Sendable in Swift 6 (verified) + +--- + +## swift-configuration Integration + +### Typed Accessors Used + +**Primitives (throwing):** +- `requiredBool(forKey:) throws -> Bool` +- `requiredString(forKey:) throws -> String` +- `requiredInt(forKey:) throws -> Int` +- `requiredDouble(forKey:) throws -> Double` + +**Arrays (throwing):** +- `requiredBoolArray(forKey:) throws -> [Bool]` +- `requiredStringArray(forKey:) throws -> [String]` +- `requiredIntArray(forKey:) throws -> [Int]` +- `requiredDoubleArray(forKey:) throws -> [Double]` + +### AccessReporter Protocol +```swift +public protocol AccessReporter { + func reportAccess(_ event: AccessEvent) +} + +public struct AccessEvent { + public let key: AbsoluteConfigKey + public let value: ConfigValue? + public let providerName: String + // ... other fields +} +``` + +**Key Benefits:** +- AccessEvent contains provider name - no manual tracking needed +- Thrown errors contain full context (key, type, provider info) +- AccessReporter integrates seamlessly with ConfigReader + +--- + +## Standard Provider Stack + +**ConfigurationVariableDataSource Standard Init:** + +**Precedence (High → Low):** +1. **Source Code Overrides** - `MutableInMemoryProvider` + - Name: "source-overrides" + - Empty initial values + - Created explicitly, stored for Editor UI (Slice 6) + +2. **Command Line Arguments** - `CommandLineArgumentsProvider` + - Requires `CommandLineArgumentsSupport` trait + - Pattern: `--feature.darkMode=true`, `--tags swift config` + +3. **Environment Variables** - `EnvironmentVariablesProvider` + - Key transformation: `feature.darkMode` → `FEATURE_DARKMODE` + +4. **Registered Fallbacks** - (Slice 5) + - `RegisteredVariablesProvider` (custom, composes MutableInMemoryProvider) + - Lowest precedence, above inline fallback + +**Not Included:** +- JSON file providers (use low-level init) +- Remote providers (Slice 3) + +**Rationale:** +- Covers 90% use case: local development + testing +- Production configs (JSON, remote) require explicit setup + +--- + +## Package.swift Configuration + +### Enable CommandLineArgumentsSupport Trait + +```swift +.target( + name: "DevConfiguration", + dependencies: [ + .product(name: "Configuration", package: "swift-configuration"), + .product(name: "DevFoundation", package: "DevFoundation"), + ], + swiftSettings: [ + .define("CommandLineArguments") + ] +) +``` + +--- + +## Implementation Sequence + +**Recommended Order:** +1. **ConfigVariable** - struct with two initializers +2. **StructuredConfigurationReading** - protocol (8 overloads) +3. **StructuredConfigReader** - implement with TODOs: + - Constructor with AccessReporter integration (TODO: TelemetryAccessReporter) + - Implement `value(for:)` for Bool (TODO: event types) + - Implement `value(for:)` for [Bool] (verify array pattern) + - Complete remaining 6 overloads +4. **Fill in data types for StructuredConfigReader:** + - `TelemetryAccessReporter` - AccessReporter implementation + - `DidAccessVariableBusEvent` - struct using ConfigContent + - `VariableResolutionFailedBusEvent` - struct with `any Error` +5. **ConfigurationVariableDataSource** - implement with TODOs (if needed): + - Standard init with explicit provider creation + - Protocol delegation (8 overloads) +6. **Fill in remaining data types** (if any) +7. **Enable `CommandLineArgumentsSupport`** in Package.swift +8. **End-to-end verification** + +**Rationale:** +- Implement main types first with TODOs to define interfaces +- Fill in supporting types as needed to resolve TODOs +- This allows incremental progress and clearer interface design +- Verify primitive and array patterns early (step 3) + +--- + +## Testing Strategy + +### Unit Test Coverage + +**ConfigVariable:** +- Two initializers (String and ConfigKey) +- Property access + +**TelemetryAccessReporter:** +- Event posting from AccessEvent +- EventBus integration +- Conversion from AccessEvent to DidAccessVariableBusEvent + +**StructuredConfigReader:** +- All 8 overloads (4 primitives + 4 arrays) +- Required accessor error handling +- Fallback on missing values +- Fallback on type mismatch +- Fallback on provider errors +- Telemetry emission (success via AccessReporter + failure direct) + +**ConfigurationVariableDataSource:** +- Standard init provider stack +- Provider precedence +- Explicit provider creation (not array index) +- Protocol delegation (all 8 overloads) + +**Integration Tests:** +- End-to-end value resolution +- Provider precedence verification +- CLI argument parsing +- Environment variable transformation +- Telemetry event flow (both success and failure) + +### Test Patterns +- Use `InMemoryProvider` for deterministic tests +- Mock EventBus to verify telemetry +- Use DevTesting stub framework +- See `Documentation/TestingGuidelines.md` + +--- + +## Success Criteria + +**Slice 1 is complete when:** +- [ ] All types compile without errors +- [ ] ConfigVariable supports both initializers (String and ConfigKey) +- [ ] StructuredConfigurationReading has 8 overloads (4 + 4) +- [ ] TelemetryAccessReporter posts events from AccessEvent +- [ ] Value resolution uses required accessors (throwing) +- [ ] AccessReporter handles success telemetry automatically +- [ ] Error telemetry includes full context +- [ ] Standard provider stack: overrides → CLI → env +- [ ] Source override provider created explicitly (not by index) +- [ ] `CommandLineArgumentsSupport` trait enabled +- [ ] All 8 type overloads work (primitives + arrays) +- [ ] Provider precedence respected +- [ ] Unit tests achieve >99% coverage +- [ ] Linting passes (`Scripts/lint`) +- [ ] All integration tests pass + +--- + +## Future Features (Deferred) + +### Variable Overlays (Post-Slice 1) +- JSON/YAML file-based configuration +- Environment-specific configs (dev, staging, prod) +- Too app-specific for standard stack +- Use low-level init for custom file providers + +### Caching (Slice 4) +- Cache resolved values by (key, type) +- Cache invalidation on provider updates +- Performance optimization + telemetry deduplication + +### Metadata (Slice 5) +- `isSecret` parameter +- Extensible metadata system +- Registration support + +--- + +## DevFoundation Consistency Patterns + +**EventBus Usage:** +- Pass `EventBus` at initialization (dependency injection) +- Post events via `eventBus.post(_:)` +- Event types conform to `BusEvent` (Sendable) + +**Error Handling:** +- Never propagate errors to API consumers +- Emit telemetry for errors instead +- Return sensible defaults (fallback values) + +**Naming Conventions:** +- Types: `` (e.g., `StructuredConfigReader`) +- Events: `DidBusEvent` +- Properties: camelCase, descriptive + +**Dependency Injection:** +- Constructor injection for dependencies (`EventBus`, providers) +- No service locator pattern +- No global state + +--- + +## All Questions Resolved ✅ + +| Question | Decision | +|----------|----------| +| ConfigKey init | Consumer choice via two initializers | +| Provider attribution | AccessReporter posts events directly | +| CLI provider | Enable trait, include in stack | +| Error Sendability | `any Error` is Sendable (verified) | +| isSecret | Defer to Slice 5 metadata | +| AccessReporter | Implement for telemetry | +| JSON provider | Exclude, add as future feature | +| Array support | Add 4 array overloads | +| Provider creation | Explicit creation, not array index | + +--- + +## Next Steps + +1. ✅ **Planning complete** (this document) +2. **Begin implementation** following sequence above +3. **Create unit tests** in worktree after implementation +4. **Verify success criteria** before marking Slice 1 complete From 50cb399b7120fbbc6930e6bba4d8144725ab16b4 Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 6 Jan 2026 10:36:19 -0500 Subject: [PATCH 11/28] Fix unit test --- Tests/DevConfigurationTests/DevConfigurationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/DevConfigurationTests/DevConfigurationTests.swift b/Tests/DevConfigurationTests/DevConfigurationTests.swift index 3923844..c26f0e9 100644 --- a/Tests/DevConfigurationTests/DevConfigurationTests.swift +++ b/Tests/DevConfigurationTests/DevConfigurationTests.swift @@ -14,6 +14,6 @@ struct DevConfigurationTests { @Test func testReverseDNSPrefix() { let result = reverseDNSPrefixed("test") - #expect(result == "com.gauriar.devconfiguration.test") + #expect(result == "devconfiguration.test") } } From fae16ecd64c8a48098b6dd4686669fd2196f6a0c Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 6 Jan 2026 10:36:40 -0500 Subject: [PATCH 12/28] Fix VerifyChanges.yaml --- .github/workflows/VerifyChanges.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index d9a7d72..033555c 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -51,10 +51,10 @@ jobs: DEV_BUILDS: DevBuilds/Sources OTHER_XCBEAUTIFY_FLAGS: --renderer github-actions XCCOV_PRETTY_VERSION: 1.2.0 - XCODE_SCHEME: DevFoundation-Package + XCODE_SCHEME: DevConfiguration-Package XCODE_DESTINATION: ${{ matrix.xcode_destination }} XCODE_TEST_PLAN: AllTests - XCODE_TEST_PRODUCTS_PATH: .build/DevFoundation.xctestproducts + XCODE_TEST_PRODUCTS_PATH: .build/DevConfiguration.xctestproducts steps: - name: Select Xcode ${{ env.XCODE_VERSION }} From edbe774016f9049be3ebf97e43a286399b4a33ed Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 6 Jan 2026 10:57:13 -0500 Subject: [PATCH 13/28] Fix VerifyChanges.yaml --- .github/workflows/VerifyChanges.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index 033555c..2e827ef 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -51,7 +51,7 @@ jobs: DEV_BUILDS: DevBuilds/Sources OTHER_XCBEAUTIFY_FLAGS: --renderer github-actions XCCOV_PRETTY_VERSION: 1.2.0 - XCODE_SCHEME: DevConfiguration-Package + XCODE_SCHEME: DevConfiguration XCODE_DESTINATION: ${{ matrix.xcode_destination }} XCODE_TEST_PLAN: AllTests XCODE_TEST_PRODUCTS_PATH: .build/DevConfiguration.xctestproducts From 7bdc469f1f53b2dba73d891fb13d0eb9564ac2c4 Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 6 Jan 2026 12:49:45 -0500 Subject: [PATCH 14/28] Revised architecture: remove ConfigurationDataSource from scope, consolidate behavior in StructuredConfigReader --- Plans/Architecture Plan.md | 96 ++++++-------- Plans/Implementation Plan.md | 120 +++++++---------- Plans/Slice 1 - Detailed Plan.md | 216 +++++++++++-------------------- 3 files changed, 169 insertions(+), 263 deletions(-) diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md index 6dcace4..6fbc311 100644 --- a/Plans/Architecture Plan.md +++ b/Plans/Architecture Plan.md @@ -169,13 +169,13 @@ Example events: --- -## 5. Composed Reader Architecture +## 5. Simplified Architecture -**Design Decision:** Split into two types for separation of concerns. +**Design Decision:** Single public type with protocol-based typed access. -### StructuredConfigReader (Core Type) +### StructuredConfigReader -Core typed accessor that bridges `ConfigVariable` to swift-configuration's `ConfigReader`. +Typed accessor that bridges `ConfigVariable` to swift-configuration's `ConfigReader`. ```swift public final class StructuredConfigReader: StructuredConfigurationReading { @@ -183,8 +183,13 @@ public final class StructuredConfigReader: StructuredConfigurationReading { private let eventBus: EventBus private let accessReporter: TelemetryAccessReporter - public init(providers: [ConfigProvider], eventBus: EventBus, accessReporter: TelemetryAccessReporter) + /// Initialize with custom provider array + /// Internally appends RegisteredVariablesProvider to end of array (lowest precedence) + public init(providers: [any ConfigProvider], eventBus: EventBus) +} +// Protocol conformance via extensions +extension StructuredConfigReader { // Protocol conformance: 8 overloads (4 primitives + 4 arrays) public func value(for variable: ConfigVariable) -> Bool public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] @@ -196,72 +201,53 @@ public final class StructuredConfigReader: StructuredConfigurationReading { - Value resolution with required accessors (`requiredBool()`, `requiredStringArray()`, etc.) - Error handling (catch all, return fallback) - Telemetry emission via AccessReporter integration -- Caching +- Internal RegisteredVariablesProvider management (appended to provider array) **Does NOT Handle:** -- Provider stack management -- Default provider configuration -- Source code override API + other conveniences - -### ConfigurationDataSource (Convenience Type) +- Provider stack composition (consumer's responsibility) +- Caching (may add later for telemetry deduplication only) -High-level convenience layer with standard provider management. +**Provider Management:** +- Consumers pass their own provider array +- Provider order determines precedence (first = highest priority) +- StructuredConfigReader internally appends RegisteredVariablesProvider to end +- No `addProvider` API — provider order should be explicit at initialization +**Example Usage:** ```swift -public final class ConfigurationDataSource: StructuredConfigurationReading { - private let reader: StructuredConfigReader - private let sourceOverrideProvider: MutableInMemoryProvider - - /// Standard init: auto-configured providers - public init(eventBus: EventBus) - - /// Low-level init: custom providers - public init(providers: [ConfigProvider], eventBus: EventBus) +// Consumer creates their own provider stack +let providers: [any ConfigProvider] = [ + AmplitudeProvider(), // Highest priority + EnvironmentVariablesProvider(), + // RegisteredVariablesProvider automatically added by StructuredConfigReader +] + +let reader = StructuredConfigReader( + providers: providers, + eventBus: eventBus +) - // Protocol delegation to StructuredConfigReader (8 overloads) - public func value(for variable: ConfigVariable) -> Bool - // ... etc -} +let darkMode = reader.value(for: .darkMode) ``` -**Responsibilities:** -- Standard provider stack management (overrides → CLI → environment) -- Source code override API -- Provider lifecycle management -- Protocol delegation to StructuredConfigReader - -**Standard Provider Stack:** -1. Command Line Arguments (`CommandLineArgumentsProvider`) -2. Environment Variables (`EnvironmentVariablesProvider`) -3. Source Code Overrides (`MutableInMemoryProvider`) -4. Remote/Async Providers (custom type) -5. Registered Fallbacks - -**Provider ordering**: Fixed at initialization. No `addProvider` — provider order determines precedence and should be explicit upfront for clarity. - -**Async providers**: Some providers (e.g., remote services) may not have values immediately. Pattern: +**Async Providers:** +Some providers (e.g., remote services) may not have values immediately: - Providers initialize synchronously but return no values until ready - Consumer controls lifecycle via explicit `await provider.fetch()` -- On activation: cache clears, reader emits update signal (via `@Observable` or stream) +- On activation: reader emits update signal (via `@Observable` or stream) - Multiple remote providers activate independently ```swift -// Remote provider template -public protocol RemoteConfigProvider: ConfigProvider { - var isReady: Bool { get } - func fetch() async throws -} - -// Consumer controls lifecycle +// Remote provider pattern let amplitudeProvider = AmplitudeProvider(...) -let dataSource = await ConfigurationVariableDataSource( - providers: [amplitudeProvider, jsonFileProvider], +let reader = StructuredConfigReader( + providers: [amplitudeProvider], eventBus: eventBus ) // Later, when app is ready -await amplitudeProvider.fetch() // Cache clears, signal emitted +await amplitudeProvider.fetch() // Signal emitted ``` --- @@ -404,9 +390,11 @@ internal final class RegisteredVariablesProvider: ConfigProvider { --- -## 8. Variable Access Caching +## 8. Variable Access Caching (Deferred) + +**Note:** Caching has been deferred. May be added later solely for telemetry deduplication. -Caching avoids costly re-decoding and prevents over-emitting telemetry for variable access issues. +Original rationale: Caching avoids costly re-decoding and prevents over-emitting telemetry for variable access issues. ### Cache Key diff --git a/Plans/Implementation Plan.md b/Plans/Implementation Plan.md index 8364e02..0de9b2f 100644 --- a/Plans/Implementation Plan.md +++ b/Plans/Implementation Plan.md @@ -7,82 +7,56 @@ Created by Duncan Lewis, 2026-01-02 ## Feature Inventory ### Sliced for Implementation -- [ ] Slice 1: ConfigVariable + StructuredConfigReader + ConfigurationDataSource + Telemetry -- [ ] Slice 2: Rich types (Codable) -- [ ] Slice 3: Remote provider support -- [ ] Slice 4: Access caching -- [ ] Slice 5: Registration + Metadata + RegisteredVariablesProvider -- [ ] Slice 6: Editor UI +- [ ] Slice 1: ConfigVariable + StructuredConfigReader + Telemetry +- [ ] Slice 2: Remote provider support + update signals +- [ ] Slice 3: Registration + Metadata + RegisteredVariablesProvider +- [ ] Slice 4: Editor UI ### Future Features (Deferred) -- [ ] Consumer update signals (@Observable/AsyncStream) +- [ ] Rich types (Codable) - may not be needed, can use multi-component ConfigKeys ("foo.bar") for nested access +- [ ] Access caching - may add later for telemetry deduplication only - [ ] Configuration sets (enable/disable groups via Editor UI) --- ## Implementation Slices -### Slice 1: ConfigVariable + StructuredConfigReader + ConfigurationDataSource + Telemetry -**Value:** End-to-end variable access with observability + standard provider setup +### Slice 1: ConfigVariable + StructuredConfigReader + Telemetry +**Value:** End-to-end variable access with observability -**Composed Reader Architecture:** -- **StructuredConfigReader**: Core typed accessor with telemetry (low-level) -- **ConfigurationDataSource**: High-level convenience with standard provider management +**Architecture:** +- **StructuredConfigReader**: Single typed accessor with telemetry +- Consumers manage their own provider stacks +- Protocol extensions provide typed access **Scope:** - ConfigVariable struct with ConfigKey storage (primitives + arrays: Bool, String, Int, Double, [Bool], [String], [Int], [Double]) - StructuredConfigurationReading protocol (8 method overloads: 4 primitives + 4 arrays) -- StructuredConfigReader (core type): - - Low-level init with providers array + eventBus +- StructuredConfigReader (single public type): + - Init with providers array + eventBus (consumers pass their own providers) - TelemetryAccessReporter integration (AccessReporter protocol) - - value(for:) implementations using required accessors (requiredBool(), requiredStringArray(), etc.) + - Protocol extension implementations using required accessors (requiredBool(), requiredStringArray(), etc.) - Error handling: catch errors, return fallback -- ConfigurationDataSource (convenience type): - - Standard init (auto-configures: source overrides → CLI → environment) - - Low-level init (custom providers) - - Protocol delegation to StructuredConfigReader - - Explicit source override provider creation (not by array index) + - Composes ConfigReader internally - Telemetry events using ConfigContent (from swift-configuration): - DidAccessVariableBusEvent (via AccessReporter) - VariableResolutionFailedBusEvent (on error) --- -### Slice 2: Rich Types (Codable) -**Value:** Support complex configuration types - -**Scope:** -- JSONDecodableValue bridge type (ExpressibleByConfigString) -- ConfigVariable support -- StructuredConfigurationReading.value(for:) overload -- VariableTypeMismatchBusEvent (decode failure telemetry) - ---- - -### Slice 3: Remote Provider Support -**Value:** Async configuration sources +### Slice 2: Remote Provider Support + Update Signals +**Value:** Async configuration sources and change notification **Scope:** - RemoteConfigProvider protocol (isReady, fetch()) -- StructuredConfigReader async init -- Provider lifecycle (fetch triggers cache clear + update signal) -- Update signal mechanism (decide: @Observable vs AsyncStream) - ---- - -### Slice 4: Access Caching -**Value:** Performance optimization, telemetry deduplication - -**Scope:** -- CacheKey (variableName + ObjectIdentifier(T.self)) -- CacheEntry (type-erased storage) -- Cache storage in StructuredConfigReader -- Cache lookup in value(for:) methods -- Cache invalidation (fetch, registration, snapshot change) +- StructuredConfigReader async init (if needed) +- Provider lifecycle patterns +- Update signal mechanism (decide: @Observable vs AsyncStream vs callback) +- **Validation**: Verify deep keypath access with multi-component ConfigKeys (e.g., "user.settings.theme") --- -### Slice 5: Registration + Metadata + Fallbacks +### Slice 3: Registration + Metadata + Fallbacks **Value:** Variable validation and extensibility **Scope:** @@ -91,45 +65,47 @@ Created by Duncan Lewis, 2026-01-02 - ConfigVariable metadata storage + .metadata(_:_:) builder - ConfigVariable dynamic member lookup for metadata - RegisteredVariablesProvider (internal ConfigProvider composing MutableInMemoryProvider) -- StructuredConfigReader.register() method overloads (9 total: 8 concrete + 1 generic Codable) + - Created internally by StructuredConfigReader + - Automatically added to end of provider array (lowest precedence) +- StructuredConfigReader.register() method overloads (8 concrete + arrays as needed) - DidAccessUnregisteredVariableBusEvent - DuplicateVariableRegistrationBusEvent --- -### Slice 6: Editor UI +### Slice 4: Editor UI **Value:** Runtime configuration override interface **Scope:** - TBD based on architecture decisions +- Provider-based UI presentation (providers manage their own UI) --- ## Context -### Composed Reader Architecture -- **StructuredConfigReader**: Core typed accessor - - Low-level init with explicit provider array +### Simplified Architecture +- **StructuredConfigReader**: Single public type for typed configuration access + - Init with explicit provider array (consumers manage their own stack) + - Internally creates RegisteredVariablesProvider (Slice 3) appended to provider array - Integrates with swift-configuration's AccessReporter for telemetry - - Implements StructuredConfigurationReading protocol -- **ConfigurationDataSource**: High-level convenience wrapper - - Composes StructuredConfigReader - - Standard init with auto-configured providers - - Delegates all value access to StructuredConfigReader + - Implements StructuredConfigurationReading via protocol extensions + - Composes ConfigReader internally + - No caching (may add later for telemetry deduplication only) ### Type System - Primitives: Bool, String, Int, Double (no Float) - Arrays: [Bool], [String], [Int], [Double] -- Rich types: T: Codable (requires both Encodable + Decodable for registration) - Type dispatch: Method overloads for compile-time resolution - ConfigKey storage: ConfigVariable stores ConfigKey (not String) with two initializers +- Nested access: Use multi-component ConfigKeys ("user.settings.theme") instead of Codable types -### Provider Precedence (Standard Stack) -1. Source Code Overrides (MutableInMemoryProvider) -2. Command Line Arguments (CommandLineArgumentsProvider) -3. Environment Variables (EnvironmentVariablesProvider) -4. RegisteredVariablesProvider (internal, lowest priority - Slice 5) -5. ConfigVariable.fallback (inline, used if all providers fail) +### Provider Precedence +Consumers pass their own provider array. Typical precedence pattern: +1. High-priority providers (remote/dynamic sources) +2. Mid-priority providers (environment, CLI args, files) +3. RegisteredVariablesProvider (internal, auto-added by StructuredConfigReader - Slice 3) +4. ConfigVariable.fallback (inline, used if all providers fail) ### Telemetry Behavior - Emitted via EventBus (passed at init) @@ -137,14 +113,16 @@ Created by Duncan Lewis, 2026-01-02 - Failure: Posted directly from catch blocks - Uses ConfigContent from swift-configuration (not custom enum) - Errors don't propagate to callers -- Access telemetry emitted once per cache lifecycle (Slice 4) -- Cached reads skip telemetry (Slice 4) +- No caching (telemetry posted on every access) -### Codable Bridge Strategy +### Codable Bridge Strategy (Deferred) - Internal JSONDecodableValue wrapper conforms to ExpressibleByConfigString - Consumers use Codable directly, no additional conformance - Fallback provider stores Codable types as JSON-encoded strings +- **Note:** May not be needed - multi-component ConfigKeys ("foo.bar") provide nested access ### Open Decisions -- Update signal mechanism (Slice 3): @Observable vs AsyncStream -- ExpressibleByConfigString fallthrough on init failure (impacts Slice 2 error handling) +- Update signal mechanism (Slice 2): @Observable vs AsyncStream vs callback +- Deep keypath access validation (Slice 2): Verify multi-component ConfigKeys work with remote providers +- ExpressibleByConfigString fallthrough on init failure (deferred, impacts Codable support if implemented) +- Editor UI approach (Slice 4): Provider-managed vs centralized UI diff --git a/Plans/Slice 1 - Detailed Plan.md b/Plans/Slice 1 - Detailed Plan.md index 522d90d..c47cbfa 100644 --- a/Plans/Slice 1 - Detailed Plan.md +++ b/Plans/Slice 1 - Detailed Plan.md @@ -9,9 +9,9 @@ Created by Duncan Lewis, 2026-01-03 ## Overview -**Slice 1 Scope:** ConfigVariable + StructuredConfigurationReading + Telemetry + Standard Init +**Slice 1 Scope:** ConfigVariable + StructuredConfigurationReading + Telemetry -**Value Delivered:** End-to-end variable access with observability and standard provider setup +**Value Delivered:** End-to-end variable access with observability **Supported Types:** - **Primitives:** `Bool`, `String`, `Int`, `Double` @@ -21,21 +21,20 @@ Created by Duncan Lewis, 2026-01-03 ## Architecture -**Two-Type Design:** -1. **StructuredConfigReader**: Core type for typed value access and telemetry -2. **ConfigurationVariableDataSource**: High-level convenience with default provider management +**Simplified Single-Type Design:** +- **StructuredConfigReader**: Single public type for typed configuration access +- Consumers manage their own provider stacks +- Protocol extensions provide typed access -**Division of Responsibilities:** +**Responsibilities:** +- Value resolution via protocol extensions +- Telemetry via AccessReporter integration +- Internal RegisteredVariablesProvider management (Slice 3) +- Error handling (catch errors, return fallback) -| Concern | StructuredConfigReader | ConfigurationVariableDataSource | -|---------|------------------------|----------------------------------| -| Value resolution | ✅ Implements | ❌ Delegates | -| Telemetry | ✅ Emits events | ❌ Transparent | -| Caching | ✅ Manages cache (Slice 4) | ❌ Transparent | -| Provider stack | ❌ Accepts array | ✅ Configures defaults | -| Override API | ❌ Not exposed | ✅ Public methods (Slice 6) | -| swift-config integration | ✅ Direct usage | ❌ Via StructuredConfigReader | -| Standard init | ❌ No defaults | ✅ Auto-configures | +**Does NOT Handle:** +- Provider stack composition (consumer's responsibility) +- Caching (deferred) See [Architecture Plan.md](./Architecture%20Plan.md) section 5 for architectural overview. @@ -256,77 +255,27 @@ public final class StructuredConfigReader: StructuredConfigurationReading { - Only post failure telemetry in catch block - Fallback always returned on error - No manual source tracking - AccessEvent has provider name +- Protocol extensions provide default implementations ---- - -### 5. ConfigurationVariableDataSource (Convenience Type) - -**Purpose:** High-level convenience with standard provider management - -**Public Interface:** -```swift -public final class ConfigurationVariableDataSource: StructuredConfigurationReading { - /// Standard init: auto-configured providers - public init(eventBus: EventBus) - - /// Low-level init: custom providers - public init(providers: [any ConfigProvider], eventBus: EventBus) - - // Protocol conformance: delegates to StructuredConfigReader (8 overloads) - public func value(for variable: ConfigVariable) -> Bool - public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] - // ... etc - - // Source override API (Slice 6 - deferred) - // public func setOverride(_ value: T, for variable: ConfigVariable) - // public func clearOverride(for variable: ConfigVariable) -} -``` - -**Standard Init Implementation:** +**Example Usage:** ```swift -public init(eventBus: EventBus) { - // Create source override provider EXPLICITLY (not by array index) - let sourceOverrideProvider = MutableInMemoryProvider( - name: "source-overrides", - initialValues: [:] - ) - - // Build provider array - let providers: [any ConfigProvider] = [ - sourceOverrideProvider, // Highest precedence - CommandLineArgumentsProvider(), - EnvironmentVariablesProvider(), - // RegisteredVariablesProvider() - Slice 5 - ] - - // Create core reader - self.reader = StructuredConfigReader( - providers: providers, - eventBus: eventBus - ) - - // Store provider reference for Editor UI (Slice 6) - self.sourceOverrideProvider = sourceOverrideProvider -} +// Consumer creates their own provider stack +let providers: [any ConfigProvider] = [ + EnvironmentVariablesProvider(), + // RegisteredVariablesProvider automatically added by StructuredConfigReader (Slice 3) +] + +let reader = StructuredConfigReader( + providers: providers, + eventBus: eventBus +) -// Protocol delegation -public func value(for variable: ConfigVariable) -> Bool { - reader.value(for: variable) -} -// ... etc for all 8 overloads +let darkMode = reader.value(for: .darkMode) ``` -**Key Design Decisions:** -- Source override provider created **explicitly** before array -- Stored directly (not via array index) -- Delegates all `value(for:)` calls to StructuredConfigReader -- Both classes (not structs) for mutable state -- Low-level init available for advanced use cases - --- -### 6. Telemetry Events +### 5. Telemetry Events **DidAccessVariableBusEvent:** ```swift @@ -397,54 +346,56 @@ public struct AccessEvent { --- -## Standard Provider Stack - -**ConfigurationVariableDataSource Standard Init:** - -**Precedence (High → Low):** -1. **Source Code Overrides** - `MutableInMemoryProvider` - - Name: "source-overrides" - - Empty initial values - - Created explicitly, stored for Editor UI (Slice 6) +## Example Provider Stacks -2. **Command Line Arguments** - `CommandLineArgumentsProvider` - - Requires `CommandLineArgumentsSupport` trait - - Pattern: `--feature.darkMode=true`, `--tags swift config` +**Consumer-Managed Configuration:** -3. **Environment Variables** - `EnvironmentVariablesProvider` - - Key transformation: `feature.darkMode` → `FEATURE_DARKMODE` +Consumers manage their own provider stacks by passing an array of providers to `StructuredConfigReader`. Provider order determines precedence (first = highest priority). `RegisteredVariablesProvider` is automatically appended internally (Slice 3) at lowest precedence. -4. **Registered Fallbacks** - (Slice 5) - - `RegisteredVariablesProvider` (custom, composes MutableInMemoryProvider) - - Lowest precedence, above inline fallback - -**Not Included:** -- JSON file providers (use low-level init) -- Remote providers (Slice 3) +**Example: Local Development** +```swift +let providers: [any ConfigProvider] = [ + EnvironmentVariablesProvider(), +] -**Rationale:** -- Covers 90% use case: local development + testing -- Production configs (JSON, remote) require explicit setup +let reader = StructuredConfigReader( + providers: providers, + eventBus: eventBus +) +``` ---- +**Example: Testing with Overrides** +```swift +let overrides = MutableInMemoryProvider( + name: "test-overrides", + initialValues: ["feature.darkMode": true] +) -## Package.swift Configuration +let providers: [any ConfigProvider] = [ + overrides, + EnvironmentVariablesProvider(), +] -### Enable CommandLineArgumentsSupport Trait +let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) +``` +**Example: Production with CLI Support** ```swift -.target( - name: "DevConfiguration", - dependencies: [ - .product(name: "Configuration", package: "swift-configuration"), - .product(name: "DevFoundation", package: "DevFoundation"), - ], - swiftSettings: [ - .define("CommandLineArguments") - ] -) +let providers: [any ConfigProvider] = [ + CommandLineArgumentsProvider(), // Requires CommandLineArgumentsSupport trait + EnvironmentVariablesProvider(), + // Add JSON/file providers as needed +] + +let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) ``` +**Notes:** +- CLI arguments pattern: `--feature.darkMode=true`, `--tags swift config` +- Environment key transformation: `feature.darkMode` → `FEATURE_DARKMODE` +- JSON/file providers: Consumer adds as needed for their use case +- Remote providers: See Slice 2 for async provider support + --- ## Implementation Sequence @@ -461,12 +412,7 @@ public struct AccessEvent { - `TelemetryAccessReporter` - AccessReporter implementation - `DidAccessVariableBusEvent` - struct using ConfigContent - `VariableResolutionFailedBusEvent` - struct with `any Error` -5. **ConfigurationVariableDataSource** - implement with TODOs (if needed): - - Standard init with explicit provider creation - - Protocol delegation (8 overloads) -6. **Fill in remaining data types** (if any) -7. **Enable `CommandLineArgumentsSupport`** in Package.swift -8. **End-to-end verification** +5. **End-to-end verification** **Rationale:** - Implement main types first with TODOs to define interfaces @@ -496,22 +442,18 @@ public struct AccessEvent { - Fallback on type mismatch - Fallback on provider errors - Telemetry emission (success via AccessReporter + failure direct) - -**ConfigurationVariableDataSource:** -- Standard init provider stack -- Provider precedence -- Explicit provider creation (not array index) -- Protocol delegation (all 8 overloads) +- Provider array initialization +- AccessReporter integration **Integration Tests:** - End-to-end value resolution - Provider precedence verification -- CLI argument parsing - Environment variable transformation - Telemetry event flow (both success and failure) +- Multiple provider stack patterns ### Test Patterns -- Use `InMemoryProvider` for deterministic tests +- Use `MutableInMemoryProvider` for deterministic tests - Mock EventBus to verify telemetry - Use DevTesting stub framework - See `Documentation/TestingGuidelines.md` @@ -528,9 +470,7 @@ public struct AccessEvent { - [ ] Value resolution uses required accessors (throwing) - [ ] AccessReporter handles success telemetry automatically - [ ] Error telemetry includes full context -- [ ] Standard provider stack: overrides → CLI → env -- [ ] Source override provider created explicitly (not by index) -- [ ] `CommandLineArgumentsSupport` trait enabled +- [ ] StructuredConfigReader accepts provider array - [ ] All 8 type overloads work (primitives + arrays) - [ ] Provider precedence respected - [ ] Unit tests achieve >99% coverage @@ -544,8 +484,8 @@ public struct AccessEvent { ### Variable Overlays (Post-Slice 1) - JSON/YAML file-based configuration - Environment-specific configs (dev, staging, prod) -- Too app-specific for standard stack -- Use low-level init for custom file providers +- Too app-specific for core library +- Consumers add custom file providers to their provider stack ### Caching (Slice 4) - Cache resolved values by (key, type) @@ -589,13 +529,13 @@ public struct AccessEvent { |----------|----------| | ConfigKey init | Consumer choice via two initializers | | Provider attribution | AccessReporter posts events directly | -| CLI provider | Enable trait, include in stack | +| CLI provider | Consumer adds to provider stack if needed | | Error Sendability | `any Error` is Sendable (verified) | | isSecret | Defer to Slice 5 metadata | | AccessReporter | Implement for telemetry | -| JSON provider | Exclude, add as future feature | +| JSON provider | Consumer adds to provider stack if needed | | Array support | Add 4 array overloads | -| Provider creation | Explicit creation, not array index | +| Standard provider stack | Removed - consumers manage their own | --- From de1872b473b347ebc8b370a53a382c0f7191d216 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 10:39:20 -0500 Subject: [PATCH 15/28] Add VariablePrivacy to planning docs --- Plans/Architecture Plan.md | 1 + Plans/Slice 1 - Detailed Plan.md | 160 ++++++++++++++++++++++++------- 2 files changed, 124 insertions(+), 37 deletions(-) diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md index 6fbc311..59e8712 100644 --- a/Plans/Architecture Plan.md +++ b/Plans/Architecture Plan.md @@ -31,6 +31,7 @@ extension ConfigVariable where Value == Bool { public struct ConfigVariable { public let key: ConfigKey // From swift-configuration public let fallback: Value + public let privacy: VariablePrivacy private var metadata: VariableMetadata // Convenience: string → ConfigKey diff --git a/Plans/Slice 1 - Detailed Plan.md b/Plans/Slice 1 - Detailed Plan.md index c47cbfa..f024841 100644 --- a/Plans/Slice 1 - Detailed Plan.md +++ b/Plans/Slice 1 - Detailed Plan.md @@ -1,7 +1,7 @@ # Slice 1: Detailed Implementation Plan Created by Duncan Lewis, 2026-01-03 -**Last Updated:** 2026-01-03 (Decisions Finalized) +**Last Updated:** 2026-01-06 (Added Variable Privacy) **Parent Document:** [Implementation Plan.md](./Implementation%20Plan.md) @@ -9,9 +9,9 @@ Created by Duncan Lewis, 2026-01-03 ## Overview -**Slice 1 Scope:** ConfigVariable + StructuredConfigurationReading + Telemetry +**Slice 1 Scope:** ConfigVariable + VariablePrivacy + StructuredConfigurationReading + Telemetry -**Value Delivered:** End-to-end variable access with observability +**Value Delivered:** End-to-end variable access with observability and privacy control **Supported Types:** - **Primitives:** `Bool`, `String`, `Int`, `Double` @@ -58,35 +58,64 @@ See [Architecture Plan.md](./Architecture%20Plan.md) section 5 for architectural ✅ No need to track "last accessed provider" - AccessEvent has all info ✅ Errors captured and posted as `VariableResolutionFailedBusEvent` -### 4. Provider Stack -✅ Source overrides created **explicitly**, not by array index -✅ CLI provider enabled via `CommandLineArgumentsSupport` trait -✅ Precedence: Source Overrides → CLI → Environment → Registration (Slice 5) +### 4. Variable Privacy +✅ VariablePrivacy enum with three cases: `auto`, `private`, `public` +✅ `auto` treats String values as secret, all others as public +✅ Privacy setting determines `isSecret` parameter passed to swift-configuration +✅ Default privacy is `auto` -### 5. Deferred to Later Slices -- `isSecret` parameter → Slice 5 (metadata) +### 5. Provider Stack +✅ Consumers pass their own provider array to StructuredConfigReader +✅ Provider order determines precedence (first = highest priority) +✅ RegisteredVariablesProvider automatically appended internally (Slice 3) + +### 6. Deferred to Later Slices - JSON file providers → Future (as "variable overlays") - Caching → Slice 4 +- Metadata system → Slice 3 --- ## Component Breakdown -### 1. ConfigVariable +### 1. VariablePrivacy + +**Purpose:** Control whether variable values are treated as secrets in telemetry and logging + +**Public Interface:** +```swift +public enum VariablePrivacy { + case auto // Secret if String type + case `private` // Always secret + case `public` // Never secret +} +``` + +**Behavior:** +- **`auto`**: Treats String values as secret, all other types as public +- **`private`**: Always treats value as secret (passes `isSecret: true`) +- **`public`**: Never treats value as secret (passes `isSecret: false`) + +**Default:** `auto` + +--- -**Purpose:** Type-safe variable definition with fallback value +### 2. ConfigVariable + +**Purpose:** Type-safe variable definition with fallback value and privacy control **Public Interface:** ```swift public struct ConfigVariable { public let key: ConfigKey public let fallback: Value + public let privacy: VariablePrivacy - // Convenience: string → ConfigKey - public init(key: String, fallback: Value) + // Convenience: string → ConfigKey, default privacy + public init(key: String, fallback: Value, privacy: VariablePrivacy = .auto) - // Direct: explicit ConfigKey - public init(key: ConfigKey, fallback: Value) + // Direct: explicit ConfigKey, default privacy + public init(key: ConfigKey, fallback: Value, privacy: VariablePrivacy = .auto) } ``` @@ -98,18 +127,18 @@ public struct ConfigVariable { ```swift enum AppConfig { static let darkMode = ConfigVariable(key: "feature.darkMode", fallback: false) - static let tags = ConfigVariable(key: "feature.tags", fallback: ["default"]) - static let timeout = ConfigVariable(key: ConfigKey("network.timeout"), fallback: 30.0) + static let apiKey = ConfigVariable(key: "api.key", fallback: "", privacy: .private) + static let timeout = ConfigVariable(key: ConfigKey("network.timeout"), fallback: 30.0, privacy: .public) } // Access -let darkMode = dataSource.value(for: AppConfig.darkMode) -let tags = dataSource.value(for: AppConfig.tags) +let darkMode = reader.value(for: AppConfig.darkMode) +let apiKey = reader.value(for: AppConfig.apiKey) // Always secret ``` --- -### 2. StructuredConfigurationReading Protocol +### 3. StructuredConfigurationReading Protocol **Purpose:** Define contract for typed configuration access @@ -211,11 +240,26 @@ public final class StructuredConfigReader: StructuredConfigurationReading { ) } + // Helper: Determine if value should be treated as secret + private func isSecret(for variable: ConfigVariable) -> Bool { + switch variable.privacy { + case .auto: + return T.self == String.self + case .private: + return true + case .public: + return false + } + } + // Primitive example public func value(for variable: ConfigVariable) -> Bool { do { // Required accessor throws if not found or type mismatch - let resolved = try reader.requiredBool(forKey: variable.key) + let resolved = try reader.requiredBool( + forKey: variable.key, + isSecret: isSecret(for: variable) + ) // AccessReporter already posted DidAccessVariableBusEvent return resolved } catch { @@ -229,10 +273,31 @@ public final class StructuredConfigReader: StructuredConfigurationReading { } } + // String example (auto = secret) + public func value(for variable: ConfigVariable) -> String { + do { + let resolved = try reader.requiredString( + forKey: variable.key, + isSecret: isSecret(for: variable) // true when auto + ) + return resolved + } catch { + eventBus.post(VariableResolutionFailedBusEvent( + key: variable.key.description, + error: error, + fallback: .string(variable.fallback) + )) + return variable.fallback + } + } + // Array example public func value(for variable: ConfigVariable<[String]>) -> [String] { do { - let resolved = try reader.requiredStringArray(forKey: variable.key) + let resolved = try reader.requiredStringArray( + forKey: variable.key, + isSecret: isSecret(for: variable) + ) // AccessReporter already posted event return resolved } catch { @@ -245,12 +310,14 @@ public final class StructuredConfigReader: StructuredConfigurationReading { } } - // ... 6 more overloads + // ... 5 more overloads } ``` **Key Design Decisions:** - Use `requiredBool()`, `requiredStringArray()`, etc. (throwing accessors) +- Pass `isSecret` parameter based on variable privacy setting +- Privacy logic: `auto` treats String as secret, `private` always secret, `public` never secret - AccessReporter posts success telemetry **automatically** - Only post failure telemetry in catch block - Fallback always returned on error @@ -314,16 +381,18 @@ public struct VariableResolutionFailedBusEvent: BusEvent { ### Typed Accessors Used **Primitives (throwing):** -- `requiredBool(forKey:) throws -> Bool` -- `requiredString(forKey:) throws -> String` -- `requiredInt(forKey:) throws -> Int` -- `requiredDouble(forKey:) throws -> Double` +- `requiredBool(forKey:isSecret:) throws -> Bool` +- `requiredString(forKey:isSecret:) throws -> String` +- `requiredInt(forKey:isSecret:) throws -> Int` +- `requiredDouble(forKey:isSecret:) throws -> Double` **Arrays (throwing):** -- `requiredBoolArray(forKey:) throws -> [Bool]` -- `requiredStringArray(forKey:) throws -> [String]` -- `requiredIntArray(forKey:) throws -> [Int]` -- `requiredDoubleArray(forKey:) throws -> [Double]` +- `requiredBoolArray(forKey:isSecret:) throws -> [Bool]` +- `requiredStringArray(forKey:isSecret:) throws -> [String]` +- `requiredIntArray(forKey:isSecret:) throws -> [Int]` +- `requiredDoubleArray(forKey:isSecret:) throws -> [Double]` + +**Note:** The `isSecret` parameter controls whether values are redacted in telemetry and logging ### AccessReporter Protocol ```swift @@ -401,24 +470,31 @@ let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) ## Implementation Sequence **Recommended Order:** -1. **ConfigVariable** - struct with two initializers +1. **ConfigVariable** - struct with two initializers (initially without privacy parameter) 2. **StructuredConfigurationReading** - protocol (8 overloads) 3. **StructuredConfigReader** - implement with TODOs: - Constructor with AccessReporter integration (TODO: TelemetryAccessReporter) - - Implement `value(for:)` for Bool (TODO: event types) + - Implement `value(for:)` for Bool (TODO: event types, initially without isSecret) - Implement `value(for:)` for [Bool] (verify array pattern) - Complete remaining 6 overloads 4. **Fill in data types for StructuredConfigReader:** - `TelemetryAccessReporter` - AccessReporter implementation - `DidAccessVariableBusEvent` - struct using ConfigContent - `VariableResolutionFailedBusEvent` - struct with `any Error` -5. **End-to-end verification** +5. **VariablePrivacy** - enum with three cases (auto, private, public) +6. **Add privacy to existing types:** + - Add `privacy` parameter to ConfigVariable initializers + - Add `isSecret(for: ConfigVariable) -> Bool` helper to StructuredConfigReader + - Update all 8 `value(for:)` implementations to pass `isSecret` parameter +7. **End-to-end verification** **Rationale:** - Implement main types first with TODOs to define interfaces +- Get basic functionality working without privacy +- Add privacy as enhancement after core functionality verified - Fill in supporting types as needed to resolve TODOs - This allows incremental progress and clearer interface design -- Verify primitive and array patterns early (step 3) +- Verify primitive and array patterns early (step 3), privacy later (step 6) --- @@ -426,8 +502,13 @@ let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) ### Unit Test Coverage +**VariablePrivacy:** +- Enum cases (auto, private, public) +- Auto behavior for String vs non-String types + **ConfigVariable:** - Two initializers (String and ConfigKey) +- Privacy parameter with default value - Property access **TelemetryAccessReporter:** @@ -437,6 +518,8 @@ let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) **StructuredConfigReader:** - All 8 overloads (4 primitives + 4 arrays) +- Privacy-based `isSecret` determination for each type +- String type always secret when privacy is auto - Required accessor error handling - Fallback on missing values - Fallback on type mismatch @@ -464,10 +547,13 @@ let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) **Slice 1 is complete when:** - [ ] All types compile without errors +- [ ] VariablePrivacy enum has three cases (auto, private, public) - [ ] ConfigVariable supports both initializers (String and ConfigKey) +- [ ] ConfigVariable includes privacy parameter with default value - [ ] StructuredConfigurationReading has 8 overloads (4 + 4) - [ ] TelemetryAccessReporter posts events from AccessEvent -- [ ] Value resolution uses required accessors (throwing) +- [ ] Value resolution uses required accessors with `isSecret` parameter +- [ ] Privacy logic correctly determines `isSecret` (auto = String only) - [ ] AccessReporter handles success telemetry automatically - [ ] Error telemetry includes full context - [ ] StructuredConfigReader accepts provider array @@ -531,7 +617,7 @@ let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) | Provider attribution | AccessReporter posts events directly | | CLI provider | Consumer adds to provider stack if needed | | Error Sendability | `any Error` is Sendable (verified) | -| isSecret | Defer to Slice 5 metadata | +| Variable privacy | VariablePrivacy enum in Slice 1 (auto/private/public) | | AccessReporter | Implement for telemetry | | JSON provider | Consumer adds to provider stack if needed | | Array support | Add 4 array overloads | From f53ce86e4282040c85bf4d3f43e6ddf91806b277 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 11:02:59 -0500 Subject: [PATCH 16/28] Change Double -> Float64 --- Plans/Architecture Plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md index 59e8712..bd61c3e 100644 --- a/Plans/Architecture Plan.md +++ b/Plans/Architecture Plan.md @@ -109,13 +109,13 @@ public protocol StructuredConfigurationReading { func value(for variable: ConfigVariable) -> Bool func value(for variable: ConfigVariable) -> String func value(for variable: ConfigVariable) -> Int - func value(for variable: ConfigVariable) -> Double + func value(for variable: ConfigVariable) -> Float64 // Arrays func value(for variable: ConfigVariable<[Bool]>) -> [Bool] func value(for variable: ConfigVariable<[String]>) -> [String] func value(for variable: ConfigVariable<[Int]>) -> [Int] - func value(for variable: ConfigVariable<[Double]>) -> [Double] + func value(for variable: ConfigVariable<[Float64]>) -> [Float64] // Rich types func value(for variable: ConfigVariable) -> T @@ -131,14 +131,14 @@ Resolution dispatches to swift-configuration's typed accessors (`requiredBool()` | `Bool` | `requiredBool(forKey:)` | | `String` | `requiredString(forKey:)` | | `Int` | `requiredInt(forKey:)` | -| `Double` | `requiredDouble(forKey:)` | +| `Float64` | `requiredDouble(forKey:)` | | `[Bool]` | `requiredBoolArray(forKey:)` | | `[String]` | `requiredStringArray(forKey:)` | | `[Int]` | `requiredIntArray(forKey:)` | -| `[Double]` | `requiredDoubleArray(forKey:)` | +| `[Float64]` | `requiredDoubleArray(forKey:)` | | `T: Codable` | String → JSON decode | -No `Float` support — use `Double`. Rich types require `Codable` (not just `Decodable`) to support registration. +- Note: Use `Float64` instead of `Double` in the interface to match DevFoundation. --- From dead737060efdf0bc066db44b99d069549aa810a Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 11:03:30 -0500 Subject: [PATCH 17/28] Leave note about handling change observation with `watch()` --- Plans/Architecture Plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md index bd61c3e..d86ce14 100644 --- a/Plans/Architecture Plan.md +++ b/Plans/Architecture Plan.md @@ -166,6 +166,8 @@ Example events: ## Open Questions - Consumer-facing update signal: How does `StructuredConfigReader` notify consumers when values may have changed? (`@Observable`, `AsyncStream`, callback, or just re-access?) + - Answer: Use `watchSnapshot` to expose an update stream function on `StructuredConfigReader`, consider + adding variable-wise watch functions in the future. - Does `ExpressibleByConfigString` support fallthrough on init failure? (assumed yes, needs verification) --- From 1f0267c194d275645152fef7e89f4e8c14652eb2 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 11:04:29 -0500 Subject: [PATCH 18/28] Fix reference to codebase name --- Documentation/MarkdownStyleGuide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/MarkdownStyleGuide.md b/Documentation/MarkdownStyleGuide.md index d94f75c..9eb9615 100644 --- a/Documentation/MarkdownStyleGuide.md +++ b/Documentation/MarkdownStyleGuide.md @@ -1,6 +1,6 @@ # Markdown Style Guide -This document defines the Markdown formatting standards for documentation in the Shopper iOS +This document defines the Markdown formatting standards for documentation in the DevConfiguration codebase. From c706f5f6a3b1b68bc9d6f37f8388124280309764 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 11:10:33 -0500 Subject: [PATCH 19/28] Reorganize Slice 1 plan. --- Plans/{ => Slice 1}/Slice 1 - Detailed Plan.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Plans/{ => Slice 1}/Slice 1 - Detailed Plan.md (100%) diff --git a/Plans/Slice 1 - Detailed Plan.md b/Plans/Slice 1/Slice 1 - Detailed Plan.md similarity index 100% rename from Plans/Slice 1 - Detailed Plan.md rename to Plans/Slice 1/Slice 1 - Detailed Plan.md From eedcb7f4babafdbdda586214e46317852cdd735b Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 11:24:14 -0500 Subject: [PATCH 20/28] Introduce ConfigVariable --- .claude/settings.local.json | 7 ++ Package.swift | 4 ++ Plans/Slice 1/ConfigVariable Test Plan.md | 9 +++ Sources/DevConfiguration/ConfigVariable.swift | 64 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 Plans/Slice 1/ConfigVariable Test Plan.md create mode 100644 Sources/DevConfiguration/ConfigVariable.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..323855c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(swift build:*)" + ] + } +} diff --git a/Package.swift b/Package.swift index 67dc9cb..80409d3 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,10 @@ let package = Package( targets: [ .target( name: "DevConfiguration", + dependencies: [ + .product(name: "Configuration", package: "swift-configuration"), + .product(name: "DevFoundation", package: "DevFoundation"), + ], swiftSettings: swiftSettings ), .testTarget( diff --git a/Plans/Slice 1/ConfigVariable Test Plan.md b/Plans/Slice 1/ConfigVariable Test Plan.md new file mode 100644 index 0000000..74a1768 --- /dev/null +++ b/Plans/Slice 1/ConfigVariable Test Plan.md @@ -0,0 +1,9 @@ +# ConfigVariable Test Plan + +Created by Duncan Lewis, 2026-01-07 + +- `ConfigVariable` + - init (w/ string) + - init converts key string to ConfigKey + - init (w/ config key) + - init stores parameters correctly (w/ each supported fallback value - 4 + 4) \ No newline at end of file diff --git a/Sources/DevConfiguration/ConfigVariable.swift b/Sources/DevConfiguration/ConfigVariable.swift new file mode 100644 index 0000000..3f21267 --- /dev/null +++ b/Sources/DevConfiguration/ConfigVariable.swift @@ -0,0 +1,64 @@ +// +// ConfigVariable.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +import Configuration + +/// A type-safe variable definition with a fallback value. +/// +/// `ConfigVariable` encapsulates a configuration key and its fallback value, +/// providing compile-time type safety for configuration access. +/// +/// ## Usage +/// +/// Define configuration variables as static properties: +/// +/// ```swift +/// extension ConfigVariable where Value == Bool { +/// static let darkMode = ConfigVariable( +/// key: "feature.darkMode", +/// fallback: false +/// ) +/// } +/// ``` +/// +/// Access values through a `StructuredConfigurationReading` instance: +/// +/// ```swift +/// let darkMode = reader.value(for: .darkMode) +/// ``` +public struct ConfigVariable { + /// The configuration key used to look up this variable's value. + public let key: ConfigKey + + /// The fallback value returned when the variable cannot be resolved. + public let fallback: Value + + + /// Creates a configuration variable with the specified string key. + /// + /// The string is converted to a `ConfigKey` using the default initializer. + /// + /// - Parameters: + /// - key: The configuration key as a string (e.g., "feature.darkMode"). + /// - fallback: The fallback value to use when variable resolution fails. + public init(key: String, fallback: Value) { + self.init(key: ConfigKey(key), fallback: fallback) + } + + + /// Creates a configuration variable with the specified `ConfigKey`. + /// + /// Use this initializer when you need to specified the `ConfigKey` directly. + /// + /// - Parameters: + /// - key: The configuration key. + /// - fallback: The fallback value to use when variable resolution fails. + public init(key: ConfigKey, fallback: Value) { + self.key = key + self.fallback = fallback + } +} From 11bd8ac056848f586af251f015b6e8daa2888955 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 11:51:48 -0500 Subject: [PATCH 21/28] Introduce VariablePrivacy --- Plans/Slice 1/ConfigVariable Test Plan.md | 7 ++++- Sources/DevConfiguration/ConfigVariable.swift | 12 +++++-- .../DevConfiguration/VariablePrivacy.swift | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 Sources/DevConfiguration/VariablePrivacy.swift diff --git a/Plans/Slice 1/ConfigVariable Test Plan.md b/Plans/Slice 1/ConfigVariable Test Plan.md index 74a1768..3b874c9 100644 --- a/Plans/Slice 1/ConfigVariable Test Plan.md +++ b/Plans/Slice 1/ConfigVariable Test Plan.md @@ -5,5 +5,10 @@ Created by Duncan Lewis, 2026-01-07 - `ConfigVariable` - init (w/ string) - init converts key string to ConfigKey + - init stores parameters correctly (w/ each supported fallback value - 4 + 4) + - init uses `.auto` privacy when not specified + - init stores explicit privacy parameter - init (w/ config key) - - init stores parameters correctly (w/ each supported fallback value - 4 + 4) \ No newline at end of file + - init stores parameters correctly (w/ each supported fallback value - 4 + 4) + - init uses `.auto` privacy when not specified + - init stores explicit privacy parameter \ No newline at end of file diff --git a/Sources/DevConfiguration/ConfigVariable.swift b/Sources/DevConfiguration/ConfigVariable.swift index 3f21267..434d86b 100644 --- a/Sources/DevConfiguration/ConfigVariable.swift +++ b/Sources/DevConfiguration/ConfigVariable.swift @@ -37,6 +37,9 @@ public struct ConfigVariable { /// The fallback value returned when the variable cannot be resolved. public let fallback: Value + /// Whether this value should be treated as a secret. + public let privacy: VariablePrivacy + /// Creates a configuration variable with the specified string key. /// @@ -45,8 +48,9 @@ public struct ConfigVariable { /// - Parameters: /// - key: The configuration key as a string (e.g., "feature.darkMode"). /// - fallback: The fallback value to use when variable resolution fails. - public init(key: String, fallback: Value) { - self.init(key: ConfigKey(key), fallback: fallback) + /// - privacy: The privacy setting for this variable. Defaults to `.auto`. + public init(key: String, fallback: Value, privacy: VariablePrivacy = .auto) { + self.init(key: ConfigKey(key), fallback: fallback, privacy: privacy) } @@ -57,8 +61,10 @@ public struct ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - fallback: The fallback value to use when variable resolution fails. - public init(key: ConfigKey, fallback: Value) { + /// - privacy: The privacy setting for this variable. Defaults to `.auto`. + public init(key: ConfigKey, fallback: Value, privacy: VariablePrivacy = .auto) { self.key = key self.fallback = fallback + self.privacy = privacy } } diff --git a/Sources/DevConfiguration/VariablePrivacy.swift b/Sources/DevConfiguration/VariablePrivacy.swift new file mode 100644 index 0000000..855910d --- /dev/null +++ b/Sources/DevConfiguration/VariablePrivacy.swift @@ -0,0 +1,31 @@ +// +// VariablePrivacy.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +/// Controls whether a configuration variable's value is treated as secret. +/// +/// Variable privacy determines how values are handled in telemetry, logging, +/// and other observability systems. Secret values are redacted or obfuscated +/// to prevent sensitive information from being exposed. +public enum VariablePrivacy { + /// Treat String values as secret, all other types as public. + /// + /// This is the default privacy level and provides sensible protection + /// for most use cases. + case auto + + /// Always treat the value as secret. + /// + /// Use this for sensitive data that should never be logged or exposed, + /// regardless of type. + case `private` + + /// Never treat the value as secret. + /// + /// Use this when you explicitly want values to be visible in logs and + /// telemetry, even if they are strings. + case `public` +} From 72a2d922791ec8da1259026fa7e56cd0ce227c28 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 13:48:56 -0500 Subject: [PATCH 22/28] Introduces the StructuredConfigReading protocol --- .../StructuredConfigReading Test Plan.md | 18 +++++ README.md | 3 +- Sources/DevConfiguration/ConfigVariable.swift | 2 +- .../StructuredConfigReading.swift | 69 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 Plans/Slice 1/StructuredConfigReading Test Plan.md create mode 100644 Sources/DevConfiguration/StructuredConfigReading.swift diff --git a/Plans/Slice 1/StructuredConfigReading Test Plan.md b/Plans/Slice 1/StructuredConfigReading Test Plan.md new file mode 100644 index 0000000..eafe80d --- /dev/null +++ b/Plans/Slice 1/StructuredConfigReading Test Plan.md @@ -0,0 +1,18 @@ +# StructuredConfigReading Test Plan + +Created by Duncan Lewis, 2026-01-07 + +## Notes + +`StructuredConfigReading` is a protocol with no implementation or testable behavior. +Testing will be performed through `StructuredConfigReader` which implements this protocol. + +All 8 method overloads will be tested as part of the StructuredConfigReader test suite: +- `value(for: ConfigVariable) -> Bool` +- `value(for: ConfigVariable) -> String` +- `value(for: ConfigVariable) -> Int` +- `value(for: ConfigVariable) -> Float64` +- `value(for: ConfigVariable<[Bool]>) -> [Bool]` +- `value(for: ConfigVariable<[String]>) -> [String]` +- `value(for: ConfigVariable<[Int]>) -> [Int]` +- `value(for: ConfigVariable<[Float64]>) -> [Float64]` diff --git a/README.md b/README.md index 148853c..78a643d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # DevConfiguration DevConfiguration is a type-safe configuration wrapper built on Apple's swift-configuration library. -It provides structured configuration management with telemetry, caching, and extensible metadata. +It provides structured configuration management with telemetry, extensible metadata, and a variable +management interface. DevConfiguration is fully documented and tested and supports iOS 26+, macOS 26+, tvOS 26+, visionOS 26+, and watchOS 26+. diff --git a/Sources/DevConfiguration/ConfigVariable.swift b/Sources/DevConfiguration/ConfigVariable.swift index 434d86b..8e9b7d2 100644 --- a/Sources/DevConfiguration/ConfigVariable.swift +++ b/Sources/DevConfiguration/ConfigVariable.swift @@ -25,7 +25,7 @@ import Configuration /// } /// ``` /// -/// Access values through a `StructuredConfigurationReading` instance: +/// Access values through a `StructuredConfigReading` instance: /// /// ```swift /// let darkMode = reader.value(for: .darkMode) diff --git a/Sources/DevConfiguration/StructuredConfigReading.swift b/Sources/DevConfiguration/StructuredConfigReading.swift new file mode 100644 index 0000000..8be1b58 --- /dev/null +++ b/Sources/DevConfiguration/StructuredConfigReading.swift @@ -0,0 +1,69 @@ +// +// StructuredConfigReading.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +/// Provides typed access to `ConfigVariable` parameters. +/// +/// This protocol defines the contract for resolving configuration variables +/// with compile-time type safety. Implementations handle provider lookups, +/// error handling, and fallback values automatically. +/// +/// Values are always returned (never nil or thrown) - if resolution fails, +/// the variable's fallback value is used. +public protocol StructuredConfigReading { + // MARK: - Primitive Types + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get a boolean value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable) -> Bool + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get a string value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable) -> String + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get an integer value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable) -> Int + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get a float64 value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable) -> Float64 + + + // MARK: - Array Types + + /// Gets the value for the specified `ConfigVariable<[Bool]>`. + /// + /// - Parameter variable: The variable to get a boolean array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable<[Bool]>) -> [Bool] + + /// Gets the value for the specified `ConfigVariable<[String]>`. + /// + /// - Parameter variable: The variable to get a string array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable<[String]>) -> [String] + + /// Gets the value for the specified `ConfigVariable<[Int]>`. + /// + /// - Parameter variable: The variable to get an integer array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable<[Int]>) -> [Int] + + /// Gets the value for the specified `ConfigVariable<[Float64]>`. + /// + /// - Parameter variable: The variable to get a float64 array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + func value(for variable: ConfigVariable<[Float64]>) -> [Float64] +} From 86c2eb8776268ba46b06b3a7de5cbc30162d2328 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 13:49:12 -0500 Subject: [PATCH 23/28] Make executable Scripts/format --- Scripts/format | 0 Tests/DevConfigurationTests/DevConfigurationTests.swift | 1 + 2 files changed, 1 insertion(+) mode change 100644 => 100755 Scripts/format diff --git a/Scripts/format b/Scripts/format old mode 100644 new mode 100755 diff --git a/Tests/DevConfigurationTests/DevConfigurationTests.swift b/Tests/DevConfigurationTests/DevConfigurationTests.swift index c26f0e9..d17a21a 100644 --- a/Tests/DevConfigurationTests/DevConfigurationTests.swift +++ b/Tests/DevConfigurationTests/DevConfigurationTests.swift @@ -8,6 +8,7 @@ import DevTesting import Foundation import Testing + @testable import DevConfiguration struct DevConfigurationTests { From 625752d7970acd522fef43e21d76eade2f9e1f4e Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 13:50:49 -0500 Subject: [PATCH 24/28] Introduces StructuredConfigReader, with basic support for reading config values --- .../StructuredConfigReader Test Plan.md | 67 +++++++ .../StructuredConfigReader.swift | 186 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 Plans/Slice 1/StructuredConfigReader Test Plan.md create mode 100644 Sources/DevConfiguration/StructuredConfigReader.swift diff --git a/Plans/Slice 1/StructuredConfigReader Test Plan.md b/Plans/Slice 1/StructuredConfigReader Test Plan.md new file mode 100644 index 0000000..0993b48 --- /dev/null +++ b/Plans/Slice 1/StructuredConfigReader Test Plan.md @@ -0,0 +1,67 @@ +# StructuredConfigReader Test Plan + +Created by Duncan Lewis, 2026-01-07 + +## StructuredConfigReader + +### Initialization +- init stores providers array +- init stores eventBus reference +- init creates ConfigReader with providers + +### Bool Overload +- value(for:) returns provider value when available +- value(for:) returns provider value from highest priority provider +- value(for:) returns fallback when provider throws +- value(for:) returns fallback when key not found +- value(for:) returns fallback on type mismatch + +### [Bool] Array Overload +- value(for:) returns provider array value when available +- value(for:) returns provider value from highest priority provider +- value(for:) returns fallback array when provider throws +- value(for:) returns fallback array when key not found +- value(for:) returns fallback array on type mismatch + +### String Overload +- value(for:) returns provider value when available +- value(for:) returns fallback when provider throws +- value(for:) returns fallback when key not found +- value(for:) returns fallback on type mismatch + +### Int Overload +- value(for:) returns provider value when available +- value(for:) returns fallback when provider throws +- value(for:) returns fallback when key not found +- value(for:) returns fallback on type mismatch + +### Float64 Overload +- value(for:) returns provider value when available +- value(for:) returns fallback when provider throws +- value(for:) returns fallback when key not found +- value(for:) returns fallback on type mismatch + +### [String] Array Overload +- value(for:) returns provider array value when available +- value(for:) returns fallback array when provider throws +- value(for:) returns fallback array when key not found +- value(for:) returns fallback array on type mismatch + +### [Int] Array Overload +- value(for:) returns provider array value when available +- value(for:) returns fallback array when provider throws +- value(for:) returns fallback array when key not found +- value(for:) returns fallback array on type mismatch + +### [Float64] Array Overload +- value(for:) returns provider array value when available +- value(for:) returns fallback array when provider throws +- value(for:) returns fallback array when key not found +- value(for:) returns fallback array on type mismatch + +### Privacy Logic (TODO) +- To be tested after isSecret helper implementation + +### Telemetry (TODO) +- Success telemetry via AccessReporter +- Failure telemetry via VariableResolutionFailedBusEvent \ No newline at end of file diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift new file mode 100644 index 0000000..0b2efc4 --- /dev/null +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -0,0 +1,186 @@ +// +// StructuredConfigReader.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +import Configuration +import DevFoundation + +/// Provides structured access to configuration values queried by a `ConfigVariable`. +/// +/// ## Usage +/// +/// let providers: [any ConfigProvider] = [ +/// EnvironmentVariablesProvider() +/// ] +/// +/// let reader = StructuredConfigReader( +/// providers: providers, +/// eventBus: eventBus +/// ) +/// +/// let darkMode = reader.value(for: .darkMode) +/// +public final class StructuredConfigReader { + /// TODO: document. + public let eventBus: EventBus + + /// TODO: document. + private let reader: ConfigReader + + + /// Creates a new `StructuredConfigReader` with the specified parameters. + /// + /// - Parameters: + /// - providers: The configuration providers, queried in order until a value is found. + /// - eventBus: Event bus for telemetry emission. + public init(providers: [any ConfigProvider], eventBus: EventBus) { + self.eventBus = eventBus + // TODO: Add TelemetryAccessReporter integration + self.reader = ConfigReader(providers: providers) + } +} + + +extension StructuredConfigReader: StructuredConfigReading { + // MARK: - Primitive Types + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get a boolean value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable) -> Bool { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredBool(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get a string value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable) -> String { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredString(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get an integer value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable) -> Int { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredInt(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameter variable: The variable to get a float64 value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable) -> Float64 { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredDouble(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + // MARK: - Array Types + + /// Gets the value for the specified `ConfigVariable<[Bool]>`. + /// + /// - Parameter variable: The variable to get a boolean array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredBoolArray(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + /// Gets the value for the specified `ConfigVariable<[String]>`. + /// + /// - Parameter variable: The variable to get a string array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable<[String]>) -> [String] { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredStringArray(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + /// Gets the value for the specified `ConfigVariable<[Int]>`. + /// + /// - Parameter variable: The variable to get an integer array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable<[Int]>) -> [Int] { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredIntArray(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } + + + /// Gets the value for the specified `ConfigVariable<[Float64]>`. + /// + /// - Parameter variable: The variable to get a float64 array value for. + /// - Returns: The configuration value of the variable, or the fallback if resolution fails. + public func value(for variable: ConfigVariable<[Float64]>) -> [Float64] { + do { + // TODO: Pass isSecret parameter based on variable.privacy + let resolved = try reader.requiredDoubleArray(forKey: variable.key, isSecret: false) + // TODO: TelemetryAccessReporter posts success telemetry automatically + return resolved + } catch { + // TODO: Post VariableResolutionFailedBusEvent + return variable.fallback + } + } +} From 07f8661271e037e98c585a8710b342d46120c8c1 Mon Sep 17 00:00:00 2001 From: dfowj Date: Wed, 7 Jan 2026 14:41:07 -0500 Subject: [PATCH 25/28] StructuredConfigReader uses variable privacy to provide the `isSecret:` parameter --- .../StructuredConfigReader Test Plan.md | 27 ++++++++- Plans/Slice 1/VariablePrivacy Test Plan.md | 15 +++++ .../StructuredConfigReader.swift | 55 ++++++++++++------- .../DevConfiguration/VariablePrivacy.swift | 21 +++++++ 4 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 Plans/Slice 1/VariablePrivacy Test Plan.md diff --git a/Plans/Slice 1/StructuredConfigReader Test Plan.md b/Plans/Slice 1/StructuredConfigReader Test Plan.md index 0993b48..3e1e15a 100644 --- a/Plans/Slice 1/StructuredConfigReader Test Plan.md +++ b/Plans/Slice 1/StructuredConfigReader Test Plan.md @@ -15,6 +15,9 @@ Created by Duncan Lewis, 2026-01-07 - value(for:) returns fallback when provider throws - value(for:) returns fallback when key not found - value(for:) returns fallback on type mismatch +- value(for:) with .auto privacy passes isSecret: false +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### [Bool] Array Overload - value(for:) returns provider array value when available @@ -22,45 +25,63 @@ Created by Duncan Lewis, 2026-01-07 - value(for:) returns fallback array when provider throws - value(for:) returns fallback array when key not found - value(for:) returns fallback array on type mismatch +- value(for:) with .auto privacy passes isSecret: false +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### String Overload - value(for:) returns provider value when available - value(for:) returns fallback when provider throws - value(for:) returns fallback when key not found - value(for:) returns fallback on type mismatch +- value(for:) with .auto privacy passes isSecret: true +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### Int Overload - value(for:) returns provider value when available - value(for:) returns fallback when provider throws - value(for:) returns fallback when key not found - value(for:) returns fallback on type mismatch +- value(for:) with .auto privacy passes isSecret: false +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### Float64 Overload - value(for:) returns provider value when available - value(for:) returns fallback when provider throws - value(for:) returns fallback when key not found - value(for:) returns fallback on type mismatch +- value(for:) with .auto privacy passes isSecret: false +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### [String] Array Overload - value(for:) returns provider array value when available - value(for:) returns fallback array when provider throws - value(for:) returns fallback array when key not found - value(for:) returns fallback array on type mismatch +- value(for:) with .auto privacy passes isSecret: true +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### [Int] Array Overload - value(for:) returns provider array value when available - value(for:) returns fallback array when provider throws - value(for:) returns fallback array when key not found - value(for:) returns fallback array on type mismatch +- value(for:) with .auto privacy passes isSecret: false +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### [Float64] Array Overload - value(for:) returns provider array value when available - value(for:) returns fallback array when provider throws - value(for:) returns fallback array when key not found - value(for:) returns fallback array on type mismatch - -### Privacy Logic (TODO) -- To be tested after isSecret helper implementation +- value(for:) with .auto privacy passes isSecret: false +- value(for:) with .private privacy passes isSecret: true +- value(for:) with .public privacy passes isSecret: false ### Telemetry (TODO) - Success telemetry via AccessReporter diff --git a/Plans/Slice 1/VariablePrivacy Test Plan.md b/Plans/Slice 1/VariablePrivacy Test Plan.md new file mode 100644 index 0000000..718d65d --- /dev/null +++ b/Plans/Slice 1/VariablePrivacy Test Plan.md @@ -0,0 +1,15 @@ +# VariablePrivacy Test Plan + +Created by Duncan Lewis, 2026-01-07 + +## VariablePrivacy + +### isPrivate +- .auto returns false +- .private returns true +- .public returns false + +### isPrivateForSensitiveTypes +- .auto returns true +- .private returns true +- .public returns false diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift index 0b2efc4..1d69d75 100644 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -23,11 +23,12 @@ import DevFoundation /// /// let darkMode = reader.value(for: .darkMode) /// +/// TODO: Revisit top-level documentation public final class StructuredConfigReader { - /// TODO: document. + /// The event bus that telemetry events are posted on. public let eventBus: EventBus - /// TODO: document. + /// The internal configuration reader that is used to resolve configuration values. private let reader: ConfigReader @@ -35,7 +36,7 @@ public final class StructuredConfigReader { /// /// - Parameters: /// - providers: The configuration providers, queried in order until a value is found. - /// - eventBus: Event bus for telemetry emission. + /// - eventBus: The event bus that telemetry events are posted on. public init(providers: [any ConfigProvider], eventBus: EventBus) { self.eventBus = eventBus // TODO: Add TelemetryAccessReporter integration @@ -53,8 +54,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> Bool { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredBool(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredBool( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -70,8 +73,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> String { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredString(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredString( + forKey: variable.key, + isSecret: variable.privacy.isPrivateForSensitiveTypes + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -87,8 +92,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> Int { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredInt(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredInt( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -104,8 +111,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> Float64 { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredDouble(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredDouble( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -123,8 +132,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredBoolArray(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredBoolArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -140,8 +151,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[String]>) -> [String] { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredStringArray(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredStringArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivateForSensitiveTypes + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -157,8 +170,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[Int]>) -> [Int] { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredIntArray(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredIntArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { @@ -174,8 +189,10 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[Float64]>) -> [Float64] { do { - // TODO: Pass isSecret parameter based on variable.privacy - let resolved = try reader.requiredDoubleArray(forKey: variable.key, isSecret: false) + let resolved = try reader.requiredDoubleArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) // TODO: TelemetryAccessReporter posts success telemetry automatically return resolved } catch { diff --git a/Sources/DevConfiguration/VariablePrivacy.swift b/Sources/DevConfiguration/VariablePrivacy.swift index 855910d..b806f54 100644 --- a/Sources/DevConfiguration/VariablePrivacy.swift +++ b/Sources/DevConfiguration/VariablePrivacy.swift @@ -29,3 +29,24 @@ public enum VariablePrivacy { /// telemetry, even if they are strings. case `public` } + + +extension VariablePrivacy { + /// Returns `true` if this setting is explicitly `.private`. + var isPrivate: Bool { + self == .private + } + + + /// Returns `true` if sensitive types (like String) should be treated as private. + /// + /// This is equivalent to `.auto || .private`. + var isPrivateForSensitiveTypes: Bool { + switch self { + case .auto, .private: + return true + case .public: + return false + } + } +} From fed3d4602b96e279e9fab717252cd979a3d496c9 Mon Sep 17 00:00:00 2001 From: dfowj Date: Thu, 8 Jan 2026 10:11:31 -0500 Subject: [PATCH 26/28] Introduces telemetry for access reporting - DidAccessVariableBusEvent: posted when a variable is successfully accessed - DidFailToAccessVariableBusEvent: posted when a variable can't be found (.success(nil)) or if an error is raised when querying providers (.failure(error)) --- .claude/settings.local.json | 3 +- Plans/Slice 1/Bus Events Test Plan.md | 12 ++++ .../StructuredConfigReader Test Plan.md | 6 +- .../TelemetryAccessReporter Test Plan.md | 19 ++++++ .../StructuredConfigReader.swift | 40 +++--------- .../Telemetry/DidAccessVariableBusEvent.swift | 34 ++++++++++ .../DidFailToAccessVariableBusEvent.swift | 29 +++++++++ .../TelemetryAccessReporter.swift | 64 +++++++++++++++++++ 8 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 Plans/Slice 1/Bus Events Test Plan.md create mode 100644 Plans/Slice 1/TelemetryAccessReporter Test Plan.md create mode 100644 Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift create mode 100644 Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift create mode 100644 Sources/DevConfiguration/TelemetryAccessReporter.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 323855c..4417fbd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(swift build:*)" + "Bash(swift build:*)", + "Bash(grep:*)" ] } } diff --git a/Plans/Slice 1/Bus Events Test Plan.md b/Plans/Slice 1/Bus Events Test Plan.md new file mode 100644 index 0000000..e974f48 --- /dev/null +++ b/Plans/Slice 1/Bus Events Test Plan.md @@ -0,0 +1,12 @@ +# Bus Events Test Plan + +Created by Duncan Lewis, 2026-01-07 + +- DidAccessVariableBusEvent + - init + - init stores parameters + +- DidFailToAccessVariableBusEvent + - init + - init stores key parameter + - init stores error parameter diff --git a/Plans/Slice 1/StructuredConfigReader Test Plan.md b/Plans/Slice 1/StructuredConfigReader Test Plan.md index 3e1e15a..c520297 100644 --- a/Plans/Slice 1/StructuredConfigReader Test Plan.md +++ b/Plans/Slice 1/StructuredConfigReader Test Plan.md @@ -83,6 +83,6 @@ Created by Duncan Lewis, 2026-01-07 - value(for:) with .private privacy passes isSecret: true - value(for:) with .public privacy passes isSecret: false -### Telemetry (TODO) -- Success telemetry via AccessReporter -- Failure telemetry via VariableResolutionFailedBusEvent \ No newline at end of file +### Telemetry +- Telemetry is handled by TelemetryAccessReporter (tested separately) +- StructuredConfigReader only needs to verify it creates ConfigReader with TelemetryAccessReporter \ No newline at end of file diff --git a/Plans/Slice 1/TelemetryAccessReporter Test Plan.md b/Plans/Slice 1/TelemetryAccessReporter Test Plan.md new file mode 100644 index 0000000..1ffade3 --- /dev/null +++ b/Plans/Slice 1/TelemetryAccessReporter Test Plan.md @@ -0,0 +1,19 @@ +# TelemetryAccessReporter Test Plan + +Created by Duncan Lewis, 2026-01-07 + +- TelemetryAccessReporter + - init + - init stores parameters + - report(_:) - Success Cases + - report(_:) posts DidAccessVariableBusEvent on successful access + - report(_:) extracts key from event.metadata.key.description + - report(_:) extracts value from event.result.success.content + - report(_:) extracts source from event.providerResults.first.providerName + - report(_:) uses "unknown" source when providerResults is empty + - report(_:) sets usedFallback to false + - report(_:) - Error Cases + - report(_:) posts DidFailToAccessVariableBusEvent when result is failure + - report(_:) extracts error from event.result.failure + - report(_:) posts DidFailToAccessVariableBusEvent when result is success(nil) + - report(_:) uses MissingValueError when result is success(nil) diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift index 1d69d75..d0387c5 100644 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -54,14 +54,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> Bool { do { - let resolved = try reader.requiredBool( + return try reader.requiredBool( forKey: variable.key, isSecret: variable.privacy.isPrivate ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -73,14 +70,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> String { do { - let resolved = try reader.requiredString( + return try reader.requiredString( forKey: variable.key, isSecret: variable.privacy.isPrivateForSensitiveTypes ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -92,14 +86,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> Int { do { - let resolved = try reader.requiredInt( + return try reader.requiredInt( forKey: variable.key, isSecret: variable.privacy.isPrivate ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -111,14 +102,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable) -> Float64 { do { - let resolved = try reader.requiredDouble( + return try reader.requiredDouble( forKey: variable.key, isSecret: variable.privacy.isPrivate ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -132,14 +120,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] { do { - let resolved = try reader.requiredBoolArray( + return try reader.requiredBoolArray( forKey: variable.key, isSecret: variable.privacy.isPrivate ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -151,14 +136,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[String]>) -> [String] { do { - let resolved = try reader.requiredStringArray( + return try reader.requiredStringArray( forKey: variable.key, isSecret: variable.privacy.isPrivateForSensitiveTypes ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -170,14 +152,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[Int]>) -> [Int] { do { - let resolved = try reader.requiredIntArray( + return try reader.requiredIntArray( forKey: variable.key, isSecret: variable.privacy.isPrivate ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } @@ -189,14 +168,11 @@ extension StructuredConfigReader: StructuredConfigReading { /// - Returns: The configuration value of the variable, or the fallback if resolution fails. public func value(for variable: ConfigVariable<[Float64]>) -> [Float64] { do { - let resolved = try reader.requiredDoubleArray( + return try reader.requiredDoubleArray( forKey: variable.key, isSecret: variable.privacy.isPrivate ) - // TODO: TelemetryAccessReporter posts success telemetry automatically - return resolved } catch { - // TODO: Post VariableResolutionFailedBusEvent return variable.fallback } } diff --git a/Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift b/Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift new file mode 100644 index 0000000..fdbce80 --- /dev/null +++ b/Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift @@ -0,0 +1,34 @@ +// +// DidAccessVariableBusEvent.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +import Configuration +import DevFoundation + +/// Posted when a configuration variable is successfully accessed. +public struct DidAccessVariableBusEvent: BusEvent { + /// The configuration key that was accessed. + public let key: String + + /// The resolved configuration value. + public let value: ConfigContent + + /// The name of the provider that supplied the value. + public let source: String + + + /// Creates a new `DidAccessVariableBusEvent` with the specified parameters. + /// + /// - Parameters: + /// - key: The configuration key that was accessed. + /// - value: The resolved configuration value. + /// - source: The name of the provider that supplied the value. + public init(key: String, value: ConfigContent, source: String) { + self.key = key + self.value = value + self.source = source + } +} diff --git a/Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift b/Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift new file mode 100644 index 0000000..4828109 --- /dev/null +++ b/Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift @@ -0,0 +1,29 @@ +// +// DidFailToAccessVariableBusEvent.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +import Configuration +import DevFoundation + +/// Posted when a configuration variable fails to resolve from any provider. +public struct DidFailToAccessVariableBusEvent: BusEvent { + /// The configuration key that failed to resolve. + public let key: String + + /// The error that caused the resolution failure. + public let error: any Error + + + /// Creates a new `DidFailToAccessVariableBusEvent` with the specified parameters. + /// + /// - Parameters: + /// - key: The configuration key that failed to resolve. + /// - error: The error that caused the resolution failure. + public init(key: String, error: any Error) { + self.key = key + self.error = error + } +} diff --git a/Sources/DevConfiguration/TelemetryAccessReporter.swift b/Sources/DevConfiguration/TelemetryAccessReporter.swift new file mode 100644 index 0000000..88219f1 --- /dev/null +++ b/Sources/DevConfiguration/TelemetryAccessReporter.swift @@ -0,0 +1,64 @@ +// +// TelemetryAccessReporter.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +import Configuration +import DevFoundation + +/// An access reporter that posts access events to an event bus. +/// +/// This reporter converts configuration access events into bus events: +/// - Successful accesses post `DidAccessVariableBusEvent` +/// - Failed accesses post `DidFailToAccessVariableBusEvent` +final class TelemetryAccessReporter: AccessReporter, Sendable { + /// The event bus that telemetry events are posted on. + let eventBus: EventBus + + + /// Creates a new `TelemetryAccessReporter` with the specified event bus. + /// + /// - Parameter eventBus: The event bus that telemetry events are posted on. + public init(eventBus: EventBus) { + self.eventBus = eventBus + } + + + func report(_ event: AccessEvent) { + // Handle the result of the configuration access + switch event.result { + case .success(let configValue?): + eventBus.post( + DidAccessVariableBusEvent( + key: event.metadata.key.description, + value: configValue.content, + source: event.providerResults.first?.providerName ?? "unknown" + ) + ) + + case .success(nil): + eventBus.post( + DidFailToAccessVariableBusEvent( + key: event.metadata.key.description, + error: MissingValueError() + ) + ) + + case .failure(let error): + eventBus.post( + DidFailToAccessVariableBusEvent( + key: event.metadata.key.description, + error: error + ) + ) + } + } +} + + +// MARK: - Utility Types + +/// Error indicating a configuration value was expected but not found. +struct MissingValueError: Error {} From d9291d0472a94215ab07e8da7a2cdcd165cbe4d6 Mon Sep 17 00:00:00 2001 From: dfowj Date: Thu, 8 Jan 2026 10:54:04 -0500 Subject: [PATCH 27/28] Refactor StructuredConfigReader initializer - Use the convenience initializer w/ EventBus parameter to get the standard TelemetryAccessReporter - Use the primary initializer to pass an access reporter type directly. --- .../StructuredConfigReader Test Plan.md | 12 ++++--- .../StructuredConfigReader.swift | 32 ++++++++++++++----- .../TelemetryAccessReporter.swift | 6 ++-- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Plans/Slice 1/StructuredConfigReader Test Plan.md b/Plans/Slice 1/StructuredConfigReader Test Plan.md index c520297..82720a9 100644 --- a/Plans/Slice 1/StructuredConfigReader Test Plan.md +++ b/Plans/Slice 1/StructuredConfigReader Test Plan.md @@ -4,10 +4,14 @@ Created by Duncan Lewis, 2026-01-07 ## StructuredConfigReader -### Initialization -- init stores providers array -- init stores eventBus reference -- init creates ConfigReader with providers +### Initialization (with eventBus) +- init(providers:eventBus:) stores accessReporter reference +- init(providers:eventBus:) creates TelemetryAccessReporter with eventBus +- init(providers:eventBus:) creates ConfigReader with providers and accessReporter + +### Initialization (with accessReporter) +- init(providers:accessReporter:) stores accessReporter reference +- init(providers:accessReporter:) creates ConfigReader with providers and accessReporter ### Bool Overload - value(for:) returns provider value when available diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift index d0387c5..9ada1b4 100644 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -25,22 +25,38 @@ import DevFoundation /// /// TODO: Revisit top-level documentation public final class StructuredConfigReader { - /// The event bus that telemetry events are posted on. - public let eventBus: EventBus + /// The access reporter that is used to report configuration access events. + public let accessReporter: any AccessReporter /// The internal configuration reader that is used to resolve configuration values. - private let reader: ConfigReader + let reader: ConfigReader - /// Creates a new `StructuredConfigReader` with the specified parameters. + /// Creates a new `StructuredConfigReader` with the specified providers and the default telemetry access reporter. + /// + /// Use this initializer when you want to use the standard `TelemetryAccessReporter`. /// /// - Parameters: /// - providers: The configuration providers, queried in order until a value is found. /// - eventBus: The event bus that telemetry events are posted on. - public init(providers: [any ConfigProvider], eventBus: EventBus) { - self.eventBus = eventBus - // TODO: Add TelemetryAccessReporter integration - self.reader = ConfigReader(providers: providers) + public convenience init(providers: [any ConfigProvider], eventBus: EventBus) { + self.init( + providers: providers, + accessReporter: TelemetryAccessReporter(eventBus: eventBus) + ) + } + + + /// Creates a new `StructuredConfigReader` with the specified providers and access reporter. + /// + /// Use this initializer when you want to directly control the access reporter used by the config reader. + /// + /// - Parameters: + /// - providers: The configuration providers, queried in order until a value is found. + /// - accessReporter: The access reporter that is used to report configuration access events. + public init(providers: [any ConfigProvider], accessReporter: any AccessReporter) { + self.accessReporter = accessReporter + self.reader = ConfigReader(providers: providers, accessReporter: accessReporter) } } diff --git a/Sources/DevConfiguration/TelemetryAccessReporter.swift b/Sources/DevConfiguration/TelemetryAccessReporter.swift index 88219f1..f7f8efc 100644 --- a/Sources/DevConfiguration/TelemetryAccessReporter.swift +++ b/Sources/DevConfiguration/TelemetryAccessReporter.swift @@ -13,9 +13,9 @@ import DevFoundation /// This reporter converts configuration access events into bus events: /// - Successful accesses post `DidAccessVariableBusEvent` /// - Failed accesses post `DidFailToAccessVariableBusEvent` -final class TelemetryAccessReporter: AccessReporter, Sendable { +public final class TelemetryAccessReporter: AccessReporter, Sendable { /// The event bus that telemetry events are posted on. - let eventBus: EventBus + public let eventBus: EventBus /// Creates a new `TelemetryAccessReporter` with the specified event bus. @@ -26,7 +26,7 @@ final class TelemetryAccessReporter: AccessReporter, Sendable { } - func report(_ event: AccessEvent) { + public func report(_ event: AccessEvent) { // Handle the result of the configuration access switch event.result { case .success(let configValue?): From a49acd47d7a540de5828edb524055f5af57818a1 Mon Sep 17 00:00:00 2001 From: dfowj Date: Thu, 8 Jan 2026 11:33:44 -0500 Subject: [PATCH 28/28] Expand StructuredConfigReader's documentation --- .../StructuredConfigReader.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift index 9ada1b4..be3c021 100644 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -10,20 +10,34 @@ import DevFoundation /// Provides structured access to configuration values queried by a `ConfigVariable`. /// -/// ## Usage +/// A structured config reader is a type-safe wrapper around swift-configuration's `ConfigReader`. It uses +/// `ConfigVariable` instances to provide compile-time type safety and structured access to configuration values. +/// The reader integrates with an access reporter to provide telemetry and observability for all configuration access. /// -/// let providers: [any ConfigProvider] = [ -/// EnvironmentVariablesProvider() -/// ] +/// To use a structured config reader, first define your configuration variables using `ConfigVariable`. Each variable +/// specifies its key, type, fallback value, and privacy level: +/// +/// extension ConfigVariable where Value == Bool { +/// static let darkMode = ConfigVariable( +/// key: "dark_mode", +/// fallback: false, +/// privacy: .auto +/// ) +/// } +/// +/// Then create a reader with your providers and query the variable: /// /// let reader = StructuredConfigReader( -/// providers: providers, +/// providers: [ +/// InMemoryProvider(values: ["dark_mode": "true"]) +/// ], /// eventBus: eventBus /// ) /// -/// let darkMode = reader.value(for: .darkMode) +/// let darkMode = reader.value(for: .darkMode) // true /// -/// TODO: Revisit top-level documentation +/// The reader never throws. If resolution fails, it returns the variable's fallback value and posts a +/// `DidFailToAccessVariableBusEvent` to the event bus. public final class StructuredConfigReader { /// The access reporter that is used to report configuration access events. public let accessReporter: any AccessReporter