Skip to content

Commit e4cc18f

Browse files
prachigauriarPrachi Gauriar
andauthored
Add variable registration to ConfigVariableReader (#9)
- Add RegisteredConfigVariable, a struct that captures a variable's key, default content, secrecy, and metadata for storage in homogeneous collections - Add encode closure to ConfigVariableContent for converting values back to ConfigContent, enabling default value encoding at registration time - Add encodeToContent on CodableValueRepresentation for converting encoded Data into the appropriate ConfigContent - Add ConfigVariableReader.register, which encodes and stores a variable's registration with duplicate-key detection and logging - Split ConfigVariableReaderTests into per-concern test files: scalar, array, raw representable, config expression, data representation, codable, and registration - Extract shared test mocks and metadata keys into Testing Support Co-authored-by: Prachi Gauriar <prachi@gauriar.com>
1 parent 7b4b0c4 commit e4cc18f

25 files changed

+3045
-2064
lines changed

Sources/DevConfiguration/Core/CodableValueRepresentation.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,28 @@ public struct CodableValueRepresentation: Sendable {
118118
}
119119

120120

121+
/// Converts encoded `Data` into the appropriate ``ConfigContent`` for this representation.
122+
///
123+
/// For string-backed representations, this converts the data to a string using the representation's encoding and
124+
/// returns it as ``ConfigContent/string(_:)``. For data-backed representations, this returns the data's bytes as
125+
/// ``ConfigContent/bytes(_:)``.
126+
///
127+
/// - Parameter data: The encoded data to convert.
128+
/// - Returns: The ``ConfigContent`` representing the encoded data.
129+
/// - Throws: ``StringEncodingError`` if the data cannot be converted to a string using the expected encoding.
130+
func encodeToContent(_ data: Data) throws -> ConfigContent {
131+
switch kind {
132+
case .string(let encoding):
133+
guard let string = String(data: data, encoding: encoding) else {
134+
throw StringEncodingError(encoding: encoding)
135+
}
136+
return .string(string)
137+
case .data:
138+
return .bytes(Array(data))
139+
}
140+
}
141+
142+
121143
/// Watches for raw data changes from the reader based on this representation.
122144
///
123145
/// Each time the underlying configuration value changes, `onUpdate` is called with the new raw data (or `nil` if the
@@ -154,3 +176,10 @@ public struct CodableValueRepresentation: Sendable {
154176
}
155177
}
156178
}
179+
180+
181+
/// An error thrown when encoded data cannot be converted to a string using the expected encoding.
182+
struct StringEncodingError: Error {
183+
/// The string encoding that failed.
184+
let encoding: String.Encoding
185+
}

Sources/DevConfiguration/Core/ConfigVariableContent.swift

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ public struct ConfigVariableContent<Value>: Sendable where Value: Sendable {
6565
_ line: UInt,
6666
_ continuation: AsyncStream<Value>.Continuation
6767
) async throws -> Void
68+
69+
/// Encodes a value into a ``ConfigContent`` for registration.
70+
let encode: @Sendable (_ value: Value) throws -> ConfigContent
6871
}
6972

7073

