Skip to content
Merged
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
29 changes: 29 additions & 0 deletions Sources/DevConfiguration/Core/CodableValueRepresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,28 @@ public struct CodableValueRepresentation: Sendable {
}


/// Converts encoded `Data` into the appropriate ``ConfigContent`` for this representation.
///
/// For string-backed representations, this converts the data to a string using the representation's encoding and
/// returns it as ``ConfigContent/string(_:)``. For data-backed representations, this returns the data's bytes as
/// ``ConfigContent/bytes(_:)``.
///
/// - Parameter data: The encoded data to convert.
/// - Returns: The ``ConfigContent`` representing the encoded data.
/// - Throws: ``StringEncodingError`` if the data cannot be converted to a string using the expected encoding.
func encodeToContent(_ data: Data) throws -> ConfigContent {
switch kind {
case .string(let encoding):
guard let string = String(data: data, encoding: encoding) else {
throw StringEncodingError(encoding: encoding)
}
return .string(string)
case .data:
return .bytes(Array(data))
}
}


/// Watches for raw data changes from the reader based on this representation.
///
/// Each time the underlying configuration value changes, `onUpdate` is called with the new raw data (or `nil` if the
Expand Down Expand Up @@ -154,3 +176,10 @@ public struct CodableValueRepresentation: Sendable {
}
}
}


/// An error thrown when encoded data cannot be converted to a string using the expected encoding.
struct StringEncodingError: Error {
/// The string encoding that failed.
let encoding: String.Encoding
}
62 changes: 44 additions & 18 deletions Sources/DevConfiguration/Core/ConfigVariableContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public struct ConfigVariableContent<Value>: Sendable where Value: Sendable {
_ line: UInt,
_ continuation: AsyncStream<Value>.Continuation
) async throws -> Void

/// Encodes a value into a ``ConfigContent`` for registration.
let encode: @Sendable (_ value: Value) throws -> ConfigContent
}


Expand Down Expand Up @@ -99,7 +102,8 @@ extension ConfigVariableContent where Value == Bool {
continuation.yield(value)
}
}
}
},
encode: { .bool($0) }
)
}
}
Expand Down Expand Up @@ -134,7 +138,8 @@ extension ConfigVariableContent where Value == [Bool] {
continuation.yield(value)
}
}
}
},
encode: { .boolArray($0) }
)
}
}
Expand Down Expand Up @@ -169,7 +174,8 @@ extension ConfigVariableContent where Value == Float64 {
continuation.yield(value)
}
}
}
},
encode: { .double($0) }
)
}
}
Expand Down Expand Up @@ -204,7 +210,8 @@ extension ConfigVariableContent where Value == [Float64] {
continuation.yield(value)
}
}
}
},
encode: { .doubleArray($0) }
)
}
}
Expand Down Expand Up @@ -239,7 +246,8 @@ extension ConfigVariableContent where Value == Int {
continuation.yield(value)
}
}
}
},
encode: { .int($0) }
)
}
}
Expand Down Expand Up @@ -274,7 +282,8 @@ extension ConfigVariableContent where Value == [Int] {
continuation.yield(value)
}
}
}
},
encode: { .intArray($0) }
)
}
}
Expand Down Expand Up @@ -309,7 +318,8 @@ extension ConfigVariableContent where Value == String {
continuation.yield(value)
}
}
}
},
encode: { .string($0) }
)
}
}
Expand Down Expand Up @@ -344,7 +354,8 @@ extension ConfigVariableContent where Value == [String] {
continuation.yield(value)
}
}
}
},
encode: { .stringArray($0) }
)
}
}
Expand Down Expand Up @@ -379,7 +390,8 @@ extension ConfigVariableContent where Value == [UInt8] {
continuation.yield(value)
}
}
}
},
encode: { .bytes($0) }
)
}
}
Expand Down Expand Up @@ -420,7 +432,8 @@ extension ConfigVariableContent where Value == [[UInt8]] {
continuation.yield(value)
}
}
}
},
encode: { .byteChunkArray($0) }
)
}
}
Expand Down Expand Up @@ -467,7 +480,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .string($0.rawValue) }
)
}

Expand Down Expand Up @@ -510,7 +524,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .stringArray($0.map(\.rawValue)) }
)
}

Expand Down Expand Up @@ -552,7 +567,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .string($0.description) }
)
}

Expand Down Expand Up @@ -595,7 +611,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .stringArray($0.map(\.description)) }
)
}
}
Expand Down Expand Up @@ -642,7 +659,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .int($0.rawValue) }
)
}

Expand Down Expand Up @@ -685,7 +703,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .intArray($0.map(\.rawValue)) }
)
}

Expand Down Expand Up @@ -727,7 +746,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .int($0.configInt) }
)
}

