From d0797f18d552372880b29b6d028f13a3380370ba Mon Sep 17 00:00:00 2001 From: dfowj Date: Fri, 11 Jul 2025 20:23:59 -0400 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 From 8f334a3b345253a408ab6934cc3767033c5955cd Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 13 Jan 2026 16:39:13 -0500 Subject: [PATCH 29/32] Adjust telemetry events - Rename to DidAccessConfigVariableEvent and DidFailToAccessConfigVariableEvent - Provide the key as `AbsoluteConfigKey` rather than `String` - Provide the value as `ConfigValue` rather than `ConfigContent` --- ...nt.swift => DidAccessConfigVariableEvent.swift} | 12 ++++++------ ...ft => DidFailToAccessConfigVariableEvent.swift} | 10 +++++----- .../DevConfiguration/TelemetryAccessReporter.swift | 14 +++++++------- 3 files changed, 18 insertions(+), 18 deletions(-) rename Sources/DevConfiguration/Telemetry/{DidAccessVariableBusEvent.swift => DidAccessConfigVariableEvent.swift} (66%) rename Sources/DevConfiguration/Telemetry/{DidFailToAccessVariableBusEvent.swift => DidFailToAccessConfigVariableEvent.swift} (65%) diff --git a/Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift b/Sources/DevConfiguration/Telemetry/DidAccessConfigVariableEvent.swift similarity index 66% rename from Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift rename to Sources/DevConfiguration/Telemetry/DidAccessConfigVariableEvent.swift index fdbce80..f5166ff 100644 --- a/Sources/DevConfiguration/Telemetry/DidAccessVariableBusEvent.swift +++ b/Sources/DevConfiguration/Telemetry/DidAccessConfigVariableEvent.swift @@ -1,5 +1,5 @@ // -// DidAccessVariableBusEvent.swift +// DidAccessConfigVariableEvent.swift // DevConfiguration // // Created by Duncan Lewis on 1/7/2026. @@ -9,24 +9,24 @@ import Configuration import DevFoundation /// Posted when a configuration variable is successfully accessed. -public struct DidAccessVariableBusEvent: BusEvent { +public struct DidAccessConfigVariableEvent: BusEvent { /// The configuration key that was accessed. - public let key: String + public let key: AbsoluteConfigKey /// The resolved configuration value. - public let value: ConfigContent + public let value: ConfigValue /// The name of the provider that supplied the value. public let source: String - /// Creates a new `DidAccessVariableBusEvent` with the specified parameters. + /// Creates a new `DidAccessConfigVariableEvent` 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) { + public init(key: AbsoluteConfigKey, value: ConfigValue, source: String) { self.key = key self.value = value self.source = source diff --git a/Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift b/Sources/DevConfiguration/Telemetry/DidFailToAccessConfigVariableEvent.swift similarity index 65% rename from Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift rename to Sources/DevConfiguration/Telemetry/DidFailToAccessConfigVariableEvent.swift index 4828109..d6c4759 100644 --- a/Sources/DevConfiguration/Telemetry/DidFailToAccessVariableBusEvent.swift +++ b/Sources/DevConfiguration/Telemetry/DidFailToAccessConfigVariableEvent.swift @@ -1,5 +1,5 @@ // -// DidFailToAccessVariableBusEvent.swift +// DidFailToAccessConfigVariableEvent.swift // DevConfiguration // // Created by Duncan Lewis on 1/7/2026. @@ -9,20 +9,20 @@ import Configuration import DevFoundation /// Posted when a configuration variable fails to resolve from any provider. -public struct DidFailToAccessVariableBusEvent: BusEvent { +public struct DidFailToAccessConfigVariableEvent: BusEvent { /// The configuration key that failed to resolve. - public let key: String + public let key: AbsoluteConfigKey /// The error that caused the resolution failure. public let error: any Error - /// Creates a new `DidFailToAccessVariableBusEvent` with the specified parameters. + /// Creates a new `DidFailToAccessConfigVariableEvent` 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) { + public init(key: AbsoluteConfigKey, error: any Error) { self.key = key self.error = error } diff --git a/Sources/DevConfiguration/TelemetryAccessReporter.swift b/Sources/DevConfiguration/TelemetryAccessReporter.swift index f7f8efc..f53ae3d 100644 --- a/Sources/DevConfiguration/TelemetryAccessReporter.swift +++ b/Sources/DevConfiguration/TelemetryAccessReporter.swift @@ -31,25 +31,25 @@ public final class TelemetryAccessReporter: AccessReporter, Sendable { switch event.result { case .success(let configValue?): eventBus.post( - DidAccessVariableBusEvent( - key: event.metadata.key.description, - value: configValue.content, + DidAccessConfigVariableEvent( + key: event.metadata.key, + value: configValue, source: event.providerResults.first?.providerName ?? "unknown" ) ) case .success(nil): eventBus.post( - DidFailToAccessVariableBusEvent( - key: event.metadata.key.description, + DidFailToAccessConfigVariableEvent( + key: event.metadata.key, error: MissingValueError() ) ) case .failure(let error): eventBus.post( - DidFailToAccessVariableBusEvent( - key: event.metadata.key.description, + DidFailToAccessConfigVariableEvent( + key: event.metadata.key, error: error ) ) From 750f449ce5c86af4ff39652f2a7f072a50cd328d Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 13 Jan 2026 16:48:25 -0500 Subject: [PATCH 30/32] Introduce ConfigVariable subscript access functions to StructuredConfigReading --- .../StructuredConfigReading Test Plan.md | 37 +++++- .../StructuredConfigReader.swift | 2 +- .../StructuredConfigReading.swift | 120 ++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/Plans/Slice 1/StructuredConfigReading Test Plan.md b/Plans/Slice 1/StructuredConfigReading Test Plan.md index eafe80d..e23d0f3 100644 --- a/Plans/Slice 1/StructuredConfigReading Test Plan.md +++ b/Plans/Slice 1/StructuredConfigReading Test Plan.md @@ -4,10 +4,11 @@ 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. +The `StructuredConfigReading` protocol defines the interface for configuration reading. It does not implement the core `value(for:)` methods (tested via StructuredConfigReader), but it provides default subscript implementations via a protocol extension that can be tested independently. -All 8 method overloads will be tested as part of the StructuredConfigReader test suite: +### Protocol Requirements (tested via StructuredConfigReader) + +The 8 `value(for:)` method overloads will be tested through `StructuredConfigReader`: - `value(for: ConfigVariable) -> Bool` - `value(for: ConfigVariable) -> String` - `value(for: ConfigVariable) -> Int` @@ -16,3 +17,33 @@ All 8 method overloads will be tested as part of the StructuredConfigReader test - `value(for: ConfigVariable<[String]>) -> [String]` - `value(for: ConfigVariable<[Int]>) -> [Int]` - `value(for: ConfigVariable<[Float64]>) -> [Float64]` + +### Protocol Extension (testable separately) + +The protocol extension provides default subscript implementations that delegate to `value(for:)`. These can be tested independently using a mock conforming type. + +## StructuredConfigReading Extension Tests + +### Subscript Access - Bool +- subscript(variable:) delegates to value(for:) for Bool + +### Subscript Access - String +- subscript(variable:) delegates to value(for:) for String + +### Subscript Access - Int +- subscript(variable:) delegates to value(for:) for Int + +### Subscript Access - Float64 +- subscript(variable:) delegates to value(for:) for Float64 + +### Subscript Access - [Bool] +- subscript(variable:) delegates to value(for:) for [Bool] + +### Subscript Access - [String] +- subscript(variable:) delegates to value(for:) for [String] + +### Subscript Access - [Int] +- subscript(variable:) delegates to value(for:) for [Int] + +### Subscript Access - [Float64] +- subscript(variable:) delegates to value(for:) for [Float64] diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift index be3c021..eb6b0a3 100644 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -34,7 +34,7 @@ import DevFoundation /// eventBus: eventBus /// ) /// -/// let darkMode = reader.value(for: .darkMode) // true +/// let darkMode = reader[.darkMode] // true /// /// The reader never throws. If resolution fails, it returns the variable's fallback value and posts a /// `DidFailToAccessVariableBusEvent` to the event bus. diff --git a/Sources/DevConfiguration/StructuredConfigReading.swift b/Sources/DevConfiguration/StructuredConfigReading.swift index 8be1b58..39f4be9 100644 --- a/Sources/DevConfiguration/StructuredConfigReading.swift +++ b/Sources/DevConfiguration/StructuredConfigReading.swift @@ -66,4 +66,124 @@ public protocol StructuredConfigReading { /// - 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] + + + // MARK: - Subscript Access + + /// 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. + subscript(variable: ConfigVariable) -> Bool { get } + + /// 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. + subscript(variable: ConfigVariable) -> String { get } + + /// 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. + subscript(variable: ConfigVariable) -> Int { get } + + /// 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. + subscript(variable: ConfigVariable) -> Float64 { get } + + /// 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. + subscript(variable: ConfigVariable<[Bool]>) -> [Bool] { get } + + /// 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. + subscript(variable: ConfigVariable<[String]>) -> [String] { get } + + /// 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. + subscript(variable: ConfigVariable<[Int]>) -> [Int] { get } + + /// 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. + subscript(variable: ConfigVariable<[Float64]>) -> [Float64] { get } +} + + +extension StructuredConfigReading { + // MARK: - Default Subscript Implementations + + /// 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 subscript(variable: ConfigVariable) -> Bool { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable) -> String { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable) -> Int { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable) -> Float64 { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable<[Bool]>) -> [Bool] { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable<[String]>) -> [String] { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable<[Int]>) -> [Int] { + value(for: variable) + } + + /// 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 subscript(variable: ConfigVariable<[Float64]>) -> [Float64] { + value(for: variable) + } } From 28227ba5683d29fa07f5b1b1947b6717051b90a5 Mon Sep 17 00:00:00 2001 From: dfowj Date: Tue, 13 Jan 2026 16:53:04 -0500 Subject: [PATCH 31/32] Rename ConfigVariable.fallback to ConfigVariable.defaultValue --- Sources/DevConfiguration/ConfigVariable.swift | 24 ++++----- .../StructuredConfigReader.swift | 40 +++++++------- .../StructuredConfigReading.swift | 52 +++++++++---------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Sources/DevConfiguration/ConfigVariable.swift b/Sources/DevConfiguration/ConfigVariable.swift index 8e9b7d2..870d254 100644 --- a/Sources/DevConfiguration/ConfigVariable.swift +++ b/Sources/DevConfiguration/ConfigVariable.swift @@ -7,9 +7,9 @@ import Configuration -/// A type-safe variable definition with a fallback value. +/// A type-safe variable definition with a default value. /// -/// `ConfigVariable` encapsulates a configuration key and its fallback value, +/// `ConfigVariable` encapsulates a configuration key and its default value, /// providing compile-time type safety for configuration access. /// /// ## Usage @@ -20,7 +20,7 @@ import Configuration /// extension ConfigVariable where Value == Bool { /// static let darkMode = ConfigVariable( /// key: "feature.darkMode", -/// fallback: false +/// defaultValue: false /// ) /// } /// ``` @@ -28,14 +28,14 @@ import Configuration /// Access values through a `StructuredConfigReading` instance: /// /// ```swift -/// let darkMode = reader.value(for: .darkMode) +/// let darkMode = reader[.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 + /// The default value returned when the variable cannot be resolved. + public let defaultValue: Value /// Whether this value should be treated as a secret. public let privacy: VariablePrivacy @@ -47,10 +47,10 @@ 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. + /// - defaultValue: The default value to use when variable resolution fails. /// - 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) + public init(key: String, defaultValue: Value, privacy: VariablePrivacy = .auto) { + self.init(key: ConfigKey(key), defaultValue: defaultValue, privacy: privacy) } @@ -60,11 +60,11 @@ public struct ConfigVariable { /// /// - Parameters: /// - key: The configuration key. - /// - fallback: The fallback value to use when variable resolution fails. + /// - defaultValue: The default value to use when variable resolution fails. /// - privacy: The privacy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, fallback: Value, privacy: VariablePrivacy = .auto) { + public init(key: ConfigKey, defaultValue: Value, privacy: VariablePrivacy = .auto) { self.key = key - self.fallback = fallback + self.defaultValue = defaultValue self.privacy = privacy } } diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift index eb6b0a3..e50d094 100644 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -15,12 +15,12 @@ import DevFoundation /// The reader integrates with an access reporter to provide telemetry and observability for all configuration access. /// /// To use a structured config reader, first define your configuration variables using `ConfigVariable`. Each variable -/// specifies its key, type, fallback value, and privacy level: +/// specifies its key, type, default value, and privacy level: /// /// extension ConfigVariable where Value == Bool { /// static let darkMode = ConfigVariable( /// key: "dark_mode", -/// fallback: false, +/// defaultValue: false, /// privacy: .auto /// ) /// } @@ -36,8 +36,8 @@ import DevFoundation /// /// let darkMode = reader[.darkMode] // true /// -/// The reader never throws. If resolution fails, it returns the variable's fallback value and posts a -/// `DidFailToAccessVariableBusEvent` to the event bus. +/// The reader never throws. If resolution fails, it returns the variable's default value and posts a +/// `DidFailToAccessConfigVariableEvent` to the event bus. public final class StructuredConfigReader { /// The access reporter that is used to report configuration access events. public let accessReporter: any AccessReporter @@ -81,7 +81,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable) -> Bool { do { return try reader.requiredBool( @@ -89,7 +89,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivate ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -97,7 +97,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable) -> String { do { return try reader.requiredString( @@ -105,7 +105,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivateForSensitiveTypes ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -113,7 +113,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable) -> Int { do { return try reader.requiredInt( @@ -121,7 +121,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivate ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -129,7 +129,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable) -> Float64 { do { return try reader.requiredDouble( @@ -137,7 +137,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivate ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -147,7 +147,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] { do { return try reader.requiredBoolArray( @@ -155,7 +155,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivate ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -163,7 +163,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable<[String]>) -> [String] { do { return try reader.requiredStringArray( @@ -171,7 +171,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivateForSensitiveTypes ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -179,7 +179,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable<[Int]>) -> [Int] { do { return try reader.requiredIntArray( @@ -187,7 +187,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivate ) } catch { - return variable.fallback + return variable.defaultValue } } @@ -195,7 +195,7 @@ extension StructuredConfigReader: StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public func value(for variable: ConfigVariable<[Float64]>) -> [Float64] { do { return try reader.requiredDoubleArray( @@ -203,7 +203,7 @@ extension StructuredConfigReader: StructuredConfigReading { isSecret: variable.privacy.isPrivate ) } catch { - return variable.fallback + return variable.defaultValue } } } diff --git a/Sources/DevConfiguration/StructuredConfigReading.swift b/Sources/DevConfiguration/StructuredConfigReading.swift index 39f4be9..9333ed5 100644 --- a/Sources/DevConfiguration/StructuredConfigReading.swift +++ b/Sources/DevConfiguration/StructuredConfigReading.swift @@ -9,35 +9,35 @@ /// /// This protocol defines the contract for resolving configuration variables /// with compile-time type safety. Implementations handle provider lookups, -/// error handling, and fallback values automatically. +/// error handling, and default values automatically. /// /// Values are always returned (never nil or thrown) - if resolution fails, -/// the variable's fallback value is used. +/// the variable's default 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. + /// - Returns: The configuration value of the variable, or the default value 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. + /// - Returns: The configuration value of the variable, or the default value 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. + /// - Returns: The configuration value of the variable, or the default value 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. func value(for variable: ConfigVariable) -> Float64 @@ -46,25 +46,25 @@ public protocol StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value 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. + /// - Returns: The configuration value of the variable, or the default value 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. + /// - Returns: The configuration value of the variable, or the default value 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. func value(for variable: ConfigVariable<[Float64]>) -> [Float64] @@ -73,49 +73,49 @@ public protocol StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable) -> Bool { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable) -> String { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable) -> Int { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable) -> Float64 { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable<[Bool]>) -> [Bool] { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable<[String]>) -> [String] { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable<[Int]>) -> [Int] { get } /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. subscript(variable: ConfigVariable<[Float64]>) -> [Float64] { get } } @@ -126,7 +126,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable) -> Bool { value(for: variable) } @@ -134,7 +134,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable) -> String { value(for: variable) } @@ -142,7 +142,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable) -> Int { value(for: variable) } @@ -150,7 +150,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable) -> Float64 { value(for: variable) } @@ -158,7 +158,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable<[Bool]>) -> [Bool] { value(for: variable) } @@ -166,7 +166,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable<[String]>) -> [String] { value(for: variable) } @@ -174,7 +174,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable<[Int]>) -> [Int] { value(for: variable) } @@ -182,7 +182,7 @@ extension StructuredConfigReading { /// 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. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. public subscript(variable: ConfigVariable<[Float64]>) -> [Float64] { value(for: variable) } From 76c228e8a6446a256e8f74b62f97087fa1d47ea1 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Mon, 16 Feb 2026 20:33:39 -0500 Subject: [PATCH 32/32] Add fetch and watch variants, do a bunch of renames, etc. --- Documentation/MVVMForSwiftUI.md | 171 +++ Documentation/MVVMForSwiftUIBackground.md | 102 ++ Documentation/TestMocks.md | 204 +++- Documentation/TestingGuidelines.md | 445 ++++++-- Package.resolved | 6 +- Plans/Architecture Plan.md | 6 +- Plans/Implementation Plan.md | 6 +- Plans/Slice 1 - Detailed Plan.md | 633 ---------- Plans/Slice 1/Bus Events Test Plan.md | 12 - Plans/Slice 1/ConfigVariable Test Plan.md | 14 - Plans/Slice 1/Slice 1 - Detailed Plan.md | 633 ---------- .../StructuredConfigReader Test Plan.md | 92 -- .../StructuredConfigReading Test Plan.md | 49 - .../TelemetryAccessReporter Test Plan.md | 19 - Plans/Slice 1/VariablePrivacy Test Plan.md | 15 - Scripts/install-git-hooks | 33 - .../ConfigVariableAccessFailedEvent.swift} | 8 +- .../ConfigVariableAccessSucceededEvent.swift} | 16 +- .../EventBusAccessReporter.swift} | 19 +- .../Core/ConfigValueReadable.swift | 574 ++++++++++ .../{ => Core}/ConfigVariable.swift | 38 +- .../Core/ConfigVariableReader.swift | 289 +++++ .../Core/VariableSecrecy.swift | 52 + .../DevConfiguration/DevConfiguration.swift | 16 - .../Documentation.docc/Info.plist | 8 + .../StructuredConfigReader.swift | 209 ---- .../StructuredConfigReading.swift | 189 --- .../DevConfiguration/VariablePrivacy.swift | 52 - .../DevConfigurationTests.swift | 20 - .../Testing Support/MockError.swift | 10 + ...ndomValueGenerating+DevConfiguration.swift | 126 ++ ...ConfigVariableAccessFailedEventTests.swift | 32 + ...figVariableAccessSucceededEventTests.swift | 34 + .../EventBusAccessReporterTests.swift | 140 +++ .../Core/ConfigVariable+SecrecyTests.swift | 44 + .../Core/ConfigVariableReaderTests.swift | 1014 +++++++++++++++++ .../Unit Tests/Core/ConfigVariableTests.swift | 54 + 37 files changed, 3258 insertions(+), 2126 deletions(-) create mode 100644 Documentation/MVVMForSwiftUI.md create mode 100644 Documentation/MVVMForSwiftUIBackground.md delete mode 100644 Plans/Slice 1 - Detailed Plan.md delete mode 100644 Plans/Slice 1/Bus Events Test Plan.md delete mode 100644 Plans/Slice 1/ConfigVariable Test Plan.md delete mode 100644 Plans/Slice 1/Slice 1 - Detailed Plan.md delete mode 100644 Plans/Slice 1/StructuredConfigReader Test Plan.md delete mode 100644 Plans/Slice 1/StructuredConfigReading Test Plan.md delete mode 100644 Plans/Slice 1/TelemetryAccessReporter Test Plan.md delete mode 100644 Plans/Slice 1/VariablePrivacy Test Plan.md rename Sources/DevConfiguration/{Telemetry/DidFailToAccessConfigVariableEvent.swift => Access Reporting/ConfigVariableAccessFailedEvent.swift} (67%) rename Sources/DevConfiguration/{Telemetry/DidAccessConfigVariableEvent.swift => Access Reporting/ConfigVariableAccessSucceededEvent.swift} (51%) rename Sources/DevConfiguration/{TelemetryAccessReporter.swift => Access Reporting/EventBusAccessReporter.swift} (71%) create mode 100644 Sources/DevConfiguration/Core/ConfigValueReadable.swift rename Sources/DevConfiguration/{ => Core}/ConfigVariable.swift (75%) create mode 100644 Sources/DevConfiguration/Core/ConfigVariableReader.swift create mode 100644 Sources/DevConfiguration/Core/VariableSecrecy.swift delete mode 100644 Sources/DevConfiguration/DevConfiguration.swift create mode 100644 Sources/DevConfiguration/Documentation.docc/Info.plist delete mode 100644 Sources/DevConfiguration/StructuredConfigReader.swift delete mode 100644 Sources/DevConfiguration/StructuredConfigReading.swift delete mode 100644 Sources/DevConfiguration/VariablePrivacy.swift delete mode 100644 Tests/DevConfigurationTests/DevConfigurationTests.swift create mode 100644 Tests/DevConfigurationTests/Testing Support/MockError.swift create mode 100644 Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessFailedEventTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessSucceededEventTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Access Reporting/EventBusAccessReporterTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift diff --git a/Documentation/MVVMForSwiftUI.md b/Documentation/MVVMForSwiftUI.md new file mode 100644 index 0000000..83116d0 --- /dev/null +++ b/Documentation/MVVMForSwiftUI.md @@ -0,0 +1,171 @@ +# MVVM for SwiftUI + +This document defines the MVVM (model-view-view model) architecture pattern we use to write +SwiftUI code in the DevConfiguration codebase. For more background on this MVVM pattern, see +[MVVMForSwiftUIBackground.md](MVVMForSwiftUIBackground.md). + + +## Overview + +Our SwiftUI architecture is composed of thrre related types: a \*View, a \*ViewModeling, a +and \*ViewModel. Screens in the app will often have each of these three types, though it +depends on the screen’s complexity and requirements. Additional types for dependency +injection are also common. + +The following sections will demonstrate the types we would create for a "ItemList" screen: + + +### ItemListViewModeling + +We begin by defining a protocol that describes the minimal interface for the view to perform its +function. In this case, we want the view to display a list of items. We use the `Observable` +protocol to allow the view to react to changes in the view model. + + @MainActor + protocol ItemListViewModeling: Observable { + var items: [ListItem] { get } + func addRow() + } + + +### ItemListView + +Next we define the view, which has no business logic and simply reflects the state of its single +property, `viewModel`. The view uses a generic parameter named `ViewModel` that conforms to the +`ItemListViewModeling` protocol. The `viewModel` property is marked as `@State` so that the view +can react to changes in the view model. + + struct ItemListView: View where ViewModel: ItemListViewModeling { + @State var viewModel: ViewModel + + var body: some View { + List { + ForEach(viewModel.items) { item in + Text(item.name) + } + } + } + } + + +### ItemListViewModel + +Finally, we create a concrete representation of our view model by implementing the +`ItemListViewModeling` protocol. This is the type that will be used by the view to display the +list of items. + +**Note**: We must declare our view model as `@Observable` to enable Swift’s property observation +mechanism. Protocols may declare `Observable` conformance as a convenience for consuming code, but +it does not confer any special behavior on conforming type itself. + + @Observable + final class ItemListViewModel: ItemListViewModeling { + var items: [ListItem] + } + + +### ItemListViewModelDependencyProviding/Provider + +If the view model requires dependencies to perform its function, we follow the guidance in the +[Dependency Injection](DependencyInjection.md) guide. In this case, the view model requires a data +source to fetch the list of items. The Dependencies Struct pattern would be appropriate for this +case. + + @Observable + final class ItemListViewModel: ItemListViewModeling { + struct Dependencies { + let itemFetcher: any ItemFetching + } + + var items: [ListItem] + + init(dependencies: Dependencies) { + self.items = dependencies.itemFetcher.fetchItems() + } + } + + +### Putting It All Together + +Typically, a parent view is responsible for creating the child view, using the parent view model to +create the child view model. The parent view model creates the child view model by instantiating it +with a dependency provider. + +We see this below with the `HomeView`/`HomeViewModel` pair. + + @MainActor + protocol HomeViewModeling: Observable { + associatedtype SomeItemListViewModel: ItemListViewModeling + func makeItemListViewModel() -> SomeItemListViewModel + } + + + @Observable + struct HomeViewModel: HomeViewModeling { + func makeItemListViewModel() -> ItemListViewModel { + return ItemListViewModel( + dependencies: .init( + itemFetcher: StandardItemFetcher() + ) + ) + } + } + + + struct HomeView: View where ViewModel: HomeViewModeling { + @State var viewModel: ViewModel + + var body: some View { + ItemListView(viewModel: viewModel.makeItemListViewModel()) + } + } + + +### Making State Changes + +View models are responsible for managing changes to the application’s state. Typically, they do so +by providing parameterless functions that the view can call to trigger state changes. + + @Observable + final class ItemListViewModel: ItemListViewModeling { + var items: [ListItem] + + + init(items: [ListItem]) { + self.items = items + } + + + func addRow() { + items.append(ListItem(name: "New Item")) + } + } + +The view can then call the `addRow` function to add a new row to the list. + + struct ItemListView: View where ViewModel: ItemListViewModeling { + @State var viewModel: ViewModel + + + var body: some View { + List { + ForEach(viewModel.items) { item in + Text(item.name) + } + } + .toolbar { + Button { + viewModel.addRow() + } label: { + Image(systemName: "plus") + } + } + } + } + + +## Misc. Instructions for AI + +### Import Requirements + + - Always include `import SwiftUI` in view files. diff --git a/Documentation/MVVMForSwiftUIBackground.md b/Documentation/MVVMForSwiftUIBackground.md new file mode 100644 index 0000000..f4ba4d0 --- /dev/null +++ b/Documentation/MVVMForSwiftUIBackground.md @@ -0,0 +1,102 @@ +# MVVM for SwiftUI + +SwiftUI is a great way to build great looking apps for Apple platforms. Unfortunately, Apple’s +example code doesn’t demonstrate good architecture. In this doc, we’ll outline some of our thoughts +on how to use the MVVM (model-view-view model) architecture in SwiftUI applications. + + +## Architectural Goals + +Before we begin, let’s discuss our goals. We’re interested in writing code that is: + + - *Correct*: the code should implement requirements with as few bugs as possible. + - *Robust*: the code should handle unexpected situations gracefully. + - *Adaptable*: within reason, we should be able to adapt our code for use in situations that we + didn’t know about when we originally wrote it. + - *Maintainable*: code is more often read than written, and thus should be easy to understand and + reason about. It should be relatively easy to make a change without introducing bugs. + - *Testable*: building correct, adaptable, and maintainable code is very difficult without a large + suite of automated tests. Thus we need to architect our code so that it is easy to test using + standard unit and integration testing techniques. + - *Portable*: while we primarily write apps that target iOS, that may not always be the case. We + may support other Apple platforms—tvOS, watchOS, or macOS—or we may ship different kinds of + apps, e.g., iMessage extensions or command-line tools for testing. Our code should be portable + enough to enable these use cases. + - *Efficient*: our code should always aim to efficiently use a device’s resources. We should avoid + using any more CPU, memory, battery, or network data allocations than is needed. + +Any architectural pattern that we use should support these goals. The portability goal in particular +is one that the iOS community doesn’t often emphasize, but that we believe is very important. When +reasoning about MVVM, you will make better architectural decisions if you don’t make assumptions +about the types of UIs your code will support. Keep that in mind as you read this document. + +## A Quick Overview of MVVM + +It’s helpful to do a quick refresher of MVVM to make sure we all understand the roles of different +components in the architecture. + + - *Models* represent your application’s concepts and operations. That is, they “model” the problem + domain of your app, independent of any particular UI representation. The model layer is where + the real work of your app happens. Because it’s independent of the UI, it should be largely + reusable and portable. For example, the business logic involved in performing a restaurant + search, fetching a menu, and adding a menu item to a cart should work the same, regardless of + what your UI looks like. + - *Views* represent the user interface of your application. While this obviously includes + traditional parts of your UI, like buttons and screens, it also includes non-visual UI, like + speech and textual interfaces. It could go so far as to include a scripting interface since that + is just a way to interface with your application. The big idea here is that the views present + information to users and receive user input. They provide an interface for users to interact + with your model layer and reflect application state. + - *View Models* mediate between models and views. They have properties for view state, which the + view uses to render its information. When a view model receives a change from the model layer, + it translates the change into a view state change, which triggers a re-rendering of the view. + View models also have functions that are used by the view to trigger business logic in response + to user input. + +## SwiftUI and MVVM + +The core challenge with SwiftUI is that views are very difficult to test. They are structs that +produce a view body using a declarative DSL, making them hard to test and unsuitable for complex +logic. As such, we must make logic inside of views simple, with minimal branching, data +transformations, etc. View models should be the central location for view logic. Models in MVVM are +no different than models in (correct) MVC: they should contain all UI-agnostic logic. + +In SwiftUI, a view has a reference to one or more view models, which in turn have references to one +or more models. View models do not have a reference to their views, and models do not have a +reference to their view models. To propagate information from a model to a view model, models can +either return values from properties or functions (manual propagation) or publish them via Combine +Publishers, Async Streams, Notifications, or Delegate protocols (automatic propagation). View +models propagate information to views almost entirely through published properties. View models +should very rarely have functions exposed to views that return a value. + +Views use generic parameters to specify their view model types, avoiding existential types for +better performance and type safety. Child view models within view modeling protocols are represented +as associated types rather than protocol existentials. + +In our SwiftUI app architecture, each view is composed of three related types: a \*View, a +\*ViewModeling, and a \*ViewModel. The \*ViewModel type will often have either a nested Dependencies +struct or nested DependencyProviding and DependencyProvider types. See our +[Dependency Injection](DependencyInjection.md) guide for more details. + + - *View*: The view code. The view has no business logic and simply reflects the state of its view + model, which is a generic parameter named `ViewModel` that conforms to the \*ViewModeling protocol. + - *ViewModeling*: A protocol that contains the minimal interface for the \*View to perform its + function. Each contains properties that are used to store the view’s state, as well as enums + that govern the view’s modals and alerts. + + All properties of a view are either simple data types with no behavior or associated types that + represent child view models. Any child view models are defined as associated types (named + `Some*ViewModel`) that conform to their corresponding `*ViewModeling` protocol, rather than + using existential types. + + Functions defined on the view model typically take no parameters. These functions either perform + some action using the view model’s underlying model and/or update a state variable to, e.g., + show a modal or an alert. + + Where possible, these protocols and their supporting types are modeled such that impossible + states do not compile. That is, if a button should only appear when a view is in a particular + state, that action function for that button should only be available when the view is in that + state. This can be achieved using algebraic data types (enums with associated values). + - *ViewModel*: A concrete implementation of the \*ViewModeling protocol. Often, these view models + have delegate protocols that are used to communicate changes between parent and child views + models. diff --git a/Documentation/TestMocks.md b/Documentation/TestMocks.md index 9d79a46..e55e9ba 100644 --- a/Documentation/TestMocks.md +++ b/Documentation/TestMocks.md @@ -1,16 +1,17 @@ # Test Mock Documentation -This document outlines the patterns and conventions for writing test mocks in Swift. +This document outlines the patterns and conventions for writing test mocks in the DevConfiguration +codebase. -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. +Its helpful to read the [Dependency Injection guide](Documentation/DependencyInjection.md) before +reading this guide, as it introduces core principles for how we think about dependency injection. ## Overview -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. +The codebase uses 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 @@ -156,7 +157,7 @@ For testing error scenarios, use simple enum-based errors: ├── [PackageName]Tests/ # Package-specific tests │ ├── Unit Tests/ │ │ └── [ModuleName] # Feature-specific tests - │ │ └── [ProtocolName]Tests.swift + │ │ └── [ProtocolName]Tests.swift │ └── Testing Support/ # Mock objects and test utilities │ ├── Mock[ProtocolName].swift # Mock implementations │ ├── MockError.swift # Test-specific error types @@ -164,12 +165,12 @@ For testing error scenarios, use simple enum-based errors: #### File Placement Guidelines: -- **Unit Test files**: Place in `Tests/[PackageName]/Unit Tests/`, matching the path of the source file in +- **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. +- **Sharing mocks**: Do not share mocks between Packages. If the same Mock is needed across + multiple packages, duplicate it. ### Naming Conventions @@ -206,15 +207,194 @@ 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 + // Initialize to non-nil to avoid crashes in tests that don't configure this stub nonisolated(unsafe) var installTelemetryBusEventHandlersStub: Stub< TelemetryBusEventObserver, Void > = .init() } +**CRITICAL - Stubs Accessed by Internal Async Tasks:** -### 3. Protocol Imports with @testable +When testing types that spawn internal `Task`s during initialization, ALL stubs accessed by those +tasks must be initialized in test setup, even if not directly tested. Failure to initialize stubs +will cause crashes when the internal task tries to access them. + +This applies to ANY mock (Observable or not) whose stubs are accessed by background tasks. + +#### Why This is Required: + +When a type spawns internal `Task`s during initialization, those tasks may access properties or +methods on injected dependencies. If those dependencies are mocks with force-unwrapped stubs, and +the stubs haven't been initialized, the app will crash when the internal task tries to access them. + +**Example crash scenario:** + + // Any mock type (Observable or not) + @MainActor + final class MockUser: User { + nonisolated(unsafe) var allSavesMembershipStub: Stub! + var allSavesMembership: Membership { + allSavesMembershipStub(id) // ❌ Crashes if stub is nil + } + } + + // Type spawns internal task during init + init(user: any User) { + self.user = user + Task { + // This accesses user.allSavesMembership, triggering the stub + let membership = user.allSavesMembership + } + } + + // Test doesn't initialize the stub + @Test + func myTest() { + let mockUser = MockUser() // ❌ allSavesMembershipStub is nil + let viewModel = MyViewModel(user: mockUser) // ❌ Crashes in internal task + } + +#### Solution Pattern: Initialize in Test Setup + +Initialize all stubs that internal tasks will access: + + @MainActor + struct MyViewModelTests: RandomValueGenerating { + var mockUser = MockUser() + + init() { + // CRITICAL: Initialize ALL stubs that the type's internal tasks will access + // Even if this particular test doesn't verify these stubs, they must be non-nil + mockUser.fetchAllSavesIfNeededStub = ThrowingStub(defaultError: nil) + mockUser.allSavesMembershipStub = Stub(defaultReturnValue: .unknown) + } + + @Test + mutating func myTest() async throws { + // Create type that spawns internal tasks using mockUser + let viewModel = MyViewModel(user: mockUser, ...) + // ... test logic ... + } + } + +#### How to Identify Required Stubs: + +1. Look for `Task { }` blocks in the type's initializer +2. Trace what properties/methods those tasks access on dependencies +3. Initialize stubs for all accessed properties/methods in test `init()` +4. Run tests - crashes will identify any missed stubs + +### 3. Prologue and Epilogue Closures for Execution Control + +For mock functions that need precise control over execution timing, use optional prologue and +epilogue closures that execute before and after the stub. + +#### Prologue Pattern + +Prologues execute before the stub is called: + + final class MockAnalyticsClient: AnalyticsClient { + nonisolated(unsafe) var sendEventsPrologue: (() async throws -> Void)? + nonisolated(unsafe) var sendEventsStub: ThrowingStub< + [Event], + Response, + any Error + >! + + + func sendEvents(_ events: [Event]) async throws -> Response { + try await sendEventsPrologue?() + return try sendEventsStub(events) + } + } + +#### Epilogue Pattern + +Epilogues execute after the stub is called. Run the epilogue in a `Task` within a `defer` block: + + final class MockTelemetryEventLogger: TelemetryEventLogging { + nonisolated(unsafe) var logEventStub: Stub! + nonisolated(unsafe) var logEventEpilogue: (() async throws -> Void)? + + + func logEvent(_ event: some TelemetryEvent) { + defer { + if let epilogue = logEventEpilogue { + Task { try? await epilogue() } + } + } + logEventStub(event) + } + } + +#### Use Cases + +**Prologues** (execute before stub): + + - **Block before processing**: Delay the mock from executing its core behavior + - **Test pre-call state**: Verify conditions before the mock operation begins + - **Insert delays**: Add artificial timing before processing + - **Coordinate setup**: Ensure test conditions are ready before mock executes + +**Epilogues** (execute after stub): + + - **Signal completion**: Notify tests when the mock operation has finished + - **Test post-call state**: Verify conditions after the stub executes but before returning + - **Coordinate with background work**: Wait for fire-and-forget operations to complete + +#### Example: Blocking with AsyncStream + + let (signalStream, signaler) = AsyncStream.makeStream() + mockClient.sendEventsPrologue = { + await signalStream.first(where: { _ in true }) + } + mockClient.sendEventsStub = ThrowingStub(defaultResult: .success(.init())) + + // Start operation that calls the mock + instance.performAction() + + // Verify intermediate state while mock is blocked + await #expect(instance.isProcessing == true) + + // Signal completion to unblock + signaler.yield() + +#### Example: Signaling Completion with Epilogue + + let telemetryLogger = MockTelemetryEventLogger() + telemetryLogger.logEventStub = Stub() + + let (signalStream, signaler) = AsyncStream.makeStream() + telemetryLogger.logEventEpilogue = { + signaler.yield() + } + + // Call function that triggers the mock (e.g., via event bus handler) + eventBus.post(SomeEvent()) + + // Wait for mock to complete + await signalStream.first { _ in true } + + // Verify the mock was called + #expect(telemetryLogger.logEventStub.calls.count == 1) + +#### Example: Adding Delays + + mockClient.sendEventsPrologue = { + try await Task.sleep(for: .milliseconds(100)) + } + +#### Benefits + + - Separates timing control (prologue/epilogue) from return values/errors (stub) + - Enables testing at different execution phases (before/after stub) + - More precise than arbitrary `Task.sleep()` delays in tests + - Eliminates race conditions from timing-based coordination + - Optional - tests can ignore if timing control isn't needed + + +### 4. Protocol Imports with @testable Import protocols under test with `@testable` when accessing internal details: diff --git a/Documentation/TestingGuidelines.md b/Documentation/TestingGuidelines.md index 81c5877..a9a4484 100644 --- a/Documentation/TestingGuidelines.md +++ b/Documentation/TestingGuidelines.md @@ -1,17 +1,17 @@ # Testing Guidelines for Claude Code -This file provides specific guidance for Claude Code when creating, updating, and maintaining -Swift tests. +This file provides specific guidance for Claude Code when creating, updating, and maintaining +tests in this repository. ## Swift Testing Framework -**IMPORTANT**: This project uses **Swift Testing framework**, NOT XCTest. Do not apply XCTest +**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()` 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 @@ -19,9 +19,9 @@ patterns or conventions. ### 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 + - **Descriptive names**: Use clear, descriptive names like `initialLoadingState()` instead of `testInitialLoadingState()` - - **Protocol-specific naming**: For protocols with concrete implementations, name tests after + - **Protocol-specific naming**: For protocols with concrete implementations, name tests after the concrete type (e.g., `StandardAuthenticationRemoteNotificationHandlerTests`) ### Unit Test Structure Conventions @@ -29,9 +29,12 @@ patterns or 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". + - **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. + - **IMPORTANT**: Each test should have exactly ONE unit-under-test. Only mark the invocation of + the unit-under-test with an "// exercise" comment. Additional function calls during setup or + verification should use "// set up" or "// expect" comments respectively. - 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 @@ -40,19 +43,21 @@ Unit tests typically have 3 portions; setup, exercise, and expect. ### Mock Testing Strategy - - **Focus on verification**: Test that mocks are called correctly, not custom mock + - **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 + - **Verify parameter passing**: Always check that mocks receive expected arguments, not just + call counts + - **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. + - **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 +**CRITICAL**: DevTesting's `ThrowingStub` has very specific initialization patterns that differ from regular `Stub`. Using incorrect initializers will cause compilation errors. #### Correct ThrowingStub Patterns: @@ -60,18 +65,15 @@ differ from regular `Stub`. Using incorrect initializers will cause compilation // For success cases: ThrowingStub(defaultReturnValue: value) - // For error cases: + // 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: + // For void return types that not throw: ThrowingStub(defaultError: nil) #### Common Mistakes to Avoid: - - ❌ `ThrowingStub(throwingError: error)` - This doesn't exist + - ❌ `ThrowingStub(throwingError: error)` - This doesn't exist - ❌ `ThrowingStub()` with separate configuration - Must provide default in initializer ### Mock Object Patterns @@ -86,14 +88,14 @@ Follow established patterns from `@Documentation/TestMocks.md`: Example mock structure: final class MockProtocolName: ProtocolName { - nonisolated(unsafe) var methodStub: Stub! - - func method(input: InputType) -> OutputType { - methodStub(input) + nonisolated(unsafe) var functionStub: Stub! + + func function(input: InputType) -> OutputType { + functionStub(input) } - + nonisolated(unsafe) var throwingMethodStub: ThrowingStub! - + func throwingMethod(input: InputType) throws -> OutputType { try throwingMethodStub(input) } @@ -102,7 +104,7 @@ Example mock structure: ### Random Value Generation with Swift Testing -**IMPORTANT**: Swift Testing uses immutable test structs, but `RandomValueGenerating` requires +**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: @@ -110,7 +112,7 @@ Example mock structure: @MainActor struct MyTests: RandomValueGenerating { var randomNumberGenerator = makeRandomNumberGenerator() - + @Test mutating func myTest() throws { let randomValue = randomAlphanumericString() @@ -127,13 +129,13 @@ Example mock structure: #### Dedicated Random Value Extensions: - - **Dedicated files**: Create `RandomValueGenerating+[ModuleName].swift` files for random value + - **Dedicated files**: Create `RandomValueGenerating+[ModuleName].swift` files for random value generation - - **Centralized functions**: Move random value creation functions to these dedicated extension + - **Centralized functions**: Move random value creation functions to these dedicated extension files - - **Consistent patterns**: Follow existing patterns from other modules (e.g., + - **Consistent patterns**: Follow existing patterns from other modules (e.g., `RandomValueGenerating+AppPlatform.swift`) - - **Proper imports**: Include necessary `@testable import` statements for modules being + - **Proper imports**: Include necessary `@testable import` statements for modules being extended Example structure: @@ -155,13 +157,13 @@ Example structure: ### Test Files - - **Naming pattern**: `[ClassName]Tests.swift` in corresponding Tests directories + - **Naming pattern**: `[TypeName]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. + - **One test file per type**: Each type 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 +### Mock Files - **Naming pattern**: `Mock[ProtocolName].swift` - **Location**: Place in `Tests/[ModuleName]/Testing Support/` directories @@ -169,7 +171,7 @@ Example structure: ### Random Value Extensions - - **Naming pattern**: `RandomValueGenerating+[ModuleName].swift` + - **Naming pattern**: `RandomValueGenerating+[ModuleName].swift` - **Location**: Place in `Tests/[ModuleName]/Testing Support/` directories - **Module-specific**: Create extensions for each module's unique types @@ -181,15 +183,18 @@ Example structure: ## Test Coverage Guidelines +### Protocols + - **Skip protocol-only tests**: Don't test pure protocols with no implementation + ### 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 + - **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`, +**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: @@ -204,7 +209,7 @@ otherwise you'll get "Errors thrown from here are not handled" compilation error #### Common Mistake: // ❌ This will cause compilation error - @Test + @Test func myTestThatExpectsErrors() { #expect(throws: SomeError.self) { try somethingThatThrows() @@ -212,52 +217,56 @@ otherwise you'll get "Errors thrown from here are not handled" compilation error } ### Main Actor Considerations - - **Test isolation**: Mark test structs and methods with `@MainActor` when testing + - **Test isolation**: Mark test structs and functions 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 + - **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 + - **State verification**: Check both mock call counts and state changes in the system under test ## Common Testing Patterns +**IMPORTANT**: Avoid using `Task.sleep()` for test coordination whenever possible. Instead, use +precise synchronization mechanisms like `AsyncStream`, `confirmation`, mock prologues/epilogues, or +returning tasks. Arbitrary delays make tests slower and less reliable. + ### Testing Initialization @Test func initializationSetsCorrectDefaults() { - let instance = ClassUnderTest() - + let instance = SystemUnderTest() + #expect(instance.property == expectedDefault) } ### Testing Dependency Calls - @Test - mutating func methodCallsDependency() { + @Test + mutating func functionCallsDependency() { let mock = MockDependency() - mock.methodStub = Stub() - - let instance = ClassUnderTest(dependency: mock) + mock.functionStub = Stub() + + let instance = SystemUnderTest(dependency: mock) instance.performAction() - - #expect(mock.methodStub.calls.count == 1) + + #expect(mock.functionStub.calls.count == 1) } ### Testing Error Scenarios @Test - mutating func methodThrowsWhenDependencyFails() { + mutating func functionThrowsWhenDependencyFails() { let mock = MockDependency() let error = MockError(description: "Test error") - mock.methodStub = ThrowingStub(defaultError: error) - - let instance = ClassUnderTest(dependency: mock) - + mock.functionStub = ThrowingStub(defaultError: error) + + let instance = SystemUnderTest(dependency: mock) + #expect(throws: MockError.self) { try instance.performAction() } @@ -269,60 +278,336 @@ otherwise you'll get "Errors thrown from here are not handled" compilation error mutating func asyncMethodCompletesSuccessfully() async throws { let mock = MockDependency() mock.asyncMethodStub = Stub(defaultReturnValue: expectedResult) - - let instance = ClassUnderTest(dependency: mock) + + let instance = SystemUnderTest(dependency: mock) let result = await instance.performAsyncAction() - + #expect(result == expectedResult) #expect(mock.asyncMethodStub.calls.count == 1) } -### Testing Async State Changes with Confirmations +### Using Confirmation to Verify Callbacks + +Swift Testing's `confirmation` API ensures that specific code paths execute. Use it to verify that +callbacks, handlers, or closures are invoked: + + @Test + mutating func linkedTextInitializationWithCustomUUID() async throws { + let text = randomBasicLatinString() + let customID = randomUUID() + + await confirmation { handlerCalled in + let linkedText = RichTextContent.LinkedText(id: customID, text: text) { + defer { handlerCalled() } + return .handled + } + + #expect(linkedText.text == text) + #expect(linkedText.id == customID) + #expect(linkedText.openHandler() == .handled) + } + } + +**Key points:** + + - Call the confirmation callback (e.g., `handlerCalled()`) when the expected code path executes + - The test will fail if the callback is never invoked + - Use `defer` in handlers to ensure confirmation happens even if early returns occur -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: +### Testing Synchronous Interfaces with Asynchronous Work + +When testing code with a synchronous interface that performs asynchronous work internally +(e.g., posting to an event bus, dispatching to a queue), use `AsyncStream` to coordinate +test expectations rather than arbitrary sleep durations: + + @Test + mutating func functionPostsEventAsynchronously() async throws { + // set up the test with mocks and event observer + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + + let instance = SystemUnderTest(eventBus: eventBus) + + // set up signaling mechanism for async completion + let (signalStream, signaler) = AsyncStream.makeStream() + await confirmation("event posted") { confirm in + observer.addHandler(for: SomeEvent.self) { event, _ in + // expect the event has correct data + #expect(event.eventData.value == expectedValue) + confirm() + signaler.yield() + } + + // exercise the test by calling the synchronous function + instance.performSynchronousMethod() + + // wait for the asynchronous work to complete + await signalStream.first { _ in true } + } + } + +#### Key Points for Synchronous Interfaces with Async Work: + + - **Use `AsyncStream.makeStream()`**: Create a signal stream and signaler to coordinate + completion + - **Signal after confirmation**: Call `signaler.yield()` after calling `confirm()` in the + handler + - **Wait for signal**: Use `await signalStream.first { _ in true }` instead of + `Task.sleep(for:)` + - **Avoid arbitrary delays**: This pattern waits precisely for the async work to complete, + making tests faster and more reliable + - **Common use cases**: Event bus posting, background queue dispatch, timer-based operations + +#### Alternative: Returning Tasks for Testability + +When possible, if a function spawns a `Task` and doesn't need to return a value, consider +returning the task itself to simplify testing. Mark the result as `@discardableResult` so +callers can ignore it in production code: + + @discardableResult + func performBackgroundWork() -> Task { + Task { + // Perform async work... + await someAsyncOperation() + } + } + +This allows tests to await the task completion directly without needing stream-based +coordination: + + @Test + mutating func backgroundWorkCompletes() async throws { + let instance = SystemUnderTest() + + // exercise the test and await the returned task + let task = instance.performBackgroundWork() + await task.value + + // expect the work completed successfully + #expect(instance.workCompletedFlag == true) + } + +**When to use this pattern:** + + - Function spawns a `Task` internally for background work + - Return value is not otherwise needed + - Tests need to verify completion or side effects + +**When to use `AsyncStream` instead:** + + - Cannot modify the function signature (e.g., protocol requirements, public API constraints) + - Function doesn't spawn a task directly (e.g., posts to event bus, uses callbacks) + - Multiple async operations occur that need individual verification + +### Testing Observable Type State Changes + +**PREFERRED PATTERN**: When testing `@Observable` types that update asynchronously through internal +tasks, use the `Observations` helper from Swift Foundation. This pattern is simpler and more reliable +than manual observation tracking. + +#### Preferred: Using Observations Helper + +The `Observations` helper directly observes property changes and waits for specific conditions: @Test @MainActor - mutating func stateChangesAsynchronously() async throws { + mutating func navigationTitleReflectsCurrentChatTitle() async throws { + // set up the test with a task that the view model will await and use to update its + // navigationTitle property + let pendingTask = Task { + return messageResponse + } + + // exercise: create view model that spawns internal Task observing chat.title + let viewModel = SearchResultsViewModel( + dependencies: dependencies, + pendingMessageResponseTask: pendingTask, + delegate: mockDelegate + ) + + // wait for internal task to process initial state + _ = await Observations({ viewModel.navigationTitle }).first { @Sendable _ in true } + + // expect that navigationTitle was updated by internal task + #expect(viewModel.navigationTitle == "Initial Title") + } + +#### Why Observations is Preferred: + +**❌ Wrong Pattern - AsyncStream Signaler:** + +This pattern doesn't actually observe state changes. It only continues the pending task but doesn't +ensure the view model's internal observation task has completed its work: + + let (signalStream, signaler) = AsyncStream.makeStream() + let pendingTask = Task { + await signalStream.first { _ in true } + return response + } + // ... create view model ... + signaler.yield() // ❌ Only unblocks pendingTask, doesn't observe state change + +**✅ Correct Pattern - Observations:** + +This pattern actually observes the property and waits for the specific condition to be true: + + _ = await Observations({ viewModel.navigationTitle }).first { @Sendable title in + title == expectedValue + } // ✅ Waits for actual state change + +#### Key Points for Observations Pattern: + + - **Use for internal async tasks**: When the type spawns internal `Task`s that update state + - **Observe the property directly**: Pass a closure that reads the property + - **Wait for specific conditions**: Use `.first { condition }` to wait for expected state + - **Mark closure as `@Sendable`**: Required for concurrency safety + - **Simpler than `withObservationTracking`**: No need for manual stream coordination + - **More reliable than signalers**: Actually observes state changes rather than just unblocking tasks + +#### When to Use Each Pattern: + + - **`Observations` helper**: Testing `@Observable` types with internal async state updates (preferred) + - **`withObservationTracking`**: When you need manual control over observation lifecycle + - **`AsyncStream` with signaler**: Testing synchronous interfaces with async work (event bus, etc.) + - **Returned tasks**: When possible, return tasks from functions for direct awaiting + +#### Alternative: Manual Observation Tracking + +When you need manual control over the observation lifecycle, use `withObservationTracking`: + + @Test @MainActor + mutating func observableStateChangesAsynchronously() async throws { // set up the test by creating the object and mocked dependencies - let instance = ClassUnderTest() + let instance = SystemUnderTest() let mockDependency = MockDependency() instance.dependency = mockDependency - + // set up observation and confirmation for async state change + let (signalStream, signaler) = AsyncStream.makeStream() try await confirmation { stateChanged in withObservationTracking { _ = instance.observableState } onChange: { stateChanged() + signaler.yield() } - + // 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)) + + // await a signal to know the state change occurred + await signalStream.first { @MainActor _ in true } } - + // expect the final state to be correct #expect(instance.observableState == expectedFinalState) } -#### Key Points for Async State Testing: +**When to use `withObservationTracking`:** + + - Need to verify observation behavior itself (not just state changes) + - Need precise control over when observation starts/stops + - Testing custom observation mechanisms + +**Otherwise, prefer `Observations` helper** for simpler, more focused tests. + +### Testing with Mock Prologue and Epilogue Closures + +When testing code that calls mock functions, you can use prologue and epilogue closures to control +execution timing. Prologues execute before the stub, epilogues execute after. See +`@Documentation/TestMocks.md` for the mock implementation patterns. + +#### Pattern: Blocking Mock Execution + + @Test + mutating func testIntermediateStateWhileSending() async throws { + // set up the test with a blocking prologue + let (signalStream, signaler) = AsyncStream.makeStream() + mockClient.sendEventsPrologue = { + await signalStream.first(where: { _ in true }) + } + mockClient.sendEventsStub = ThrowingStub(defaultResult: .success(.init())) + + let instance = SystemUnderTest(client: mockClient) + + // exercise the test by triggering the async operation + instance.performAction() + + // expect intermediate state while mock is blocked + await #expect(instance.isProcessing == true) + await #expect(instance.queuedItems.count == 5) + + // signal completion to unblock the mock + signaler.yield() + + // allow async processing to complete + try await Task.sleep(for: .milliseconds(100)) + + // expect final state after mock completes + await #expect(instance.isProcessing == false) + await #expect(instance.queuedItems.isEmpty) + } + +#### Pattern: Adding Controlled Delays + + @Test + mutating func testTimeoutBehavior() async throws { + // set up the test with a delay in the mock + mockClient.sendEventsPrologue = { + try await Task.sleep(for: .milliseconds(200)) + } + mockClient.sendEventsStub = ThrowingStub(defaultResult: .success(.init())) + + let instance = SystemUnderTest(client: mockClient, timeout: .milliseconds(100)) + + // exercise the test + instance.performActionWithTimeout() + + // expect timeout occurred before mock completed + try await Task.sleep(for: .milliseconds(150)) + await #expect(instance.didTimeout == true) + } + +#### Pattern: Signaling Completion with Epilogue + + @Test + mutating func testEventHandlerLogsToTelemetry() async throws { + // set up the test with epilogue for coordination + let telemetryLogger = MockTelemetryEventLogger() + telemetryLogger.logEventStub = Stub() + + let (signalStream, signaler) = AsyncStream.makeStream() + telemetryLogger.logEventEpilogue = { + signaler.yield() + } + + // exercise the test by posting event + eventBus.post(SomeEvent()) + + // wait for async handler to complete logging + await signalStream.first { _ in true } + + // expect the event was logged + #expect(telemetryLogger.logEventStub.calls.count == 1) + } + +#### Key Points + + - **Prologues execute before stub**: Control timing before mock processes input + - **Epilogues execute after stub**: Signal completion or coordinate post-execution state + - **Eliminates arbitrary delays**: Replace `Task.sleep` with precise synchronization + - **Test intermediate states**: Verify system behavior at different execution phases + - **Optional by default**: Tests can ignore if timing control isn't needed + - **Separate concerns**: Prologue/epilogue handle timing, stub handles return values/errors - - **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 + - **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 +When in doubt, follow existing patterns from similar tests in the codebase and reference the established documentation for architectural guidance. diff --git a/Package.resolved b/Package.resolved index d2c9367..ef97c41 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "77ad12a74a8d296251809be2f40a314368fba06ab3cf5d6d49301db109f61b97", + "originHash" : "80a17d18e0ccc6a214bff7f95c4a75b53a3047602341b244fe9313cd1ea0f425", "pins" : [ { "identity" : "devfoundation", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-configuration", "state" : { - "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", - "version" : "1.0.0" + "revision" : "b4768bd68d8a6fb356bd372cb41905046244fcae", + "version" : "1.0.2" } }, { diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md index d86ce14..6eeba04 100644 --- a/Plans/Architecture Plan.md +++ b/Plans/Architecture Plan.md @@ -31,7 +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 + public let secrecy: ConfigVariableSecrecy private var metadata: VariableMetadata // Convenience: string → ConfigKey @@ -184,7 +184,7 @@ Typed accessor that bridges `ConfigVariable` to swift-configuration's `Config public final class StructuredConfigReader: StructuredConfigurationReading { private let reader: ConfigReader private let eventBus: EventBus - private let accessReporter: TelemetryAccessReporter + private let accessReporter: EventBusAccessReporter /// Initialize with custom provider array /// Internally appends RegisteredVariablesProvider to end of array (lowest precedence) @@ -457,4 +457,4 @@ Cache clears when any provider is mutated: - 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 +- After invalidation, next access re-emits telemetry diff --git a/Plans/Implementation Plan.md b/Plans/Implementation Plan.md index 0de9b2f..54ec1e9 100644 --- a/Plans/Implementation Plan.md +++ b/Plans/Implementation Plan.md @@ -7,7 +7,7 @@ Created by Duncan Lewis, 2026-01-02 ## Feature Inventory ### Sliced for Implementation -- [ ] Slice 1: ConfigVariable + StructuredConfigReader + Telemetry +- [X] Slice 1: ConfigVariable + StructuredConfigReader + Telemetry - [ ] Slice 2: Remote provider support + update signals - [ ] Slice 3: Registration + Metadata + RegisteredVariablesProvider - [ ] Slice 4: Editor UI @@ -34,7 +34,7 @@ Created by Duncan Lewis, 2026-01-02 - StructuredConfigurationReading protocol (8 method overloads: 4 primitives + 4 arrays) - StructuredConfigReader (single public type): - Init with providers array + eventBus (consumers pass their own providers) - - TelemetryAccessReporter integration (AccessReporter protocol) + - EventBusAccessReporter integration (AccessReporter protocol) - Protocol extension implementations using required accessors (requiredBool(), requiredStringArray(), etc.) - Error handling: catch errors, return fallback - Composes ConfigReader internally @@ -109,7 +109,7 @@ Consumers pass their own provider array. Typical precedence pattern: ### Telemetry Behavior - Emitted via EventBus (passed at init) -- Success: Posted automatically via TelemetryAccessReporter (AccessReporter integration) +- Success: Posted automatically via EventBusAccessReporter (AccessReporter integration) - Failure: Posted directly from catch blocks - Uses ConfigContent from swift-configuration (not custom enum) - Errors don't propagate to callers diff --git a/Plans/Slice 1 - Detailed Plan.md b/Plans/Slice 1 - Detailed Plan.md deleted file mode 100644 index f024841..0000000 --- a/Plans/Slice 1 - Detailed Plan.md +++ /dev/null @@ -1,633 +0,0 @@ -# Slice 1: Detailed Implementation Plan - -Created by Duncan Lewis, 2026-01-03 -**Last Updated:** 2026-01-06 (Added Variable Privacy) - -**Parent Document:** [Implementation Plan.md](./Implementation%20Plan.md) - ---- - -## Overview - -**Slice 1 Scope:** ConfigVariable + VariablePrivacy + StructuredConfigurationReading + Telemetry - -**Value Delivered:** End-to-end variable access with observability and privacy control - -**Supported Types:** -- **Primitives:** `Bool`, `String`, `Int`, `Double` -- **Arrays:** `[Bool]`, `[String]`, `[Int]`, `[Double]` - ---- - -## Architecture - -**Simplified Single-Type Design:** -- **StructuredConfigReader**: Single public type for typed configuration access -- Consumers manage their own provider stacks -- Protocol extensions provide typed access - -**Responsibilities:** -- Value resolution via protocol extensions -- Telemetry via AccessReporter integration -- Internal RegisteredVariablesProvider management (Slice 3) -- Error handling (catch errors, return fallback) - -**Does NOT Handle:** -- Provider stack composition (consumer's responsibility) -- Caching (deferred) - -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. 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. 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. 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` - ---- - -### 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, default privacy - public init(key: String, fallback: Value, privacy: VariablePrivacy = .auto) - - // Direct: explicit ConfigKey, default privacy - public init(key: ConfigKey, fallback: Value, privacy: VariablePrivacy = .auto) -} -``` - -**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 apiKey = ConfigVariable(key: "api.key", fallback: "", privacy: .private) - static let timeout = ConfigVariable(key: ConfigKey("network.timeout"), fallback: 30.0, privacy: .public) -} - -// Access -let darkMode = reader.value(for: AppConfig.darkMode) -let apiKey = reader.value(for: AppConfig.apiKey) // Always secret -``` - ---- - -### 3. 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 - ) - } - - // 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, - isSecret: isSecret(for: variable) - ) - // 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 - } - } - - // 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, - isSecret: isSecret(for: variable) - ) - // AccessReporter already posted event - return resolved - } catch { - eventBus.post(VariableResolutionFailedBusEvent( - key: variable.key.description, - error: error, - fallback: .stringArray(variable.fallback) - )) - return variable.fallback - } - } - - // ... 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 -- No manual source tracking - AccessEvent has provider name -- Protocol extensions provide default implementations - -**Example Usage:** -```swift -// 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 -) - -let darkMode = reader.value(for: .darkMode) -``` - ---- - -### 5. 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:isSecret:) throws -> Bool` -- `requiredString(forKey:isSecret:) throws -> String` -- `requiredInt(forKey:isSecret:) throws -> Int` -- `requiredDouble(forKey:isSecret:) throws -> Double` - -**Arrays (throwing):** -- `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 -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 - ---- - -## Example Provider Stacks - -**Consumer-Managed Configuration:** - -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. - -**Example: Local Development** -```swift -let providers: [any ConfigProvider] = [ - EnvironmentVariablesProvider(), -] - -let reader = StructuredConfigReader( - providers: providers, - eventBus: eventBus -) -``` - -**Example: Testing with Overrides** -```swift -let overrides = MutableInMemoryProvider( - name: "test-overrides", - initialValues: ["feature.darkMode": true] -) - -let providers: [any ConfigProvider] = [ - overrides, - EnvironmentVariablesProvider(), -] - -let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) -``` - -**Example: Production with CLI Support** -```swift -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 - -**Recommended Order:** -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, 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. **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), privacy later (step 6) - ---- - -## Testing Strategy - -### 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:** -- Event posting from AccessEvent -- EventBus integration -- Conversion from AccessEvent to DidAccessVariableBusEvent - -**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 -- Fallback on provider errors -- Telemetry emission (success via AccessReporter + failure direct) -- Provider array initialization -- AccessReporter integration - -**Integration Tests:** -- End-to-end value resolution -- Provider precedence verification -- Environment variable transformation -- Telemetry event flow (both success and failure) -- Multiple provider stack patterns - -### Test Patterns -- Use `MutableInMemoryProvider` 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 -- [ ] 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 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 -- [ ] 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 core library -- Consumers add custom file providers to their provider stack - -### 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 | Consumer adds to provider stack if needed | -| Error Sendability | `any Error` is Sendable (verified) | -| 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 | -| Standard provider stack | Removed - consumers manage their own | - ---- - -## 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 diff --git a/Plans/Slice 1/Bus Events Test Plan.md b/Plans/Slice 1/Bus Events Test Plan.md deleted file mode 100644 index e974f48..0000000 --- a/Plans/Slice 1/Bus Events Test Plan.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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/ConfigVariable Test Plan.md b/Plans/Slice 1/ConfigVariable Test Plan.md deleted file mode 100644 index 3b874c9..0000000 --- a/Plans/Slice 1/ConfigVariable Test Plan.md +++ /dev/null @@ -1,14 +0,0 @@ -# ConfigVariable Test Plan - -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) - - init uses `.auto` privacy when not specified - - init stores explicit privacy parameter \ No newline at end of file diff --git a/Plans/Slice 1/Slice 1 - Detailed Plan.md b/Plans/Slice 1/Slice 1 - Detailed Plan.md deleted file mode 100644 index f024841..0000000 --- a/Plans/Slice 1/Slice 1 - Detailed Plan.md +++ /dev/null @@ -1,633 +0,0 @@ -# Slice 1: Detailed Implementation Plan - -Created by Duncan Lewis, 2026-01-03 -**Last Updated:** 2026-01-06 (Added Variable Privacy) - -**Parent Document:** [Implementation Plan.md](./Implementation%20Plan.md) - ---- - -## Overview - -**Slice 1 Scope:** ConfigVariable + VariablePrivacy + StructuredConfigurationReading + Telemetry - -**Value Delivered:** End-to-end variable access with observability and privacy control - -**Supported Types:** -- **Primitives:** `Bool`, `String`, `Int`, `Double` -- **Arrays:** `[Bool]`, `[String]`, `[Int]`, `[Double]` - ---- - -## Architecture - -**Simplified Single-Type Design:** -- **StructuredConfigReader**: Single public type for typed configuration access -- Consumers manage their own provider stacks -- Protocol extensions provide typed access - -**Responsibilities:** -- Value resolution via protocol extensions -- Telemetry via AccessReporter integration -- Internal RegisteredVariablesProvider management (Slice 3) -- Error handling (catch errors, return fallback) - -**Does NOT Handle:** -- Provider stack composition (consumer's responsibility) -- Caching (deferred) - -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. 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. 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. 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` - ---- - -### 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, default privacy - public init(key: String, fallback: Value, privacy: VariablePrivacy = .auto) - - // Direct: explicit ConfigKey, default privacy - public init(key: ConfigKey, fallback: Value, privacy: VariablePrivacy = .auto) -} -``` - -**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 apiKey = ConfigVariable(key: "api.key", fallback: "", privacy: .private) - static let timeout = ConfigVariable(key: ConfigKey("network.timeout"), fallback: 30.0, privacy: .public) -} - -// Access -let darkMode = reader.value(for: AppConfig.darkMode) -let apiKey = reader.value(for: AppConfig.apiKey) // Always secret -``` - ---- - -### 3. 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 - ) - } - - // 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, - isSecret: isSecret(for: variable) - ) - // 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 - } - } - - // 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, - isSecret: isSecret(for: variable) - ) - // AccessReporter already posted event - return resolved - } catch { - eventBus.post(VariableResolutionFailedBusEvent( - key: variable.key.description, - error: error, - fallback: .stringArray(variable.fallback) - )) - return variable.fallback - } - } - - // ... 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 -- No manual source tracking - AccessEvent has provider name -- Protocol extensions provide default implementations - -**Example Usage:** -```swift -// 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 -) - -let darkMode = reader.value(for: .darkMode) -``` - ---- - -### 5. 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:isSecret:) throws -> Bool` -- `requiredString(forKey:isSecret:) throws -> String` -- `requiredInt(forKey:isSecret:) throws -> Int` -- `requiredDouble(forKey:isSecret:) throws -> Double` - -**Arrays (throwing):** -- `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 -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 - ---- - -## Example Provider Stacks - -**Consumer-Managed Configuration:** - -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. - -**Example: Local Development** -```swift -let providers: [any ConfigProvider] = [ - EnvironmentVariablesProvider(), -] - -let reader = StructuredConfigReader( - providers: providers, - eventBus: eventBus -) -``` - -**Example: Testing with Overrides** -```swift -let overrides = MutableInMemoryProvider( - name: "test-overrides", - initialValues: ["feature.darkMode": true] -) - -let providers: [any ConfigProvider] = [ - overrides, - EnvironmentVariablesProvider(), -] - -let reader = StructuredConfigReader(providers: providers, eventBus: eventBus) -``` - -**Example: Production with CLI Support** -```swift -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 - -**Recommended Order:** -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, 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. **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), privacy later (step 6) - ---- - -## Testing Strategy - -### 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:** -- Event posting from AccessEvent -- EventBus integration -- Conversion from AccessEvent to DidAccessVariableBusEvent - -**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 -- Fallback on provider errors -- Telemetry emission (success via AccessReporter + failure direct) -- Provider array initialization -- AccessReporter integration - -**Integration Tests:** -- End-to-end value resolution -- Provider precedence verification -- Environment variable transformation -- Telemetry event flow (both success and failure) -- Multiple provider stack patterns - -### Test Patterns -- Use `MutableInMemoryProvider` 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 -- [ ] 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 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 -- [ ] 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 core library -- Consumers add custom file providers to their provider stack - -### 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 | Consumer adds to provider stack if needed | -| Error Sendability | `any Error` is Sendable (verified) | -| 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 | -| Standard provider stack | Removed - consumers manage their own | - ---- - -## 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 diff --git a/Plans/Slice 1/StructuredConfigReader Test Plan.md b/Plans/Slice 1/StructuredConfigReader Test Plan.md deleted file mode 100644 index 82720a9..0000000 --- a/Plans/Slice 1/StructuredConfigReader Test Plan.md +++ /dev/null @@ -1,92 +0,0 @@ -# StructuredConfigReader Test Plan - -Created by Duncan Lewis, 2026-01-07 - -## StructuredConfigReader - -### 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 -- 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 -- 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 -- 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 -- 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 -- 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 -- 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/StructuredConfigReading Test Plan.md b/Plans/Slice 1/StructuredConfigReading Test Plan.md deleted file mode 100644 index e23d0f3..0000000 --- a/Plans/Slice 1/StructuredConfigReading Test Plan.md +++ /dev/null @@ -1,49 +0,0 @@ -# StructuredConfigReading Test Plan - -Created by Duncan Lewis, 2026-01-07 - -## Notes - -The `StructuredConfigReading` protocol defines the interface for configuration reading. It does not implement the core `value(for:)` methods (tested via StructuredConfigReader), but it provides default subscript implementations via a protocol extension that can be tested independently. - -### Protocol Requirements (tested via StructuredConfigReader) - -The 8 `value(for:)` method overloads will be tested through `StructuredConfigReader`: -- `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]` - -### Protocol Extension (testable separately) - -The protocol extension provides default subscript implementations that delegate to `value(for:)`. These can be tested independently using a mock conforming type. - -## StructuredConfigReading Extension Tests - -### Subscript Access - Bool -- subscript(variable:) delegates to value(for:) for Bool - -### Subscript Access - String -- subscript(variable:) delegates to value(for:) for String - -### Subscript Access - Int -- subscript(variable:) delegates to value(for:) for Int - -### Subscript Access - Float64 -- subscript(variable:) delegates to value(for:) for Float64 - -### Subscript Access - [Bool] -- subscript(variable:) delegates to value(for:) for [Bool] - -### Subscript Access - [String] -- subscript(variable:) delegates to value(for:) for [String] - -### Subscript Access - [Int] -- subscript(variable:) delegates to value(for:) for [Int] - -### Subscript Access - [Float64] -- subscript(variable:) delegates to value(for:) for [Float64] diff --git a/Plans/Slice 1/TelemetryAccessReporter Test Plan.md b/Plans/Slice 1/TelemetryAccessReporter Test Plan.md deleted file mode 100644 index 1ffade3..0000000 --- a/Plans/Slice 1/TelemetryAccessReporter Test Plan.md +++ /dev/null @@ -1,19 +0,0 @@ -# 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/Plans/Slice 1/VariablePrivacy Test Plan.md b/Plans/Slice 1/VariablePrivacy Test Plan.md deleted file mode 100644 index 718d65d..0000000 --- a/Plans/Slice 1/VariablePrivacy Test Plan.md +++ /dev/null @@ -1,15 +0,0 @@ -# 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/Scripts/install-git-hooks b/Scripts/install-git-hooks index e29caa6..670ea93 100755 --- a/Scripts/install-git-hooks +++ b/Scripts/install-git-hooks @@ -43,41 +43,8 @@ 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/Telemetry/DidFailToAccessConfigVariableEvent.swift b/Sources/DevConfiguration/Access Reporting/ConfigVariableAccessFailedEvent.swift similarity index 67% rename from Sources/DevConfiguration/Telemetry/DidFailToAccessConfigVariableEvent.swift rename to Sources/DevConfiguration/Access Reporting/ConfigVariableAccessFailedEvent.swift index d6c4759..92cf57f 100644 --- a/Sources/DevConfiguration/Telemetry/DidFailToAccessConfigVariableEvent.swift +++ b/Sources/DevConfiguration/Access Reporting/ConfigVariableAccessFailedEvent.swift @@ -1,5 +1,5 @@ // -// DidFailToAccessConfigVariableEvent.swift +// ConfigVariableAccessFailedEvent.swift // DevConfiguration // // Created by Duncan Lewis on 1/7/2026. @@ -8,8 +8,8 @@ import Configuration import DevFoundation -/// Posted when a configuration variable fails to resolve from any provider. -public struct DidFailToAccessConfigVariableEvent: BusEvent { +/// A bus event posted when a configuration variable fails to resolve from any provider. +public struct ConfigVariableAccessFailedEvent: BusEvent { /// The configuration key that failed to resolve. public let key: AbsoluteConfigKey @@ -17,7 +17,7 @@ public struct DidFailToAccessConfigVariableEvent: BusEvent { public let error: any Error - /// Creates a new `DidFailToAccessConfigVariableEvent` with the specified parameters. + /// Creates a new `ConfigVariableAccessFailedEvent` with the specified parameters. /// /// - Parameters: /// - key: The configuration key that failed to resolve. diff --git a/Sources/DevConfiguration/Telemetry/DidAccessConfigVariableEvent.swift b/Sources/DevConfiguration/Access Reporting/ConfigVariableAccessSucceededEvent.swift similarity index 51% rename from Sources/DevConfiguration/Telemetry/DidAccessConfigVariableEvent.swift rename to Sources/DevConfiguration/Access Reporting/ConfigVariableAccessSucceededEvent.swift index f5166ff..5f697ef 100644 --- a/Sources/DevConfiguration/Telemetry/DidAccessConfigVariableEvent.swift +++ b/Sources/DevConfiguration/Access Reporting/ConfigVariableAccessSucceededEvent.swift @@ -1,5 +1,5 @@ // -// DidAccessConfigVariableEvent.swift +// ConfigVariableAccessSucceededEvent.swift // DevConfiguration // // Created by Duncan Lewis on 1/7/2026. @@ -8,8 +8,8 @@ import Configuration import DevFoundation -/// Posted when a configuration variable is successfully accessed. -public struct DidAccessConfigVariableEvent: BusEvent { +/// A bus event posted when a configuration variable is successfully accessed. +public struct ConfigVariableAccessSucceededEvent: BusEvent { /// The configuration key that was accessed. public let key: AbsoluteConfigKey @@ -17,18 +17,18 @@ public struct DidAccessConfigVariableEvent: BusEvent { public let value: ConfigValue /// The name of the provider that supplied the value. - public let source: String + public let providerName: String? - /// Creates a new `DidAccessConfigVariableEvent` with the specified parameters. + /// Creates a new `ConfigVariableAccessSucceededEvent` 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: AbsoluteConfigKey, value: ConfigValue, source: String) { + /// - providerName: The name of the provider that supplied the value. + public init(key: AbsoluteConfigKey, value: ConfigValue, providerName: String?) { self.key = key self.value = value - self.source = source + self.providerName = providerName } } diff --git a/Sources/DevConfiguration/TelemetryAccessReporter.swift b/Sources/DevConfiguration/Access Reporting/EventBusAccessReporter.swift similarity index 71% rename from Sources/DevConfiguration/TelemetryAccessReporter.swift rename to Sources/DevConfiguration/Access Reporting/EventBusAccessReporter.swift index f53ae3d..e589573 100644 --- a/Sources/DevConfiguration/TelemetryAccessReporter.swift +++ b/Sources/DevConfiguration/Access Reporting/EventBusAccessReporter.swift @@ -1,5 +1,5 @@ // -// TelemetryAccessReporter.swift +// EventBusAccessReporter.swift // DevConfiguration // // Created by Duncan Lewis on 1/7/2026. @@ -11,14 +11,15 @@ 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` -public final class TelemetryAccessReporter: AccessReporter, Sendable { +/// +/// - Successful accesses post ``ConfigVariableAccessSucceededEvent`` +/// - Failed accesses post ``ConfigVariableAccessFailedEvent`` +public struct EventBusAccessReporter: AccessReporter { /// The event bus that telemetry events are posted on. public let eventBus: EventBus - /// Creates a new `TelemetryAccessReporter` with the specified event bus. + /// Creates a new `EventBusAccessReporter` with the specified event bus. /// /// - Parameter eventBus: The event bus that telemetry events are posted on. public init(eventBus: EventBus) { @@ -31,16 +32,16 @@ public final class TelemetryAccessReporter: AccessReporter, Sendable { switch event.result { case .success(let configValue?): eventBus.post( - DidAccessConfigVariableEvent( + ConfigVariableAccessSucceededEvent( key: event.metadata.key, value: configValue, - source: event.providerResults.first?.providerName ?? "unknown" + providerName: event.providerResults.first?.providerName ) ) case .success(nil): eventBus.post( - DidFailToAccessConfigVariableEvent( + ConfigVariableAccessFailedEvent( key: event.metadata.key, error: MissingValueError() ) @@ -48,7 +49,7 @@ public final class TelemetryAccessReporter: AccessReporter, Sendable { case .failure(let error): eventBus.post( - DidFailToAccessConfigVariableEvent( + ConfigVariableAccessFailedEvent( key: event.metadata.key, error: error ) diff --git a/Sources/DevConfiguration/Core/ConfigValueReadable.swift b/Sources/DevConfiguration/Core/ConfigValueReadable.swift new file mode 100644 index 0000000..f544652 --- /dev/null +++ b/Sources/DevConfiguration/Core/ConfigValueReadable.swift @@ -0,0 +1,574 @@ +// +// ConfigValueReadable.swift +// DevConfiguration +// +// Created by Duncan Lewis on 2/16/2026. +// + +import Configuration +import Foundation + +/// A type of that can be read by a ConfigReader. +/// +/// This protocol provides the bridge between `ConfigVariableReader` and `ConfigReader`, allowing generic +/// implementations that dispatch to the appropriate type-specific methods. +public protocol ConfigValueReadable: Sendable { + /// Gets the required value for the specified key from the reader. + /// + /// - Parameters: + /// - key: The configuration key. + /// - reader: The configuration reader. + /// - isSecret: Whether the value is secret. + /// - fileID: The source file identifier. + /// - line: The source line number. + /// - Returns: The configuration value. + /// - Throws: An error if the value cannot be retrieved. + static func requiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> Self + + /// Asynchronously fetches the required value for the specified key from the reader. + /// + /// - Parameters: + /// - key: The configuration key. + /// - reader: The configuration reader. + /// - isSecret: Whether the value is secret. + /// - fileID: The source file identifier. + /// - line: The source line number. + /// - Returns: The configuration value. + /// - Throws: An error if the value cannot be fetched. + static func fetchRequiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> Self + + /// Watches for updates to the value for the specified key, using a default if the key is not found. + /// + /// - Parameters: + /// - key: The configuration key. + /// - reader: The configuration reader. + /// - isSecret: Whether the value is secret. + /// - defaultValue: The default value to use if the key is not found. + /// - fileID: The source file identifier. + /// - line: The source line number. + /// - updatesHandler: A closure that handles the async sequence of updates. + /// - Returns: The result produced by the handler. + static func watchValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: Self, + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return + + + /// Gets the required array value for the specified key from the reader. + /// + /// - Parameters: + /// - key: The configuration key. + /// - reader: The configuration reader. + /// - isSecret: Whether the value is secret. + /// - fileID: The source file identifier. + /// - line: The source line number. + /// - Returns: The configuration array value. + /// - Throws: An error if the value cannot be retrieved. + static func requiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> [Self] + + /// Asynchronously fetches the required array value for the specified key from the reader. + /// + /// - Parameters: + /// - key: The configuration key. + /// - reader: The configuration reader. + /// - isSecret: Whether the value is secret. + /// - fileID: The source file identifier. + /// - line: The source line number. + /// - Returns: The configuration array value. + /// - Throws: An error if the value cannot be fetched. + static func fetchRequiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> [Self] + + /// Watches for updates to the array value for the specified key, using a default if the key is not found. + /// + /// - Parameters: + /// - key: The configuration key. + /// - reader: The configuration reader. + /// - isSecret: Whether the value is secret. + /// - defaultValue: The default value to use if the key is not found. + /// - fileID: The source file identifier. + /// - line: The source line number. + /// - updatesHandler: A closure that handles the async sequence of updates. + /// - Returns: The result produced by the handler. + static func watchArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: [Self], + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Self], Never>) async throws -> Return + ) async throws -> Return where Return: ~Copyable +} + + +// MARK: - Bool Conformance + +extension Bool: ConfigValueReadable { + public static func requiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> Bool { + try reader.requiredBool(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> Bool { + try await reader.fetchRequiredBool(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: Bool, + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchBool( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } + + + public static func requiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> [Bool] { + try reader.requiredBoolArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> [Bool] { + try await reader.fetchRequiredBoolArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: [Bool], + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Bool], Never>) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchBoolArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } +} + + +// MARK: - Data Conformance + +extension Data: ConfigValueReadable { + public static func requiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> Data { + Data(try reader.requiredBytes(forKey: key, isSecret: isSecret, fileID: fileID, line: line)) + } + + + public static func fetchRequiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> Data { + Data(try await reader.fetchRequiredBytes(forKey: key, isSecret: isSecret, fileID: fileID, line: line)) + } + + + public static func watchValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: Data, + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchBytes( + forKey: key, + isSecret: isSecret, + default: Array(defaultValue), + fileID: fileID, + line: line + ) { (updates) in + try await updatesHandler(ConfigUpdatesAsyncSequence(updates.map { Data($0) })) + } + } + + + public static func requiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> [Data] { + try reader.requiredByteChunkArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + .map { Data($0) } + } + + + public static func fetchRequiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> [Data] { + try await reader.fetchRequiredByteChunkArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + .map { Data($0) } + } + + + public static func watchArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: [Data], + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Data], Never>) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchByteChunkArray( + forKey: key, + isSecret: isSecret, + default: defaultValue.map { Array($0) }, + fileID: fileID, + line: line + ) { (updates) in + try await updatesHandler(ConfigUpdatesAsyncSequence(updates.map { $0.map { Data($0) } })) + } + } +} + + +// MARK: - Float64 Conformance + +extension Float64: ConfigValueReadable { + public static func requiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> Float64 { + try reader.requiredDouble(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> Float64 { + try await reader.fetchRequiredDouble(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: Float64, + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchDouble( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } + + + public static func requiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> [Float64] { + try reader.requiredDoubleArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> [Float64] { + try await reader.fetchRequiredDoubleArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: [Float64], + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Float64], Never>) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchDoubleArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } +} + + +// MARK: - Int Conformance + +extension Int: ConfigValueReadable { + public static func requiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> Int { + try reader.requiredInt(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> Int { + try await reader.fetchRequiredInt(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: Int, + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchInt( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } + + + public static func requiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> [Int] { + try reader.requiredIntArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> [Int] { + try await reader.fetchRequiredIntArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: [Int], + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Int], Never>) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchIntArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } +} + + +// MARK: - String Conformance + +extension String: ConfigValueReadable { + public static func requiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> String { + try reader.requiredString(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> String { + try await reader.fetchRequiredString(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: String, + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchString( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } + + + public static func requiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) throws -> [String] { + try reader.requiredStringArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func fetchRequiredArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> [String] { + try await reader.fetchRequiredStringArray(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + } + + + public static func watchArrayValue( + forKey key: ConfigKey, + reader: ConfigReader, + isSecret: Bool, + default defaultValue: [String], + fileID: String, + line: UInt, + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[String], Never>) async throws -> Return + ) async throws -> Return where Return: ~Copyable { + try await reader.watchStringArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } +} diff --git a/Sources/DevConfiguration/ConfigVariable.swift b/Sources/DevConfiguration/Core/ConfigVariable.swift similarity index 75% rename from Sources/DevConfiguration/ConfigVariable.swift rename to Sources/DevConfiguration/Core/ConfigVariable.swift index 870d254..b0d43a8 100644 --- a/Sources/DevConfiguration/ConfigVariable.swift +++ b/Sources/DevConfiguration/Core/ConfigVariable.swift @@ -9,8 +9,8 @@ import Configuration /// A type-safe variable definition with a default value. /// -/// `ConfigVariable` encapsulates a configuration key and its default value, -/// providing compile-time type safety for configuration access. +/// `ConfigVariable` encapsulates a configuration key and its default value, providing compile-time type safety for +/// configuration access. /// /// ## Usage /// @@ -30,7 +30,7 @@ import Configuration /// ```swift /// let darkMode = reader[.darkMode] /// ``` -public struct ConfigVariable { +public struct ConfigVariable: Sendable where Value: Sendable { /// The configuration key used to look up this variable's value. public let key: ConfigKey @@ -38,33 +38,35 @@ public struct ConfigVariable { public let defaultValue: Value /// Whether this value should be treated as a secret. - public let privacy: VariablePrivacy + public let secrecy: ConfigVariableSecrecy - /// Creates a configuration variable with the specified string key. + /// Creates a configuration variable with the specified `ConfigKey`. /// - /// The string is converted to a `ConfigKey` using the default initializer. + /// Use this initializer when you need to specified the `ConfigKey` directly. /// /// - Parameters: - /// - key: The configuration key as a string (e.g., "feature.darkMode"). + /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - privacy: The privacy setting for this variable. Defaults to `.auto`. - public init(key: String, defaultValue: Value, privacy: VariablePrivacy = .auto) { - self.init(key: ConfigKey(key), defaultValue: defaultValue, privacy: privacy) + /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. + public init(key: ConfigKey, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) { + self.key = key + self.defaultValue = defaultValue + self.secrecy = secrecy } +} - /// Creates a configuration variable with the specified `ConfigKey`. +extension ConfigVariable { + /// Creates a configuration variable with the specified string key. /// - /// Use this initializer when you need to specified the `ConfigKey` directly. + /// The string is converted to a `ConfigKey` using the default initializer. /// /// - Parameters: - /// - key: The configuration key. + /// - key: The configuration key as a string (e.g., "feature.darkMode"). /// - defaultValue: The default value to use when variable resolution fails. - /// - privacy: The privacy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, privacy: VariablePrivacy = .auto) { - self.key = key - self.defaultValue = defaultValue - self.privacy = privacy + /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. + public init(key: String, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: ConfigKey(key), defaultValue: defaultValue, secrecy: secrecy) } } diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift new file mode 100644 index 0000000..48efa51 --- /dev/null +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -0,0 +1,289 @@ +// +// ConfigVariableReader.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +import Configuration +import DevFoundation + +/// Provides structured access to configuration values queried by a `ConfigVariable`. +/// +/// A config variable 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. +/// +/// To use a config variable reader, first define your configuration variables using ``ConfigVariable``. Each variable +/// specifies its key, type, default value, and secrecy level: +/// +/// extension ConfigVariable where Value == Bool { +/// static let darkMode = ConfigVariable( +/// key: "dark_mode", +/// defaultValue: false, +/// secrecy: .auto +/// ) +/// } +/// +/// Then create a reader with your providers and query the variable: +/// +/// let reader = ConfigVariableReader( +/// providers: [ +/// InMemoryProvider(values: ["dark_mode": "true"]) +/// ], +/// eventBus: eventBus +/// ) +/// +/// let darkMode = reader[.darkMode] // true +/// +/// The reader never throws. If resolution fails, it returns the variable's default value and posts a +/// ``ConfigVariableAccessFailedEvent`` to the event bus. +public struct ConfigVariableReader { + /// The access reporter that is used to report configuration access events. + public let accessReporter: any AccessReporter + + /// The configuration reader that is used to resolve configuration values. + public let reader: ConfigReader + + /// The configuration reader's providers. + /// + /// This is stored so that + public let providers: [any ConfigProvider] + + + /// Creates a new `ConfigVariableReader` with the specified providers and the default telemetry access reporter. + /// + /// Use this initializer when you want to use the standard `EventBusAccessReporter`. + /// + /// - 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.init( + providers: providers, + accessReporter: EventBusAccessReporter(eventBus: eventBus) + ) + } + + + /// Creates a new `ConfigVariableReader` 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) + self.providers = providers + } +} + + +// MARK: - Get + +extension ConfigVariableReader { + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to get a value for. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. + public func value( + for variable: ConfigVariable, + fileID: String = #fileID, + line: UInt = #line + ) -> Value + where Value: ConfigValueReadable { + do { + return try Value.requiredValue( + forKey: variable.key, + reader: reader, + isSecret: variable.isSecret, + fileID: fileID, + line: line + ) + } catch { + return variable.defaultValue + } + } + + + /// Gets the value for the specified array `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to get an array value for. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. + public func value( + for variable: ConfigVariable<[Element]>, + fileID: String = #fileID, + line: UInt = #line + ) -> [Element] + where Element: ConfigValueReadable { + do { + return try Element.requiredArrayValue( + forKey: variable.key, + reader: reader, + isSecret: variable.isSecret, + fileID: fileID, + line: line + ) + } catch { + return variable.defaultValue + } + } +} + + +// MARK: - Subscript Get + +extension ConfigVariableReader { + /// Gets the value for the specified `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to get a value for. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. + public subscript( + variable: ConfigVariable, + fileID: String = #fileID, + line: UInt = #line + ) -> Value + where Value: ConfigValueReadable { + value(for: variable, fileID: fileID, line: line) + } + + + /// Gets the value for the specified array `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to get an array value for. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. + public subscript( + variable: ConfigVariable<[Element]>, + fileID: String = #fileID, + line: UInt = #line + ) -> [Element] + where Element: ConfigValueReadable { + value(for: variable, fileID: fileID, line: line) + } +} + + +// MARK: - Fetch + +extension ConfigVariableReader { + /// Asynchronously fetches the value for the specified `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to fetch a value for. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. + public func fetchValue( + for variable: ConfigVariable, + fileID: String = #fileID, + line: UInt = #line + ) async -> Value + where Value: ConfigValueReadable { + do { + return try await Value.fetchRequiredValue( + forKey: variable.key, + reader: reader, + isSecret: variable.isSecret, + fileID: fileID, + line: line + ) + } catch { + return variable.defaultValue + } + } + + + /// Asynchronously fetches the value for the specified array `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to fetch an array value for. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The configuration value of the variable, or the default value if resolution fails. + public func fetchValue( + for variable: ConfigVariable<[Element]>, + fileID: String = #fileID, + line: UInt = #line + ) async -> [Element] + where Element: ConfigValueReadable { + do { + return try await Element.fetchRequiredArrayValue( + forKey: variable.key, + reader: reader, + isSecret: variable.isSecret, + fileID: fileID, + line: line + ) + } catch { + return variable.defaultValue + } + } +} + + +// MARK: - Watch + +extension ConfigVariableReader { + /// Watches for updates to the value for the specified `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to watch for updates. + /// - updatesHandler: A closure that handles an async sequence of updates to the value. + /// - Returns: The result produced by the handler. + public func watchValue( + for variable: ConfigVariable, + fileID: String = #fileID, + line: UInt = #line, + updatesHandler: (_ updates: any AsyncSequence) async throws -> Return + ) async throws -> Return + where Value: ConfigValueReadable, Return: ~Copyable { + try await Value.watchValue( + forKey: variable.key, + reader: reader, + isSecret: variable.isSecret, + default: variable.defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } + + + /// Watches for updates to the value for the specified array `ConfigVariable`. + /// + /// - Parameters: + /// - variable: The variable to watch for updates. + /// - updatesHandler: A closure that handles an async sequence of updates to the value. + /// - Returns: The result produced by the handler. + public func watchValue( + for variable: ConfigVariable<[Element]>, + fileID: String = #fileID, + line: UInt = #line, + updatesHandler: (_ updates: any AsyncSequence<[Element], Never>) async throws -> Return + ) async throws -> Return + where Element: ConfigValueReadable, Return: ~Copyable { + try await Element.watchArrayValue( + forKey: variable.key, + reader: reader, + isSecret: variable.isSecret, + default: variable.defaultValue, + fileID: fileID, + line: line, + updatesHandler: updatesHandler + ) + } +} diff --git a/Sources/DevConfiguration/Core/VariableSecrecy.swift b/Sources/DevConfiguration/Core/VariableSecrecy.swift new file mode 100644 index 0000000..d4da697 --- /dev/null +++ b/Sources/DevConfiguration/Core/VariableSecrecy.swift @@ -0,0 +1,52 @@ +// +// ConfigVariableSecrecy.swift +// DevConfiguration +// +// Created by Duncan Lewis on 1/7/2026. +// + +/// Controls whether a configuration variable's value is treated as secret. +/// +/// Variable secrecy 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 ConfigVariableSecrecy: CaseIterable, Sendable { + /// Treats `String` and `[String]` values as secret and all other types as public. + /// + /// This is the default secrecy 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 secret + + /// 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 or + /// string arrays. + case `public` +} + + +extension ConfigVariable { + /// Whether the variable is secret. + var isSecret: Bool { + return secrecy == .secret + } +} + + +extension ConfigVariable { + /// Whether the variable is secret, that is, not `.public`. + var isSecret: Bool { + return secrecy != .public + } +} + + +extension ConfigVariable<[String]> { + /// Whether the variable is secret, that is, not `.public`. + var isSecret: Bool { + return secrecy != .public + } +} diff --git a/Sources/DevConfiguration/DevConfiguration.swift b/Sources/DevConfiguration/DevConfiguration.swift deleted file mode 100644 index 2c15b89..0000000 --- a/Sources/DevConfiguration/DevConfiguration.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// DevConfiguration.swift -// DevConfiguration -// -// Created by Duncan Lewis on 6/11/25. -// - -import Foundation - -/// Prepends the specified string with `"devconfiguration."`. -/// -/// - Parameter suffix: The string that will have DevConfiguration’s reverse DNS prefix prepended to it. -@usableFromInline -func reverseDNSPrefixed(_ suffix: String) -> String { - return "devconfiguration.\(suffix)" -} diff --git a/Sources/DevConfiguration/Documentation.docc/Info.plist b/Sources/DevConfiguration/Documentation.docc/Info.plist new file mode 100644 index 0000000..958f6c8 --- /dev/null +++ b/Sources/DevConfiguration/Documentation.docc/Info.plist @@ -0,0 +1,8 @@ + + + + + CDDefaultCodeListingLanguage + swift + + diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift deleted file mode 100644 index e50d094..0000000 --- a/Sources/DevConfiguration/StructuredConfigReader.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// 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`. -/// -/// 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. -/// -/// To use a structured config reader, first define your configuration variables using `ConfigVariable`. Each variable -/// specifies its key, type, default value, and privacy level: -/// -/// extension ConfigVariable where Value == Bool { -/// static let darkMode = ConfigVariable( -/// key: "dark_mode", -/// defaultValue: false, -/// privacy: .auto -/// ) -/// } -/// -/// Then create a reader with your providers and query the variable: -/// -/// let reader = StructuredConfigReader( -/// providers: [ -/// InMemoryProvider(values: ["dark_mode": "true"]) -/// ], -/// eventBus: eventBus -/// ) -/// -/// let darkMode = reader[.darkMode] // true -/// -/// The reader never throws. If resolution fails, it returns the variable's default value and posts a -/// `DidFailToAccessConfigVariableEvent` to the event bus. -public final class StructuredConfigReader { - /// 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. - let reader: ConfigReader - - - /// 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 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) - } -} - - -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 default value if resolution fails. - public func value(for variable: ConfigVariable) -> Bool { - do { - return try reader.requiredBool( - forKey: variable.key, - isSecret: variable.privacy.isPrivate - ) - } catch { - return variable.defaultValue - } - } - - - /// 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 default value if resolution fails. - public func value(for variable: ConfigVariable) -> String { - do { - return try reader.requiredString( - forKey: variable.key, - isSecret: variable.privacy.isPrivateForSensitiveTypes - ) - } catch { - return variable.defaultValue - } - } - - - /// 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 default value if resolution fails. - public func value(for variable: ConfigVariable) -> Int { - do { - return try reader.requiredInt( - forKey: variable.key, - isSecret: variable.privacy.isPrivate - ) - } catch { - return variable.defaultValue - } - } - - - /// 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 default value if resolution fails. - public func value(for variable: ConfigVariable) -> Float64 { - do { - return try reader.requiredDouble( - forKey: variable.key, - isSecret: variable.privacy.isPrivate - ) - } catch { - return variable.defaultValue - } - } - - - // 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 default value if resolution fails. - public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] { - do { - return try reader.requiredBoolArray( - forKey: variable.key, - isSecret: variable.privacy.isPrivate - ) - } catch { - return variable.defaultValue - } - } - - - /// 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 default value if resolution fails. - public func value(for variable: ConfigVariable<[String]>) -> [String] { - do { - return try reader.requiredStringArray( - forKey: variable.key, - isSecret: variable.privacy.isPrivateForSensitiveTypes - ) - } catch { - return variable.defaultValue - } - } - - - /// 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 default value if resolution fails. - public func value(for variable: ConfigVariable<[Int]>) -> [Int] { - do { - return try reader.requiredIntArray( - forKey: variable.key, - isSecret: variable.privacy.isPrivate - ) - } catch { - return variable.defaultValue - } - } - - - /// 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 default value if resolution fails. - public func value(for variable: ConfigVariable<[Float64]>) -> [Float64] { - do { - return try reader.requiredDoubleArray( - forKey: variable.key, - isSecret: variable.privacy.isPrivate - ) - } catch { - return variable.defaultValue - } - } -} diff --git a/Sources/DevConfiguration/StructuredConfigReading.swift b/Sources/DevConfiguration/StructuredConfigReading.swift deleted file mode 100644 index 9333ed5..0000000 --- a/Sources/DevConfiguration/StructuredConfigReading.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// 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 default values automatically. -/// -/// Values are always returned (never nil or thrown) - if resolution fails, -/// the variable's default 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 default value 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 default value 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 default value 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 default value 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 default value 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 default value 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 default value 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 default value if resolution fails. - func value(for variable: ConfigVariable<[Float64]>) -> [Float64] - - - // MARK: - Subscript Access - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable) -> Bool { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable) -> String { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable) -> Int { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable) -> Float64 { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable<[Bool]>) -> [Bool] { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable<[String]>) -> [String] { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable<[Int]>) -> [Int] { get } - - /// 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 default value if resolution fails. - subscript(variable: ConfigVariable<[Float64]>) -> [Float64] { get } -} - - -extension StructuredConfigReading { - // MARK: - Default Subscript Implementations - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable) -> Bool { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable) -> String { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable) -> Int { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable) -> Float64 { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable<[Bool]>) -> [Bool] { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable<[String]>) -> [String] { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable<[Int]>) -> [Int] { - value(for: variable) - } - - /// 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 default value if resolution fails. - public subscript(variable: ConfigVariable<[Float64]>) -> [Float64] { - value(for: variable) - } -} diff --git a/Sources/DevConfiguration/VariablePrivacy.swift b/Sources/DevConfiguration/VariablePrivacy.swift deleted file mode 100644 index b806f54..0000000 --- a/Sources/DevConfiguration/VariablePrivacy.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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` -} - - -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 - } - } -} diff --git a/Tests/DevConfigurationTests/DevConfigurationTests.swift b/Tests/DevConfigurationTests/DevConfigurationTests.swift deleted file mode 100644 index d17a21a..0000000 --- a/Tests/DevConfigurationTests/DevConfigurationTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// DevConfigurationTests.swift -// DevConfiguration -// -// Created by Duncan Lewis on 7/11/25. -// - -import DevTesting -import Foundation -import Testing - -@testable import DevConfiguration - -struct DevConfigurationTests { - @Test - func testReverseDNSPrefix() { - let result = reverseDNSPrefixed("test") - #expect(result == "devconfiguration.test") - } -} diff --git a/Tests/DevConfigurationTests/Testing Support/MockError.swift b/Tests/DevConfigurationTests/Testing Support/MockError.swift new file mode 100644 index 0000000..5888e67 --- /dev/null +++ b/Tests/DevConfigurationTests/Testing Support/MockError.swift @@ -0,0 +1,10 @@ +// +// MockError.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +struct MockError: Error, Hashable { + let id: String +} diff --git a/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift b/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift new file mode 100644 index 0000000..27f3af6 --- /dev/null +++ b/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift @@ -0,0 +1,126 @@ +// +// RandomValueGenerating+DevConfiguration.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import Configuration +import DevConfiguration +import DevTesting +import Foundation + +extension RandomValueGenerating { + mutating func randomAbsoluteConfigKey() -> AbsoluteConfigKey { + return AbsoluteConfigKey(randomConfigKey()) + } + + + mutating func randomAccessEvent( + key: AbsoluteConfigKey? = nil, + result: Result? = nil, + providerResults: [AccessEvent.ProviderResult]? = nil + ) -> AccessEvent { + return AccessEvent( + metadata: AccessEvent.Metadata( + accessKind: randomElement(in: [.get, .fetch, .watch])!, + key: key ?? randomAbsoluteConfigKey(), + valueType: .string, + sourceLocation: AccessEvent.Metadata.SourceLocation( + fileID: randomAlphanumericString(), + line: random(UInt.self, in: .min ... .max) + ), + accessTimestamp: randomDate() + ), + providerResults: providerResults ?? [randomProviderResult()], + result: result ?? .success(randomConfigValue()) + ) + } + + + mutating func randomBoolArray() -> [Bool] { + return Array(count: randomInt(in: 0 ... 5)) { randomBool() } + } + + + mutating func randomByteChunkArray() -> [[UInt8]] { + return Array(count: randomInt(in: 0 ... 5)) { randomBytes() } + } + + + mutating func randomBytes() -> [UInt8] { + return Array(count: randomInt(in: 0 ... 5)) { random(UInt8.self, in: .min ... .max) } + } + + + mutating func randomConfigContent() -> ConfigContent { + switch randomInt(in: 0 ... 9) { + case 0: + .string(randomAlphanumericString()) + case 1: + .int(randomInt(in: .min ... .max)) + case 2: + .double(randomFloat64(in: -100_000 ... 100_000)) + case 3: + .bool(randomBool()) + case 4: + .bytes(randomBytes()) + case 5: + .stringArray(randomStringArray()) + case 6: + .intArray(randomIntArray()) + case 7: + .doubleArray(randomFloat64Array()) + case 8: + .boolArray(randomBoolArray()) + default: + .byteChunkArray(randomByteChunkArray()) + } + } + + + mutating func randomConfigKey() -> ConfigKey { + let components = Array(count: randomInt(in: 1 ... 5)) { randomAlphanumericString() } + return ConfigKey(components) + } + + + mutating func randomConfigValue() -> ConfigValue { + return ConfigValue(randomConfigContent(), isSecret: randomBool()) + } + + + mutating func randomConfigVariableSecrecy() -> ConfigVariableSecrecy { + return randomCase(of: ConfigVariableSecrecy.self)! + } + + + mutating func randomError() -> MockError { + return MockError(id: randomAlphanumericString()) + } + + + mutating func randomFloat64Array() -> [Float64] { + return Array(count: randomInt(in: 0 ... 5)) { randomFloat64(in: -100_000 ... 100_000) } + } + + + mutating func randomIntArray() -> [Int] { + return Array(count: randomInt(in: 0 ... 5)) { randomInt(in: .min ... .max) } + } + + + mutating func randomProviderResult( + providerName: String? = nil, + result: Result? = nil + ) -> AccessEvent.ProviderResult { + let providerName = providerName ?? randomAlphanumericString() + let result = result ?? .success(.init(encodedKey: randomAlphanumericString(), value: randomConfigValue())) + return AccessEvent.ProviderResult(providerName: providerName, result: result) + } + + + mutating func randomStringArray() -> [String] { + return Array(count: randomInt(in: 0 ... 5)) { randomAlphanumericString() } + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessFailedEventTests.swift b/Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessFailedEventTests.swift new file mode 100644 index 0000000..d8179aa --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessFailedEventTests.swift @@ -0,0 +1,32 @@ +// +// ConfigVariableAccessFailedEventTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import Configuration +import DevConfiguration +import DevTesting +import Testing + +struct ConfigVariableAccessFailedEventTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - init + + @Test + mutating func initStoresParameters() { + // set up the test by creating random parameters + let key = randomAbsoluteConfigKey() + let error = randomError() + + // exercise the test by creating the event + let event = ConfigVariableAccessFailedEvent(key: key, error: error) + + // expect that the event stores the parameters + #expect(event.key == key) + #expect(event.error as? MockError == error) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessSucceededEventTests.swift b/Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessSucceededEventTests.swift new file mode 100644 index 0000000..369b51b --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Access Reporting/ConfigVariableAccessSucceededEventTests.swift @@ -0,0 +1,34 @@ +// +// ConfigVariableAccessSucceededEventTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import Configuration +import DevConfiguration +import DevTesting +import Testing + +struct ConfigVariableAccessSucceededEventTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - init + + @Test(arguments: [false, true]) + mutating func initStoresParameters(hasProviderName: Bool) { + // set up the test by creating random parameters + let key = randomAbsoluteConfigKey() + let value = randomConfigValue() + let providerName = hasProviderName ? randomAlphanumericString() : nil + + // exercise the test by creating the event + let event = ConfigVariableAccessSucceededEvent(key: key, value: value, providerName: providerName) + + // expect that the event stores the parameters + #expect(event.key == key) + #expect(event.value == value) + #expect(event.providerName == providerName) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Access Reporting/EventBusAccessReporterTests.swift b/Tests/DevConfigurationTests/Unit Tests/Access Reporting/EventBusAccessReporterTests.swift new file mode 100644 index 0000000..b8ccec1 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Access Reporting/EventBusAccessReporterTests.swift @@ -0,0 +1,140 @@ +// +// EventBusAccessReporterTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import Configuration +import DevFoundation +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +struct EventBusAccessReporterTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - init + + @Test + func initStoresParameters() { + // set up the test by creating an event bus + let eventBus = EventBus() + + // exercise the test by creating the reporter + let reporter = EventBusAccessReporter(eventBus: eventBus) + + // expect that the reporter stores the event bus + #expect(reporter.eventBus === eventBus) + } + + + // MARK: - report(_:) + + @Test + mutating func reportPostsAccessSucceededEventOnSuccess() async throws { + // set up the test with an event bus and observer + let eventBus = EventBus() + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + + let reporter = EventBusAccessReporter(eventBus: eventBus) + + // set up an access event with a successful result and multiple provider results + let key = randomAbsoluteConfigKey() + let configValue = randomConfigValue() + let providerResults = Array(count: randomInt(in: 2 ... 5)) { + randomProviderResult() + } + + let firstProviderName = providerResults.first!.providerName + let accessEvent = randomAccessEvent( + key: key, + result: .success(configValue), + providerResults: providerResults + ) + + // set up a stream to receive the posted event + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableAccessSucceededEvent.self) { (event, _) in + continuation.yield(event) + } + + // exercise the test by reporting the access event + reporter.report(accessEvent) + + // expect that a ConfigVariableAccessSucceededEvent was posted with correct values + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == key) + #expect(postedEvent.value == configValue) + #expect(postedEvent.providerName == firstProviderName) + } + + + @Test + mutating func reportPostsAccessFailedEventWithMissingValueErrorOnSuccessNil() async throws { + // set up the test with an event bus and observer + let eventBus = EventBus() + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + + let reporter = EventBusAccessReporter(eventBus: eventBus) + + // set up an access event with a success(nil) result + let key = randomAbsoluteConfigKey() + let accessEvent = randomAccessEvent( + key: key, + result: .success(nil) + ) + + // set up a stream to receive the posted event + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableAccessFailedEvent.self) { (event, _) in + continuation.yield(event) + } + + // exercise the test by reporting the access event + reporter.report(accessEvent) + + // expect that a ConfigVariableAccessFailedEvent was posted with MissingValueError + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == key) + #expect(postedEvent.error is MissingValueError) + } + + + @Test + mutating func reportPostsAccessFailedEventOnFailure() async throws { + // set up the test with an event bus and observer + let eventBus = EventBus() + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + + let reporter = EventBusAccessReporter(eventBus: eventBus) + + // set up an access event with a failure result + let key = randomAbsoluteConfigKey() + let error = randomError() + let accessEvent = randomAccessEvent( + key: key, + result: .failure(error) + ) + + // set up a stream to receive the posted event + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableAccessFailedEvent.self) { (event, _) in + continuation.yield(event) + } + + // exercise the test by reporting the access event + reporter.report(accessEvent) + + // expect that a ConfigVariableAccessFailedEvent was posted with the error + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == key) + #expect(postedEvent.error as? MockError == error) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift new file mode 100644 index 0000000..83f7272 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift @@ -0,0 +1,44 @@ +// +// ConfigVariable+SecrecyTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/26. +// + +import Configuration +import DevTesting +import Testing + +@testable import DevConfiguration + +struct ConfigVariable_SecrecyTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - isSecret + + @Test(arguments: ConfigVariableSecrecy.allCases) + mutating func isSecret(secrecy: ConfigVariableSecrecy) { + let intVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: randomInt(in: .min ... .max), + secrecy: secrecy + ) + + let stringVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: randomAlphanumericString(), + secrecy: secrecy + ) + + let stringArrayVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: Array(count: randomInt(in: 0 ... 5)) { randomAlphanumericString() }, + secrecy: secrecy + ) + + #expect(intVariable.isSecret == (secrecy == .secret)) + #expect(stringVariable.isSecret == [.secret, .auto].contains(secrecy)) + #expect(stringArrayVariable.isSecret == [.secret, .auto].contains(secrecy)) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift new file mode 100644 index 0000000..ed44010 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift @@ -0,0 +1,1014 @@ +// +// ConfigVariableReaderTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import Configuration +import DevFoundation +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +struct ConfigVariableReaderTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + /// A mutable provider for testing. + let provider = MutableInMemoryProvider(initialValues: [:]) + + /// The event bus for testing event posting. + let eventBus = EventBus() + + /// The reader under test. + lazy var reader: ConfigVariableReader = { + ConfigVariableReader(providers: [provider], eventBus: eventBus) + }() + + + // MARK: - Bool tests + + @Test + mutating func valueForBoolReturnsProviderValue() { + testValueReturnsProviderValue(using: BoolTestHelper()) + } + + + @Test + mutating func valueForBoolReturnsDefaultWhenKeyNotFound() { + testValueReturnsDefaultWhenKeyNotFound(using: BoolTestHelper()) + } + + + @Test + mutating func fetchValueForBoolReturnsProviderValue() async { + await testFetchValueReturnsProviderValue(using: BoolTestHelper()) + } + + + @Test + mutating func fetchValueForBoolReturnsDefaultWhenKeyNotFound() async { + await testFetchValueReturnsDefaultWhenKeyNotFound(using: BoolTestHelper()) + } + + + @Test + mutating func watchValueForBoolReceivesUpdates() async throws { + try await testWatchValueReceivesUpdates(using: BoolTestHelper()) + } + + + @Test + mutating func subscriptBoolReturnsProviderValue() { + testSubscriptReturnsProviderValue(using: BoolTestHelper()) + } + + + // MARK: - Data tests + + @Test + mutating func valueForDataReturnsProviderValue() { + testValueReturnsProviderValue(using: DataTestHelper()) + } + + + @Test + mutating func valueForDataReturnsDefaultWhenKeyNotFound() { + testValueReturnsDefaultWhenKeyNotFound(using: DataTestHelper()) + } + + + @Test + mutating func fetchValueForDataReturnsProviderValue() async { + await testFetchValueReturnsProviderValue(using: DataTestHelper()) + } + + + @Test + mutating func fetchValueForDataReturnsDefaultWhenKeyNotFound() async { + await testFetchValueReturnsDefaultWhenKeyNotFound(using: DataTestHelper()) + } + + + @Test + mutating func watchValueForDataReceivesUpdates() async throws { + try await testWatchValueReceivesUpdates(using: DataTestHelper()) + } + + + @Test + mutating func subscriptDataReturnsProviderValue() { + testSubscriptReturnsProviderValue(using: DataTestHelper()) + } + + + // MARK: - Float64 tests + + @Test + mutating func valueForFloat64ReturnsProviderValue() { + testValueReturnsProviderValue(using: Float64TestHelper()) + } + + + @Test + mutating func valueForFloat64ReturnsDefaultWhenKeyNotFound() { + testValueReturnsDefaultWhenKeyNotFound(using: Float64TestHelper()) + } + + + @Test + mutating func fetchValueForFloat64ReturnsProviderValue() async { + await testFetchValueReturnsProviderValue(using: Float64TestHelper()) + } + + + @Test + mutating func fetchValueForFloat64ReturnsDefaultWhenKeyNotFound() async { + await testFetchValueReturnsDefaultWhenKeyNotFound(using: Float64TestHelper()) + } + + + @Test + mutating func watchValueForFloat64ReceivesUpdates() async throws { + try await testWatchValueReceivesUpdates(using: Float64TestHelper()) + } + + + @Test + mutating func subscriptFloat64ReturnsProviderValue() { + testSubscriptReturnsProviderValue(using: Float64TestHelper()) + } + + + // MARK: - Int tests + + @Test + mutating func valueForIntReturnsProviderValue() { + testValueReturnsProviderValue(using: IntTestHelper()) + } + + + @Test + mutating func valueForIntReturnsDefaultWhenKeyNotFound() { + testValueReturnsDefaultWhenKeyNotFound(using: IntTestHelper()) + } + + + @Test + mutating func fetchValueForIntReturnsProviderValue() async { + await testFetchValueReturnsProviderValue(using: IntTestHelper()) + } + + + @Test + mutating func fetchValueForIntReturnsDefaultWhenKeyNotFound() async { + await testFetchValueReturnsDefaultWhenKeyNotFound(using: IntTestHelper()) + } + + + @Test + mutating func watchValueForIntReceivesUpdates() async throws { + try await testWatchValueReceivesUpdates(using: IntTestHelper()) + } + + + @Test + mutating func subscriptIntReturnsProviderValue() { + testSubscriptReturnsProviderValue(using: IntTestHelper()) + } + + + // MARK: - String tests + + @Test + mutating func valueForStringReturnsProviderValue() { + testValueReturnsProviderValue(using: StringTestHelper()) + } + + + @Test + mutating func valueForStringReturnsDefaultWhenKeyNotFound() { + testValueReturnsDefaultWhenKeyNotFound(using: StringTestHelper()) + } + + + @Test + mutating func fetchValueForStringReturnsProviderValue() async { + await testFetchValueReturnsProviderValue(using: StringTestHelper()) + } + + + @Test + mutating func fetchValueForStringReturnsDefaultWhenKeyNotFound() async { + await testFetchValueReturnsDefaultWhenKeyNotFound(using: StringTestHelper()) + } + + + @Test + mutating func watchValueForStringReceivesUpdates() async throws { + try await testWatchValueReceivesUpdates(using: StringTestHelper()) + } + + + @Test + mutating func subscriptStringReturnsProviderValue() { + testSubscriptReturnsProviderValue(using: StringTestHelper()) + } + + + // MARK: - [Bool] tests + + @Test + mutating func valueForBoolArrayReturnsProviderValue() { + testArrayValueReturnsProviderValue(using: BoolArrayTestHelper()) + } + + + @Test + mutating func valueForBoolArrayReturnsDefaultWhenKeyNotFound() { + testArrayValueReturnsDefaultWhenKeyNotFound(using: BoolArrayTestHelper()) + } + + + @Test + mutating func fetchValueForBoolArrayReturnsProviderValue() async { + await testArrayFetchValueReturnsProviderValue(using: BoolArrayTestHelper()) + } + + + @Test + mutating func fetchValueForBoolArrayReturnsDefaultWhenKeyNotFound() async { + await testArrayFetchValueReturnsDefaultWhenKeyNotFound(using: BoolArrayTestHelper()) + } + + + @Test + mutating func watchValueForBoolArrayReceivesUpdates() async throws { + try await testArrayWatchValueReceivesUpdates(using: BoolArrayTestHelper()) + } + + + @Test + mutating func subscriptBoolArrayReturnsProviderValue() { + testArraySubscriptReturnsProviderValue(using: BoolArrayTestHelper()) + } + + + // MARK: - [Data] tests + + @Test + mutating func valueForDataArrayReturnsProviderValue() { + testArrayValueReturnsProviderValue(using: DataArrayTestHelper()) + } + + + @Test + mutating func valueForDataArrayReturnsDefaultWhenKeyNotFound() { + testArrayValueReturnsDefaultWhenKeyNotFound(using: DataArrayTestHelper()) + } + + + @Test + mutating func fetchValueForDataArrayReturnsProviderValue() async { + await testArrayFetchValueReturnsProviderValue(using: DataArrayTestHelper()) + } + + + @Test + mutating func fetchValueForDataArrayReturnsDefaultWhenKeyNotFound() async { + await testArrayFetchValueReturnsDefaultWhenKeyNotFound(using: DataArrayTestHelper()) + } + + + @Test + mutating func watchValueForDataArrayReceivesUpdates() async throws { + try await testArrayWatchValueReceivesUpdates(using: DataArrayTestHelper()) + } + + + @Test + mutating func subscriptDataArrayReturnsProviderValue() { + testArraySubscriptReturnsProviderValue(using: DataArrayTestHelper()) + } + + + // MARK: - [Float64] tests + + @Test + mutating func valueForFloat64ArrayReturnsProviderValue() { + testArrayValueReturnsProviderValue(using: Float64ArrayTestHelper()) + } + + + @Test + mutating func valueForFloat64ArrayReturnsDefaultWhenKeyNotFound() { + testArrayValueReturnsDefaultWhenKeyNotFound(using: Float64ArrayTestHelper()) + } + + + @Test + mutating func fetchValueForFloat64ArrayReturnsProviderValue() async { + await testArrayFetchValueReturnsProviderValue(using: Float64ArrayTestHelper()) + } + + + @Test + mutating func fetchValueForFloat64ArrayReturnsDefaultWhenKeyNotFound() async { + await testArrayFetchValueReturnsDefaultWhenKeyNotFound(using: Float64ArrayTestHelper()) + } + + + @Test + mutating func watchValueForFloat64ArrayReceivesUpdates() async throws { + try await testArrayWatchValueReceivesUpdates(using: Float64ArrayTestHelper()) + } + + + @Test + mutating func subscriptFloat64ArrayReturnsProviderValue() { + testArraySubscriptReturnsProviderValue(using: Float64ArrayTestHelper()) + } + + + // MARK: - [Int] tests + + @Test + mutating func valueForIntArrayReturnsProviderValue() { + testArrayValueReturnsProviderValue(using: IntArrayTestHelper()) + } + + + @Test + mutating func valueForIntArrayReturnsDefaultWhenKeyNotFound() { + testArrayValueReturnsDefaultWhenKeyNotFound(using: IntArrayTestHelper()) + } + + + @Test + mutating func fetchValueForIntArrayReturnsProviderValue() async { + await testArrayFetchValueReturnsProviderValue(using: IntArrayTestHelper()) + } + + @Test + mutating func fetchValueForIntArrayReturnsDefaultWhenKeyNotFound() async { + await testArrayFetchValueReturnsDefaultWhenKeyNotFound(using: IntArrayTestHelper()) + } + + + @Test + mutating func watchValueForIntArrayReceivesUpdates() async throws { + try await testArrayWatchValueReceivesUpdates(using: IntArrayTestHelper()) + } + + + @Test + mutating func subscriptIntArrayReturnsProviderValue() { + testArraySubscriptReturnsProviderValue(using: IntArrayTestHelper()) + } + + + // MARK: - [String] tests + + @Test + mutating func valueForStringArrayReturnsProviderValue() { + testArrayValueReturnsProviderValue(using: StringArrayTestHelper()) + } + + + @Test + mutating func valueForStringArrayReturnsDefaultWhenKeyNotFound() { + testArrayValueReturnsDefaultWhenKeyNotFound(using: StringArrayTestHelper()) + } + + + @Test + mutating func fetchValueForStringArrayReturnsProviderValue() async { + await testArrayFetchValueReturnsProviderValue(using: StringArrayTestHelper()) + } + + @Test + mutating func fetchValueForStringArrayReturnsDefaultWhenKeyNotFound() async { + await testArrayFetchValueReturnsDefaultWhenKeyNotFound(using: StringArrayTestHelper()) + } + + + @Test + mutating func watchValueForStringArrayReceivesUpdates() async throws { + try await testArrayWatchValueReceivesUpdates(using: StringArrayTestHelper()) + } + + + @Test + mutating func subscriptStringArrayReturnsProviderValue() { + testArraySubscriptReturnsProviderValue(using: StringArrayTestHelper()) + } + + + // MARK: - Generic Test Helpers + + /// Tests that `value(for:)` returns the provider value when the key exists. + mutating func testValueReturnsProviderValue( + using helper: Helper + ) where Helper: ConfigValueTestHelper { + // set up + let key = randomConfigKey() + let expectedValue = helper.randomValue(using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: expectedValue, using: &randomNumberGenerator) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: expectedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) + } + + + /// Tests that `value(for:)` returns the default value when the key is not found. + mutating func testValueReturnsDefaultWhenKeyNotFound( + using helper: Helper + ) where Helper: ConfigValueTestHelper { + // set up + let key = randomConfigKey() + let defaultValue = helper.randomValue(using: &randomNumberGenerator) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == defaultValue) + } + + + /// Tests that `fetchValue(for:)` returns the provider value when the key exists. + mutating func testFetchValueReturnsProviderValue( + using helper: Helper + ) async where Helper: ConfigValueTestHelper { + // set up + let key = randomConfigKey() + let expectedValue = helper.randomValue(using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: expectedValue, using: &randomNumberGenerator) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: expectedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise + let result = await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) + } + + + /// Tests that `fetchValue(for:)` returns the default value when the key is not found. + mutating func testFetchValueReturnsDefaultWhenKeyNotFound( + using helper: Helper + ) async where Helper: ConfigValueTestHelper { + // set up + let key = randomConfigKey() + let defaultValue = helper.randomValue(using: &randomNumberGenerator) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = await reader.fetchValue(for: variable) + + // expect + #expect(result == defaultValue) + } + + + /// Tests that `watchValue(for:)` receives updates when the provider value changes. + mutating func testWatchValueReceivesUpdates( + using helper: Helper + ) async throws where Helper: ConfigValueTestHelper { + // set up + let key = randomConfigKey() + let initialValue = helper.randomValue(using: &randomNumberGenerator) + let updatedValue = helper.differentValue(from: initialValue, using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: initialValue, using: &randomNumberGenerator) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: initialValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise and expect + try await reader.watchValue(for: variable) { (updates) in + var iterator = updates.makeAsyncIterator() + + // first value should be initial + let value1 = try await iterator.next() + #expect(value1 == initialValue) + + // update the provider + provider.setValue( + .init( + helper.configContent(for: updatedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // next value should be updated + let value2 = try await iterator.next() + #expect(value2 == updatedValue) + } + } + + + /// Tests that subscript returns the provider value when the key exists. + mutating func testSubscriptReturnsProviderValue( + using helper: Helper + ) where Helper: ConfigValueTestHelper { + // set up + let key = randomConfigKey() + let expectedValue = helper.randomValue(using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: expectedValue, using: &randomNumberGenerator) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: expectedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise + let result = reader[variable] + + // expect + #expect(result == expectedValue) + } + + + // MARK: - Generic Array Test Helpers + + /// Tests that `value(for:)` returns the provider value when the key exists for array types. + mutating func testArrayValueReturnsProviderValue( + using helper: Helper + ) where Helper: ConfigArrayValueTestHelper { + // set up + let key = randomConfigKey() + let expectedValue = helper.randomValue(using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: expectedValue, using: &randomNumberGenerator) + let variable = ConfigVariable<[Helper.Element]>(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: expectedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) + } + + + /// Tests that `value(for:)` returns the default value when the key is not found for array types. + mutating func testArrayValueReturnsDefaultWhenKeyNotFound( + using helper: Helper + ) where Helper: ConfigArrayValueTestHelper { + // set up + let key = randomConfigKey() + let defaultValue = helper.randomValue(using: &randomNumberGenerator) + let variable = ConfigVariable<[Helper.Element]>(key: key, defaultValue: defaultValue) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == defaultValue) + } + + + /// Tests that `fetchValue(for:)` returns the provider value when the key exists for array types. + mutating func testArrayFetchValueReturnsProviderValue( + using helper: Helper + ) async where Helper: ConfigArrayValueTestHelper { + // set up + let key = randomConfigKey() + let expectedValue = helper.randomValue(using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: expectedValue, using: &randomNumberGenerator) + let variable = ConfigVariable<[Helper.Element]>(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: expectedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise + let result = await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) + } + + + /// Tests that `fetchValue(for:)` returns the default value when the key is not found for array types. + mutating func testArrayFetchValueReturnsDefaultWhenKeyNotFound( + using helper: Helper + ) async where Helper: ConfigArrayValueTestHelper { + // set up + let key = randomConfigKey() + let defaultValue = helper.randomValue(using: &randomNumberGenerator) + let variable = ConfigVariable<[Helper.Element]>(key: key, defaultValue: defaultValue) + + // exercise + let result = await reader.fetchValue(for: variable) + + // expect + #expect(result == defaultValue) + } + + + /// Tests that `watchValue(for:)` receives updates when the provider value changes for array types. + mutating func testArrayWatchValueReceivesUpdates( + using helper: Helper + ) async throws where Helper: ConfigArrayValueTestHelper { + // set up + let key = randomConfigKey() + let initialValue = helper.randomValue(using: &randomNumberGenerator) + let updatedValue = helper.differentValue(from: initialValue, using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: initialValue, using: &randomNumberGenerator) + let variable = ConfigVariable<[Helper.Element]>(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: initialValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise and expect + try await reader.watchValue(for: variable) { (updates) in + var iterator = updates.makeAsyncIterator() + + // first value should be initial + let value1 = try await iterator.next() + #expect(value1 == initialValue) + + // update the provider + provider.setValue( + .init( + helper.configContent(for: updatedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // next value should be updated + let value2 = try await iterator.next() + #expect(value2 == updatedValue) + } + } + + + /// Tests that subscript returns the provider value when the key exists for array types. + mutating func testArraySubscriptReturnsProviderValue( + using helper: Helper + ) where Helper: ConfigArrayValueTestHelper { + // set up + let key = randomConfigKey() + let expectedValue = helper.randomValue(using: &randomNumberGenerator) + let defaultValue = helper.differentValue(from: expectedValue, using: &randomNumberGenerator) + let variable = ConfigVariable<[Helper.Element]>(key: key, defaultValue: defaultValue) + provider.setValue( + .init( + helper.configContent(for: expectedValue), + isSecret: randomBool() + ), + forKey: .init(key) + ) + + // exercise + let result = reader[variable] + + // expect + #expect(result == expectedValue) + } + + + // MARK: - Event Bus Integration + + @Test + mutating func valuePostsAccessSucceededEventWhenFound() async throws { + // set up + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + + let key = randomConfigKey() + let expectedValue = randomBool() + let variable = ConfigVariable(key: key, defaultValue: !expectedValue) + provider.setValue( + .init( + .bool(expectedValue), + isSecret: randomBool() + ), + forKey: .init(variable.key) + ) + + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableAccessSucceededEvent.self) { (event, _) in + continuation.yield(event) + } + + // exercise + _ = reader.value(for: variable) + + // expect + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == AbsoluteConfigKey(variable.key)) + #expect(postedEvent.value.content == .bool(expectedValue)) + } + + + @Test + mutating func valuePostsAccessFailedEventWhenNotFound() async throws { + // set up + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + + let key = randomConfigKey() + let defaultValue = randomBool() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableAccessFailedEvent.self) { (event, _) in + continuation.yield(event) + } + + // exercise + _ = reader.value(for: variable) + + // expect + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == AbsoluteConfigKey(variable.key)) + } +} + + +// MARK: - ConfigValueTestHelper Protocol + +/// A protocol that abstracts the type-specific details needed to test `ConfigVariableReader` with different value +/// types. +/// +/// Conforming types provide the logic for generating random values, creating config content, and producing different +/// values for testing default value fallback behavior. +protocol ConfigValueTestHelper { + /// The configuration value type being tested. + associatedtype Value: ConfigValueReadable & Equatable + + /// Generates a random value of the associated type. + func randomValue(using generator: inout some RandomNumberGenerator) -> Value + + /// Returns a value that is different from the provided value. + func differentValue(from value: Value, using generator: inout some RandomNumberGenerator) -> Value + + /// Converts the value to its corresponding `ConfigContent` representation. + func configContent(for value: Value) -> ConfigContent +} + + +// MARK: - ConfigArrayValueTestHelper Protocol + +/// A protocol that abstracts the type-specific details needed to test `ConfigVariableReader` with array value types. +/// +/// This is separate from `ConfigValueTestHelper` because array types have their element type conform to +/// `ConfigValueReadable`, not the array type itself. +protocol ConfigArrayValueTestHelper { + /// The element type of the array being tested. + associatedtype Element: ConfigValueReadable & Equatable + + /// Generates a random array value. + func randomValue(using generator: inout some RandomNumberGenerator) -> [Element] + + /// Returns an array that is different from the provided array. + func differentValue(from value: [Element], using generator: inout some RandomNumberGenerator) -> [Element] + + /// Converts the array to its corresponding `ConfigContent` representation. + func configContent(for value: [Element]) -> ConfigContent +} + + +// MARK: - BoolTestHelper + +private struct BoolTestHelper: ConfigValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> Bool { + Bool.random(using: &generator) + } + + + func differentValue(from value: Bool, using generator: inout some RandomNumberGenerator) -> Bool { + !value + } + + + func configContent(for value: Bool) -> ConfigContent { + .bool(value) + } +} + + +// MARK: - DataTestHelper + +private struct DataTestHelper: ConfigValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> Data { + let count = Int.random(in: 1 ... 32, using: &generator) + return Data.random(count: count, using: &generator) + } + + + func differentValue(from value: Data, using generator: inout some RandomNumberGenerator) -> Data { + value + randomValue(using: &generator) + } + + + func configContent(for value: Data) -> ConfigContent { + .bytes(Array(value)) + } +} + + +// MARK: - Float64TestHelper + +private struct Float64TestHelper: ConfigValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> Float64 { + Float64.random(in: 1 ... 100_000, using: &generator) + } + + + func differentValue(from value: Float64, using generator: inout some RandomNumberGenerator) -> Float64 { + value + randomValue(using: &generator) + } + + + func configContent(for value: Float64) -> ConfigContent { + .double(value) + } +} + + +// MARK: - IntTestHelper + +private struct IntTestHelper: ConfigValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> Int { + Int.random(in: 1 ... 100_000, using: &generator) + } + + + func differentValue(from value: Int, using generator: inout some RandomNumberGenerator) -> Int { + value + randomValue(using: &generator) + } + + + func configContent(for value: Int) -> ConfigContent { + .int(value) + } +} + + +// MARK: - StringTestHelper + +private struct StringTestHelper: ConfigValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> String { + let count = Int.random(in: 5 ... 20, using: &generator) + return String.randomAlphanumeric(count: count, using: &generator) + } + + + func differentValue(from value: String, using generator: inout some RandomNumberGenerator) -> String { + value + randomValue(using: &generator) + } + + + func configContent(for value: String) -> ConfigContent { + .string(value) + } +} + + +// MARK: - BoolArrayTestHelper + +private struct BoolArrayTestHelper: ConfigArrayValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> [Bool] { + let count = Int.random(in: 1 ... 5, using: &generator) + return Array(count: count) { Bool.random(using: &generator) } + } + + + func differentValue(from value: [Bool], using generator: inout some RandomNumberGenerator) -> [Bool] { + value + randomValue(using: &generator) + } + + + func configContent(for value: [Bool]) -> ConfigContent { + .boolArray(value) + } +} + + +// MARK: - DataArrayTestHelper + +private struct DataArrayTestHelper: ConfigArrayValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> [Data] { + let count = Int.random(in: 1 ... 5, using: &generator) + return Array(count: count) { + let byteCount = Int.random(in: 1 ... 32, using: &generator) + return Data.random(count: byteCount, using: &generator) + } + } + + + func differentValue(from value: [Data], using generator: inout some RandomNumberGenerator) -> [Data] { + value + randomValue(using: &generator) + } + + + func configContent(for value: [Data]) -> ConfigContent { + .byteChunkArray(value.map { Array($0) }) + } +} + + +// MARK: - Float64ArrayTestHelper + +private struct Float64ArrayTestHelper: ConfigArrayValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> [Float64] { + let count = Int.random(in: 1 ... 5, using: &generator) + return Array(count: count) { Float64.random(in: 1 ... 100_000, using: &generator) } + } + + + func differentValue(from value: [Float64], using generator: inout some RandomNumberGenerator) -> [Float64] { + value + randomValue(using: &generator) + } + + + func configContent(for value: [Float64]) -> ConfigContent { + .doubleArray(value) + } +} + + +// MARK: - IntArrayTestHelper + +private struct IntArrayTestHelper: ConfigArrayValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> [Int] { + let count = Int.random(in: 1 ... 5, using: &generator) + return Array(count: count) { Int.random(in: 1 ... 100_000, using: &generator) } + } + + + func differentValue(from value: [Int], using generator: inout some RandomNumberGenerator) -> [Int] { + value + randomValue(using: &generator) + } + + + func configContent(for value: [Int]) -> ConfigContent { + .intArray(value) + } +} + + +// MARK: - StringArrayTestHelper + +private struct StringArrayTestHelper: ConfigArrayValueTestHelper { + func randomValue(using generator: inout some RandomNumberGenerator) -> [String] { + let count = Int.random(in: 1 ... 5, using: &generator) + return Array(count: count) { String.randomAlphanumeric(count: count * 3, using: &generator) } + } + + + func differentValue(from value: [String], using generator: inout some RandomNumberGenerator) -> [String] { + return value + randomValue(using: &generator) + } + + + func configContent(for value: [String]) -> ConfigContent { + .stringArray(value) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift new file mode 100644 index 0000000..a437071 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift @@ -0,0 +1,54 @@ +// +// ConfigVariableTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import Configuration +import DevConfiguration +import DevTesting +import Testing + +struct ConfigVariableTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - init(key: ConfigKey, …) + + @Test + mutating func initWithConfigKeyStoresParameters() { + // set up the test by creating random parameters + let configKey = randomConfigKey() + let defaultValue = randomInt(in: .min ... .max) + let secrecy = randomConfigVariableSecrecy() + + // exercise the test by creating the config variable + let variable = ConfigVariable(key: configKey, defaultValue: defaultValue, secrecy: secrecy) + + // expect that the variable stores the parameters + #expect(variable.key == configKey) + #expect(variable.defaultValue == defaultValue) + #expect(variable.secrecy == secrecy) + } + + + // MARK: - init(key: String, …) + + @Test + mutating func initWithStringConvertsKeyAndStoresParameters() { + // set up the test by creating a dot-separated key string + let key = randomConfigKey() + let keyString = key.components.joined(separator: ".") + let defaultValue = randomInt(in: .min ... .max) + let secrecy = randomConfigVariableSecrecy() + + // exercise the test by creating the config variable with a string key + let variable = ConfigVariable(key: keyString, defaultValue: defaultValue, secrecy: secrecy) + + // expect that the string is converted to a ConfigKey and parameters are stored + #expect(variable.key == ConfigKey(keyString)) + #expect(variable.defaultValue == defaultValue) + #expect(variable.secrecy == secrecy) + } +}