From def859adf446031577860fe72d451ea7a951bfbd Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 17 Jun 2026 19:40:40 -0400 Subject: [PATCH 1/8] ConfigKeyKit: add ConfigValueReading (issue #1, dep-free core) Move the CLI->ENV->default source-precedence resolution that downstream consumers hand-wrote as `extension ConfigReader { read(_:) }` into the Foundation-only core behind a `ConfigValueReading` protocol. An associated `Key` type mirrors swift-configuration's ConfigReader read surface so a consumer conforms in one line (`makeConfigKey`), and the read(_:) overloads (String/Bool/Double required; String/Int/Double/Date optional) are shared and unit-tested via a mock conformer. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ConfigKeyKit/ConfigValueReading.swift | 164 ++++++++++++++++++ .../ConfigValueReadingTests.swift | 134 ++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 Sources/ConfigKeyKit/ConfigValueReading.swift create mode 100644 Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift diff --git a/Sources/ConfigKeyKit/ConfigValueReading.swift b/Sources/ConfigKeyKit/ConfigValueReading.swift new file mode 100644 index 0000000..c57a6d0 --- /dev/null +++ b/Sources/ConfigKeyKit/ConfigValueReading.swift @@ -0,0 +1,164 @@ +// +// 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 + + /// 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 { + /// Reads a required string value: CLI → ENV → the key's default. + public func read(_ key: ConfigKey) -> String { + resolvedString(key) ?? key.defaultValue + } + + /// Reads a required double value: CLI → ENV → the key's default. + public func read(_ key: ConfigKey) -> Double { + resolvedDouble(key) ?? key.defaultValue + } + + /// Reads a required boolean value. + /// + /// - CLI: flag presence indicates `true` (e.g. `--verbose`). + /// - ENV: accepts `true` / `1` / `yes` (case-insensitive); empty is absent. + /// - Otherwise the key's default. + public func read(_ key: ConfigKey) -> Bool { + if let cli = key.key(for: .commandLine), + string(forKey: makeConfigKey(cli), isSecret: false, fileID: #fileID, line: #line) != nil + { + return true + } + if let env = key.key(for: .environment), + let value = string( + forKey: makeConfigKey(env), isSecret: false, fileID: #fileID, line: #line + ) + { + let normalized = value.lowercased().trimmingCharacters(in: .whitespaces) + return normalized == "true" || normalized == "1" || normalized == "yes" + } + return key.defaultValue + } + + /// Reads an optional string value: CLI → ENV → `nil`. + public func read(_ key: OptionalConfigKey) -> String? { + resolvedString(key) + } + + /// Reads an optional integer value: CLI → ENV → `nil`. + public func read(_ key: OptionalConfigKey) -> Int? { + resolvedInt(key) + } + + /// Reads an optional double value: CLI → ENV → `nil`. + public func read(_ key: OptionalConfigKey) -> Double? { + resolvedDouble(key) + } + + /// Reads an optional ISO8601 date value: CLI → ENV → `nil`. + public func read(_ key: OptionalConfigKey) -> Date? { + guard let value = resolvedString(key) else { + return nil + } + return ISO8601DateFormatter().date(from: value) + } + + // MARK: - Source-precedence resolution + + private func resolvedString(_ key: any ConfigurationKey) -> String? { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = string( + forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line + ) { + return value + } + } + return nil + } + + private func resolvedInt(_ key: any ConfigurationKey) -> Int? { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = int( + forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line + ) { + return value + } + } + return nil + } + + private func resolvedDouble(_ key: any ConfigurationKey) -> Double? { + for source in ConfigKeySource.allCases { + guard let keyString = key.key(for: source) else { continue } + if let value = double( + forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line + ) { + return value + } + } + return nil + } +} diff --git a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift new file mode 100644 index 0000000..e671560 --- /dev/null +++ b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift @@ -0,0 +1,134 @@ +// +// 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 + +/// Dict-backed mock reader, keyed by the exact per-source key strings that +/// `ConfigKey`/`OptionalConfigKey` produce. No configuration framework needed. +private struct MockReader: ConfigValueReading { + var strings: [String: String] = [:] + var ints: [String: Int] = [:] + var doubles: [String: Double] = [:] + + func makeConfigKey(_ string: String) -> String { string } + + func string(forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt) -> String? { + strings[key] + } + + func int(forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt) -> Int? { + ints[key] + } + + func double(forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt) -> Double? { + doubles[key] + } +} + +@Suite("ConfigValueReading Tests") +internal struct ConfigValueReadingTests { + private let key = ConfigKey("base-url", envPrefix: "BRIGHTDIGIT", default: "default-url") + private var cliKey: String { key.key(for: .commandLine)! } + private var envKey: String { key.key(for: .environment)! } + + @Test("Required string: CLI wins over ENV") + internal func requiredStringCLIPrecedence() { + let reader = MockReader(strings: [cliKey: "from-cli", envKey: "from-env"]) + #expect(reader.read(key) == "from-cli") + } + + @Test("Required string: ENV used when CLI absent") + internal func requiredStringENVFallback() { + let reader = MockReader(strings: [envKey: "from-env"]) + #expect(reader.read(key) == "from-env") + } + + @Test("Required string: default used when neither source present") + internal func requiredStringDefault() { + let reader = MockReader() + #expect(reader.read(key) == "default-url") + } + + @Test("Required bool: CLI flag presence is true") + internal func boolCLIPresence() { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) + let reader = MockReader(strings: [boolKey.key(for: .commandLine)!: ""]) + #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) { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) + let reader = MockReader(strings: [boolKey.key(for: .environment)!: value]) + #expect(reader.read(boolKey) == expected) + } + + @Test("Required bool: default when absent") + internal func boolDefault() { + let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: true) + #expect(MockReader().read(boolKey) == true) + } + + @Test("Optional int: parsed with precedence, nil when absent") + internal func optionalInt() { + let intKey = OptionalConfigKey("episode-number", envPrefix: "BRIGHTDIGIT") + let reader = MockReader(ints: [intKey.key(for: .commandLine)!: 42]) + #expect(reader.read(intKey) == 42) + #expect(MockReader().read(intKey) == nil) + } + + @Test("Optional double: parsed, nil when absent") + internal func optionalDouble() { + let doubleKey = OptionalConfigKey("min-interval", envPrefix: "BRIGHTDIGIT") + let reader = MockReader(doubles: [doubleKey.key(for: .environment)!: 1.5]) + #expect(reader.read(doubleKey) == 1.5) + #expect(MockReader().read(doubleKey) == nil) + } + + @Test("Optional string: nil when absent") + internal func optionalStringNil() { + let optKey = OptionalConfigKey("episode-title", envPrefix: "BRIGHTDIGIT") + #expect(MockReader().read(optKey) == nil) + } + + @Test("Optional date: ISO8601 parsed from value") + internal func optionalDate() { + let dateKey = OptionalConfigKey("published-at", envPrefix: "BRIGHTDIGIT") + let iso = "2026-06-17T00:00:00Z" + let reader = MockReader(strings: [dateKey.key(for: .commandLine)!: iso]) + #expect(reader.read(dateKey) == ISO8601DateFormatter().date(from: iso)) + #expect(MockReader().read(dateKey) == nil) + } +} From e1508de380a58887f2ad6deccd4c28af1bd116c8 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 17 Jun 2026 20:13:48 -0400 Subject: [PATCH 2/8] ConfigKeyKit tests: satisfy STRICT lint (force_unwrap, one_declaration_per_file) Split MockConfigValueReader into its own file and replace force-unwraps with try #require, so swift-format + SwiftLint --strict pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ConfigValueReadingTests.swift | 73 +++++++------------ .../MockConfigValueReader.swift | 59 +++++++++++++++ 2 files changed, 87 insertions(+), 45 deletions(-) create mode 100644 Tests/ConfigKeyKitTests/MockConfigValueReader.swift diff --git a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift index e671560..1286741 100644 --- a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift +++ b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift @@ -32,56 +32,35 @@ import Testing @testable import ConfigKeyKit -/// Dict-backed mock reader, keyed by the exact per-source key strings that -/// `ConfigKey`/`OptionalConfigKey` produce. No configuration framework needed. -private struct MockReader: ConfigValueReading { - var strings: [String: String] = [:] - var ints: [String: Int] = [:] - var doubles: [String: Double] = [:] - - func makeConfigKey(_ string: String) -> String { string } - - func string(forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt) -> String? { - strings[key] - } - - func int(forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt) -> Int? { - ints[key] - } - - func double(forKey key: String, isSecret _: Bool, fileID _: String, line _: UInt) -> Double? { - doubles[key] - } -} - @Suite("ConfigValueReading Tests") internal struct ConfigValueReadingTests { private let key = ConfigKey("base-url", envPrefix: "BRIGHTDIGIT", default: "default-url") - private var cliKey: String { key.key(for: .commandLine)! } - private var envKey: String { key.key(for: .environment)! } @Test("Required string: CLI wins over ENV") - internal func requiredStringCLIPrecedence() { - let reader = MockReader(strings: [cliKey: "from-cli", envKey: "from-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() { - let reader = MockReader(strings: [envKey: "from-env"]) + 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() { - let reader = MockReader() - #expect(reader.read(key) == "default-url") + #expect(MockConfigValueReader().read(key) == "default-url") } @Test("Required bool: CLI flag presence is true") - internal func boolCLIPresence() { + internal func boolCLIPresence() throws { let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) - let reader = MockReader(strings: [boolKey.key(for: .commandLine)!: ""]) + let cli = try #require(boolKey.key(for: .commandLine)) + let reader = MockConfigValueReader(strings: [cli: ""]) #expect(reader.read(boolKey) == true) } @@ -89,46 +68,50 @@ internal struct ConfigValueReadingTests { "Required bool: ENV truthy strings", arguments: [("true", true), ("1", true), ("YES", true), ("false", false), ("0", false)] ) - internal func boolENVParsing(value: String, expected: Bool) { + internal func boolENVParsing(value: String, expected: Bool) throws { let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false) - let reader = MockReader(strings: [boolKey.key(for: .environment)!: value]) + 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(MockReader().read(boolKey) == true) + #expect(MockConfigValueReader().read(boolKey) == true) } @Test("Optional int: parsed with precedence, nil when absent") - internal func optionalInt() { + internal func optionalInt() throws { let intKey = OptionalConfigKey("episode-number", envPrefix: "BRIGHTDIGIT") - let reader = MockReader(ints: [intKey.key(for: .commandLine)!: 42]) + let cli = try #require(intKey.key(for: .commandLine)) + let reader = MockConfigValueReader(ints: [cli: 42]) #expect(reader.read(intKey) == 42) - #expect(MockReader().read(intKey) == nil) + #expect(MockConfigValueReader().read(intKey) == nil) } @Test("Optional double: parsed, nil when absent") - internal func optionalDouble() { + internal func optionalDouble() throws { let doubleKey = OptionalConfigKey("min-interval", envPrefix: "BRIGHTDIGIT") - let reader = MockReader(doubles: [doubleKey.key(for: .environment)!: 1.5]) + let env = try #require(doubleKey.key(for: .environment)) + let reader = MockConfigValueReader(doubles: [env: 1.5]) #expect(reader.read(doubleKey) == 1.5) - #expect(MockReader().read(doubleKey) == nil) + #expect(MockConfigValueReader().read(doubleKey) == nil) } @Test("Optional string: nil when absent") internal func optionalStringNil() { let optKey = OptionalConfigKey("episode-title", envPrefix: "BRIGHTDIGIT") - #expect(MockReader().read(optKey) == nil) + #expect(MockConfigValueReader().read(optKey) == nil) } @Test("Optional date: ISO8601 parsed from value") - internal func optionalDate() { + internal func optionalDate() throws { let dateKey = OptionalConfigKey("published-at", envPrefix: "BRIGHTDIGIT") let iso = "2026-06-17T00:00:00Z" - let reader = MockReader(strings: [dateKey.key(for: .commandLine)!: iso]) + let cli = try #require(dateKey.key(for: .commandLine)) + let reader = MockConfigValueReader(strings: [cli: iso]) #expect(reader.read(dateKey) == ISO8601DateFormatter().date(from: iso)) - #expect(MockReader().read(dateKey) == nil) + #expect(MockConfigValueReader().read(dateKey) == nil) } } diff --git a/Tests/ConfigKeyKitTests/MockConfigValueReader.swift b/Tests/ConfigKeyKitTests/MockConfigValueReader.swift new file mode 100644 index 0000000..322242b --- /dev/null +++ b/Tests/ConfigKeyKitTests/MockConfigValueReader.swift @@ -0,0 +1,59 @@ +// +// 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 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] + } +} From c65c88cd4279c1fc6d6d9834023fdccb26e475c4 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 19 Jun 2026 16:04:48 -0400 Subject: [PATCH 3/8] adding missing CLAUDE.md --- CLAUDE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 CLAUDE.md 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. From 4c5d1e1a4cd09825c66c3268224874772a6ca7e5 Mon Sep 17 00:00:00 2001 From: leogdion Date: Fri, 19 Jun 2026 16:23:34 -0400 Subject: [PATCH 4/8] CI: add Swift 6.4 nightly to build matrices Add swiftlang/swift:nightly-6.4 to the main build-ubuntu matrix and the source-compatibility suite. Native target only on the main matrix, since nightly does not publish matching Wasm SDK snapshots. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ConfigKeyKit.yml | 11 +++++++++-- .github/workflows/swift-source-compat.yml | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ConfigKeyKit.yml b/.github/workflows/ConfigKeyKit.yml index 2bcb8a1..aa90995 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"}]' >> "$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" } + type: wasm + - swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4" } + type: wasm-embedded steps: - uses: actions/checkout@v6 - uses: brightdigit/swift-build@v1 diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml index 982157d..4057173 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-noble container: ${{ matrix.container }} From 408f24ff8c0c6643aef95322d2b4318a9e6316c0 Mon Sep 17 00:00:00 2001 From: leogdion Date: Fri, 19 Jun 2026 16:27:07 -0400 Subject: [PATCH 5/8] CI: fix Swift 6.4 nightly image tag (nightly-6.4.x) The release-branch nightly tags use the X.Y.x form, e.g. swiftlang/swift:nightly-6.4.x-noble; nightly-6.4-noble does not exist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ConfigKeyKit.yml | 6 +++--- .github/workflows/swift-source-compat.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ConfigKeyKit.yml b/.github/workflows/ConfigKeyKit.yml index aa90995..6e6f66a 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"},{"version":"6.4","image":"swiftlang/swift:nightly-6.4"}]' >> "$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" @@ -81,9 +81,9 @@ jobs: # 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" } + - swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4.x" } type: wasm - - swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4" } + - swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4.x" } type: wasm-embedded steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml index 4057173..6583fae 100644 --- a/.github/workflows/swift-source-compat.yml +++ b/.github/workflows/swift-source-compat.yml @@ -18,7 +18,7 @@ jobs: container: - swift:6.2 - swift:6.3 - - swiftlang/swift:nightly-6.4-noble + - swiftlang/swift:nightly-6.4.x-noble container: ${{ matrix.container }} From 84acb3c99040455fc010da7868501f0eaaccc071 Mon Sep 17 00:00:00 2001 From: leogdion Date: Fri, 19 Jun 2026 16:48:06 -0400 Subject: [PATCH 6/8] CI: use brightdigit/swift-coverage-action fork for Ubuntu coverage Swift 6.4's swiftbuild engine emits an Xcode-like coverage layout (Tests.so + -test-runner stub, no .xctest) that upstream sersoft-gmbh/swift-coverage-action@v5 can't pair, so the 6.4 nightly Ubuntu leg failed with "No coverage files found" under fail-on-empty-output: true. Point the Ubuntu coverage step at the SHA-pinned brightdigit fork (Linux-gated patch); macOS legs stay on upstream @v5. See brightdigit.com#111 / issue #92. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ConfigKeyKit.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ConfigKeyKit.yml b/.github/workflows/ConfigKeyKit.yml index 6e6f66a..8309829 100644 --- a/.github/workflows/ConfigKeyKit.yml +++ b/.github/workflows/ConfigKeyKit.yml @@ -106,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: From 5a626f65665fdbbdc3b538778e1780f87f4c78df Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 19 Jun 2026 20:11:20 -0400 Subject: [PATCH 7/8] Issue #2: explicit ConfigKeySource precedence contract Make source precedence a stable, documented API instead of relying on the undocumented CaseIterable declaration order. - Add PrioritizedConfigKeySource protocol with `static var priority` (defaults to Array(allCases)); ConfigKeySource pins an explicit [.commandLine, .environment], decoupling precedence from case order. - Add overridable `sourcePriority` to ConfigValueReading (defaults to ConfigKeySource.priority); resolvedString/Int/Double now iterate it instead of allCases. - Tests: pin priority order + drift guard (priority covers every case), and a reversed-sourcePriority override resolving ENV over CLI. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ConfigKeyKit/ConfigKeySource.swift | 10 +++++ Sources/ConfigKeyKit/ConfigValueReading.swift | 15 +++++-- .../PrioritizedConfigKeySource.swift | 45 +++++++++++++++++++ .../ConfigKeySourceTests.swift | 10 +++++ .../ConfigValueReadingTests.swift | 11 +++++ .../MockConfigValueReader.swift | 1 + 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 Sources/ConfigKeyKit/PrioritizedConfigKeySource.swift 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 index c57a6d0..d31fc21 100644 --- a/Sources/ConfigKeyKit/ConfigValueReading.swift +++ b/Sources/ConfigKeyKit/ConfigValueReading.swift @@ -55,6 +55,11 @@ 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 @@ -69,6 +74,10 @@ public protocol ConfigValueReading { } extension ConfigValueReading { + /// The sources consulted during resolution, defaulting to + /// ``ConfigKeySource/priority`` (command line, then environment). + public var sourcePriority: [ConfigKeySource] { ConfigKeySource.priority } + /// Reads a required string value: CLI → ENV → the key's default. public func read(_ key: ConfigKey) -> String { resolvedString(key) ?? key.defaultValue @@ -127,7 +136,7 @@ extension ConfigValueReading { // MARK: - Source-precedence resolution private func resolvedString(_ key: any ConfigurationKey) -> String? { - for source in ConfigKeySource.allCases { + for source in sourcePriority { guard let keyString = key.key(for: source) else { continue } if let value = string( forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line @@ -139,7 +148,7 @@ extension ConfigValueReading { } private func resolvedInt(_ key: any ConfigurationKey) -> Int? { - for source in ConfigKeySource.allCases { + for source in sourcePriority { guard let keyString = key.key(for: source) else { continue } if let value = int( forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line @@ -151,7 +160,7 @@ extension ConfigValueReading { } private func resolvedDouble(_ key: any ConfigurationKey) -> Double? { - for source in ConfigKeySource.allCases { + for source in sourcePriority { guard let keyString = key.key(for: source) else { continue } if let value = double( forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line 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..6c0bc87 100644 --- a/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift +++ b/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift @@ -40,4 +40,14 @@ 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)) + } } diff --git a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift index 1286741..6c6bb11 100644 --- a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift +++ b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift @@ -56,6 +56,17 @@ internal struct ConfigValueReadingTests { #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) diff --git a/Tests/ConfigKeyKitTests/MockConfigValueReader.swift b/Tests/ConfigKeyKitTests/MockConfigValueReader.swift index 322242b..eced1c5 100644 --- a/Tests/ConfigKeyKitTests/MockConfigValueReader.swift +++ b/Tests/ConfigKeyKitTests/MockConfigValueReader.swift @@ -36,6 +36,7 @@ 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 } From 6cfa51ba2f0fcb8ae0c8b645797f50508b9a9022 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 19 Jun 2026 20:47:06 -0400 Subject: [PATCH 8/8] Address PR #3 review: priority-aware reads, typed-API gaps, configurable parsing, isSecret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bool reads honor sourcePriority (was hardcoded CLI→ENV); empty ENV value is now treated as absent and falls back to the key's default. - Add read(ConfigKey) and read(OptionalConfigKey) to close the required-Int / optional-Bool API gaps. - Add generic read(_:parsing:) that falls through on parse failure; Date becomes a convenience defaulting to ISO8601 with a caller-overridable parser. - Make isSecret a settable property on keys (default false), exposed via the ConfigurationKey protocol, and forward it through every read(). - Collapse the duplicated resolved* helpers into one generic helper. - Tests: reversed-priority bool, empty-ENV bool, date fallthrough, custom date parser, generic parse, isSecret forwarding, and a priority count guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ConfigKeyKit/ConfigKey+Bool.swift | 18 ++- Sources/ConfigKeyKit/ConfigKey.swift | 24 +++- Sources/ConfigKeyKit/ConfigValueReading.swift | 133 ++++++++++++------ Sources/ConfigKeyKit/ConfigurationKey.swift | 6 + Sources/ConfigKeyKit/OptionalConfigKey.swift | 14 +- .../ConfigKeySourceTests.swift | 2 + .../ConfigValueReadingTests.swift | 97 +++++++++++++ .../SecretRecordingReader.swift | 62 ++++++++ 8 files changed, 304 insertions(+), 52 deletions(-) create mode 100644 Tests/ConfigKeyKitTests/SecretRecordingReader.swift 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/ConfigValueReading.swift b/Sources/ConfigKeyKit/ConfigValueReading.swift index d31fc21..fb47721 100644 --- a/Sources/ConfigKeyKit/ConfigValueReading.swift +++ b/Sources/ConfigKeyKit/ConfigValueReading.swift @@ -78,95 +78,140 @@ extension ConfigValueReading { /// ``ConfigKeySource/priority`` (command line, then environment). public var sourcePriority: [ConfigKeySource] { ConfigKeySource.priority } - /// Reads a required string value: CLI → ENV → the key's default. + // 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 double value: CLI → ENV → the key's default. + /// 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. + /// Reads a required boolean value, consulting sources in ``sourcePriority`` + /// order and falling back to the key's default. /// - /// - CLI: flag presence indicates `true` (e.g. `--verbose`). - /// - ENV: accepts `true` / `1` / `yes` (case-insensitive); empty is absent. - /// - Otherwise 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 { - if let cli = key.key(for: .commandLine), - string(forKey: makeConfigKey(cli), isSecret: false, fileID: #fileID, line: #line) != nil - { - return true - } - if let env = key.key(for: .environment), - let value = string( - forKey: makeConfigKey(env), isSecret: false, fileID: #fileID, line: #line - ) - { - let normalized = value.lowercased().trimmingCharacters(in: .whitespaces) - return normalized == "true" || normalized == "1" || normalized == "yes" - } - return key.defaultValue + resolvedBool(key) ?? key.defaultValue } - /// Reads an optional string value: CLI → ENV → `nil`. + // 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: CLI → ENV → `nil`. + /// 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: CLI → ENV → `nil`. + /// Reads an optional double value, or `nil` if no source provides one. public func read(_ key: OptionalConfigKey) -> Double? { resolvedDouble(key) } - /// Reads an optional ISO8601 date value: CLI → ENV → `nil`. - public func read(_ key: OptionalConfigKey) -> Date? { - guard let value = resolvedString(key) else { - return nil - } - return ISO8601DateFormatter().date(from: value) + // 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 - // MARK: - Source-precedence resolution - - private func resolvedString(_ key: any ConfigurationKey) -> String? { + /// 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 } - if let value = string( - forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line - ) { - return value + 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 } - private func resolvedInt(_ key: any ConfigurationKey) -> Int? { + /// 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 = int( - forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line - ) { + 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 } - if let value = double( - forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line - ) { - return value + 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/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift b/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift index 6c0bc87..c942797 100644 --- a/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift +++ b/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift @@ -49,5 +49,7 @@ internal struct ConfigKeySourceTests { @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 index 6c6bb11..cb2cb15 100644 --- a/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift +++ b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift @@ -125,4 +125,101 @@ internal struct ConfigValueReadingTests { #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/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 + } +}