Expand Down Expand Up @@ -770,7 +790,8 @@ extension ConfigVariableContent {
continuation.yield(value)
}
}
}
},
encode: { .intArray($0.map(\.configInt)) }
)
}
}
Expand Down Expand Up @@ -905,6 +926,11 @@ extension ConfigVariableContent {
}
continuation.yield(defaultValue)
}
},
encode: { (value) in
let resolvedEncoder = encoder ?? JSONEncoder()
let data = try resolvedEncoder.encode(value)
return try representation.encodeToContent(data)
}
)
}
Expand Down
45 changes: 45 additions & 0 deletions Sources/DevConfiguration/Core/ConfigVariableReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Configuration
import DevFoundation
import OSLog

/// Provides access to configuration values queried by a `ConfigVariable`.
///
Expand Down Expand Up @@ -53,6 +54,12 @@ public struct ConfigVariableReader {
/// The event bus used to post diagnostic events like ``ConfigVariableDecodingFailedEvent``.
public let eventBus: EventBus

/// The variables that have been registered with this reader, keyed by their configuration key.
private(set) var registeredVariables: [ConfigKey: RegisteredConfigVariable] = [:]

/// The logger used for registration diagnostics.
private static let logger = Logger(subsystem: "DevConfiguration", category: "ConfigVariableReader")


/// Creates a new `ConfigVariableReader` with the specified providers and the default telemetry access reporter.
///
Expand Down Expand Up @@ -87,6 +94,44 @@ public struct ConfigVariableReader {
}


// MARK: - Registration

extension ConfigVariableReader {
/// Registers a configuration variable with this reader.
///
/// Registration records the variable's key, default value, secrecy, and metadata in a non-generic form so that the
/// reader can provide information about all registered variables without needing their generic type parameters.
///
/// Registration is intended to be performed during setup, before the reader is shared with other components. If a
/// variable with the same key has already been registered, the new registration overwrites the previous one, a
/// warning is logged, and an assertion failure is triggered.
///
/// - Parameter variable: The configuration variable to register.
public mutating func register<Value>(_ variable: ConfigVariable<Value>) {
let defaultContent: ConfigContent
do {
defaultContent = try variable.content.encode(variable.defaultValue)
} catch {
assertionFailure("Failed to encode default value for config variable '\(variable.key)': \(error)")
Self.logger.error("Failed to encode default value for config variable '\(variable.key)': \(error)")
return
}

if registeredVariables[variable.key] != nil {
assertionFailure("Config variable '\(variable.key)' is already registered")
Self.logger.error("Config variable '\(variable.key)' is already registered; overwriting")
}

registeredVariables[variable.key] = RegisteredConfigVariable(
key: variable.key,
defaultContent: defaultContent,
secrecy: variable.secrecy,
metadata: variable.metadata
)
}
}


// MARK: - Value Access

extension ConfigVariableReader {
Expand Down
42 changes: 42 additions & 0 deletions Sources/DevConfiguration/Core/RegisteredConfigVariable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// RegisteredConfigVariable.swift
// DevConfiguration
//
// Created by Prachi Gauriar on 3/5/2026.
//

import Configuration

/// A non-generic representation of a registered ``ConfigVariable``.
///
/// `RegisteredConfigVariable` stores the type-erased information from a ``ConfigVariable`` so that registered variables
/// can be stored in homogeneous collections. It captures the variable's key, its default value as a ``ConfigContent``,
/// its secrecy setting, and any attached metadata.
@dynamicMemberLookup
struct RegisteredConfigVariable: Sendable {
/// The configuration key used to look up this variable's value.
let key: ConfigKey

/// The variable's default value represented as a ``ConfigContent``.
let defaultContent: ConfigContent

/// Whether this value should be treated as a secret.
let secrecy: ConfigVariableSecrecy

/// The configuration variable's metadata.
let metadata: ConfigVariableMetadata


/// Provides dynamic member lookup access to metadata properties.
///
/// This subscript enables dot-syntax access to metadata properties, mirroring the access pattern on
/// ``ConfigVariable``.
///
/// - Parameter keyPath: A keypath to a property on `ConfigVariableMetadata`.
/// - Returns: The value of the metadata property.
subscript<MetadataValue>(
dynamicMember keyPath: KeyPath<ConfigVariableMetadata, MetadataValue>
) -> MetadataValue {
metadata[keyPath: keyPath]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// MockCodableConfig.swift
// DevConfiguration
//
// Created by Prachi Gauriar on 3/5/2026.
//

struct MockCodableConfig: Codable, Hashable, Sendable {
let variant: String
let count: Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// MockConfigIntValue.swift
// DevConfiguration
//
// Created by Prachi Gauriar on 3/5/2026.
//

import Configuration

struct MockConfigIntValue: ExpressibleByConfigInt, Hashable, Sendable {
let intValue: Int
var configInt: Int { intValue }
var description: String { "\(intValue)" }

init?(configInt: Int) {
self.intValue = configInt
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// MockConfigStringValue.swift
// DevConfiguration
//
// Created by Prachi Gauriar on 3/5/2026.
//

import Configuration

struct MockConfigStringValue: ExpressibleByConfigString, Hashable, Sendable {
let stringValue: String
var description: String { stringValue }

init?(configString: String) {
self.stringValue = configString
}
}
Loading