diff --git a/.github/workflows/ConfigKeyKit.yml b/.github/workflows/ConfigKeyKit.yml index 2bcb8a1..8309829 100644 --- a/.github/workflows/ConfigKeyKit.yml +++ b/.github/workflows/ConfigKeyKit.yml @@ -58,7 +58,7 @@ jobs: run: | if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3"},{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' >> "$GITHUB_OUTPUT" echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" else echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" @@ -70,7 +70,7 @@ jobs: name: Build on Ubuntu needs: configure runs-on: ubuntu-latest - container: swift:${{ matrix.swift.version }}-${{ matrix.os }} + container: ${{ matrix.swift.image && format('{0}-{1}', matrix.swift.image, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false @@ -78,6 +78,13 @@ jobs: os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + # Swift nightly does not publish matching Wasm SDK snapshots, so build + # the 6.4 nightly toolchain for the native target only. + exclude: + - swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4.x" } + type: wasm + - swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4.x" } + type: wasm-embedded steps: - uses: actions/checkout@v6 - uses: brightdigit/swift-build@v1 @@ -99,7 +106,13 @@ jobs: - name: Install coverage.py (silences codecov-cli probe warning) if: steps.build.outputs.contains-code-coverage == 'true' run: pip3 install --quiet --user coverage 2>/dev/null || true - - uses: sersoft-gmbh/swift-coverage-action@v5 + # brightdigit fork pinned by SHA: under Swift 6.4 the swiftbuild engine + # emits an Xcode-like layout (Tests.so + -test-runner stub, no + # .xctest), which upstream sersoft-gmbh/swift-coverage-action@v5 cannot + # pair, silently dropping Linux coverage (issue #92). The patch is + # Linux-gated; macOS legs stay on upstream @v5. Drop the pin for a tagged + # release once the fix is merged upstream. + - uses: brightdigit/swift-coverage-action@2f8538f723b99ab2406ac3a0e5b3355a9de4cf6c if: steps.build.outputs.contains-code-coverage == 'true' id: coverage-files with: diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml index 982157d..6583fae 100644 --- a/.github/workflows/swift-source-compat.yml +++ b/.github/workflows/swift-source-compat.yml @@ -18,6 +18,7 @@ jobs: container: - swift:6.2 - swift:6.3 + - swiftlang/swift:nightly-6.4.x-noble container: ${{ matrix.container }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4b071e9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +ConfigKeyKit is a tiny, **dependency-free, Foundation-only** Swift 6.2 library (single `ConfigKeyKit` library product). It defines configuration keys whose base name resolves to per-source key strings — e.g. `cloudkit.container_id` on the CLI vs. `MYAPP_CLOUDKIT_CONTAINER_ID` in the environment. It deliberately does **not** depend on `apple/swift-configuration`; it produces key strings you feed into whatever provider stack you use. Keep it dependency-free — adding a dependency is a design change, not a routine edit. + +## Commands + +- `make build` / `swift build` — build +- `make test` / `swift test` — run the full suite (Swift Testing, not XCTest) +- Run one test: `swift test --filter ConfigKeyTests` (suite) or `swift test --filter "ConfigKey with default"` (by `@Test` display name) +- `make lint` — runs `Scripts/lint.sh`: swift-format, SwiftLint, license-header check, and `periphery` dead-code scan +- `make clean` + +Lint/format tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` can find them outside CI. `Scripts/lint.sh` is env-driven: `LINT_MODE` (`STRICT` adds `--strict`/`--configuration`; `NONE`/`INSTALL` short-circuit), `FORMAT_ONLY=1` skips lint+build, and outside CI it auto-formats in place before linting. + +## Architecture + +Two loosely-related feature sets live in one product: + +**Config keys** (the core purpose) — A `ConfigurationKey` protocol exposes one method: `key(for: ConfigKeySource) -> String?`. Two concrete value types implement it: +- `ConfigKey` — has a required `defaultValue` (resolution is non-optional). +- `OptionalConfigKey` — no default (resolves to optional). + +Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> NamingStyle`), and an `explicitKeys` override map. `key(for:)` checks `explicitKeys` first, otherwise transforms `baseKey` through the source's `NamingStyle`. `ConfigKeySource` is just `.commandLine` / `.environment`. `NamingStyle` is a protocol; `StandardNamingStyle` provides `.dotSeparated` (CLI, identity) and `.screamingSnakeCase(prefix:)` (ENV, uppercases + replaces `.` with `_`, optional prefix). The convenience init `ConfigKey("base", envPrefix:, default:)` wires these two standard styles automatically. `ConfigKey+Bool.swift` adds `Bool`-specialized inits; `+Debug` files add `CustomDebugStringConvertible`. + +**CLI scaffolding** (optional, ignore if you only need keys) — A lightweight command-dispatch layer: `Command` protocol (associated `Config: ConfigurationParseable`, static metadata, async `createInstance()`/`execute()`), the `CommandRegistry` **actor** (concurrency-safe registry, `.shared` singleton plus `internal init()` for isolated test instances), `CommandLineParser` (splits argv into command name + args), and `ConfigurationParseable` (async parse-from-sources protocol; `BaseConfig == Never` means a root config and unlocks the parentless convenience init). + +## Conventions + +- **Swift 6.2** with these upcoming features enabled in `Package.swift` (apply them in new code): `ExistentialAny` (write `any NamingStyle`), `InternalImportsByDefault` (mark imports `public import` / `internal import` explicitly — e.g. Foundation is `internal import Foundation`), `MemberImportVisibility`, `FullTypedThrows`. +- Everything is `Sendable`; preserve that. +- SwiftLint runs with many opt-in rules including `explicit_acl` / `explicit_top_level_acl` (declare access control explicitly on every declaration) and `force_unwrapping` (no `!`). `file_name` is enforced — the primary type's name must match the filename; extensions use the `Type+Feature.swift` pattern. +- Every source file carries the MIT license header (copyright "Leo Dion" / "BrightDigit"); `Scripts/header.sh` enforces it. New files need it. +- `periphery.yml` sets `retain_public: true`, so public API is never flagged as dead code. + +## Note + +`ConfigKeyKit.git/` in the working tree is a bare git repo (a mirror clone), not part of the package — leave it alone. diff --git a/Sources/ConfigKeyKit/ConfigKey+Bool.swift b/Sources/ConfigKeyKit/ConfigKey+Bool.swift index b30909a..33e4f8c 100644 --- a/Sources/ConfigKeyKit/ConfigKey+Bool.swift +++ b/Sources/ConfigKeyKit/ConfigKey+Bool.swift @@ -33,7 +33,13 @@ extension ConfigKey where Value == Bool { /// - cli: Command-line argument name /// - env: Environment variable name /// - defaultVal: Default value (defaults to false) - public init(cli: String, env: String, default defaultVal: Bool = false) { + /// - isSecret: Whether the value is sensitive (defaults to false) + public init( + cli: String, + env: String, + default defaultVal: Bool = false, + isSecret: Bool = false + ) { self.baseKey = nil self.styles = [:] var keys: [ConfigKeySource: String] = [:] @@ -41,6 +47,7 @@ extension ConfigKey where Value == Bool { keys[.environment] = env self.explicitKeys = keys self.defaultValue = defaultVal + self.isSecret = isSecret } /// Initialize a boolean configuration key from base string @@ -48,7 +55,13 @@ extension ConfigKey where Value == Bool { /// - base: Base key string (e.g., "sync.verbose") /// - envPrefix: Prefix for environment variable (defaults to nil) /// - defaultVal: Default value (defaults to false) - public init(_ base: String, envPrefix: String? = nil, default defaultVal: Bool = false) { + /// - isSecret: Whether the value is sensitive (defaults to false) + public init( + _ base: String, + envPrefix: String? = nil, + default defaultVal: Bool = false, + isSecret: Bool = false + ) { self.baseKey = base self.styles = [ .commandLine: StandardNamingStyle.dotSeparated, @@ -56,5 +69,6 @@ extension ConfigKey where Value == Bool { ] self.explicitKeys = [:] self.defaultValue = defaultVal + self.isSecret = isSecret } } diff --git a/Sources/ConfigKeyKit/ConfigKey.swift b/Sources/ConfigKeyKit/ConfigKey.swift index 4115c1a..925b05b 100644 --- a/Sources/ConfigKeyKit/ConfigKey.swift +++ b/Sources/ConfigKeyKit/ConfigKey.swift @@ -47,12 +47,19 @@ public struct ConfigKey: ConfigurationKey, Sendable { internal let explicitKeys: [ConfigKeySource: String] /// The default value returned when no source provides a value. public let defaultValue: Value + /// Whether the value behind this key is sensitive (e.g. a token or password). + public let isSecret: Bool /// The base key string used for this configuration key public var base: String? { baseKey } /// Initialize with explicit CLI and ENV keys and required default - public init(cli: String? = nil, env: String? = nil, default defaultVal: Value) { + public init( + cli: String? = nil, + env: String? = nil, + default defaultVal: Value, + isSecret: Bool = false + ) { self.baseKey = nil self.styles = [:] var keys: [ConfigKeySource: String] = [:] @@ -60,6 +67,7 @@ public struct ConfigKey: ConfigurationKey, Sendable { if let env = env { keys[.environment] = env } self.explicitKeys = keys self.defaultValue = defaultVal + self.isSecret = isSecret } /// Initialize from a base key string with naming styles and required default @@ -67,15 +75,18 @@ public struct ConfigKey: ConfigurationKey, Sendable { /// - base: Base key string (e.g., "cloudkit.container_id") /// - styles: Dictionary mapping sources to naming styles /// - defaultVal: Required default value + /// - isSecret: Whether the value is sensitive (defaults to false) public init( base: String, styles: [ConfigKeySource: any NamingStyle], - default defaultVal: Value + default defaultVal: Value, + isSecret: Bool = false ) { self.baseKey = base self.styles = styles self.explicitKeys = [:] self.defaultValue = defaultVal + self.isSecret = isSecret } /// Convenience initializer with standard naming conventions and required default @@ -83,7 +94,13 @@ public struct ConfigKey: ConfigurationKey, Sendable { /// - base: Base key string (e.g., "cloudkit.container_id") /// - envPrefix: Prefix for environment variable (defaults to nil) /// - defaultVal: Required default value - public init(_ base: String, envPrefix: String? = nil, default defaultVal: Value) { + /// - isSecret: Whether the value is sensitive (defaults to false) + public init( + _ base: String, + envPrefix: String? = nil, + default defaultVal: Value, + isSecret: Bool = false + ) { self.baseKey = base self.styles = [ .commandLine: StandardNamingStyle.dotSeparated, @@ -91,6 +108,7 @@ public struct ConfigKey: ConfigurationKey, Sendable { ] self.explicitKeys = [:] self.defaultValue = defaultVal + self.isSecret = isSecret } /// Returns the resolved key string for the given source. diff --git a/Sources/ConfigKeyKit/ConfigKeySource.swift b/Sources/ConfigKeyKit/ConfigKeySource.swift index 242f510..b45dab4 100644 --- a/Sources/ConfigKeyKit/ConfigKeySource.swift +++ b/Sources/ConfigKeyKit/ConfigKeySource.swift @@ -35,3 +35,13 @@ public enum ConfigKeySource: CaseIterable, Sendable { /// Environment variables (e.g., CLOUDKIT_CONTAINER_ID) case environment } + +extension ConfigKeySource: PrioritizedConfigKeySource { + /// Sources in precedence order, highest first: command line overrides + /// environment. + /// + /// This order is part of the public API. `ConfigValueReading` resolution + /// consults sources in this sequence, and it is pinned explicitly so it stays + /// independent of `case` declaration order. + public static let priority: [ConfigKeySource] = [.commandLine, .environment] +} diff --git a/Sources/ConfigKeyKit/ConfigValueReading.swift b/Sources/ConfigKeyKit/ConfigValueReading.swift new file mode 100644 index 0000000..fb47721 --- /dev/null +++ b/Sources/ConfigKeyKit/ConfigValueReading.swift @@ -0,0 +1,218 @@ +// +// ConfigValueReading.swift +// ConfigKeyKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// A reader that resolves ``ConfigKey`` / ``OptionalConfigKey`` values across +/// every ``ConfigKeySource`` in precedence order. +/// +/// This protocol holds the source-precedence resolution that downstream +/// consumers previously hand-wrote as an `extension ConfigReader { read(_:) }` +/// (see issue #1, "Remove Need for Extension"). The logic lives here, in +/// ConfigKeyKit's Foundation-only core, so it is shared and unit-testable with a +/// trivial mock — no configuration framework required. +/// +/// The three primitive requirements mirror the read surface of +/// `swift-configuration`'s `ConfigReader` exactly, so a consumer conforms in a +/// single line: +/// +/// ```swift +/// extension ConfigReader: @retroactive ConfigValueReading { +/// public func makeConfigKey(_ s: String) -> Configuration.ConfigKey { .init(s) } +/// } +/// ``` +/// +/// `string(forKey:isSecret:fileID:line:)`, `int(...)`, and `double(...)` are +/// then witnessed by `ConfigReader`'s own methods, and ``Key`` infers to +/// `Configuration.ConfigKey`. +public protocol ConfigValueReading { + /// The reader's native key type (e.g. `Configuration.ConfigKey`). + associatedtype Key + + /// The sources consulted during resolution, in precedence order, highest + /// first. Defaults to ``ConfigKeySource/priority``; override to resolve a + /// reader with a different precedence (e.g. environment before command line). + var sourcePriority: [ConfigKeySource] { get } + + /// Builds a native ``Key`` from a resolved per-source key string. + func makeConfigKey(_ string: String) -> Key + + /// Reads a string value for the native key, or `nil` if absent. + func string(forKey key: Key, isSecret: Bool, fileID: String, line: UInt) -> String? + + /// Reads an integer value for the native key, or `nil` if absent. + func int(forKey key: Key, isSecret: Bool, fileID: String, line: UInt) -> Int? + + /// Reads a double value for the native key, or `nil` if absent. + func double(forKey key: Key, isSecret: Bool, fileID: String, line: UInt) -> Double? +} + +extension ConfigValueReading { + /// The sources consulted during resolution, defaulting to + /// ``ConfigKeySource/priority`` (command line, then environment). + public var sourcePriority: [ConfigKeySource] { ConfigKeySource.priority } + + // MARK: - Required values + + /// Reads a required string value, consulting sources in ``sourcePriority`` + /// order and falling back to the key's default. + public func read(_ key: ConfigKey) -> String { + resolvedString(key) ?? key.defaultValue + } + + /// Reads a required integer value, consulting sources in ``sourcePriority`` + /// order and falling back to the key's default. + public func read(_ key: ConfigKey) -> Int { + resolvedInt(key) ?? key.defaultValue + } + + /// Reads a required double value, consulting sources in ``sourcePriority`` + /// order and falling back to the key's default. + public func read(_ key: ConfigKey) -> Double { + resolvedDouble(key) ?? key.defaultValue + } + + /// Reads a required boolean value, consulting sources in ``sourcePriority`` + /// order and falling back to the key's default. + /// + /// - Command line: a present key indicates `true` (flag presence, e.g. + /// `--verbose`). + /// - Other sources: `true` / `1` / `yes` (case-insensitive) are truthy; an + /// empty value is treated as absent and the next source is consulted. + public func read(_ key: ConfigKey) -> Bool { + resolvedBool(key) ?? key.defaultValue + } + + // MARK: - Optional values + + /// Reads an optional string value, or `nil` if no source provides one. + public func read(_ key: OptionalConfigKey) -> String? { + resolvedString(key) + } + + /// Reads an optional integer value, or `nil` if no source provides one. + public func read(_ key: OptionalConfigKey) -> Int? { + resolvedInt(key) + } + + /// Reads an optional double value, or `nil` if no source provides one. + public func read(_ key: OptionalConfigKey) -> Double? { + resolvedDouble(key) + } + + // swiftlint:disable discouraged_optional_boolean + /// Reads an optional boolean value, or `nil` if no source provides one + /// (same truthiness rules as the required boolean overload). + public func read(_ key: OptionalConfigKey) -> Bool? { + resolvedBool(key) + } + // swiftlint:enable discouraged_optional_boolean + + /// Reads an optional value parsed from a source string with `transform`. + /// + /// Sources are consulted in ``sourcePriority`` order; the first source whose + /// string value parses to a non-`nil` result wins. If a higher-precedence + /// source provides a string that fails to parse, resolution falls through to + /// the next source. + public func read(_ key: OptionalConfigKey, parsing transform: (String) -> T?) -> T? { + for source in sourcePriority { + guard let keyString = key.key(for: source) else { continue } + guard + let value = string( + forKey: makeConfigKey(keyString), isSecret: key.isSecret, fileID: #fileID, line: #line + ) + else { continue } + if let parsed = transform(value) { + return parsed + } + } + return nil + } + + /// Reads an optional ISO8601 date value, or `nil` if no source provides a + /// parseable date. + /// + /// Equivalent to `read(_:parsing:)` with an ``ISO8601DateFormatter``. For + /// other formats, call `read(_:parsing:)` with a custom parser. + public func read(_ key: OptionalConfigKey) -> Date? { + read(key, parsing: { ISO8601DateFormatter().date(from: $0) }) + } + + // MARK: - Source-precedence resolution + + /// Returns the first non-`nil` value produced by `lookup` across the sources + /// in ``sourcePriority`` order, forwarding the key's secrecy to the reader. + private func resolved( + _ key: any ConfigurationKey, + _ lookup: (Key, Bool) -> T? + ) -> T? { + for source in sourcePriority { + guard let keyString = key.key(for: source) else { continue } + if let value = lookup(makeConfigKey(keyString), key.isSecret) { + return value + } + } + return nil + } + + private func resolvedString(_ key: any ConfigurationKey) -> String? { + resolved(key) { string(forKey: $0, isSecret: $1, fileID: #fileID, line: #line) } + } + + private func resolvedInt(_ key: any ConfigurationKey) -> Int? { + resolved(key) { int(forKey: $0, isSecret: $1, fileID: #fileID, line: #line) } + } + + private func resolvedDouble(_ key: any ConfigurationKey) -> Double? { + resolved(key) { double(forKey: $0, isSecret: $1, fileID: #fileID, line: #line) } + } + + // swiftlint:disable:next discouraged_optional_boolean + private func resolvedBool(_ key: any ConfigurationKey) -> Bool? { + for source in sourcePriority { + guard let keyString = key.key(for: source) else { continue } + guard + let value = string( + forKey: makeConfigKey(keyString), isSecret: key.isSecret, fileID: #fileID, line: #line + ) + else { continue } + if source == .commandLine { + // Flag presence indicates true (e.g. `--verbose`). + return true + } + let normalized = value.lowercased().trimmingCharacters(in: .whitespaces) + if normalized.isEmpty { + // An empty value is treated as absent; consult the next source. + continue + } + return normalized == "true" || normalized == "1" || normalized == "yes" + } + return nil + } +} diff --git a/Sources/ConfigKeyKit/ConfigurationKey.swift b/Sources/ConfigKeyKit/ConfigurationKey.swift index a15da10..fda7def 100644 --- a/Sources/ConfigKeyKit/ConfigurationKey.swift +++ b/Sources/ConfigKeyKit/ConfigurationKey.swift @@ -29,6 +29,12 @@ /// Protocol for configuration keys that support multiple sources public protocol ConfigurationKey: Sendable { + /// Whether the value behind this key is sensitive (e.g. a token or password). + /// + /// Readers forward this to the underlying configuration source so secret + /// values can be redacted from logs and diagnostics. + var isSecret: Bool { get } + /// Get the key string for a specific source /// - Parameter source: The configuration source (CLI or ENV) /// - Returns: The key string for that source, or nil if the key doesn't support that source diff --git a/Sources/ConfigKeyKit/OptionalConfigKey.swift b/Sources/ConfigKeyKit/OptionalConfigKey.swift index 23b63d4..cdf1cd6 100644 --- a/Sources/ConfigKeyKit/OptionalConfigKey.swift +++ b/Sources/ConfigKeyKit/OptionalConfigKey.swift @@ -42,44 +42,52 @@ public struct OptionalConfigKey: ConfigurationKey, Sendable { internal let baseKey: String? internal let styles: [ConfigKeySource: any NamingStyle] internal let explicitKeys: [ConfigKeySource: String] + /// Whether the value behind this key is sensitive (e.g. a token or password). + public let isSecret: Bool /// The base key string used for this configuration key public var base: String? { baseKey } /// Initialize with explicit CLI and ENV keys (no default) - public init(cli: String? = nil, env: String? = nil) { + public init(cli: String? = nil, env: String? = nil, isSecret: Bool = false) { self.baseKey = nil self.styles = [:] var keys: [ConfigKeySource: String] = [:] if let cli = cli { keys[.commandLine] = cli } if let env = env { keys[.environment] = env } self.explicitKeys = keys + self.isSecret = isSecret } /// Initialize from a base key string with naming styles (no default) /// - Parameters: /// - base: Base key string (e.g., "cloudkit.key_id") /// - styles: Dictionary mapping sources to naming styles + /// - isSecret: Whether the value is sensitive (defaults to false) public init( base: String, - styles: [ConfigKeySource: any NamingStyle] + styles: [ConfigKeySource: any NamingStyle], + isSecret: Bool = false ) { self.baseKey = base self.styles = styles self.explicitKeys = [:] + self.isSecret = isSecret } /// Convenience initializer with standard naming conventions (no default) /// - Parameters: /// - base: Base key string (e.g., "cloudkit.key_id") /// - envPrefix: Prefix for environment variable (defaults to nil) - public init(_ base: String, envPrefix: String? = nil) { + /// - isSecret: Whether the value is sensitive (defaults to false) + public init(_ base: String, envPrefix: String? = nil, isSecret: Bool = false) { self.baseKey = base self.styles = [ .commandLine: StandardNamingStyle.dotSeparated, .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), ] self.explicitKeys = [:] + self.isSecret = isSecret } /// Returns the resolved key string for the given source. diff --git a/Sources/ConfigKeyKit/PrioritizedConfigKeySource.swift b/Sources/ConfigKeyKit/PrioritizedConfigKeySource.swift new file mode 100644 index 0000000..4022e30 --- /dev/null +++ b/Sources/ConfigKeyKit/PrioritizedConfigKeySource.swift @@ -0,0 +1,45 @@ +// +// PrioritizedConfigKeySource.swift +// ConfigKeyKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A `CaseIterable` whose cases carry a precedence order, highest first. +/// +/// Conformers expose ``priority`` as the authoritative ordering for resolution, +/// decoupling precedence from the order in which `case`s happen to be declared. +public protocol PrioritizedConfigKeySource: CaseIterable { + /// The cases in precedence order, highest priority first. + static var priority: [Self] { get } +} + +extension PrioritizedConfigKeySource { + /// Defaults to declaration order (`Array(allCases)`). + /// + /// Conformers that want precedence to be independent of `case` declaration + /// order should override this with an explicit array. + public static var priority: [Self] { Array(allCases) } +} diff --git a/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift b/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift index 48eab16..c942797 100644 --- a/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift +++ b/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift @@ -40,4 +40,16 @@ internal struct ConfigKeySourceTests { #expect(sources.contains(.commandLine)) #expect(sources.contains(.environment)) } + + @Test("Priority order is command line before environment") + internal func priorityOrder() { + #expect(ConfigKeySource.priority == [.commandLine, .environment]) + } + + @Test("Priority covers every case") + internal func priorityCoversEveryCase() { + #expect(Set(ConfigKeySource.priority) == Set(ConfigKeySource.allCases)) + // Count guards against a duplicate in `priority`, which `Set` equality hides. + #expect(ConfigKeySource.priority.count == ConfigKeySource.allCases.count) + } } diff --git a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift new file mode 100644 index 0000000..cb2cb15 --- /dev/null +++ b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift @@ -0,0 +1,225 @@ +// +// ConfigValueReadingTests.swift +// ConfigKeyKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import ConfigKeyKit + +@Suite("ConfigValueReading Tests") +internal struct ConfigValueReadingTests { + private let key = ConfigKey("base-url", envPrefix: "BRIGHTDIGIT", default: "default-url") + + @Test("Required string: CLI wins over ENV") + internal func requiredStringCLIPrecedence() throws { + let cli = try #require(key.key(for: .commandLine)) + let env = try #require(key.key(for: .environment)) + let reader = MockConfigValueReader(strings: [cli: "from-cli", env: "from-env"]) + #expect(reader.read(key) == "from-cli") + } + + @Test("Required string: ENV used when CLI absent") + internal func requiredStringENVFallback() throws { + let env = try #require(key.key(for: .environment)) + let reader = MockConfigValueReader(strings: [env: "from-env"]) + #expect(reader.read(key) == "from-env") + } + + @Test("Required string: default used when neither source present") + internal func requiredStringDefault() { + #expect(MockConfigValueReader().read(key) == "default-url") + } + + @Test("sourcePriority override: ENV wins over CLI when reversed") + internal func sourcePriorityOverride() throws { + let cli = try #require(key.key(for: .commandLine)) + let env = try #require(key.key(for: .environment)) + let reader = MockConfigValueReader( + strings: [cli: "from-cli", env: "from-env"], + sourcePriority: [.environment, .commandLine] + ) + #expect(reader.read(key) == "from-env") + } + + @Test("Required bool: CLI flag presence is true") + internal func boolCLIPresence() throws { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) + let cli = try #require(boolKey.key(for: .commandLine)) + let reader = MockConfigValueReader(strings: [cli: ""]) + #expect(reader.read(boolKey) == true) + } + + @Test( + "Required bool: ENV truthy strings", + arguments: [("true", true), ("1", true), ("YES", true), ("false", false), ("0", false)] + ) + internal func boolENVParsing(value: String, expected: Bool) throws { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) + let env = try #require(boolKey.key(for: .environment)) + let reader = MockConfigValueReader(strings: [env: value]) + #expect(reader.read(boolKey) == expected) + } + + @Test("Required bool: default when absent") + internal func boolDefault() { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: true) + #expect(MockConfigValueReader().read(boolKey) == true) + } + + @Test("Optional int: parsed with precedence, nil when absent") + internal func optionalInt() throws { + let intKey = OptionalConfigKey("episode-number", envPrefix: "BRIGHTDIGIT") + let cli = try #require(intKey.key(for: .commandLine)) + let reader = MockConfigValueReader(ints: [cli: 42]) + #expect(reader.read(intKey) == 42) + #expect(MockConfigValueReader().read(intKey) == nil) + } + + @Test("Optional double: parsed, nil when absent") + internal func optionalDouble() throws { + let doubleKey = OptionalConfigKey("min-interval", envPrefix: "BRIGHTDIGIT") + let env = try #require(doubleKey.key(for: .environment)) + let reader = MockConfigValueReader(doubles: [env: 1.5]) + #expect(reader.read(doubleKey) == 1.5) + #expect(MockConfigValueReader().read(doubleKey) == nil) + } + + @Test("Optional string: nil when absent") + internal func optionalStringNil() { + let optKey = OptionalConfigKey("episode-title", envPrefix: "BRIGHTDIGIT") + #expect(MockConfigValueReader().read(optKey) == nil) + } + + @Test("Optional date: ISO8601 parsed from value") + internal func optionalDate() throws { + let dateKey = OptionalConfigKey("published-at", envPrefix: "BRIGHTDIGIT") + let iso = "2026-06-17T00:00:00Z" + let cli = try #require(dateKey.key(for: .commandLine)) + let reader = MockConfigValueReader(strings: [cli: iso]) + #expect(reader.read(dateKey) == ISO8601DateFormatter().date(from: iso)) + #expect(MockConfigValueReader().read(dateKey) == nil) + } + + @Test("Required int: CLI wins, ENV fallback, default when absent") + internal func requiredInt() throws { + let intKey = ConfigKey("episode-number", envPrefix: "BRIGHTDIGIT", default: -1) + let cli = try #require(intKey.key(for: .commandLine)) + let env = try #require(intKey.key(for: .environment)) + #expect(MockConfigValueReader(ints: [cli: 1, env: 2]).read(intKey) == 1) + #expect(MockConfigValueReader(ints: [env: 2]).read(intKey) == 2) + #expect(MockConfigValueReader().read(intKey) == -1) + } + + @Test("Optional bool: CLI presence true, ENV truthy, nil when absent") + internal func optionalBool() throws { + let boolKey = OptionalConfigKey("verbose", envPrefix: "BRIGHTDIGIT") + let cli = try #require(boolKey.key(for: .commandLine)) + let env = try #require(boolKey.key(for: .environment)) + #expect(MockConfigValueReader(strings: [cli: ""]).read(boolKey) == true) + #expect(MockConfigValueReader(strings: [env: "yes"]).read(boolKey) == true) + #expect(MockConfigValueReader(strings: [env: "false"]).read(boolKey) == false) + #expect(MockConfigValueReader().read(boolKey) == nil) + } + + @Test("Required bool honors sourcePriority: ENV value wins over CLI flag when reversed") + internal func boolReversedPriority() throws { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) + let cli = try #require(boolKey.key(for: .commandLine)) + let env = try #require(boolKey.key(for: .environment)) + // CLI flag present (true) and ENV explicitly "false": precedence decides. + let forward = MockConfigValueReader(strings: [cli: "", env: "false"]) + #expect(forward.read(boolKey) == true) + let reversed = MockConfigValueReader( + strings: [cli: "", env: "false"], + sourcePriority: [.environment, .commandLine] + ) + #expect(reversed.read(boolKey) == false) + } + + @Test("Required bool: empty ENV is treated as absent, default used") + internal func boolEmptyENVUsesDefault() throws { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: true) + let env = try #require(boolKey.key(for: .environment)) + #expect(MockConfigValueReader(strings: [env: ""]).read(boolKey) == true) + #expect(MockConfigValueReader(strings: [env: " "]).read(boolKey) == true) + } + + @Test("Optional date: falls through to next source when higher precedence fails to parse") + internal func optionalDateParseFallthrough() throws { + let dateKey = OptionalConfigKey("published-at", envPrefix: "BRIGHTDIGIT") + let iso = "2026-06-17T00:00:00Z" + let cli = try #require(dateKey.key(for: .commandLine)) + let env = try #require(dateKey.key(for: .environment)) + let reader = MockConfigValueReader(strings: [cli: "not-a-date", env: iso]) + #expect(reader.read(dateKey) == ISO8601DateFormatter().date(from: iso)) + } + + @Test("Optional date: custom parser handles a non-ISO format") + internal func optionalDateCustomParser() throws { + let dateKey = OptionalConfigKey("published-at", envPrefix: "BRIGHTDIGIT") + let cli = try #require(dateKey.key(for: .commandLine)) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "UTC") + let reader = MockConfigValueReader(strings: [cli: "2026-06-17"]) + #expect( + reader.read(dateKey, parsing: formatter.date(from:)) == formatter.date(from: "2026-06-17")) + } + + @Test("Generic parse: falls through to next source when transform fails") + internal func genericParseFallthrough() throws { + let intKey = OptionalConfigKey("episode-number", envPrefix: "BRIGHTDIGIT") + let cli = try #require(intKey.key(for: .commandLine)) + let env = try #require(intKey.key(for: .environment)) + let reader = MockConfigValueReader(strings: [cli: "not-an-int", env: "7"]) + #expect(reader.read(intKey, parsing: { Int($0) }) == 7) + } + + @Test("Generic parse: parses an arbitrary type (URL)") + internal func genericParseURL() throws { + let urlKey = OptionalConfigKey("endpoint", envPrefix: "BRIGHTDIGIT") + let cli = try #require(urlKey.key(for: .commandLine)) + let reader = MockConfigValueReader(strings: [cli: "https://example.com"]) + #expect(reader.read(urlKey, parsing: { URL(string: $0) }) == URL(string: "https://example.com")) + #expect(MockConfigValueReader().read(urlKey, parsing: { URL(string: $0) }) == nil) + } + + @Test("isSecret is forwarded to the reader") + internal func isSecretForwarded() throws { + let secretKey = ConfigKey("api.token", envPrefix: "BRIGHTDIGIT", default: "", isSecret: true) + let plainKey = ConfigKey("base-url", envPrefix: "BRIGHTDIGIT", default: "") + let cliSecret = try #require(secretKey.key(for: .commandLine)) + let cliPlain = try #require(plainKey.key(for: .commandLine)) + let reader = SecretRecordingReader() + _ = reader.read(secretKey) + _ = reader.read(plainKey) + #expect(reader.capturedSecrets[cliSecret] == true) + #expect(reader.capturedSecrets[cliPlain] == false) + } +} diff --git a/Tests/ConfigKeyKitTests/MockConfigValueReader.swift b/Tests/ConfigKeyKitTests/MockConfigValueReader.swift new file mode 100644 index 0000000..eced1c5 --- /dev/null +++ b/Tests/ConfigKeyKitTests/MockConfigValueReader.swift @@ -0,0 +1,60 @@ +// +// MockConfigValueReader.swift +// ConfigKeyKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import ConfigKeyKit + +/// Dict-backed ``ConfigValueReading`` keyed by the exact per-source key strings +/// that `ConfigKey` / `OptionalConfigKey` produce, so the shared `read(_:)` +/// resolution can be exercised without any configuration framework. +internal struct MockConfigValueReader: ConfigValueReading { + internal var strings: [String: String] = [:] + internal var ints: [String: Int] = [:] + internal var doubles: [String: Double] = [:] + internal var sourcePriority: [ConfigKeySource] = ConfigKeySource.priority + + internal func makeConfigKey(_ string: String) -> String { string } + + internal func string( + forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt + ) -> String? { + strings[key] + } + + internal func int( + forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt + ) -> Int? { + ints[key] + } + + internal func double( + forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt + ) -> Double? { + doubles[key] + } +} diff --git a/Tests/ConfigKeyKitTests/SecretRecordingReader.swift b/Tests/ConfigKeyKitTests/SecretRecordingReader.swift new file mode 100644 index 0000000..ff4e2fa --- /dev/null +++ b/Tests/ConfigKeyKitTests/SecretRecordingReader.swift @@ -0,0 +1,62 @@ +// +// SecretRecordingReader.swift +// ConfigKeyKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import ConfigKeyKit + +/// A ``ConfigValueReading`` that records the `isSecret` flag it was passed for +/// each resolved key, so tests can assert that a key's secrecy is forwarded to +/// the underlying reader. Backed by a reference type so reads (which are +/// non-mutating) can capture state. +internal final class SecretRecordingReader: ConfigValueReading { + /// The `isSecret` value most recently observed for each per-source key string. + internal private(set) var capturedSecrets: [String: Bool] = [:] + + internal func makeConfigKey(_ string: String) -> String { string } + + internal func string( + forKey key: String, isSecret: Bool, fileID _: String, line _: UInt + ) -> String? { + capturedSecrets[key] = isSecret + return nil + } + + internal func int( + forKey key: String, isSecret: Bool, fileID _: String, line _: UInt + ) -> Int? { + capturedSecrets[key] = isSecret + return nil + } + + internal func double( + forKey key: String, isSecret: Bool, fileID _: String, line _: UInt + ) -> Double? { + capturedSecrets[key] = isSecret + return nil + } +}