Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 7 additions & 17 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ jobs:
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -36,19 +36,9 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'codefactor-io[bot]'
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage

Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.

Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.

plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--model sonnet --allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
# or https://code.claude.com/docs/en/cli-reference for available options

2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ jobs:
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# claude_args: '--allowed-tools Bash(gh pr *)'

164 changes: 164 additions & 0 deletions Sources/ConfigKeyKit/ConfigValueReading.swift
Original file line number Diff line number Diff line change
@@ -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>) -> String {
resolvedString(key) ?? key.defaultValue
}

/// Reads a required double value: CLI → ENV → the key's default.
public func read(_ key: ConfigKey<Double>) -> 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>) -> 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>) -> String? {
resolvedString(key)
}

/// Reads an optional integer value: CLI → ENV → `nil`.
public func read(_ key: OptionalConfigKey<Int>) -> Int? {
resolvedInt(key)
}

/// Reads an optional double value: CLI → ENV → `nil`.
public func read(_ key: OptionalConfigKey<Double>) -> Double? {
resolvedDouble(key)
}

/// Reads an optional ISO8601 date value: CLI → ENV → `nil`.
public func read(_ key: OptionalConfigKey<Date>) -> 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
}
}
117 changes: 117 additions & 0 deletions Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//
// 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("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<Int>("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<Double>("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<String>("episode-title", envPrefix: "BRIGHTDIGIT")
#expect(MockConfigValueReader().read(optKey) == nil)
}

@Test("Optional date: ISO8601 parsed from value")
internal func optionalDate() throws {
let dateKey = OptionalConfigKey<Date>("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)
}
}
59 changes: 59 additions & 0 deletions Tests/ConfigKeyKitTests/MockConfigValueReader.swift
Original file line number Diff line number Diff line change
@@ -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]
}
}
Loading