diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 9adfd52..b5e8cfd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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 @@ -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:*)"' \ No newline at end of file + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..6b15fac 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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 *)' 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..1286741 --- /dev/null +++ b/Tests/ConfigKeyKitTests/ConfigValueReadingTests.swift @@ -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("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) + } +} 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] + } +}