Skip to content

Commit 5a626f6

Browse files
leogdionclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 84acb3c commit 5a626f6

6 files changed

Lines changed: 89 additions & 3 deletions

File tree

Sources/ConfigKeyKit/ConfigKeySource.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,13 @@ public enum ConfigKeySource: CaseIterable, Sendable {
3535
/// Environment variables (e.g., CLOUDKIT_CONTAINER_ID)
3636
case environment
3737
}
38+
39+
extension ConfigKeySource: PrioritizedConfigKeySource {
40+
/// Sources in precedence order, highest first: command line overrides
41+
/// environment.
42+
///
43+
/// This order is part of the public API. `ConfigValueReading` resolution
44+
/// consults sources in this sequence, and it is pinned explicitly so it stays
45+
/// independent of `case` declaration order.
46+
public static let priority: [ConfigKeySource] = [.commandLine, .environment]
47+
}

Sources/ConfigKeyKit/ConfigValueReading.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ public protocol ConfigValueReading {
5555
/// The reader's native key type (e.g. `Configuration.ConfigKey`).
5656
associatedtype Key
5757

58+
/// The sources consulted during resolution, in precedence order, highest
59+
/// first. Defaults to ``ConfigKeySource/priority``; override to resolve a
60+
/// reader with a different precedence (e.g. environment before command line).
61+
var sourcePriority: [ConfigKeySource] { get }
62+
5863
/// Builds a native ``Key`` from a resolved per-source key string.
5964
func makeConfigKey(_ string: String) -> Key
6065

@@ -69,6 +74,10 @@ public protocol ConfigValueReading {
6974
}
7075

7176
extension ConfigValueReading {
77+
/// The sources consulted during resolution, defaulting to
78+
/// ``ConfigKeySource/priority`` (command line, then environment).
79+
public var sourcePriority: [ConfigKeySource] { ConfigKeySource.priority }
80+
7281
/// Reads a required string value: CLI → ENV → the key's default.
7382
public func read(_ key: ConfigKey<String>) -> String {
7483
resolvedString(key) ?? key.defaultValue
@@ -127,7 +136,7 @@ extension ConfigValueReading {
127136
// MARK: - Source-precedence resolution
128137

129138
private func resolvedString(_ key: any ConfigurationKey) -> String? {
130-
for source in ConfigKeySource.allCases {
139+
for source in sourcePriority {
131140
guard let keyString = key.key(for: source) else { continue }
132141
if let value = string(
133142
forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line
@@ -139,7 +148,7 @@ extension ConfigValueReading {
139148
}
140149

141150
private func resolvedInt(_ key: any ConfigurationKey) -> Int? {
142-
for source in ConfigKeySource.allCases {
151+
for source in sourcePriority {
143152
guard let keyString = key.key(for: source) else { continue }
144153
if let value = int(
145154
forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line
@@ -151,7 +160,7 @@ extension ConfigValueReading {
151160
}
152161

153162
private func resolvedDouble(_ key: any ConfigurationKey) -> Double? {
154-
for source in ConfigKeySource.allCases {
163+
for source in sourcePriority {
155164
guard let keyString = key.key(for: source) else { continue }
156165
if let value = double(
157166
forKey: makeConfigKey(keyString), isSecret: false, fileID: #fileID, line: #line
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// PrioritizedConfigKeySource.swift
3+
// ConfigKeyKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2026 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the "Software"), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
/// A `CaseIterable` whose cases carry a precedence order, highest first.
31+
///
32+
/// Conformers expose ``priority`` as the authoritative ordering for resolution,
33+
/// decoupling precedence from the order in which `case`s happen to be declared.
34+
public protocol PrioritizedConfigKeySource: CaseIterable {
35+
/// The cases in precedence order, highest priority first.
36+
static var priority: [Self] { get }
37+
}
38+
39+
extension PrioritizedConfigKeySource {
40+
/// Defaults to declaration order (`Array(allCases)`).
41+
///
42+
/// Conformers that want precedence to be independent of `case` declaration
43+
/// order should override this with an explicit array.
44+
public static var priority: [Self] { Array(allCases) }
45+
}

Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,14 @@ internal struct ConfigKeySourceTests {
4040
#expect(sources.contains(.commandLine))
4141
#expect(sources.contains(.environment))
4242
}
43+
44+
@Test("Priority order is command line before environment")
45+
internal func priorityOrder() {
46+
#expect(ConfigKeySource.priority == [.commandLine, .environment])
47+
}
48+
49+
@Test("Priority covers every case")
50+
internal func priorityCoversEveryCase() {
51+
#expect(Set(ConfigKeySource.priority) == Set(ConfigKeySource.allCases))
52+
}
4353
}

Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ internal struct ConfigValueReadingTests {
5656
#expect(MockConfigValueReader().read(key) == "default-url")
5757
}
5858

59+
@Test("sourcePriority override: ENV wins over CLI when reversed")
60+
internal func sourcePriorityOverride() throws {
61+
let cli = try #require(key.key(for: .commandLine))
62+
let env = try #require(key.key(for: .environment))
63+
let reader = MockConfigValueReader(
64+
strings: [cli: "from-cli", env: "from-env"],
65+
sourcePriority: [.environment, .commandLine]
66+
)
67+
#expect(reader.read(key) == "from-env")
68+
}
69+
5970
@Test("Required bool: CLI flag presence is true")
6071
internal func boolCLIPresence() throws {
6172
let boolKey = ConfigKey("verbose", envPrefix: "BRIGHTDIGIT", default: false)

Tests/ConfigKeyKitTests/MockConfigValueReader.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ internal struct MockConfigValueReader: ConfigValueReading {
3636
internal var strings: [String: String] = [:]
3737
internal var ints: [String: Int] = [:]
3838
internal var doubles: [String: Double] = [:]
39+
internal var sourcePriority: [ConfigKeySource] = ConfigKeySource.priority
3940

4041
internal func makeConfigKey(_ string: String) -> String { string }
4142

0 commit comments

Comments
 (0)