diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 4417fbd..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(swift build:*)", - "Bash(grep:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 9ae82d8..248443c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc Open Sourcing/ +.claude/settings.local.json diff --git a/Package.resolved b/Package.resolved index ef97c41..69ffadd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "80a17d18e0ccc6a214bff7f95c4a75b53a3047602341b244fe9313cd1ea0f425", + "originHash" : "ea344d91ca081a8d76646289b8dc1fab4bf021986aeb4261afa9052259b26704", "pins" : [ { "identity" : "devfoundation", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-configuration", "state" : { - "revision" : "b4768bd68d8a6fb356bd372cb41905046244fcae", - "version" : "1.0.2" + "revision" : "1bb939fe7bbb00b8f8bab664cc90020c035c08d9", + "version" : "1.1.0" } }, { diff --git a/Package.swift b/Package.swift index 80409d3..8fb4d4c 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-configuration", from: "1.1.0"), .package(url: "https://github.com/DevKitOrganization/DevFoundation.git", from: "1.7.0"), .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"), ], diff --git a/Plans/Architecture Plan.md b/Plans/Architecture Plan.md deleted file mode 100644 index 6eeba04..0000000 --- a/Plans/Architecture Plan.md +++ /dev/null @@ -1,460 +0,0 @@ -# DevConfiguration Architecture - -Typesafe configuration wrapper on Apple's swift-configuration. - ---- - -## 1. Variable Definitions - -Variables defined anywhere by consumers; encouraged pattern is static properties on the `ConfigVariable` type: - -```swift -extension ConfigVariable where Value == Bool { - static let darkMode = ConfigVariable( - key: "feature.darkMode", - fallback: false - ) -} - -// Access: config.value(for: .darkMode) -``` - -**Key format**: `ConfigKey` (from swift-configuration). Consumers can use string convenience initializer or construct ConfigKey explicitly. Key transformation is provider-specific: -- JSON/YAML: `feature.darkMode` → nested lookup `{ "feature": { "darkMode": ... } }` -- Environment: `feature.darkMode` → `FEATURE_DARKMODE` -- Custom providers: define their own transformation - -### Core Types - -```swift -@dynamicMemberLookup -public struct ConfigVariable { - public let key: ConfigKey // From swift-configuration - public let fallback: Value - public let secrecy: ConfigVariableSecrecy - private var metadata: VariableMetadata - - // Convenience: string → ConfigKey - public init(key: String, fallback: Value) - - // Direct: explicit ConfigKey - public init(key: ConfigKey, fallback: Value) - - /// Builder-style metadata setter - public func metadata(_ keyPath: WritableKeyPath, _ value: M) -> Self - - /// Dynamic member access to metadata values - public subscript(dynamicMember keyPath: WritableKeyPath) -> M -} -``` - -### Metadata System - -Extensible via SwiftUI Environment-style key pattern: - -```swift -public protocol VariableMetadataKey { - associatedtype Value - static var defaultValue: Value { get } - - /// Display name for editor UI - static var keyDisplayText: String { get } - - /// Value formatting for editor UI - static func displayText(for value: Value) -> String? -} - -public struct VariableMetadata { - public subscript(key: K.Type) -> K.Value { get set } -} -``` - -Consumer-defined metadata: - -```swift -// Define key -private struct ExpirationDateKey: VariableMetadataKey { - static var defaultValue: Date? { nil } - static var keyDisplayText: String { "Expiration" } - static func displayText(for value: Date?) -> String? { value?.formatted() } -} - -// Extend VariableMetadata -extension VariableMetadata { - var expirationDate: Date? { - get { self[ExpirationDateKey.self] } - set { self[ExpirationDateKey.self] = newValue } - } -} - -// Usage -let flag = ConfigVariable(key: "feature.x", fallback: false) - .metadata(\.expirationDate, Date.now.addingTimeInterval(5 * 86400)) - -// Reading -let expires = flag.expirationDate -``` - ---- - -## 2. Variable Access - -- Always synchronous (async support for remote providers) -- Never fails — fallback returned on error -- Method overloads for compile-time dispatch - -```swift -public protocol StructuredConfigurationReading { - // Primitives - func value(for variable: ConfigVariable) -> Bool - func value(for variable: ConfigVariable) -> String - func value(for variable: ConfigVariable) -> Int - func value(for variable: ConfigVariable) -> Float64 - - // Arrays - func value(for variable: ConfigVariable<[Bool]>) -> [Bool] - func value(for variable: ConfigVariable<[String]>) -> [String] - func value(for variable: ConfigVariable<[Int]>) -> [Int] - func value(for variable: ConfigVariable<[Float64]>) -> [Float64] - - // Rich types - func value(for variable: ConfigVariable) -> T -} -``` - -Resolution dispatches to swift-configuration's typed accessors (`requiredBool()`, `requiredStringArray()`, etc.), catches errors, returns fallback. - -### Supported Value Types - -| Type | Resolution | -|------|------------| -| `Bool` | `requiredBool(forKey:)` | -| `String` | `requiredString(forKey:)` | -| `Int` | `requiredInt(forKey:)` | -| `Float64` | `requiredDouble(forKey:)` | -| `[Bool]` | `requiredBoolArray(forKey:)` | -| `[String]` | `requiredStringArray(forKey:)` | -| `[Int]` | `requiredIntArray(forKey:)` | -| `[Float64]` | `requiredDoubleArray(forKey:)` | -| `T: Codable` | String → JSON decode | - -- Note: Use `Float64` instead of `Double` in the interface to match DevFoundation. - ---- - -## 3. Telemetry - -Telemetry emitted via `EventBus` (passed at init). Errors don't propagate to callers — fallback returned, event posted. - -Example events: -- `DidAccessVariableBusEvent` — variable accessed (key, value, source, usedFallback) -- `DidAccessUnregisteredVariableBusEvent` — accessed variable not in registry -- `VariableResolutionFailedBusEvent` — error during resolution (key, error, fallback used) - ---- - -## 4. Relationship to swift-configuration - -**Uses**: `ConfigReader`, `ConfigProvider` protocol, provider precedence, built-in providers - -**Abstracts over**: Typed accessors, per-call defaults, async patterns - -**Adds**: `ConfigVariable`, generic access, guaranteed returns, error observation, registration, caching, editor UI - ---- - -## Open Questions - -- Consumer-facing update signal: How does `StructuredConfigReader` notify consumers when values may have changed? (`@Observable`, `AsyncStream`, callback, or just re-access?) - - Answer: Use `watchSnapshot` to expose an update stream function on `StructuredConfigReader`, consider - adding variable-wise watch functions in the future. -- Does `ExpressibleByConfigString` support fallthrough on init failure? (assumed yes, needs verification) - ---- - -## 5. Simplified Architecture - -**Design Decision:** Single public type with protocol-based typed access. - -### StructuredConfigReader - -Typed accessor that bridges `ConfigVariable` to swift-configuration's `ConfigReader`. - -```swift -public final class StructuredConfigReader: StructuredConfigurationReading { - private let reader: ConfigReader - private let eventBus: EventBus - private let accessReporter: EventBusAccessReporter - - /// Initialize with custom provider array - /// Internally appends RegisteredVariablesProvider to end of array (lowest precedence) - public init(providers: [any ConfigProvider], eventBus: EventBus) -} - -// Protocol conformance via extensions -extension StructuredConfigReader { - // Protocol conformance: 8 overloads (4 primitives + 4 arrays) - public func value(for variable: ConfigVariable) -> Bool - public func value(for variable: ConfigVariable<[Bool]>) -> [Bool] - // ... etc -} -``` - -**Responsibilities:** -- Value resolution with required accessors (`requiredBool()`, `requiredStringArray()`, etc.) -- Error handling (catch all, return fallback) -- Telemetry emission via AccessReporter integration -- Internal RegisteredVariablesProvider management (appended to provider array) - -**Does NOT Handle:** -- Provider stack composition (consumer's responsibility) -- Caching (may add later for telemetry deduplication only) - -**Provider Management:** -- Consumers pass their own provider array -- Provider order determines precedence (first = highest priority) -- StructuredConfigReader internally appends RegisteredVariablesProvider to end -- No `addProvider` API — provider order should be explicit at initialization - -**Example Usage:** -```swift -// Consumer creates their own provider stack -let providers: [any ConfigProvider] = [ - AmplitudeProvider(), // Highest priority - EnvironmentVariablesProvider(), - // RegisteredVariablesProvider automatically added by StructuredConfigReader -] - -let reader = StructuredConfigReader( - providers: providers, - eventBus: eventBus -) - -let darkMode = reader.value(for: .darkMode) -``` - -**Async Providers:** -Some providers (e.g., remote services) may not have values immediately: - -- Providers initialize synchronously but return no values until ready -- Consumer controls lifecycle via explicit `await provider.fetch()` -- On activation: reader emits update signal (via `@Observable` or stream) -- Multiple remote providers activate independently - -```swift -// Remote provider pattern -let amplitudeProvider = AmplitudeProvider(...) -let reader = StructuredConfigReader( - providers: [amplitudeProvider], - eventBus: eventBus -) - -// Later, when app is ready -await amplitudeProvider.fetch() // Signal emitted -``` - ---- - -## 6. Rich Data Transformation - -For Codable types, we bridge to swift-config's `ExpressibleByConfigString` protocol via an internal wrapper. - -### Internal Bridge Type - -```swift -internal struct JSONDecodableValue: ExpressibleByConfigString { - let value: T - - init?(configString: String) { - guard let data = configString.data(using: .utf8), - let decoded = try? JSONDecoder().decode(T.self, from: data) else { - return nil - } - self.value = decoded - } -} -``` - -### Codable Access Implementation - -```swift -func value(for variable: ConfigVariable) -> T { - if let wrapped: JSONDecodableValue = reader.string( - forKey: variable.key, - as: JSONDecodableValue.self - ) { - return wrapped.value - } - return variable.fallback -} -``` - -**Benefits:** -- Consumers use `Codable` directly — no extra conformance needed -- Leverages swift-config's intended extensibility (`ExpressibleByConfigString`) -- DevConfig owns bridging logic internally - -**Limitation:** Fallthrough on transform failure depends on swift-config's behavior (unverified). If unsupported, transform failure returns fallback without trying next provider. - ---- - -## 7. Variable Registration - -Registration informs the reader of expected variables, stores fallback values as the lowest-priority provider, and enables configuration validation telemetry. - -### Registration API - -```swift -extension StructuredConfigReader { - func register(_ variable: ConfigVariable) { … } - func register(_ variable: ConfigVariable) { … } - func register(_ variable: ConfigVariable) { … } - func register(_ variable: ConfigVariable) { … } - func register(_ variable: ConfigVariable<[Bool]>) { … } - func register(_ variable: ConfigVariable<[String]>) { … } - func register(_ variable: ConfigVariable<[Int]>) { … } - func register(_ variable: ConfigVariable<[Double]>) { … } - func register(_ variable: ConfigVariable) where Value: Codable { … } -} -``` - -Usage: -```swift -structuredReader.register(.darkMode) -structuredReader.register(.timeout) -``` - -**Note:** Rich types require `Codable` (not just `Decodable`) to support registration — fallback values must be encoded for storage in the internal provider. - -### Internal Provider - -A custom `ConfigProvider` owned by `StructuredConfigReader`, inserted at lowest precedence: - -```swift -internal final class RegisteredVariablesProvider: ConfigProvider { - private let provider: MutableInMemoryProvider - private var registeredKeys: Set = [] - private var metadata: [String: VariableMetadata] = [:] // for editor UI - - init() { - self.provider = MutableInMemoryProvider( - name: "registered-variables", - initialValues: [:] - ) - } - - func register(_ variable: ConfigVariable) { - // Track registration - registeredKeys.insert(variable.key.description) - metadata[variable.key.description] = variable.metadata - - // Store value in composed provider - // (Implementation delegates to MutableInMemoryProvider's storage) - } - - func isRegistered(_ key: ConfigKey) -> Bool { - registeredKeys.contains(key.description) - } - - func metadata(for key: ConfigKey) -> VariableMetadata? { - metadata[key.description] - } - - // ConfigProvider conformance delegates to composed provider - // (snapshot, value lookup, etc.) -} -``` - -**Design Benefits:** -- Composes `MutableInMemoryProvider` instead of reimplementing storage -- Registration tracking (keys + metadata) stays separate from value storage -- Leverages swift-configuration's existing provider implementation - -### Precedence - -``` -1. Provider A (e.g., remote) -2. Provider B (e.g., JSON file) -3. RegisteredVariablesProvider ← internal, lowest priority -4. ConfigVariable.fallback ← inline, used only if all providers fail -``` - -### Registration Behavior - -- **Timing**: Not enforced. Variables can be accessed before registration; telemetry will flag this. -- **Duplicate keys**: Last registration wins; telemetry emitted for duplicate registration. -- **Distributed registration**: Subapps/modules can register their variables at app launch. - -### Telemetry - -- `DidAccessUnregisteredVariableBusEvent` — key not in `registeredKeys` -- `VariableTypeMismatchBusEvent` — decode failed using accessing type (implies registration/access type mismatch) -- `DuplicateVariableRegistrationBusEvent` — same key registered multiple times - ---- - -## 8. Variable Access Caching (Deferred) - -**Note:** Caching has been deferred. May be added later solely for telemetry deduplication. - -Original rationale: Caching avoids costly re-decoding and prevents over-emitting telemetry for variable access issues. - -### Cache Key - -```swift -struct CacheKey: Hashable, Sendable { - let variableName: String - let variableType: ObjectIdentifier - - init(_ variable: ConfigVariable) { - self.variableName = variable.key.description - self.variableType = ObjectIdentifier(T.self) - } -} -``` - -Different types for the same key produce different cache keys — type mismatch won't return stale cached value. - -### Cache Entry - -```swift -struct CacheEntry: Sendable { - let value: any Sendable -} -``` - -Type-erased storage; cast to expected type on access. - -### Access Pattern - -```swift -func value(for variable: ConfigVariable) -> T { - let cacheKey = CacheKey(variable) - - // Cache hit - if let entry = cache[cacheKey], - let resolved = entry.value as? T { - return resolved // No telemetry on cached access - } - - // Cache miss — resolve, emit telemetry, cache - let resolved = resolveFromProviders(variable) - cache[cacheKey] = CacheEntry(value: resolved) - emitAccessTelemetry(variable, resolved) - return resolved -} -``` - -### Cache Invalidation - -Cache clears when any provider is mutated: -- Remote provider fetch completes (`await provider.fetch()`) -- Local override via Editor UI -- Variable registration -- Any provider snapshot change (via swift-config's `watchSnapshot()`) - -### Telemetry Deduplication - -- Telemetry emitted once per key per cache lifecycle -- Cached access skips telemetry posting -- After invalidation, next access re-emits telemetry diff --git a/Plans/Implementation Plan.md b/Plans/Implementation Plan.md deleted file mode 100644 index 54ec1e9..0000000 --- a/Plans/Implementation Plan.md +++ /dev/null @@ -1,128 +0,0 @@ -# DevConfiguration Implementation Plan - -Created by Duncan Lewis, 2026-01-02 - ---- - -## Feature Inventory - -### Sliced for Implementation -- [X] Slice 1: ConfigVariable + StructuredConfigReader + Telemetry -- [ ] Slice 2: Remote provider support + update signals -- [ ] Slice 3: Registration + Metadata + RegisteredVariablesProvider -- [ ] Slice 4: Editor UI - -### Future Features (Deferred) -- [ ] Rich types (Codable) - may not be needed, can use multi-component ConfigKeys ("foo.bar") for nested access -- [ ] Access caching - may add later for telemetry deduplication only -- [ ] Configuration sets (enable/disable groups via Editor UI) - ---- - -## Implementation Slices - -### Slice 1: ConfigVariable + StructuredConfigReader + Telemetry -**Value:** End-to-end variable access with observability - -**Architecture:** -- **StructuredConfigReader**: Single typed accessor with telemetry -- Consumers manage their own provider stacks -- Protocol extensions provide typed access - -**Scope:** -- ConfigVariable struct with ConfigKey storage (primitives + arrays: Bool, String, Int, Double, [Bool], [String], [Int], [Double]) -- StructuredConfigurationReading protocol (8 method overloads: 4 primitives + 4 arrays) -- StructuredConfigReader (single public type): - - Init with providers array + eventBus (consumers pass their own providers) - - EventBusAccessReporter integration (AccessReporter protocol) - - Protocol extension implementations using required accessors (requiredBool(), requiredStringArray(), etc.) - - Error handling: catch errors, return fallback - - Composes ConfigReader internally -- Telemetry events using ConfigContent (from swift-configuration): - - DidAccessVariableBusEvent (via AccessReporter) - - VariableResolutionFailedBusEvent (on error) - ---- - -### Slice 2: Remote Provider Support + Update Signals -**Value:** Async configuration sources and change notification - -**Scope:** -- RemoteConfigProvider protocol (isReady, fetch()) -- StructuredConfigReader async init (if needed) -- Provider lifecycle patterns -- Update signal mechanism (decide: @Observable vs AsyncStream vs callback) -- **Validation**: Verify deep keypath access with multi-component ConfigKeys (e.g., "user.settings.theme") - ---- - -### Slice 3: Registration + Metadata + Fallbacks -**Value:** Variable validation and extensibility - -**Scope:** -- VariableMetadataKey protocol -- VariableMetadata struct (subscript access) -- ConfigVariable metadata storage + .metadata(_:_:) builder -- ConfigVariable dynamic member lookup for metadata -- RegisteredVariablesProvider (internal ConfigProvider composing MutableInMemoryProvider) - - Created internally by StructuredConfigReader - - Automatically added to end of provider array (lowest precedence) -- StructuredConfigReader.register() method overloads (8 concrete + arrays as needed) -- DidAccessUnregisteredVariableBusEvent -- DuplicateVariableRegistrationBusEvent - ---- - -### Slice 4: Editor UI -**Value:** Runtime configuration override interface - -**Scope:** -- TBD based on architecture decisions -- Provider-based UI presentation (providers manage their own UI) - ---- - -## Context - -### Simplified Architecture -- **StructuredConfigReader**: Single public type for typed configuration access - - Init with explicit provider array (consumers manage their own stack) - - Internally creates RegisteredVariablesProvider (Slice 3) appended to provider array - - Integrates with swift-configuration's AccessReporter for telemetry - - Implements StructuredConfigurationReading via protocol extensions - - Composes ConfigReader internally - - No caching (may add later for telemetry deduplication only) - -### Type System -- Primitives: Bool, String, Int, Double (no Float) -- Arrays: [Bool], [String], [Int], [Double] -- Type dispatch: Method overloads for compile-time resolution -- ConfigKey storage: ConfigVariable stores ConfigKey (not String) with two initializers -- Nested access: Use multi-component ConfigKeys ("user.settings.theme") instead of Codable types - -### Provider Precedence -Consumers pass their own provider array. Typical precedence pattern: -1. High-priority providers (remote/dynamic sources) -2. Mid-priority providers (environment, CLI args, files) -3. RegisteredVariablesProvider (internal, auto-added by StructuredConfigReader - Slice 3) -4. ConfigVariable.fallback (inline, used if all providers fail) - -### Telemetry Behavior -- Emitted via EventBus (passed at init) -- Success: Posted automatically via EventBusAccessReporter (AccessReporter integration) -- Failure: Posted directly from catch blocks -- Uses ConfigContent from swift-configuration (not custom enum) -- Errors don't propagate to callers -- No caching (telemetry posted on every access) - -### Codable Bridge Strategy (Deferred) -- Internal JSONDecodableValue wrapper conforms to ExpressibleByConfigString -- Consumers use Codable directly, no additional conformance -- Fallback provider stores Codable types as JSON-encoded strings -- **Note:** May not be needed - multi-component ConfigKeys ("foo.bar") provide nested access - -### Open Decisions -- Update signal mechanism (Slice 2): @Observable vs AsyncStream vs callback -- Deep keypath access validation (Slice 2): Verify multi-component ConfigKeys work with remote providers -- ExpressibleByConfigString fallthrough on init failure (deferred, impacts Codable support if implemented) -- Editor UI approach (Slice 4): Provider-managed vs centralized UI diff --git a/README.md b/README.md index f6d29bc..fa6c9bb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DevConfiguration -DevConfiguration is a type-safe configuration wrapper built on Apple's swift-configuration library. +DevConfiguration is a type-safe configuration wrapper built on Apple’s swift-configuration library. It provides structured configuration management with telemetry, extensible metadata, and a variable management interface. @@ -22,8 +22,8 @@ public interfaces are fully documented and tested. We aim for overall test cover To set up the development environment: - 1. Run `Scripts/install-git-hooks` to install pre-commit hooks that automatically check code - formatting. + 1. Run `Scripts/install-git-hooks` to install pre-commit hooks that automatically check + code formatting. 2. Use `Scripts/lint` to manually check code formatting at any time. 3. Use `Scripts/format` to automatically format code. diff --git a/Sources/DevConfiguration/Access Reporting/ConfigVariableDecodingFailedEvent.swift b/Sources/DevConfiguration/Access Reporting/ConfigVariableDecodingFailedEvent.swift new file mode 100644 index 0000000..f9f3ed4 --- /dev/null +++ b/Sources/DevConfiguration/Access Reporting/ConfigVariableDecodingFailedEvent.swift @@ -0,0 +1,38 @@ +// +// ConfigVariableDecodingFailedEvent.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/5/2026. +// + +import Configuration +import DevFoundation + +/// A bus event posted when a configuration variable’s raw value is found but cannot be decoded. +/// +/// This is distinct from ``ConfigVariableAccessFailedEvent``, which indicates the key itself could not be resolved +/// from any provider. A decoding failure means the provider returned a value, but it could not be decoded into the +/// expected type. +public struct ConfigVariableDecodingFailedEvent: BusEvent { + /// The configuration key whose value could not be decoded. + public let key: AbsoluteConfigKey + + /// The type that the value was being decoded into. + public let targetType: Any.Type + + /// The decoding error. + public let error: any Error + + + /// Creates a new `ConfigVariableDecodingFailedEvent` with the specified parameters. + /// + /// - Parameters: + /// - key: The configuration key whose value could not be decoded. + /// - targetType: The type that the value was being decoded into. + /// - error: The decoding error. + public init(key: AbsoluteConfigKey, targetType: Any.Type, error: any Error) { + self.key = key + self.targetType = targetType + self.error = error + } +} diff --git a/Sources/DevConfiguration/Core/CodableValueRepresentation.swift b/Sources/DevConfiguration/Core/CodableValueRepresentation.swift new file mode 100644 index 0000000..1cdb608 --- /dev/null +++ b/Sources/DevConfiguration/Core/CodableValueRepresentation.swift @@ -0,0 +1,156 @@ +// +// CodableValueRepresentation.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/5/2026. +// + +import Configuration +import Foundation + +/// Describes how a `Codable` configuration value is represented within the configuration provider. +/// +/// This type determines which `ConfigContent` case the reader pulls from when decoding a `Codable` value, and which +/// case the encoder writes to when storing a value for registration. +/// +/// `CodableValueRepresentation` is a struct with a private backing enum, allowing new representations to be added in +/// the future without breaking existing consumers. +public struct CodableValueRepresentation: Sendable { + /// The underlying kinds of representations that a `Codable` value can be. + /// + /// This enum exists so that we can add new representations without breaking the public API. + private enum Kind: Sendable { + /// Indicates that the value is stored as a string with the specified encoding. + case string(encoding: String.Encoding) + + /// Indicates that the value is stored as bytes. + case data + } + + + /// The underlying kind of this representation. + private let kind: Kind + + + /// The value is stored as a `ConfigContent.string`. + /// + /// The given encoding is used to convert between `String` and `Data` for the decoder and encoder. + /// + /// - Parameter encoding: The string encoding to use. Defaults to `.utf8`. + public static func string(encoding: String.Encoding = .utf8) -> CodableValueRepresentation { + CodableValueRepresentation(kind: .string(encoding: encoding)) + } + + + /// The value is stored as `ConfigContent.bytes`. + public static var data: CodableValueRepresentation { + CodableValueRepresentation(kind: .data) + } + + + /// Whether this representation uses string-backed storage. + var isStringBacked: Bool { + switch kind { + case .string: + true + case .data: + false + } + } + + + /// Reads raw data synchronously from the reader based on this representation. + /// + /// For string-backed representations, this reads a string value and converts it to `Data` using the representation’s + /// encoding. For data-backed representations, this reads a byte array and wraps it in `Data`. + /// + /// - Parameters: + /// - reader: The configuration reader to read from. + /// - key: The configuration key to look up. + /// - isSecret: Whether the value should be treated as a secret for access reporting. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The raw data for the key, or `nil` if the key was not found. + func readData( + from reader: ConfigReader, + forKey key: ConfigKey, + isSecret: Bool, + fileID: String, + line: UInt + ) -> Data? { + switch kind { + case .string(let encoding): + reader.string(forKey: key, isSecret: isSecret, fileID: fileID, line: line)? + .data(using: encoding) + case .data: + reader.bytes(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + .map { Data($0) } + } + } + + + /// Fetches raw data asynchronously from the reader based on this representation. + /// + /// This is the async counterpart of ``readData(from:forKey:isSecret:fileID:line:)``. + /// + /// - Parameters: + /// - reader: The configuration reader to fetch from. + /// - key: The configuration key to look up. + /// - isSecret: Whether the value should be treated as a secret for access reporting. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - Returns: The raw data for the key, or `nil` if the key was not found. + func fetchData( + from reader: ConfigReader, + forKey key: ConfigKey, + isSecret: Bool, + fileID: String, + line: UInt + ) async throws -> Data? { + switch kind { + case .string(let encoding): + try await reader.fetchString(forKey: key, isSecret: isSecret, fileID: fileID, line: line)? + .data(using: encoding) + case .data: + try await reader.fetchBytes(forKey: key, isSecret: isSecret, fileID: fileID, line: line) + .map { Data($0) } + } + } + + + /// Watches for raw data changes from the reader based on this representation. + /// + /// Each time the underlying configuration value changes, `onUpdate` is called with the new raw data (or `nil` if the + /// key is no longer present). + /// + /// - Parameters: + /// - reader: The configuration reader to watch. + /// - key: The configuration key to watch. + /// - isSecret: Whether the value should be treated as a secret for access reporting. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. + /// - onUpdate: A closure invoked with the updated raw data each time the value changes. + func watchData( + from reader: ConfigReader, + forKey key: ConfigKey, + isSecret: Bool, + fileID: String, + line: UInt, + onUpdate: @Sendable (Data?) -> Void + ) async throws { + switch kind { + case .string(let encoding): + try await reader.watchString(forKey: key, isSecret: isSecret, fileID: fileID, line: line) { (updates) in + for await value in updates { + onUpdate(value?.data(using: encoding)) + } + } + case .data: + try await reader.watchBytes(forKey: key, isSecret: isSecret, fileID: fileID, line: line) { (updates) in + for await value in updates { + onUpdate(value.map { Data($0) }) + } + } + } + } +} diff --git a/Sources/DevConfiguration/Core/ConfigVariable.swift b/Sources/DevConfiguration/Core/ConfigVariable.swift index 4fcdb4c..bcba29e 100644 --- a/Sources/DevConfiguration/Core/ConfigVariable.swift +++ b/Sources/DevConfiguration/Core/ConfigVariable.swift @@ -9,31 +9,36 @@ import Configuration /// A type-safe variable definition with a default value. /// -/// `ConfigVariable` encapsulates a configuration key, its default value, its secrecy, and any custom metadata that -/// might be attached to it. Using configuration variables ensures that variables will be read using the correct type -/// and default value. +/// `ConfigVariable` encapsulates a configuration key, its default value, its content, its secrecy, and any custom +/// metadata that might be attached to it. Using configuration variables ensures that variables will be read using the +/// correct type and default value. /// -/// ``ConfigVariableReader``s are used to read the value of a config variable. While `ConfigVariable` is a generic type, -/// `ConfigVariableReader` only supports reading variables whose `Value` is one of: +/// For primitive types, create a variable without specifying content — the appropriate content is set automatically: /// -/// - `Bool` -/// - `Data` -/// - `Float64` or `Double` -/// - `Int` -/// - `String` -/// - `[Bool]` -/// - `[Data]` -/// - `[Float64]` or `[Double]` -/// - `[Int]` -/// - `[String]` +/// static let timeout = ConfigVariable(key: "timeout", defaultValue: 30) +/// static let darkMode = ConfigVariable(key: "feature.darkMode", defaultValue: false) +/// +/// For `Codable` types, specify the content explicitly: +/// +/// static let experiment = ConfigVariable( +/// key: "experiment.onboarding", +/// defaultValue: ExperimentConfig.default, +/// content: .json() +/// ) @dynamicMemberLookup public struct ConfigVariable: Sendable where Value: Sendable { - /// The configuration key used to look up this variable's value. + /// A typealias for the content type associated with this variable’s value type. + public typealias Content = ConfigVariableContent + + /// 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 + /// Describes how this variable’s value maps to and from `ConfigContent` primitives. + public let content: Content + /// Whether this value should be treated as a secret. public let secrecy: ConfigVariableSecrecy @@ -41,17 +46,17 @@ public struct ConfigVariable: Sendable where Value: Sendable { private(set) var metadata = ConfigVariableMetadata() - /// Creates a configuration variable with the specified `ConfigKey`. - /// - /// Use this initializer when you need to specified the `ConfigKey` directly. + /// Creates a configuration variable with the specified `ConfigKey` and content. /// /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. + /// - content: Describes how the value maps to and from `ConfigContent` primitives. /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) { + public init(key: ConfigKey, defaultValue: Value, content: Content, secrecy: ConfigVariableSecrecy = .auto) { self.key = key self.defaultValue = defaultValue + self.content = content self.secrecy = secrecy } @@ -101,16 +106,289 @@ public struct ConfigVariable: Sendable where Value: Sendable { } +// MARK: - Primitive Initializers + +extension ConfigVariable where Value == Bool { + /// Creates a `Bool` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/bool`` automatically. + /// + /// - 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: Bool, secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .bool, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == [Bool] { + /// Creates a `[Bool]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/boolArray`` automatically. + /// + /// - 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: [Bool], secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .boolArray, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == Float64 { + /// Creates a `Float64` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/float64`` automatically. + /// + /// - 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: Float64, secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .float64, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == [Float64] { + /// Creates a `[Float64]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/float64Array`` automatically. + /// + /// - 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: [Float64], secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .float64Array, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == Int { + /// Creates an `Int` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/int`` automatically. + /// + /// - 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: Int, secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .int, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == [Int] { + /// Creates an `[Int]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/intArray`` automatically. + /// + /// - 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: [Int], secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .intArray, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == String { + /// Creates a `String` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/string`` automatically. + /// + /// - 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: String, secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .string, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == [String] { + /// Creates a `[String]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/stringArray`` automatically. + /// + /// - 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: [String], secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .stringArray, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == [UInt8] { + /// Creates a `[UInt8]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/bytes`` automatically. + /// + /// - 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: [UInt8], secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .bytes, secrecy: secrecy) + } +} + + +extension ConfigVariable where Value == [[UInt8]] { + /// Creates a `[[UInt8]]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/byteChunkArray`` automatically. + /// + /// - 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: [[UInt8]], secrecy: ConfigVariableSecrecy = .auto) { + self.init(key: key, defaultValue: defaultValue, content: .byteChunkArray, secrecy: secrecy) + } +} + + +// MARK: - String-Convertible Initializers + +extension ConfigVariable { + /// Creates a `RawRepresentable` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/rawRepresentableString()`` automatically. The value is resolved by + /// reading a string from the provider and converting it using the type’s `RawRepresentable` conformance. + /// + /// - 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) + where Value: RawRepresentable & Sendable, Value.RawValue == String { + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableString(), secrecy: secrecy) + } +} + + +extension ConfigVariable { + /// Creates a `[RawRepresentable]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/rawRepresentableStringArray()`` automatically. + /// + /// - 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: [Element], secrecy: ConfigVariableSecrecy = .auto) + where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == String { + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableStringArray(), secrecy: secrecy) + } +} + + +extension ConfigVariable { + /// Creates an `ExpressibleByConfigString` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/expressibleByConfigString()`` automatically. The value is resolved by + /// reading a string from the provider and converting it using the type’s `ExpressibleByConfigString` conformance. + /// + /// - 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) + where Value: ExpressibleByConfigString { + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigString(), secrecy: secrecy) + } +} + + extension ConfigVariable { - /// Creates a configuration variable with the specified string key. + /// Creates a `[ExpressibleByConfigString]` configuration variable. /// - /// The string is converted to a `ConfigKey` using the default initializer. + /// Content is set to ``ConfigVariableContent/expressibleByConfigStringArray()`` automatically. /// /// - Parameters: - /// - key: The configuration key as a string (e.g., "feature.darkMode"). + /// - key: The configuration key. + /// - defaultValue: The default value to use when variable resolution fails. + /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. + public init(key: ConfigKey, defaultValue: [Element], secrecy: ConfigVariableSecrecy = .auto) + where Value == [Element], Element: ExpressibleByConfigString & Sendable { + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigStringArray(), secrecy: secrecy) + } +} + + +// MARK: - Int-Convertible Initializers + +extension ConfigVariable { + /// Creates a `RawRepresentable` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/rawRepresentableInt()`` automatically. The value is resolved by + /// reading an integer from the provider and converting it using the type’s `RawRepresentable` conformance. + /// + /// - 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) + where Value: RawRepresentable & Sendable, Value.RawValue == Int { + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableInt(), secrecy: secrecy) + } +} + + +extension ConfigVariable { + /// Creates a `[RawRepresentable]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/rawRepresentableIntArray()`` automatically. + /// + /// - 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: [Element], secrecy: ConfigVariableSecrecy = .auto) + where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == Int { + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableIntArray(), secrecy: secrecy) + } +} + + +extension ConfigVariable { + /// Creates an `ExpressibleByConfigInt` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/expressibleByConfigInt()`` automatically. The value is resolved by + /// reading an integer from the provider and converting it using the type’s `ExpressibleByConfigInt` conformance. + /// + /// - 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) + where Value: ExpressibleByConfigInt { + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigInt(), secrecy: secrecy) + } +} + + +extension ConfigVariable { + /// Creates a `[ExpressibleByConfigInt]` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/expressibleByConfigIntArray()`` automatically. + /// + /// - 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: String, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: ConfigKey(key), defaultValue: defaultValue, secrecy: secrecy) + public init(key: ConfigKey, defaultValue: [Element], secrecy: ConfigVariableSecrecy = .auto) + where Value == [Element], Element: ExpressibleByConfigInt & Sendable { + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigIntArray(), secrecy: secrecy) } } diff --git a/Sources/DevConfiguration/Core/ConfigVariableContent.swift b/Sources/DevConfiguration/Core/ConfigVariableContent.swift new file mode 100644 index 0000000..94cd678 --- /dev/null +++ b/Sources/DevConfiguration/Core/ConfigVariableContent.swift @@ -0,0 +1,911 @@ +// +// ConfigVariableContent.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/5/2026. +// + +import Configuration +import DevFoundation +import Foundation + +/// Describes how a ``ConfigVariable`` value maps to and from `ConfigContent` primitives. +/// +/// `ConfigVariableContent` encapsulates which `ConfigReader` method to call, how to decode the raw primitive into the +/// variable’s value type, and how to encode the value back for registration. It also determines secrecy behavior based +/// on the underlying content type. +/// +/// For primitive types like `Bool`, `Int`, `String`, etc., you typically don’t need to interact with this type +/// directly — ``ConfigVariable`` initializers set the appropriate content automatically. For `Codable` types, you +/// specify the content explicitly using factories like ``json(representation:decoder:encoder:)`` or +/// ``propertyList(representation:decoder:encoder:)``: +/// +/// let experiment = ConfigVariable( +/// key: "experiment.onboarding", +/// defaultValue: ExperimentConfig.default, +/// content: .json() +/// ) +public struct ConfigVariableContent: Sendable where Value: Sendable { + /// Whether `.auto` secrecy treats this content type as secret. + public let isAutoSecret: Bool + + /// Reads the value synchronously from a `ConfigReader`. + let read: + @Sendable ( + _ reader: ConfigReader, + _ key: ConfigKey, + _ isSecret: Bool, + _ defaultValue: Value, + _ eventBus: EventBus, + _ fileID: String, + _ line: UInt + ) -> Value + + /// Fetches the value asynchronously from a `ConfigReader`. + let fetch: + @Sendable ( + _ reader: ConfigReader, + _ key: ConfigKey, + _ isSecret: Bool, + _ defaultValue: Value, + _ eventBus: EventBus, + _ fileID: String, + _ line: UInt + ) async throws -> Value + + /// Watches for value changes, yielding decoded values to the continuation. + let startWatching: + @Sendable ( + _ reader: ConfigReader, + _ key: ConfigKey, + _ isSecret: Bool, + _ defaultValue: Value, + _ eventBus: EventBus, + _ fileID: String, + _ line: UInt, + _ continuation: AsyncStream.Continuation + ) async throws -> Void +} + + +// MARK: - Primitive Content Factories + +extension ConfigVariableContent where Value == Bool { + /// Content for `Bool` values. + public static var bool: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.bool(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchBool( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchBool( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == [Bool] { + /// Content for `[Bool]` values. + public static var boolArray: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.boolArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchBoolArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchBoolArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == Float64 { + /// Content for `Float64` values. + public static var float64: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.double(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchDouble( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchDouble( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == [Float64] { + /// Content for `[Float64]` values. + public static var float64Array: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.doubleArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchDoubleArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchDoubleArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == Int { + /// Content for `Int` values. + public static var int: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.int(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchInt( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchInt( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == [Int] { + /// Content for `[Int]` values. + public static var intArray: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.intArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchIntArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchIntArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == String { + /// Content for `String` values. + public static var string: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: true, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.string(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchString( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchString( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == [String] { + /// Content for `[String]` values. + public static var stringArray: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: true, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.stringArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchStringArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchStringArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == [UInt8] { + /// Content for `[UInt8]` (bytes) values. + public static var bytes: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.bytes(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchBytes( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchBytes( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +extension ConfigVariableContent where Value == [[UInt8]] { + /// Content for `[[UInt8]]` (byte chunk array) values. + public static var byteChunkArray: ConfigVariableContent { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.byteChunkArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchByteChunkArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchByteChunkArray( + forKey: key, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +// MARK: - String-Convertible Content Factories + +extension ConfigVariableContent { + /// Content for `RawRepresentable` values. + public static func rawRepresentableString() -> ConfigVariableContent + where Value: RawRepresentable & Sendable, Value.RawValue == String { + ConfigVariableContent( + isAutoSecret: true, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.string( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchString( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchString( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } + + + /// Content for `[RawRepresentable]` values. + public static func rawRepresentableStringArray() -> ConfigVariableContent + where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == String { + ConfigVariableContent( + isAutoSecret: true, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.stringArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchStringArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchStringArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } + + + /// Content for `ExpressibleByConfigString` values. + public static func expressibleByConfigString() -> ConfigVariableContent where Value: ExpressibleByConfigString { + ConfigVariableContent( + isAutoSecret: true, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.string( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchString( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchString( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } + + + /// Content for `[ExpressibleByConfigString]` values. + public static func expressibleByConfigStringArray() -> ConfigVariableContent + where Value == [Element], Element: ExpressibleByConfigString & Sendable { + ConfigVariableContent( + isAutoSecret: true, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.stringArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchStringArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchStringArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +// MARK: - Int-Convertible Content Factories + +extension ConfigVariableContent { + /// Content for `RawRepresentable` values. + public static func rawRepresentableInt() -> ConfigVariableContent + where Value: RawRepresentable & Sendable, Value.RawValue == Int { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.int( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchInt( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchInt( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } + + + /// Content for `[RawRepresentable]` values. + public static func rawRepresentableIntArray() -> ConfigVariableContent + where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == Int { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.intArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchIntArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchIntArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } + + + /// Content for `ExpressibleByConfigInt` values. + public static func expressibleByConfigInt() -> ConfigVariableContent where Value: ExpressibleByConfigInt { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.int( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchInt( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchInt( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } + + + /// Content for `[ExpressibleByConfigInt]` values. + public static func expressibleByConfigIntArray() -> ConfigVariableContent + where Value == [Element], Element: ExpressibleByConfigInt & Sendable { + ConfigVariableContent( + isAutoSecret: false, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.intArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchIntArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchIntArray( + forKey: key, + as: Element.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + } + ) + } +} + + +// MARK: - Codable Content Factories + +extension ConfigVariableContent { + /// Content for JSON-encoded `Codable` values. + /// + /// - Parameters: + /// - representation: How the JSON value is represented in the provider. Defaults to `.string()`. + /// - decoder: The JSON decoder to use. If `nil`, a default `JSONDecoder` is created when needed. + /// - encoder: The JSON encoder to use. If `nil`, a default `JSONEncoder` is created when needed. + public static func json( + representation: CodableValueRepresentation = .string(), + decoder: JSONDecoder? = nil, + encoder: JSONEncoder? = nil + ) -> ConfigVariableContent where Value: Codable { + codable( + representation: representation, + decoder: decoder as (any TopLevelDecoder & Sendable)?, + encoder: encoder as (any TopLevelEncoder & Sendable)? + ) + } + + + /// Content for property list-encoded `Codable` values. + /// + /// - Parameters: + /// - representation: How the property list value is represented in the provider. Defaults to `.data`. + /// - decoder: The property list decoder to use. If `nil`, a default `PropertyListDecoder` is created when needed. + /// - encoder: The property list encoder to use. If `nil`, a default `PropertyListEncoder` is created when needed. + public static func propertyList( + representation: CodableValueRepresentation = .data, + decoder: PropertyListDecoder? = nil, + encoder: PropertyListEncoder? = nil + ) -> ConfigVariableContent where Value: Codable { + codable( + representation: representation, + decoder: decoder as (any TopLevelDecoder & Sendable)?, + encoder: encoder as (any TopLevelEncoder & Sendable)? + ) + } + + + /// Creates content for a `Codable` value using the specified representation, decoder, and encoder. + private static func codable( + representation: CodableValueRepresentation, + decoder: (any TopLevelDecoder & Sendable)?, + encoder: (any TopLevelEncoder & Sendable)? + ) -> ConfigVariableContent where Value: Codable { + ConfigVariableContent( + isAutoSecret: representation.isStringBacked, + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + guard + let data = representation.readData( + from: reader, + forKey: key, + isSecret: isSecret, + fileID: fileID, + line: line + ) + else { + return defaultValue + } + + let resolvedDecoder = decoder ?? JSONDecoder() + do { + return try resolvedDecoder.decode(Value.self, from: data) + } catch { + eventBus.post( + ConfigVariableDecodingFailedEvent( + key: AbsoluteConfigKey(key), + targetType: Value.self, + error: error + ) + ) + return defaultValue + } + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + guard + let data = try await representation.fetchData( + from: reader, + forKey: key, + isSecret: isSecret, + fileID: fileID, + line: line + ) + else { + return defaultValue + } + + let resolvedDecoder = decoder ?? JSONDecoder() + do { + return try resolvedDecoder.decode(Value.self, from: data) + } catch { + eventBus.post( + ConfigVariableDecodingFailedEvent( + key: AbsoluteConfigKey(key), + targetType: Value.self, + error: error + ) + ) + return defaultValue + } + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + let resolvedDecoder = decoder ?? JSONDecoder() + + try await representation.watchData( + from: reader, + forKey: key, + isSecret: isSecret, + fileID: fileID, + line: line + ) { data in + if let data { + do { + continuation.yield(try resolvedDecoder.decode(Value.self, from: data)) + return + } catch { + eventBus.post( + ConfigVariableDecodingFailedEvent( + key: AbsoluteConfigKey(key), + targetType: Value.self, + error: error + ) + ) + } + } + continuation.yield(defaultValue) + } + } + ) + } +} diff --git a/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift b/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift index 570e673..f87c6e0 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift @@ -43,7 +43,7 @@ import Foundation public struct ConfigVariableMetadata: Hashable, Sendable { /// A structure containing human-readable text representations of a metadata key-value pair. /// - /// `DisplayText` pairs a metadata key's display name with the formatted string representation of its value. These + /// `DisplayText` pairs a metadata key’s display name with the formatted string representation of its value. These /// representations are intended for use in user interfaces, logs, and debugging output. struct DisplayText: Hashable, Sendable { /// The human-readable display name for the metadata key (e.g., "Project", "Environment"). @@ -64,8 +64,8 @@ public struct ConfigVariableMetadata: Hashable, Sendable { /// metadata key type. /// /// This dictionary maintains human-readable representations of stored metadata values for use in user interfaces, - /// logs, and debugging output. Each entry maps a metadata key's `ObjectIdentifier` to a `DisplayText` structure - /// containing both the key's display name and the formatted value. + /// logs, and debugging output. Each entry maps a metadata key’s `ObjectIdentifier` to a `DisplayText` structure + /// containing both the key’s display name and the formatted value. private var displayText: [ObjectIdentifier: DisplayText] = [:] @@ -77,10 +77,10 @@ public struct ConfigVariableMetadata: Hashable, Sendable { /// Accesses the metadata value associated with the given key type. /// - /// Returns the key's `defaultValue` if no value has been explicitly set. + /// Returns the key’s `defaultValue` if no value has been explicitly set. /// /// - Parameter key: The metadata key type that identifies which metadata value to access. - /// - Returns: The stored value for the given key, or the key's `defaultValue` if no value has been set. + /// - Returns: The stored value for the given key, or the key’s `defaultValue` if no value has been set. public subscript(key: Key.Type) -> Key.Value where Key: ConfigVariableMetadataKey { get { let defaultValue = key.defaultValue @@ -97,7 +97,7 @@ public struct ConfigVariableMetadata: Hashable, Sendable { /// Returns an array of all display text representations for the metadata values currently stored in this container. /// /// This property provides access to human-readable key-value pairs representing all metadata that has been - /// explicitly set. Each `DisplayText` entry contains both the metadata key's display name and the formatted value. + /// explicitly set. Each `DisplayText` entry contains both the metadata key’s display name and the formatted value. /// /// The returned array is unordered and includes only metadata that has been assigned through the subscript setter. /// Metadata keys that still have their default values are not included in the results. @@ -151,7 +151,7 @@ public protocol ConfigVariableMetadataKey { /// The default value returned when no value has been explicitly set for this metadata key. /// - /// This value is used by ``ConfigVariableMetadata``'s subscript when retrieving a value for a key that has not + /// This value is used by ``ConfigVariableMetadata``’s subscript when retrieving a value for a key that has not /// been assigned. For optional metadata, this is typically `nil`. For required metadata, provide a sensible /// default that represents the absence of explicit configuration. static var defaultValue: Value { get } diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index 57887db..628d34e 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -8,9 +8,9 @@ import Configuration import DevFoundation -/// Provides structured access to configuration values queried by a `ConfigVariable`. +/// Provides access to configuration values queried by a `ConfigVariable`. /// -/// A config variable reader is a type-safe wrapper around swift-configuration's `ConfigReader`. It uses +/// 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. /// @@ -36,7 +36,7 @@ import DevFoundation /// /// let darkMode = reader[.darkMode] // true /// -/// The reader never throws. If resolution fails, it returns the variable's default value and posts a +/// 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. @@ -45,11 +45,14 @@ public struct ConfigVariableReader { /// The configuration reader that is used to resolve configuration values. public let reader: ConfigReader - /// The configuration reader's providers. + /// The configuration reader’s providers. /// /// This is stored so that public let providers: [any ConfigProvider] + /// The event bus used to post diagnostic events like ``ConfigVariableDecodingFailedEvent``. + public let eventBus: EventBus + /// Creates a new `ConfigVariableReader` with the specified providers and the default telemetry access reporter. /// @@ -61,887 +64,169 @@ public struct ConfigVariableReader { public init(providers: [any ConfigProvider], eventBus: EventBus) { self.init( providers: providers, - accessReporter: EventBusAccessReporter(eventBus: eventBus) + accessReporter: EventBusAccessReporter(eventBus: eventBus), + eventBus: eventBus ) } - /// Creates a new `ConfigVariableReader` with the specified providers and access reporter. + /// Creates a new `ConfigVariableReader` with the specified providers, access reporter, and event bus. /// /// 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) { + /// - eventBus: The event bus used to post diagnostic events. + public init(providers: [any ConfigProvider], accessReporter: any AccessReporter, eventBus: EventBus) { self.accessReporter = accessReporter self.reader = ConfigReader(providers: providers, accessReporter: accessReporter) self.providers = providers + self.eventBus = eventBus } } -// MARK: - Get - -extension ConfigVariableReader { - /// Gets the value for the specified `Bool` config variable. - /// - /// - 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 - ) -> Bool { - return reader.bool( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `[Bool]` config variable. - /// - /// - 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<[Bool]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [Bool] { - return reader.boolArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `Float64` config variable. - /// - /// - 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 - ) -> Float64 { - return reader.double( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `[Float64]` config variable. - /// - /// - 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<[Float64]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [Float64] { - return reader.doubleArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `Int` config variable. - /// - /// - 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 - ) -> Int { - return reader.int( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `[Int]` config variable. - /// - /// - 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<[Int]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [Int] { - return reader.intArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `String` config variable. - /// - /// - 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 - ) -> String { - return reader.string( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `[String]` config variable. - /// - /// - 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<[String]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [String] { - return reader.stringArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `[UInt8]` config variable. - /// - /// - 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<[UInt8]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [UInt8] { - return reader.bytes( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Gets the value for the specified `[[UInt8]]` config variable. - /// - /// - 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<[[UInt8]]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [[UInt8]] { - return reader.byteChunkArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } -} - - -// MARK: - Subscript Get +// MARK: - Value Access extension ConfigVariableReader { - /// Gets the value for the specified `Bool` config variable. - /// - /// - 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 - ) -> Bool { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `[Bool]` config variable. - /// - /// - 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<[Bool]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [Bool] { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `Float64` config variable. - /// - /// - 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 - ) -> Float64 { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `[Float64]` config variable. + /// Gets the value for the specified config variable. /// /// - 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<[Float64]>, + public func value( + for variable: ConfigVariable, fileID: String = #fileID, line: UInt = #line - ) -> [Float64] { - value(for: variable, fileID: fileID, line: line) + ) -> Value { + variable.content.read(reader, variable.key, isSecret(variable), variable.defaultValue, eventBus, fileID, line) } - /// Gets the value for the specified `Int` config variable. + /// Gets the value for the specified config variable. /// /// - 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 - ) -> Int { + public subscript( + variable: ConfigVariable, + fileID fileID: String = #fileID, + line line: UInt = #line + ) -> Value { value(for: variable, fileID: fileID, line: line) } - /// Gets the value for the specified `[Int]` config variable. - /// - /// - 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<[Int]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [Int] { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `String` config variable. - /// - /// - 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 - ) -> String { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `[String]` config variable. - /// - /// - 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<[String]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [String] { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `[UInt8]` config variable. - /// - /// - 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<[UInt8]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [UInt8] { - value(for: variable, fileID: fileID, line: line) - } - - - /// Gets the value for the specified `[[UInt8]]` config variable. - /// - /// - 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<[[UInt8]]>, - fileID: String = #fileID, - line: UInt = #line - ) -> [[UInt8]] { - value(for: variable, fileID: fileID, line: line) - } -} - - -// MARK: - Fetch - -extension ConfigVariableReader { - /// Asynchronously fetches the value for the specified `Bool` config variable. - /// - /// - 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 throws -> Bool { - return try await reader.fetchBool( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `[Bool]` config variable. - /// - /// - 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<[Bool]>, - fileID: String = #fileID, - line: UInt = #line - ) async throws -> [Bool] { - return try await reader.fetchBoolArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `Float64` config variable. - /// - /// - 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 throws -> Float64 { - return try await reader.fetchDouble( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `[Float64]` config variable. - /// - /// - 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<[Float64]>, - fileID: String = #fileID, - line: UInt = #line - ) async throws -> [Float64] { - return try await reader.fetchDoubleArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `Int` config variable. + /// Fetches the value for the specified config variable asynchronously. /// /// - 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, + public func fetchValue( + for variable: ConfigVariable, fileID: String = #fileID, line: UInt = #line - ) async throws -> Int { - return try await reader.fetchInt( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line + ) async throws -> Value { + try await variable.content.fetch( + reader, + variable.key, + isSecret(variable), + variable.defaultValue, + eventBus, + fileID, + line ) } - /// Asynchronously fetches the value for the specified `[Int]` config variable. + /// Watches a config variable for value changes. /// - /// - 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<[Int]>, - fileID: String = #fileID, - line: UInt = #line - ) async throws -> [Int] { - return try await reader.fetchIntArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `String` config variable. + /// The `updatesHandler` receives an `AsyncStream` of the variable’s decoded values, which yields a new element each + /// time the underlying configuration value changes. The return value of the handler is returned by this method. /// /// - Parameters: - /// - variable: The variable to fetch a value for. + /// - variable: The variable to watch. /// - 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, + /// - updatesHandler: A closure that receives a stream of updated values. + /// - Returns: The value returned by the `updatesHandler`. + public func watchValue( + for variable: ConfigVariable, fileID: String = #fileID, - line: UInt = #line - ) async throws -> String { - return try await reader.fetchString( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `[String]` config variable. - /// - /// - 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<[String]>, - fileID: String = #fileID, - line: UInt = #line - ) async throws -> [String] { - return try await reader.fetchStringArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `[UInt8]` config variable. - /// - /// - 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<[UInt8]>, - fileID: String = #fileID, - line: UInt = #line - ) async throws -> [UInt8] { - return try await reader.fetchBytes( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) - } - - - /// Asynchronously fetches the value for the specified `[[UInt8]]` config variable. - /// - /// - 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<[[UInt8]]>, - fileID: String = #fileID, - line: UInt = #line - ) async throws -> [[UInt8]] { - return try await reader.fetchByteChunkArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line - ) + line: UInt = #line, + updatesHandler: @Sendable @escaping (AsyncStream) async throws -> Return + ) async throws -> Return where Return: Sendable { + // Capture these locally so that the @Sendable task closures below don’t need to capture `self`. + let configReader = reader + let eventBus = eventBus + let isSecret = isSecret(variable) + let (stream, continuation) = AsyncStream.makeStream() + + // We use a task group with two concurrent tasks: one that watches the underlying provider for changes and + // yields decoded values into the stream, and one that passes the stream to the caller’s handler. The group’s + // element type is `Return?` so the watcher task can return `nil` while the handler task returns the caller’s + // result. + return try await withThrowingTaskGroup(of: Return?.self) { (group) in + // Task 1: Watch the provider for changes. Each time the raw value changes, the content’s startWatching + // closure decodes it and yields the result into the continuation. When watching ends (due to cancellation + // or the provider stopping), we finish the continuation so the handler’s stream terminates. + group.addTask { + defer { continuation.finish() } + try await variable.content.startWatching( + configReader, + variable.key, + isSecret, + variable.defaultValue, + eventBus, + fileID, + line, + continuation + ) + return nil + } + + // Task 2: Run the caller’s handler with the decoded value stream. + group.addTask { + return try await updatesHandler(stream) + } + + // Wait for the first non-nil result, which will be from the handler task. Once the handler returns, + // cancel the watcher task so the provider stops being observed. + for try await result in group { + if let result { + group.cancelAll() + return result + } + } + + // The handler task always returns a non-nil value, so we should never reach this point. + fatalError() + } } } -// MARK: - Watch +// MARK: - Secrecy extension ConfigVariableReader { - /// Watches for updates to the value for the specified `Bool` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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: ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchBool( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `[Bool]` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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<[Bool]>, - fileID: String = #fileID, - line: UInt = #line, - updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Bool], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchBoolArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `Float64` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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: ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchDouble( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `[Float64]` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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<[Float64]>, - fileID: String = #fileID, - line: UInt = #line, - updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Float64], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchDoubleArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `Int` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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: ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchInt( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `[Int]` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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<[Int]>, - fileID: String = #fileID, - line: UInt = #line, - updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[Int], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchIntArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `String` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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: ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchString( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `[String]` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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<[String]>, - fileID: String = #fileID, - line: UInt = #line, - updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[String], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchStringArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `[UInt8]` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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<[UInt8]>, - fileID: String = #fileID, - line: UInt = #line, - updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[UInt8], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchBytes( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) - } - - - /// Watches for updates to the value for the specified `[[UInt8]]` config variable. - /// - /// - Parameters: - /// - variable: The variable to watch for updates. - /// - fileID: The source file identifier for access reporting. - /// - line: The source line number for access reporting. - /// - 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<[[UInt8]]>, - fileID: String = #fileID, - line: UInt = #line, - updatesHandler: (_ updates: ConfigUpdatesAsyncSequence<[[UInt8]], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchByteChunkArray( - forKey: variable.key, - isSecret: variable.isSecret, - default: variable.defaultValue, - fileID: fileID, - line: line, - updatesHandler: updatesHandler - ) + /// Whether the given variable is secret. + /// + /// When secrecy is `.auto`, this defers to the variable’s content to determine the appropriate secrecy. + /// String- backed and codable content types default to secret, while numeric and boolean types default to public. + /// + /// - Parameter variable: The config variable whose secrecy is being determined. + func isSecret(_ variable: ConfigVariable) -> Bool { + let resolvedSecrecy = + variable.secrecy == .auto + ? (variable.content.isAutoSecret ? .secret : .public) + : variable.secrecy + return resolvedSecrecy == .secret } } diff --git a/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift b/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift index d4da697..3ee870d 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift @@ -5,12 +5,14 @@ // Created by Duncan Lewis on 1/7/2026. // -/// Controls whether a configuration variable's value is treated as secret. +import Configuration + +/// 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. + /// Treats `String`, `[String]`, and `String`-backed 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 @@ -22,31 +24,7 @@ public enum ConfigVariableSecrecy: CaseIterable, Sendable { /// 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. + /// Use this when you explicitly want values to be visible in logs and telemetry, even if they are strings, + /// string arrays, or string-backed. 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/Documentation.docc/Documentation.md b/Sources/DevConfiguration/Documentation.docc/Documentation.md index b06e8d8..0d158f2 100644 --- a/Sources/DevConfiguration/Documentation.docc/Documentation.md +++ b/Sources/DevConfiguration/Documentation.docc/Documentation.md @@ -5,7 +5,7 @@ A type-safe wrapper around Swift Configuration with conveniences for type safety ## Overview -DevConfiguration is a type-safe configuration wrapper built on Apple's Swift Configuration library. It provides +DevConfiguration is a type-safe configuration wrapper built on Apple’s Swift Configuration library. It provides configuration management with extensible metadata, a variable management UI, and access logging via the event bus. @@ -27,7 +27,9 @@ configuration management with extensible metadata, a variable management UI, and - ``EventBusAccessReporter`` - ``ConfigVariableAccessSucceededEvent`` - ``ConfigVariableAccessFailedEvent`` +- ``ConfigVariableDecodingFailedEvent`` ### Supporting Types -- ``ConfigValueReadable`` +- ``ConfigVariableContent`` +- ``CodableValueRepresentation`` diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift deleted file mode 100644 index 83f7272..0000000 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariable+SecrecyTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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 index d5e1347..812bb09 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift @@ -2,12 +2,13 @@ // ConfigVariableReaderTests.swift // DevConfiguration // -// Created by Prachi Gauriar on 2/16/2026. +// Created by Prachi Gauriar on 2/16/26. // import Configuration import DevFoundation import DevTesting +import Foundation import Testing @testable import DevConfiguration @@ -26,118 +27,485 @@ struct ConfigVariableReaderTests: RandomValueGenerating { ConfigVariableReader(providers: [provider], eventBus: eventBus) }() + /// Sets a value in the provider for the given key with a random `isSecret` flag. + private mutating func setProviderValue(_ content: ConfigContent, forKey key: ConfigKey) { + provider.setValue( + .init(content, isSecret: randomBool()), + forKey: .init(key) + ) + } + + + // 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 + ) + + let rawRepresentableStringVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: MockStringEnum.allCases.randomElement(using: &randomNumberGenerator)!, + secrecy: secrecy + ) + + let rawRepresentableStringArrayVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: Array(count: randomInt(in: 0 ... 5)) { + MockStringEnum.allCases.randomElement(using: &randomNumberGenerator)! + }, + secrecy: secrecy + ) + + let expressibleByConfigStringVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: MockConfigStringValue(configString: randomAlphanumericString())!, + secrecy: secrecy + ) + + let expressibleByConfigStringArrayVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: Array(count: randomInt(in: 0 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + }, + secrecy: secrecy + ) + + let rawRepresentableIntVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: MockIntEnum.allCases.randomElement(using: &randomNumberGenerator)!, + secrecy: secrecy + ) + + let rawRepresentableIntArrayVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: Array(count: randomInt(in: 0 ... 5)) { + MockIntEnum.allCases.randomElement(using: &randomNumberGenerator)! + }, + secrecy: secrecy + ) + + let expressibleByConfigIntVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: MockConfigIntValue(configInt: randomInt(in: .min ... .max))!, + secrecy: secrecy + ) + + let expressibleByConfigIntArrayVariable = ConfigVariable( + key: randomConfigKey(), + defaultValue: Array(count: randomInt(in: 0 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + }, + secrecy: secrecy + ) + + let isNotPublic = [.secret, .auto].contains(secrecy) + let isSecret = secrecy == .secret + + let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) + #expect(reader.isSecret(intVariable) == isSecret) + #expect(reader.isSecret(stringVariable) == isNotPublic) + #expect(reader.isSecret(stringArrayVariable) == isNotPublic) + #expect(reader.isSecret(rawRepresentableStringVariable) == isNotPublic) + #expect(reader.isSecret(rawRepresentableStringArrayVariable) == isNotPublic) + #expect(reader.isSecret(expressibleByConfigStringVariable) == isNotPublic) + #expect(reader.isSecret(expressibleByConfigStringArrayVariable) == isNotPublic) + #expect(reader.isSecret(rawRepresentableIntVariable) == isSecret) + #expect(reader.isSecret(rawRepresentableIntArrayVariable) == isSecret) + #expect(reader.isSecret(expressibleByConfigIntVariable) == isSecret) + #expect(reader.isSecret(expressibleByConfigIntArrayVariable) == isSecret) + } + // MARK: - Bool tests @Test mutating func valueForBoolReturnsProviderValue() { - testValueReturnsProviderValue(using: BoolTestHelper()) + // set up + let key = randomConfigKey() + let expectedValue = randomBool() + let defaultValue = !expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.bool(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test mutating func valueForBoolReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: BoolTestHelper()) + // set up + let key = randomConfigKey() + let defaultValue = randomBool() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == defaultValue) } @Test mutating func fetchValueForBoolReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: BoolTestHelper()) + // set up + let key = randomConfigKey() + let expectedValue = randomBool() + let defaultValue = !expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.bool(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test mutating func fetchValueForBoolReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: BoolTestHelper()) + // set up + let key = randomConfigKey() + let defaultValue = randomBool() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == defaultValue) } @Test mutating func watchValueForBoolReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: BoolTestHelper()) + // set up + let key = randomConfigKey() + let initialValue = randomBool() + let updatedValue = !initialValue + let defaultValue = randomBool() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.bool(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.bool(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } @Test mutating func subscriptBoolReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: BoolTestHelper()) + // set up + let key = randomConfigKey() + let expectedValue = randomBool() + let defaultValue = !expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.bool(expectedValue), forKey: key) + + // exercise + let result = reader[variable] + + // expect + #expect(result == expectedValue) } - // MARK: - [Bool] tests + // MARK: - Int tests @Test - mutating func valueForBoolArrayReturnsProviderValue() { - testValueReturnsProviderValue(using: BoolArrayTestHelper()) + mutating func valueForIntReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomInt(in: .min ... .max) + let defaultValue = randomInt(in: .min ... .max) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func valueForBoolArrayReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: BoolArrayTestHelper()) + mutating func fetchValueForIntReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomInt(in: .min ... .max) + let defaultValue = randomInt(in: .min ... .max) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForBoolArrayReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: BoolArrayTestHelper()) + mutating func watchValueForIntReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomInt(in: .min ... .max) + let updatedValue = randomInt(in: .min ... .max) + let defaultValue = randomInt(in: .min ... .max) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.int(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } + // MARK: - Float64 tests + @Test - mutating func fetchValueForBoolArrayReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: BoolArrayTestHelper()) + mutating func valueForFloat64ReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomFloat64(in: -100_000 ... 100_000) + let defaultValue = randomFloat64(in: -100_000 ... 100_000) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.double(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForBoolArrayReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: BoolArrayTestHelper()) + mutating func fetchValueForFloat64ReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomFloat64(in: -100_000 ... 100_000) + let defaultValue = randomFloat64(in: -100_000 ... 100_000) + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.double(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func subscriptBoolArrayReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: BoolArrayTestHelper()) + mutating func watchValueForFloat64ReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomFloat64(in: -100_000 ... 100_000) + let updatedValue = randomFloat64(in: -100_000 ... 100_000) + let defaultValue = randomFloat64(in: -100_000 ... 100_000) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.double(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.double(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - // MARK: - Float64 tests + // MARK: - [Bool] tests @Test - mutating func valueForFloat64ReturnsProviderValue() { - testValueReturnsProviderValue(using: Float64TestHelper()) + mutating func valueForBoolArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomBoolArray() + let defaultValue = randomBoolArray() + let variable = ConfigVariable<[Bool]>(key: key, defaultValue: defaultValue) + setProviderValue(.boolArray(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func valueForFloat64ReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: Float64TestHelper()) + mutating func fetchValueForBoolArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomBoolArray() + let defaultValue = randomBoolArray() + let variable = ConfigVariable<[Bool]>(key: key, defaultValue: defaultValue) + setProviderValue(.boolArray(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForFloat64ReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: Float64TestHelper()) + mutating func watchValueForBoolArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomBoolArray() + let updatedValue = randomBoolArray() + let defaultValue = randomBoolArray() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable<[Bool]>(key: key, defaultValue: defaultValue) + setProviderValue(.boolArray(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.boolArray(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } + // MARK: - [Int] tests + @Test - mutating func fetchValueForFloat64ReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: Float64TestHelper()) + mutating func valueForIntArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomIntArray() + let defaultValue = randomIntArray() + let variable = ConfigVariable<[Int]>(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForFloat64ReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: Float64TestHelper()) + mutating func fetchValueForIntArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomIntArray() + let defaultValue = randomIntArray() + let variable = ConfigVariable<[Int]>(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func subscriptFloat64ReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: Float64TestHelper()) + mutating func watchValueForIntArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomIntArray() + let updatedValue = randomIntArray() + let defaultValue = randomIntArray() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable<[Int]>(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.intArray(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } @@ -145,944 +513,1612 @@ struct ConfigVariableReaderTests: RandomValueGenerating { @Test mutating func valueForFloat64ArrayReturnsProviderValue() { - testValueReturnsProviderValue(using: Float64ArrayTestHelper()) + // set up + let key = randomConfigKey() + let expectedValue = randomFloat64Array() + let defaultValue = randomFloat64Array() + let variable = ConfigVariable<[Float64]>(key: key, defaultValue: defaultValue) + setProviderValue(.doubleArray(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func valueForFloat64ArrayReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: Float64ArrayTestHelper()) + mutating func fetchValueForFloat64ArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomFloat64Array() + let defaultValue = randomFloat64Array() + let variable = ConfigVariable<[Float64]>(key: key, defaultValue: defaultValue) + setProviderValue(.doubleArray(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForFloat64ArrayReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: Float64ArrayTestHelper()) + mutating func watchValueForFloat64ArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomFloat64Array() + let updatedValue = randomFloat64Array() + let defaultValue = randomFloat64Array() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable<[Float64]>(key: key, defaultValue: defaultValue) + setProviderValue(.doubleArray(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.doubleArray(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } + // MARK: - String tests + @Test - mutating func fetchValueForFloat64ArrayReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: Float64ArrayTestHelper()) + mutating func valueForStringReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomAlphanumericString() + let defaultValue = randomAlphanumericString() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForFloat64ArrayReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: Float64ArrayTestHelper()) + mutating func valueForStringReturnsDefaultWhenKeyNotFound() { + // set up + let key = randomConfigKey() + let defaultValue = randomAlphanumericString() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == defaultValue) } @Test - mutating func subscriptFloat64ArrayReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: Float64ArrayTestHelper()) + mutating func fetchValueForStringReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomAlphanumericString() + let defaultValue = randomAlphanumericString() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } - // MARK: - Int tests + @Test + mutating func fetchValueForStringReturnsDefaultWhenKeyNotFound() async throws { + // set up + let key = randomConfigKey() + let defaultValue = randomAlphanumericString() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == defaultValue) + } + @Test - mutating func valueForIntReturnsProviderValue() { - testValueReturnsProviderValue(using: IntTestHelper()) + mutating func watchValueForStringReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomAlphanumericString() + let updatedValue = randomAlphanumericString() + let defaultValue = randomAlphanumericString() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.string(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } @Test - mutating func valueForIntReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: IntTestHelper()) + mutating func subscriptStringReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomAlphanumericString() + let defaultValue = randomAlphanumericString() + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue), forKey: key) + + // exercise + let result = reader[variable] + + // expect + #expect(result == expectedValue) } + // MARK: - [String] tests + @Test - mutating func fetchValueForIntReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: IntTestHelper()) + mutating func valueForStringArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomStringArray() + let defaultValue = randomStringArray() + let variable = ConfigVariable<[String]>(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForIntReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: IntTestHelper()) + mutating func fetchValueForStringArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomStringArray() + let defaultValue = randomStringArray() + let variable = ConfigVariable<[String]>(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForIntReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: IntTestHelper()) + mutating func watchValueForStringArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomStringArray() + let updatedValue = randomStringArray() + let defaultValue = randomStringArray() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable<[String]>(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.stringArray(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } + // MARK: - [UInt8] tests + @Test - mutating func subscriptIntReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: IntTestHelper()) + mutating func valueForBytesReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomBytes() + let defaultValue = randomBytes() + let variable = ConfigVariable<[UInt8]>(key: key, defaultValue: defaultValue) + setProviderValue(.bytes(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } - // MARK: - [Int] tests + @Test + mutating func fetchValueForBytesReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomBytes() + let defaultValue = randomBytes() + let variable = ConfigVariable<[UInt8]>(key: key, defaultValue: defaultValue) + setProviderValue(.bytes(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) + } + @Test - mutating func valueForIntArrayReturnsProviderValue() { - testValueReturnsProviderValue(using: IntArrayTestHelper()) + mutating func watchValueForBytesReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomBytes() + let updatedValue = randomBytes() + let defaultValue = randomBytes() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable<[UInt8]>(key: key, defaultValue: defaultValue) + setProviderValue(.bytes(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.bytes(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } + } + + + // MARK: - [[UInt8]] tests + + @Test + mutating func valueForByteChunkArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomByteChunkArray() + let defaultValue = randomByteChunkArray() + let variable = ConfigVariable<[[UInt8]]>(key: key, defaultValue: defaultValue) + setProviderValue(.byteChunkArray(expectedValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func valueForIntArrayReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: IntArrayTestHelper()) + mutating func fetchValueForByteChunkArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomByteChunkArray() + let defaultValue = randomByteChunkArray() + let variable = ConfigVariable<[[UInt8]]>(key: key, defaultValue: defaultValue) + setProviderValue(.byteChunkArray(expectedValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForIntArrayReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: IntArrayTestHelper()) + mutating func watchValueForByteChunkArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomByteChunkArray() + let updatedValue = randomByteChunkArray() + let defaultValue = randomByteChunkArray() + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable<[[UInt8]]>(key: key, defaultValue: defaultValue) + setProviderValue(.byteChunkArray(initialValue), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.byteChunkArray(updatedValue), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } + } + + + // MARK: - RawRepresentable tests + + @Test + mutating func valueForRawRepresentableStringReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomCase(of: MockStringEnum.self)! + var defaultValue: MockStringEnum + repeat { defaultValue = randomCase(of: MockStringEnum.self)! } while defaultValue == expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue.rawValue), forKey: key) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForIntArrayReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: IntArrayTestHelper()) + mutating func valueForRawRepresentableStringReturnsDefaultWhenKeyNotFound() { + // set up + let key = randomConfigKey() + let defaultValue = randomCase(of: MockStringEnum.self)! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + + // exercise + let result = reader.value(for: variable) + + // expect + #expect(result == defaultValue) } - @Test - mutating func watchValueForIntArrayReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: IntArrayTestHelper()) + @Test + mutating func fetchValueForRawRepresentableStringReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = randomCase(of: MockStringEnum.self)! + var defaultValue: MockStringEnum + repeat { defaultValue = randomCase(of: MockStringEnum.self)! } while defaultValue == expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue.rawValue), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) } @Test - mutating func subscriptIntArrayReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: IntArrayTestHelper()) - } - + mutating func fetchValueForRawRepresentableStringReturnsDefaultWhenKeyNotFound() async throws { + // set up + let key = randomConfigKey() + let defaultValue = randomCase(of: MockStringEnum.self)! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) - // MARK: - String tests + // exercise + let result = try await reader.fetchValue(for: variable) - @Test - mutating func valueForStringReturnsProviderValue() { - testValueReturnsProviderValue(using: StringTestHelper()) + // expect + #expect(result == defaultValue) } @Test - mutating func valueForStringReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: StringTestHelper()) - } + mutating func watchValueForRawRepresentableStringReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = randomCase(of: MockStringEnum.self)! + var differentValue: MockStringEnum + repeat { differentValue = randomCase(of: MockStringEnum.self)! } while differentValue == initialValue + let updatedValue = differentValue + let defaultValue = randomCase(of: MockStringEnum.self)! + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(initialValue.rawValue), forKey: key) + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - @Test - mutating func fetchValueForStringReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: StringTestHelper()) - } + let value1 = await iterator.next() + #expect(value1 == initialValue) + provider.setValue( + .init(.string(updatedValue.rawValue), isSecret: isSecret), + forKey: .init(key) + ) - @Test - mutating func fetchValueForStringReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: StringTestHelper()) + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } @Test - mutating func watchValueForStringReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: StringTestHelper()) - } + mutating func subscriptRawRepresentableStringReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = randomCase(of: MockStringEnum.self)! + var defaultValue: MockStringEnum + repeat { defaultValue = randomCase(of: MockStringEnum.self)! } while defaultValue == expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue.rawValue), forKey: key) + // exercise + let result = reader[variable] - @Test - mutating func subscriptStringReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: StringTestHelper()) + // expect + #expect(result == expectedValue) } - // MARK: - [String] tests + // MARK: - [RawRepresentable] tests @Test - mutating func valueForStringArrayReturnsProviderValue() { - testValueReturnsProviderValue(using: StringArrayTestHelper()) - } + mutating func valueForRawRepresentableStringArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(expectedValue.map(\.rawValue)), forKey: key) + // exercise + let result = reader.value(for: variable) - @Test - mutating func valueForStringArrayReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: StringArrayTestHelper()) + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForStringArrayReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: StringArrayTestHelper()) - } + mutating func fetchValueForRawRepresentableStringArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(expectedValue.map(\.rawValue)), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - @Test - mutating func fetchValueForStringArrayReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: StringArrayTestHelper()) + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForStringArrayReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: StringArrayTestHelper()) - } + mutating func watchValueForRawRepresentableStringArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let updatedValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockStringEnum.self)! } + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(initialValue.map(\.rawValue)), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + let value1 = await iterator.next() + #expect(value1 == initialValue) - @Test - mutating func subscriptStringArrayReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: StringArrayTestHelper()) + provider.setValue( + .init(.stringArray(updatedValue.map(\.rawValue)), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - // MARK: - [UInt8] tests + // MARK: - ExpressibleByConfigString tests @Test - mutating func valueForBytesReturnsProviderValue() { - testValueReturnsProviderValue(using: BytesTestHelper()) - } + mutating func valueForExpressibleByConfigStringReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = MockConfigStringValue(configString: randomAlphanumericString())! + let defaultValue = MockConfigStringValue(configString: randomAlphanumericString())! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue.description), forKey: key) + // exercise + let result = reader.value(for: variable) - @Test - mutating func valueForBytesReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: BytesTestHelper()) + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForBytesReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: BytesTestHelper()) - } + mutating func fetchValueForExpressibleByConfigStringReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = MockConfigStringValue(configString: randomAlphanumericString())! + let defaultValue = MockConfigStringValue(configString: randomAlphanumericString())! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(expectedValue.description), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - @Test - mutating func fetchValueForBytesReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: BytesTestHelper()) + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForBytesReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: BytesTestHelper()) - } + mutating func watchValueForExpressibleByConfigStringReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = MockConfigStringValue(configString: randomAlphanumericString())! + let updatedValue = MockConfigStringValue(configString: randomAlphanumericString())! + let defaultValue = MockConfigStringValue(configString: randomAlphanumericString())! + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.string(initialValue.description), forKey: key) + + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + let value1 = await iterator.next() + #expect(value1 == initialValue) - @Test - mutating func subscriptBytesReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: BytesTestHelper()) + provider.setValue( + .init(.string(updatedValue.description), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - // MARK: - [[UInt8]] tests + // MARK: - [ExpressibleByConfigString] tests @Test - mutating func valueForByteChunkArrayReturnsProviderValue() { - testValueReturnsProviderValue(using: ByteChunkArrayTestHelper()) - } + mutating func valueForExpressibleByConfigStringArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(expectedValue.map(\.description)), forKey: key) + // exercise + let result = reader.value(for: variable) - @Test - mutating func valueForByteChunkArrayReturnsDefaultWhenKeyNotFound() { - testValueReturnsDefaultWhenKeyNotFound(using: ByteChunkArrayTestHelper()) + // expect + #expect(result == expectedValue) } @Test - mutating func fetchValueForByteChunkArrayReturnsProviderValue() async throws { - try await testFetchValueReturnsProviderValue(using: ByteChunkArrayTestHelper()) - } + mutating func fetchValueForExpressibleByConfigStringArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(expectedValue.map(\.description)), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - @Test - mutating func fetchValueForByteChunkArrayReturnsDefaultWhenKeyNotFound() async throws { - try await testFetchValueReturnsDefaultWhenKeyNotFound(using: ByteChunkArrayTestHelper()) + // expect + #expect(result == expectedValue) } @Test - mutating func watchValueForByteChunkArrayReceivesUpdates() async throws { - try await testWatchValueReceivesUpdates(using: ByteChunkArrayTestHelper()) - } + mutating func watchValueForExpressibleByConfigStringArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let updatedValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigStringValue(configString: randomAlphanumericString())! + } + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.stringArray(initialValue.map(\.description)), forKey: key) + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - @Test - mutating func subscriptByteChunkArrayReturnsProviderValue() { - testSubscriptReturnsProviderValue(using: ByteChunkArrayTestHelper()) + let value1 = await iterator.next() + #expect(value1 == initialValue) + + provider.setValue( + .init(.stringArray(updatedValue.map(\.description)), isSecret: isSecret), + forKey: .init(key) + ) + + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - // MARK: - Generic Test Helpers + // MARK: - RawRepresentable tests - /// Tests that `value(for:)` returns the provider value when the key exists. - mutating func testValueReturnsProviderValue(using helper: Helper) { + @Test + mutating func valueForRawRepresentableIntReturnsProviderValue() { // 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) - ) + let expectedValue = randomCase(of: MockIntEnum.self)! + var defaultValue: MockIntEnum + repeat { defaultValue = randomCase(of: MockIntEnum.self)! } while defaultValue == expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue.rawValue), forKey: key) // exercise - let result = helper.getValue(from: reader, for: variable) + 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) { + @Test + mutating func valueForRawRepresentableIntReturnsDefaultWhenKeyNotFound() { // set up let key = randomConfigKey() - let defaultValue = helper.randomValue(using: &randomNumberGenerator) - let variable = ConfigVariable(key: key, defaultValue: defaultValue) + let defaultValue = randomCase(of: MockIntEnum.self)! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) // exercise - let result = helper.getValue(from: reader, for: variable) + 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 throws { + @Test + mutating func fetchValueForRawRepresentableIntReturnsProviderValue() async throws { // 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) - ) + let expectedValue = randomCase(of: MockIntEnum.self)! + var defaultValue: MockIntEnum + repeat { defaultValue = randomCase(of: MockIntEnum.self)! } while defaultValue == expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue.rawValue), forKey: key) // exercise - let result = try await helper.fetchValue(from: reader, for: variable) + let result = try 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 throws { + @Test + mutating func fetchValueForRawRepresentableIntReturnsDefaultWhenKeyNotFound() async throws { // set up let key = randomConfigKey() - let defaultValue = helper.randomValue(using: &randomNumberGenerator) - let variable = ConfigVariable(key: key, defaultValue: defaultValue) + let defaultValue = randomCase(of: MockIntEnum.self)! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) // exercise - let result = try await helper.fetchValue(from: reader, for: variable) + let result = try await reader.fetchValue(for: variable) // expect #expect(result == defaultValue) } - /// Tests that `watchValue(for:updatesHandler:)` receives updates when the provider value changes. - mutating func testWatchValueReceivesUpdates( - using helper: Helper - ) async throws { + @Test + mutating func watchValueForRawRepresentableIntReceivesUpdates() async throws { // 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) - ) + let initialValue = randomCase(of: MockIntEnum.self)! + var differentValue: MockIntEnum + repeat { differentValue = randomCase(of: MockIntEnum.self)! } while differentValue == initialValue + let updatedValue = differentValue + let defaultValue = randomCase(of: MockIntEnum.self)! + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(initialValue.rawValue), forKey: key) // exercise and expect - try await helper.watchValue(from: reader, for: variable) { (updates) in + try await reader.watchValue(for: variable) { updates in var iterator = updates.makeAsyncIterator() - // first value should be initial - let value1 = try await iterator.next() + let value1 = await iterator.next() #expect(value1 == initialValue) - // update the provider provider.setValue( - .init( - helper.configContent(for: updatedValue), - isSecret: randomBool() - ), + .init(.int(updatedValue.rawValue), isSecret: isSecret), forKey: .init(key) ) - // next value should be updated - let value2 = try await iterator.next() + let value2 = await iterator.next() #expect(value2 == updatedValue) } } - /// Tests that subscript returns the provider value when the key exists. - mutating func testSubscriptReturnsProviderValue(using helper: Helper) { + @Test + mutating func subscriptRawRepresentableIntReturnsProviderValue() { // 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) - ) + let expectedValue = randomCase(of: MockIntEnum.self)! + var defaultValue: MockIntEnum + repeat { defaultValue = randomCase(of: MockIntEnum.self)! } while defaultValue == expectedValue + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue.rawValue), forKey: key) // exercise - let result = helper.subscriptValue(from: reader, for: variable) + let result = reader[variable] // expect #expect(result == expectedValue) } - // MARK: - Event Bus Integration + // MARK: - [RawRepresentable] tests @Test - mutating func valuePostsAccessSucceededEventWhenFound() async throws { + mutating func valueForRawRepresentableIntArrayReturnsProviderValue() { // 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) - } + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(expectedValue.map(\.rawValue)), forKey: key) // exercise - _ = reader.value(for: variable) + let result = 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)) + #expect(result == expectedValue) } -} -// MARK: - ReaderTestHelper Protocol + @Test + mutating func fetchValueForRawRepresentableIntArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(expectedValue.map(\.rawValue)), forKey: key) + + // exercise + let result = try await reader.fetchValue(for: variable) + + // expect + #expect(result == expectedValue) + } -/// A protocol that abstracts the type-specific details needed to test `ConfigVariableReader` with different value -/// types. -/// -/// Each conforming type encapsulates random value generation, config content conversion, and reader interaction for a -/// specific value type. -protocol ReaderTestHelper { - /// The configuration value type being tested. - associatedtype Value: Equatable & Sendable - /// Generates a random value of the associated type. - func randomValue(using generator: inout some RandomNumberGenerator) -> Value + @Test + mutating func watchValueForRawRepresentableIntArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let updatedValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { randomCase(of: MockIntEnum.self)! } + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(initialValue.map(\.rawValue)), forKey: key) - /// Returns a value that is different from the provided value. - func differentValue(from value: Value, using generator: inout some RandomNumberGenerator) -> Value + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - /// Converts the value to its corresponding `ConfigContent` representation. - func configContent(for value: Value) -> ConfigContent + let value1 = await iterator.next() + #expect(value1 == initialValue) - /// Gets the value from the reader using `value(for:)`. - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Value + provider.setValue( + .init(.intArray(updatedValue.map(\.rawValue)), isSecret: isSecret), + forKey: .init(key) + ) - /// Fetches the value from the reader using `fetchValue(for:)`. - func fetchValue(from reader: ConfigVariableReader, for variable: ConfigVariable) async throws -> Value + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } + } - /// Watches the value from the reader using `watchValue(for:updatesHandler:)`. - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable, - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return - /// Gets the value from the reader using the subscript. - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Value -} + // MARK: - ExpressibleByConfigInt tests + @Test + mutating func valueForExpressibleByConfigIntReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let defaultValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue.configInt), forKey: key) -// MARK: - BoolTestHelper + // exercise + let result = reader.value(for: variable) -private struct BoolTestHelper: ReaderTestHelper { - func randomValue(using generator: inout some RandomNumberGenerator) -> Bool { - Bool.random(using: &generator) + // expect + #expect(result == expectedValue) } - func differentValue(from value: Bool, using generator: inout some RandomNumberGenerator) -> Bool { - !value - } + @Test + mutating func fetchValueForExpressibleByConfigIntReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let defaultValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(expectedValue.configInt), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - func configContent(for value: Bool) -> ConfigContent { - .bool(value) + // expect + #expect(result == expectedValue) } - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Bool { - reader.value(for: variable) - } + @Test + mutating func watchValueForExpressibleByConfigIntReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let updatedValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let defaultValue = MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.int(initialValue.configInt), forKey: key) + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - func fetchValue(from reader: ConfigVariableReader, for variable: ConfigVariable) async throws -> Bool { - try await reader.fetchValue(for: variable) - } + let value1 = await iterator.next() + #expect(value1 == initialValue) + provider.setValue( + .init(.int(updatedValue.configInt), isSecret: isSecret), + forKey: .init(key) + ) - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable, - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Bool { - reader[variable] - } -} + // MARK: - [ExpressibleByConfigInt] tests + @Test + mutating func valueForExpressibleByConfigIntArrayReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(expectedValue.map(\.configInt)), forKey: key) -// MARK: - Float64TestHelper + // exercise + let result = reader.value(for: variable) -private struct Float64TestHelper: ReaderTestHelper { - func randomValue(using generator: inout some RandomNumberGenerator) -> Float64 { - Float64.random(in: 1 ... 100_000, using: &generator) + // expect + #expect(result == expectedValue) } - func differentValue(from value: Float64, using generator: inout some RandomNumberGenerator) -> Float64 { - value + randomValue(using: &generator) - } + @Test + mutating func fetchValueForExpressibleByConfigIntArrayReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(expectedValue.map(\.configInt)), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - func configContent(for value: Float64) -> ConfigContent { - .double(value) + // expect + #expect(result == expectedValue) } - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Float64 { - reader.value(for: variable) - } + @Test + mutating func watchValueForExpressibleByConfigIntArrayReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let updatedValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let defaultValue = Array(count: randomInt(in: 1 ... 5)) { + MockConfigIntValue(configInt: randomInt(in: .min ... .max))! + } + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue) + setProviderValue(.intArray(initialValue.map(\.configInt)), forKey: key) + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable - ) async throws -> Float64 { - try await reader.fetchValue(for: variable) - } + let value1 = await iterator.next() + #expect(value1 == initialValue) + provider.setValue( + .init(.intArray(updatedValue.map(\.configInt)), isSecret: isSecret), + forKey: .init(key) + ) - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable, - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Float64 { - reader[variable] - } -} + // MARK: - JSON Codable tests + @Test + mutating func valueForJSONReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + let jsonData = try! JSONEncoder().encode(expectedValue) + let jsonString = String(data: jsonData, encoding: .utf8)! + setProviderValue(.string(jsonString), forKey: key) -// MARK: - IntTestHelper + // exercise + let result = reader.value(for: variable) -private struct IntTestHelper: ReaderTestHelper { - func randomValue(using generator: inout some RandomNumberGenerator) -> Int { - Int.random(in: 1 ... 100_000, using: &generator) + // expect + #expect(result == expectedValue) } - func differentValue(from value: Int, using generator: inout some RandomNumberGenerator) -> Int { - value + randomValue(using: &generator) - } + @Test + mutating func valueForJSONReturnsDefaultWhenKeyNotFound() { + // set up + let key = randomConfigKey() + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + // exercise + let result = reader.value(for: variable) - func configContent(for value: Int) -> ConfigContent { - .int(value) + // expect + #expect(result == defaultValue) } - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Int { - reader.value(for: variable) - } + @Test + mutating func valueForJSONReturnsDefaultWhenDecodingFails() { + // set up + let key = randomConfigKey() + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + setProviderValue(.string("not valid json"), forKey: key) + // exercise + let result = reader.value(for: variable) - func fetchValue(from reader: ConfigVariableReader, for variable: ConfigVariable) async throws -> Int { - try await reader.fetchValue(for: variable) + // expect + #expect(result == defaultValue) } - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable, - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } - + @Test + mutating func valueForJSONPostsDecodingFailedEventWhenDecodingFails() async throws { + // set up + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> Int { - reader[variable] - } -} + let key = randomConfigKey() + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + setProviderValue(.string("not valid json"), forKey: key) + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableDecodingFailedEvent.self) { (event, _) in + continuation.yield(event) + } -// MARK: - StringTestHelper + // exercise + _ = reader.value(for: variable) -private struct StringTestHelper: ReaderTestHelper { - func randomValue(using generator: inout some RandomNumberGenerator) -> String { - let count = Int.random(in: 5 ... 20, using: &generator) - return String.randomAlphanumeric(count: count, using: &generator) + // expect + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == AbsoluteConfigKey(variable.key)) + #expect(postedEvent.targetType is MockCodableConfig.Type) } - func differentValue(from value: String, using generator: inout some RandomNumberGenerator) -> String { - value + randomValue(using: &generator) - } + @Test + mutating func fetchValueForJSONReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + let jsonData = try! JSONEncoder().encode(expectedValue) + let jsonString = String(data: jsonData, encoding: .utf8)! + setProviderValue(.string(jsonString), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - func configContent(for value: String) -> ConfigContent { - .string(value) + // expect + #expect(result == expectedValue) } - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> String { - reader.value(for: variable) - } + @Test + mutating func fetchValueForJSONReturnsDefaultWhenKeyNotFound() async throws { + // set up + let key = randomConfigKey() + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + // exercise + let result = try await reader.fetchValue(for: variable) - func fetchValue(from reader: ConfigVariableReader, for variable: ConfigVariable) async throws -> String { - try await reader.fetchValue(for: variable) + // expect + #expect(result == defaultValue) } - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable, - updatesHandler: (ConfigUpdatesAsyncSequence) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } - + @Test + mutating func watchValueForJSONReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let updatedValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + let encoder = JSONEncoder() + let initialJSON = String(data: try! encoder.encode(initialValue), encoding: .utf8)! + let updatedJSON = String(data: try! encoder.encode(updatedValue), encoding: .utf8)! + setProviderValue(.string(initialJSON), forKey: key) - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable) -> String { - reader[variable] - } -} + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + let value1 = await iterator.next() + #expect(value1 == initialValue) -// MARK: - BytesTestHelper + provider.setValue( + .init(.string(updatedJSON), isSecret: isSecret), + forKey: .init(key) + ) -private struct BytesTestHelper: ReaderTestHelper { - func randomValue(using generator: inout some RandomNumberGenerator) -> [UInt8] { - let count = Int.random(in: 1 ... 32, using: &generator) - return Array(count: count) { UInt8.random(in: .min ... .max, using: &generator) } + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - func differentValue(from value: [UInt8], using generator: inout some RandomNumberGenerator) -> [UInt8] { - value + randomValue(using: &generator) - } + @Test + mutating func subscriptJSONReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let defaultValue = MockCodableConfig(variant: randomAlphanumericString(), count: randomInt(in: 1 ... 100)) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + let jsonData = try! JSONEncoder().encode(expectedValue) + let jsonString = String(data: jsonData, encoding: .utf8)! + setProviderValue(.string(jsonString), forKey: key) + // exercise + let result = reader[variable] - func configContent(for value: [UInt8]) -> ConfigContent { - .bytes(value) + // expect + #expect(result == expectedValue) } - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[UInt8]>) -> [UInt8] { - reader.value(for: variable) - } - + // MARK: - JSON Codable Error Path Tests - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[UInt8]> - ) async throws -> [UInt8] { - try await reader.fetchValue(for: variable) - } + @Test + mutating func fetchValueForJSONReturnsDefaultWhenDecodingFails() async throws { + // set up + let key = randomConfigKey() + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + setProviderValue(.string("not valid json"), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[UInt8]>, - updatesHandler: (ConfigUpdatesAsyncSequence<[UInt8], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) + // expect + #expect(result == defaultValue) } - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[UInt8]>) -> [UInt8] { - reader[variable] - } -} + @Test + mutating func fetchValueForJSONPostsDecodingFailedEventWhenDecodingFails() async throws { + // set up + let observer = ContextualBusEventObserver(context: ()) + eventBus.addObserver(observer) + let key = randomConfigKey() + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + setProviderValue(.string("not valid json"), forKey: key) + + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableDecodingFailedEvent.self) { (event, _) in + continuation.yield(event) + } -// MARK: - BoolArrayTestHelper + // exercise + _ = try await reader.fetchValue(for: variable) -private struct BoolArrayTestHelper: ReaderTestHelper { - 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) } + // expect + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == AbsoluteConfigKey(variable.key)) + #expect(postedEvent.targetType is MockCodableConfig.Type) } - func differentValue(from value: [Bool], using generator: inout some RandomNumberGenerator) -> [Bool] { - value + randomValue(using: &generator) - } + @Test + mutating func watchValueForJSONYieldsDefaultWhenDecodingFails() async throws { + // set up + let key = randomConfigKey() + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let validValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + let validJSON = String(data: try! JSONEncoder().encode(validValue), encoding: .utf8)! + setProviderValue(.string("not valid json"), forKey: key) + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - func configContent(for value: [Bool]) -> ConfigContent { - .boolArray(value) - } + let value1 = await iterator.next() + #expect(value1 == defaultValue) + provider.setValue( + .init(.string(validJSON), isSecret: isSecret), + forKey: .init(key) + ) - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[Bool]>) -> [Bool] { - reader.value(for: variable) + let value2 = await iterator.next() + #expect(value2 == validValue) + } } - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[Bool]> - ) async throws -> [Bool] { - try await reader.fetchValue(for: variable) - } + @Test + mutating func watchValueForJSONYieldsDefaultWhenKeyNotFound() async throws { + // set up + let key = randomConfigKey() + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let validValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable(key: key, defaultValue: defaultValue, content: .json()) + let validJSON = String(data: try! JSONEncoder().encode(validValue), encoding: .utf8)! + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[Bool]>, - updatesHandler: (ConfigUpdatesAsyncSequence<[Bool], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } + let value1 = await iterator.next() + #expect(value1 == defaultValue) + provider.setValue( + .init(.string(validJSON), isSecret: isSecret), + forKey: .init(key) + ) - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[Bool]>) -> [Bool] { - reader[variable] + let value2 = await iterator.next() + #expect(value2 == validValue) + } } -} -// MARK: - Float64ArrayTestHelper + // MARK: - Property List Codable Tests -private struct Float64ArrayTestHelper: ReaderTestHelper { - 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) } - } + @Test + mutating func valueForPropertyListReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let variable = ConfigVariable( + key: key, + defaultValue: defaultValue, + content: .propertyList(decoder: PropertyListDecoder()) + ) + let plistData = try! PropertyListEncoder().encode(expectedValue) + setProviderValue(.bytes(Array(plistData)), forKey: key) + // exercise + let result = reader.value(for: variable) - func differentValue(from value: [Float64], using generator: inout some RandomNumberGenerator) -> [Float64] { - value + randomValue(using: &generator) + // expect + #expect(result == expectedValue) } - func configContent(for value: [Float64]) -> ConfigContent { - .doubleArray(value) - } + @Test + mutating func fetchValueForPropertyListReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let variable = ConfigVariable( + key: key, + defaultValue: defaultValue, + content: .propertyList(decoder: PropertyListDecoder()) + ) + let plistData = try! PropertyListEncoder().encode(expectedValue) + setProviderValue(.bytes(Array(plistData)), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[Float64]>) -> [Float64] { - reader.value(for: variable) + // expect + #expect(result == expectedValue) } - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[Float64]> - ) async throws -> [Float64] { - try await reader.fetchValue(for: variable) - } + @Test + mutating func watchValueForPropertyListReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let updatedValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable( + key: key, + defaultValue: defaultValue, + content: .propertyList(decoder: PropertyListDecoder()) + ) + let encoder = PropertyListEncoder() + let initialPlist = try! encoder.encode(initialValue) + let updatedPlist = try! encoder.encode(updatedValue) + setProviderValue(.bytes(Array(initialPlist)), forKey: key) + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[Float64]>, - updatesHandler: (ConfigUpdatesAsyncSequence<[Float64], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } + let value1 = await iterator.next() + #expect(value1 == initialValue) + provider.setValue( + .init(.bytes(Array(updatedPlist)), isSecret: isSecret), + forKey: .init(key) + ) - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[Float64]>) -> [Float64] { - reader[variable] + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } -} - - -// MARK: - IntArrayTestHelper -private struct IntArrayTestHelper: ReaderTestHelper { - 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) } - } + // MARK: - JSON Data Representation Tests - func differentValue(from value: [Int], using generator: inout some RandomNumberGenerator) -> [Int] { - value + randomValue(using: &generator) - } + @Test + mutating func valueForJSONWithDataRepresentationReturnsProviderValue() { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let variable = ConfigVariable( + key: key, + defaultValue: defaultValue, + content: .json(representation: .data) + ) + let jsonData = try! JSONEncoder().encode(expectedValue) + setProviderValue(.bytes(Array(jsonData)), forKey: key) + // exercise + let result = reader.value(for: variable) - func configContent(for value: [Int]) -> ConfigContent { - .intArray(value) + // expect + #expect(result == expectedValue) } - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[Int]>) -> [Int] { - reader.value(for: variable) - } + @Test + mutating func fetchValueForJSONWithDataRepresentationReturnsProviderValue() async throws { + // set up + let key = randomConfigKey() + let expectedValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let variable = ConfigVariable( + key: key, + defaultValue: defaultValue, + content: .json(representation: .data) + ) + let jsonData = try! JSONEncoder().encode(expectedValue) + setProviderValue(.bytes(Array(jsonData)), forKey: key) + // exercise + let result = try await reader.fetchValue(for: variable) - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[Int]> - ) async throws -> [Int] { - try await reader.fetchValue(for: variable) + // expect + #expect(result == expectedValue) } - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[Int]>, - updatesHandler: (ConfigUpdatesAsyncSequence<[Int], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } - + @Test + mutating func watchValueForJSONWithDataRepresentationReceivesUpdates() async throws { + // set up + let key = randomConfigKey() + let initialValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let updatedValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let defaultValue = MockCodableConfig( + variant: randomAlphanumericString(), + count: randomInt(in: 1 ... 100) + ) + let isSecret = randomBool() + let provider = provider + let variable = ConfigVariable( + key: key, + defaultValue: defaultValue, + content: .json(representation: .data) + ) + let encoder = JSONEncoder() + let initialJSON = try! encoder.encode(initialValue) + let updatedJSON = try! encoder.encode(updatedValue) + setProviderValue(.bytes(Array(initialJSON)), forKey: key) - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[Int]>) -> [Int] { - reader[variable] - } -} + // exercise and expect + try await reader.watchValue(for: variable) { updates in + var iterator = updates.makeAsyncIterator() + let value1 = await iterator.next() + #expect(value1 == initialValue) -// MARK: - StringArrayTestHelper + provider.setValue( + .init(.bytes(Array(updatedJSON)), isSecret: isSecret), + forKey: .init(key) + ) -private struct StringArrayTestHelper: ReaderTestHelper { - 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) } + let value2 = await iterator.next() + #expect(value2 == updatedValue) + } } - func differentValue(from value: [String], using generator: inout some RandomNumberGenerator) -> [String] { - value + randomValue(using: &generator) - } - + // MARK: - Event Bus Integration - func configContent(for value: [String]) -> ConfigContent { - .stringArray(value) - } + @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) + ) - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[String]>) -> [String] { - reader.value(for: variable) - } + let (eventStream, continuation) = AsyncStream.makeStream() + observer.addHandler(for: ConfigVariableAccessSucceededEvent.self) { (event, _) in + continuation.yield(event) + } + // exercise + _ = reader.value(for: variable) - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[String]> - ) async throws -> [String] { - try await reader.fetchValue(for: variable) + // expect + let postedEvent = try #require(await eventStream.first { _ in true }) + #expect(postedEvent.key == AbsoluteConfigKey(variable.key)) + #expect(postedEvent.value.content == .bool(expectedValue)) } +} - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[String]>, - updatesHandler: (ConfigUpdatesAsyncSequence<[String], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } - +// MARK: - MockCodableConfig - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[String]>) -> [String] { - reader[variable] - } +private struct MockCodableConfig: Codable, Hashable, Sendable { + let variant: String + let count: Int } -// MARK: - ByteChunkArrayTestHelper - -private struct ByteChunkArrayTestHelper: ReaderTestHelper { - func randomValue(using generator: inout some RandomNumberGenerator) -> [[UInt8]] { - let count = Int.random(in: 1 ... 5, using: &generator) - return Array(count: count) { - let byteCount = Int.random(in: 1 ... 32, using: &generator) - return Array(count: byteCount) { UInt8.random(in: .min ... .max, using: &generator) } - } - } +// MARK: - MockStringEnum +private enum MockStringEnum: String, CaseIterable, Sendable { + case alpha + case bravo + case charlie + case delta +} - func differentValue(from value: [[UInt8]], using generator: inout some RandomNumberGenerator) -> [[UInt8]] { - value + randomValue(using: &generator) - } +// MARK: - MockIntEnum - func configContent(for value: [[UInt8]]) -> ConfigContent { - .byteChunkArray(value) - } +private enum MockIntEnum: Int, CaseIterable, Sendable { + case one = 1 + case two = 2 + case three = 3 + case four = 4 +} - func getValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[[UInt8]]>) -> [[UInt8]] { - reader.value(for: variable) - } +// MARK: - MockConfigStringValue +private struct MockConfigStringValue: ExpressibleByConfigString, Hashable, Sendable { + let stringValue: String + var description: String { stringValue } - func fetchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[[UInt8]]> - ) async throws -> [[UInt8]] { - try await reader.fetchValue(for: variable) + init?(configString: String) { + self.stringValue = configString } +} - func watchValue( - from reader: ConfigVariableReader, - for variable: ConfigVariable<[[UInt8]]>, - updatesHandler: (ConfigUpdatesAsyncSequence<[[UInt8]], Never>) async throws -> Return - ) async throws -> Return { - try await reader.watchValue(for: variable, updatesHandler: updatesHandler) - } +// MARK: - MockConfigIntValue +private struct MockConfigIntValue: ExpressibleByConfigInt, Hashable, Sendable { + let intValue: Int + var configInt: Int { intValue } + var description: String { "\(intValue)" } - func subscriptValue(from reader: ConfigVariableReader, for variable: ConfigVariable<[[UInt8]]>) -> [[UInt8]] { - reader[variable] + init?(configInt: Int) { + self.intValue = configInt } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift index f9be2dd..9b0f5c4 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift @@ -33,26 +33,6 @@ struct ConfigVariableTests: RandomValueGenerating { } - // 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) - } - - // MARK: - metadata(_:_:) @Test