@@ -99,7 +102,8 @@ extension ConfigVariableContent where Value == Bool {
99102
continuation.yield(value)
100103
}
101104
}
102-
}
105+
},
106+
encode: { .bool($0) }
103107
)
104108
}
105109
}
@@ -134,7 +138,8 @@ extension ConfigVariableContent where Value == [Bool] {
134138
continuation.yield(value)
135139
}
136140
}
137-
}
141+
},
142+
encode: { .boolArray($0) }
138143
)
139144
}
140145
}
@@ -169,7 +174,8 @@ extension ConfigVariableContent where Value == Float64 {
169174
continuation.yield(value)
170175
}
171176
}
172-
}
177+
},
178+
encode: { .double($0) }
173179
)
174180
}
175181
}
@@ -204,7 +210,8 @@ extension ConfigVariableContent where Value == [Float64] {
204210
continuation.yield(value)
205211
}
206212
}
207-
}
213+
},
214+
encode: { .doubleArray($0) }
208215
)
209216
}
210217
}
@@ -239,7 +246,8 @@ extension ConfigVariableContent where Value == Int {
239246
continuation.yield(value)
240247
}
241248
}
242-
}
249+
},
250+
encode: { .int($0) }
243251
)
244252
}
245253
}
@@ -274,7 +282,8 @@ extension ConfigVariableContent where Value == [Int] {
274282
continuation.yield(value)
275283
}
276284
}
277-
}
285+
},
286+
encode: { .intArray($0) }
278287
)
279288
}
280289
}
@@ -309,7 +318,8 @@ extension ConfigVariableContent where Value == String {
309318
continuation.yield(value)
310319
}
311320
}
312-
}
321+
},
322+
encode: { .string($0) }
313323
)
314324
}
315325
}
@@ -344,7 +354,8 @@ extension ConfigVariableContent where Value == [String] {
344354
continuation.yield(value)
345355
}
346356
}
347-
}
357+
},
358+
encode: { .stringArray($0) }
348359
)
349360
}
350361
}
@@ -379,7 +390,8 @@ extension ConfigVariableContent where Value == [UInt8] {
379390
continuation.yield(value)
380391
}
381392
}
382-
}
393+
},
394+
encode: { .bytes($0) }
383395
)
384396
}
385397
}
@@ -420,7 +432,8 @@ extension ConfigVariableContent where Value == [[UInt8]] {
420432
continuation.yield(value)
421433
}
422434
}
423-
}
435+
},
436+
encode: { .byteChunkArray($0) }
424437
)
425438
}
426439
}
@@ -467,7 +480,8 @@ extension ConfigVariableContent {
467480
continuation.yield(value)
468481
}
469482
}
470-
}
483+
},
484+
encode: { .string($0.rawValue) }
471485
)
472486
}
473487

@@ -510,7 +524,8 @@ extension ConfigVariableContent {
510524
continuation.yield(value)
511525
}
512526
}
513-
}
527+
},
528+
encode: { .stringArray($0.map(\.rawValue)) }
514529
)
515530
}
516531

@@ -552,7 +567,8 @@ extension ConfigVariableContent {
552567
continuation.yield(value)
553568
}
554569
}
555-
}
570+
},
571+
encode: { .string($0.description) }
556572
)
557573
}
558574

@@ -595,7 +611,8 @@ extension ConfigVariableContent {
595611
continuation.yield(value)
596612
}
597613
}
598-
}
614+
},
615+
encode: { .stringArray($0.map(\.description)) }
599616
)
600617
}
601618
}
@@ -642,7 +659,8 @@ extension ConfigVariableContent {
642659
continuation.yield(value)
643660
}
644661
}
645-
}
662+
},
663+
encode: { .int($0.rawValue) }
646664
)
647665
}
648666

@@ -685,7 +703,8 @@ extension ConfigVariableContent {
685703
continuation.yield(value)
686704
}
687705
}
688-
}
706+
},
707+
encode: { .intArray($0.map(\.rawValue)) }
689708
)
690709
}
691710

@@ -727,7 +746,8 @@ extension ConfigVariableContent {
727746
continuation.yield(value)
728747
}
729748
}
730-
}
749+
},
750+
encode: { .int($0.configInt) }
731751
)
732752
}
733753

@@ -770,7 +790,8 @@ extension ConfigVariableContent {
770790
continuation.yield(value)
771791
}
772792
}
773-
}
793+
},
794+
encode: { .intArray($0.map(\.configInt)) }
774795
)
775796
}
776797
}
@@ -905,6 +926,11 @@ extension ConfigVariableContent {
905926
}
906927
continuation.yield(defaultValue)
907928
}
929+
},
930+
encode: { (value) in
931+
let resolvedEncoder = encoder ?? JSONEncoder()
932+
let data = try resolvedEncoder.encode(value)
933+
return try representation.encodeToContent(data)
908934
}
909935
)
910936
}

Sources/DevConfiguration/Core/ConfigVariableReader.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Configuration
99
import DevFoundation
10+
import OSLog
1011

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

57+
/// The variables that have been registered with this reader, keyed by their configuration key.
58+
private(set) var registeredVariables: [ConfigKey: RegisteredConfigVariable] = [:]
59+
60+
/// The logger used for registration diagnostics.
61+
private static let logger = Logger(subsystem: "DevConfiguration", category: "ConfigVariableReader")
62+
5663

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

8996

