From 1a4e1d0b91e6ec6c5b66395a7b3e026c92d0254a Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Tue, 17 Feb 2026 10:57:21 -0500 Subject: [PATCH] Add support for variable metadata - Add ConfigVariableMetadataKey, which represents type-safe metadata keys - Add ConfigVariableMetadata for storing type-safe extensible metadata - Update ConfigVariable to support dynamic member lookup of metadata values - Add table of contents to documentation --- .../Core/ConfigVariable.swift | 82 +++++-- .../Core/ConfigVariableMetadata.swift | 224 ++++++++++++++++++ .../Core/ConfigVariableReader.swift | 4 + ...recy.swift => ConfigVariableSecrecy.swift} | 0 .../Documentation.docc/Documentation.md | 33 +++ .../Core/ConfigVariableMetadataTests.swift | 154 ++++++++++++ .../Unit Tests/Core/ConfigVariableTests.swift | 77 ++++++ 7 files changed, 555 insertions(+), 19 deletions(-) create mode 100644 Sources/DevConfiguration/Core/ConfigVariableMetadata.swift rename Sources/DevConfiguration/Core/{VariableSecrecy.swift => ConfigVariableSecrecy.swift} (100%) create mode 100644 Sources/DevConfiguration/Documentation.docc/Documentation.md create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableMetadataTests.swift diff --git a/Sources/DevConfiguration/Core/ConfigVariable.swift b/Sources/DevConfiguration/Core/ConfigVariable.swift index b0d43a8..4fcdb4c 100644 --- a/Sources/DevConfiguration/Core/ConfigVariable.swift +++ b/Sources/DevConfiguration/Core/ConfigVariable.swift @@ -9,27 +9,24 @@ import Configuration /// A type-safe variable definition with a default value. /// -/// `ConfigVariable` encapsulates a configuration key and its default value, providing compile-time type safety for -/// configuration access. +/// `ConfigVariable` encapsulates a configuration key, its default value, its secrecy, and any custom metadata that +/// might be attached to it. Using configuration variables ensures that variables will be read using the correct type +/// and default value. /// -/// ## Usage +/// ``ConfigVariableReader``s are used to read the value of a config variable. While `ConfigVariable` is a generic type, +/// `ConfigVariableReader` only supports reading variables whose `Value` is one of: /// -/// Define configuration variables as static properties: -/// -/// ```swift -/// extension ConfigVariable where Value == Bool { -/// static let darkMode = ConfigVariable( -/// key: "feature.darkMode", -/// defaultValue: false -/// ) -/// } -/// ``` -/// -/// Access values through a `StructuredConfigReading` instance: -/// -/// ```swift -/// let darkMode = reader[.darkMode] -/// ``` +/// - `Bool` +/// - `Data` +/// - `Float64` or `Double` +/// - `Int` +/// - `String` +/// - `[Bool]` +/// - `[Data]` +/// - `[Float64]` or `[Double]` +/// - `[Int]` +/// - `[String]` +@dynamicMemberLookup public struct ConfigVariable: Sendable where Value: Sendable { /// The configuration key used to look up this variable's value. public let key: ConfigKey @@ -40,6 +37,9 @@ public struct ConfigVariable: Sendable where Value: Sendable { /// Whether this value should be treated as a secret. public let secrecy: ConfigVariableSecrecy + /// The configuration variable’s metadata. + private(set) var metadata = ConfigVariableMetadata() + /// Creates a configuration variable with the specified `ConfigKey`. /// @@ -54,6 +54,50 @@ public struct ConfigVariable: Sendable where Value: Sendable { self.defaultValue = defaultValue self.secrecy = secrecy } + + + /// Sets a metadata value on this configuration variable using a keypath. + /// + /// This function allows you to attach metadata to a configuration variable using a fluent builder pattern. Metadata + /// can include any custom metadata defined in ``ConfigVariableMetadata``. + /// + /// let variable = ConfigVariable(key: "feature.darkMode", defaultValue: false) + /// .metadata(\.owningTeam, .alpha) + /// .metadata(\.project, "Onboarding") + /// .metadata(\.expirationDate, DateComponents(year: 2026, month: 2, day: 16)) + /// + /// - Parameters: + /// - keyPath: A writable keypath to the metadata property on `ConfigVariableMetadata`. + /// - value: The value to set for the metadata property. + /// - Returns: A copy of the `ConfigVariable` with the metadata value applied. + public func metadata( + _ keyPath: WritableKeyPath, + _ value: MetadataValue + ) -> Self { + var copy = self + copy.metadata[keyPath: keyPath] = value + return copy + } + + + /// Provides dynamic member lookup access to metadata properties. + /// + /// This subscript enables dot-syntax access to metadata properties. It provides both read and write access to any + /// property on ``ConfigVariableMetadata``. + /// + /// var variable = ConfigVariable(key: "feature.darkMode", defaultValue: false) + /// variable.owningTeam = .alpha + /// variable.project = "Onboarding" + /// let team = variable.owningTeam + /// + /// - Parameter keyPath: A writable keypath to a property on `ConfigVariableMetadata`. + /// - Returns: The value of the metadata property. + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> MetadataValue { + get { return metadata[keyPath: keyPath] } + set { metadata[keyPath: keyPath] = newValue } + } } diff --git a/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift b/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift new file mode 100644 index 0000000..570e673 --- /dev/null +++ b/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift @@ -0,0 +1,224 @@ +// +// ConfigVariableMetadata.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/16/2026. +// + +import DevFoundation +import Foundation + +/// A type-safe, extensible container for storing arbitrary metadata associated with configuration variables. +/// +/// `ConfigVariableMetadata` provides a flexible system for attaching custom metadata to configuration variables without +/// requiring changes to the core configuration types. Metadata is accessed through type-safe keys that conform to +/// ``ConfigVariableMetadataKey``, ensuring compile-time safety while allowing unlimited extensibility. +/// +/// ## Usage +/// +/// Define custom metadata keys by creating types conforming to ``ConfigVariableMetadataKey`` and extending +/// `ConfigVariableMetadata` with convenience properties: +/// +/// private struct ProjectMetadataKey: ConfigVariableMetadataKey { +/// static let defaultValue: String? = nil +/// static let keyDisplayText = "Project" +/// } +/// +/// extension ConfigVariableMetadata { +/// var project: String? { +/// get { self[ProjectMetadataKey.self] } +/// set { self[ProjectMetadataKey.self] = newValue } +/// } +/// } +/// +/// Then use the metadata with configuration variables: +/// +/// var metadata = ConfigVariableMetadata() +/// metadata.project = "MyApp" +/// +/// ## Thread Safety +/// +/// `ConfigVariableMetadata` conforms to `Sendable`, making it safe to use across concurrency domains. All stored +/// values must also conform to `Sendable` to maintain this guarantee. +public struct ConfigVariableMetadata: Hashable, Sendable { + /// A structure containing human-readable text representations of a metadata key-value pair. + /// + /// `DisplayText` pairs a metadata key's display name with the formatted string representation of its value. These + /// representations are intended for use in user interfaces, logs, and debugging output. + struct DisplayText: Hashable, Sendable { + /// The human-readable display name for the metadata key (e.g., "Project", "Environment"). + let key: String + + /// The formatted string representation of the metadata value. + /// + /// If `nil`, the value has no canonical display representation, and a standard string should be displayed + /// instead. + let value: String? + } + + + /// Internal storage for metadata values, keyed by the unique identifier of each metadata key type. + private var metadata: [ObjectIdentifier: AnySendableHashable] = [:] + + /// Internal storage for display text representations of metadata values, keyed by the unique identifier of each + /// metadata key type. + /// + /// This dictionary maintains human-readable representations of stored metadata values for use in user interfaces, + /// logs, and debugging output. Each entry maps a metadata key's `ObjectIdentifier` to a `DisplayText` structure + /// containing both the key's display name and the formatted value. + private var displayText: [ObjectIdentifier: DisplayText] = [:] + + + /// Creates an empty metadata container with no values set. + /// + /// All metadata keys will return their default values until explicitly set. + public init() {} + + + /// Accesses the metadata value associated with the given key type. + /// + /// Returns the key's `defaultValue` if no value has been explicitly set. + /// + /// - Parameter key: The metadata key type that identifies which metadata value to access. + /// - Returns: The stored value for the given key, or the key's `defaultValue` if no value has been set. + public subscript(key: Key.Type) -> Key.Value where Key: ConfigVariableMetadataKey { + get { + let defaultValue = key.defaultValue + return metadata[ObjectIdentifier(key), default: AnySendableHashable(defaultValue)].base as! Key.Value + } + set { + let id = ObjectIdentifier(key) + metadata[id] = AnySendableHashable(newValue) + displayText[id] = .init(key: Key.keyDisplayText, value: Key.displayText(for: newValue)) + } + } + + + /// Returns an array of all display text representations for the metadata values currently stored in this container. + /// + /// This property provides access to human-readable key-value pairs representing all metadata that has been + /// explicitly set. Each `DisplayText` entry contains both the metadata key's display name and the formatted value. + /// + /// The returned array is unordered and includes only metadata that has been assigned through the subscript setter. + /// Metadata keys that still have their default values are not included in the results. + /// + /// - Returns: An array of `DisplayText` structures representing all stored metadata entries. + var displayTextEntries: [DisplayText] { + return Array(displayText.values) + } +} + + +// MARK: - ConfigVariableMetadataKey + +/// A type that defines a key for storing and retrieving metadata associated with configuration variables. +/// +/// Use this protocol to create custom metadata keys that can be used with ``ConfigVariableMetadata``. Each conforming +/// type acts as a unique key for a specific piece of metadata, providing type-safe access through the subscript on +/// `ConfigVariableMetadata`. +/// +/// ## Creating a Custom Metadata Key +/// +/// To define a new metadata key, create a private type that conforms to `ConfigVariableMetadataKey` and implement the +/// required properties: +/// +/// private struct projectMetadataKey: ConfigVariableMetadataKey { +/// static let defaultValue: String? = nil +/// static let keyDisplayText: String = "Project" +/// } +/// +/// Then extend `ConfigVariableMetadata` with a convenience property to access the value: +/// +/// extension ConfigVariableMetadata { +/// var project: String? { +/// get { self[ProjectMetadataKey.self] } +/// set { self[ProjectMetadataKey.self] = newValue } +/// } +/// } +/// +/// ## Default Implementations +/// +/// DevConfiguration provides default implementations of ``displayText(for:)`` for common value types: +/// +/// - Generic values use `String(describing:)` +/// - `RawRepresentable` values use their `rawValue` +/// - `Optional` values unwrap and describe the wrapped value +public protocol ConfigVariableMetadataKey { + /// The type of value stored for this metadata key. + /// + /// The value type must conform to `Hashable` for equality comparisons and `Sendable` for safe concurrent access. + associatedtype Value: Hashable & Sendable + + /// The default value returned when no value has been explicitly set for this metadata key. + /// + /// This value is used by ``ConfigVariableMetadata``'s subscript when retrieving a value for a key that has not + /// been assigned. For optional metadata, this is typically `nil`. For required metadata, provide a sensible + /// default that represents the absence of explicit configuration. + static var defaultValue: Value { get } + + /// A human-readable label for this metadata key, used when displaying metadata in user interfaces or logs. + /// + /// This text should be localized when appropriate and should clearly describe what the metadata represents. + /// For example, a key that stores a project name might return `"Project"`. + static var keyDisplayText: String { get } + + /// Returns a human-readable string representation of the given metadata value for display purposes. + /// + /// This function is used to convert metadata values into text suitable for display in user interfaces, logs, or + /// debugging output. The returned string should be localized when appropriate and provide a clear, concise + /// representation of the value. + /// + /// DevConfiguration provides default implementations for common types: + /// + /// - For general values, returns `String(describing:)` + /// - For `RawRepresentable` values, returns the `rawValue` + /// - For `Optional` values, returns `nil` when the value is `nil`, or a description of the unwrapped value + /// + /// ## Custom Implementations + /// + /// Provide your own implementation when you need custom formatting for your metadata values. For example, if your + /// value is a `Date`, you might return a formatted version of it: + /// + /// static func displayText(for date: Date) -> String? { + /// return date.formatted(date: .long, time: .omitted) + /// } + /// + /// - Note: `ConfigVariableMetadata` only gets display text when a value is set. As such, the display text for a + /// given value should not change over time. For example, when formatting a date, don’t use relative formatting, + /// as the time between when the display text is computed and displayed may be significant. + /// + /// - Parameter value: The metadata value to convert to a display string. + /// - Returns: A human-readable string representation of the value, or `nil` if the value should not be displayed + /// (such as when an optional value is `nil`). + static func displayText(for value: Value) -> String? +} + + +// MARK: - Default Implementations + +extension ConfigVariableMetadataKey { + public static func displayText(for value: Value) -> String? { + return String(describing: value) + } +} + + +extension ConfigVariableMetadataKey where Value: RawRepresentable { + public static func displayText(for value: Value) -> String? { + return value.rawValue + } +} + + +extension ConfigVariableMetadataKey where Value: OptionalRepresentable { + public static func displayText(for value: Value) -> String? { + return value.optionalRepresentation.map { String(describing: $0) } + } +} + + +extension ConfigVariableMetadataKey where Value: OptionalRepresentable, Value.Wrapped: RawRepresentable { + public static func displayText(for value: Value) -> String? { + return value.optionalRepresentation.map { $0.rawValue } + } +} diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index 48efa51..de95bd4 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -242,6 +242,8 @@ extension ConfigVariableReader { /// /// - Parameters: /// - variable: The variable to watch for updates. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. /// - updatesHandler: A closure that handles an async sequence of updates to the value. /// - Returns: The result produced by the handler. public func watchValue( @@ -267,6 +269,8 @@ extension ConfigVariableReader { /// /// - Parameters: /// - variable: The variable to watch for updates. + /// - fileID: The source file identifier for access reporting. + /// - line: The source line number for access reporting. /// - updatesHandler: A closure that handles an async sequence of updates to the value. /// - Returns: The result produced by the handler. public func watchValue( diff --git a/Sources/DevConfiguration/Core/VariableSecrecy.swift b/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift similarity index 100% rename from Sources/DevConfiguration/Core/VariableSecrecy.swift rename to Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift diff --git a/Sources/DevConfiguration/Documentation.docc/Documentation.md b/Sources/DevConfiguration/Documentation.docc/Documentation.md new file mode 100644 index 0000000..b06e8d8 --- /dev/null +++ b/Sources/DevConfiguration/Documentation.docc/Documentation.md @@ -0,0 +1,33 @@ +# ``DevConfiguration`` + +A type-safe wrapper around Swift Configuration with conveniences for type safety and app development. + + +## Overview + +DevConfiguration is a type-safe configuration wrapper built on Apple's Swift Configuration library. It provides +configuration management with extensible metadata, a variable management UI, and access logging via the event bus. + + +## Topics + +### Reading Variables + +- ``ConfigVariable`` +- ``ConfigVariableReader`` + +### Variable Metadata + +- ``ConfigVariableMetadata`` +- ``ConfigVariableMetadataKey`` +- ``ConfigVariableSecrecy`` + +### Access Reporting + +- ``EventBusAccessReporter`` +- ``ConfigVariableAccessSucceededEvent`` +- ``ConfigVariableAccessFailedEvent`` + +### Supporting Types + +- ``ConfigValueReadable`` diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableMetadataTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableMetadataTests.swift new file mode 100644 index 0000000..d928b17 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableMetadataTests.swift @@ -0,0 +1,154 @@ +// +// ConfigVariableMetadataTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 2/17/2026. +// + +import DevTesting +import Testing + +@testable import DevConfiguration + +struct ConfigVariableMetadataTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - subscript + + @Test + mutating func subscriptGetReturnsDefaultValueWhenNotSetAndStoresAndRetrievesSetValue() { + // set up the test by creating empty metadata + var metadata = ConfigVariableMetadata() + + // expect that unset key returns default value + #expect(metadata[IntMetadataKey.self] == 0) + #expect(metadata[StringMetadataKey.self] == nil) + + // exercise the test by setting values + let intValue = randomInt(in: .min ... .max) + let stringValue = randomAlphanumericString() + metadata[IntMetadataKey.self] = intValue + metadata[StringMetadataKey.self] = stringValue + + // expect that values are stored and retrieved correctly + #expect(metadata[IntMetadataKey.self] == intValue) + #expect(metadata[StringMetadataKey.self] == stringValue) + } + + + // MARK: - displayTextEntries + + @Test + mutating func subscriptSetterUpdatesDisplayTextEntries() { + // set up the test by creating metadata and setting values + var metadata = ConfigVariableMetadata() + let intValue = randomInt(in: .min ... .max) + let stringValue = randomAlphanumericString() + + // expect that initially displayTextEntries is empty + #expect(metadata.displayTextEntries.isEmpty) + + // exercise the test by setting metadata values + metadata[IntMetadataKey.self] = intValue + metadata[StringMetadataKey.self] = stringValue + + // expect that displayTextEntries contains both entries + let entries = metadata.displayTextEntries + #expect(entries.count == 2) + #expect(entries.contains(.init(key: "IntKey", value: String(intValue)))) + #expect(entries.contains(.init(key: "StringKey", value: stringValue))) + } + + + @Test + mutating func multipleMetadataKeysWorkIndependently() { + // set up the test by creating metadata with initial values + var metadata = ConfigVariableMetadata() + let intValue1 = randomInt(in: .min ... .max) + let stringValue1 = randomAlphanumericString() + metadata[IntMetadataKey.self] = intValue1 + metadata[StringMetadataKey.self] = stringValue1 + + // exercise the test by updating one key + let intValue2 = randomInt(in: .min ... .max) + metadata[IntMetadataKey.self] = intValue2 + + // expect that the updated key has the new value and the other key is unchanged + #expect(metadata[IntMetadataKey.self] == intValue2) + #expect(metadata[StringMetadataKey.self] == stringValue1) + } + + + // MARK: - displayText(for:) + + @Test + mutating func displayTextForRawRepresentableReturnsRawValue() { + let value = randomCase(of: MetadataEnum.self)! + #expect(EnumMetadataKey.displayText(for: value) == value.rawValue) + } + + + @Test + func displayTextForOptionalReturnsNilWhenValueIsNil() { + #expect(OptionalIntMetadataKey.displayText(for: nil) == nil) + } + + + @Test + mutating func displayTextForOptionalReturnsDescriptionWhenValueIsNotNil() { + let int = randomInt(in: .min ... .max) + #expect(OptionalIntMetadataKey.displayText(for: int) == String(int)) + } + + + @Test + mutating func displayTextForOptionalRawRepresentableReturnsRawValueWhenNotNil() { + let value = randomCase(of: MetadataEnum.self)! + #expect(OptionalEnumMetadataKey.displayText(for: .valueB) == value.rawValue) + } + + + @Test + func displayTextForOptionalRawRepresentableReturnsNilWhenValueIsNil() { + #expect(OptionalEnumMetadataKey.displayText(for: nil) == nil) + } +} + + +// MARK: - Test Metadata Keys + +private enum MetadataEnum: String, CaseIterable, Sendable { + case valueA + case valueB +} + + +private struct EnumMetadataKey: ConfigVariableMetadataKey { + static let defaultValue = MetadataEnum.valueA + static let keyDisplayText = "EnumKey" +} + + +private struct IntMetadataKey: ConfigVariableMetadataKey { + static let defaultValue = 0 + static let keyDisplayText = "IntKey" +} + + +private struct OptionalEnumMetadataKey: ConfigVariableMetadataKey { + static let defaultValue: MetadataEnum? = nil + static let keyDisplayText = "OptionalEnumKey" +} + + +private struct OptionalIntMetadataKey: ConfigVariableMetadataKey { + static let defaultValue: Int? = nil + static let keyDisplayText = "OptionalIntKey" +} + + +private struct StringMetadataKey: ConfigVariableMetadataKey { + static let defaultValue: String? = nil + static let keyDisplayText = "StringKey" +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift index a437071..f9be2dd 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift @@ -51,4 +51,81 @@ struct ConfigVariableTests: RandomValueGenerating { #expect(variable.defaultValue == defaultValue) #expect(variable.secrecy == secrecy) } + + + // MARK: - metadata(_:_:) + + @Test + mutating func metadataMethodSetsMetadataAndReturnsNewInstance() { + // set up the test by creating a config variable + let original = ConfigVariable(key: randomConfigKey(), defaultValue: randomInt(in: .min ... .max)) + let metadataValue = randomAlphanumericString() + + // exercise the test by setting metadata using the fluent method + let updated = original.metadata(\.testProject, metadataValue) + + // expect that the updated variable has the metadata and original is unchanged + #expect(updated.testProject == metadataValue) + #expect(original.testProject == nil) + } + + + @Test + mutating func metadataMethodChainingWorks() { + // set up the test by creating a config variable and metadata values + let variable = ConfigVariable(key: randomConfigKey(), defaultValue: randomInt(in: .min ... .max)) + let project = randomAlphanumericString() + let team = randomAlphanumericString() + + // exercise the test by chaining multiple metadata calls + let updated = variable.metadata(\.testProject, project) + .metadata(\.testTeam, team) + + // expect that both metadata values are set + #expect(updated.testProject == project) + #expect(updated.testTeam == team) + } + + + // MARK: - Dynamic Member Subscript + + @Test + mutating func dynamicMemberSubscriptGetAndSet() { + // set up the test by creating a config variable + var variable = ConfigVariable(key: randomConfigKey(), defaultValue: randomInt(in: .min ... .max)) + let project = randomAlphanumericString() + + // exercise the test by setting metadata via dynamic member subscript + variable.testProject = project + + // expect that the metadata value is set and can be retrieved + #expect(variable.testProject == project) + } +} + + +// MARK: - Test Metadata Keys + +private struct TestProjectMetadataKey: ConfigVariableMetadataKey { + static let defaultValue: String? = nil + static let keyDisplayText = "TestProject" +} + + +private struct TestTeamMetadataKey: ConfigVariableMetadataKey { + static let defaultValue: String? = nil + static let keyDisplayText = "TestTeam" +} + + +extension ConfigVariableMetadata { + fileprivate var testProject: String? { + get { self[TestProjectMetadataKey.self] } + set { self[TestProjectMetadataKey.self] = newValue } + } + + fileprivate var testTeam: String? { + get { self[TestTeamMetadataKey.self] } + set { self[TestTeamMetadataKey.self] = newValue } + } }