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