97+
// MARK: - Registration
98+
99+
extension ConfigVariableReader {
100+
/// Registers a configuration variable with this reader.
101+
///
102+
/// Registration records the variable's key, default value, secrecy, and metadata in a non-generic form so that the
103+
/// reader can provide information about all registered variables without needing their generic type parameters.
104+
///
105+
/// Registration is intended to be performed during setup, before the reader is shared with other components. If a
106+
/// variable with the same key has already been registered, the new registration overwrites the previous one, a
107+
/// warning is logged, and an assertion failure is triggered.
108+
///
109+
/// - Parameter variable: The configuration variable to register.
110+
public mutating func register<Value>(_ variable: ConfigVariable<Value>) {
111+
let defaultContent: ConfigContent
112+
do {
113+
defaultContent = try variable.content.encode(variable.defaultValue)
114+
} catch {
115+
assertionFailure("Failed to encode default value for config variable '\(variable.key)': \(error)")
116+
Self.logger.error("Failed to encode default value for config variable '\(variable.key)': \(error)")
117+
return
118+
}
119+
120+
if registeredVariables[variable.key] != nil {
121+
assertionFailure("Config variable '\(variable.key)' is already registered")
122+
Self.logger.error("Config variable '\(variable.key)' is already registered; overwriting")
123+
}
124+
125+
registeredVariables[variable.key] = RegisteredConfigVariable(
126+
key: variable.key,
127+
defaultContent: defaultContent,
128+
secrecy: variable.secrecy,
129+
metadata: variable.metadata
130+
)
131+
}
132+
}
133+
134+
90135
// MARK: - Value Access
91136

92137
extension ConfigVariableReader {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//
2+
// RegisteredConfigVariable.swift
3+
// DevConfiguration
4+
//
5+
// Created by Prachi Gauriar on 3/5/2026.
6+
//
7+
8+
import Configuration
9+
10+
/// A non-generic representation of a registered ``ConfigVariable``.
11+
///
12+
/// `RegisteredConfigVariable` stores the type-erased information from a ``ConfigVariable`` so that registered variables
13+
/// can be stored in homogeneous collections. It captures the variable's key, its default value as a ``ConfigContent``,
14+
/// its secrecy setting, and any attached metadata.
15+
@dynamicMemberLookup
16+
struct RegisteredConfigVariable: Sendable {
17+
/// The configuration key used to look up this variable's value.
18+
let key: ConfigKey
19+
20+
/// The variable's default value represented as a ``ConfigContent``.
21+
let defaultContent: ConfigContent
22+
23+
/// Whether this value should be treated as a secret.
24+
let secrecy: ConfigVariableSecrecy
25+
26+
/// The configuration variable's metadata.
27+
let metadata: ConfigVariableMetadata
28+
29+
30+
/// Provides dynamic member lookup access to metadata properties.
31+
///
32+
/// This subscript enables dot-syntax access to metadata properties, mirroring the access pattern on
33+
/// ``ConfigVariable``.
34+
///
35+
/// - Parameter keyPath: A keypath to a property on `ConfigVariableMetadata`.
36+
/// - Returns: The value of the metadata property.
37+
subscript<MetadataValue>(
38+
dynamicMember keyPath: KeyPath<ConfigVariableMetadata, MetadataValue>
39+
) -> MetadataValue {
40+
metadata[keyPath: keyPath]
41+
}
42+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//
2+
// MockCodableConfig.swift
3+
// DevConfiguration
4+
//
5+
// Created by Prachi Gauriar on 3/5/2026.
6+
//
7+
8+
struct MockCodableConfig: Codable, Hashable, Sendable {
9+
let variant: String
10+
let count: Int
11+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// MockConfigIntValue.swift
3+
// DevConfiguration
4+
//
5+
// Created by Prachi Gauriar on 3/5/2026.
6+
//
7+
8+
import Configuration
9+
10+
struct MockConfigIntValue: ExpressibleByConfigInt, Hashable, Sendable {
11+
let intValue: Int
12+
var configInt: Int { intValue }
13+
var description: String { "\(intValue)" }
14+
15+
init?(configInt: Int) {
16+
self.intValue = configInt
17+
}
18+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// MockConfigStringValue.swift
3+
// DevConfiguration
4+
//
5+
// Created by Prachi Gauriar on 3/5/2026.
6+
//
7+
8+
import Configuration
9+
10+
struct MockConfigStringValue: ExpressibleByConfigString, Hashable, Sendable {
11+
let stringValue: String
12+
var description: String { stringValue }
13+
14+
init?(configString: String) {
15+
self.stringValue = configString
16+
}
17+
}

0 commit comments

Comments
 (0)