diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4417fbd --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(swift build:*)", + "Bash(grep:*)" + ] + } +} diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index 6764a03..2e827ef 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -1,5 +1,4 @@ name: Verify Changes - on: merge_group: pull_request: @@ -7,101 +6,132 @@ 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_DESTINATION: ${{ matrix.xcode_destination }} - XCODE_TEST_PLAN: DevConfiguration + XCODE_TEST_PLAN: AllTests + XCODE_TEST_PRODUCTS_PATH: .build/DevConfiguration.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- 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: | - 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 @@ -109,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/.gitignore b/.gitignore index 0023a53..9ae82d8 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/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/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/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/Package.resolved b/Package.resolved index f811c42..d2c9367 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,85 @@ { - "originHash" : "222f113614d29faab34495fcc0b3117443743f1bed9ee6a27b0e343febfa0c14", + "originHash" : "77ad12a74a8d296251809be2f40a314368fba06ab3cf5d6d49301db109f61b97", "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" + } + }, + { + "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" } } ], diff --git a/Package.swift b/Package.swift index 5380ca1..80409d3 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,11 +23,17 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.0.0-beta.7"), + .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"), ], targets: [ .target( name: "DevConfiguration", + dependencies: [ + .product(name: "Configuration", package: "swift-configuration"), + .product(name: "DevFoundation", package: "DevFoundation"), + ], swiftSettings: swiftSettings ), .testTarget( diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md new file mode 100644 index 0000000..d86ce14 --- /dev/null +++ b/Plans/Architecture Plan.md @@ -0,0 +1,460 @@ +# DevConfiguration Architecture + +Typesafe configuration wrapper on Apple's swift-configuration. + +--- + +## 1. Variable Definitions + +Variables defined anywhere by consumers; encouraged pattern is static properties on the `ConfigVariable` type: + +```swift +extension ConfigVariable where Value == Bool { + static let darkMode = ConfigVariable( + key: "feature.darkMode", + fallback: false + ) +} + +// Access: config.value(for: .darkMode) +``` + +**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 + +### Core Types + +```swift +@dynamicMemberLookup +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 + 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 +} +``` + +### 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 +``` + +--- + +## 2. Variable Access + +- Always synchronous (async support for remote providers) +- Never fails — fallback returned on error +- Method overloads for compile-time dispatch + +```swift +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) -> 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<[Float64]>) -> [Float64] + + // Rich types + func value(for variable: ConfigVariable) -> T +} +``` + +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:)` | +| `Float64` | `requiredDouble(forKey:)` | +| `[Bool]` | `requiredBoolArray(forKey:)` | +| `[String]` | `requiredStringArray(forKey:)` | +| `[Int]` | `requiredIntArray(forKey:)` | +| `[Float64]` | `requiredDoubleArray(forKey:)` | +| `T: Codable` | String → JSON decode | + +- Note: Use `Float64` instead of `Double` in the interface to match DevFoundation. + +--- + +## 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?) + - 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) + +--- + +## 5. Simplified Architecture + +**Design Decision:** Single public type with protocol-based typed access. + +### StructuredConfigReader + +Typed accessor that bridges `ConfigVariable` to swift-configuration's `ConfigReader`. + +```swift +public final class StructuredConfigReader: StructuredConfigurationReading { + private let reader: ConfigReader + private let eventBus: EventBus + private let 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] + // ... etc +} +``` + +**Responsibilities:** +- Value resolution with required accessors (`requiredBool()`, `requiredStringArray()`, etc.) +- Error handling (catch all, return fallback) +- Telemetry emission via AccessReporter integration +- Internal RegisteredVariablesProvider management (appended to provider array) + +**Does NOT Handle:** +- Provider stack composition (consumer's responsibility) +- Caching (may add later for telemetry deduplication only) + +**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 +// 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 +) + +let darkMode = reader.value(for: .darkMode) +``` + +**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: reader emits update signal (via `@Observable` or stream) +- Multiple remote providers activate independently + +```swift +// Remote provider pattern +let amplitudeProvider = AmplitudeProvider(...) +let reader = StructuredConfigReader( + providers: [amplitudeProvider], + eventBus: eventBus +) + +// Later, when app is ready +await amplitudeProvider.fetch() // 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 +extension StructuredConfigReader { + 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(.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. + +### Internal Provider + +A custom `ConfigProvider` owned by `StructuredConfigReader`, inserted at lowest precedence: + +```swift +internal final class RegisteredVariablesProvider: ConfigProvider { + private let provider: MutableInMemoryProvider + private var registeredKeys: Set = [] + private var metadata: [String: VariableMetadata] = [:] // for editor UI + + init() { + self.provider = MutableInMemoryProvider( + name: "registered-variables", + initialValues: [:] + ) + } + + func register(_ variable: ConfigVariable) { + // 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: ConfigKey) -> Bool { + registeredKeys.contains(key.description) + } + + func metadata(for key: ConfigKey) -> VariableMetadata? { + metadata[key.description] + } + + // ConfigProvider conformance delegates to composed provider + // (snapshot, value lookup, etc.) +} +``` + +**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. RegisteredVariablesProvider ← 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 (Deferred) + +**Note:** Caching has been deferred. May be added later solely for telemetry deduplication. + +Original rationale: 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.description + 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/Plans/Implementation Plan.md b/Plans/Implementation Plan.md new file mode 100644 index 0000000..0de9b2f --- /dev/null +++ b/Plans/Implementation Plan.md @@ -0,0 +1,128 @@ +# DevConfiguration Implementation Plan + +Created by Duncan Lewis, 2026-01-02 + +--- + +## Feature Inventory + +### Sliced for Implementation +- [ ] Slice 1: ConfigVariable + StructuredConfigReader + Telemetry +- [ ] Slice 2: Remote provider support + update signals +- [ ] Slice 3: Registration + Metadata + RegisteredVariablesProvider +- [ ] Slice 4: Editor UI + +### Future Features (Deferred) +- [ ] 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 + Telemetry +**Value:** End-to-end variable access with observability + +**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 (single public type): + - Init with providers array + eventBus (consumers pass their own providers) + - TelemetryAccessReporter integration (AccessReporter protocol) + - Protocol extension implementations using required accessors (requiredBool(), requiredStringArray(), etc.) + - Error handling: catch errors, return fallback + - Composes ConfigReader internally +- Telemetry events using ConfigContent (from swift-configuration): + - DidAccessVariableBusEvent (via AccessReporter) + - VariableResolutionFailedBusEvent (on error) + +--- + +### Slice 2: Remote Provider Support + Update Signals +**Value:** Async configuration sources and change notification + +**Scope:** +- RemoteConfigProvider protocol (isReady, fetch()) +- 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 3: 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 +- RegisteredVariablesProvider (internal ConfigProvider composing MutableInMemoryProvider) + - 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 4: Editor UI +**Value:** Runtime configuration override interface + +**Scope:** +- TBD based on architecture decisions +- Provider-based UI presentation (providers manage their own UI) + +--- + +## Context + +### 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 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] +- 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 +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) +- 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 +- No caching (telemetry posted on every access) + +### 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 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/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/ConfigVariable Test Plan.md b/Plans/Slice 1/ConfigVariable Test Plan.md new file mode 100644 index 0000000..3b874c9 --- /dev/null +++ b/Plans/Slice 1/ConfigVariable Test Plan.md @@ -0,0 +1,14 @@ +# 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 new file mode 100644 index 0000000..f024841 --- /dev/null +++ b/Plans/Slice 1/Slice 1 - Detailed Plan.md @@ -0,0 +1,633 @@ +# 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 new file mode 100644 index 0000000..82720a9 --- /dev/null +++ b/Plans/Slice 1/StructuredConfigReader Test Plan.md @@ -0,0 +1,92 @@ +# 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 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/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/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/README.md b/README.md index a177220..78a643d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # DevConfiguration -Description forthcoming. +DevConfiguration is a type-safe configuration wrapper built on Apple's swift-configuration library. +It provides structured configuration management with telemetry, extensible metadata, and a variable +management interface. -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 +25,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 100755 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/" 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/ConfigVariable.swift b/Sources/DevConfiguration/ConfigVariable.swift new file mode 100644 index 0000000..8e9b7d2 --- /dev/null +++ b/Sources/DevConfiguration/ConfigVariable.swift @@ -0,0 +1,70 @@ +// +// 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 `StructuredConfigReading` 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 + + /// Whether this value should be treated as a secret. + public let privacy: VariablePrivacy + + + /// 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. + /// - 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) + } + + + /// 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. + /// - 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/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)" } diff --git a/Sources/DevConfiguration/StructuredConfigReader.swift b/Sources/DevConfiguration/StructuredConfigReader.swift new file mode 100644 index 0000000..be3c021 --- /dev/null +++ b/Sources/DevConfiguration/StructuredConfigReader.swift @@ -0,0 +1,209 @@ +// +// 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, 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: [ +/// InMemoryProvider(values: ["dark_mode": "true"]) +/// ], +/// eventBus: eventBus +/// ) +/// +/// let darkMode = reader.value(for: .darkMode) // true +/// +/// 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 + + /// 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 fallback 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.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 { + return try reader.requiredString( + forKey: variable.key, + isSecret: variable.privacy.isPrivateForSensitiveTypes + ) + } catch { + 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 { + return try reader.requiredInt( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) + } catch { + 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 { + return try reader.requiredDouble( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) + } catch { + 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 { + return try reader.requiredBoolArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) + } catch { + 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 { + return try reader.requiredStringArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivateForSensitiveTypes + ) + } catch { + 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 { + return try reader.requiredIntArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) + } catch { + 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 { + return try reader.requiredDoubleArray( + forKey: variable.key, + isSecret: variable.privacy.isPrivate + ) + } catch { + return variable.fallback + } + } +} 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] +} 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..f7f8efc --- /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` +public final class TelemetryAccessReporter: AccessReporter, Sendable { + /// The event bus that telemetry events are posted on. + public 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 + } + + + public 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 {} diff --git a/Sources/DevConfiguration/VariablePrivacy.swift b/Sources/DevConfiguration/VariablePrivacy.swift new file mode 100644 index 0000000..b806f54 --- /dev/null +++ b/Sources/DevConfiguration/VariablePrivacy.swift @@ -0,0 +1,52 @@ +// +// 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 index 3923844..d17a21a 100644 --- a/Tests/DevConfigurationTests/DevConfigurationTests.swift +++ b/Tests/DevConfigurationTests/DevConfigurationTests.swift @@ -8,12 +8,13 @@ import DevTesting import Foundation import Testing + @testable import DevConfiguration struct DevConfigurationTests { @Test func testReverseDNSPrefix() { let result = reverseDNSPrefixed("test") - #expect(result == "com.gauriar.devconfiguration.test") + #expect(result == "devconfiguration.test") } }