Skip to content
19 changes: 16 additions & 3 deletions .github/workflows/ConfigKeyKit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
run: |
if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then
echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT"
echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT"
echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3"},{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' >> "$GITHUB_OUTPUT"
echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT"
else
echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT"
Expand All @@ -70,14 +70,21 @@ 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
matrix:
os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }}
swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }}
type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }}
# Swift nightly does not publish matching Wasm SDK snapshots, so build
# the 6.4 nightly toolchain for the native target only.
exclude:
- swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4.x" }
type: wasm
- swift: { version: "6.4", image: "swiftlang/swift:nightly-6.4.x" }
type: wasm-embedded
steps:
- uses: actions/checkout@v6
- uses: brightdigit/swift-build@v1
Expand All @@ -99,7 +106,13 @@ jobs:
- name: Install coverage.py (silences codecov-cli probe warning)
if: steps.build.outputs.contains-code-coverage == 'true'
run: pip3 install --quiet --user coverage 2>/dev/null || true
- uses: sersoft-gmbh/swift-coverage-action@v5
# brightdigit fork pinned by SHA: under Swift 6.4 the swiftbuild engine
# emits an Xcode-like layout (<Name>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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/swift-source-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
container:
- swift:6.2
- swift:6.3
- swiftlang/swift:nightly-6.4.x-noble

container: ${{ matrix.container }}

Expand Down
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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<Value>` — has a required `defaultValue` (resolution is non-optional).
Comment on lines +23 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Correct the protocol name in architecture guidance.

Line 23 documents ConfigurationKey, but the project contract uses ConfigKey. This will send contributors to the wrong symbol/API naming.

Based on learnings: The ConfigKey protocol has key(for: ConfigKeySource) -> String? as its single required method.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 23 - 24, The documentation in CLAUDE.md incorrectly
references a protocol named `ConfigurationKey` at line 23, but the actual
protocol in the project is named `ConfigKey`. Update the documentation to
replace all instances of `ConfigurationKey` with `ConfigKey` to ensure the
architecture guidance directs contributors to the correct protocol name and API
they should be using.

Source: Learnings

- `OptionalConfigKey<Value>` — 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.
18 changes: 16 additions & 2 deletions Sources/ConfigKeyKit/ConfigKey+Bool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,42 @@ 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] = [:]
keys[.commandLine] = cli
keys[.environment] = env
self.explicitKeys = keys
self.defaultValue = defaultVal
self.isSecret = isSecret
}

/// Initialize a boolean configuration key from base string
/// - Parameters:
/// - 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,
.environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix),
]
self.explicitKeys = [:]
self.defaultValue = defaultVal
self.isSecret = isSecret
}
}
24 changes: 21 additions & 3 deletions Sources/ConfigKeyKit/ConfigKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,50 +47,68 @@ public struct ConfigKey<Value: Sendable>: 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] = [:]
if let cli = cli { keys[.commandLine] = cli }
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
/// - Parameters:
/// - 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
/// - Parameters:
/// - 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,
.environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix),
]
self.explicitKeys = [:]
self.defaultValue = defaultVal
self.isSecret = isSecret
}

/// Returns the resolved key string for the given source.
Expand Down
10 changes: 10 additions & 0 deletions Sources/ConfigKeyKit/ConfigKeySource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ public enum ConfigKeySource: CaseIterable, Sendable {
/// Environment variables (e.g., CLOUDKIT_CONTAINER_ID)
case environment
}

extension ConfigKeySource: PrioritizedConfigKeySource {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add explicit ACL to the extension declaration.

Line 39 introduces a top-level extension without an explicit access modifier. Under explicit_top_level_acl, declare this as public (or internal) explicitly.

Suggested fix
-extension ConfigKeySource: PrioritizedConfigKeySource {
+public extension ConfigKeySource: PrioritizedConfigKeySource {

As per coding guidelines, "Declare access control explicitly on every declaration using explicit_acl and explicit_top_level_acl rules enforced by SwiftLint."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extension ConfigKeySource: PrioritizedConfigKeySource {
public extension ConfigKeySource: PrioritizedConfigKeySource {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ConfigKeyKit/ConfigKeySource.swift` at line 39, The extension
declaration for ConfigKeySource that conforms to PrioritizedConfigKeySource is
missing an explicit access control modifier. Add an explicit access modifier
(public or internal) before the extension keyword in the ConfigKeySource
extension declaration to comply with the explicit_top_level_acl SwiftLint rule.

Source: Coding guidelines

/// 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]
}
Loading
Loading