From 531f02b3cff7882d320a53735fbd6b3acf1c96fc Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Sat, 7 Mar 2026 21:24:08 -0500 Subject: [PATCH 1/9] Add editor UI infrastructure: metadata keys, EditorControl, and content editing support - Add displayName and requiresRelaunch metadata keys with localized display text via a new string catalog resource bundle - Add EditorControl, a struct describing which UI control to use when editing a variable (toggle, text field, number field, decimal field, or none) - Add editorControl and parse properties to ConfigVariableContent, set automatically by each content factory based on value type - Capture editorControl and parse on RegisteredConfigVariable during registration - Move ConfigVariableMetadata and metadata keys into a Metadata directory; move metadata tests accordingly - Add architecture plan and implementation plan documents for the editor UI feature --- CLAUDE.md | 30 +- Documentation/EditorUI/ArchitecturePlan.md | 300 ++++++++++++++++++ Documentation/EditorUI/ImplementationPlan.md | 292 +++++++++++++++++ Package.swift | 3 + .../Core/ConfigVariableContent.swift | 85 +++-- .../Core/ConfigVariableReader.swift | 4 +- .../DevConfiguration/Core/EditorControl.swift | 60 ++++ .../Core/RegisteredConfigVariable.swift | 9 + .../ConfigVariableMetadata.swift | 0 .../Metadata/DisplayNameMetadataKey.swift | 26 ++ .../RequiresRelaunchMetadataKey.swift | 26 ++ .../Resources/Localizable.xcstrings | 26 ++ .../ConfigVariableContentEditorTests.swift | 277 ++++++++++++++++ ...onfigVariableReaderRegistrationTests.swift | 7 +- .../Core/RegisteredConfigVariableTests.swift | 8 +- .../ConfigVariableMetadataTests.swift | 0 .../DisplayNameMetadataKeyTests.swift | 48 +++ .../RequiresRelaunchMetadataKeyTests.swift | 42 +++ 18 files changed, 1209 insertions(+), 34 deletions(-) create mode 100644 Documentation/EditorUI/ArchitecturePlan.md create mode 100644 Documentation/EditorUI/ImplementationPlan.md create mode 100644 Sources/DevConfiguration/Core/EditorControl.swift rename Sources/DevConfiguration/{Core => Metadata}/ConfigVariableMetadata.swift (100%) create mode 100644 Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift create mode 100644 Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift create mode 100644 Sources/DevConfiguration/Resources/Localizable.xcstrings create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift rename Tests/DevConfigurationTests/Unit Tests/{Core => Metadata}/ConfigVariableMetadataTests.swift (100%) create mode 100644 Tests/DevConfigurationTests/Unit Tests/Metadata/DisplayNameMetadataKeyTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 135162f..70fa475 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,10 @@ repository. ### Building and Testing - - **Build**: `swift build` - - **Test all**: `swift test` - - **Test specific target**: `swift test --filter DevConfigurationTests` - - **Test with coverage**: Use Xcode test plans in `Build Support/Test Plans/` (AllTests.xctestplan - for all tests) + - **Build**: `xcodebuild build -scheme DevConfiguration -destination 'generic/platform=macOS'` + - **Test all**: `xcodebuild test -scheme DevConfiguration -destination 'platform=macOS'` + - **Test with coverage**: Use Xcode test plans in `Build Support/Test Plans/` + (DevConfiguration.xctestplan) ### Code Quality @@ -28,22 +27,32 @@ The repository uses GitHub Actions for CI/CD with the workflow in - Lints code on PRs using `swift format` - Builds and tests on macOS only (other platforms disabled due to GitHub Actions stability) - Generates code coverage reports using xccovPretty - - Requires Xcode 16.0.1 and macOS 16 runners + - Requires Xcode 26.0.1 and macOS 26 runners ## Architecture Overview DevConfiguration is a type-safe configuration wrapper built on Apple's swift-configuration library. -It provides structured configuration management with telemetry, caching, and extensible metadata. +It provides structured configuration management with access reporting via EventBus and extensible +metadata. + +### Source Structure + + - **Sources/DevConfiguration/Core/**: `ConfigVariable`, `ConfigVariableReader`, + `ConfigVariableContent`, `CodableValueRepresentation`, `RegisteredConfigVariable`, + and `ConfigVariableSecrecy` + - **Sources/DevConfiguration/Metadata/**: `ConfigVariableMetadata` and metadata key types + (`DisplayNameMetadataKey`, `RequiresRelaunchMetadataKey`) + - **Sources/DevConfiguration/Access Reporting/**: EventBus-based access and decoding events ### Key Documents - - **Architecture Plan.md**: Complete architectural design and technical decisions - - **Implementation Plan.md**: Phased implementation roadmap broken into 6 slices - **Documentation/TestingGuidelines.md**: Testing standards and patterns - **Documentation/TestMocks.md**: Mock creation and usage guidelines - **Documentation/DependencyInjection.md**: Dependency injection patterns - **Documentation/MarkdownStyleGuide.md**: Documentation formatting standards + - **Documentation/MVVMForSwiftUI.md**: MVVM architecture for SwiftUI + - **Documentation/MVVMForSwiftUIBackground.md**: Background on MVVM design decisions ## Dependencies @@ -61,5 +70,4 @@ External dependencies managed via Swift Package Manager: - Uses Swift 6.2 with `ExistentialAny` and `MemberImportVisibility` features enabled - Minimum deployment targets: iOS, macOS, tvOS, visionOS, and watchOS 26 - All public APIs must be documented and tested - - Test coverage target: >99% - - Implementation follows phased approach in Implementation Plan.md \ No newline at end of file + - Test coverage target: >99% \ No newline at end of file diff --git a/Documentation/EditorUI/ArchitecturePlan.md b/Documentation/EditorUI/ArchitecturePlan.md new file mode 100644 index 0000000..a062500 --- /dev/null +++ b/Documentation/EditorUI/ArchitecturePlan.md @@ -0,0 +1,300 @@ +# Editor UI Architecture Plan + + +## Overview + +The Editor UI is a SwiftUI-based interface that allows users to inspect and override the values +of registered configuration variables in a `ConfigVariableReader`. It operates as a "document" +— changes are staged in a working copy, committed on save, and persisted across app launches +via a `ConfigProvider` backed by UserDefaults. + +The editor is opt-in: `ConfigVariableReader` accepts an `isEditorEnabled` flag at init. When +enabled, an internal override provider is prepended to the reader's provider list, taking +precedence over all other providers. + + +## Module Structure + +All editor code lives in the `DevConfiguration` target, guarded by `#if canImport(SwiftUI)`. + + - **View model protocols** (`*ViewModeling`) live **outside** the `#if` block so they can be + tested without SwiftUI. + - **Views** (`*View`) and **concrete view models** (`*ViewModel`) live inside the `#if` block. + - The full MVVM pattern is used: `*ViewModeling` protocol, generic `*View`, and `@Observable` + `*ViewModel`. + + +## Key Types + + +### EditorOverrideProvider + +A `ConfigProvider` implementation that stores override values in memory and persists them to +UserDefaults. + + - **Suite**: `devkit.DevConfiguration` + - **Provider name**: `"Editor"` + - Conforms to `ConfigProvider` (sync `value(forKey:type:)`, async `fetchValue`, `watchValue`) + - On init, loads any previously persisted overrides from UserDefaults + - On save, writes current overrides to UserDefaults + - On clear, removes all overrides from both memory and UserDefaults (after save) + - Prepended to the reader's `providers` array when `isEditorEnabled` is true + - Always assigned a distinctive color (e.g., `.orange`) for the provider capsule + + +### EditorDocument + +The working copy model that tracks staged overrides, enabling save, cancel, undo, and redo. + + - Initialized with the current committed overrides from `EditorOverrideProvider` + - Tracks a dictionary of `[ConfigKey: ConfigContent?]` where: + - A key with a `ConfigContent` value means "override this variable with this value" + - A key with `nil` means "remove the override for this variable" + - Absence of a key means "no change from committed state" + - **Dirty tracking**: compares working copy against committed state to determine if there + are unsaved changes + - **Undo/redo**: integrates with `UndoManager`; each override change (set, remove, clear + all) registers an undo action + - **Save**: computes the delta of changed keys, commits to `EditorOverrideProvider`, calls + the `onSave` closure with a collection of `SavedChange` values (each containing the key + and the variable's full `RegisteredConfigVariable`, giving consumers access to all + metadata including `requiresRelaunch`) + - **Clear all overrides**: removes all overrides in the working copy (undoable, requires + save to take effect) + + +### ConfigVariableReader Changes + + - New `isEditorEnabled: Bool` parameter on init (immutable, defaults to `false`) + - When enabled, creates an `EditorOverrideProvider` and prepends it to the providers list + - Stores a reference to the `EditorOverrideProvider` for use by the editor UI + - Exposes a method or property to get the editor view (exact API TBD — see + [Public API Surface](#public-api-surface)) + + +### ConfigVariableContent Additions + +New properties to support editing: + + - **`editorControl: EditorControl`** — describes which UI control to show: + - `.toggle` — for `Bool` + - `.textField` — for `String` + - `.numberField` — for `Int` + - `.decimalField` — for `Float64` + - `.none` — for types that don't support editing (bytes, arrays, codable) + - **`parse: @Sendable (String) -> ConfigContent?`** — for text-based editors, parses raw user + input into a `ConfigContent` value; `nil` if input is invalid + +Content factories set these automatically: + + - `.bool` → `.toggle`, parse: `Bool("true"/"false")` → `.bool(_)` + - `.string` → `.textField`, parse: identity → `.string(_)` + - `.int` → `.numberField`, parse: `Int(_)` → `.int(_)` + - `.float64` → `.decimalField`, parse: `Double(_)` → `.double(_)` + - `.rawRepresentableString()` → `.textField`, parse: identity → `.string(_)` + - `.rawRepresentableInt()` → `.numberField`, parse: `Int(_)` → `.int(_)` + - All others → `.none`, parse: `nil` + +These fields are stored on `RegisteredConfigVariable` at registration time. + + +### EditorControl + +A struct with a private backing enum, allowing new control types to be added in the future +without breaking existing consumers. + + public struct EditorControl: Hashable, Sendable { + private enum Kind: Hashable, Sendable { + case toggle + case textField + case numberField + case decimalField + case none + } + + private let kind: Kind + + public static var toggle: EditorControl { .init(kind: .toggle) } + public static var textField: EditorControl { .init(kind: .textField) } + public static var numberField: EditorControl { .init(kind: .numberField) } + public static var decimalField: EditorControl { .init(kind: .decimalField) } + public static var none: EditorControl { .init(kind: .none) } + } + + +### ConfigVariableMetadata Additions + +Two new metadata keys: + + - **`displayName: String?`** — a human-readable label for the variable, shown in the list and + detail views. When `nil`, the variable's key is used as the display text. + - **`requiresRelaunch: Bool`** — indicates that changes to this variable don't take effect + until the app is relaunched. The editor does not act on this directly; it's provided to the + `onSave` closure's changed keys so the consumer can prompt as appropriate. + + +## Views + + +### ConfigVariableEditorView (List View) + +The top-level editor view showing all registered variables. + +**Layout:** + + - **Toolbar**: Cancel button (leading), title ("Configuration Editor"), Save button + (trailing), overflow menu (`...`) containing Undo, Redo, and Clear Editor Overrides + - **Search bar**: filters variables by display name, key, current value, and metadata + - **List**: one row per registered variable, sorted by display name (falling back to key) + +**List row contents:** + + - Display name and key (both always shown; if no display name is set, the key is used as + the display name, so it appears twice) + - Current value (from working copy state — override if set, otherwise resolved value) + - Provider capsule — colored rounded rect with the provider name; color is deterministic + based on provider index (editor override provider always gets its own color) + +**Actions:** + + - Tap row → navigates to detail view + - Cancel → if dirty, shows "Discard Changes?" alert; otherwise dismisses + - Save → commits working copy, calls `onSave` with changed variables, dismisses + - Toolbar overflow menu (`...`): Undo, Redo, and Clear Editor Overrides + - Clear Editor Overrides → shows confirmation alert, then clears all overrides in working + copy (undoable, still requires save) + + +### ConfigVariableDetailView + +The detail view for a single variable. + +**Layout (sections):** + + - **Header**: display name, key + - **Current Value**: the resolved value with its provider capsule + - **Override section**: + - "Enable Override" toggle + - When enabled, shows the appropriate editor control based on `EditorControl` + - Changes register with `UndoManager` + - **Provider Values**: value from each provider, each with its provider capsule + - Incompatible values (wrong `ConfigContent` case for the variable's type) shown with + strikethrough + - Secret values redacted by default with tap-to-reveal (detail view only) + - **Metadata**: all metadata entries from `displayTextEntries` + +**Editor controls by type:** + + - `.toggle` — `Toggle` bound to the override value + - `.textField` — `TextField` (strings are treated as single-line; multiline support can be + added later if a use case arises or a new `EditorControl` type is introduced) + - `.numberField` — `TextField` with `.numberPad` keyboard, rejects fractional input + - `.decimalField` — `TextField` with `.decimalPad` keyboard + - `.none` — no override section (read-only) + + +## Provider Colors + +Each provider is assigned a deterministic color from a fixed palette of SwiftUI system colors. +The assignment is based on the provider's index in the reader's `providers` array: + + private static let providerColors: [Color] = [ + .blue, .green, .purple, .pink, .teal, .indigo, .mint, .cyan, .brown, .gray + ] + +The editor override provider always uses `.orange`, regardless of index. If there are more +providers than colors, colors wrap around. + + +## Provider Value Display + +To show the value from each provider in the detail view, the editor queries each provider +individually using `value(forKey:type:)`. The result is displayed as: + + - The raw `ConfigContent` value formatted as a string + - A colored provider capsule with the provider's name + - If the `ConfigContent` case doesn't match the variable's expected type (e.g., + `.string("hello")` for a `Bool` variable), the value text is shown with strikethrough + + +## Undo/Redo + +The editor uses SwiftUI's `UndoManager`, scoped to the editor session. + + - Each override change (enable, modify value, disable, clear all) registers an undo action + - Undo/redo actions are available in the toolbar overflow menu (`...`) + - The undo stack is discarded when the editor is dismissed + + +## Persistence + +The `EditorOverrideProvider` persists its committed overrides to UserDefaults: + + - **Suite name**: `devkit.DevConfiguration` + - **Storage format**: `ConfigContent` is `Codable` (it's an enum with associated values that + are all codable), so overrides are stored as a `[String: Data]` dictionary where keys are + config key strings and values are JSON-encoded `ConfigContent` + - **Load**: on init, reads from UserDefaults and populates in-memory storage + - **Save**: on commit, writes the full override dictionary to UserDefaults + - **Clear**: on clear + save, removes the key from UserDefaults + + +## Public API Surface + +The minimal public API for consumers: + + // On ConfigVariableReader + public init( + providers: [any ConfigProvider], + eventBus: EventBus, + isEditorEnabled: Bool = false + ) + + // Public view (inside #if canImport(SwiftUI)) + public struct ConfigVariableEditor: View { + public init( + reader: ConfigVariableReader, + onSave: @escaping ([RegisteredConfigVariable]) -> Void + ) + } + +`ConfigVariableEditor` is a public SwiftUI view that consumers initialize directly with a +`ConfigVariableReader` and an `onSave` closure. The consumer is responsible for presentation +(sheet, full-screen cover, navigation push, etc.). The `onSave` closure receives an array of +`RegisteredConfigVariable` values for variables whose overrides changed, giving the consumer +access to all metadata (including `requiresRelaunch`) to decide on post-save behavior. + + +## Config Variable Issues (Future Integration) + +The editor is designed to accommodate a future `ConfigVariableIssueEvaluator` system: + + - **`ConfigVariableIssueEvaluator` protocol**: given a snapshot of providers, their values, + and registered variables, returns an array of issues + - **`ConfigVariableIssue`**: has a kind (identifying string), affected variable (key), + severity (warning/error), and human-readable description + - **Evaluators** are passed to `ConfigVariableReader` at init + - **Editor integration**: issues would appear as warning/error indicators in the list view + rows and detail view, with a filter for "Variables with Issues" + - **Non-editor usage**: a public function on the reader evaluates all issues on demand, which + can be used for config hygiene checks in code + +To prepare for this, the editor's list and detail views should be designed with space for +status indicators, and the filtering system should be extensible to support issue-based +filters. + + +## Design Decisions + + - **`#if canImport(SwiftUI)`** keeps everything in one target, avoiding a separate module + and the public API surface it would require. View model protocols live outside the guard + for testability. + - **Working copy model** ensures the editor behaves like a document — changes are staged, + can be undone, and only take effect on explicit save. + - **`EditorControl` on `ConfigVariableContent`** lets each content type declare its editing + capabilities at the type level, keeping the view layer free of type-switching logic. + - **Deterministic provider colors** ensure a consistent visual identity across editor + sessions without requiring providers to declare their own colors. + - **`onSave` closure with changed `RegisteredConfigVariable` values** gives consumers full + control over post-save behavior (relaunch prompts, analytics, etc.) without the editor + needing to know about those concerns. diff --git a/Documentation/EditorUI/ImplementationPlan.md b/Documentation/EditorUI/ImplementationPlan.md new file mode 100644 index 0000000..3c2e1df --- /dev/null +++ b/Documentation/EditorUI/ImplementationPlan.md @@ -0,0 +1,292 @@ +# Editor UI Implementation Plan + +This document breaks the Editor UI feature into incremental implementation slices. Each slice +is a self-contained unit of work that builds on the previous ones, is independently testable, +and results in a working (if incomplete) system. + + +## Slice 1: Metadata & Content Additions + +Add the new metadata keys and editor control infrastructure to the existing types. No UI code. + +### 1a: `displayName` Metadata Key + + - Define `DisplayNameMetadataKey` (private struct conforming to `ConfigVariableMetadataKey`) + - Add `displayName: String?` computed property on `ConfigVariableMetadata` + - Tests: set/get display name, verify display text, verify default is `nil` + +### 1b: `requiresRelaunch` Metadata Key + + - Define `RequiresRelaunchMetadataKey` (private struct conforming to + `ConfigVariableMetadataKey`) + - Add `requiresRelaunch: Bool` computed property on `ConfigVariableMetadata` + - Tests: set/get, verify display text, verify default is `false` + +### 1c: `EditorControl` Enum + + - Define `EditorControl` enum with cases: `.toggle`, `.textField`, `.numberField`, + `.decimalField`, `.none` + - No conformances needed beyond `Sendable` (and `Hashable` for testing convenience) + +### 1d: Editor Support on `ConfigVariableContent` + + - Add `editorControl: EditorControl` property to `ConfigVariableContent` + - Add `parse: (@Sendable (String) -> ConfigContent?)?` property to `ConfigVariableContent` + - Update all content factories to set these: + - `.bool` → `.toggle`, parse: `{ Bool($0).map { .bool($0) } }` + - `.string` → `.textField`, parse: `{ .string($0) }` + - `.int` → `.numberField`, parse: `{ Int($0).map { .int($0) } }` + - `.float64` → `.decimalField`, parse: `{ Double($0).map { .double($0) } }` + - `.rawRepresentableString()` → `.textField`, parse: `{ .string($0) }` + - `.rawRepresentableInt()` → `.numberField`, parse: `{ Int($0).map { .int($0) } }` + - `.expressibleByConfigString()` → `.textField`, parse: `{ .string($0) }` + - `.expressibleByConfigInt()` → `.numberField`, parse: `{ Int($0).map { .int($0) } }` + - All array and codable variants → `.none`, parse: `nil` + - Tests: verify each factory sets the correct editor control and parse behavior + +### 1e: Editor Support on `RegisteredConfigVariable` + + - Add `editorControl: EditorControl` and `parse` closure to `RegisteredConfigVariable` + - Update `ConfigVariableReader.register(_:)` to capture these from the content + - Tests: verify registration captures editor control and parse + + +## Slice 2: EditorOverrideProvider + +Build the `ConfigProvider` that stores and persists editor overrides. + +### 2a: In-Memory Storage + + - Create `EditorOverrideProvider` conforming to `ConfigProvider` + - `providerName` returns `"Editor"` + - Internal storage: `[ConfigKey: ConfigContent]` + - Implement `value(forKey:type:)` — returns the stored content if present and type-compatible + - Implement `fetchValue(forKey:type:)` — same logic, async + - Implement `watchValue(forKey:type:updatesHandler:)` — yields values when overrides change + - Implement `snapshot()` — returns current state + - Public methods: `setOverride(_:forKey:)`, `removeOverride(forKey:)`, `removeAllOverrides()`, + `overrides` (current dictionary), `hasOverride(forKey:)` + - Tests: full coverage of storage, retrieval, removal, type compatibility + +### 2b: UserDefaults Persistence + + - Add `load()` method that reads overrides from `UserDefaults(suiteName:)` + - Add `persist()` method that writes overrides to UserDefaults + - Add `clearPersistence()` method that removes the key from UserDefaults + - Storage format: `[String: Data]` where values are JSON-encoded `ConfigContent` + - `load()` is called on init; `persist()` is called externally after save + - Tests: verify round-trip persistence, verify load on init, verify clear + +### 2c: Integration with ConfigVariableReader + + - Add `isEditorEnabled: Bool` parameter to both `ConfigVariableReader` inits (default + `false`) + - When enabled, create `EditorOverrideProvider`, call `load()`, prepend to providers + - Store reference to the provider as an optional internal property + - Tests: verify provider is prepended when enabled, absent when disabled, overrides take + precedence + + +## Slice 3: EditorDocument + +Build the working copy model with undo/redo support. + +### 3a: Core Working Copy + + - Create `EditorDocument` (or `ConfigEditorDocument`) + - Initialized with `EditorOverrideProvider`'s current committed overrides + - Tracks working copy as `[ConfigKey: ConfigContent]` (the full desired override state) + - Methods: + - `setOverride(_:forKey:)` — sets an override in the working copy + - `removeOverride(forKey:)` — removes an override from the working copy + - `removeAllOverrides()` — clears all overrides in the working copy + - `override(forKey:) -> ConfigContent?` — returns the working copy's override + - `hasOverride(forKey:) -> Bool` + - `isDirty: Bool` — whether working copy differs from committed state + - `changedKeys: Set` — keys that differ from committed state + - Tests: full coverage of working copy operations and dirty tracking + +### 3b: Save & Commit + + - `save()` method: + - Computes delta (changed keys only) + - Updates `EditorOverrideProvider` with the working copy state + - Calls `persist()` on the provider + - Updates the committed baseline to match the working copy + - Returns the changed keys as a `Set` + - The view model layer maps these keys to `RegisteredConfigVariable` values for the + `onSave` closure + - Tests: verify delta computation, provider update, persistence, baseline reset + +### 3c: Undo/Redo Integration + + - `EditorDocument` accepts an `UndoManager?` + - Each mutation method registers an undo action before applying the change + - `removeAllOverrides()` registers a single undo action that restores the full prior state + - Tests: verify undo/redo for set, remove, and clear-all operations + + +## Slice 4: View Model Layer + +Build the view model protocols and concrete implementations. All testable without SwiftUI. + +### 4a: Variable List View Model + + - **Protocol** `ConfigVariableListViewModeling` (outside `#if canImport(SwiftUI)`): + - `var variables: [VariableListItem] { get }` — filtered/sorted list + - `var searchText: String { get set }` + - `func save() -> [RegisteredConfigVariable]` + - `func cancel()` + - `var isDirty: Bool { get }` + - `func clearAllOverrides()` + - `func undo()` / `func redo()` + - `var canUndo: Bool { get }` / `var canRedo: Bool { get }` + - Associated type for detail view model + - **`VariableListItem`**: key, display name (defaults to key if not set), current value + (as string), provider name, provider color index, has override (bool), editor control + - **Concrete `ConfigVariableListViewModel`** (inside `#if canImport(SwiftUI)`): + - `@Observable`, owns the `EditorDocument` + - Queries each provider for current values to determine which provider is responsible + - Sorts by display name (falling back to key) + - Filters by search text across name, key, value, metadata + - Tests: sorting, filtering, save/cancel, dirty tracking, undo/redo delegation + +### 4b: Variable Detail View Model + + - **Protocol** `ConfigVariableDetailViewModeling` (outside `#if canImport(SwiftUI)`): + - `var key: ConfigKey { get }` + - `var displayName: String { get }` + - `var metadata: [ConfigVariableMetadata.DisplayText] { get }` + - `var providerValues: [ProviderValue] { get }` — value from each provider + - `var isOverrideEnabled: Bool { get set }` + - `var overrideText: String { get set }` — for text-based editors + - `var overrideBool: Bool { get set }` — for toggle + - `var editorControl: EditorControl { get }` + - `var isSecretRevealed: Bool { get set }` — tap-to-reveal state + - **`ProviderValue`**: provider name, color index, raw value string, is compatible (bool) + - **Concrete `ConfigVariableDetailViewModel`**: + - Reads from providers via `value(forKey:type:)` on each + - Determines compatibility by checking if the `ConfigContent` case matches expected type + - Override toggle delegates to `EditorDocument.setOverride` / `removeOverride` + - Text/number changes parse via the stored `parse` closure and update the document + - Tests: provider value display, compatibility detection, override enable/disable, parse + validation, secret reveal toggle + + +## Slice 5: SwiftUI Views + +Build the views. All inside `#if canImport(SwiftUI)`. + +### 5a: Supporting Views + + - **`ProviderCapsuleView`** — colored rounded rect with provider name text + - **Provider color assignment** — static function mapping provider index to system color; + editor override provider always returns `.orange` + +### 5b: ConfigVariableEditorView (List) + + - Generic on `ViewModel: ConfigVariableListViewModeling` + - `NavigationStack` with `List` + - Search bar via `.searchable` modifier + - Each row: display name, key, value, provider capsule + - Tap row → push `ConfigVariableDetailView` + - Toolbar: Cancel (leading), Save (trailing), overflow menu with Undo, Redo, and Clear + Editor Overrides + - Cancel shows alert if dirty + - Clear Editor Overrides shows confirmation alert + +### 5c: ConfigVariableDetailView + + - Generic on `ViewModel: ConfigVariableDetailViewModeling` + - Sections: Header, Override, Provider Values, Metadata + - Override section: + - "Enable Override" toggle + - When enabled, shows editor control based on `editorControl` + - Toggle for `.toggle` + - `TextField` for `.textField` / `.numberField` / `.decimalField` with appropriate + keyboard types + - Provider values section: + - Each provider's value with capsule + - Strikethrough for incompatible values + - Tap-to-reveal for secret values + - Metadata section: list of key-value pairs from `displayTextEntries` + +### 5d: Public Entry Point + + - `ConfigVariableEditor` — a public `View` struct (inside `#if canImport(SwiftUI)`) + - Initialized with a `ConfigVariableReader` and an + `onSave: ([RegisteredConfigVariable]) -> Void` closure + - Creates the list view model internally and wraps the list view + - Asserts that `isEditorEnabled` is true on the reader + +### 5e: View Tests + +Tests use **Swift Snapshot Testing** for visual regression and **ViewInspector** for +structural and behavioral verification. Views are generic on their view model protocols, so +tests inject mock view models. + + - **Snapshot tests** (visual regression): + - List view: empty state, populated list, list with overrides, list with search active + - Detail view: read-only variable, variable with override enabled (each editor control + type), secret value redacted vs revealed, incompatible provider values with + strikethrough + - Provider capsule: each provider color, editor override provider color + - Snapshots captured for both iOS and Mac to verify cross-platform layout + - **ViewInspector tests** (structural/behavioral): + - List view: verify rows render correct display name, key, value, and provider capsule; + verify search filters rows; verify cancel alert appears when dirty; verify save calls + view model; verify overflow menu contains undo, redo, and clear actions + - Detail view: verify sections are present; verify "Enable Override" toggle shows/hides + editor control; verify toggle control binds to `overrideBool`; verify text field + controls bind to `overrideText`; verify tap-to-reveal toggles `isSecretRevealed`; + verify incompatible values have strikethrough + + +## Slice 6: Polish & Integration + +### 6a: Accessibility + + - Ensure all interactive elements have accessibility labels + - Provider capsules should be distinguishable without color (include provider name text) + - Override controls should announce state changes + +### 6b: Mac Compatibility + + - Verify layout works on macOS (wider layout, no `.numberPad` keyboard) + - Adjust text fields to use appropriate styling per platform + +### 6c: Documentation + + - DocC documentation for all public types and methods + - Usage guide with code examples + - Add to existing architecture documentation + + +## Dependencies Between Slices + + Slice 1 (Metadata & Content) + │ + ├──▶ Slice 2 (EditorOverrideProvider) + │ │ + │ └──▶ Slice 3 (EditorDocument) + │ │ + │ └──▶ Slice 4 (View Models) + │ │ + │ └──▶ Slice 5 (Views) + │ │ + │ └──▶ Slice 6 (Polish) + │ + └──▶ Slice 4 can begin protocol design in parallel with Slices 2–3 + +Slices 1 and 2a–2b can proceed in parallel. Slice 4's protocol definitions can be drafted +alongside Slices 2–3, with concrete implementations depending on those slices. + + +## New Package Dependencies + +Slice 5e requires two new test dependencies in `Package.swift`: + + - **swift-snapshot-testing** (Point-Free): visual regression tests for views + - **ViewInspector**: structural and behavioral verification of SwiftUI view hierarchies + +These are added only to the test target. diff --git a/Package.swift b/Package.swift index 8fb4d4c..fcfc037 100644 --- a/Package.swift +++ b/Package.swift @@ -34,6 +34,9 @@ let package = Package( .product(name: "Configuration", package: "swift-configuration"), .product(name: "DevFoundation", package: "DevFoundation"), ], + resources: [ + .process("Resources"), + ], swiftSettings: swiftSettings ), .testTarget( diff --git a/Sources/DevConfiguration/Core/ConfigVariableContent.swift b/Sources/DevConfiguration/Core/ConfigVariableContent.swift index 5dffea7..44469e6 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableContent.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableContent.swift @@ -68,6 +68,15 @@ public struct ConfigVariableContent: Sendable where Value: Sendable { /// Encodes a value into a ``ConfigContent`` for registration. let encode: @Sendable (_ value: Value) throws -> ConfigContent + + /// The editor control to use when editing this variable's value in the editor UI. + public let editorControl: EditorControl + + /// Parses a raw string from the editor UI into a ``ConfigContent`` value. + /// + /// Returns `nil` if the string cannot be parsed into a valid value for this content type. When `nil` itself, the + /// content type does not support editing. + let parse: (@Sendable (_ input: String) -> ConfigContent?)? } @@ -103,7 +112,9 @@ extension ConfigVariableContent where Value == Bool { } } }, - encode: { .bool($0) } + encode: { .bool($0) }, + editorControl: .toggle, + parse: { Bool($0).map { .bool($0) } } ) } } @@ -139,7 +150,9 @@ extension ConfigVariableContent where Value == [Bool] { } } }, - encode: { .boolArray($0) } + encode: { .boolArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -175,7 +188,9 @@ extension ConfigVariableContent where Value == Float64 { } } }, - encode: { .double($0) } + encode: { .double($0) }, + editorControl: .decimalField, + parse: { Double($0).map { .double($0) } } ) } } @@ -211,7 +226,9 @@ extension ConfigVariableContent where Value == [Float64] { } } }, - encode: { .doubleArray($0) } + encode: { .doubleArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -247,7 +264,9 @@ extension ConfigVariableContent where Value == Int { } } }, - encode: { .int($0) } + encode: { .int($0) }, + editorControl: .numberField, + parse: { Int($0).map { .int($0) } } ) } } @@ -283,7 +302,9 @@ extension ConfigVariableContent where Value == [Int] { } } }, - encode: { .intArray($0) } + encode: { .intArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -319,7 +340,9 @@ extension ConfigVariableContent where Value == String { } } }, - encode: { .string($0) } + encode: { .string($0) }, + editorControl: .textField, + parse: { .string($0) } ) } } @@ -355,7 +378,9 @@ extension ConfigVariableContent where Value == [String] { } } }, - encode: { .stringArray($0) } + encode: { .stringArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -391,7 +416,9 @@ extension ConfigVariableContent where Value == [UInt8] { } } }, - encode: { .bytes($0) } + encode: { .bytes($0) }, + editorControl: .none, + parse: nil ) } } @@ -433,7 +460,9 @@ extension ConfigVariableContent where Value == [[UInt8]] { } } }, - encode: { .byteChunkArray($0) } + encode: { .byteChunkArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -481,7 +510,9 @@ extension ConfigVariableContent { } } }, - encode: { .string($0.rawValue) } + encode: { .string($0.rawValue) }, + editorControl: .textField, + parse: { .string($0) } ) } @@ -525,7 +556,9 @@ extension ConfigVariableContent { } } }, - encode: { .stringArray($0.map(\.rawValue)) } + encode: { .stringArray($0.map(\.rawValue)) }, + editorControl: .none, + parse: nil ) } @@ -568,7 +601,9 @@ extension ConfigVariableContent { } } }, - encode: { .string($0.description) } + encode: { .string($0.description) }, + editorControl: .textField, + parse: { .string($0) } ) } @@ -612,7 +647,9 @@ extension ConfigVariableContent { } } }, - encode: { .stringArray($0.map(\.description)) } + encode: { .stringArray($0.map(\.description)) }, + editorControl: .none, + parse: nil ) } } @@ -660,7 +697,9 @@ extension ConfigVariableContent { } } }, - encode: { .int($0.rawValue) } + encode: { .int($0.rawValue) }, + editorControl: .numberField, + parse: { Int($0).map { .int($0) } } ) } @@ -704,7 +743,9 @@ extension ConfigVariableContent { } } }, - encode: { .intArray($0.map(\.rawValue)) } + encode: { .intArray($0.map(\.rawValue)) }, + editorControl: .none, + parse: nil ) } @@ -747,7 +788,9 @@ extension ConfigVariableContent { } } }, - encode: { .int($0.configInt) } + encode: { .int($0.configInt) }, + editorControl: .numberField, + parse: { Int($0).map { .int($0) } } ) } @@ -791,7 +834,9 @@ extension ConfigVariableContent { } } }, - encode: { .intArray($0.map(\.configInt)) } + encode: { .intArray($0.map(\.configInt)) }, + editorControl: .none, + parse: nil ) } } @@ -931,7 +976,9 @@ extension ConfigVariableContent { let resolvedEncoder = encoder ?? JSONEncoder() let data = try resolvedEncoder.encode(value) return try representation.encodeToContent(data) - } + }, + editorControl: .none, + parse: nil ) } } diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index b168a62..a561e37 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -141,7 +141,9 @@ extension ConfigVariableReader { key: variable.key, defaultContent: defaultContent, secrecy: variable.secrecy, - metadata: variable.metadata + metadata: variable.metadata, + editorControl: variable.content.editorControl, + parse: variable.content.parse ) } } diff --git a/Sources/DevConfiguration/Core/EditorControl.swift b/Sources/DevConfiguration/Core/EditorControl.swift new file mode 100644 index 0000000..f85d1b1 --- /dev/null +++ b/Sources/DevConfiguration/Core/EditorControl.swift @@ -0,0 +1,60 @@ +// +// EditorControl.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +/// Describes which UI control the editor should use to edit a configuration variable's value. +/// +/// Each ``ConfigVariableContent`` instance has an associated `EditorControl` that tells the editor UI which input +/// control to present when the user enables an override. Content factories set this automatically based on the +/// variable's value type. +public struct EditorControl: Hashable, Sendable { + /// The underlying kinds of editor controls. + private enum Kind: Hashable, Sendable { + case toggle + case textField + case numberField + case decimalField + case none + } + + + /// The underlying kind of this editor control. + private let kind: Kind +} + + +extension EditorControl { + /// A toggle control, used for `Bool` values. + public static var toggle: EditorControl { + EditorControl(kind: .toggle) + } + + /// A text field control, used for `String` and string-backed values. + public static var textField: EditorControl { + EditorControl(kind: .textField) + } + + /// A number field control, used for `Int` and integer-backed values. + /// + /// Rejects fractional input. + public static var numberField: EditorControl { + EditorControl(kind: .numberField) + } + + /// A decimal field control, used for `Float64` values. + /// + /// Allows fractional input. + public static var decimalField: EditorControl { + EditorControl(kind: .decimalField) + } + + /// No editor control. + /// + /// The variable is read-only in the editor. + public static var none: EditorControl { + EditorControl(kind: .none) + } +} diff --git a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift index 0f16524..b904292 100644 --- a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift +++ b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift @@ -26,6 +26,15 @@ struct RegisteredConfigVariable: Sendable { /// The configuration variable's metadata. let metadata: ConfigVariableMetadata + /// The editor control to use when editing this variable's value in the editor UI. + let editorControl: EditorControl + + /// Parses a raw string from the editor UI into a ``ConfigContent`` value. + /// + /// Returns `nil` if the string cannot be parsed. When this property itself is `nil`, the variable does not support + /// editing. + let parse: (@Sendable (_ input: String) -> ConfigContent?)? + /// Provides dynamic member lookup access to metadata properties. /// diff --git a/Sources/DevConfiguration/Core/ConfigVariableMetadata.swift b/Sources/DevConfiguration/Metadata/ConfigVariableMetadata.swift similarity index 100% rename from Sources/DevConfiguration/Core/ConfigVariableMetadata.swift rename to Sources/DevConfiguration/Metadata/ConfigVariableMetadata.swift diff --git a/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift b/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift new file mode 100644 index 0000000..4f333e0 --- /dev/null +++ b/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift @@ -0,0 +1,26 @@ +// +// DisplayNameMetadataKey.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Foundation + +/// The metadata key for a human-readable display name. +private struct DisplayNameMetadataKey: ConfigVariableMetadataKey { + static let defaultValue: String? = nil + static let keyDisplayText = String(localized: "displayNameMetadata.keyDisplayText", bundle: #bundle) +} + + +extension ConfigVariableMetadata { + /// A human-readable display name for the configuration variable. + /// + /// When set, this name is used in the editor UI and other display contexts instead of the raw configuration key. + /// When `nil`, the variable's key is used as the display text. + public var displayName: String? { + get { self[DisplayNameMetadataKey.self] } + set { self[DisplayNameMetadataKey.self] = newValue } + } +} diff --git a/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift b/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift new file mode 100644 index 0000000..c5bb380 --- /dev/null +++ b/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift @@ -0,0 +1,26 @@ +// +// RequiresRelaunchMetadataKey.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Foundation + +/// The metadata key indicating that changes to a variable require an app relaunch to take effect. +private struct RequiresRelaunchMetadataKey: ConfigVariableMetadataKey { + static let defaultValue = false + static let keyDisplayText = String(localized: "requiresRelaunchMetadata.keyDisplayText", bundle: #bundle) +} + + +extension ConfigVariableMetadata { + /// Whether changes to the configuration variable require an app relaunch to take effect. + /// + /// When `true`, the editor UI communicates this to consumers via the `onSave` closure so they can prompt the user + /// to relaunch the app. Defaults to `false`. + public var requiresRelaunch: Bool { + get { self[RequiresRelaunchMetadataKey.self] } + set { self[RequiresRelaunchMetadataKey.self] = newValue } + } +} diff --git a/Sources/DevConfiguration/Resources/Localizable.xcstrings b/Sources/DevConfiguration/Resources/Localizable.xcstrings new file mode 100644 index 0000000..f3371f2 --- /dev/null +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -0,0 +1,26 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "displayNameMetadata.keyDisplayText" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display Name" + } + } + } + }, + "requiresRelaunchMetadata.keyDisplayText" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires Relaunch" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift new file mode 100644 index 0000000..1ad5df5 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift @@ -0,0 +1,277 @@ +// +// ConfigVariableContentEditorTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import DevTesting +import Testing + +@testable import DevConfiguration + +struct ConfigVariableContentEditorTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - Editor Control + + @Test + func boolEditorControlIsToggle() { + #expect(ConfigVariableContent.bool.editorControl == .toggle) + } + + + @Test + func boolArrayEditorControlIsNone() { + #expect(ConfigVariableContent<[Bool]>.boolArray.editorControl == .none) + } + + + @Test + func float64EditorControlIsDecimalField() { + #expect(ConfigVariableContent.float64.editorControl == .decimalField) + } + + + @Test + func float64ArrayEditorControlIsNone() { + #expect(ConfigVariableContent<[Float64]>.float64Array.editorControl == .none) + } + + + @Test + func intEditorControlIsNumberField() { + #expect(ConfigVariableContent.int.editorControl == .numberField) + } + + + @Test + func intArrayEditorControlIsNone() { + #expect(ConfigVariableContent<[Int]>.intArray.editorControl == .none) + } + + + @Test + func stringEditorControlIsTextField() { + #expect(ConfigVariableContent.string.editorControl == .textField) + } + + + @Test + func stringArrayEditorControlIsNone() { + #expect(ConfigVariableContent<[String]>.stringArray.editorControl == .none) + } + + + @Test + func bytesEditorControlIsNone() { + #expect(ConfigVariableContent<[UInt8]>.bytes.editorControl == .none) + } + + + @Test + func byteChunkArrayEditorControlIsNone() { + #expect(ConfigVariableContent<[[UInt8]]>.byteChunkArray.editorControl == .none) + } + + + @Test + func rawRepresentableStringEditorControlIsTextField() { + let content = ConfigVariableContent.rawRepresentableString() + #expect(content.editorControl == .textField) + } + + + @Test + func rawRepresentableStringArrayEditorControlIsNone() { + let content = ConfigVariableContent<[TestStringEnum]>.rawRepresentableStringArray() + #expect(content.editorControl == .none) + } + + + @Test + func rawRepresentableIntEditorControlIsNumberField() { + let content = ConfigVariableContent.rawRepresentableInt() + #expect(content.editorControl == .numberField) + } + + + @Test + func rawRepresentableIntArrayEditorControlIsNone() { + let content = ConfigVariableContent<[TestIntEnum]>.rawRepresentableIntArray() + #expect(content.editorControl == .none) + } + + + @Test + func expressibleByConfigStringEditorControlIsTextField() { + let content = ConfigVariableContent.expressibleByConfigString() + #expect(content.editorControl == .textField) + } + + + @Test + func expressibleByConfigStringArrayEditorControlIsNone() { + let content = ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray() + #expect(content.editorControl == .none) + } + + + @Test + func expressibleByConfigIntEditorControlIsNumberField() { + let content = ConfigVariableContent.expressibleByConfigInt() + #expect(content.editorControl == .numberField) + } + + + @Test + func expressibleByConfigIntArrayEditorControlIsNone() { + let content = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray() + #expect(content.editorControl == .none) + } + + + @Test + func jsonEditorControlIsNone() { + let content = ConfigVariableContent.json() + #expect(content.editorControl == .none) + } + + + @Test + func propertyListEditorControlIsNone() { + let content = ConfigVariableContent.propertyList() + #expect(content.editorControl == .none) + } + + + // MARK: - Parse + + @Test + func boolParseReturnsBoolContentForValidInput() { + let parse = ConfigVariableContent.bool.parse + #expect(parse?("true") == .bool(true)) + #expect(parse?("false") == .bool(false)) + } + + + @Test + func boolParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent.bool.parse + #expect(parse?("notABool") == nil) + } + + + @Test + func float64ParseReturnsDoubleContentForValidInput() { + let parse = ConfigVariableContent.float64.parse + #expect(parse?("3.14") == .double(3.14)) + #expect(parse?("42") == .double(42.0)) + } + + + @Test + func float64ParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent.float64.parse + #expect(parse?("notANumber") == nil) + } + + + @Test + mutating func intParseReturnsIntContentForValidInput() { + let parse = ConfigVariableContent.int.parse + let value = randomInt(in: -1000 ... 1000) + #expect(parse?(String(value)) == .int(value)) + } + + + @Test + func intParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent.int.parse + #expect(parse?("3.14") == nil) + #expect(parse?("notANumber") == nil) + } + + + @Test + mutating func stringParseReturnsStringContent() { + let parse = ConfigVariableContent.string.parse + let value = randomAlphanumericString() + #expect(parse?(value) == .string(value)) + } + + + @Test + mutating func rawRepresentableStringParseReturnsStringContent() { + let parse = ConfigVariableContent.rawRepresentableString().parse + let value = randomAlphanumericString() + #expect(parse?(value) == .string(value)) + } + + + @Test + mutating func rawRepresentableIntParseReturnsIntContentForValidInput() { + let parse = ConfigVariableContent.rawRepresentableInt().parse + let value = randomInt(in: -1000 ... 1000) + #expect(parse?(String(value)) == .int(value)) + } + + + @Test + mutating func expressibleByConfigStringParseReturnsStringContent() { + let parse = ConfigVariableContent.expressibleByConfigString().parse + let value = randomAlphanumericString() + #expect(parse?(value) == .string(value)) + } + + + @Test + mutating func expressibleByConfigIntParseReturnsIntContentForValidInput() { + let parse = ConfigVariableContent.expressibleByConfigInt().parse + let value = randomInt(in: -1000 ... 1000) + #expect(parse?(String(value)) == .int(value)) + } + + + @Test + func arrayAndByteContentParseIsNil() { + #expect(ConfigVariableContent<[Bool]>.boolArray.parse == nil) + #expect(ConfigVariableContent<[Float64]>.float64Array.parse == nil) + #expect(ConfigVariableContent<[Int]>.intArray.parse == nil) + #expect(ConfigVariableContent<[String]>.stringArray.parse == nil) + #expect(ConfigVariableContent<[UInt8]>.bytes.parse == nil) + #expect(ConfigVariableContent<[[UInt8]]>.byteChunkArray.parse == nil) + #expect(ConfigVariableContent<[TestStringEnum]>.rawRepresentableStringArray().parse == nil) + #expect(ConfigVariableContent<[TestIntEnum]>.rawRepresentableIntArray().parse == nil) + #expect(ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray().parse == nil) + #expect(ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().parse == nil) + } + + + @Test + func codableContentParseIsNil() { + #expect(ConfigVariableContent.json().parse == nil) + #expect(ConfigVariableContent.propertyList().parse == nil) + } +} + + +// MARK: - Test Types + +private enum TestStringEnum: String, Sendable { + case a + case b +} + + +private enum TestIntEnum: Int, Sendable { + case a = 0 + case b = 1 +} + + +private struct TestCodable: Codable, Sendable { + let value: String +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift index 8f55bd1..61883b9 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift @@ -41,6 +41,9 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { #expect(registered?.defaultContent == .int(defaultValue)) #expect(registered?.secrecy == secrecy) #expect(registered?.testTeam == metadata[TestTeamMetadataKey.self]) + #expect(registered?.editorControl == .numberField) + #expect(registered?.parse?("42") == .int(42)) + #expect(registered?.parse?("notAnInt") == nil) } @@ -101,7 +104,9 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { "", .init(codingPath: [], debugDescription: "") ) - } + }, + editorControl: .none, + parse: nil ) ) diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift index 16118ad..681f6bc 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift @@ -26,7 +26,9 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { key: randomConfigKey(), defaultContent: randomConfigContent(), secrecy: randomConfigVariableSecrecy(), - metadata: metadata + metadata: metadata, + editorControl: .none, + parse: nil ) // expect @@ -41,7 +43,9 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { key: randomConfigKey(), defaultContent: randomConfigContent(), secrecy: randomConfigVariableSecrecy(), - metadata: ConfigVariableMetadata() + metadata: ConfigVariableMetadata(), + editorControl: .none, + parse: nil ) // expect diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableMetadataTests.swift b/Tests/DevConfigurationTests/Unit Tests/Metadata/ConfigVariableMetadataTests.swift similarity index 100% rename from Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableMetadataTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Metadata/ConfigVariableMetadataTests.swift diff --git a/Tests/DevConfigurationTests/Unit Tests/Metadata/DisplayNameMetadataKeyTests.swift b/Tests/DevConfigurationTests/Unit Tests/Metadata/DisplayNameMetadataKeyTests.swift new file mode 100644 index 0000000..7bc92c9 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Metadata/DisplayNameMetadataKeyTests.swift @@ -0,0 +1,48 @@ +// +// DisplayNameMetadataKeyTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import DevTesting +import Testing + +@testable import DevConfiguration + +struct DisplayNameMetadataKeyTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + mutating func displayNameDefaultsToNilAndStoresAndRetrievesValue() { + // set up + var metadata = ConfigVariableMetadata() + + // expect that unset display name returns nil + #expect(metadata.displayName == nil) + + // exercise + let name = randomAlphanumericString() + metadata.displayName = name + + // expect that the value is stored and retrieved correctly + #expect(metadata.displayName == name) + } + + + @Test + mutating func displayNameDisplayTextShowsValue() throws { + // set up + var metadata = ConfigVariableMetadata() + let name = randomAlphanumericString() + + // exercise + metadata.displayName = name + + // expect that displayTextEntries contains the display name entry with a localized key + let entries = metadata.displayTextEntries + let entry = try #require(entries.first { $0.value == name }) + #expect(entry.key != "displayNameMetadata.keyDisplayText") + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift b/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift new file mode 100644 index 0000000..5031d89 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift @@ -0,0 +1,42 @@ +// +// RequiresRelaunchMetadataKeyTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Testing + +@testable import DevConfiguration + +struct RequiresRelaunchMetadataKeyTests { + @Test + func requiresRelaunchDefaultsToFalseAndStoresAndRetrievesValue() { + // set up + var metadata = ConfigVariableMetadata() + + // expect that unset requiresRelaunch returns false + #expect(metadata.requiresRelaunch == false) + + // exercise + metadata.requiresRelaunch = true + + // expect that the value is stored and retrieved correctly + #expect(metadata.requiresRelaunch == true) + } + + + @Test + func requiresRelaunchDisplayTextShowsValue() throws { + // set up + var metadata = ConfigVariableMetadata() + + // exercise + metadata.requiresRelaunch = true + + // expect that displayTextEntries contains the requires relaunch entry with a localized key + let entries = metadata.displayTextEntries + let entry = try #require(entries.first { $0.value == "true" }) + #expect(entry.key != "requiresRelaunchMetadata.keyDisplayText") + } +} From 14b0271b799501fae550b4649dfb9dc49060f21f Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Sat, 7 Mar 2026 23:10:03 -0500 Subject: [PATCH 2/9] Add EditorOverrideProvider with persistence and reader integration - Add configType computed property and Codable conformance to ConfigContent for type checking and JSON serialization - Add EditorOverrideProvider, a ConfigProvider that stores editor overrides in memory with real-time value and snapshot watching, following the MutableInMemoryProvider pattern - Add UserDefaults persistence to EditorOverrideProvider via load, persist, and clearPersistence methods with injectable UserDefaults for testability - Add isEditorEnabled parameter to ConfigVariableReader inits; when enabled, creates an EditorOverrideProvider, loads persisted overrides, and prepends it to the provider list - Add Editor source and test directories for editor-specific code --- .../Core/ConfigVariableReader.swift | 41 +- .../Editor/EditorOverrideProvider.swift | 407 +++++++++++++ .../Extensions/ConfigContent+Additions.swift | 105 ++++ .../ConfigVariableReaderEditorTests.swift | 112 ++++ .../Editor/EditorOverrideProviderTests.swift | 539 ++++++++++++++++++ .../ConfigContent+AdditionsTests.swift | 73 +++ 6 files changed, 1272 insertions(+), 5 deletions(-) create mode 100644 Sources/DevConfiguration/Editor/EditorOverrideProvider.swift create mode 100644 Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Editor/ConfigVariableReaderEditorTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Editor/EditorOverrideProviderTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index a561e37..3c2c07a 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -62,6 +62,11 @@ public final class ConfigVariableReader: Sendable { /// The event bus used to post diagnostic events like ``ConfigVariableDecodingFailedEvent``. public let eventBus: EventBus + /// The editor override provider, if editor support is enabled. + /// + /// When non-nil, this provider is the first entry in ``providers`` and takes precedence over all other providers. + let editorOverrideProvider: EditorOverrideProvider? + /// The mutable state protected by a mutex. private let mutableState = Mutex(MutableState()) @@ -76,11 +81,17 @@ public final class ConfigVariableReader: Sendable { /// - Parameters: /// - providers: The configuration providers, queried in order until a value is found. /// - eventBus: The event bus that telemetry events are posted on. - public convenience init(providers: [any ConfigProvider], eventBus: EventBus) { + /// - isEditorEnabled: Whether editor override support is enabled. Defaults to `false`. + public convenience init( + providers: [any ConfigProvider], + eventBus: EventBus, + isEditorEnabled: Bool = false + ) { self.init( providers: providers, accessReporter: EventBusAccessReporter(eventBus: eventBus), - eventBus: eventBus + eventBus: eventBus, + isEditorEnabled: isEditorEnabled ) } @@ -93,10 +104,30 @@ public final class ConfigVariableReader: Sendable { /// - providers: The configuration providers, queried in order until a value is found. /// - accessReporter: The access reporter that is used to report configuration access events. /// - eventBus: The event bus used to post diagnostic events. - public init(providers: [any ConfigProvider], accessReporter: any AccessReporter, eventBus: EventBus) { + /// - isEditorEnabled: Whether editor override support is enabled. Defaults to `false`. + public init( + providers: [any ConfigProvider], + accessReporter: any AccessReporter, + eventBus: EventBus, + isEditorEnabled: Bool = false + ) { + let editorOverrideProvider: EditorOverrideProvider? + let effectiveProviders: [any ConfigProvider] + + if isEditorEnabled { + let provider = EditorOverrideProvider() + provider.load(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) + editorOverrideProvider = provider + effectiveProviders = [provider] + providers + } else { + editorOverrideProvider = nil + effectiveProviders = providers + } + + self.editorOverrideProvider = editorOverrideProvider self.accessReporter = accessReporter - self.reader = ConfigReader(providers: providers, accessReporter: accessReporter) - self.providers = providers + self.reader = ConfigReader(providers: effectiveProviders, accessReporter: accessReporter) + self.providers = effectiveProviders self.eventBus = eventBus } diff --git a/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift b/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift new file mode 100644 index 0000000..082aed7 --- /dev/null +++ b/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift @@ -0,0 +1,407 @@ +// +// EditorOverrideProvider.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import Foundation +import OSLog +import Synchronization + +/// A configuration provider that stores editor overrides in memory and persists them to UserDefaults. +/// +/// `EditorOverrideProvider` is prepended to the reader's provider list when `isEditorEnabled` is true, giving +/// overrides the highest priority. Values are stored in memory for fast access and can be persisted to UserDefaults +/// for durability across app launches. +final class EditorOverrideProvider: Sendable { + /// The UserDefaults suite name used for persistence. + static let suiteName = "devkit.DevConfiguration" + + /// The UserDefaults key under which overrides are stored. + private static let persistenceKey = "editorOverrides" + + /// The logger used for persistence diagnostics. + private static let logger = Logger(subsystem: "DevConfiguration", category: "EditorOverrideProvider") + + /// The mutable state of the provider, protected by a `Mutex`. + private struct MutableState: Sendable { + /// The current overrides keyed by their configuration key. + var overrides: [ConfigKey: ConfigContent] = [:] + + /// Active watchers for individual configuration keys. + var valueWatchers: [ConfigKey: [UUID: AsyncStream.Continuation]] = [:] + + /// Active watchers for provider state snapshots. + var snapshotWatchers: [UUID: AsyncStream.Continuation] = [:] + } + + + /// An immutable snapshot of the provider's current overrides. + struct Snapshot: ConfigSnapshot, Sendable { + let providerName: String + let overrides: [ConfigKey: ConfigContent] + + func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + let configKey = ConfigKey(key.components, context: key.context) + let encodedKey = key.description + guard let content = overrides[configKey], content.configType == type else { + return LookupResult(encodedKey: encodedKey, value: nil) + } + + return LookupResult(encodedKey: encodedKey, value: ConfigValue(content, isSecret: false)) + } + } + + + /// The mutable state protected by a mutex. + private let mutableState: Mutex = .init(MutableState()) +} + + +// MARK: - Override Management + +extension EditorOverrideProvider { + /// The current overrides. + var overrides: [ConfigKey: ConfigContent] { + mutableState.withLock { $0.overrides } + } + + + /// Whether an override exists for the given key. + /// + /// - Parameter key: The configuration key to check. + /// - Returns: `true` if an override is stored for the key. + func hasOverride(forKey key: ConfigKey) -> Bool { + mutableState.withLock { $0.overrides[key] != nil } + } + + + /// Sets an override value for the given key. + /// + /// If the new content is the same as the existing override, no change is made and watchers are not notified. + /// + /// - Parameters: + /// - content: The override content value. + /// - key: The configuration key to override. + func setOverride(_ content: ConfigContent, forKey key: ConfigKey) { + var valueContinuations: [UUID: AsyncStream.Continuation]? + var snapshotUpdate: ([UUID: AsyncStream.Continuation], Snapshot)? + + mutableState.withLock { state in + guard state.overrides[key] != content else { + return + } + + state.overrides[key] = content + valueContinuations = state.valueWatchers[key] + + if !state.snapshotWatchers.isEmpty { + snapshotUpdate = (state.snapshotWatchers, makeSnapshot(from: state)) + } + } + + let configValue = ConfigValue(content, isSecret: false) + if let valueContinuations { + for (_, continuation) in valueContinuations { + continuation.yield(configValue) + } + } + + if let (continuations, snapshot) = snapshotUpdate { + for (_, continuation) in continuations { + continuation.yield(snapshot) + } + } + } + + + /// Removes the override for the given key. + /// + /// If no override exists for the key, no change is made and watchers are not notified. + /// + /// - Parameter key: The configuration key whose override should be removed. + func removeOverride(forKey key: ConfigKey) { + var valueContinuations: [UUID: AsyncStream.Continuation]? + var snapshotUpdate: ([UUID: AsyncStream.Continuation], Snapshot)? + + mutableState.withLock { state in + guard state.overrides.removeValue(forKey: key) != nil else { + return + } + + valueContinuations = state.valueWatchers[key] + + if !state.snapshotWatchers.isEmpty { + snapshotUpdate = (state.snapshotWatchers, makeSnapshot(from: state)) + } + } + + if let valueContinuations { + for (_, continuation) in valueContinuations { + continuation.yield(nil) + } + } + + if let (continuations, snapshot) = snapshotUpdate { + for (_, continuation) in continuations { + continuation.yield(snapshot) + } + } + } + + + /// Removes all overrides. + /// + /// Notifies all active value watchers with `nil` and all snapshot watchers with an empty snapshot. + func removeAllOverrides() { + var allValueContinuations: [[UUID: AsyncStream.Continuation]] = [] + var snapshotUpdate: ([UUID: AsyncStream.Continuation], Snapshot)? + + mutableState.withLock { state in + guard !state.overrides.isEmpty else { + return + } + + for key in state.overrides.keys { + if let watchers = state.valueWatchers[key] { + allValueContinuations.append(watchers) + } + } + + state.overrides.removeAll() + + if !state.snapshotWatchers.isEmpty { + snapshotUpdate = (state.snapshotWatchers, makeSnapshot(from: state)) + } + } + + for watchers in allValueContinuations { + for (_, continuation) in watchers { + continuation.yield(nil) + } + } + + if let (continuations, snapshot) = snapshotUpdate { + for (_, continuation) in continuations { + continuation.yield(snapshot) + } + } + } + + + /// Creates a snapshot from the current mutable state. + /// + /// Must be called while the mutex is locked. + private func makeSnapshot(from state: MutableState) -> Snapshot { + Snapshot(providerName: providerName, overrides: state.overrides) + } +} + + +// MARK: - Persistence + +extension EditorOverrideProvider { + /// Loads persisted overrides from the given UserDefaults into memory. + /// + /// Any entries that fail to decode are silently skipped. This method is intended to be called once during setup, + /// before the provider is shared with other components. + /// + /// - Parameter userDefaults: The UserDefaults instance to load from. + func load(from userDefaults: UserDefaults) { + guard let stored = userDefaults.dictionary(forKey: Self.persistenceKey) as? [String: Data] else { + return + } + + let decoder = JSONDecoder() + var loadedOverrides: [ConfigKey: ConfigContent] = [:] + for (keyString, data) in stored { + do { + let content = try decoder.decode(ConfigContent.self, from: data) + loadedOverrides[ConfigKey(keyString)] = content + } catch { + Self.logger.error("Failed to decode persisted override for key '\(keyString)': \(error)") + } + } + + mutableState.withLock { state in + state.overrides = loadedOverrides + } + } + + + /// Persists the current overrides to the given UserDefaults. + /// + /// Each override is JSON-encoded individually. The resulting dictionary is stored under the persistence key. + /// + /// - Parameter userDefaults: The UserDefaults instance to persist to. + func persist(to userDefaults: UserDefaults) { + let currentOverrides = overrides + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + + var stored: [String: Data] = [:] + for (key, content) in currentOverrides { + do { + stored[key.description] = try encoder.encode(content) + } catch { + // This should never happen + Self.logger.error("Failed to encode override for key '\(key)': \(error)") + } + } + + userDefaults.set(stored, forKey: Self.persistenceKey) + } + + + /// Removes all persisted overrides from the given UserDefaults. + /// + /// This does not affect the in-memory overrides. + /// + /// - Parameter userDefaults: The UserDefaults instance to clear. + func clearPersistence(from userDefaults: UserDefaults) { + userDefaults.removeObject(forKey: Self.persistenceKey) + } +} + + +// MARK: - Value Watching + +extension EditorOverrideProvider { + /// Adds a value watcher continuation for the given key. + /// + /// The continuation is immediately yielded the current value for the key. + private func addValueContinuation( + _ continuation: AsyncStream.Continuation, + id: UUID, + forKey key: ConfigKey + ) { + mutableState.withLock { state in + state.valueWatchers[key, default: [:]][id] = continuation + let value = state.overrides[key].map { ConfigValue($0, isSecret: false) } + continuation.yield(value) + } + } + + + /// Removes the value watcher continuation for the given identifier and key. + private func removeValueContinuation(id: UUID, forKey key: ConfigKey) { + mutableState.withLock { state in + state.valueWatchers[key]?[id] = nil + } + } + + + /// Adds a snapshot watcher continuation. + /// + /// The continuation is immediately yielded the current snapshot. + private func addSnapshotContinuation( + _ continuation: AsyncStream.Continuation, + id: UUID + ) { + mutableState.withLock { state in + state.snapshotWatchers[id] = continuation + continuation.yield(makeSnapshot(from: state)) + } + } + + + /// Removes the snapshot watcher continuation for the given identifier. + private func removeSnapshotContinuation(id: UUID) { + mutableState.withLock { state in + state.snapshotWatchers[id] = nil + } + } +} + + +// MARK: - ConfigProvider + +extension EditorOverrideProvider: ConfigProvider { + var providerName: String { + "Editor" + } + + + func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + mutableState.withLock { state in + let configKey = ConfigKey(key.components, context: key.context) + let encodedKey = key.description + + guard let content = state.overrides[configKey], content.configType == type else { + return LookupResult(encodedKey: encodedKey, value: nil) + } + + return LookupResult(encodedKey: encodedKey, value: ConfigValue(content, isSecret: false)) + } + } + + + func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { + try value(forKey: key, type: type) + } + + + // swift-format-ignore + // + // Note: + // The swift-format-ignore rule here is due to a bug in swift-format where it is putting a space between + // nonisolated and (nonsending). This causes a compilation error. We cannot disable formatting for just a + // parameter, so we have to disable it for the entire function. + func watchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType, + updatesHandler: nonisolated(nonsending)( + _ updates: ConfigUpdatesAsyncSequence, Never> + ) async throws -> Return + ) async throws -> Return { + let configKey = ConfigKey(key.components, context: key.context) + let encodedKey = key.description + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let id = UUID() + addValueContinuation(continuation, id: id, forKey: configKey) + defer { + removeValueContinuation(id: id, forKey: configKey) + } + + return try await updatesHandler( + ConfigUpdatesAsyncSequence( + stream.map { (value: ConfigValue?) -> Result in + guard let value, value.content.configType == type else { + return .success(LookupResult(encodedKey: encodedKey, value: nil)) + } + + return .success(LookupResult(encodedKey: encodedKey, value: value)) + } + ) + ) + } + + + func snapshot() -> any ConfigSnapshot { + mutableState.withLock { makeSnapshot(from: $0) } + } + + + // swift-format-ignore + // + // Note: + // The swift-format-ignore rule here is due to a bug in swift-format where it is putting a space between + // nonisolated and (nonsending). This causes a compilation error. We cannot disable formatting for just a + // parameter, so we have to disable it for the entire function. + func watchSnapshot( + updatesHandler: nonisolated(nonsending)( + _ updates: ConfigUpdatesAsyncSequence + ) async throws -> Return + ) async throws -> Return { + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let id = UUID() + addSnapshotContinuation(continuation, id: id) + defer { + removeSnapshotContinuation(id: id) + } + + return try await updatesHandler(ConfigUpdatesAsyncSequence(stream.map { $0 })) + } +} diff --git a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift new file mode 100644 index 0000000..91b1d68 --- /dev/null +++ b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift @@ -0,0 +1,105 @@ +// +// ConfigContent+Additions.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import Foundation + +extension ConfigContent { + /// The configuration type of this content. + /// + /// This mirrors the `package`-scoped `type` property on `ConfigContent` in swift-configuration, which is not + /// accessible from this module. + var configType: ConfigType { + switch self { + case .string: .string + case .int: .int + case .double: .double + case .bool: .bool + case .bytes: .bytes + case .stringArray: .stringArray + case .intArray: .intArray + case .doubleArray: .doubleArray + case .boolArray: .boolArray + case .byteChunkArray: .byteChunkArray + } + } +} + + +// MARK: - Codable + +extension ConfigContent: @retroactive Codable { + private enum CodingKeys: String, CodingKey { + case type + case value + } + + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(configType.rawValue, forKey: .type) + + switch self { + case .string(let value): + try container.encode(value, forKey: .value) + case .int(let value): + try container.encode(value, forKey: .value) + case .double(let value): + try container.encode(value, forKey: .value) + case .bool(let value): + try container.encode(value, forKey: .value) + case .bytes(let value): + try container.encode(value, forKey: .value) + case .stringArray(let value): + try container.encode(value, forKey: .value) + case .intArray(let value): + try container.encode(value, forKey: .value) + case .doubleArray(let value): + try container.encode(value, forKey: .value) + case .boolArray(let value): + try container.encode(value, forKey: .value) + case .byteChunkArray(let value): + try container.encode(value, forKey: .value) + } + } + + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let typeString = try container.decode(String.self, forKey: .type) + guard let type = ConfigType(rawValue: typeString) else { + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown config type: \(typeString)" + ) + } + + switch type { + case .string: + self = .string(try container.decode(String.self, forKey: .value)) + case .int: + self = .int(try container.decode(Int.self, forKey: .value)) + case .double: + self = .double(try container.decode(Double.self, forKey: .value)) + case .bool: + self = .bool(try container.decode(Bool.self, forKey: .value)) + case .bytes: + self = .bytes(try container.decode([UInt8].self, forKey: .value)) + case .stringArray: + self = .stringArray(try container.decode([String].self, forKey: .value)) + case .intArray: + self = .intArray(try container.decode([Int].self, forKey: .value)) + case .doubleArray: + self = .doubleArray(try container.decode([Double].self, forKey: .value)) + case .boolArray: + self = .boolArray(try container.decode([Bool].self, forKey: .value)) + case .byteChunkArray: + self = .byteChunkArray(try container.decode([[UInt8]].self, forKey: .value)) + } + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/ConfigVariableReaderEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/ConfigVariableReaderEditorTests.swift new file mode 100644 index 0000000..141f3a2 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/ConfigVariableReaderEditorTests.swift @@ -0,0 +1,112 @@ +// +// ConfigVariableReaderEditorTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import DevFoundation +import DevTesting +import Testing + +@testable import DevConfiguration + +struct ConfigVariableReaderEditorTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + func editorDisabledByDefault() { + // set up + let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) + + // expect + #expect(reader.editorOverrideProvider == nil) + } + + + @Test + func editorDisabledExplicitly() { + // set up + let reader = ConfigVariableReader( + providers: [InMemoryProvider(values: [:])], + eventBus: EventBus(), + isEditorEnabled: false + ) + + // expect + #expect(reader.editorOverrideProvider == nil) + } + + + @Test + func editorEnabledCreatesProvider() { + // set up + let reader = ConfigVariableReader(providers: [], eventBus: EventBus(), isEditorEnabled: true) + + // expect + #expect(reader.editorOverrideProvider != nil) + } + + + @Test + func editorProviderIsFirstInProviders() { + // set up + let otherProvider = InMemoryProvider(values: [:]) + let reader = ConfigVariableReader( + providers: [otherProvider], + eventBus: EventBus(), + isEditorEnabled: true + ) + + // expect + #expect(reader.providers.count == 2) + #expect(reader.providers.first is EditorOverrideProvider) + } + + + @Test + mutating func editorOverrideTakesPrecedence() { + // set up + let key = randomConfigKey() + let initialValue = randomAlphanumericString() + let overrideValue = randomAlphanumericString() + + let otherProvider = InMemoryProvider( + values: [ + AbsoluteConfigKey(key): ConfigValue(.string(initialValue), isSecret: false) + ] + ) + let reader = ConfigVariableReader( + providers: [otherProvider], + eventBus: EventBus(), + isEditorEnabled: true + ) + + let variable = ConfigVariable( + key: key, + defaultValue: randomAlphanumericString(), + secrecy: .public + ) + + // Verify the provider value is returned before any override + #expect(reader.value(for: variable) == initialValue) + + // exercise — set an override + reader.editorOverrideProvider!.setOverride(.string(overrideValue), forKey: key) + + // expect the override takes precedence + #expect(reader.value(for: variable) == overrideValue) + } + + + @Test + func convenienceInitPassesIsEditorEnabled() { + // set up + let reader = ConfigVariableReader(providers: [], eventBus: EventBus(), isEditorEnabled: true) + + // expect + #expect(reader.editorOverrideProvider != nil) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/EditorOverrideProviderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/EditorOverrideProviderTests.swift new file mode 100644 index 0000000..b06eddf --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/EditorOverrideProviderTests.swift @@ -0,0 +1,539 @@ +// +// EditorOverrideProviderTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +struct EditorOverrideProviderTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + func providerNameIsEditor() { + // set up + let provider = EditorOverrideProvider() + + // expect + #expect(provider.providerName == "Editor") + } + + + @Test + mutating func setOverrideThenRetrieve() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let content = ConfigContent.string(randomAlphanumericString()) + + // exercise + provider.setOverride(content, forKey: key) + + // expect + #expect(provider.overrides[key] == content) + } + + + @Test + mutating func removeOverrideClearsStoredValue() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(.bool(true), forKey: key) + + // exercise + provider.removeOverride(forKey: key) + + // expect + #expect(provider.overrides[key] == nil) + } + + + @Test + mutating func removeOverrideForNonexistentKeyIsNoOp() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + + // exercise + provider.removeOverride(forKey: key) + + // expect + #expect(provider.overrides.isEmpty) + } + + + @Test + func removeAllOverridesWhenEmptyIsNoOp() { + // set up + let provider = EditorOverrideProvider() + + // exercise + provider.removeAllOverrides() + + // expect + #expect(provider.overrides.isEmpty) + } + + + @Test + mutating func removeAllOverridesClearsEverything() { + // set up + let provider = EditorOverrideProvider() + let key1 = randomConfigKey() + let key2 = randomConfigKey() + provider.setOverride(.int(1), forKey: key1) + provider.setOverride(.int(2), forKey: key2) + + // exercise + provider.removeAllOverrides() + + // expect + #expect(provider.overrides.isEmpty) + } + + + @Test + mutating func hasOverrideReturnsTrueWhenSet() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(.bool(true), forKey: key) + + // expect + #expect(provider.hasOverride(forKey: key)) + } + + + @Test + mutating func hasOverrideReturnsFalseWhenNotSet() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + + // expect + #expect(!provider.hasOverride(forKey: key)) + } + + + @Test + mutating func overridesReturnsFullDictionary() { + // set up + let provider = EditorOverrideProvider() + let key1 = randomConfigKey() + let key2 = randomConfigKey() + let content1 = ConfigContent.string("a") + let content2 = ConfigContent.int(42) + provider.setOverride(content1, forKey: key1) + provider.setOverride(content2, forKey: key2) + + // expect + #expect(provider.overrides == [key1: content1, key2: content2]) + } + + + @Test + mutating func valueForKeyReturnsValueWhenTypeMatches() throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let content = ConfigContent.int(42) + provider.setOverride(content, forKey: key) + + // exercise + let result = try provider.value(forKey: AbsoluteConfigKey(key), type: .int) + + // expect + #expect(result.value == ConfigValue(content, isSecret: false)) + } + + + @Test + mutating func valueForKeyReturnsNilValueWhenTypeMismatches() throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(.int(42), forKey: key) + + // exercise + let result = try provider.value(forKey: AbsoluteConfigKey(key), type: .string) + + // expect + #expect(result.value == nil) + } + + + @Test + mutating func valueForKeyReturnsNilValueWhenKeyNotFound() throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + + // exercise + let result = try provider.value(forKey: AbsoluteConfigKey(key), type: .string) + + // expect + #expect(result.value == nil) + } + + + @Test + mutating func fetchValueDelegatesToValue() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let content = ConfigContent.bool(true) + provider.setOverride(content, forKey: key) + + // exercise + let result = try await provider.fetchValue(forKey: AbsoluteConfigKey(key), type: .bool) + + // expect + #expect(result.value == ConfigValue(content, isSecret: false)) + } + + + @Test + mutating func snapshotReturnsCurrentState() throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let content = ConfigContent.double(3.14) + provider.setOverride(content, forKey: key) + + // exercise + let snapshot = provider.snapshot() + + // expect + #expect(snapshot.providerName == "Editor") + let result = try snapshot.value(forKey: AbsoluteConfigKey(key), type: .double) + #expect(result.value == ConfigValue(content, isSecret: false)) + } + + + @Test + mutating func setOverrideDoesNotNotifyWhenValueUnchanged() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let absoluteKey = AbsoluteConfigKey(key) + provider.setOverride(.int(1), forKey: key) + + // exercise + try await provider.watchValue(forKey: absoluteKey, type: .int) { updates in + var iterator = updates.makeAsyncIterator() + + // Consume initial value + _ = try #require(await iterator.next()) + + // Set the same value again + provider.setOverride(.int(1), forKey: key) + + // Set a different value to verify the stream is still working + provider.setOverride(.int(2), forKey: key) + + // expect the next emitted value is 2, not 1 (the duplicate was skipped) + let next = try #require(await iterator.next()) + #expect(try next.get().value == ConfigValue(.int(2), isSecret: false)) + } + } + + + @Test + mutating func removeOverrideNotifiesValueWatchers() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let absoluteKey = AbsoluteConfigKey(key) + provider.setOverride(.string("hello"), forKey: key) + + // exercise + try await provider.watchValue(forKey: absoluteKey, type: .string) { updates in + var iterator = updates.makeAsyncIterator() + + // Consume initial value + let first = try #require(await iterator.next()) + #expect(try first.get().value == ConfigValue(.string("hello"), isSecret: false)) + + // Remove the override + provider.removeOverride(forKey: key) + + // expect nil value + let second = try #require(await iterator.next()) + #expect(try second.get().value == nil) + } + } + + + @Test + mutating func removeOverrideNotifiesSnapshotWatchers() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + + provider.setOverride(.bool(true), forKey: key) + + // exercise + try await provider.watchSnapshot { updates in + var iterator = updates.makeAsyncIterator() + + // Consume initial snapshot (has the override) + let first = try #require(await iterator.next()) + let firstResult = try first.value(forKey: AbsoluteConfigKey(key), type: .bool) + #expect(firstResult.value != nil) + + // Remove the override + provider.removeOverride(forKey: key) + + // expect updated snapshot without the override + let second = try #require(await iterator.next()) + let secondResult = try second.value(forKey: AbsoluteConfigKey(key), type: .bool) + #expect(secondResult.value == nil) + } + } + + + @Test + mutating func removeAllOverridesNotifiesValueWatchers() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let absoluteKey = AbsoluteConfigKey(key) + provider.setOverride(.int(42), forKey: key) + + // exercise + try await provider.watchValue(forKey: absoluteKey, type: .int) { updates in + var iterator = updates.makeAsyncIterator() + + // Consume initial value + let first = try #require(await iterator.next()) + #expect(try first.get().value == ConfigValue(.int(42), isSecret: false)) + + // Remove all overrides + provider.removeAllOverrides() + + // expect nil value + let second = try #require(await iterator.next()) + #expect(try second.get().value == nil) + } + } + + + @Test + mutating func removeAllOverridesNotifiesSnapshotWatchers() async throws { + // set up + let provider = EditorOverrideProvider() + let key1 = randomConfigKey() + let key2 = randomConfigKey() + provider.setOverride(.int(1), forKey: key1) + provider.setOverride(.int(2), forKey: key2) + + // exercise + try await provider.watchSnapshot { updates in + var iterator = updates.makeAsyncIterator() + + // Consume initial snapshot + _ = try #require(await iterator.next()) + + // Remove all overrides + provider.removeAllOverrides() + + // expect empty snapshot + let second = try #require(await iterator.next()) + let result1 = try second.value(forKey: AbsoluteConfigKey(key1), type: .int) + let result2 = try second.value(forKey: AbsoluteConfigKey(key2), type: .int) + #expect(result1.value == nil) + #expect(result2.value == nil) + } + } + + + @Test + mutating func watchValueReturnsNilValueWhenTypeMismatches() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let absoluteKey = AbsoluteConfigKey(key) + provider.setOverride(.int(42), forKey: key) + + // exercise — watch as .string, but the override is .int + try await provider.watchValue(forKey: absoluteKey, type: .string) { updates in + var iterator = updates.makeAsyncIterator() + + // expect nil value due to type mismatch + let first = try #require(await iterator.next()) + #expect(try first.get().value == nil) + + // Update with another int — still mismatches .string + provider.setOverride(.int(99), forKey: key) + + let second = try #require(await iterator.next()) + #expect(try second.get().value == nil) + } + } + + + @Test + mutating func watchValueEmitsInitialAndSubsequentChanges() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let absoluteKey = AbsoluteConfigKey(key) + provider.setOverride(.int(1), forKey: key) + + // exercise + try await provider.watchValue(forKey: absoluteKey, type: .int) { updates in + var iterator = updates.makeAsyncIterator() + + // expect initial value + let first = try #require(await iterator.next()) + #expect(try first.get().value == ConfigValue(.int(1), isSecret: false)) + + // Update the override + provider.setOverride(.int(2), forKey: key) + + // expect updated value + let second = try #require(await iterator.next()) + #expect(try second.get().value == ConfigValue(.int(2), isSecret: false)) + } + } + + + @Test + mutating func watchSnapshotEmitsInitialAndSubsequentChanges() async throws { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + + // exercise + try await provider.watchSnapshot { updates in + var iterator = updates.makeAsyncIterator() + + // expect initial empty snapshot + let first = try #require(await iterator.next()) + #expect(first.providerName == "Editor") + let firstResult = try first.value(forKey: AbsoluteConfigKey(key), type: .string) + #expect(firstResult.value == nil) + + // Update the override + provider.setOverride(.string("hello"), forKey: key) + + // expect updated snapshot + let second = try #require(await iterator.next()) + let secondResult = try second.value(forKey: AbsoluteConfigKey(key), type: .string) + #expect(secondResult.value == ConfigValue(.string("hello"), isSecret: false)) + } + } +} + + +// MARK: - Persistence Tests + +struct EditorOverrideProviderPersistenceTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + /// Creates a test-specific UserDefaults suite and cleans up the persistence key. + private func makeTestUserDefaults() -> UserDefaults { + let suiteName = "devkit.DevConfiguration.test.\(UUID())" + let userDefaults = UserDefaults(suiteName: suiteName)! + userDefaults.removeObject(forKey: "editorOverrides") + return userDefaults + } + + + @Test + mutating func persistThenLoadRoundTripsOverrides() { + // set up + let userDefaults = makeTestUserDefaults() + let key1 = randomConfigKey() + let key2 = randomConfigKey() + let content1 = ConfigContent.string(randomAlphanumericString()) + let content2 = ConfigContent.int(randomInt(in: .min ... .max)) + + let provider1 = EditorOverrideProvider() + provider1.setOverride(content1, forKey: key1) + provider1.setOverride(content2, forKey: key2) + provider1.persist(to: userDefaults) + + // exercise + let provider2 = EditorOverrideProvider() + provider2.load(from: userDefaults) + + // expect + #expect(provider2.overrides[key1] == content1) + #expect(provider2.overrides[key2] == content2) + } + + + @Test + func persistEmptyOverrides() { + // set up + let userDefaults = makeTestUserDefaults() + let provider1 = EditorOverrideProvider() + provider1.persist(to: userDefaults) + + // exercise + let provider2 = EditorOverrideProvider() + provider2.load(from: userDefaults) + + // expect + #expect(provider2.overrides.isEmpty) + } + + + @Test + mutating func clearPersistenceRemovesStoredData() { + // set up + let userDefaults = makeTestUserDefaults() + let provider = EditorOverrideProvider() + provider.setOverride(.bool(true), forKey: randomConfigKey()) + provider.persist(to: userDefaults) + + // exercise + provider.clearPersistence(from: userDefaults) + + // expect + let reloaded = EditorOverrideProvider() + reloaded.load(from: userDefaults) + #expect(reloaded.overrides.isEmpty) + } + + + @Test + func loadWithNoStoredDataResultsInEmptyOverrides() { + // set up + let userDefaults = makeTestUserDefaults() + + // exercise + let provider = EditorOverrideProvider() + provider.load(from: userDefaults) + + // expect + #expect(provider.overrides.isEmpty) + } + + + @Test + mutating func loadWithCorruptDataResultsInEmptyOverrides() { + // set up + let userDefaults = makeTestUserDefaults() + let corruptData: [String: Data] = [ + randomAlphanumericString(): randomData() + ] + userDefaults.set(corruptData, forKey: "editorOverrides") + + // exercise + let provider = EditorOverrideProvider() + provider.load(from: userDefaults) + + // expect + #expect(provider.overrides.isEmpty) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift new file mode 100644 index 0000000..27da5be --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift @@ -0,0 +1,73 @@ +// +// ConfigContent+AdditionsTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +struct ConfigContent_AdditionsTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test( + arguments: [ + (ConfigContent.string("hello"), ConfigType.string), + (.int(42), .int), + (.double(3.14), .double), + (.bool(true), .bool), + (.bytes([1, 2, 3]), .bytes), + (.stringArray(["a", "b"]), .stringArray), + (.intArray([1, 2]), .intArray), + (.doubleArray([1.0, 2.0]), .doubleArray), + (.boolArray([true, false]), .boolArray), + (.byteChunkArray([[1], [2]]), .byteChunkArray), + ] + ) + func configTypeReturnsCorrectType(content: ConfigContent, expectedType: ConfigType) { + #expect(content.configType == expectedType) + } + + + @Test( + arguments: [ + ConfigContent.string("hello"), + .int(42), + .double(3.14), + .bool(true), + .bytes([0, 255, 128]), + .stringArray(["a", "b", "c"]), + .intArray([1, 2, 3]), + .doubleArray([1.5, 2.5]), + .boolArray([true, false, true]), + .byteChunkArray([[1, 2], [3, 4]]), + ] + ) + func codableRoundTripsContent(content: ConfigContent) throws { + // exercise + let data = try JSONEncoder().encode(content) + let decoded = try JSONDecoder().decode(ConfigContent.self, from: data) + + // expect + #expect(decoded == content) + } + + + @Test + func decodingUnknownTypeThrows() throws { + // set up + let json = #"{"type":"unknown","value":"test"}"# + let data = Data(json.utf8) + + // expect + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ConfigContent.self, from: data) + } + } +} From 0f52b755416270aa86b9708fa18a4ef7212ccf06 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Sat, 7 Mar 2026 23:27:58 -0500 Subject: [PATCH 3/9] Add EditorDocument with working copy, save, and undo/redo support - Add EditorDocument, a @MainActor working copy model that tracks staged overrides separately from the committed baseline in EditorOverrideProvider - Support setOverride, removeOverride, and removeAllOverrides with dirty tracking via isDirty and changedKeys - Add save method that computes the delta, updates the provider, persists to UserDefaults, and resets the baseline - Integrate with UndoManager for undo/redo of all mutation operations, including a single undo action for removeAllOverrides - Reorder EditorOverrideProvider declarations to place types before stored properties --- .../Editor/EditorDocument.swift | 209 ++++++ .../Editor/EditorOverrideProvider.swift | 18 +- .../Editor/EditorDocumentTests.swift | 621 ++++++++++++++++++ 3 files changed, 839 insertions(+), 9 deletions(-) create mode 100644 Sources/DevConfiguration/Editor/EditorDocument.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Editor/EditorDocumentTests.swift diff --git a/Sources/DevConfiguration/Editor/EditorDocument.swift b/Sources/DevConfiguration/Editor/EditorDocument.swift new file mode 100644 index 0000000..8da1f22 --- /dev/null +++ b/Sources/DevConfiguration/Editor/EditorDocument.swift @@ -0,0 +1,209 @@ +// +// EditorDocument.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import Foundation + + +/// A working copy model that tracks staged editor overrides with undo/redo support. +/// +/// `EditorDocument` maintains a working copy of configuration overrides separate from the committed state in +/// ``EditorOverrideProvider``. Changes are staged in the working copy and only applied to the provider on +/// ``save()``. The document supports undo/redo for all mutations via an `UndoManager`. +@MainActor +final class EditorDocument { + /// The editor override provider that this document commits to on save. + private let provider: EditorOverrideProvider + + /// The undo manager for registering undo/redo actions, if any. + private let undoManager: UndoManager? + + /// The committed baseline, snapshotted from the provider at init. + private var baseline: [ConfigKey: ConfigContent] + + /// The working copy of overrides. + private(set) var workingCopy: [ConfigKey: ConfigContent] + + + /// Creates a new editor document. + /// + /// The document's working copy and baseline are initialized from the provider's current overrides. + /// + /// - Parameters: + /// - provider: The editor override provider to commit to on save. + /// - undoManager: An optional undo manager for registering undo/redo actions. + init(provider: EditorOverrideProvider, undoManager: UndoManager? = nil) { + self.provider = provider + self.undoManager = undoManager + let currentOverrides = provider.overrides + self.baseline = currentOverrides + self.workingCopy = currentOverrides + } +} + + +// MARK: - Working Copy + +extension EditorDocument { + /// Returns the override value for the given key in the working copy. + /// + /// - Parameter key: The configuration key to look up. + /// - Returns: The override content, or `nil` if no override exists. + func override(forKey key: ConfigKey) -> ConfigContent? { + workingCopy[key] + } + + + /// Whether the working copy contains an override for the given key. + /// + /// - Parameter key: The configuration key to check. + /// - Returns: `true` if the working copy has an override for the key. + func hasOverride(forKey key: ConfigKey) -> Bool { + workingCopy[key] != nil + } + + + /// Sets an override in the working copy. + /// + /// If an undo manager is set, an undo action is registered that restores the previous value (or removes the + /// override if there was none). + /// + /// - Parameters: + /// - content: The override content value. + /// - key: The configuration key to override. + func setOverride(_ content: ConfigContent, forKey key: ConfigKey) { + let previousContent = workingCopy[key] + workingCopy[key] = content + registerUndoForSet(previousContent: previousContent, key: key) + } + + + /// Removes the override for the given key from the working copy. + /// + /// If an undo manager is set and an override existed, an undo action is registered that restores the previous + /// value. + /// + /// - Parameter key: The configuration key whose override should be removed. + func removeOverride(forKey key: ConfigKey) { + let previousContent = workingCopy.removeValue(forKey: key) + if let previousContent { + registerUndoForRemove(previousContent: previousContent, key: key) + } + } + + + /// Removes all overrides from the working copy. + /// + /// If an undo manager is set and overrides existed, a single undo action is registered that restores all + /// previous overrides. + func removeAllOverrides() { + let previousWorkingCopy = workingCopy + workingCopy.removeAll() + + if !previousWorkingCopy.isEmpty { + registerUndoForRemoveAll(previousWorkingCopy: previousWorkingCopy) + } + } +} + + +// MARK: - Dirty Tracking + +extension EditorDocument { + /// Whether the working copy differs from the committed baseline. + var isDirty: Bool { + workingCopy != baseline + } + + + /// The set of keys that differ between the working copy and the committed baseline. + /// + /// This includes keys that were added, removed, or changed relative to the baseline. + var changedKeys: Set { + var changed = Set() + + // Keys in working copy that are new or changed + for (key, content) in workingCopy where baseline[key] != content { + changed.insert(key) + } + + // Keys in baseline that were removed from working copy + for key in baseline.keys where workingCopy[key] == nil { + changed.insert(key) + } + + return changed + } +} + + +// MARK: - Save + +extension EditorDocument { + /// Saves the working copy to the editor override provider and persists to UserDefaults. + /// + /// This computes the delta between the working copy and baseline, updates the provider to match the working + /// copy, persists the overrides, and resets the baseline to match the working copy. + /// + /// - Returns: The set of keys that changed relative to the previous committed state. + @discardableResult + func save() -> Set { + let changed = changedKeys + baseline = workingCopy + + // Update the provider to match the working copy + provider.removeAllOverrides() + for (key, content) in workingCopy { + provider.setOverride(content, forKey: key) + } + provider.persist(to: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) + + return changed + } +} + + +// MARK: - Undo Registration + +extension EditorDocument { + /// Registers an undo action for a `setOverride` call. + private func registerUndoForSet(previousContent: ConfigContent?, key: ConfigKey) { + guard let undoManager else { return } + + if let previousContent { + undoManager.registerUndo(withTarget: self) { document in + document.setOverride(previousContent, forKey: key) + } + } else { + undoManager.registerUndo(withTarget: self) { document in + document.removeOverride(forKey: key) + } + } + } + + + /// Registers an undo action for a `removeOverride` call. + private func registerUndoForRemove(previousContent: ConfigContent, key: ConfigKey) { + guard let undoManager else { return } + + undoManager.registerUndo(withTarget: self) { document in + document.setOverride(previousContent, forKey: key) + } + } + + + /// Registers an undo action for a `removeAllOverrides` call. + private func registerUndoForRemoveAll(previousWorkingCopy: [ConfigKey: ConfigContent]) { + guard let undoManager else { return } + + undoManager.registerUndo(withTarget: self) { document in + let currentWorkingCopy = document.workingCopy + document.workingCopy = previousWorkingCopy + document.registerUndoForRemoveAll(previousWorkingCopy: currentWorkingCopy) + } + } +} diff --git a/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift b/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift index 082aed7..483455e 100644 --- a/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift +++ b/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift @@ -16,15 +16,6 @@ import Synchronization /// overrides the highest priority. Values are stored in memory for fast access and can be persisted to UserDefaults /// for durability across app launches. final class EditorOverrideProvider: Sendable { - /// The UserDefaults suite name used for persistence. - static let suiteName = "devkit.DevConfiguration" - - /// The UserDefaults key under which overrides are stored. - private static let persistenceKey = "editorOverrides" - - /// The logger used for persistence diagnostics. - private static let logger = Logger(subsystem: "DevConfiguration", category: "EditorOverrideProvider") - /// The mutable state of the provider, protected by a `Mutex`. private struct MutableState: Sendable { /// The current overrides keyed by their configuration key. @@ -55,6 +46,15 @@ final class EditorOverrideProvider: Sendable { } + /// The UserDefaults suite name used for persistence. + static let suiteName = "devkit.DevConfiguration" + + /// The UserDefaults key under which overrides are stored. + private static let persistenceKey = "editorOverrides" + + /// The logger used for persistence diagnostics. + private static let logger = Logger(subsystem: "DevConfiguration", category: "EditorOverrideProvider") + /// The mutable state protected by a mutex. private let mutableState: Mutex = .init(MutableState()) } diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/EditorDocumentTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/EditorDocumentTests.swift new file mode 100644 index 0000000..85ec308 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/EditorDocumentTests.swift @@ -0,0 +1,621 @@ +// +// EditorDocumentTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +@MainActor +struct EditorDocumentTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - Init + + @Test + func initWithEmptyProvider() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // expect + #expect(document.workingCopy.isEmpty) + #expect(!document.isDirty) + } + + + @Test + mutating func initWithPopulatedProvider() { + // set up + let provider = EditorOverrideProvider() + let key1 = randomConfigKey() + let content1 = randomConfigContent() + let key2 = randomConfigKey() + let content2 = randomConfigContent() + provider.setOverride(content1, forKey: key1) + provider.setOverride(content2, forKey: key2) + + // exercise + let document = EditorDocument(provider: provider) + + // expect + #expect(document.workingCopy == [key1: content1, key2: content2]) + #expect(!document.isDirty) + } + + + // MARK: - Working Copy + + @Test + mutating func setOverrideThenRetrieve() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + let content = randomConfigContent() + + // exercise + document.setOverride(content, forKey: key) + + // expect + #expect(document.override(forKey: key) == content) + } + + + @Test + mutating func setOverrideOverwritesPreviousValue() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + let content1 = ConfigContent.string(randomAlphanumericString()) + let content2 = ConfigContent.int(randomInt(in: .min ... .max)) + + document.setOverride(content1, forKey: key) + + // exercise + document.setOverride(content2, forKey: key) + + // expect + #expect(document.override(forKey: key) == content2) + } + + + @Test + mutating func overrideForNonexistentKeyReturnsNil() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // expect + #expect(document.override(forKey: randomConfigKey()) == nil) + } + + + @Test + mutating func hasOverrideReturnsTrueForExistingKey() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + document.setOverride(randomConfigContent(), forKey: key) + + // expect + #expect(document.hasOverride(forKey: key)) + } + + + @Test + mutating func hasOverrideReturnsFalseForNonexistentKey() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // expect + #expect(!document.hasOverride(forKey: randomConfigKey())) + } + + + @Test + mutating func removeOverrideClearsValue() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + document.setOverride(randomConfigContent(), forKey: key) + + // exercise + document.removeOverride(forKey: key) + + // expect + #expect(document.override(forKey: key) == nil) + #expect(!document.hasOverride(forKey: key)) + } + + + @Test + mutating func removeOverrideForNonexistentKeyIsNoOp() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + document.setOverride(randomConfigContent(), forKey: key) + + // exercise + document.removeOverride(forKey: randomConfigKey()) + + // expect — original override is untouched + #expect(document.workingCopy.count == 1) + } + + + @Test + mutating func removeAllOverridesClearsWorkingCopy() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(randomConfigContent(), forKey: randomConfigKey()) + document.setOverride(randomConfigContent(), forKey: randomConfigKey()) + + // exercise + document.removeAllOverrides() + + // expect + #expect(document.workingCopy.isEmpty) + } + + + @Test + func removeAllOverridesWhenEmptyIsNoOp() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // exercise + document.removeAllOverrides() + + // expect + #expect(document.workingCopy.isEmpty) + #expect(!document.isDirty) + } + + + // MARK: - Dirty Tracking + + @Test + func isNotDirtyAfterInit() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // expect + #expect(!document.isDirty) + } + + + @Test + mutating func isDirtyAfterSetOverride() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // exercise + document.setOverride(randomConfigContent(), forKey: randomConfigKey()) + + // expect + #expect(document.isDirty) + } + + + @Test + mutating func isNotDirtyAfterRevertingToBaseline() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + + document.setOverride(randomConfigContent(), forKey: key) + #expect(document.isDirty) + + // exercise + document.removeOverride(forKey: key) + + // expect + #expect(!document.isDirty) + } + + + @Test + mutating func isDirtyAfterRemovingBaselineOverride() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(randomConfigContent(), forKey: key) + let document = EditorDocument(provider: provider) + + // exercise + document.removeOverride(forKey: key) + + // expect + #expect(document.isDirty) + } + + + @Test + mutating func isDirtyAfterChangingBaselineValue() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(.bool(true), forKey: key) + let document = EditorDocument(provider: provider) + + // exercise + document.setOverride(.bool(false), forKey: key) + + // expect + #expect(document.isDirty) + } + + + @Test + func changedKeysIsEmptyAfterInit() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // expect + #expect(document.changedKeys.isEmpty) + } + + + @Test + mutating func changedKeysIncludesAddedKey() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + + // exercise + document.setOverride(randomConfigContent(), forKey: key) + + // expect + #expect(document.changedKeys == [key]) + } + + + @Test + mutating func changedKeysIncludesRemovedKey() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(randomConfigContent(), forKey: key) + let document = EditorDocument(provider: provider) + + // exercise + document.removeOverride(forKey: key) + + // expect + #expect(document.changedKeys == [key]) + } + + + @Test + mutating func changedKeysIncludesModifiedKey() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(.bool(true), forKey: key) + let document = EditorDocument(provider: provider) + + // exercise + document.setOverride(.bool(false), forKey: key) + + // expect + #expect(document.changedKeys == [key]) + } + + + @Test + mutating func changedKeysExcludesUnchangedKey() { + // set up + let provider = EditorOverrideProvider() + let unchangedKey = randomConfigKey() + let changedKey = randomConfigKey() + provider.setOverride(.bool(true), forKey: unchangedKey) + let document = EditorDocument(provider: provider) + + // exercise + document.setOverride(randomConfigContent(), forKey: changedKey) + + // expect + #expect(document.changedKeys == [changedKey]) + } + + + // MARK: - Save + + @Test + mutating func saveReturnsChangedKeys() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key1 = randomConfigKey() + let key2 = randomConfigKey() + document.setOverride(randomConfigContent(), forKey: key1) + document.setOverride(randomConfigContent(), forKey: key2) + + // exercise + let changed = document.save() + + // expect + #expect(changed == [key1, key2]) + } + + + @Test + mutating func saveResetsBaselineSoDocumentIsClean() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(randomConfigContent(), forKey: randomConfigKey()) + #expect(document.isDirty) + + // exercise + document.save() + + // expect + #expect(!document.isDirty) + #expect(document.changedKeys.isEmpty) + } + + + @Test + mutating func saveUpdatesProviderOverrides() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + let content = randomConfigContent() + document.setOverride(content, forKey: key) + + // exercise + document.save() + + // expect + #expect(provider.overrides == [key: content]) + } + + + @Test + mutating func savePersistsToUserDefaults() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + let content = randomConfigContent() + document.setOverride(content, forKey: key) + + // exercise + document.save() + + // expect — verify persistence by loading into a fresh provider + let freshProvider = EditorOverrideProvider() + freshProvider.load(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) + #expect(freshProvider.overrides[key] == content) + + // clean up + provider.clearPersistence(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) + } + + + @Test + func saveWithNoChangesReturnsEmptySet() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + // exercise + let changed = document.save() + + // expect + #expect(changed.isEmpty) + } + + + @Test + mutating func saveWithRemovedBaselineKeyIncludesItInChangedKeys() { + // set up + let provider = EditorOverrideProvider() + let key = randomConfigKey() + provider.setOverride(randomConfigContent(), forKey: key) + let document = EditorDocument(provider: provider) + document.removeOverride(forKey: key) + + // exercise + let changed = document.save() + + // expect + #expect(changed == [key]) + #expect(provider.overrides.isEmpty) + } + + + // MARK: - Undo/Redo + + @Test + mutating func undoSetOverrideRestoresPreviousValue() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let originalContent = ConfigContent.string(randomAlphanumericString()) + provider.setOverride(originalContent, forKey: key) + let document = EditorDocument(provider: provider, undoManager: undoManager) + + document.setOverride(.bool(true), forKey: key) + #expect(document.override(forKey: key) == .bool(true)) + + // exercise + undoManager.undo() + + // expect + #expect(document.override(forKey: key) == originalContent) + } + + + @Test + mutating func undoSetOverrideRemovesNewKey() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider, undoManager: undoManager) + let key = randomConfigKey() + + document.setOverride(randomConfigContent(), forKey: key) + #expect(document.hasOverride(forKey: key)) + + // exercise + undoManager.undo() + + // expect + #expect(!document.hasOverride(forKey: key)) + } + + + @Test + mutating func redoSetOverrideReapplies() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider, undoManager: undoManager) + let key = randomConfigKey() + let content = randomConfigContent() + + document.setOverride(content, forKey: key) + undoManager.undo() + #expect(!document.hasOverride(forKey: key)) + + // exercise + undoManager.redo() + + // expect + #expect(document.override(forKey: key) == content) + } + + + @Test + mutating func undoRemoveOverrideRestoresValue() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let content = randomConfigContent() + provider.setOverride(content, forKey: key) + let document = EditorDocument(provider: provider, undoManager: undoManager) + + document.removeOverride(forKey: key) + #expect(!document.hasOverride(forKey: key)) + + // exercise + undoManager.undo() + + // expect + #expect(document.override(forKey: key) == content) + } + + + @Test + mutating func redoRemoveOverrideRemovesAgain() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let key = randomConfigKey() + let content = randomConfigContent() + provider.setOverride(content, forKey: key) + let document = EditorDocument(provider: provider, undoManager: undoManager) + + document.removeOverride(forKey: key) + undoManager.undo() + #expect(document.hasOverride(forKey: key)) + + // exercise + undoManager.redo() + + // expect + #expect(!document.hasOverride(forKey: key)) + } + + + @Test + mutating func undoRemoveAllOverridesRestoresAll() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let key1 = randomConfigKey() + let content1 = randomConfigContent() + let key2 = randomConfigKey() + let content2 = randomConfigContent() + provider.setOverride(content1, forKey: key1) + provider.setOverride(content2, forKey: key2) + let document = EditorDocument(provider: provider, undoManager: undoManager) + + document.removeAllOverrides() + #expect(document.workingCopy.isEmpty) + + // exercise + undoManager.undo() + + // expect + #expect(document.workingCopy == [key1: content1, key2: content2]) + } + + + @Test + mutating func redoRemoveAllOverridesClearsAgain() { + // set up + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let key1 = randomConfigKey() + let content1 = randomConfigContent() + let key2 = randomConfigKey() + let content2 = randomConfigContent() + provider.setOverride(content1, forKey: key1) + provider.setOverride(content2, forKey: key2) + let document = EditorDocument(provider: provider, undoManager: undoManager) + + document.removeAllOverrides() + undoManager.undo() + #expect(document.workingCopy.count == 2) + + // exercise + undoManager.redo() + + // expect + #expect(document.workingCopy.isEmpty) + } + + + @Test + mutating func noUndoManagerDoesNotCrash() { + // set up + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let key = randomConfigKey() + + // exercise — all mutations should work without an undo manager + document.setOverride(randomConfigContent(), forKey: key) + document.removeOverride(forKey: key) + document.setOverride(randomConfigContent(), forKey: key) + document.removeAllOverrides() + + // expect + #expect(document.workingCopy.isEmpty) + } +} From a753788dd3ca6a018f597fe1251c02a21d148df3 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Sun, 8 Mar 2026 01:19:43 -0500 Subject: [PATCH 4/9] Add view model layer for editor UI with list and detail protocols - Add displayString computed property to ConfigContent using locale-aware formatters for numbers and list formatting - Add VariableListItem and ProviderValue data structs for the list and detail views respectively - Add ConfigVariableListViewModeling and ConfigVariableDetailViewModeling protocols outside #if canImport(SwiftUI) for testability - Add ConfigVariableListViewModel with filtered/sorted variable list, search, value resolution across providers, save, cancel, undo/redo delegation, and detail view model creation - Add ConfigVariableDetailViewModel with provider value queries, override enable/disable, text and bool override editing via parse closures, and secret reveal toggle - Make EditorDocument @Observable so view model computed properties react to working copy changes - Add static editorProviderName to EditorOverrideProvider to avoid hardcoded provider name strings - Add localized unknownProviderName string for variables that resolve to their default value - Reorganize Editor directory into Data Models and View Models subdirectories --- Documentation/TestMocks.md | 2 +- Documentation/TestingGuidelines.md | 8 +- .../{ => Data Models}/EditorDocument.swift | 2 +- .../EditorOverrideProvider.swift | 7 +- .../ConfigVariableDetailViewModel.swift | 133 ++++++ .../ConfigVariableDetailViewModeling.swift | 50 +++ .../ConfigVariableListViewModel.swift | 168 +++++++ .../ConfigVariableListViewModeling.swift | 58 +++ .../Editor/View Models/ProviderValue.swift | 18 + .../Editor/View Models/VariableListItem.swift | 35 ++ .../Extensions/ConfigContent+Additions.swift | 35 ++ .../Resources/Localizable.xcstrings | 10 + .../ConfigVariableReaderEditorTests.swift | 0 .../EditorDocumentTests.swift | 0 .../EditorOverrideProviderTests.swift | 0 .../ConfigVariableDetailViewModelTests.swift | 366 +++++++++++++++ .../ConfigVariableListViewModelTests.swift | 423 ++++++++++++++++++ .../ConfigContentDisplayStringTests.swift | 125 ++++++ .../RequiresRelaunchMetadataKeyTests.swift | 4 +- 19 files changed, 1434 insertions(+), 10 deletions(-) rename Sources/DevConfiguration/Editor/{ => Data Models}/EditorDocument.swift (99%) rename Sources/DevConfiguration/Editor/{ => Data Models}/EditorOverrideProvider.swift (99%) create mode 100644 Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift create mode 100644 Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift create mode 100644 Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift create mode 100644 Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift create mode 100644 Sources/DevConfiguration/Editor/View Models/ProviderValue.swift create mode 100644 Sources/DevConfiguration/Editor/View Models/VariableListItem.swift rename Tests/DevConfigurationTests/Unit Tests/Editor/{ => Data Models}/ConfigVariableReaderEditorTests.swift (100%) rename Tests/DevConfigurationTests/Unit Tests/Editor/{ => Data Models}/EditorDocumentTests.swift (100%) rename Tests/DevConfigurationTests/Unit Tests/Editor/{ => Data Models}/EditorOverrideProviderTests.swift (100%) create mode 100644 Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContentDisplayStringTests.swift diff --git a/Documentation/TestMocks.md b/Documentation/TestMocks.md index e55e9ba..5a4cbc6 100644 --- a/Documentation/TestMocks.md +++ b/Documentation/TestMocks.md @@ -355,7 +355,7 @@ Epilogues execute after the stub is called. Run the epilogue in a `Task` within instance.performAction() // Verify intermediate state while mock is blocked - await #expect(instance.isProcessing == true) + await #expect(instance.isProcessing) // Signal completion to unblock signaler.yield() diff --git a/Documentation/TestingGuidelines.md b/Documentation/TestingGuidelines.md index a9a4484..b76dd9d 100644 --- a/Documentation/TestingGuidelines.md +++ b/Documentation/TestingGuidelines.md @@ -384,7 +384,7 @@ coordination: await task.value // expect the work completed successfully - #expect(instance.workCompletedFlag == true) + #expect(instance.workCompletedFlag) } **When to use this pattern:** @@ -533,7 +533,7 @@ execution timing. Prologues execute before the stub, epilogues execute after. Se instance.performAction() // expect intermediate state while mock is blocked - await #expect(instance.isProcessing == true) + await #expect(instance.isProcessing) await #expect(instance.queuedItems.count == 5) // signal completion to unblock the mock @@ -543,7 +543,7 @@ execution timing. Prologues execute before the stub, epilogues execute after. Se try await Task.sleep(for: .milliseconds(100)) // expect final state after mock completes - await #expect(instance.isProcessing == false) + await #expect(!instance.isProcessing) await #expect(instance.queuedItems.isEmpty) } @@ -564,7 +564,7 @@ execution timing. Prologues execute before the stub, epilogues execute after. Se // expect timeout occurred before mock completed try await Task.sleep(for: .milliseconds(150)) - await #expect(instance.didTimeout == true) + await #expect(instance.didTimeout) } #### Pattern: Signaling Completion with Epilogue diff --git a/Sources/DevConfiguration/Editor/EditorDocument.swift b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift similarity index 99% rename from Sources/DevConfiguration/Editor/EditorDocument.swift rename to Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift index 8da1f22..3420caa 100644 --- a/Sources/DevConfiguration/Editor/EditorDocument.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift @@ -14,7 +14,7 @@ import Foundation /// `EditorDocument` maintains a working copy of configuration overrides separate from the committed state in /// ``EditorOverrideProvider``. Changes are staged in the working copy and only applied to the provider on /// ``save()``. The document supports undo/redo for all mutations via an `UndoManager`. -@MainActor +@MainActor @Observable final class EditorDocument { /// The editor override provider that this document commits to on save. private let provider: EditorOverrideProvider diff --git a/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift similarity index 99% rename from Sources/DevConfiguration/Editor/EditorOverrideProvider.swift rename to Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift index 483455e..1a2a1a8 100644 --- a/Sources/DevConfiguration/Editor/EditorOverrideProvider.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift @@ -7,8 +7,8 @@ import Configuration import Foundation -import OSLog import Synchronization +import os /// A configuration provider that stores editor overrides in memory and persists them to UserDefaults. /// @@ -46,6 +46,9 @@ final class EditorOverrideProvider: Sendable { } + /// The name used to identify this provider. + static let editorProviderName = "Editor" + /// The UserDefaults suite name used for persistence. static let suiteName = "devkit.DevConfiguration" @@ -320,7 +323,7 @@ extension EditorOverrideProvider { extension EditorOverrideProvider: ConfigProvider { var providerName: String { - "Editor" + Self.editorProviderName } diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift new file mode 100644 index 0000000..13a990b --- /dev/null +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift @@ -0,0 +1,133 @@ +// +// ConfigVariableDetailViewModel.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import Foundation + +/// The concrete detail view model for a single configuration variable in the editor. +/// +/// `ConfigVariableDetailViewModel` displays a variable's metadata, the value from each provider, and override +/// controls. It delegates override mutations to the ``EditorDocument`` and parses text input using the variable's +/// parse closure. +@MainActor @Observable +final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { + /// The registered variable this detail view model represents. + private let variable: RegisteredConfigVariable + + /// The editor document managing the working copy. + private let document: EditorDocument + + /// The reader's providers, queried for per-provider values. + private let providers: [any ConfigProvider] + + /// Whether the variable's secret value is currently revealed. + var isSecretRevealed = false + + + /// Creates a new detail view model. + /// + /// - Parameters: + /// - variable: The registered variable to display. + /// - document: The editor document managing the working copy. + /// - providers: The reader's providers. + init( + variable: RegisteredConfigVariable, + document: EditorDocument, + providers: [any ConfigProvider] + ) { + self.variable = variable + self.document = document + self.providers = providers + } + + + var key: ConfigKey { + variable.key + } + + + var displayName: String { + variable.displayName ?? variable.key.description + } + + + var metadataEntries: [ConfigVariableMetadata.DisplayText] { + variable.metadata.displayTextEntries + } + + + var providerValues: [ProviderValue] { + let absoluteKey = AbsoluteConfigKey(variable.key) + let expectedType = variable.defaultContent.configType + + return providers.compactMap { provider in + guard + let result = try? provider.value(forKey: absoluteKey, type: expectedType), + let configValue = result.value + else { + return nil + } + + return ProviderValue( + providerName: provider.providerName, + valueString: configValue.content.displayString + ) + } + } + + + var isOverrideEnabled: Bool { + get { + document.hasOverride(forKey: variable.key) + } + set { + if newValue { + document.setOverride(variable.defaultContent, forKey: variable.key) + } else { + document.removeOverride(forKey: variable.key) + } + } + } + + + var overrideText: String { + get { + guard let content = document.override(forKey: variable.key) else { + return "" + } + return content.displayString + } + set { + guard let parse = variable.parse, let content = parse(newValue) else { + return + } + document.setOverride(content, forKey: variable.key) + } + } + + + var overrideBool: Bool { + get { + guard case .bool(let value) = document.override(forKey: variable.key) else { + return false + } + return value + } + set { + document.setOverride(.bool(newValue), forKey: variable.key) + } + } + + + var editorControl: EditorControl { + variable.editorControl + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift new file mode 100644 index 0000000..fc46ce2 --- /dev/null +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift @@ -0,0 +1,50 @@ +// +// ConfigVariableDetailViewModeling.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +import Configuration +import Foundation + +/// The view model protocol for the configuration variable detail view. +/// +/// `ConfigVariableDetailViewModeling` defines the interface that the detail view uses to display a single +/// configuration variable's metadata, provider values, and override controls. It supports enabling and editing +/// overrides via the appropriate editor control, and toggling secret value visibility. +@MainActor +protocol ConfigVariableDetailViewModeling: Observable { + /// The configuration key for this variable. + var key: ConfigKey { get } + + /// The human-readable display name for this variable. + var displayName: String { get } + + /// The metadata entries to display. + var metadataEntries: [ConfigVariableMetadata.DisplayText] { get } + + /// The value from each provider for this variable. + var providerValues: [ProviderValue] { get } + + /// Whether an editor override is enabled for this variable. + /// + /// Setting this to `true` enables the override with the variable's default value. Setting it to `false` removes + /// the override. + var isOverrideEnabled: Bool { get set } + + /// The override value as a string, for text-based editor controls. + /// + /// Setting this parses the string into a ``ConfigContent`` value using the variable's parse closure and updates + /// the working copy if parsing succeeds. + var overrideText: String { get set } + + /// The override value as a boolean, for toggle editor controls. + var overrideBool: Bool { get set } + + /// The editor control to use when editing this variable's value. + var editorControl: EditorControl { get } + + /// Whether the variable's secret value is currently revealed. + var isSecretRevealed: Bool { get set } +} diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift new file mode 100644 index 0000000..ad1d954 --- /dev/null +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift @@ -0,0 +1,168 @@ +// +// ConfigVariableListViewModel.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import Foundation + +/// The concrete list view model for the configuration variable editor. +/// +/// `ConfigVariableListViewModel` owns an ``EditorDocument`` and provides a filtered, sorted list of +/// ``VariableListItem`` values for display. It resolves which provider owns each variable's value by querying +/// providers in order, delegates save/cancel/undo/redo to the document and undo manager, and creates detail view +/// models for individual variables. +@MainActor @Observable +final class ConfigVariableListViewModel: ConfigVariableListViewModeling { + /// The editor document managing the working copy. + private let document: EditorDocument + + /// The registered variables from the reader, keyed by configuration key. + private let registeredVariables: [ConfigKey: RegisteredConfigVariable] + + /// The reader's providers, queried in order for value resolution. + private let providers: [any ConfigProvider] + + /// The undo manager for the editor session. + private let undoManager: UndoManager + + + /// The current search text used to filter the variable list. + var searchText = "" + + + /// Creates a new list view model. + /// + /// - Parameters: + /// - document: The editor document managing the working copy. + /// - registeredVariables: The registered variables from the reader. + /// - providers: The reader's providers, queried in order for value resolution. + /// - undoManager: The undo manager for the editor session. + init( + document: EditorDocument, + registeredVariables: [ConfigKey: RegisteredConfigVariable], + providers: [any ConfigProvider], + undoManager: UndoManager + ) { + self.document = document + self.registeredVariables = registeredVariables + self.providers = providers + self.undoManager = undoManager + } + + + var variables: [VariableListItem] { + let items = registeredVariables.values.map { variable in + let (content, providerName) = resolvedValue(for: variable) + return VariableListItem( + key: variable.key, + displayName: variable.displayName ?? variable.key.description, + currentValue: content.displayString, + providerName: providerName, + hasOverride: document.hasOverride(forKey: variable.key), + editorControl: variable.editorControl + ) + } + + let filtered = + searchText.isEmpty + ? items + : items.filter { item in + item.displayName.localizedStandardContains(searchText) + || item.key.description.localizedStandardContains(searchText) + || item.currentValue.localizedStandardContains(searchText) + || item.providerName.localizedStandardContains(searchText) + } + + return filtered.sorted { (lhs, rhs) in + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + + var isDirty: Bool { + document.isDirty + } + + + var canUndo: Bool { + undoManager.canUndo + } + + + var canRedo: Bool { + undoManager.canRedo + } + + + func save() -> [RegisteredConfigVariable] { + let changedKeys = document.save() + return changedKeys.compactMap { registeredVariables[$0] } + } + + + func cancel() {} + + + func clearAllOverrides() { + document.removeAllOverrides() + } + + + func undo() { + undoManager.undo() + } + + + func redo() { + undoManager.redo() + } + + + func makeDetailViewModel(for key: ConfigKey) -> ConfigVariableDetailViewModel { + guard let variable = registeredVariables[key] else { + preconditionFailure("No registered variable for key '\(key)'") + } + + return ConfigVariableDetailViewModel( + variable: variable, + document: document, + providers: providers + ) + } +} + + +// MARK: - Value Resolution + +extension ConfigVariableListViewModel { + /// Resolves the current value and owning provider name for a registered variable. + /// + /// Checks the document's working copy first, then queries each provider in order. Falls back to the variable's + /// default content if no provider has a value. + private func resolvedValue(for variable: RegisteredConfigVariable) -> (ConfigContent, String) { + if let override = document.override(forKey: variable.key) { + return (override, EditorOverrideProvider.editorProviderName) + } + + let absoluteKey = AbsoluteConfigKey(variable.key) + let expectedType = variable.defaultContent.configType + + for provider in providers { + if let result = try? provider.value(forKey: absoluteKey, type: expectedType), let value = result.value { + return (value.content, provider.providerName) + } + } + + return ( + variable.defaultContent, + String(localized: "variableListItem.unknownProviderName", bundle: #bundle) + ) + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift new file mode 100644 index 0000000..9140e9b --- /dev/null +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift @@ -0,0 +1,58 @@ +// +// ConfigVariableListViewModeling.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +import Configuration +import Foundation + +/// The view model protocol for the configuration variable list view. +/// +/// `ConfigVariableListViewModeling` defines the interface that the list view uses to display and interact with +/// registered configuration variables. It provides a filtered and sorted list of variables, search functionality, +/// dirty tracking, undo/redo support, and the ability to save or cancel changes. +/// +/// Conforming types must also provide a factory method for creating detail view models for individual variables. +@MainActor +protocol ConfigVariableListViewModeling: Observable { + /// The type of detail view model created by ``makeDetailViewModel(for:)``. + associatedtype DetailViewModel: ConfigVariableDetailViewModeling + + /// The filtered and sorted list of variables to display. + var variables: [VariableListItem] { get } + + /// The current search text used to filter ``variables``. + var searchText: String { get set } + + /// Whether the editor document has unsaved changes. + var isDirty: Bool { get } + + /// Whether there is an undo action available. + var canUndo: Bool { get } + + /// Whether there is a redo action available. + var canRedo: Bool { get } + + /// Saves the current working copy and returns the registered variables whose overrides changed. + func save() -> [RegisteredConfigVariable] + + /// Cancels editing, discarding any unsaved changes. + func cancel() + + /// Removes all editor overrides from the working copy. + func clearAllOverrides() + + /// Undoes the most recent change. + func undo() + + /// Redoes the most recently undone change. + func redo() + + /// Creates a detail view model for the variable with the given key. + /// + /// - Parameter key: The configuration key of the variable to inspect. + /// - Returns: A detail view model for the variable. + func makeDetailViewModel(for key: ConfigKey) -> DetailViewModel +} diff --git a/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift b/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift new file mode 100644 index 0000000..a93e3c6 --- /dev/null +++ b/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift @@ -0,0 +1,18 @@ +// +// ProviderValue.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +/// A data structure representing a single provider's value for a configuration variable in the detail view. +/// +/// Each `ProviderValue` contains the provider's name and the value it has for the variable formatted as a display +/// string. +struct ProviderValue: Hashable, Sendable { + /// The name of the provider. + let providerName: String + + /// The provider's value for the variable, formatted as a display string. + let valueString: String +} diff --git a/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift b/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift new file mode 100644 index 0000000..64ba5db --- /dev/null +++ b/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift @@ -0,0 +1,35 @@ +// +// VariableListItem.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +import Configuration + +/// A data structure representing a single row in the configuration variable list. +/// +/// Each `VariableListItem` contains the information needed to display a configuration variable in the editor's list +/// view, including its display name, current value, the provider that owns the value, and whether an editor override +/// is active. +struct VariableListItem: Hashable, Sendable { + /// The configuration key for this variable. + let key: ConfigKey + + /// The human-readable display name for this variable. + /// + /// This is the variable's ``ConfigVariableMetadata/displayName`` if set, or the key's description otherwise. + let displayName: String + + /// The current resolved value formatted as a display string. + let currentValue: String + + /// The name of the provider that currently owns this variable's value. + let providerName: String + + /// Whether an editor override is active for this variable in the working copy. + let hasOverride: Bool + + /// The editor control to use when editing this variable's value. + let editorControl: EditorControl +} diff --git a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift index 91b1d68..9de290d 100644 --- a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift +++ b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift @@ -30,6 +30,41 @@ extension ConfigContent { } +// MARK: - Display String + +extension ConfigContent { + /// A human-readable string representation of this content's value. + /// + /// Numeric values are formatted using locale-aware formatters. Array values are formatted as narrow-width lists. + /// Byte values use the memory byte count style. + var displayString: String { + switch self { + case .bool(let value): + String(value) + case .int(let value): + value.formatted() + case .double(let value): + value.formatted() + case .string(let value): + value + case .bytes(let value): + value.count.formatted(.byteCount(style: .memory)) + case .boolArray(let value): + value.map(String.init).formatted(.list(type: .and, width: .narrow)) + case .intArray(let value): + value.map { $0.formatted() }.formatted(.list(type: .and, width: .narrow)) + case .doubleArray(let value): + value.map { $0.formatted() }.formatted(.list(type: .and, width: .narrow)) + case .stringArray(let value): + value.formatted(.list(type: .and, width: .narrow)) + case .byteChunkArray(let value): + value.map { $0.count.formatted(.byteCount(style: .memory)) } + .formatted(.list(type: .and, width: .narrow)) + } + } +} + + // MARK: - Codable extension ConfigContent: @retroactive Codable { diff --git a/Sources/DevConfiguration/Resources/Localizable.xcstrings b/Sources/DevConfiguration/Resources/Localizable.xcstrings index f3371f2..694c10d 100644 --- a/Sources/DevConfiguration/Resources/Localizable.xcstrings +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -20,6 +20,16 @@ } } } + }, + "variableListItem.unknownProviderName" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + } + } } }, "version" : "1.0" diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/ConfigVariableReaderEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/ConfigVariableReaderEditorTests.swift similarity index 100% rename from Tests/DevConfigurationTests/Unit Tests/Editor/ConfigVariableReaderEditorTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/ConfigVariableReaderEditorTests.swift diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/EditorDocumentTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift similarity index 100% rename from Tests/DevConfigurationTests/Unit Tests/Editor/EditorDocumentTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/EditorOverrideProviderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift similarity index 100% rename from Tests/DevConfigurationTests/Unit Tests/Editor/EditorOverrideProviderTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift new file mode 100644 index 0000000..eb49797 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift @@ -0,0 +1,366 @@ +// +// ConfigVariableDetailViewModelTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +@MainActor +struct ConfigVariableDetailViewModelTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - Properties + + @Test + mutating func keyReturnsVariableKey() { + // set up + let key = randomConfigKey() + let viewModel = makeDetailViewModel(key: key) + + // expect + #expect(viewModel.key == key) + } + + + @Test + mutating func displayNameReturnsMetadataDisplayName() { + // set up + let displayName = randomAlphanumericString() + var metadata = ConfigVariableMetadata() + metadata.displayName = displayName + + let viewModel = makeDetailViewModel(metadata: metadata) + + // expect + #expect(viewModel.displayName == displayName) + } + + + @Test + mutating func displayNameFallsBackToKeyDescription() { + // set up + let key = randomConfigKey() + let viewModel = makeDetailViewModel(key: key) + + // expect + #expect(viewModel.displayName == key.description) + } + + + @Test + mutating func metadataEntriesReturnsVariableMetadata() { + // set up + let displayName = randomAlphanumericString() + var metadata = ConfigVariableMetadata() + metadata.displayName = displayName + + let viewModel = makeDetailViewModel(metadata: metadata) + + // expect + #expect(viewModel.metadataEntries == metadata.displayTextEntries) + } + + + @Test + mutating func editorControlReturnsVariableEditorControl() { + // set up + let editorControl = randomElement(in: [EditorControl.toggle, .textField, .numberField, .decimalField, .none])! + let viewModel = makeDetailViewModel(editorControl: editorControl) + + // expect + #expect(viewModel.editorControl == editorControl) + } + + + // MARK: - Provider Values + + @Test + mutating func providerValuesQueriesProviders() throws { + // set up + let key = randomConfigKey() + let content = ConfigContent.string(randomAlphanumericString()) + let providerName = randomAlphanumericString() + let inMemoryProvider = InMemoryProvider( + name: providerName, + values: [AbsoluteConfigKey(key): ConfigValue(content, isSecret: false)] + ) + + let viewModel = makeDetailViewModel( + key: key, defaultContent: .string(""), editorControl: .textField, providers: [inMemoryProvider] + ) + + // exercise + let value = try #require(viewModel.providerValues.first) + + // expect + #expect(value.providerName == inMemoryProvider.providerName) + #expect(value.valueString == content.displayString) + } + + + @Test + mutating func providerValuesExcludesProvidersWithNoValue() { + // set up + let key = randomConfigKey() + let providerWithValue = InMemoryProvider( + name: randomAlphanumericString(), + values: [AbsoluteConfigKey(key): ConfigValue(.int(randomInt(in: -100 ... 100)), isSecret: false)] + ) + let providerWithoutValue = InMemoryProvider(name: randomAlphanumericString(), values: [:]) + + let viewModel = makeDetailViewModel( + key: key, + defaultContent: .int(0), + editorControl: .numberField, + providers: [providerWithValue, providerWithoutValue] + ) + + // expect + #expect(viewModel.providerValues.map(\.providerName) == [providerWithValue.providerName]) + } + + + // MARK: - Override Enable/Disable + + @Test + mutating func isOverrideEnabledReturnsFalseWhenNoOverride() { + // set up + let viewModel = makeDetailViewModel() + + // expect + #expect(!viewModel.isOverrideEnabled) + } + + + @Test + mutating func settingIsOverrideEnabledToTrueSetsDefaultContent() { + // set up + let key = randomConfigKey() + let defaultContent = ConfigContent.int(randomInt(in: -100 ... 100)) + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + + let viewModel = makeDetailViewModel(key: key, defaultContent: defaultContent, document: document) + + // exercise + viewModel.isOverrideEnabled = true + + // expect + #expect(viewModel.isOverrideEnabled) + #expect(document.override(forKey: key) == defaultContent) + } + + + @Test + mutating func settingIsOverrideEnabledToFalseRemovesOverride() { + // set up + let key = randomConfigKey() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(randomConfigContent(), forKey: key) + + let viewModel = makeDetailViewModel(key: key, document: document) + + // exercise + viewModel.isOverrideEnabled = false + + // expect + #expect(!viewModel.isOverrideEnabled) + #expect(!document.hasOverride(forKey: key)) + } + + + // MARK: - Override Text + + @Test + mutating func overrideTextReturnsEmptyStringWhenNoOverride() { + // set up + let viewModel = makeDetailViewModel() + + // expect + #expect(viewModel.overrideText == "") + } + + + @Test + mutating func overrideTextReturnsDisplayStringOfOverride() { + // set up + let key = randomConfigKey() + let value = randomAlphanumericString() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(.string(value), forKey: key) + + let viewModel = makeDetailViewModel(key: key, document: document) + + // expect + #expect(viewModel.overrideText == value) + } + + + @Test + mutating func settingOverrideTextParsesAndUpdatesDocument() { + // set up + let key = randomConfigKey() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(.int(0), forKey: key) + + let inputValue = randomInt(in: 1 ... 100) + let viewModel = makeDetailViewModel( + key: key, + defaultContent: .int(0), + editorControl: .numberField, + parse: { Int($0).map { .int($0) } }, + document: document + ) + + // exercise + viewModel.overrideText = String(inputValue) + + // expect + #expect(document.override(forKey: key) == .int(inputValue)) + } + + + @Test + mutating func settingOverrideTextWithInvalidInputDoesNotUpdate() { + // set up + let key = randomConfigKey() + let originalContent = ConfigContent.int(randomInt(in: -100 ... 100)) + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(originalContent, forKey: key) + + let viewModel = makeDetailViewModel( + key: key, + defaultContent: .int(0), + editorControl: .numberField, + parse: { Int($0).map { .int($0) } }, + document: document + ) + + // exercise + viewModel.overrideText = randomAlphanumericString() + + // expect + #expect(document.override(forKey: key) == originalContent) + } + + + // MARK: - Override Bool + + @Test + mutating func overrideBoolReturnsFalseWhenNoOverride() { + // set up + let viewModel = makeDetailViewModel() + + // expect + #expect(!viewModel.overrideBool) + } + + + @Test + mutating func overrideBoolReturnsValueFromDocument() { + // set up + let key = randomConfigKey() + let value = randomBool() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(.bool(value), forKey: key) + + let viewModel = makeDetailViewModel(key: key, document: document) + + // expect + #expect(viewModel.overrideBool == value) + } + + + @Test + mutating func settingOverrideBoolUpdatesDocument() { + // set up + let key = randomConfigKey() + let value = randomBool() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(.bool(!value), forKey: key) + + let viewModel = makeDetailViewModel(key: key, document: document) + + // exercise + viewModel.overrideBool = value + + // expect + #expect(document.override(forKey: key) == .bool(value)) + } + + + // MARK: - Secret Reveal + + @Test + mutating func isSecretRevealedDefaultsToFalse() { + // set up + let viewModel = makeDetailViewModel() + + // expect + #expect(!viewModel.isSecretRevealed) + } + + + @Test + mutating func isSecretRevealedCanBeToggled() { + // set up + let viewModel = makeDetailViewModel() + + // exercise + viewModel.isSecretRevealed = true + + // expect + #expect(viewModel.isSecretRevealed) + } +} + + +// MARK: - Helpers + +extension ConfigVariableDetailViewModelTests { + private mutating func makeDetailViewModel( + key: ConfigKey? = nil, + defaultContent: ConfigContent = .bool(false), + metadata: ConfigVariableMetadata = ConfigVariableMetadata(), + editorControl: EditorControl = .toggle, + parse: (@Sendable (String) -> ConfigContent?)? = nil, + document: EditorDocument? = nil, + providers: [any ConfigProvider] = [] + ) -> ConfigVariableDetailViewModel { + let effectiveKey = key ?? randomConfigKey() + let variable = RegisteredConfigVariable( + key: effectiveKey, + defaultContent: defaultContent, + secrecy: randomConfigVariableSecrecy(), + metadata: metadata, + editorControl: editorControl, + parse: parse + ) + + let effectiveDocument = document ?? EditorDocument(provider: EditorOverrideProvider()) + + return ConfigVariableDetailViewModel( + variable: variable, + document: effectiveDocument, + providers: providers + ) + } +} + +#endif diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift new file mode 100644 index 0000000..79465aa --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift @@ -0,0 +1,423 @@ +// +// ConfigVariableListViewModelTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +@MainActor +struct ConfigVariableListViewModelTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - Variables + + @Test + mutating func variablesSortedByDisplayName() { + // set up + let displayNames = Array(count: 3) { randomAlphanumericString() } + let sortedNames = displayNames.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + + var variables: [ConfigKey: RegisteredConfigVariable] = [:] + for name in displayNames { + let key = randomConfigKey() + var metadata = ConfigVariableMetadata() + metadata.displayName = name + variables[key] = randomRegisteredVariable(key: key, metadata: metadata) + } + + let viewModel = makeListViewModel(registeredVariables: variables) + + // exercise + let resultNames = viewModel.variables.map(\.displayName) + + // expect + #expect(resultNames == sortedNames) + } + + + @Test + mutating func variableUsesKeyDescriptionWhenNoDisplayName() throws { + // set up + let key = randomConfigKey() + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key: randomRegisteredVariable(key: key) + ] + + let viewModel = makeListViewModel(registeredVariables: variables) + + // exercise + let item = try #require(viewModel.variables.first) + + // expect + #expect(item.displayName == key.description) + } + + + @Test + mutating func variableShowsOverrideValueWhenOverrideExists() throws { + // set up + let key = randomConfigKey() + let overrideContent = ConfigContent.int(randomInt(in: -100 ... 100)) + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key: randomRegisteredVariable(key: key, defaultContent: .int(0), editorControl: .numberField) + ] + + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(overrideContent, forKey: key) + + let viewModel = makeListViewModel(document: document, registeredVariables: variables, providers: [provider]) + + // exercise + let item = try #require(viewModel.variables.first) + + // expect + #expect(item.currentValue == overrideContent.displayString) + #expect(item.providerName == EditorOverrideProvider.editorProviderName) + #expect(item.hasOverride) + } + + + @Test + mutating func variableShowsProviderValueWhenNoOverride() throws { + // set up + let key = randomConfigKey() + let content = ConfigContent.string(randomAlphanumericString()) + let inMemoryProvider = InMemoryProvider( + name: randomAlphanumericString(), + values: [AbsoluteConfigKey(key): ConfigValue(content, isSecret: false)] + ) + + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key: randomRegisteredVariable(key: key, defaultContent: .string(""), editorControl: .textField) + ] + + let viewModel = makeListViewModel(registeredVariables: variables, providers: [inMemoryProvider]) + + // exercise + let item = try #require(viewModel.variables.first) + + // expect + #expect(item.currentValue == content.displayString) + #expect(item.providerName == inMemoryProvider.providerName) + #expect(!item.hasOverride) + } + + + @Test + mutating func variableShowsDefaultWhenNoProviderHasValue() throws { + // set up + let key = randomConfigKey() + let defaultContent = ConfigContent.bool(randomBool()) + + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key: randomRegisteredVariable(key: key, defaultContent: defaultContent, editorControl: .toggle) + ] + + let viewModel = makeListViewModel(registeredVariables: variables) + + // exercise + let item = try #require(viewModel.variables.first) + + // expect + #expect(item.currentValue == defaultContent.displayString) + #expect(item.providerName != "variableListItem.unknownProviderName") + } + + + // MARK: - Search + + @Test + mutating func searchFiltersVariablesByDisplayName() { + // set up + let targetName = randomAlphanumericString() + let otherName = randomAlphanumericString() + + let key1 = randomConfigKey() + let key2 = randomConfigKey() + + var metadata1 = ConfigVariableMetadata() + metadata1.displayName = targetName + var metadata2 = ConfigVariableMetadata() + metadata2.displayName = otherName + + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key1: randomRegisteredVariable(key: key1, metadata: metadata1), + key2: randomRegisteredVariable(key: key2, metadata: metadata2), + ] + + let viewModel = makeListViewModel(registeredVariables: variables) + + // exercise + viewModel.searchText = targetName + + // expect + #expect(viewModel.variables.map(\.displayName) == [targetName]) + } + + + @Test + mutating func searchFiltersVariablesByCurrentValue() { + // set up + let key = randomConfigKey() + let searchableValue = randomAlphanumericString() + let content = ConfigContent.string(searchableValue) + let inMemoryProvider = InMemoryProvider( + values: [AbsoluteConfigKey(key): ConfigValue(content, isSecret: false)] + ) + + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key: randomRegisteredVariable(key: key, defaultContent: .string(""), editorControl: .textField) + ] + + let viewModel = makeListViewModel(registeredVariables: variables, providers: [inMemoryProvider]) + + // exercise + viewModel.searchText = searchableValue + + // expect + #expect(viewModel.variables.count == 1) + } + + + @Test + mutating func searchWithNoMatchReturnsEmpty() { + // set up + let key = randomConfigKey() + var metadata = ConfigVariableMetadata() + metadata.displayName = randomAlphanumericString() + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key: randomRegisteredVariable(key: key, metadata: metadata) + ] + + let viewModel = makeListViewModel(registeredVariables: variables) + + // exercise + viewModel.searchText = randomAlphanumericString() + + // expect + #expect(viewModel.variables.isEmpty) + } + + + @Test + mutating func emptySearchReturnsAllVariables() { + // set up + let key1 = randomConfigKey() + let key2 = randomConfigKey() + let variables: [ConfigKey: RegisteredConfigVariable] = [ + key1: randomRegisteredVariable(key: key1), + key2: randomRegisteredVariable(key: key2), + ] + + let viewModel = makeListViewModel(registeredVariables: variables) + + // exercise + viewModel.searchText = "" + + // expect + #expect(viewModel.variables.count == 2) + } + + + // MARK: - Dirty Tracking + + @Test + mutating func isDirtyDelegatesToDocument() { + // set up + let key = randomConfigKey() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + let viewModel = makeListViewModel(document: document) + + #expect(!viewModel.isDirty) + + // exercise + document.setOverride(randomConfigContent(), forKey: key) + + // expect + #expect(viewModel.isDirty) + } + + + // MARK: - Save + + @Test + mutating func saveReturnsChangedRegisteredVariables() { + // set up + let key1 = randomConfigKey() + let key2 = randomConfigKey() + + let variable1 = randomRegisteredVariable(key: key1) + let variable2 = randomRegisteredVariable(key: key2) + + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(randomConfigContent(), forKey: key1) + + let viewModel = makeListViewModel( + document: document, + registeredVariables: [key1: variable1, key2: variable2], + providers: [provider] + ) + + // exercise + let changed = viewModel.save() + + // expect + #expect(changed.map(\.key) == [key1]) + } + + + // MARK: - Clear All Overrides + + @Test + mutating func clearAllOverridesDelegatesToDocument() { + // set up + let key = randomConfigKey() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(randomConfigContent(), forKey: key) + + let viewModel = makeListViewModel(document: document) + + // exercise + viewModel.clearAllOverrides() + + // expect + #expect(document.workingCopy.isEmpty) + } + + + // MARK: - Undo/Redo + + @Test + mutating func undoDelegatesToUndoManager() { + // set up + let key = randomConfigKey() + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider, undoManager: undoManager) + document.setOverride(randomConfigContent(), forKey: key) + + let viewModel = makeListViewModel(document: document, undoManager: undoManager) + #expect(viewModel.canUndo) + + // exercise + viewModel.undo() + + // expect + #expect(!document.hasOverride(forKey: key)) + } + + + @Test + mutating func redoDelegatesToUndoManager() { + // set up + let key = randomConfigKey() + let content = randomConfigContent() + let undoManager = UndoManager() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider, undoManager: undoManager) + document.setOverride(content, forKey: key) + undoManager.undo() + + let viewModel = makeListViewModel(document: document, undoManager: undoManager) + #expect(viewModel.canRedo) + + // exercise + viewModel.redo() + + // expect + #expect(document.override(forKey: key) == content) + } + + + // MARK: - Cancel + + @Test + mutating func cancelDoesNotModifyDocument() { + // set up + let key = randomConfigKey() + let content = randomConfigContent() + let provider = EditorOverrideProvider() + let document = EditorDocument(provider: provider) + document.setOverride(content, forKey: key) + + let viewModel = makeListViewModel(document: document) + + // exercise + viewModel.cancel() + + // expect + #expect(document.override(forKey: key) == content) + #expect(document.isDirty) + } + + + // MARK: - Detail View Model + + @Test + mutating func makeDetailViewModelReturnsViewModel() { + // set up + let key = randomConfigKey() + let variable = randomRegisteredVariable(key: key) + + let viewModel = makeListViewModel(registeredVariables: [key: variable]) + + // exercise + let detailVM = viewModel.makeDetailViewModel(for: key) + + // expect + #expect(detailVM.key == key) + } +} + + +// MARK: - Helpers + +extension ConfigVariableListViewModelTests { + private func makeListViewModel( + document: EditorDocument? = nil, + registeredVariables: [ConfigKey: RegisteredConfigVariable] = [:], + providers: [any ConfigProvider] = [], + undoManager: UndoManager = UndoManager() + ) -> ConfigVariableListViewModel { + let effectiveDocument = document ?? EditorDocument(provider: EditorOverrideProvider()) + return ConfigVariableListViewModel( + document: effectiveDocument, + registeredVariables: registeredVariables, + providers: providers, + undoManager: undoManager + ) + } + + + private mutating func randomRegisteredVariable( + key: ConfigKey? = nil, + defaultContent: ConfigContent = .bool(false), + metadata: ConfigVariableMetadata = ConfigVariableMetadata(), + editorControl: EditorControl = .toggle + ) -> RegisteredConfigVariable { + RegisteredConfigVariable( + key: key ?? randomConfigKey(), + defaultContent: defaultContent, + secrecy: randomConfigVariableSecrecy(), + metadata: metadata, + editorControl: editorControl, + parse: nil + ) + } +} + +#endif diff --git a/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContentDisplayStringTests.swift b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContentDisplayStringTests.swift new file mode 100644 index 0000000..cb262b8 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContentDisplayStringTests.swift @@ -0,0 +1,125 @@ +// +// ConfigContentDisplayStringTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +struct ConfigContentDisplayStringTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test(arguments: [false, true]) + mutating func boolDisplayString(value: Bool) { + #expect(ConfigContent.bool(value).displayString == "\(value)") + } + + + @Test + mutating func intDisplayString() { + let value = randomInt(in: -100 ... 100) + #expect(ConfigContent.int(value).displayString == value.formatted()) + } + + + @Test + mutating func doubleDisplayString() { + let value = randomFloat64(in: -100 ... 100) + #expect(ConfigContent.double(value).displayString == value.formatted()) + } + + + @Test + mutating func stringDisplayString() { + let value = randomAlphanumericString() + #expect(ConfigContent.string(value).displayString == value) + } + + + @Test + mutating func bytesDisplayString() { + let bytes = Array(count: randomInt(in: 1 ... 10)) { random(UInt8.self, in: .min ... .max) } + #expect(ConfigContent.bytes(bytes).displayString == bytes.count.formatted(.byteCount(style: .memory))) + } + + + @Test + mutating func boolArrayDisplayString() { + let value = Array(count: randomInt(in: 2 ... 5)) { randomBool() } + let expected = value.map(String.init).formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.boolArray(value).displayString == expected) + } + + + @Test + mutating func intArrayDisplayString() { + let value = Array(count: randomInt(in: 2 ... 5)) { randomInt(in: -100 ... 100) } + let expected = value.map { $0.formatted() }.formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.intArray(value).displayString == expected) + } + + + @Test + mutating func doubleArrayDisplayString() { + let value = Array(count: randomInt(in: 2 ... 5)) { randomFloat64(in: -100 ... 100) } + let expected = value.map { $0.formatted() }.formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.doubleArray(value).displayString == expected) + } + + + @Test + mutating func stringArrayDisplayString() { + let value = Array(count: randomInt(in: 2 ... 5)) { randomAlphanumericString() } + let expected = value.formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.stringArray(value).displayString == expected) + } + + + @Test + mutating func byteChunkArrayDisplayString() { + let value = Array(count: randomInt(in: 2 ... 5)) { + Array(count: randomInt(in: 1 ... 10)) { random(UInt8.self, in: .min ... .max) } + } + let expected = value.map { $0.count.formatted(.byteCount(style: .memory)) } + .formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.byteChunkArray(value).displayString == expected) + } + + + @Test + func emptyBoolArrayDisplayString() { + let stringArray: [String] = [] + let expected = stringArray.formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.boolArray([]).displayString == expected) + } + + + @Test + func emptyIntArrayDisplayString() { + let stringArray: [String] = [] + let expected = stringArray.formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.intArray([]).displayString == expected) + } + + + @Test + func emptyStringArrayDisplayString() { + let stringArray: [String] = [] + let expected = stringArray.formatted(.list(type: .and, width: .narrow)) + #expect(ConfigContent.stringArray([]).displayString == expected) + } + + + @Test + func emptyBytesDisplayString() { + let expected = 0.formatted(.byteCount(style: .memory)) + #expect(ConfigContent.bytes([]).displayString == expected) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift b/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift index 5031d89..75c0a1f 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Metadata/RequiresRelaunchMetadataKeyTests.swift @@ -16,13 +16,13 @@ struct RequiresRelaunchMetadataKeyTests { var metadata = ConfigVariableMetadata() // expect that unset requiresRelaunch returns false - #expect(metadata.requiresRelaunch == false) + #expect(!metadata.requiresRelaunch) // exercise metadata.requiresRelaunch = true // expect that the value is stored and retrieved correctly - #expect(metadata.requiresRelaunch == true) + #expect(metadata.requiresRelaunch) } From 6c7a53a0cf6a986eceadda2c596013891dd1f709 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Sun, 8 Mar 2026 03:12:43 -0400 Subject: [PATCH 5/9] Add SwiftUI views for editor UI with provider badge and public entry point - Add ProviderBadge view and providerColor(at:) function for deterministic provider color assignment from a fixed palette - Add ConfigVariableDetailView with header, override controls (toggle, text field, number/decimal fields), provider values with tap-to-reveal for secrets, and metadata sections - Add ConfigVariableEditorView with searchable list, navigation to detail views, toolbar with save/cancel and overflow menu for undo, redo, and clear overrides, and confirmation alerts - Add ConfigVariableEditor as the public entry point, creating the view model layer from a ConfigVariableReader - Replace secrecy property on RegisteredConfigVariable with a resolved isSecret boolean, computed at registration time using ConfigVariableReader.isSecret(_:) - Add isSecret to ConfigVariableDetailViewModeling protocol and concrete implementation - Add providerIndex to VariableListItem and ProviderValue for correct provider color assignment in views - Add 21 localized strings for editor and detail view labels, alerts, and section headers --- .../Core/ConfigVariableReader.swift | 2 +- .../Core/RegisteredConfigVariable.swift | 16 +- .../Editor/Data Models/EditorDocument.swift | 1 - .../Data Models/EditorOverrideProvider.swift | 2 +- .../ConfigVariableDetailViewModel.swift | 8 +- .../ConfigVariableDetailViewModeling.swift | 3 + .../ConfigVariableListViewModel.swift | 17 +- .../Editor/View Models/ProviderValue.swift | 3 + .../Editor/View Models/VariableListItem.swift | 3 + .../Views/ConfigVariableDetailView.swift | 228 ++++++++++++++ .../Editor/Views/ConfigVariableEditor.swift | 75 +++++ .../Views/ConfigVariableEditorView.swift | 280 ++++++++++++++++++ .../Editor/Views/ProviderBadge.swift | 58 ++++ .../Resources/Localizable.xcstrings | 220 ++++++++++++++ ...onfigVariableReaderRegistrationTests.swift | 19 +- .../Core/RegisteredConfigVariableTests.swift | 4 +- .../EditorOverrideProviderTests.swift | 6 +- .../ConfigVariableDetailViewModelTests.swift | 13 +- .../ConfigVariableListViewModelTests.swift | 2 +- 19 files changed, 925 insertions(+), 35 deletions(-) create mode 100644 Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift create mode 100644 Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift create mode 100644 Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift create mode 100644 Sources/DevConfiguration/Editor/Views/ProviderBadge.swift diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index 3c2c07a..20061cb 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -171,7 +171,7 @@ extension ConfigVariableReader { state.registeredVariables[variable.key] = RegisteredConfigVariable( key: variable.key, defaultContent: defaultContent, - secrecy: variable.secrecy, + isSecret: isSecret(variable), metadata: variable.metadata, editorControl: variable.content.editorControl, parse: variable.content.parse diff --git a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift index b904292..2649cbb 100644 --- a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift +++ b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift @@ -13,21 +13,23 @@ import Configuration /// 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 { +public struct RegisteredConfigVariable: Sendable { /// The configuration key used to look up this variable's value. - let key: ConfigKey + public let key: ConfigKey /// The variable's default value represented as a ``ConfigContent``. - let defaultContent: ConfigContent + public let defaultContent: ConfigContent - /// Whether this value should be treated as a secret. - let secrecy: ConfigVariableSecrecy + /// Whether this variable's value should be treated as secret. + /// + /// This is resolved at registration time from the variable's ``ConfigVariableSecrecy`` setting and content type. + public let isSecret: Bool /// The configuration variable's metadata. - let metadata: ConfigVariableMetadata + public let metadata: ConfigVariableMetadata /// The editor control to use when editing this variable's value in the editor UI. - let editorControl: EditorControl + public let editorControl: EditorControl /// Parses a raw string from the editor UI into a ``ConfigContent`` value. /// diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift index 3420caa..754b653 100644 --- a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift @@ -8,7 +8,6 @@ import Configuration import Foundation - /// A working copy model that tracks staged editor overrides with undo/redo support. /// /// `EditorDocument` maintains a working copy of configuration overrides separate from the committed state in diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift index 1a2a1a8..487aae3 100644 --- a/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift @@ -47,7 +47,7 @@ final class EditorOverrideProvider: Sendable { /// The name used to identify this provider. - static let editorProviderName = "Editor" + static let editorProviderName = String(localized: "editorOverrideProvider.name", bundle: #bundle) /// The UserDefaults suite name used for persistence. static let suiteName = "devkit.DevConfiguration" diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift index 13a990b..383e778 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift @@ -30,6 +30,11 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { var isSecretRevealed = false + var isSecret: Bool { + variable.isSecret + } + + /// Creates a new detail view model. /// /// - Parameters: @@ -66,7 +71,7 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { let absoluteKey = AbsoluteConfigKey(variable.key) let expectedType = variable.defaultContent.configType - return providers.compactMap { provider in + return providers.enumerated().compactMap { index, provider in guard let result = try? provider.value(forKey: absoluteKey, type: expectedType), let configValue = result.value @@ -76,6 +81,7 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { return ProviderValue( providerName: provider.providerName, + providerIndex: index, valueString: configValue.content.displayString ) } diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift index fc46ce2..e8fe135 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift @@ -45,6 +45,9 @@ protocol ConfigVariableDetailViewModeling: Observable { /// The editor control to use when editing this variable's value. var editorControl: EditorControl { get } + /// Whether this variable's value is secret. + var isSecret: Bool { get } + /// Whether the variable's secret value is currently revealed. var isSecretRevealed: Bool { get set } } diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift b/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift index ad1d954..5064f99 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift +++ b/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift @@ -57,12 +57,13 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { var variables: [VariableListItem] { let items = registeredVariables.values.map { variable in - let (content, providerName) = resolvedValue(for: variable) + let (content, providerName, providerIndex) = resolvedValue(for: variable) return VariableListItem( key: variable.key, displayName: variable.displayName ?? variable.key.description, currentValue: content.displayString, providerName: providerName, + providerIndex: providerIndex, hasOverride: document.hasOverride(forKey: variable.key), editorControl: variable.editorControl ) @@ -140,27 +141,29 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { // MARK: - Value Resolution extension ConfigVariableListViewModel { - /// Resolves the current value and owning provider name for a registered variable. + /// Resolves the current value, owning provider name, and provider index for a registered variable. /// /// Checks the document's working copy first, then queries each provider in order. Falls back to the variable's /// default content if no provider has a value. - private func resolvedValue(for variable: RegisteredConfigVariable) -> (ConfigContent, String) { + private func resolvedValue(for variable: RegisteredConfigVariable) -> (ConfigContent, String, Int) { if let override = document.override(forKey: variable.key) { - return (override, EditorOverrideProvider.editorProviderName) + let editorIndex = providers.firstIndex { $0.providerName == EditorOverrideProvider.editorProviderName } ?? 0 + return (override, EditorOverrideProvider.editorProviderName, editorIndex) } let absoluteKey = AbsoluteConfigKey(variable.key) let expectedType = variable.defaultContent.configType - for provider in providers { + for (index, provider) in providers.enumerated() { if let result = try? provider.value(forKey: absoluteKey, type: expectedType), let value = result.value { - return (value.content, provider.providerName) + return (value.content, provider.providerName, index) } } return ( variable.defaultContent, - String(localized: "variableListItem.unknownProviderName", bundle: #bundle) + String(localized: "variableListItem.unknownProviderName", bundle: #bundle), + providers.count ) } } diff --git a/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift b/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift index a93e3c6..74d3177 100644 --- a/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift +++ b/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift @@ -13,6 +13,9 @@ struct ProviderValue: Hashable, Sendable { /// The name of the provider. let providerName: String + /// The index of the provider in the reader's provider list, used for color assignment. + let providerIndex: Int + /// The provider's value for the variable, formatted as a display string. let valueString: String } diff --git a/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift b/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift index 64ba5db..fbaa2b0 100644 --- a/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift +++ b/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift @@ -27,6 +27,9 @@ struct VariableListItem: Hashable, Sendable { /// The name of the provider that currently owns this variable's value. let providerName: String + /// The index of the provider in the reader's provider list, used for color assignment. + let providerIndex: Int + /// Whether an editor override is active for this variable in the working copy. let hasOverride: Bool diff --git a/Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift b/Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift new file mode 100644 index 0000000..0913e6d --- /dev/null +++ b/Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift @@ -0,0 +1,228 @@ +// +// ConfigVariableDetailView.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import SwiftUI + +/// The detail view for a single configuration variable in the editor. +/// +/// `ConfigVariableDetailView` displays a variable's metadata, the value from each provider, and override controls. +/// It is generic on its view model protocol, allowing tests to inject mock view models. +struct ConfigVariableDetailView: View { + @State var viewModel: ViewModel + + + var body: some View { + Form { + headerSection + overrideSection + providerValuesSection + metadataSection + } + .navigationTitle(viewModel.displayName) + } +} + + +// MARK: - Sections + +extension ConfigVariableDetailView { + private var headerSection: some View { + Section { + Text(viewModel.key.description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + + @ViewBuilder + private var overrideSection: some View { + if viewModel.editorControl != .none { + Section(String(localized: "detailView.overrideSection.header", bundle: #bundle)) { + Toggle( + String(localized: "detailView.overrideSection.enableToggle", bundle: #bundle), + isOn: $viewModel.isOverrideEnabled + ) + + if viewModel.isOverrideEnabled { + overrideControl + } + } + } + } + + + @ViewBuilder + private var overrideControl: some View { + if viewModel.editorControl == .toggle { + Toggle( + String(localized: "detailView.overrideSection.valueToggle", bundle: #bundle), + isOn: $viewModel.overrideBool + ) + } else { + TextField( + String(localized: "detailView.overrideSection.valueTextField", bundle: #bundle), + text: $viewModel.overrideText + ) + #if os(iOS) || os(visionOS) + .keyboardType(keyboardType) + #endif + } + } + + + #if os(iOS) || os(visionOS) + private var keyboardType: UIKeyboardType { + if viewModel.editorControl == .numberField { + .numberPad + } else if viewModel.editorControl == .decimalField { + .decimalPad + } else { + .default + } + } + #endif + + + private var providerValuesSection: some View { + Section(String(localized: "detailView.providerValuesSection.header", bundle: #bundle)) { + if viewModel.isSecret && !viewModel.isSecretRevealed { + Button(String(localized: "detailView.providerValuesSection.tapToReveal", bundle: #bundle)) { + viewModel.isSecretRevealed = true + } + } else { + ForEach(viewModel.providerValues, id: \.self) { providerValue in + LabeledContent { + ProviderBadge( + providerName: providerValue.providerName, + color: providerColor(at: providerValue.providerIndex) + ) + } label: { + Text(providerValue.valueString) + } + } + } + } + } + + + @ViewBuilder + private var metadataSection: some View { + let entries = viewModel.metadataEntries + if !entries.isEmpty { + Section(String(localized: "detailView.metadataSection.header", bundle: #bundle)) { + ForEach(entries, id: \.key) { entry in + LabeledContent(entry.key, value: entry.value ?? "—") + } + } + } + } +} + + +// MARK: - Preview Support + +@MainActor @Observable +private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { + let key: ConfigKey + let displayName: String + let metadataEntries: [ConfigVariableMetadata.DisplayText] + let providerValues: [ProviderValue] + let isSecret: Bool + let editorControl: EditorControl + + var isOverrideEnabled = false + var overrideText = "" + var overrideBool = false + var isSecretRevealed = false + + + init( + key: ConfigKey, + displayName: String, + metadataEntries: [ConfigVariableMetadata.DisplayText] = [], + providerValues: [ProviderValue] = [], + isSecret: Bool = false, + editorControl: EditorControl = .textField, + isOverrideEnabled: Bool = false, + overrideText: String = "", + overrideBool: Bool = false + ) { + self.key = key + self.displayName = displayName + self.metadataEntries = metadataEntries + self.providerValues = providerValues + self.isSecret = isSecret + self.editorControl = editorControl + self.isOverrideEnabled = isOverrideEnabled + self.overrideText = overrideText + self.overrideBool = overrideBool + } +} + + +#Preview("Text Field") { + NavigationStack { + ConfigVariableDetailView( + viewModel: PreviewDetailViewModel( + key: "feature.api_endpoint", + displayName: "API Endpoint", + metadataEntries: [ + .init(key: "Display Name", value: "API Endpoint"), + .init(key: "Requires Relaunch", value: "Yes"), + ], + providerValues: [ + ProviderValue(providerName: "Remote", providerIndex: 1, valueString: "https://api.example.com"), + ProviderValue(providerName: "Default", providerIndex: 2, valueString: "https://localhost:8080"), + ], + editorControl: .textField, + isOverrideEnabled: true, + overrideText: "https://staging.example.com" + ) + ) + } +} + + +#Preview("Toggle") { + NavigationStack { + ConfigVariableDetailView( + viewModel: PreviewDetailViewModel( + key: "feature.dark_mode", + displayName: "Dark Mode", + providerValues: [ + ProviderValue(providerName: "Remote", providerIndex: 1, valueString: "false") + ], + editorControl: .toggle, + isOverrideEnabled: true, + overrideBool: true + ) + ) + } +} + + +#Preview("Secret") { + NavigationStack { + ConfigVariableDetailView( + viewModel: PreviewDetailViewModel( + key: "service.api_key", + displayName: "API Key", + providerValues: [ + ProviderValue(providerName: "Remote", providerIndex: 1, valueString: "sk-1234567890abcdef") + ], + isSecret: true, + editorControl: .textField + ) + ) + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift b/Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift new file mode 100644 index 0000000..d07128d --- /dev/null +++ b/Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift @@ -0,0 +1,75 @@ +// +// ConfigVariableEditor.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import SwiftUI + +/// A SwiftUI view that presents the configuration variable editor. +/// +/// `ConfigVariableEditor` is the public entry point for the editor UI. It is initialized with a +/// ``ConfigVariableReader`` that has editor support enabled and an `onSave` closure that receives the registered +/// variables whose overrides changed. +/// +/// The consumer is responsible for presentation (sheet, full-screen cover, navigation push, etc.). +/// +/// .sheet(isPresented: $isEditorPresented) { +/// ConfigVariableEditor(reader: reader) { changedVariables in +/// // Handle changed variables +/// } onCancel: { +/// isEditorPresented = false +/// } +/// } +public struct ConfigVariableEditor: View { + /// The list view model created from the reader. + @State private var viewModel: ConfigVariableListViewModel + + /// The closure to call with the changed variables when the user saves. + private let onSave: ([RegisteredConfigVariable]) -> Void + + /// The closure to call when the user cancels editing. + private let onCancel: () -> Void + + + /// Creates a new configuration variable editor. + /// + /// - Parameters: + /// - reader: The configuration variable reader. Must have been created with `isEditorEnabled` set to `true`. + /// - onSave: A closure called with the registered variables whose overrides changed when the user saves. + /// - onCancel: A closure called when the user cancels editing. + public init( + reader: ConfigVariableReader, + onSave: @escaping ([RegisteredConfigVariable]) -> Void, + onCancel: @escaping () -> Void + ) { + guard let editorOverrideProvider = reader.editorOverrideProvider else { + preconditionFailure( + "ConfigVariableEditor requires a ConfigVariableReader with isEditorEnabled set to true" + ) + } + + let undoManager = UndoManager() + let document = EditorDocument(provider: editorOverrideProvider, undoManager: undoManager) + self._viewModel = State( + initialValue: ConfigVariableListViewModel( + document: document, + registeredVariables: reader.registeredVariables, + providers: reader.providers, + undoManager: undoManager + ) + ) + self.onSave = onSave + self.onCancel = onCancel + } + + + public var body: some View { + ConfigVariableEditorView(viewModel: viewModel, onSave: onSave, onCancel: onCancel) + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift b/Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift new file mode 100644 index 0000000..88ea5c8 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift @@ -0,0 +1,280 @@ +// +// ConfigVariableEditorView.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import SwiftUI + +/// The list view for the configuration variable editor. +/// +/// `ConfigVariableEditorView` displays all registered configuration variables in a searchable, sorted list. Each row +/// shows the variable's display name, key, current value, and a provider badge. Tapping a row navigates to the +/// variable's detail view. +/// +/// The toolbar provides Cancel, Save, and an overflow menu with Undo, Redo, and Clear Editor Overrides actions. +struct ConfigVariableEditorView: View { + @State var viewModel: ViewModel + + /// The closure to call with the changed variables when the user saves. + var onSave: ([RegisteredConfigVariable]) -> Void + + /// The closure to call when the user cancels editing. + var onCancel: () -> Void + + @State private var isShowingDiscardAlert = false + @State private var isShowingClearAlert = false + + + var body: some View { + NavigationStack { + List(viewModel.variables, id: \.key) { item in + NavigationLink(value: item.key) { + VariableRow(item: item) + } + } + .navigationTitle(String(localized: "editorView.navigationTitle", bundle: #bundle)) + .navigationDestination(for: ConfigKey.self) { key in + ConfigVariableDetailView(viewModel: viewModel.makeDetailViewModel(for: key)) + } + .searchable(text: $viewModel.searchText) + .toolbar { toolbarContent } + .alert( + String(localized: "editorView.discardAlert.title", bundle: #bundle), + isPresented: $isShowingDiscardAlert + ) { + Button( + String(localized: "editorView.discardAlert.discardButton", bundle: #bundle), + role: .destructive + ) { + viewModel.cancel() + onCancel() + } + + Button( + String(localized: "editorView.discardAlert.keepEditingButton", bundle: #bundle), + role: .cancel + ) {} + } message: { + Text(String(localized: "editorView.discardAlert.message", bundle: #bundle)) + } + .alert( + String(localized: "editorView.clearAlert.title", bundle: #bundle), + isPresented: $isShowingClearAlert + ) { + Button( + String(localized: "editorView.clearAlert.clearButton", bundle: #bundle), + role: .destructive + ) { + viewModel.clearAllOverrides() + } + + Button( + String(localized: "editorView.discardAlert.keepEditingButton", bundle: #bundle), + role: .cancel + ) {} + } message: { + Text(String(localized: "editorView.clearAlert.message", bundle: #bundle)) + } + } + } +} + + +// MARK: - Toolbar + +extension ConfigVariableEditorView { + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + if viewModel.isDirty { + isShowingDiscardAlert = true + } else { + viewModel.cancel() + onCancel() + } + } label: { + Label(String(localized: "editorView.cancelButton", bundle: #bundle), systemImage: "xmark") + } + } + + ToolbarItem(placement: .confirmationAction) { + Button { + let changedVariables = viewModel.save() + onSave(changedVariables) + } label: { + Label(String(localized: "editorView.saveButton", bundle: #bundle), systemImage: "checkmark") + } + } + + ToolbarItem(placement: .primaryAction) { + Menu { + Button(String(localized: "editorView.undoButton", bundle: #bundle)) { + viewModel.undo() + } + .disabled(!viewModel.canUndo) + + Button(String(localized: "editorView.redoButton", bundle: #bundle)) { + viewModel.redo() + } + .disabled(!viewModel.canRedo) + + Divider() + + Button(String(localized: "editorView.clearOverridesButton", bundle: #bundle), role: .destructive) { + isShowingClearAlert = true + } + } label: { + Label( + String(localized: "editorView.overflowMenu.label", bundle: #bundle), + systemImage: "ellipsis.circle" + ) + } + } + } +} + + +// MARK: - Variable Row + +extension ConfigVariableEditorView { + /// A single row in the configuration variable list. + private struct VariableRow: View { + let item: VariableListItem + + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .font(.body) + + Text(item.key.description) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text(item.currentValue) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + ProviderBadge( + providerName: item.providerName, + color: providerColor(at: item.providerIndex) + ) + } + } + .padding(.vertical, 2) + } + } +} + + +// MARK: - Preview Support + +@MainActor @Observable +private final class PreviewListViewModel: ConfigVariableListViewModeling { + var variables: [VariableListItem] + var searchText = "" + var isDirty: Bool + var canUndo = false + var canRedo = false + + + init(variables: [VariableListItem], isDirty: Bool = false) { + self.variables = variables + self.isDirty = isDirty + } + + + func save() -> [RegisteredConfigVariable] { [] } + func cancel() {} + func clearAllOverrides() {} + func undo() {} + func redo() {} + + + func makeDetailViewModel(for key: ConfigKey) -> PreviewEditorDetailViewModel { + PreviewEditorDetailViewModel(key: key, displayName: key.description) + } +} + + +@MainActor @Observable +private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeling { + let key: ConfigKey + let displayName: String + let metadataEntries: [ConfigVariableMetadata.DisplayText] = [] + let providerValues: [ProviderValue] = [] + let isSecret = false + let editorControl: EditorControl = .none + + var isOverrideEnabled = false + var overrideText = "" + var overrideBool = false + var isSecretRevealed = false + + + init(key: ConfigKey, displayName: String) { + self.key = key + self.displayName = displayName + } +} + + +#Preview { + ConfigVariableEditorView( + viewModel: PreviewListViewModel( + variables: [ + VariableListItem( + key: "feature.dark_mode", + displayName: "Dark Mode", + currentValue: "true", + providerName: "Editor", + providerIndex: 0, + hasOverride: true, + editorControl: .toggle + ), + VariableListItem( + key: "feature.api_endpoint", + displayName: "API Endpoint", + currentValue: "https://api.example.com", + providerName: "Remote", + providerIndex: 1, + hasOverride: false, + editorControl: .textField + ), + VariableListItem( + key: "feature.max_retries", + displayName: "Max Retries", + currentValue: "3", + providerName: "Default", + providerIndex: 2, + hasOverride: false, + editorControl: .numberField + ), + VariableListItem( + key: "feature.timeout", + displayName: "Timeout", + currentValue: "30.0", + providerName: "Remote", + providerIndex: 1, + hasOverride: false, + editorControl: .decimalField + ), + ], + isDirty: true + ), + onSave: { _ in }, + onCancel: {} + ) +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Views/ProviderBadge.swift b/Sources/DevConfiguration/Editor/Views/ProviderBadge.swift new file mode 100644 index 0000000..66af2fd --- /dev/null +++ b/Sources/DevConfiguration/Editor/Views/ProviderBadge.swift @@ -0,0 +1,58 @@ +// +// ProviderBadge.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import SwiftUI + +/// A small colored badge that displays a configuration provider's name. +/// +/// `ProviderBadge` is used in the editor's list and detail views to visually identify which provider owns a +/// configuration value. The badge color is assigned deterministically based on the provider's index in the reader's +/// provider list. +struct ProviderBadge: View { + /// The name of the provider to display. + let providerName: String + + /// The color to use for the badge. + let color: Color + + + var body: some View { + Text(providerName) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(.white) + .background(color, in: .capsule) + } +} + + +/// Returns a color for the provider at the given index. +/// +/// Colors are assigned from a fixed palette and wrap around if there are more providers than colors. +/// +/// - Parameter index: The provider's index in the reader's provider list. +/// - Returns: A color for the provider. +func providerColor(at index: Int) -> Color { + let palette: [Color] = [.blue, .green, .yellow, .orange, .red, .indigo, .purple, .mint, .cyan] + return palette[index % palette.count] +} + + +#Preview { + VStack(spacing: 8) { + ForEach(Array(0 ..< 9), id: \.self) { index in + ProviderBadge(providerName: "Provider \(index)", color: providerColor(at: index)) + } + } + .padding() +} + +#endif diff --git a/Sources/DevConfiguration/Resources/Localizable.xcstrings b/Sources/DevConfiguration/Resources/Localizable.xcstrings index 694c10d..0c4e542 100644 --- a/Sources/DevConfiguration/Resources/Localizable.xcstrings +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -11,6 +11,16 @@ } } }, + "editorOverrideProvider.name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editor" + } + } + } + }, "requiresRelaunchMetadata.keyDisplayText" : { "localizations" : { "en" : { @@ -21,6 +31,216 @@ } } }, + "editorView.cancelButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "editorView.clearAlert.clearButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" + } + } + } + }, + "editorView.clearAlert.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This will remove all editor overrides. You can undo this action." + } + } + } + }, + "editorView.clearAlert.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear All Overrides?" + } + } + } + }, + "editorView.clearOverridesButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear Editor Overrides" + } + } + } + }, + "editorView.discardAlert.discardButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard" + } + } + } + }, + "editorView.discardAlert.keepEditingButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep Editing" + } + } + } + }, + "editorView.discardAlert.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You have unsaved changes that will be lost." + } + } + } + }, + "editorView.discardAlert.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard Changes?" + } + } + } + }, + "editorView.navigationTitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration Editor" + } + } + } + }, + "editorView.overflowMenu.label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More" + } + } + } + }, + "editorView.redoButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redo" + } + } + } + }, + "editorView.saveButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "editorView.undoButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Undo" + } + } + } + }, + "detailView.metadataSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metadata" + } + } + } + }, + "detailView.overrideSection.enableToggle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Override" + } + } + } + }, + "detailView.overrideSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + } + } + }, + "detailView.overrideSection.valueTextField" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + } + } + }, + "detailView.overrideSection.valueToggle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + } + } + }, + "detailView.providerValuesSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provider Values" + } + } + } + }, + "detailView.providerValuesSection.tapToReveal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap to Reveal" + } + } + } + }, "variableListItem.unknownProviderName" : { "localizations" : { "en" : { diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift index 61883b9..b731469 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift @@ -18,7 +18,7 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { @Test - mutating func registerStoresVariableWithCorrectProperties() { + mutating func registerStoresVariableWithCorrectProperties() throws { // set up let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) @@ -35,15 +35,14 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { reader.register(variable) // expect - let registered = reader.registeredVariables[key] - #expect(registered != nil) - #expect(registered?.key == key) - #expect(registered?.defaultContent == .int(defaultValue)) - #expect(registered?.secrecy == secrecy) - #expect(registered?.testTeam == metadata[TestTeamMetadataKey.self]) - #expect(registered?.editorControl == .numberField) - #expect(registered?.parse?("42") == .int(42)) - #expect(registered?.parse?("notAnInt") == nil) + let registered = try #require(reader.registeredVariables[key]) + #expect(registered.key == key) + #expect(registered.defaultContent == .int(defaultValue)) + #expect(registered.isSecret == reader.isSecret(variable)) + #expect(registered.testTeam == metadata[TestTeamMetadataKey.self]) + #expect(registered.editorControl == .numberField) + #expect(registered.parse?("42") == .int(42)) + #expect(registered.parse?("notAnInt") == nil) } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift index 681f6bc..297cb2f 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift @@ -25,7 +25,7 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { let variable = RegisteredConfigVariable( key: randomConfigKey(), defaultContent: randomConfigContent(), - secrecy: randomConfigVariableSecrecy(), + isSecret: randomBool(), metadata: metadata, editorControl: .none, parse: nil @@ -42,7 +42,7 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { let variable = RegisteredConfigVariable( key: randomConfigKey(), defaultContent: randomConfigContent(), - secrecy: randomConfigVariableSecrecy(), + isSecret: randomBool(), metadata: ConfigVariableMetadata(), editorControl: .none, parse: nil diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift index b06eddf..7a77435 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift @@ -22,7 +22,7 @@ struct EditorOverrideProviderTests: RandomValueGenerating { let provider = EditorOverrideProvider() // expect - #expect(provider.providerName == "Editor") + #expect(provider.providerName != "editorOverrideProvider.name") } @@ -212,7 +212,7 @@ struct EditorOverrideProviderTests: RandomValueGenerating { let snapshot = provider.snapshot() // expect - #expect(snapshot.providerName == "Editor") + #expect(snapshot.providerName != "editorOverrideProvider.name") let result = try snapshot.value(forKey: AbsoluteConfigKey(key), type: .double) #expect(result.value == ConfigValue(content, isSecret: false)) } @@ -418,7 +418,7 @@ struct EditorOverrideProviderTests: RandomValueGenerating { // expect initial empty snapshot let first = try #require(await iterator.next()) - #expect(first.providerName == "Editor") + #expect(first.providerName != "editorOverrideProvider.name") let firstResult = try first.value(forKey: AbsoluteConfigKey(key), type: .string) #expect(firstResult.value == nil) diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift index eb49797..1368a91 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift @@ -82,6 +82,16 @@ struct ConfigVariableDetailViewModelTests: RandomValueGenerating { } + @Test(arguments: [false, true]) + mutating func isSecretReturnsVariableIsSecret(isSecret: Bool) { + // set up + let viewModel = makeDetailViewModel(isSecret: isSecret) + + // expect + #expect(viewModel.isSecret == isSecret) + } + + // MARK: - Provider Values @Test @@ -337,6 +347,7 @@ extension ConfigVariableDetailViewModelTests { private mutating func makeDetailViewModel( key: ConfigKey? = nil, defaultContent: ConfigContent = .bool(false), + isSecret: Bool? = nil, metadata: ConfigVariableMetadata = ConfigVariableMetadata(), editorControl: EditorControl = .toggle, parse: (@Sendable (String) -> ConfigContent?)? = nil, @@ -347,7 +358,7 @@ extension ConfigVariableDetailViewModelTests { let variable = RegisteredConfigVariable( key: effectiveKey, defaultContent: defaultContent, - secrecy: randomConfigVariableSecrecy(), + isSecret: isSecret ?? randomBool(), metadata: metadata, editorControl: editorControl, parse: parse diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift index 79465aa..0e7790f 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift @@ -412,7 +412,7 @@ extension ConfigVariableListViewModelTests { RegisteredConfigVariable( key: key ?? randomConfigKey(), defaultContent: defaultContent, - secrecy: randomConfigVariableSecrecy(), + isSecret: randomBool(), metadata: metadata, editorControl: editorControl, parse: nil From 3d67416511c94e88d0146d65147b2c64eb346081 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Sun, 8 Mar 2026 23:18:29 -0400 Subject: [PATCH 6/9] UI tweaks --- App/App.xcodeproj/project.pbxproj | 369 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 87 +++++ .../xcschemes/ExampleApp.xcscheme | 90 +++++ App/Sources/App/ContentView.swift | 39 ++ App/Sources/App/ContentViewModel.swift | 114 ++++++ App/Sources/App/ExampleApp.swift | 17 + Documentation/EditorUI/ArchitecturePlan.md | 4 +- Documentation/EditorUI/ImplementationPlan.md | 4 +- .../Core/ConfigVariableReader.swift | 39 +- .../DevConfiguration/Core/Localization.swift | 28 ++ .../Core/NamedConfigProvider.swift | 39 ++ .../ConfigVariableDetailView.swift | 114 ++++-- .../ConfigVariableDetailViewModel.swift | 77 +++- .../ConfigVariableDetailViewModeling.swift | 3 + .../ConfigVariableListView.swift} | 135 ++++--- .../ConfigVariableListViewModel.swift | 33 +- .../ConfigVariableListViewModeling.swift | 3 - .../VariableListItem.swift | 3 + .../Editor/ConfigVariableEditor.swift | 66 ++++ .../Data Models/EditorOverrideProvider.swift | 4 +- .../{Views => Utilities}/ProviderBadge.swift | 13 +- .../ProviderValue.swift | 3 + .../Editor/Views/ConfigVariableEditor.swift | 75 ---- .../Extensions/ConfigContent+Additions.swift | 21 + .../Metadata/DisplayNameMetadataKey.swift | 2 +- .../RequiresRelaunchMetadataKey.swift | 2 +- .../Resources/Localizable.xcstrings | 190 ++++++--- .../Core/ConfigVariableReaderArrayTests.swift | 2 +- .../ConfigVariableReaderCodableTests.swift | 2 +- ...gVariableReaderConfigExpressionTests.swift | 2 +- ...ariableReaderDataRepresentationTests.swift | 2 +- .../ConfigVariableReaderEditorTests.swift | 19 +- ...gVariableReaderRawRepresentableTests.swift | 2 +- ...onfigVariableReaderRegistrationTests.swift | 8 +- .../ConfigVariableReaderScalarTests.swift | 2 +- .../Core/ConfigVariableReaderTests.swift | 4 +- .../ConfigVariableDetailViewModelTests.swift | 2 +- .../ConfigVariableListViewModelTests.swift | 28 +- 39 files changed, 1319 insertions(+), 335 deletions(-) create mode 100644 App/App.xcodeproj/project.pbxproj create mode 100644 App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme create mode 100644 App/Sources/App/ContentView.swift create mode 100644 App/Sources/App/ContentViewModel.swift create mode 100644 App/Sources/App/ExampleApp.swift create mode 100644 Sources/DevConfiguration/Core/Localization.swift create mode 100644 Sources/DevConfiguration/Core/NamedConfigProvider.swift rename Sources/DevConfiguration/Editor/{Views => Config Variable Detail}/ConfigVariableDetailView.swift (56%) rename Sources/DevConfiguration/Editor/{View Models => Config Variable Detail}/ConfigVariableDetailViewModel.swift (55%) rename Sources/DevConfiguration/Editor/{View Models => Config Variable Detail}/ConfigVariableDetailViewModeling.swift (93%) rename Sources/DevConfiguration/Editor/{Views/ConfigVariableEditorView.swift => Config Variable List/ConfigVariableListView.swift} (61%) rename Sources/DevConfiguration/Editor/{View Models => Config Variable List}/ConfigVariableListViewModel.swift (81%) rename Sources/DevConfiguration/Editor/{View Models => Config Variable List}/ConfigVariableListViewModeling.swift (96%) rename Sources/DevConfiguration/Editor/{View Models => Config Variable List}/VariableListItem.swift (92%) create mode 100644 Sources/DevConfiguration/Editor/ConfigVariableEditor.swift rename Sources/DevConfiguration/Editor/{Views => Utilities}/ProviderBadge.swift (75%) rename Sources/DevConfiguration/Editor/{View Models => Utilities}/ProviderValue.swift (86%) delete mode 100644 Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift rename Tests/DevConfigurationTests/Unit Tests/{Editor/Data Models => Core}/ConfigVariableReaderEditorTests.swift (78%) rename Tests/DevConfigurationTests/Unit Tests/Editor/{View Models => Config Variable Detail}/ConfigVariableDetailViewModelTests.swift (99%) rename Tests/DevConfigurationTests/Unit Tests/Editor/{View Models => Config Variable List}/ConfigVariableListViewModelTests.swift (93%) diff --git a/App/App.xcodeproj/project.pbxproj b/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..65cf054 --- /dev/null +++ b/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,369 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 4C23D0F52F5E005000666984 /* DevConfiguration in Frameworks */ = {isa = PBXBuildFile; productRef = 4C23D0F42F5E005000666984 /* DevConfiguration */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 4C23D0E12F5DFFE700666984 /* ExampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4C23D0E32F5DFFE700666984 /* Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Sources; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4C23D0DE2F5DFFE700666984 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C23D0F52F5E005000666984 /* DevConfiguration in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4C23D0D82F5DFFE700666984 = { + isa = PBXGroup; + children = ( + 4C23D0E32F5DFFE700666984 /* Sources */, + 4C23D0E22F5DFFE700666984 /* Products */, + ); + sourceTree = ""; + }; + 4C23D0E22F5DFFE700666984 /* Products */ = { + isa = PBXGroup; + children = ( + 4C23D0E12F5DFFE700666984 /* ExampleApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4C23D0E02F5DFFE700666984 /* ExampleApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4C23D0EC2F5DFFE800666984 /* Build configuration list for PBXNativeTarget "ExampleApp" */; + buildPhases = ( + 4C23D0DD2F5DFFE700666984 /* Sources */, + 4C23D0DE2F5DFFE700666984 /* Frameworks */, + 4C23D0DF2F5DFFE700666984 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4C23D0E32F5DFFE700666984 /* Sources */, + ); + name = ExampleApp; + packageProductDependencies = ( + 4C23D0F42F5E005000666984 /* DevConfiguration */, + ); + productName = ExampleApp; + productReference = 4C23D0E12F5DFFE700666984 /* ExampleApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4C23D0D92F5DFFE700666984 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + 4C23D0E02F5DFFE700666984 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = 4C23D0DC2F5DFFE700666984 /* Build configuration list for PBXProject "App" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4C23D0D82F5DFFE700666984; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 4C23D0F32F5E005000666984 /* XCLocalSwiftPackageReference "../../DevConfiguration" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 4C23D0E22F5DFFE700666984 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4C23D0E02F5DFFE700666984 /* ExampleApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4C23D0DF2F5DFFE700666984 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4C23D0DD2F5DFFE700666984 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4C23D0EA2F5DFFE800666984 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 4C23D0EB2F5DFFE800666984 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 4C23D0ED2F5DFFE800666984 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = devkit.DevConfiguration.ExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 4C23D0EE2F5DFFE800666984 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = devkit.DevConfiguration.ExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4C23D0DC2F5DFFE700666984 /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C23D0EA2F5DFFE800666984 /* Debug */, + 4C23D0EB2F5DFFE800666984 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4C23D0EC2F5DFFE800666984 /* Build configuration list for PBXNativeTarget "ExampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C23D0ED2F5DFFE800666984 /* Debug */, + 4C23D0EE2F5DFFE800666984 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 4C23D0F32F5E005000666984 /* XCLocalSwiftPackageReference "../../DevConfiguration" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../DevConfiguration; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4C23D0F42F5E005000666984 /* DevConfiguration */ = { + isa = XCSwiftPackageProductDependency; + productName = DevConfiguration; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 4C23D0D92F5DFFE700666984 /* Project object */; +} diff --git a/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..297264f --- /dev/null +++ b/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,87 @@ +{ + "originHash" : "474c34dbe71ab68ccde42a3c0694cc8ee635990a91016d67075d57eb185f82d9", + "pins" : [ + { + "identity" : "devfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DevKitOrganization/DevFoundation.git", + "state" : { + "revision" : "1764b4f2978c039eb0da4c55902c807709df3604", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + } + ], + "version" : 3 +} diff --git a/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme b/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme new file mode 100644 index 0000000..cfb7742 --- /dev/null +++ b/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Sources/App/ContentView.swift b/App/Sources/App/ContentView.swift new file mode 100644 index 0000000..69b1924 --- /dev/null +++ b/App/Sources/App/ContentView.swift @@ -0,0 +1,39 @@ +// +// ContentView.swift +// App +// +// Created by Prachi Gauriar on 3/8/26. +// + +import DevConfiguration +import SwiftUI + +struct ContentView: View { + @State var viewModel: ContentViewModel + @State var isPresentingConfigEditor: Bool = false + + var body: some View { + NavigationStack { + ScrollView { + Text(viewModel.variableValues) + .padding() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Edit Config", systemImage: "gear") { + isPresentingConfigEditor = true + } + } + } + .sheet(isPresented: $isPresentingConfigEditor) { + ConfigVariableEditor(reader: viewModel.configVariableReader) { variables in + print(variables) + } + } + } + } +} + +#Preview { + ContentView(viewModel: ContentViewModel()) +} diff --git a/App/Sources/App/ContentViewModel.swift b/App/Sources/App/ContentViewModel.swift new file mode 100644 index 0000000..abba6da --- /dev/null +++ b/App/Sources/App/ContentViewModel.swift @@ -0,0 +1,114 @@ +// +// ContentViewModel.swift +// ExampleApp +// +// Created by Prachi Gauriar on 3/8/26. +// + +import Configuration +import DevConfiguration +import DevFoundation +import Foundation + +final class ContentViewModel { + let configVariableReader: ConfigVariableReader + let inMemoryProvider = MutableInMemoryProvider(initialValues: [:]) + let eventBus: EventBus = EventBus() + + let boolVariable = ConfigVariable(key: "dark_mode_enabled", defaultValue: false) + .metadata(\.displayName, "Dark Mode Enabled") + let float64Variable = ConfigVariable(key: "gravitationalConstant", defaultValue: 6.6743e-11) + .metadata(\.displayName, "Newton’s Gravitational Constant") + let intVariable = ConfigVariable(key: "configurationRefreshInterval", defaultValue: 1000) + .metadata(\.displayName, "Configuration Refresh Interval (ms)") + let stringVariable = ConfigVariable(key: "appName", defaultValue: "Example", secrecy: .public) + .metadata(\.displayName, "App Name") + + let boolArrayVariable = ConfigVariable(key: "bool_array", defaultValue: [false, true, true, false]) + .metadata(\.displayName, "Bool Array Example") + let float64ArrayVariable = ConfigVariable(key: "float64_array", defaultValue: [0, 1, 2.78182, 3.14159]) + .metadata(\.displayName, "Float Array Example") + let intArrayVariable = ConfigVariable(key: "int_array", defaultValue: [1, 2, 4, 8, 16, 32]) + .metadata(\.displayName, "Int Array Example") + let stringArrayVariable = ConfigVariable( + key: "string_array", + defaultValue: ["Thom", "Jonny", "Ed", "Colin", "Phil"], + secrecy: .public + ).metadata(\.displayName, "String Array Example") + + let jsonVariable = ConfigVariable( + key: "complexConfig", + defaultValue: ComplexConfiguration(field1: "a", field2: 1), + content: .json(representation: .data), + secrecy: .public + ).metadata(\.displayName, "Complex Config") + + let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades) + .metadata(\.displayName, "Favorite Card Suit") + + let stringBackedVariable = ConfigVariable(key: "favoriteBeatle", defaultValue: Beatle.john) + .metadata(\.displayName, "Favorite Beatle") + + + init() { + self.configVariableReader = ConfigVariableReader( + namedProviders: [ + NamedConfigProvider(EnvironmentVariablesProvider(), displayName: "Environment"), + NamedConfigProvider(inMemoryProvider, displayName: "In-Memory"), + ], + eventBus: eventBus, + isEditorEnabled: true + ) + + configVariableReader.register(boolVariable) + configVariableReader.register(boolArrayVariable) + configVariableReader.register(float64Variable) + configVariableReader.register(float64ArrayVariable) + configVariableReader.register(intVariable) + configVariableReader.register(intArrayVariable) + configVariableReader.register(intBackedVariable) + configVariableReader.register(stringVariable) + configVariableReader.register(stringArrayVariable) + configVariableReader.register(stringBackedVariable) + configVariableReader.register(jsonVariable) + } + + + var variableValues: String { + """ + boolVariable = \(configVariableReader[boolVariable]) + boolArrayVariable = \(configVariableReader[boolArrayVariable]) + float64Variable = \(configVariableReader[float64Variable]) + float64ArrayVariable = \(configVariableReader[float64ArrayVariable]) + intVariable = \(configVariableReader[intVariable]) + intArrayVariable = \(configVariableReader[intArrayVariable]) + intBackedVariable = \(configVariableReader[intBackedVariable]) + stringVariable = \(configVariableReader[stringVariable]) + stringArrayVariable = \(configVariableReader[stringArrayVariable]) + stringBackedVariable = \(configVariableReader[stringBackedVariable]) + jsonVariable = \(configVariableReader[jsonVariable]) + """ + } +} + + +struct ComplexConfiguration: Codable, Hashable, Sendable { + let field1: String + let field2: Int +} + + +enum Beatle: String, Codable, Hashable, Sendable { + case john = "John" + case paul = "Paul" + case george = "George" + case ringo = "Ringo" +} + + +enum CardSuit: Int, Codable, Hashable, Sendable { + case spades + case hearts + case clubs + case diamonds +} diff --git a/App/Sources/App/ExampleApp.swift b/App/Sources/App/ExampleApp.swift new file mode 100644 index 0000000..b647cdf --- /dev/null +++ b/App/Sources/App/ExampleApp.swift @@ -0,0 +1,17 @@ +// +// ExampleApp.swift +// App +// +// Created by Prachi Gauriar on 3/8/26. +// + +import SwiftUI + +@main +struct ExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView(viewModel: ContentViewModel()) + } + } +} diff --git a/Documentation/EditorUI/ArchitecturePlan.md b/Documentation/EditorUI/ArchitecturePlan.md index a062500..1b095b5 100644 --- a/Documentation/EditorUI/ArchitecturePlan.md +++ b/Documentation/EditorUI/ArchitecturePlan.md @@ -136,7 +136,7 @@ Two new metadata keys: ## Views -### ConfigVariableEditorView (List View) +### ConfigVariableListView (List View) The top-level editor view showing all registered variables. @@ -177,7 +177,7 @@ The detail view for a single variable. - "Enable Override" toggle - When enabled, shows the appropriate editor control based on `EditorControl` - Changes register with `UndoManager` - - **Provider Values**: value from each provider, each with its provider capsule + - **Values**: value from each provider, each with its provider capsule - Incompatible values (wrong `ConfigContent` case for the variable's type) shown with strikethrough - Secret values redacted by default with tap-to-reveal (detail view only) diff --git a/Documentation/EditorUI/ImplementationPlan.md b/Documentation/EditorUI/ImplementationPlan.md index 3c2e1df..3107c8c 100644 --- a/Documentation/EditorUI/ImplementationPlan.md +++ b/Documentation/EditorUI/ImplementationPlan.md @@ -183,7 +183,7 @@ Build the views. All inside `#if canImport(SwiftUI)`. - **Provider color assignment** — static function mapping provider index to system color; editor override provider always returns `.orange` -### 5b: ConfigVariableEditorView (List) +### 5b: ConfigVariableListView (List) - Generic on `ViewModel: ConfigVariableListViewModeling` - `NavigationStack` with `List` @@ -198,7 +198,7 @@ Build the views. All inside `#if canImport(SwiftUI)`. ### 5c: ConfigVariableDetailView - Generic on `ViewModel: ConfigVariableDetailViewModeling` - - Sections: Header, Override, Provider Values, Metadata + - Sections: Header, Override, Values, Metadata - Override section: - "Enable Override" toggle - When enabled, shows editor control based on `editorControl` diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index 20061cb..53d4235 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -30,8 +30,8 @@ import Synchronization /// Then create a reader with your providers and query the variable: /// /// let reader = ConfigVariableReader( -/// providers: [ -/// InMemoryProvider(values: ["dark_mode": "true"]) +/// namedProviders: [ +/// .init(InMemoryProvider(values: ["dark_mode": "true"]), displayName: "In-Memory") /// ], /// eventBus: eventBus /// ) @@ -54,17 +54,18 @@ public final class ConfigVariableReader: Sendable { /// The configuration reader that is used to resolve configuration values. public let reader: ConfigReader - /// The configuration reader’s providers. + /// The configuration reader’s named providers. /// - /// This is stored so that - public let providers: [any ConfigProvider] + /// When editor support is enabled, the editor override provider is the first entry. + public let namedProviders: [NamedConfigProvider] /// The event bus used to post diagnostic events like ``ConfigVariableDecodingFailedEvent``. public let eventBus: EventBus /// The editor override provider, if editor support is enabled. /// - /// When non-nil, this provider is the first entry in ``providers`` and takes precedence over all other providers. + /// When non-nil, this provider is the first entry in ``namedProviders`` and takes precedence over all other + /// providers. let editorOverrideProvider: EditorOverrideProvider? /// The mutable state protected by a mutex. @@ -79,16 +80,16 @@ public final class ConfigVariableReader: Sendable { /// Use this initializer when you want to use the standard `EventBusAccessReporter`. /// /// - Parameters: - /// - providers: The configuration providers, queried in order until a value is found. + /// - namedProviders: The named configuration providers, queried in order until a value is found. /// - eventBus: The event bus that telemetry events are posted on. /// - isEditorEnabled: Whether editor override support is enabled. Defaults to `false`. public convenience init( - providers: [any ConfigProvider], + namedProviders: [NamedConfigProvider], eventBus: EventBus, isEditorEnabled: Bool = false ) { self.init( - providers: providers, + namedProviders: namedProviders, accessReporter: EventBusAccessReporter(eventBus: eventBus), eventBus: eventBus, isEditorEnabled: isEditorEnabled @@ -101,33 +102,33 @@ public final class ConfigVariableReader: Sendable { /// Use this initializer when you want to directly control the access reporter used by the config reader. /// /// - Parameters: - /// - providers: The configuration providers, queried in order until a value is found. + /// - namedProviders: The named configuration providers, queried in order until a value is found. /// - accessReporter: The access reporter that is used to report configuration access events. /// - eventBus: The event bus used to post diagnostic events. /// - isEditorEnabled: Whether editor override support is enabled. Defaults to `false`. public init( - providers: [any ConfigProvider], + namedProviders: [NamedConfigProvider], accessReporter: any AccessReporter, eventBus: EventBus, isEditorEnabled: Bool = false ) { - let editorOverrideProvider: EditorOverrideProvider? - let effectiveProviders: [any ConfigProvider] + var editorOverrideProvider: EditorOverrideProvider? + var namedProviders = namedProviders if isEditorEnabled { let provider = EditorOverrideProvider() provider.load(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) editorOverrideProvider = provider - effectiveProviders = [provider] + providers - } else { - editorOverrideProvider = nil - effectiveProviders = providers + namedProviders.insert(.init(provider, displayName: localizedString("editorOverrideProvider.name")), at: 0) } self.editorOverrideProvider = editorOverrideProvider self.accessReporter = accessReporter - self.reader = ConfigReader(providers: effectiveProviders, accessReporter: accessReporter) - self.providers = effectiveProviders + self.reader = ConfigReader( + providers: namedProviders.map(\.provider), + accessReporter: accessReporter + ) + self.namedProviders = namedProviders self.eventBus = eventBus } diff --git a/Sources/DevConfiguration/Core/Localization.swift b/Sources/DevConfiguration/Core/Localization.swift new file mode 100644 index 0000000..df1a8eb --- /dev/null +++ b/Sources/DevConfiguration/Core/Localization.swift @@ -0,0 +1,28 @@ +// +// Localization.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/26. +// + +import Foundation + +func localizedString(_ keyAndValue: String.LocalizationValue) -> String { + String(localized: keyAndValue, bundle: #bundle) +} + + +func localizedStringResource(_ keyAndValue: String.LocalizationValue) -> LocalizedStringResource { + LocalizedStringResource(keyAndValue, bundle: #bundle) +} + + +#if canImport(SwiftUI) +import SwiftUI + +extension Text { + init(localized localizationValue: String.LocalizationValue) { + self.init(localizedString(localizationValue)) + } +} +#endif diff --git a/Sources/DevConfiguration/Core/NamedConfigProvider.swift b/Sources/DevConfiguration/Core/NamedConfigProvider.swift new file mode 100644 index 0000000..6b9360e --- /dev/null +++ b/Sources/DevConfiguration/Core/NamedConfigProvider.swift @@ -0,0 +1,39 @@ +// +// NamedConfigProvider.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +import Configuration + +/// A configuration provider paired with a human-readable display name. +/// +/// Use `NamedConfigProvider` when adding providers to a ``ConfigVariableReader`` to control how the provider's name +/// appears in the editor UI. If no display name is specified, the provider's ``ConfigProvider/providerName`` is used. +/// +/// let reader = ConfigVariableReader( +/// providers: [ +/// NamedConfigProvider(environmentProvider, displayName: "Environment"), +/// NamedConfigProvider(remoteProvider) +/// ], +/// eventBus: eventBus +/// ) +public struct NamedConfigProvider: Sendable { + /// The configuration provider. + public let provider: any ConfigProvider + + /// The human-readable display name for this provider. + public let displayName: String + + + /// Creates a named configuration provider. + /// + /// - Parameters: + /// - provider: The configuration provider. + /// - displayName: A human-readable display name. If `nil`, the provider's `providerName` is used. + public init(_ provider: any ConfigProvider, displayName: String? = nil) { + self.provider = provider + self.displayName = displayName ?? provider.providerName + } +} diff --git a/Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift similarity index 56% rename from Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift rename to Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift index 0913e6d..025f35b 100644 --- a/Sources/DevConfiguration/Editor/Views/ConfigVariableDetailView.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift @@ -35,9 +35,15 @@ struct ConfigVariableDetailView: Vi extension ConfigVariableDetailView { private var headerSection: some View { Section { - Text(viewModel.key.description) - .font(.subheadline) - .foregroundStyle(.secondary) + LabeledContent(localizedStringResource("detailView.headerSection.key")) { + Text(viewModel.key.description) + .font(.caption.monospaced()) + } + + LabeledContent(localizedStringResource("detailView.headerSection.type")) { + Text(viewModel.typeName) + .font(.caption.monospaced()) + } } } @@ -45,11 +51,29 @@ extension ConfigVariableDetailView { @ViewBuilder private var overrideSection: some View { if viewModel.editorControl != .none { - Section(String(localized: "detailView.overrideSection.header", bundle: #bundle)) { - Toggle( - String(localized: "detailView.overrideSection.enableToggle", bundle: #bundle), - isOn: $viewModel.isOverrideEnabled - ) + Section(localizedStringResource("detailView.overrideSection.header")) { + LabeledContent(localizedStringResource("detailView.overrideSection.editorOverrideLabel")) { + if viewModel.isOverrideEnabled { + Button(role: .destructive) { + viewModel.isOverrideEnabled = false + } label: { + HStack(alignment: .firstTextBaseline) { + Text(localized: "detailView.overrideSection.removeOverride") + Image(systemName: "xmark.circle.fill") + } + } + .tint(.red) + } else { + Button { + viewModel.isOverrideEnabled = true + } label: { + HStack(alignment: .firstTextBaseline) { + Text(localized: "detailView.overrideSection.addOverride") + Image(systemName: "plus.circle.fill") + } + } + } + } if viewModel.isOverrideEnabled { overrideControl @@ -61,19 +85,30 @@ extension ConfigVariableDetailView { @ViewBuilder private var overrideControl: some View { - if viewModel.editorControl == .toggle { - Toggle( - String(localized: "detailView.overrideSection.valueToggle", bundle: #bundle), - isOn: $viewModel.overrideBool - ) - } else { - TextField( - String(localized: "detailView.overrideSection.valueTextField", bundle: #bundle), - text: $viewModel.overrideText - ) - #if os(iOS) || os(visionOS) - .keyboardType(keyboardType) - #endif + LabeledContent(localizedStringResource("detailView.overrideSection.valueLabel")) { + if viewModel.editorControl == .toggle { + HStack { + Spacer().layoutPriority(0) + Picker( + localizedStringResource("detailView.overrideSection.valuePicker"), + selection: $viewModel.overrideBool + ) { + Text(localized: "detailView.overridenSection.valuePickerFalse").tag(false) + Text(localized: "detailView.overridenSection.valuePickerTrue").tag(true) + } + .pickerStyle(.segmented) + } + } else { + TextField( + localizedStringResource("detailView.overrideSection.valueTextField"), + text: $viewModel.overrideText + ) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + #if os(iOS) || os(visionOS) + .keyboardType(keyboardType) + #endif + } } } @@ -92,20 +127,28 @@ extension ConfigVariableDetailView { private var providerValuesSection: some View { - Section(String(localized: "detailView.providerValuesSection.header", bundle: #bundle)) { + Section(localizedStringResource("detailView.providerValuesSection.header")) { if viewModel.isSecret && !viewModel.isSecretRevealed { - Button(String(localized: "detailView.providerValuesSection.tapToReveal", bundle: #bundle)) { + Button(localizedStringResource("detailView.providerValuesSection.tapToReveal")) { viewModel.isSecretRevealed = true } } else { ForEach(viewModel.providerValues, id: \.self) { providerValue in LabeledContent { + Text(providerValue.valueString) + .font(.caption.monospaced()) + } label: { ProviderBadge( providerName: providerValue.providerName, - color: providerColor(at: providerValue.providerIndex) + color: providerColor(at: providerValue.providerIndex), + isActive: providerValue.isActive ) - } label: { - Text(providerValue.valueString) + } + } + + if viewModel.isSecret { + Button(localizedStringResource("detailView.providerValuesSection.hideValues")) { + viewModel.isSecretRevealed = false } } } @@ -117,7 +160,7 @@ extension ConfigVariableDetailView { private var metadataSection: some View { let entries = viewModel.metadataEntries if !entries.isEmpty { - Section(String(localized: "detailView.metadataSection.header", bundle: #bundle)) { + Section(localizedStringResource("detailView.metadataSection.header")) { ForEach(entries, id: \.key) { entry in LabeledContent(entry.key, value: entry.value ?? "—") } @@ -133,6 +176,7 @@ extension ConfigVariableDetailView { private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { let key: ConfigKey let displayName: String + let typeName: String let metadataEntries: [ConfigVariableMetadata.DisplayText] let providerValues: [ProviderValue] let isSecret: Bool @@ -147,6 +191,7 @@ private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { init( key: ConfigKey, displayName: String, + typeName: String = "String", metadataEntries: [ConfigVariableMetadata.DisplayText] = [], providerValues: [ProviderValue] = [], isSecret: Bool = false, @@ -157,6 +202,7 @@ private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { ) { self.key = key self.displayName = displayName + self.typeName = typeName self.metadataEntries = metadataEntries self.providerValues = providerValues self.isSecret = isSecret @@ -179,8 +225,12 @@ private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { .init(key: "Requires Relaunch", value: "Yes"), ], providerValues: [ - ProviderValue(providerName: "Remote", providerIndex: 1, valueString: "https://api.example.com"), - ProviderValue(providerName: "Default", providerIndex: 2, valueString: "https://localhost:8080"), + ProviderValue( + providerName: "Remote", providerIndex: 1, isActive: false, + valueString: "https://api.example.com"), + ProviderValue( + providerName: "Default", providerIndex: 2, isActive: false, + valueString: "https://localhost:8080"), ], editorControl: .textField, isOverrideEnabled: true, @@ -197,8 +247,9 @@ private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { viewModel: PreviewDetailViewModel( key: "feature.dark_mode", displayName: "Dark Mode", + typeName: "Bool", providerValues: [ - ProviderValue(providerName: "Remote", providerIndex: 1, valueString: "false") + ProviderValue(providerName: "Remote", providerIndex: 1, isActive: false, valueString: "false") ], editorControl: .toggle, isOverrideEnabled: true, @@ -216,7 +267,8 @@ private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { key: "service.api_key", displayName: "API Key", providerValues: [ - ProviderValue(providerName: "Remote", providerIndex: 1, valueString: "sk-1234567890abcdef") + ProviderValue( + providerName: "Remote", providerIndex: 1, isActive: true, valueString: "sk-1234567890abcdef") ], isSecret: true, editorControl: .textField diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift similarity index 55% rename from Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift rename to Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift index 383e778..8f028b4 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModel.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift @@ -23,8 +23,8 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { /// The editor document managing the working copy. private let document: EditorDocument - /// The reader's providers, queried for per-provider values. - private let providers: [any ConfigProvider] + /// The reader's named providers, queried for per-provider values. + private let namedProviders: [NamedConfigProvider] /// Whether the variable's secret value is currently revealed. var isSecretRevealed = false @@ -40,15 +40,15 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { /// - Parameters: /// - variable: The registered variable to display. /// - document: The editor document managing the working copy. - /// - providers: The reader's providers. + /// - namedProviders: The reader's named providers. init( variable: RegisteredConfigVariable, document: EditorDocument, - providers: [any ConfigProvider] + namedProviders: [NamedConfigProvider] ) { self.variable = variable self.document = document - self.providers = providers + self.namedProviders = namedProviders } @@ -62,6 +62,11 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { } + var typeName: String { + variable.defaultContent.typeDisplayName + } + + var metadataEntries: [ConfigVariableMetadata.DisplayText] { variable.metadata.displayTextEntries } @@ -70,21 +75,64 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { var providerValues: [ProviderValue] { let absoluteKey = AbsoluteConfigKey(variable.key) let expectedType = variable.defaultContent.configType + let overrideContent = document.override(forKey: variable.key) + let hasOverride = overrideContent != nil + + var values: [ProviderValue] = [] + + // If there's a working copy override, show it as the editor provider value + if let overrideContent { + let editorIndex = + namedProviders.firstIndex { $0.provider.providerName == EditorOverrideProvider.providerName } ?? 0 + + values.append( + ProviderValue( + providerName: namedProviders[editorIndex].displayName, + providerIndex: editorIndex, + isActive: true, + valueString: overrideContent.displayString + ) + ) + } + + var foundActive = false + for (index, namedProvider) in namedProviders.enumerated() { + // Skip the editor provider since we handle it above from the working copy + if namedProvider.provider.providerName == EditorOverrideProvider.providerName { + continue + } - return providers.enumerated().compactMap { index, provider in guard - let result = try? provider.value(forKey: absoluteKey, type: expectedType), + let result = try? namedProvider.provider.value(forKey: absoluteKey, type: expectedType), let configValue = result.value else { - return nil + continue } - return ProviderValue( - providerName: provider.providerName, - providerIndex: index, - valueString: configValue.content.displayString + let isActive = !hasOverride && !foundActive + foundActive = foundActive || isActive + + values.append( + ProviderValue( + providerName: namedProvider.displayName, + providerIndex: index, + isActive: isActive, + valueString: configValue.content.displayString + ) ) } + + // Always show the default value last + values.append( + ProviderValue( + providerName: localizedString("editor.defaultProviderName"), + providerIndex: namedProviders.count, + isActive: !hasOverride && !foundActive, + valueString: variable.defaultContent.displayString + ) + ) + + return values } @@ -104,10 +152,7 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { var overrideText: String { get { - guard let content = document.override(forKey: variable.key) else { - return "" - } - return content.displayString + return document.override(forKey: variable.key)?.displayString ?? "" } set { guard let parse = variable.parse, let content = parse(newValue) else { diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift similarity index 93% rename from Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift rename to Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift index e8fe135..3e9abca 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableDetailViewModeling.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift @@ -21,6 +21,9 @@ protocol ConfigVariableDetailViewModeling: Observable { /// The human-readable display name for this variable. var displayName: String { get } + /// A human-readable name for this variable's value type, such as `"String"` or `"Int"`. + var typeName: String { get } + /// The metadata entries to display. var metadataEntries: [ConfigVariableMetadata.DisplayText] { get } diff --git a/Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift similarity index 61% rename from Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift rename to Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift index 88ea5c8..dd55efb 100644 --- a/Sources/DevConfiguration/Editor/Views/ConfigVariableEditorView.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift @@ -1,5 +1,5 @@ // -// ConfigVariableEditorView.swift +// ConfigVariableListView.swift // DevConfiguration // // Created by Prachi Gauriar on 3/8/2026. @@ -12,73 +12,67 @@ import SwiftUI /// The list view for the configuration variable editor. /// -/// `ConfigVariableEditorView` displays all registered configuration variables in a searchable, sorted list. Each row +/// `ConfigVariableListView` displays all registered configuration variables in a searchable, sorted list. Each row /// shows the variable's display name, key, current value, and a provider badge. Tapping a row navigates to the /// variable's detail view. /// /// The toolbar provides Cancel, Save, and an overflow menu with Undo, Redo, and Clear Editor Overrides actions. -struct ConfigVariableEditorView: View { +struct ConfigVariableListView: View { @State var viewModel: ViewModel /// The closure to call with the changed variables when the user saves. var onSave: ([RegisteredConfigVariable]) -> Void - /// The closure to call when the user cancels editing. - var onCancel: () -> Void + @Environment(\.dismiss) private var dismiss - @State private var isShowingDiscardAlert = false + @State private var isShowingSaveAlert = false @State private var isShowingClearAlert = false var body: some View { NavigationStack { - List(viewModel.variables, id: \.key) { item in - NavigationLink(value: item.key) { - VariableRow(item: item) + List { + Section(localizedStringResource("editorView.variablesSection.header")) { + ForEach(viewModel.variables, id: \.key) { item in + NavigationLink(value: item.key) { + VariableRow(item: item) + } + } } } - .navigationTitle(String(localized: "editorView.navigationTitle", bundle: #bundle)) + .navigationTitle(localizedStringResource("editorView.navigationTitle")) + #if os(iOS) || os(watchOS) + .navigationBarTitleDisplayMode(.inline) + #endif .navigationDestination(for: ConfigKey.self) { key in ConfigVariableDetailView(viewModel: viewModel.makeDetailViewModel(for: key)) } .searchable(text: $viewModel.searchText) .toolbar { toolbarContent } - .alert( - String(localized: "editorView.discardAlert.title", bundle: #bundle), - isPresented: $isShowingDiscardAlert - ) { - Button( - String(localized: "editorView.discardAlert.discardButton", bundle: #bundle), - role: .destructive - ) { - viewModel.cancel() - onCancel() + .alert(localizedStringResource("editorView.saveAlert.title"), isPresented: $isShowingSaveAlert) { + Button(localizedStringResource("editorView.saveAlert.saveButton")) { + let changedVariables = viewModel.save() + onSave(changedVariables) + dismiss() } + .keyboardShortcut(.defaultAction) - Button( - String(localized: "editorView.discardAlert.keepEditingButton", bundle: #bundle), - role: .cancel - ) {} + Button(localizedStringResource("editorView.saveAlert.dontSaveButton"), role: .destructive) { + dismiss() + } + + Button(localizedStringResource("editorView.saveAlert.cancelButton"), role: .cancel) {} } message: { - Text(String(localized: "editorView.discardAlert.message", bundle: #bundle)) + Text(localizedStringResource("editorView.saveAlert.message")) } - .alert( - String(localized: "editorView.clearAlert.title", bundle: #bundle), - isPresented: $isShowingClearAlert - ) { - Button( - String(localized: "editorView.clearAlert.clearButton", bundle: #bundle), - role: .destructive - ) { + .alert(localizedStringResource("editorView.clearAlert.title"), isPresented: $isShowingClearAlert) { + Button(localizedStringResource("editorView.clearAlert.clearButton"), role: .destructive) { viewModel.clearAllOverrides() } - Button( - String(localized: "editorView.discardAlert.keepEditingButton", bundle: #bundle), - role: .cancel - ) {} + Button(localizedStringResource("editorView.saveAlert.cancelButton"), role: .cancel) {} } message: { - Text(String(localized: "editorView.clearAlert.message", bundle: #bundle)) + Text(localizedStringResource("editorView.clearAlert.message")) } } } @@ -87,19 +81,18 @@ struct ConfigVariableEditorView: View // MARK: - Toolbar -extension ConfigVariableEditorView { +extension ConfigVariableListView { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { Button { if viewModel.isDirty { - isShowingDiscardAlert = true + isShowingSaveAlert = true } else { - viewModel.cancel() - onCancel() + dismiss() } } label: { - Label(String(localized: "editorView.cancelButton", bundle: #bundle), systemImage: "xmark") + Label(localizedStringResource("editorView.dismissButton"), systemImage: "xmark") } } @@ -107,33 +100,38 @@ extension ConfigVariableEditorView { Button { let changedVariables = viewModel.save() onSave(changedVariables) + dismiss() } label: { - Label(String(localized: "editorView.saveButton", bundle: #bundle), systemImage: "checkmark") + Label(localizedStringResource("editorView.saveButton"), systemImage: "checkmark") } + .disabled(!viewModel.isDirty) } ToolbarItem(placement: .primaryAction) { Menu { - Button(String(localized: "editorView.undoButton", bundle: #bundle)) { + Button { viewModel.undo() + } label: { + Label(localizedStringResource("editorView.undoButton"), systemImage: "arrow.uturn.backward") } .disabled(!viewModel.canUndo) - Button(String(localized: "editorView.redoButton", bundle: #bundle)) { + Button { viewModel.redo() + } label: { + Label(localizedStringResource("editorView.redoButton"), systemImage: "arrow.uturn.forward") } .disabled(!viewModel.canRedo) Divider() - Button(String(localized: "editorView.clearOverridesButton", bundle: #bundle), role: .destructive) { + Button(role: .destructive) { isShowingClearAlert = true + } label: { + Label(localizedStringResource("editorView.clearOverridesButton"), systemImage: "trash") } } label: { - Label( - String(localized: "editorView.overflowMenu.label", bundle: #bundle), - systemImage: "ellipsis.circle" - ) + Label(localizedStringResource("editorView.overflowMenu.label"), systemImage: "ellipsis") } } } @@ -142,34 +140,32 @@ extension ConfigVariableEditorView { // MARK: - Variable Row -extension ConfigVariableEditorView { +extension ConfigVariableListView { /// A single row in the configuration variable list. private struct VariableRow: View { let item: VariableListItem var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 8) { Text(item.displayName) - .font(.body) + .font(.headline) Text(item.key.description) - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Text(item.currentValue) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() + .font(.caption.monospaced()) + HStack(alignment: .firstTextBaseline) { ProviderBadge( providerName: item.providerName, color: providerColor(at: item.providerIndex) ) + Spacer() + Text(item.isSecret ? "••••••••" : item.currentValue) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) } + .padding(.top, 8) } .padding(.vertical, 2) } @@ -195,7 +191,6 @@ private final class PreviewListViewModel: ConfigVariableListViewModeling { func save() -> [RegisteredConfigVariable] { [] } - func cancel() {} func clearAllOverrides() {} func undo() {} func redo() {} @@ -211,6 +206,7 @@ private final class PreviewListViewModel: ConfigVariableListViewModeling { private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeling { let key: ConfigKey let displayName: String + let typeName = "String" let metadataEntries: [ConfigVariableMetadata.DisplayText] = [] let providerValues: [ProviderValue] = [] let isSecret = false @@ -230,7 +226,7 @@ private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeli #Preview { - ConfigVariableEditorView( + ConfigVariableListView( viewModel: PreviewListViewModel( variables: [ VariableListItem( @@ -239,6 +235,7 @@ private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeli currentValue: "true", providerName: "Editor", providerIndex: 0, + isSecret: false, hasOverride: true, editorControl: .toggle ), @@ -248,6 +245,7 @@ private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeli currentValue: "https://api.example.com", providerName: "Remote", providerIndex: 1, + isSecret: false, hasOverride: false, editorControl: .textField ), @@ -257,6 +255,7 @@ private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeli currentValue: "3", providerName: "Default", providerIndex: 2, + isSecret: false, hasOverride: false, editorControl: .numberField ), @@ -266,14 +265,14 @@ private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeli currentValue: "30.0", providerName: "Remote", providerIndex: 1, + isSecret: false, hasOverride: false, editorControl: .decimalField ), ], isDirty: true ), - onSave: { _ in }, - onCancel: {} + onSave: { _ in } ) } diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift similarity index 81% rename from Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift rename to Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift index 5064f99..6b06b17 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModel.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift @@ -24,8 +24,8 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { /// The registered variables from the reader, keyed by configuration key. private let registeredVariables: [ConfigKey: RegisteredConfigVariable] - /// The reader's providers, queried in order for value resolution. - private let providers: [any ConfigProvider] + /// The reader's named providers, queried in order for value resolution. + private let namedProviders: [NamedConfigProvider] /// The undo manager for the editor session. private let undoManager: UndoManager @@ -40,17 +40,17 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { /// - Parameters: /// - document: The editor document managing the working copy. /// - registeredVariables: The registered variables from the reader. - /// - providers: The reader's providers, queried in order for value resolution. + /// - namedProviders: The reader's named providers, queried in order for value resolution. /// - undoManager: The undo manager for the editor session. init( document: EditorDocument, registeredVariables: [ConfigKey: RegisteredConfigVariable], - providers: [any ConfigProvider], + namedProviders: [NamedConfigProvider], undoManager: UndoManager ) { self.document = document self.registeredVariables = registeredVariables - self.providers = providers + self.namedProviders = namedProviders self.undoManager = undoManager } @@ -64,6 +64,7 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { currentValue: content.displayString, providerName: providerName, providerIndex: providerIndex, + isSecret: variable.isSecret, hasOverride: document.hasOverride(forKey: variable.key), editorControl: variable.editorControl ) @@ -106,9 +107,6 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { } - func cancel() {} - - func clearAllOverrides() { document.removeAllOverrides() } @@ -132,7 +130,7 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling { return ConfigVariableDetailViewModel( variable: variable, document: document, - providers: providers + namedProviders: namedProviders ) } } @@ -147,23 +145,26 @@ extension ConfigVariableListViewModel { /// default content if no provider has a value. private func resolvedValue(for variable: RegisteredConfigVariable) -> (ConfigContent, String, Int) { if let override = document.override(forKey: variable.key) { - let editorIndex = providers.firstIndex { $0.providerName == EditorOverrideProvider.editorProviderName } ?? 0 - return (override, EditorOverrideProvider.editorProviderName, editorIndex) + let editorIndex = + namedProviders.firstIndex { $0.provider.providerName == EditorOverrideProvider.providerName } ?? 0 + return (override, namedProviders[editorIndex].displayName, editorIndex) } let absoluteKey = AbsoluteConfigKey(variable.key) let expectedType = variable.defaultContent.configType - for (index, provider) in providers.enumerated() { - if let result = try? provider.value(forKey: absoluteKey, type: expectedType), let value = result.value { - return (value.content, provider.providerName, index) + for (index, namedProvider) in namedProviders.enumerated() { + if let result = try? namedProvider.provider.value(forKey: absoluteKey, type: expectedType), + let value = result.value + { + return (value.content, namedProvider.displayName, index) } } return ( variable.defaultContent, - String(localized: "variableListItem.unknownProviderName", bundle: #bundle), - providers.count + localizedString("editor.defaultProviderName"), + namedProviders.count ) } } diff --git a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift similarity index 96% rename from Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift rename to Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift index 9140e9b..a1663d7 100644 --- a/Sources/DevConfiguration/Editor/View Models/ConfigVariableListViewModeling.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift @@ -38,9 +38,6 @@ protocol ConfigVariableListViewModeling: Observable { /// Saves the current working copy and returns the registered variables whose overrides changed. func save() -> [RegisteredConfigVariable] - /// Cancels editing, discarding any unsaved changes. - func cancel() - /// Removes all editor overrides from the working copy. func clearAllOverrides() diff --git a/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift similarity index 92% rename from Sources/DevConfiguration/Editor/View Models/VariableListItem.swift rename to Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift index fbaa2b0..9d1a184 100644 --- a/Sources/DevConfiguration/Editor/View Models/VariableListItem.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift @@ -30,6 +30,9 @@ struct VariableListItem: Hashable, Sendable { /// The index of the provider in the reader's provider list, used for color assignment. let providerIndex: Int + /// Whether this variable's value is secret and should be redacted in the list. + let isSecret: Bool + /// Whether an editor override is active for this variable in the working copy. let hasOverride: Bool diff --git a/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift b/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift new file mode 100644 index 0000000..ec4dbf7 --- /dev/null +++ b/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift @@ -0,0 +1,66 @@ +// +// ConfigVariableEditor.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/8/2026. +// + +#if canImport(SwiftUI) + +import SwiftUI + +/// A SwiftUI view that presents the configuration variable editor. +/// +/// `ConfigVariableEditor` is initialized with a ``ConfigVariableReader`` that has editor support enabled and an +/// `onSave` closure that receives the registered variables whose overrides changed. +/// +/// The consumer is responsible for presentation (sheet, full-screen cover, navigation push, etc.). +/// +/// .sheet(isPresented: $isEditorPresented) { +/// ConfigVariableEditor(reader: reader) { changedVariables in +/// // Handle changed variables +/// } +/// } +public struct ConfigVariableEditor: View { + /// The list view model created from the reader. + @State private var viewModel: ConfigVariableListViewModel? + + /// The closure to call with the changed variables when the user saves. + private let onSave: ([RegisteredConfigVariable]) -> Void + + + /// Creates a new configuration variable editor. + /// + /// - Parameters: + /// - reader: The configuration variable reader. If the reader does was not created with `isEditorEnabled` set to + /// `true`, the view is empty. + /// - onSave: A closure called with the registered variables whose overrides changed when the user saves. + public init( + reader: ConfigVariableReader, + onSave: @escaping ([RegisteredConfigVariable]) -> Void + ) { + self.onSave = onSave + + if let editorOverrideProvider = reader.editorOverrideProvider { + let undoManager = UndoManager() + let document = EditorDocument(provider: editorOverrideProvider, undoManager: undoManager) + self._viewModel = State( + initialValue: ConfigVariableListViewModel( + document: document, + registeredVariables: reader.registeredVariables, + namedProviders: reader.namedProviders, + undoManager: undoManager + ) + ) + } + } + + + public var body: some View { + if let viewModel { + ConfigVariableListView(viewModel: viewModel, onSave: onSave) + } + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift index 487aae3..dee519e 100644 --- a/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift @@ -47,7 +47,7 @@ final class EditorOverrideProvider: Sendable { /// The name used to identify this provider. - static let editorProviderName = String(localized: "editorOverrideProvider.name", bundle: #bundle) + static let providerName = "EditorOverrideProvider" /// The UserDefaults suite name used for persistence. static let suiteName = "devkit.DevConfiguration" @@ -323,7 +323,7 @@ extension EditorOverrideProvider { extension EditorOverrideProvider: ConfigProvider { var providerName: String { - Self.editorProviderName + Self.providerName } diff --git a/Sources/DevConfiguration/Editor/Views/ProviderBadge.swift b/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift similarity index 75% rename from Sources/DevConfiguration/Editor/Views/ProviderBadge.swift rename to Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift index 66af2fd..1244d8c 100644 --- a/Sources/DevConfiguration/Editor/Views/ProviderBadge.swift +++ b/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift @@ -21,6 +21,11 @@ struct ProviderBadge: View { /// The color to use for the badge. let color: Color + /// Whether this badge represents the active provider. + /// + /// Inactive badges use a muted gray style. + var isActive: Bool = true + var body: some View { Text(providerName) @@ -28,8 +33,8 @@ struct ProviderBadge: View { .fontWeight(.medium) .padding(.horizontal, 8) .padding(.vertical, 2) - .foregroundStyle(.white) - .background(color, in: .capsule) + .foregroundStyle(isActive ? .white : .secondary) + .background(isActive ? color : Color(white: 0.9), in: .capsule) } } @@ -41,7 +46,7 @@ struct ProviderBadge: View { /// - Parameter index: The provider's index in the reader's provider list. /// - Returns: A color for the provider. func providerColor(at index: Int) -> Color { - let palette: [Color] = [.blue, .green, .yellow, .orange, .red, .indigo, .purple, .mint, .cyan] + let palette: [Color] = [.blue, .green, .indigo, .gray, .cyan, .yellow, .orange, .purple, .mint, .red] return palette[index % palette.count] } @@ -51,6 +56,8 @@ func providerColor(at index: Int) -> Color { ForEach(Array(0 ..< 9), id: \.self) { index in ProviderBadge(providerName: "Provider \(index)", color: providerColor(at: index)) } + + ProviderBadge(providerName: "Provider 9", color: .red, isActive: false) } .padding() } diff --git a/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift b/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift similarity index 86% rename from Sources/DevConfiguration/Editor/View Models/ProviderValue.swift rename to Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift index 74d3177..79e7b3c 100644 --- a/Sources/DevConfiguration/Editor/View Models/ProviderValue.swift +++ b/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift @@ -16,6 +16,9 @@ struct ProviderValue: Hashable, Sendable { /// The index of the provider in the reader's provider list, used for color assignment. let providerIndex: Int + /// Whether this provider is the one currently supplying the resolved value. + let isActive: Bool + /// The provider's value for the variable, formatted as a display string. let valueString: String } diff --git a/Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift b/Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift deleted file mode 100644 index d07128d..0000000 --- a/Sources/DevConfiguration/Editor/Views/ConfigVariableEditor.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// ConfigVariableEditor.swift -// DevConfiguration -// -// Created by Prachi Gauriar on 3/8/2026. -// - -#if canImport(SwiftUI) - -import SwiftUI - -/// A SwiftUI view that presents the configuration variable editor. -/// -/// `ConfigVariableEditor` is the public entry point for the editor UI. It is initialized with a -/// ``ConfigVariableReader`` that has editor support enabled and an `onSave` closure that receives the registered -/// variables whose overrides changed. -/// -/// The consumer is responsible for presentation (sheet, full-screen cover, navigation push, etc.). -/// -/// .sheet(isPresented: $isEditorPresented) { -/// ConfigVariableEditor(reader: reader) { changedVariables in -/// // Handle changed variables -/// } onCancel: { -/// isEditorPresented = false -/// } -/// } -public struct ConfigVariableEditor: View { - /// The list view model created from the reader. - @State private var viewModel: ConfigVariableListViewModel - - /// The closure to call with the changed variables when the user saves. - private let onSave: ([RegisteredConfigVariable]) -> Void - - /// The closure to call when the user cancels editing. - private let onCancel: () -> Void - - - /// Creates a new configuration variable editor. - /// - /// - Parameters: - /// - reader: The configuration variable reader. Must have been created with `isEditorEnabled` set to `true`. - /// - onSave: A closure called with the registered variables whose overrides changed when the user saves. - /// - onCancel: A closure called when the user cancels editing. - public init( - reader: ConfigVariableReader, - onSave: @escaping ([RegisteredConfigVariable]) -> Void, - onCancel: @escaping () -> Void - ) { - guard let editorOverrideProvider = reader.editorOverrideProvider else { - preconditionFailure( - "ConfigVariableEditor requires a ConfigVariableReader with isEditorEnabled set to true" - ) - } - - let undoManager = UndoManager() - let document = EditorDocument(provider: editorOverrideProvider, undoManager: undoManager) - self._viewModel = State( - initialValue: ConfigVariableListViewModel( - document: document, - registeredVariables: reader.registeredVariables, - providers: reader.providers, - undoManager: undoManager - ) - ) - self.onSave = onSave - self.onCancel = onCancel - } - - - public var body: some View { - ConfigVariableEditorView(viewModel: viewModel, onSave: onSave, onCancel: onCancel) - } -} - -#endif diff --git a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift index 9de290d..84557f0 100644 --- a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift +++ b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift @@ -30,6 +30,27 @@ extension ConfigContent { } +// MARK: - Type Display Name + +extension ConfigContent { + /// A human-readable name for this content's type. + var typeDisplayName: String { + switch self { + case .bool: "Bool" + case .int: "Int" + case .double: "Float64" + case .string: "String" + case .bytes: "Data" + case .boolArray: "[Bool]" + case .intArray: "[Int]" + case .doubleArray: "[Float64]" + case .stringArray: "[String]" + case .byteChunkArray: "[Data]" + } + } +} + + // MARK: - Display String extension ConfigContent { diff --git a/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift b/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift index 4f333e0..d26ff7d 100644 --- a/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift +++ b/Sources/DevConfiguration/Metadata/DisplayNameMetadataKey.swift @@ -10,7 +10,7 @@ import Foundation /// The metadata key for a human-readable display name. private struct DisplayNameMetadataKey: ConfigVariableMetadataKey { static let defaultValue: String? = nil - static let keyDisplayText = String(localized: "displayNameMetadata.keyDisplayText", bundle: #bundle) + static let keyDisplayText = localizedString("displayNameMetadata.keyDisplayText") } diff --git a/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift b/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift index c5bb380..7f0bdfa 100644 --- a/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift +++ b/Sources/DevConfiguration/Metadata/RequiresRelaunchMetadataKey.swift @@ -10,7 +10,7 @@ import Foundation /// The metadata key indicating that changes to a variable require an app relaunch to take effect. private struct RequiresRelaunchMetadataKey: ConfigVariableMetadataKey { static let defaultValue = false - static let keyDisplayText = String(localized: "requiresRelaunchMetadata.keyDisplayText", bundle: #bundle) + static let keyDisplayText = localizedString("requiresRelaunchMetadata.keyDisplayText") } diff --git a/Sources/DevConfiguration/Resources/Localizable.xcstrings b/Sources/DevConfiguration/Resources/Localizable.xcstrings index 0c4e542..dff81f3 100644 --- a/Sources/DevConfiguration/Resources/Localizable.xcstrings +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -1,252 +1,342 @@ { "sourceLanguage" : "en", "strings" : { - "displayNameMetadata.keyDisplayText" : { + "detailView.headerSection.key" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Display Name" + "value" : "Key" } } } }, - "editorOverrideProvider.name" : { + "detailView.headerSection.type" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Editor" + "value" : "Type" } } } }, - "requiresRelaunchMetadata.keyDisplayText" : { + "detailView.metadataSection.header" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Requires Relaunch" + "value" : "Metadata" } } } }, - "editorView.cancelButton" : { + "detailView.overridenSection.valuePickerFalse" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel" + "value" : "False" } } } }, - "editorView.clearAlert.clearButton" : { + "detailView.overridenSection.valuePickerTrue" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Clear" + "value" : "True" } } } }, - "editorView.clearAlert.message" : { + "detailView.overrideSection.addOverride" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "This will remove all editor overrides. You can undo this action." + "value" : "Add" } } } }, - "editorView.clearAlert.title" : { + "detailView.overrideSection.editorOverrideLabel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Clear All Overrides?" + "value" : "Editor Override" } } } }, - "editorView.clearOverridesButton" : { + "detailView.overrideSection.header" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Clear Editor Overrides" + "value" : "Override" } } } }, - "editorView.discardAlert.discardButton" : { + "detailView.overrideSection.removeOverride" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Discard" + "value" : "Remove" } } } }, - "editorView.discardAlert.keepEditingButton" : { + "detailView.overrideSection.valueLabel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keep Editing" + "value" : "Value" } } } }, - "editorView.discardAlert.message" : { + "detailView.overrideSection.valueTextField" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "You have unsaved changes that will be lost." + "value" : "Value" } } } }, - "editorView.discardAlert.title" : { + "detailView.providerValuesSection.header" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Discard Changes?" + "value" : "Values" } } } }, - "editorView.navigationTitle" : { + "detailView.providerValuesSection.hideValues" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Configuration Editor" + "value" : "Hide Values" } } } }, - "editorView.overflowMenu.label" : { + "detailView.providerValuesSection.tapToReveal" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "More" + "value" : "Show Values" } } } }, - "editorView.redoButton" : { + "displayNameMetadata.keyDisplayText" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Redo" + "value" : "Display Name" } } } }, - "editorView.saveButton" : { + "editor.defaultProviderName" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Save" + "value" : "Default" } } } }, - "editorView.undoButton" : { + "editorOverrideProvider.name" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Undo" + "value" : "Editor" } } } }, - "detailView.metadataSection.header" : { + "editorView.clearAlert.clearButton" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Metadata" + "value" : "Clear" } } } }, - "detailView.overrideSection.enableToggle" : { + "editorView.clearAlert.message" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enable Override" + "value" : "This will remove all editor overrides. You can undo this action." } } } }, - "detailView.overrideSection.header" : { + "editorView.clearAlert.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Override" + "value" : "Clear All Overrides?" } } } }, - "detailView.overrideSection.valueTextField" : { + "editorView.clearOverridesButton" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Value" + "value" : "Clear Overrides" } } } }, - "detailView.overrideSection.valueToggle" : { + "editorView.dismissButton" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Value" + "value" : "Dismiss" } } } }, - "detailView.providerValuesSection.header" : { + "editorView.navigationTitle" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Provider Values" + "value" : "Configuration Editor" } } } }, - "detailView.providerValuesSection.tapToReveal" : { + "editorView.overflowMenu.label" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tap to Reveal" + "value" : "More" } } } }, - "variableListItem.unknownProviderName" : { + "editorView.redoButton" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unknown" + "value" : "Redo" + } + } + } + }, + "editorView.saveAlert.cancelButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "editorView.saveAlert.dontSaveButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard" + } + } + } + }, + "editorView.saveAlert.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you don’t save, your changes will be lost." + } + } + } + }, + "editorView.saveAlert.saveButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "editorView.saveAlert.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save Changes?" + } + } + } + }, + "editorView.saveButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "editorView.undoButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Undo" + } + } + } + }, + "editorView.variablesSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables" + } + } + } + }, + "requiresRelaunchMetadata.keyDisplayText" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires Relaunch" } } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderArrayTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderArrayTests.swift index dbe5473..a8bb46a 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderArrayTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderArrayTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderArrayTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() /// Sets a value in the provider for the given key with a random `isSecret` flag. diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderCodableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderCodableTests.swift index 9d011bc..268cc76 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderCodableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderCodableTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderCodableTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() /// Sets a value in the provider for the given key with a random `isSecret` flag. diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderConfigExpressionTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderConfigExpressionTests.swift index a4e296c..08cb7f2 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderConfigExpressionTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderConfigExpressionTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderConfigExpressionTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() /// Sets a value in the provider for the given key with a random `isSecret` flag. diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderDataRepresentationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderDataRepresentationTests.swift index 1de3ed1..c7dd74d 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderDataRepresentationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderDataRepresentationTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderDataRepresentationTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() /// Sets a value in the provider for the given key with a random `isSecret` flag. diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/ConfigVariableReaderEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift similarity index 78% rename from Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/ConfigVariableReaderEditorTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift index 141f3a2..a3b9799 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/ConfigVariableReaderEditorTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift @@ -19,7 +19,10 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { @Test func editorDisabledByDefault() { // set up - let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) + let reader = ConfigVariableReader( + namedProviders: [.init(InMemoryProvider(values: [:]))], + eventBus: EventBus() + ) // expect #expect(reader.editorOverrideProvider == nil) @@ -30,7 +33,7 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { func editorDisabledExplicitly() { // set up let reader = ConfigVariableReader( - providers: [InMemoryProvider(values: [:])], + namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus(), isEditorEnabled: false ) @@ -43,7 +46,7 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { @Test func editorEnabledCreatesProvider() { // set up - let reader = ConfigVariableReader(providers: [], eventBus: EventBus(), isEditorEnabled: true) + let reader = ConfigVariableReader(namedProviders: [], eventBus: EventBus(), isEditorEnabled: true) // expect #expect(reader.editorOverrideProvider != nil) @@ -55,14 +58,14 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { // set up let otherProvider = InMemoryProvider(values: [:]) let reader = ConfigVariableReader( - providers: [otherProvider], + namedProviders: [.init(otherProvider)], eventBus: EventBus(), isEditorEnabled: true ) // expect - #expect(reader.providers.count == 2) - #expect(reader.providers.first is EditorOverrideProvider) + #expect(reader.namedProviders.count == 2) + #expect(reader.namedProviders.first?.provider is EditorOverrideProvider) } @@ -79,7 +82,7 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { ] ) let reader = ConfigVariableReader( - providers: [otherProvider], + namedProviders: [.init(otherProvider)], eventBus: EventBus(), isEditorEnabled: true ) @@ -104,7 +107,7 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { @Test func convenienceInitPassesIsEditorEnabled() { // set up - let reader = ConfigVariableReader(providers: [], eventBus: EventBus(), isEditorEnabled: true) + let reader = ConfigVariableReader(namedProviders: [], eventBus: EventBus(), isEditorEnabled: true) // expect #expect(reader.editorOverrideProvider != nil) diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift index 3f12480..d3c666d 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() /// Sets a value in the provider for the given key with a random `isSecret` flag. diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift index b731469..62e6bfa 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift @@ -20,7 +20,7 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { @Test mutating func registerStoresVariableWithCorrectProperties() throws { // set up - let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) var metadata = ConfigVariableMetadata() metadata[TestTeamMetadataKey.self] = randomAlphanumericString() @@ -49,7 +49,7 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { @Test mutating func registerMultipleVariablesStoresAll() { // set up - let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) let key1 = randomConfigKey() let key2 = randomConfigKey() let variable1 = ConfigVariable(key: key1, defaultValue: randomBool()) @@ -71,7 +71,7 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { func registerDuplicateKeyHalts() async { await #expect(processExitsWith: .failure) { let reader = ConfigVariableReader( - providers: [InMemoryProvider(values: [:])], + namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus() ) let variable1 = ConfigVariable(key: "duplicate.key", defaultValue: 1) @@ -87,7 +87,7 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { func registerWithEncodeFailureHalts() async { await #expect(processExitsWith: .failure) { let reader = ConfigVariableReader( - providers: [InMemoryProvider(values: [:])], + namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus() ) let variable = ConfigVariable( diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderScalarTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderScalarTests.swift index 1116bef..d20c5ae 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderScalarTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderScalarTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderScalarTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() /// Sets a value in the provider for the given key with a random `isSecret` flag. diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift index 4d6086f..242d8a4 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift @@ -24,7 +24,7 @@ struct ConfigVariableReaderTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() @@ -109,7 +109,7 @@ struct ConfigVariableReaderTests: RandomValueGenerating { let isNotPublic = [.secret, .auto].contains(secrecy) let isSecret = secrecy == .secret - let reader = ConfigVariableReader(providers: [InMemoryProvider(values: [:])], eventBus: EventBus()) + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) #expect(reader.isSecret(intVariable) == isSecret) #expect(reader.isSecret(stringVariable) == isNotPublic) #expect(reader.isSecret(stringArrayVariable) == isNotPublic) diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift similarity index 99% rename from Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift index 1368a91..ae7ca1a 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableDetailViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift @@ -369,7 +369,7 @@ extension ConfigVariableDetailViewModelTests { return ConfigVariableDetailViewModel( variable: variable, document: effectiveDocument, - providers: providers + namedProviders: providers.map { NamedConfigProvider($0) } ) } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift similarity index 93% rename from Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift rename to Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift index 0e7790f..65a58f2 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/View Models/ConfigVariableListViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift @@ -83,7 +83,7 @@ struct ConfigVariableListViewModelTests: RandomValueGenerating { // expect #expect(item.currentValue == overrideContent.displayString) - #expect(item.providerName == EditorOverrideProvider.editorProviderName) + #expect(item.providerName == EditorOverrideProvider.providerName) #expect(item.hasOverride) } @@ -131,7 +131,7 @@ struct ConfigVariableListViewModelTests: RandomValueGenerating { // expect #expect(item.currentValue == defaultContent.displayString) - #expect(item.providerName != "variableListItem.unknownProviderName") + #expect(item.providerName != "editor.defaultProviderName") } @@ -343,28 +343,6 @@ struct ConfigVariableListViewModelTests: RandomValueGenerating { } - // MARK: - Cancel - - @Test - mutating func cancelDoesNotModifyDocument() { - // set up - let key = randomConfigKey() - let content = randomConfigContent() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(content, forKey: key) - - let viewModel = makeListViewModel(document: document) - - // exercise - viewModel.cancel() - - // expect - #expect(document.override(forKey: key) == content) - #expect(document.isDirty) - } - - // MARK: - Detail View Model @Test @@ -397,7 +375,7 @@ extension ConfigVariableListViewModelTests { return ConfigVariableListViewModel( document: effectiveDocument, registeredVariables: registeredVariables, - providers: providers, + namedProviders: providers.map { NamedConfigProvider($0) }, undoManager: undoManager ) } From ce5ae16f5f4d056459dc6a75366e952bea795c41 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Mon, 9 Mar 2026 15:25:30 -0400 Subject: [PATCH 7/9] Editor UI refactor --- .../xcschemes/ExampleApp.xcscheme | 2 +- App/Sources/App/ContentViewModel.swift | 10 +- .../Core/CodableValueRepresentation.swift | 11 - .../Core/ConfigVariable.swift | 121 +-- .../Core/ConfigVariableContent.swift | 25 +- .../Core/ConfigVariableReader.swift | 28 +- .../Core/ConfigVariableSecrecy.swift | 30 - .../Core/RegisteredConfigVariable.swift | 9 +- .../Documentation.docc/Documentation.md | 1 - .../ConfigVariableDetailView.swift | 111 +-- .../ConfigVariableDetailViewModel.swift | 194 ++--- .../ConfigVariableDetailViewModeling.swift | 52 +- .../ConfigVariableListView.swift | 134 +-- .../ConfigVariableListViewModel.swift | 156 ++-- .../ConfigVariableListViewModeling.swift | 59 +- .../VariableListItem.swift | 4 +- .../Editor/ConfigVariableEditor.swift | 31 +- .../Editor/Data Models/EditorDocument.swift | 448 +++++++--- .../Data Models/ProviderEditorSnapshot.swift | 24 + .../Editor/Utilities/ProviderBadge.swift | 15 +- .../Editor/Utilities/ProviderValue.swift | 7 +- .../ConfigSnapshot+ConfigContent.swift | 43 + ...ndomValueGenerating+DevConfiguration.swift | 29 +- .../ConfigVariableReaderEditorTests.swift | 2 +- ...onfigVariableReaderRegistrationTests.swift | 8 +- .../Core/ConfigVariableReaderTests.swift | 96 --- .../Unit Tests/Core/ConfigVariableTests.swift | 15 +- .../Core/RegisteredConfigVariableTests.swift | 2 + .../ConfigVariableDetailViewModelTests.swift | 470 +++++----- .../ConfigVariableListViewModelTests.swift | 543 ++++++------ .../Data Models/EditorDocumentTests.swift | 805 +++++++++--------- .../ConfigContent+AdditionsTests.swift | 19 + 32 files changed, 1714 insertions(+), 1790 deletions(-) delete mode 100644 Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift create mode 100644 Sources/DevConfiguration/Editor/Data Models/ProviderEditorSnapshot.swift create mode 100644 Sources/DevConfiguration/Extensions/ConfigSnapshot+ConfigContent.swift diff --git a/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme b/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme index cfb7742..54b7adb 100644 --- a/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme +++ b/App/App.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme @@ -58,7 +58,7 @@ diff --git a/App/Sources/App/ContentViewModel.swift b/App/Sources/App/ContentViewModel.swift index abba6da..627508c 100644 --- a/App/Sources/App/ContentViewModel.swift +++ b/App/Sources/App/ContentViewModel.swift @@ -21,7 +21,7 @@ final class ContentViewModel { .metadata(\.displayName, "Newton’s Gravitational Constant") let intVariable = ConfigVariable(key: "configurationRefreshInterval", defaultValue: 1000) .metadata(\.displayName, "Configuration Refresh Interval (ms)") - let stringVariable = ConfigVariable(key: "appName", defaultValue: "Example", secrecy: .public) + let stringVariable = ConfigVariable(key: "appName", defaultValue: "Example") .metadata(\.displayName, "App Name") let boolArrayVariable = ConfigVariable(key: "bool_array", defaultValue: [false, true, true, false]) @@ -32,18 +32,16 @@ final class ContentViewModel { .metadata(\.displayName, "Int Array Example") let stringArrayVariable = ConfigVariable( key: "string_array", - defaultValue: ["Thom", "Jonny", "Ed", "Colin", "Phil"], - secrecy: .public + defaultValue: ["Thom", "Jonny", "Ed", "Colin", "Phil"] ).metadata(\.displayName, "String Array Example") let jsonVariable = ConfigVariable( key: "complexConfig", defaultValue: ComplexConfiguration(field1: "a", field2: 1), - content: .json(representation: .data), - secrecy: .public + content: .json(representation: .data) ).metadata(\.displayName, "Complex Config") - let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades) + let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades, isSecret: true) .metadata(\.displayName, "Favorite Card Suit") let stringBackedVariable = ConfigVariable(key: "favoriteBeatle", defaultValue: Beatle.john) diff --git a/Sources/DevConfiguration/Core/CodableValueRepresentation.swift b/Sources/DevConfiguration/Core/CodableValueRepresentation.swift index 3847f41..922a6b5 100644 --- a/Sources/DevConfiguration/Core/CodableValueRepresentation.swift +++ b/Sources/DevConfiguration/Core/CodableValueRepresentation.swift @@ -48,17 +48,6 @@ public struct CodableValueRepresentation: Sendable { } - /// Whether this representation uses string-backed storage. - var isStringBacked: Bool { - switch kind { - case .string: - true - case .data: - false - } - } - - /// Reads raw data synchronously from the reader based on this representation. /// /// For string-backed representations, this reads a string value and converts it to `Data` using the representation’s diff --git a/Sources/DevConfiguration/Core/ConfigVariable.swift b/Sources/DevConfiguration/Core/ConfigVariable.swift index bcba29e..853539f 100644 --- a/Sources/DevConfiguration/Core/ConfigVariable.swift +++ b/Sources/DevConfiguration/Core/ConfigVariable.swift @@ -39,8 +39,11 @@ public struct ConfigVariable: Sendable where Value: Sendable { /// Describes how this variable’s value maps to and from `ConfigContent` primitives. public let content: Content - /// Whether this value should be treated as a secret. - public let secrecy: ConfigVariableSecrecy + /// Whether this variable’s value should be treated as secret. + /// + /// Secret values are redacted or obfuscated in telemetry, logging, and other observability systems to prevent + /// sensitive information from being exposed. Defaults to `false`. + public let isSecret: Bool /// The configuration variable’s metadata. private(set) var metadata = ConfigVariableMetadata() @@ -52,12 +55,12 @@ public struct ConfigVariable: Sendable where Value: Sendable { /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. /// - content: Describes how the value maps to and from `ConfigContent` primitives. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, content: Content, secrecy: ConfigVariableSecrecy = .auto) { + /// - isSecret: Whether this variable’s value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, content: Content, isSecret: Bool = false) { self.key = key self.defaultValue = defaultValue self.content = content - self.secrecy = secrecy + self.isSecret = isSecret } @@ -116,9 +119,9 @@ extension ConfigVariable where Value == Bool { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Bool, secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .bool, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Bool, isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .bool, isSecret: isSecret) } } @@ -131,9 +134,9 @@ extension ConfigVariable where Value == [Bool] { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Bool], secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .boolArray, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Bool], isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .boolArray, isSecret: isSecret) } } @@ -146,9 +149,9 @@ extension ConfigVariable where Value == Float64 { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Float64, secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .float64, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Float64, isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .float64, isSecret: isSecret) } } @@ -161,9 +164,9 @@ extension ConfigVariable where Value == [Float64] { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Float64], secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .float64Array, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Float64], isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .float64Array, isSecret: isSecret) } } @@ -176,9 +179,9 @@ extension ConfigVariable where Value == Int { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Int, secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .int, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Int, isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .int, isSecret: isSecret) } } @@ -191,9 +194,9 @@ extension ConfigVariable where Value == [Int] { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Int], secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .intArray, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Int], isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .intArray, isSecret: isSecret) } } @@ -206,9 +209,9 @@ extension ConfigVariable where Value == String { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: String, secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .string, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: String, isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .string, isSecret: isSecret) } } @@ -221,9 +224,9 @@ extension ConfigVariable where Value == [String] { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [String], secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .stringArray, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [String], isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .stringArray, isSecret: isSecret) } } @@ -236,9 +239,9 @@ extension ConfigVariable where Value == [UInt8] { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [UInt8], secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .bytes, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [UInt8], isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .bytes, isSecret: isSecret) } } @@ -251,9 +254,9 @@ extension ConfigVariable where Value == [[UInt8]] { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [[UInt8]], secrecy: ConfigVariableSecrecy = .auto) { - self.init(key: key, defaultValue: defaultValue, content: .byteChunkArray, secrecy: secrecy) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [[UInt8]], isSecret: Bool = false) { + self.init(key: key, defaultValue: defaultValue, content: .byteChunkArray, isSecret: isSecret) } } @@ -269,10 +272,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false) where Value: RawRepresentable & Sendable, Value.RawValue == String { - self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableString(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableString(), isSecret: isSecret) } } @@ -285,10 +288,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Element], secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Element], isSecret: Bool = false) where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == String { - self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableStringArray(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableStringArray(), isSecret: isSecret) } } @@ -302,10 +305,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false) where Value: ExpressibleByConfigString { - self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigString(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigString(), isSecret: isSecret) } } @@ -318,10 +321,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Element], secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Element], isSecret: Bool = false) where Value == [Element], Element: ExpressibleByConfigString & Sendable { - self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigStringArray(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigStringArray(), isSecret: isSecret) } } @@ -337,10 +340,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false) where Value: RawRepresentable & Sendable, Value.RawValue == Int { - self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableInt(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableInt(), isSecret: isSecret) } } @@ -353,10 +356,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Element], secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Element], isSecret: Bool = false) where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == Int { - self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableIntArray(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableIntArray(), isSecret: isSecret) } } @@ -370,10 +373,10 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: Value, secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false) where Value: ExpressibleByConfigInt { - self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigInt(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigInt(), isSecret: isSecret) } } @@ -386,9 +389,9 @@ extension ConfigVariable { /// - Parameters: /// - key: The configuration key. /// - defaultValue: The default value to use when variable resolution fails. - /// - secrecy: The secrecy setting for this variable. Defaults to `.auto`. - public init(key: ConfigKey, defaultValue: [Element], secrecy: ConfigVariableSecrecy = .auto) + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: [Element], isSecret: Bool = false) where Value == [Element], Element: ExpressibleByConfigInt & Sendable { - self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigIntArray(), secrecy: secrecy) + self.init(key: key, defaultValue: defaultValue, content: .expressibleByConfigIntArray(), isSecret: isSecret) } } diff --git a/Sources/DevConfiguration/Core/ConfigVariableContent.swift b/Sources/DevConfiguration/Core/ConfigVariableContent.swift index 44469e6..8dc56be 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableContent.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableContent.swift @@ -12,8 +12,7 @@ import Foundation /// Describes how a ``ConfigVariable`` value maps to and from `ConfigContent` primitives. /// /// `ConfigVariableContent` encapsulates which `ConfigReader` method to call, how to decode the raw primitive into the -/// variable’s value type, and how to encode the value back for registration. It also determines secrecy behavior based -/// on the underlying content type. +/// variable’s value type, and how to encode the value back for registration. /// /// For primitive types like `Bool`, `Int`, `String`, etc., you typically don’t need to interact with this type /// directly — ``ConfigVariable`` initializers set the appropriate content automatically. For `Codable` types, you @@ -26,9 +25,6 @@ import Foundation /// content: .json() /// ) public struct ConfigVariableContent: Sendable where Value: Sendable { - /// Whether `.auto` secrecy treats this content type as secret. - public let isAutoSecret: Bool - /// Reads the value synchronously from a `ConfigReader`. let read: @Sendable ( @@ -86,7 +82,6 @@ extension ConfigVariableContent where Value == Bool { /// Content for `Bool` values. public static var bool: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.bool(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -124,7 +119,6 @@ extension ConfigVariableContent where Value == [Bool] { /// Content for `[Bool]` values. public static var boolArray: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.boolArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -162,7 +156,6 @@ extension ConfigVariableContent where Value == Float64 { /// Content for `Float64` values. public static var float64: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.double(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -200,7 +193,6 @@ extension ConfigVariableContent where Value == [Float64] { /// Content for `[Float64]` values. public static var float64Array: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.doubleArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -238,7 +230,6 @@ extension ConfigVariableContent where Value == Int { /// Content for `Int` values. public static var int: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.int(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -276,7 +267,6 @@ extension ConfigVariableContent where Value == [Int] { /// Content for `[Int]` values. public static var intArray: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.intArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -314,7 +304,6 @@ extension ConfigVariableContent where Value == String { /// Content for `String` values. public static var string: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: true, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.string(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -352,7 +341,6 @@ extension ConfigVariableContent where Value == [String] { /// Content for `[String]` values. public static var stringArray: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: true, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.stringArray(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -390,7 +378,6 @@ extension ConfigVariableContent where Value == [UInt8] { /// Content for `[UInt8]` (bytes) values. public static var bytes: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.bytes(forKey: key, isSecret: isSecret, default: defaultValue, fileID: fileID, line: line) }, @@ -428,7 +415,6 @@ extension ConfigVariableContent where Value == [[UInt8]] { /// Content for `[[UInt8]]` (byte chunk array) values. public static var byteChunkArray: ConfigVariableContent { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.byteChunkArray( forKey: key, @@ -475,7 +461,6 @@ extension ConfigVariableContent { public static func rawRepresentableString() -> ConfigVariableContent where Value: RawRepresentable & Sendable, Value.RawValue == String { ConfigVariableContent( - isAutoSecret: true, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.string( forKey: key, @@ -521,7 +506,6 @@ extension ConfigVariableContent { public static func rawRepresentableStringArray() -> ConfigVariableContent where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == String { ConfigVariableContent( - isAutoSecret: true, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.stringArray( forKey: key, @@ -566,7 +550,6 @@ extension ConfigVariableContent { /// Content for `ExpressibleByConfigString` values. public static func expressibleByConfigString() -> ConfigVariableContent where Value: ExpressibleByConfigString { ConfigVariableContent( - isAutoSecret: true, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.string( forKey: key, @@ -612,7 +595,6 @@ extension ConfigVariableContent { public static func expressibleByConfigStringArray() -> ConfigVariableContent where Value == [Element], Element: ExpressibleByConfigString & Sendable { ConfigVariableContent( - isAutoSecret: true, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.stringArray( forKey: key, @@ -662,7 +644,6 @@ extension ConfigVariableContent { public static func rawRepresentableInt() -> ConfigVariableContent where Value: RawRepresentable & Sendable, Value.RawValue == Int { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.int( forKey: key, @@ -708,7 +689,6 @@ extension ConfigVariableContent { public static func rawRepresentableIntArray() -> ConfigVariableContent where Value == [Element], Element: RawRepresentable & Sendable, Element.RawValue == Int { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.intArray( forKey: key, @@ -753,7 +733,6 @@ extension ConfigVariableContent { /// Content for `ExpressibleByConfigInt` values. public static func expressibleByConfigInt() -> ConfigVariableContent where Value: ExpressibleByConfigInt { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.int( forKey: key, @@ -799,7 +778,6 @@ extension ConfigVariableContent { public static func expressibleByConfigIntArray() -> ConfigVariableContent where Value == [Element], Element: ExpressibleByConfigInt & Sendable { ConfigVariableContent( - isAutoSecret: false, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in reader.intArray( forKey: key, @@ -890,7 +868,6 @@ extension ConfigVariableContent { encoder: (any TopLevelEncoder & Sendable)? ) -> ConfigVariableContent where Value: Codable { ConfigVariableContent( - isAutoSecret: representation.isStringBacked, read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in guard let data = representation.readData( diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index 53d4235..10dd9f9 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -172,8 +172,9 @@ extension ConfigVariableReader { state.registeredVariables[variable.key] = RegisteredConfigVariable( key: variable.key, defaultContent: defaultContent, - isSecret: isSecret(variable), + isSecret: variable.isSecret, metadata: variable.metadata, + destinationTypeName: String(describing: Value.self), editorControl: variable.content.editorControl, parse: variable.content.parse ) @@ -197,7 +198,7 @@ extension ConfigVariableReader { fileID: String = #fileID, line: UInt = #line ) -> Value { - variable.content.read(reader, variable.key, isSecret(variable), variable.defaultValue, eventBus, fileID, line) + variable.content.read(reader, variable.key, variable.isSecret, variable.defaultValue, eventBus, fileID, line) } @@ -232,7 +233,7 @@ extension ConfigVariableReader { try await variable.content.fetch( reader, variable.key, - isSecret(variable), + variable.isSecret, variable.defaultValue, eventBus, fileID, @@ -261,7 +262,7 @@ extension ConfigVariableReader { // Capture these locally so that the @Sendable task closures below don’t need to capture `self`. let configReader = reader let eventBus = eventBus - let isSecret = isSecret(variable) + let isSecret = variable.isSecret let (stream, continuation) = AsyncStream.makeStream() // We use a task group with two concurrent tasks: one that watches the underlying provider for changes and @@ -306,22 +307,3 @@ extension ConfigVariableReader { } } } - - -// MARK: - Secrecy - -extension ConfigVariableReader { - /// Whether the given variable is secret. - /// - /// When secrecy is `.auto`, this defers to the variable’s content to determine the appropriate secrecy. - /// String- backed and codable content types default to secret, while numeric and boolean types default to public. - /// - /// - Parameter variable: The config variable whose secrecy is being determined. - func isSecret(_ variable: ConfigVariable) -> Bool { - let resolvedSecrecy = - variable.secrecy == .auto - ? (variable.content.isAutoSecret ? .secret : .public) - : variable.secrecy - return resolvedSecrecy == .secret - } -} diff --git a/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift b/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift deleted file mode 100644 index 3ee870d..0000000 --- a/Sources/DevConfiguration/Core/ConfigVariableSecrecy.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ConfigVariableSecrecy.swift -// DevConfiguration -// -// Created by Duncan Lewis on 1/7/2026. -// - -import Configuration - -/// Controls whether a configuration variable’s value is treated as secret. -/// -/// Variable secrecy determines how values are handled in telemetry, logging, and other observability systems. Secret -/// values are redacted or obfuscated to prevent sensitive information from being exposed. -public enum ConfigVariableSecrecy: CaseIterable, Sendable { - /// Treats `String`, `[String]`, and `String`-backed values as secret and all other types as public. - /// - /// This is the default secrecy level and provides sensible protection for most use cases. - case auto - - /// Always treat the value as secret. - /// - /// Use this for sensitive data that should never be logged or exposed, regardless of type. - case secret - - /// Never treat the value as secret. - /// - /// Use this when you explicitly want values to be visible in logs and telemetry, even if they are strings, - /// string arrays, or string-backed. - case `public` -} diff --git a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift index 2649cbb..dc2d418 100644 --- a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift +++ b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift @@ -21,13 +21,18 @@ public struct RegisteredConfigVariable: Sendable { public let defaultContent: ConfigContent /// Whether this variable's value should be treated as secret. - /// - /// This is resolved at registration time from the variable's ``ConfigVariableSecrecy`` setting and content type. public let isSecret: Bool /// The configuration variable's metadata. public let metadata: ConfigVariableMetadata + /// The name of the variable's Swift value type (e.g., `"Int"`, `"CardSuit"`). + /// + /// This is captured at registration time via `String(describing: Value.self)` and may differ from the content type + /// name when the variable uses a type that maps to a primitive content type (e.g., an `Int`-backed enum stored as + /// ``ConfigContent/int(_:)``). + public let destinationTypeName: String + /// The editor control to use when editing this variable's value in the editor UI. public let editorControl: EditorControl diff --git a/Sources/DevConfiguration/Documentation.docc/Documentation.md b/Sources/DevConfiguration/Documentation.docc/Documentation.md index 0d158f2..e56ae2b 100644 --- a/Sources/DevConfiguration/Documentation.docc/Documentation.md +++ b/Sources/DevConfiguration/Documentation.docc/Documentation.md @@ -20,7 +20,6 @@ configuration management with extensible metadata, a variable management UI, and - ``ConfigVariableMetadata`` - ``ConfigVariableMetadataKey`` -- ``ConfigVariableSecrecy`` ### Access Reporting diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift index 025f35b..606d40a 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift @@ -103,6 +103,7 @@ extension ConfigVariableDetailView { localizedStringResource("detailView.overrideSection.valueTextField"), text: $viewModel.overrideText ) + .onSubmit { viewModel.commitOverrideText() } .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) #if os(iOS) || os(visionOS) @@ -137,12 +138,14 @@ extension ConfigVariableDetailView { LabeledContent { Text(providerValue.valueString) .font(.caption.monospaced()) + .strikethrough(!providerValue.contentTypeMatches) } label: { ProviderBadge( providerName: providerValue.providerName, color: providerColor(at: providerValue.providerIndex), isActive: providerValue.isActive ) + .strikethrough(!providerValue.contentTypeMatches) } } @@ -169,112 +172,4 @@ extension ConfigVariableDetailView { } } - -// MARK: - Preview Support - -@MainActor @Observable -private final class PreviewDetailViewModel: ConfigVariableDetailViewModeling { - let key: ConfigKey - let displayName: String - let typeName: String - let metadataEntries: [ConfigVariableMetadata.DisplayText] - let providerValues: [ProviderValue] - let isSecret: Bool - let editorControl: EditorControl - - var isOverrideEnabled = false - var overrideText = "" - var overrideBool = false - var isSecretRevealed = false - - - init( - key: ConfigKey, - displayName: String, - typeName: String = "String", - metadataEntries: [ConfigVariableMetadata.DisplayText] = [], - providerValues: [ProviderValue] = [], - isSecret: Bool = false, - editorControl: EditorControl = .textField, - isOverrideEnabled: Bool = false, - overrideText: String = "", - overrideBool: Bool = false - ) { - self.key = key - self.displayName = displayName - self.typeName = typeName - self.metadataEntries = metadataEntries - self.providerValues = providerValues - self.isSecret = isSecret - self.editorControl = editorControl - self.isOverrideEnabled = isOverrideEnabled - self.overrideText = overrideText - self.overrideBool = overrideBool - } -} - - -#Preview("Text Field") { - NavigationStack { - ConfigVariableDetailView( - viewModel: PreviewDetailViewModel( - key: "feature.api_endpoint", - displayName: "API Endpoint", - metadataEntries: [ - .init(key: "Display Name", value: "API Endpoint"), - .init(key: "Requires Relaunch", value: "Yes"), - ], - providerValues: [ - ProviderValue( - providerName: "Remote", providerIndex: 1, isActive: false, - valueString: "https://api.example.com"), - ProviderValue( - providerName: "Default", providerIndex: 2, isActive: false, - valueString: "https://localhost:8080"), - ], - editorControl: .textField, - isOverrideEnabled: true, - overrideText: "https://staging.example.com" - ) - ) - } -} - - -#Preview("Toggle") { - NavigationStack { - ConfigVariableDetailView( - viewModel: PreviewDetailViewModel( - key: "feature.dark_mode", - displayName: "Dark Mode", - typeName: "Bool", - providerValues: [ - ProviderValue(providerName: "Remote", providerIndex: 1, isActive: false, valueString: "false") - ], - editorControl: .toggle, - isOverrideEnabled: true, - overrideBool: true - ) - ) - } -} - - -#Preview("Secret") { - NavigationStack { - ConfigVariableDetailView( - viewModel: PreviewDetailViewModel( - key: "service.api_key", - displayName: "API Key", - providerValues: [ - ProviderValue( - providerName: "Remote", providerIndex: 1, isActive: true, valueString: "sk-1234567890abcdef") - ], - isSecret: true, - editorControl: .textField - ) - ) - } -} - #endif diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift index 8f028b4..3eda2b1 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift @@ -2,7 +2,7 @@ // ConfigVariableDetailViewModel.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/8/2026. +// Created by Prachi Gauriar on 3/9/2026. // #if canImport(SwiftUI) @@ -10,174 +10,116 @@ import Configuration import Foundation -/// The concrete detail view model for a single configuration variable in the editor. +/// The concrete view model for the configuration variable detail view. /// -/// `ConfigVariableDetailViewModel` displays a variable's metadata, the value from each provider, and override -/// controls. It delegates override mutations to the ``EditorDocument`` and parses text input using the variable's -/// parse closure. -@MainActor @Observable +/// `ConfigVariableDetailViewModel` queries an ``EditorDocument`` for a single registered variable's data and manages +/// override editing state. It is the single source of truth for the detail view's display and interaction logic. +@MainActor +@Observable final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { - /// The registered variable this detail view model represents. - private let variable: RegisteredConfigVariable - - /// The editor document managing the working copy. + /// The document that owns the variable data. private let document: EditorDocument - /// The reader's named providers, queried for per-provider values. - private let namedProviders: [NamedConfigProvider] - - /// Whether the variable's secret value is currently revealed. - var isSecretRevealed = false + /// The registered variable this view model represents. + private let registeredVariable: RegisteredConfigVariable + let key: ConfigKey + let displayName: String + let typeName: String + let metadataEntries: [ConfigVariableMetadata.DisplayText] + let isSecret: Bool + let editorControl: EditorControl - var isSecret: Bool { - variable.isSecret - } + var overrideText = "" + var isSecretRevealed = false /// Creates a new detail view model. /// /// - Parameters: - /// - variable: The registered variable to display. - /// - document: The editor document managing the working copy. - /// - namedProviders: The reader's named providers. - init( - variable: RegisteredConfigVariable, - document: EditorDocument, - namedProviders: [NamedConfigProvider] - ) { - self.variable = variable + /// - document: The editor document. + /// - registeredVariable: The registered variable to display. + init(document: EditorDocument, registeredVariable: RegisteredConfigVariable) { self.document = document - self.namedProviders = namedProviders - } - - - var key: ConfigKey { - variable.key - } - - - var displayName: String { - variable.displayName ?? variable.key.description - } - - - var typeName: String { - variable.defaultContent.typeDisplayName + self.registeredVariable = registeredVariable + self.key = registeredVariable.key + self.displayName = registeredVariable.displayName ?? registeredVariable.key.description + self.typeName = registeredVariable.destinationTypeName + self.metadataEntries = registeredVariable.metadata.displayTextEntries + self.isSecret = registeredVariable.isSecret + self.editorControl = registeredVariable.editorControl + + if let content = document.override(forKey: registeredVariable.key) { + self.overrideText = content.displayString + } else if let resolved = document.resolvedValue(forKey: registeredVariable.key) { + self.overrideText = resolved.content.displayString + } } - var metadataEntries: [ConfigVariableMetadata.DisplayText] { - variable.metadata.displayTextEntries - } - + // MARK: - Provider Values var providerValues: [ProviderValue] { - let absoluteKey = AbsoluteConfigKey(variable.key) - let expectedType = variable.defaultContent.configType - let overrideContent = document.override(forKey: variable.key) - let hasOverride = overrideContent != nil - - var values: [ProviderValue] = [] - - // If there's a working copy override, show it as the editor provider value - if let overrideContent { - let editorIndex = - namedProviders.firstIndex { $0.provider.providerName == EditorOverrideProvider.providerName } ?? 0 - - values.append( - ProviderValue( - providerName: namedProviders[editorIndex].displayName, - providerIndex: editorIndex, - isActive: true, - valueString: overrideContent.displayString - ) - ) - } - - var foundActive = false - for (index, namedProvider) in namedProviders.enumerated() { - // Skip the editor provider since we handle it above from the working copy - if namedProvider.provider.providerName == EditorOverrideProvider.providerName { - continue - } - - guard - let result = try? namedProvider.provider.value(forKey: absoluteKey, type: expectedType), - let configValue = result.value - else { - continue - } - - let isActive = !hasOverride && !foundActive - foundActive = foundActive || isActive - - values.append( - ProviderValue( - providerName: namedProvider.displayName, - providerIndex: index, - isActive: isActive, - valueString: configValue.content.displayString - ) - ) - } - - // Always show the default value last - values.append( - ProviderValue( - providerName: localizedString("editor.defaultProviderName"), - providerIndex: namedProviders.count, - isActive: !hasOverride && !foundActive, - valueString: variable.defaultContent.displayString - ) - ) - - return values + document.providerValues(forKey: key) } + // MARK: - Override Management + var isOverrideEnabled: Bool { get { - document.hasOverride(forKey: variable.key) + document.hasOverride(forKey: key) } set { if newValue { - document.setOverride(variable.defaultContent, forKey: variable.key) + enableOverride() } else { - document.removeOverride(forKey: variable.key) + document.removeOverride(forKey: key) } } } - var overrideText: String { + var overrideBool: Bool { get { - return document.override(forKey: variable.key)?.displayString ?? "" + guard case .bool(let value) = document.override(forKey: key) else { + return false + } + return value } set { - guard let parse = variable.parse, let content = parse(newValue) else { - return - } - document.setOverride(content, forKey: variable.key) + document.setOverride(.bool(newValue), forKey: key) } } - var overrideBool: Bool { - get { - guard case .bool(let value) = document.override(forKey: variable.key) else { - return false - } - return value + func commitOverrideText() { + guard let parse = registeredVariable.parse else { + return } - set { - document.setOverride(.bool(newValue), forKey: variable.key) + + let text = overrideText + guard let content = parse(text) else { + return } + + document.setOverride(content, forKey: key) } - var editorControl: EditorControl { - variable.editorControl + // MARK: - Private + + /// Enables an override by setting the default content or the current resolved value. + private func enableOverride() { + let content: ConfigContent + if let resolved = document.resolvedValue(forKey: key) { + content = resolved.content + } else { + content = registeredVariable.defaultContent + } + + overrideText = content.displayString + document.setOverride(content, forKey: key) } } diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift index 3e9abca..f22b604 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift @@ -2,17 +2,19 @@ // ConfigVariableDetailViewModeling.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/8/2026. +// Created by Prachi Gauriar on 3/9/2026. // +#if canImport(SwiftUI) + import Configuration import Foundation -/// The view model protocol for the configuration variable detail view. +/// The interface for a configuration variable detail view's view model. /// -/// `ConfigVariableDetailViewModeling` defines the interface that the detail view uses to display a single -/// configuration variable's metadata, provider values, and override controls. It supports enabling and editing -/// overrides via the appropriate editor control, and toggling secret value visibility. +/// `ConfigVariableDetailViewModeling` defines the minimal interface that ``ConfigVariableDetailView`` needs to display +/// and edit a single configuration variable. The view binds to properties and calls methods on this protocol without +/// knowing the concrete implementation. @MainActor protocol ConfigVariableDetailViewModeling: Observable { /// The configuration key for this variable. @@ -21,36 +23,38 @@ protocol ConfigVariableDetailViewModeling: Observable { /// The human-readable display name for this variable. var displayName: String { get } - /// A human-readable name for this variable's value type, such as `"String"` or `"Int"`. + /// The type name to display in the header (e.g., `"Int"` or `"CardSuit"`). var typeName: String { get } - /// The metadata entries to display. + /// The metadata entries to display in the metadata section. var metadataEntries: [ConfigVariableMetadata.DisplayText] { get } - /// The value from each provider for this variable. + /// The provider values to display in the provider values section. var providerValues: [ProviderValue] { get } - /// Whether an editor override is enabled for this variable. - /// - /// Setting this to `true` enables the override with the variable's default value. Setting it to `false` removes - /// the override. + /// Whether this variable's value is secret. + var isSecret: Bool { get } + + /// The editor control to use for this variable's override. + var editorControl: EditorControl { get } + + /// Whether the user has enabled an override for this variable. var isOverrideEnabled: Bool { get set } - /// The override value as a string, for text-based editor controls. - /// - /// Setting this parses the string into a ``ConfigContent`` value using the variable's parse closure and updates - /// the working copy if parsing succeeds. + /// The text value for the override, used with text field and number field controls. var overrideText: String { get set } - /// The override value as a boolean, for toggle editor controls. + /// The boolean value for the override, used with toggle controls. var overrideBool: Bool { get set } - /// The editor control to use when editing this variable's value. - var editorControl: EditorControl { get } - - /// Whether this variable's value is secret. - var isSecret: Bool { get } - - /// Whether the variable's secret value is currently revealed. + /// Whether the secret value is currently revealed. var isSecretRevealed: Bool { get set } + + /// Commits the current override text to the document. + /// + /// Called when the user submits the text field. Parses the text into a ``ConfigContent`` and sets the override + /// on the document. + func commitOverrideText() } + +#endif diff --git a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift index dd55efb..cb2990b 100644 --- a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift @@ -20,14 +20,8 @@ import SwiftUI struct ConfigVariableListView: View { @State var viewModel: ViewModel - /// The closure to call with the changed variables when the user saves. - var onSave: ([RegisteredConfigVariable]) -> Void - @Environment(\.dismiss) private var dismiss - @State private var isShowingSaveAlert = false - @State private var isShowingClearAlert = false - var body: some View { NavigationStack { @@ -47,12 +41,12 @@ struct ConfigVariableListView: View { .navigationDestination(for: ConfigKey.self) { key in ConfigVariableDetailView(viewModel: viewModel.makeDetailViewModel(for: key)) } + .interactiveDismissDisabled(viewModel.isDirty) .searchable(text: $viewModel.searchText) .toolbar { toolbarContent } - .alert(localizedStringResource("editorView.saveAlert.title"), isPresented: $isShowingSaveAlert) { + .alert(localizedStringResource("editorView.saveAlert.title"), isPresented: $viewModel.isShowingSaveAlert) { Button(localizedStringResource("editorView.saveAlert.saveButton")) { - let changedVariables = viewModel.save() - onSave(changedVariables) + viewModel.save() dismiss() } .keyboardShortcut(.defaultAction) @@ -65,9 +59,12 @@ struct ConfigVariableListView: View { } message: { Text(localizedStringResource("editorView.saveAlert.message")) } - .alert(localizedStringResource("editorView.clearAlert.title"), isPresented: $isShowingClearAlert) { + .alert( + localizedStringResource("editorView.clearAlert.title"), + isPresented: $viewModel.isShowingClearAlert + ) { Button(localizedStringResource("editorView.clearAlert.clearButton"), role: .destructive) { - viewModel.clearAllOverrides() + viewModel.confirmClearAllOverrides() } Button(localizedStringResource("editorView.saveAlert.cancelButton"), role: .cancel) {} @@ -86,11 +83,7 @@ extension ConfigVariableListView { private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { Button { - if viewModel.isDirty { - isShowingSaveAlert = true - } else { - dismiss() - } + viewModel.requestDismiss { dismiss() } } label: { Label(localizedStringResource("editorView.dismissButton"), systemImage: "xmark") } @@ -98,8 +91,7 @@ extension ConfigVariableListView { ToolbarItem(placement: .confirmationAction) { Button { - let changedVariables = viewModel.save() - onSave(changedVariables) + viewModel.save() dismiss() } label: { Label(localizedStringResource("editorView.saveButton"), systemImage: "checkmark") @@ -126,7 +118,7 @@ extension ConfigVariableListView { Divider() Button(role: .destructive) { - isShowingClearAlert = true + viewModel.requestClearAllOverrides() } label: { Label(localizedStringResource("editorView.clearOverridesButton"), systemImage: "trash") } @@ -172,108 +164,4 @@ extension ConfigVariableListView { } } - -// MARK: - Preview Support - -@MainActor @Observable -private final class PreviewListViewModel: ConfigVariableListViewModeling { - var variables: [VariableListItem] - var searchText = "" - var isDirty: Bool - var canUndo = false - var canRedo = false - - - init(variables: [VariableListItem], isDirty: Bool = false) { - self.variables = variables - self.isDirty = isDirty - } - - - func save() -> [RegisteredConfigVariable] { [] } - func clearAllOverrides() {} - func undo() {} - func redo() {} - - - func makeDetailViewModel(for key: ConfigKey) -> PreviewEditorDetailViewModel { - PreviewEditorDetailViewModel(key: key, displayName: key.description) - } -} - - -@MainActor @Observable -private final class PreviewEditorDetailViewModel: ConfigVariableDetailViewModeling { - let key: ConfigKey - let displayName: String - let typeName = "String" - let metadataEntries: [ConfigVariableMetadata.DisplayText] = [] - let providerValues: [ProviderValue] = [] - let isSecret = false - let editorControl: EditorControl = .none - - var isOverrideEnabled = false - var overrideText = "" - var overrideBool = false - var isSecretRevealed = false - - - init(key: ConfigKey, displayName: String) { - self.key = key - self.displayName = displayName - } -} - - -#Preview { - ConfigVariableListView( - viewModel: PreviewListViewModel( - variables: [ - VariableListItem( - key: "feature.dark_mode", - displayName: "Dark Mode", - currentValue: "true", - providerName: "Editor", - providerIndex: 0, - isSecret: false, - hasOverride: true, - editorControl: .toggle - ), - VariableListItem( - key: "feature.api_endpoint", - displayName: "API Endpoint", - currentValue: "https://api.example.com", - providerName: "Remote", - providerIndex: 1, - isSecret: false, - hasOverride: false, - editorControl: .textField - ), - VariableListItem( - key: "feature.max_retries", - displayName: "Max Retries", - currentValue: "3", - providerName: "Default", - providerIndex: 2, - isSecret: false, - hasOverride: false, - editorControl: .numberField - ), - VariableListItem( - key: "feature.timeout", - displayName: "Timeout", - currentValue: "30.0", - providerName: "Remote", - providerIndex: 1, - isSecret: false, - hasOverride: false, - editorControl: .decimalField - ), - ], - isDirty: true - ), - onSave: { _ in } - ) -} - #endif diff --git a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift index 6b06b17..2868c94 100644 --- a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift @@ -2,7 +2,7 @@ // ConfigVariableListViewModel.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/8/2026. +// Created by Prachi Gauriar on 3/9/2026. // #if canImport(SwiftUI) @@ -10,162 +10,128 @@ import Configuration import Foundation -/// The concrete list view model for the configuration variable editor. +/// The concrete view model for the configuration variable list view. /// -/// `ConfigVariableListViewModel` owns an ``EditorDocument`` and provides a filtered, sorted list of -/// ``VariableListItem`` values for display. It resolves which provider owns each variable's value by querying -/// providers in order, delegates save/cancel/undo/redo to the document and undo manager, and creates detail view -/// models for individual variables. -@MainActor @Observable +/// `ConfigVariableListViewModel` queries an ``EditorDocument`` to build the list of variable items, handles search +/// filtering and sorting, and delegates save, undo, and redo operations to the document. +@MainActor +@Observable final class ConfigVariableListViewModel: ConfigVariableListViewModeling { - /// The editor document managing the working copy. + /// The document that owns the variable data. private let document: EditorDocument - /// The registered variables from the reader, keyed by configuration key. - private let registeredVariables: [ConfigKey: RegisteredConfigVariable] + /// The closure to call with the changed variables when the user saves. + private let onSave: ([RegisteredConfigVariable]) -> Void - /// The reader's named providers, queried in order for value resolution. - private let namedProviders: [NamedConfigProvider] - - /// The undo manager for the editor session. - private let undoManager: UndoManager - - - /// The current search text used to filter the variable list. var searchText = "" + var isShowingSaveAlert = false + var isShowingClearAlert = false /// Creates a new list view model. /// /// - Parameters: - /// - document: The editor document managing the working copy. - /// - registeredVariables: The registered variables from the reader. - /// - namedProviders: The reader's named providers, queried in order for value resolution. - /// - undoManager: The undo manager for the editor session. - init( - document: EditorDocument, - registeredVariables: [ConfigKey: RegisteredConfigVariable], - namedProviders: [NamedConfigProvider], - undoManager: UndoManager - ) { + /// - document: The editor document. + /// - onSave: A closure called with the registered variables whose overrides changed when the user saves. + init(document: EditorDocument, onSave: @escaping ([RegisteredConfigVariable]) -> Void) { self.document = document - self.registeredVariables = registeredVariables - self.namedProviders = namedProviders - self.undoManager = undoManager + self.onSave = onSave } + // MARK: - Variables + var variables: [VariableListItem] { - let items = registeredVariables.values.map { variable in - let (content, providerName, providerIndex) = resolvedValue(for: variable) + let items = document.registeredVariables.values.map { variable -> VariableListItem in + let displayName = variable.displayName ?? variable.key.description + let resolved = document.resolvedValue(forKey: variable.key) + return VariableListItem( key: variable.key, - displayName: variable.displayName ?? variable.key.description, - currentValue: content.displayString, - providerName: providerName, - providerIndex: providerIndex, + displayName: displayName, + currentValue: resolved?.content.displayString ?? "", + providerName: resolved?.providerDisplayName ?? "", + providerIndex: resolved?.providerIndex, isSecret: variable.isSecret, hasOverride: document.hasOverride(forKey: variable.key), editorControl: variable.editorControl ) } - let filtered = - searchText.isEmpty - ? items - : items.filter { item in + let filtered: [VariableListItem] + if searchText.isEmpty { + filtered = items + } else { + filtered = items.filter { item in item.displayName.localizedStandardContains(searchText) || item.key.description.localizedStandardContains(searchText) - || item.currentValue.localizedStandardContains(searchText) - || item.providerName.localizedStandardContains(searchText) } - - return filtered.sorted { (lhs, rhs) in - lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending } + + return filtered.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } } + // MARK: - Dirty Tracking + var isDirty: Bool { document.isDirty } var canUndo: Bool { - undoManager.canUndo + document.undoManager.canUndo } var canRedo: Bool { - undoManager.canRedo + document.undoManager.canRedo } - func save() -> [RegisteredConfigVariable] { - let changedKeys = document.save() - return changedKeys.compactMap { registeredVariables[$0] } + // MARK: - Actions + + func requestDismiss(_ dismiss: () -> Void) { + if isDirty { + isShowingSaveAlert = true + } else { + dismiss() + } } - func clearAllOverrides() { - document.removeAllOverrides() + func save() { + let changedKeys = document.changedKeys + document.save() + onSave(changedKeys.compactMap { document.registeredVariables[$0] }) } - func undo() { - undoManager.undo() + func requestClearAllOverrides() { + isShowingClearAlert = true } - func redo() { - undoManager.redo() + func confirmClearAllOverrides() { + document.removeAllOverrides() } - func makeDetailViewModel(for key: ConfigKey) -> ConfigVariableDetailViewModel { - guard let variable = registeredVariables[key] else { - preconditionFailure("No registered variable for key '\(key)'") - } - - return ConfigVariableDetailViewModel( - variable: variable, - document: document, - namedProviders: namedProviders - ) + func undo() { + document.undoManager.undo() } -} - -// MARK: - Value Resolution -extension ConfigVariableListViewModel { - /// Resolves the current value, owning provider name, and provider index for a registered variable. - /// - /// Checks the document's working copy first, then queries each provider in order. Falls back to the variable's - /// default content if no provider has a value. - private func resolvedValue(for variable: RegisteredConfigVariable) -> (ConfigContent, String, Int) { - if let override = document.override(forKey: variable.key) { - let editorIndex = - namedProviders.firstIndex { $0.provider.providerName == EditorOverrideProvider.providerName } ?? 0 - return (override, namedProviders[editorIndex].displayName, editorIndex) - } + func redo() { + document.undoManager.redo() + } - let absoluteKey = AbsoluteConfigKey(variable.key) - let expectedType = variable.defaultContent.configType - for (index, namedProvider) in namedProviders.enumerated() { - if let result = try? namedProvider.provider.value(forKey: absoluteKey, type: expectedType), - let value = result.value - { - return (value.content, namedProvider.displayName, index) - } - } + // MARK: - Detail View Model - return ( - variable.defaultContent, - localizedString("editor.defaultProviderName"), - namedProviders.count - ) + func makeDetailViewModel(for key: ConfigKey) -> ConfigVariableDetailViewModel { + let registeredVariable = document.registeredVariables[key]! + return ConfigVariableDetailViewModel(document: document, registeredVariable: registeredVariable) } } diff --git a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift index a1663d7..7dced20 100644 --- a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift @@ -2,54 +2,73 @@ // ConfigVariableListViewModeling.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/8/2026. +// Created by Prachi Gauriar on 3/9/2026. // +#if canImport(SwiftUI) + import Configuration import Foundation -/// The view model protocol for the configuration variable list view. -/// -/// `ConfigVariableListViewModeling` defines the interface that the list view uses to display and interact with -/// registered configuration variables. It provides a filtered and sorted list of variables, search functionality, -/// dirty tracking, undo/redo support, and the ability to save or cancel changes. +/// The interface for a configuration variable list view's view model. /// -/// Conforming types must also provide a factory method for creating detail view models for individual variables. +/// `ConfigVariableListViewModeling` defines the minimal interface that ``ConfigVariableListView`` needs to display and +/// manage the list of configuration variables. The view binds to properties and calls methods on this protocol without +/// knowing the concrete implementation. @MainActor protocol ConfigVariableListViewModeling: Observable { - /// The type of detail view model created by ``makeDetailViewModel(for:)``. + /// The associated detail view model type. associatedtype DetailViewModel: ConfigVariableDetailViewModeling - /// The filtered and sorted list of variables to display. + /// The filtered and sorted list of variable items to display. var variables: [VariableListItem] { get } - /// The current search text used to filter ``variables``. + /// The current search text for filtering variables. var searchText: String { get set } - /// Whether the editor document has unsaved changes. + /// Whether the working copy has unsaved changes. var isDirty: Bool { get } - /// Whether there is an undo action available. + /// Whether the undo manager can undo. var canUndo: Bool { get } - /// Whether there is a redo action available. + /// Whether the undo manager can redo. var canRedo: Bool { get } - /// Saves the current working copy and returns the registered variables whose overrides changed. - func save() -> [RegisteredConfigVariable] + /// Whether the save confirmation alert is showing. + var isShowingSaveAlert: Bool { get set } + + /// Whether the clear overrides confirmation alert is showing. + var isShowingClearAlert: Bool { get set } - /// Removes all editor overrides from the working copy. - func clearAllOverrides() + /// Requests dismissal of the editor. + /// + /// If the working copy has unsaved changes, this presents the save alert. Otherwise, it calls the dismiss closure + /// immediately. + /// + /// - Parameter dismiss: A closure that dismisses the editor view. + func requestDismiss(_ dismiss: () -> Void) - /// Undoes the most recent change. + /// Saves the working copy to the editor override provider. + func save() + + /// Requests clearing all overrides by presenting the clear confirmation alert. + func requestClearAllOverrides() + + /// Confirms clearing all overrides from the working copy. + func confirmClearAllOverrides() + + /// Undoes the last working copy change. func undo() - /// Redoes the most recently undone change. + /// Redoes the last undone working copy change. func redo() /// Creates a detail view model for the variable with the given key. /// - /// - Parameter key: The configuration key of the variable to inspect. + /// - Parameter key: The configuration key of the variable to display. /// - Returns: A detail view model for the variable. func makeDetailViewModel(for key: ConfigKey) -> DetailViewModel } + +#endif diff --git a/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift index 9d1a184..73ef865 100644 --- a/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift @@ -28,7 +28,9 @@ struct VariableListItem: Hashable, Sendable { let providerName: String /// The index of the provider in the reader's provider list, used for color assignment. - let providerIndex: Int + /// + /// This is `nil` when the working copy (editor override provider) owns the value. + let providerIndex: Int? /// Whether this variable's value is secret and should be redacted in the list. let isSecret: Bool diff --git a/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift b/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift index ec4dbf7..c687fb0 100644 --- a/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift +++ b/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift @@ -25,32 +25,33 @@ public struct ConfigVariableEditor: View { /// The list view model created from the reader. @State private var viewModel: ConfigVariableListViewModel? - /// The closure to call with the changed variables when the user saves. - private let onSave: ([RegisteredConfigVariable]) -> Void - /// Creates a new configuration variable editor. /// /// - Parameters: - /// - reader: The configuration variable reader. If the reader does was not created with `isEditorEnabled` set to + /// - reader: The configuration variable reader. If the reader was not created with `isEditorEnabled` set to /// `true`, the view is empty. /// - onSave: A closure called with the registered variables whose overrides changed when the user saves. public init( reader: ConfigVariableReader, onSave: @escaping ([RegisteredConfigVariable]) -> Void ) { - self.onSave = onSave - if let editorOverrideProvider = reader.editorOverrideProvider { - let undoManager = UndoManager() - let document = EditorDocument(provider: editorOverrideProvider, undoManager: undoManager) + // Exclude the editor override provider from the named providers passed to the document, + // since it is always the first entry in the reader's provider list + let namedProviders = Array(reader.namedProviders.dropFirst()) + + let document = EditorDocument( + editorOverrideProvider: editorOverrideProvider, + workingCopyDisplayName: localizedString("editorOverrideProvider.name"), + namedProviders: namedProviders, + registeredVariables: Array(reader.registeredVariables.values), + userDefaults: .standard, + undoManager: UndoManager() + ) + self._viewModel = State( - initialValue: ConfigVariableListViewModel( - document: document, - registeredVariables: reader.registeredVariables, - namedProviders: reader.namedProviders, - undoManager: undoManager - ) + initialValue: ConfigVariableListViewModel(document: document, onSave: onSave) ) } } @@ -58,7 +59,7 @@ public struct ConfigVariableEditor: View { public var body: some View { if let viewModel { - ConfigVariableListView(viewModel: viewModel, onSave: onSave) + ConfigVariableListView(viewModel: viewModel) } } } diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift index 754b653..7cdfd5f 100644 --- a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift @@ -2,207 +2,423 @@ // EditorDocument.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/7/2026. +// Created by Prachi Gauriar on 3/9/2026. // import Configuration import Foundation +import Synchronization -/// A working copy model that tracks staged editor overrides with undo/redo support. +/// The central domain model for the configuration variable editor. /// -/// `EditorDocument` maintains a working copy of configuration overrides separate from the committed state in -/// ``EditorOverrideProvider``. Changes are staged in the working copy and only applied to the provider on -/// ``save()``. The document supports undo/redo for all mutations via an `UndoManager`. -@MainActor @Observable +/// `EditorDocument` is the single source of truth for the editor UI. It owns provider snapshots, the working copy of +/// editor overrides, value resolution, dirty tracking, and save/undo/redo. Views and view models query the document +/// rather than providers directly. +/// +/// On initialization, the document: +/// 1. Snapshots all providers into an ordered array of ``ProviderEditorSnapshot`` values +/// 2. Builds a final "Default" snapshot from registered variable default contents +/// 3. Initializes the working copy from the editor override provider's current overrides +/// +/// The document watches each provider for snapshot changes and updates its corresponding ``ProviderEditorSnapshot`` +/// automatically. +@MainActor +@Observable final class EditorDocument { - /// The editor override provider that this document commits to on save. - private let provider: EditorOverrideProvider + /// The result of resolving a configuration variable's value. + struct ResolvedValue { + /// The resolved content value. + let content: ConfigContent + + /// The display name of the provider that owns this value. + let providerDisplayName: String + + /// The index of the owning provider, used for color assignment. + /// + /// This is `nil` when the working copy owns the value. + let providerIndex: Int? + } - /// The undo manager for registering undo/redo actions, if any. - private let undoManager: UndoManager? - /// The committed baseline, snapshotted from the provider at init. - private var baseline: [ConfigKey: ConfigContent] + /// The registered variables, keyed by their configuration key. + let registeredVariables: [ConfigKey: RegisteredConfigVariable] + + /// The editor override provider. + private let editorOverrideProvider: EditorOverrideProvider + + /// The UserDefaults instance used for persisting overrides. + private let userDefaults: UserDefaults + + /// The undo manager used for working copy changes. + let undoManager: UndoManager + + /// The display name of the editor override provider. + let workingCopyDisplayName: String - /// The working copy of overrides. + /// The ordered provider snapshots, including real providers and the trailing "Default" snapshot. + private(set) var providerSnapshots: [ProviderEditorSnapshot] + + /// The working copy of editor overrides. private(set) var workingCopy: [ConfigKey: ConfigContent] + /// The baseline overrides at the time of the last save, used for dirty tracking. + private var baseline: [ConfigKey: ConfigContent] + + /// The task that watches providers for snapshot changes, stored in a `Mutex` so it can be cancelled from `deinit`. + private let watchTask = Mutex?>(nil) + /// Creates a new editor document. /// - /// The document's working copy and baseline are initialized from the provider's current overrides. - /// /// - Parameters: - /// - provider: The editor override provider to commit to on save. - /// - undoManager: An optional undo manager for registering undo/redo actions. - init(provider: EditorOverrideProvider, undoManager: UndoManager? = nil) { - self.provider = provider + /// - editorOverrideProvider: The editor override provider that stores the working copy. + /// - workingCopyDisplayName: The display name for the working copy in the UI. + /// - namedProviders: The reader's named providers, excluding the editor override provider. + /// - registeredVariables: The registered variables to display in the editor. + /// - userDefaults: The UserDefaults instance used for persisting overrides. + /// - undoManager: The undo manager for working copy changes. + init( + editorOverrideProvider: EditorOverrideProvider, + workingCopyDisplayName: String, + namedProviders: [NamedConfigProvider], + registeredVariables: [RegisteredConfigVariable], + userDefaults: UserDefaults, + undoManager: UndoManager + ) { + self.editorOverrideProvider = editorOverrideProvider + self.workingCopyDisplayName = workingCopyDisplayName + self.userDefaults = userDefaults self.undoManager = undoManager - let currentOverrides = provider.overrides - self.baseline = currentOverrides + + // Build registered variables dictionary + var registeredVariablesByKey: [ConfigKey: RegisteredConfigVariable] = [:] + for variable in registeredVariables { + registeredVariablesByKey[variable.key] = variable + } + self.registeredVariables = registeredVariablesByKey + + // Snapshot real providers + var snapshots: [ProviderEditorSnapshot] = [] + for (index, namedProvider) in namedProviders.enumerated() { + let snapshot = namedProvider.provider.snapshot() + var values: [ConfigKey: ConfigContent] = [:] + for variable in registeredVariables { + let preferredType = variable.defaultContent.configType + if let content = snapshot.configContent(forKey: variable.key, preferredType: preferredType) { + values[variable.key] = content + } + } + + snapshots.append( + ProviderEditorSnapshot( + displayName: namedProvider.displayName, + index: index, + values: values + ) + ) + } + + // Build "Default" snapshot from registered variable defaults + let defaultIndex = namedProviders.count + var defaultValues: [ConfigKey: ConfigContent] = [:] + for variable in registeredVariables { + defaultValues[variable.key] = variable.defaultContent + } + + snapshots.append( + ProviderEditorSnapshot( + displayName: localizedString("editor.defaultProviderName"), + index: defaultIndex, + values: defaultValues + ) + ) + + self.providerSnapshots = snapshots + + // Initialize working copy and baseline from current overrides + let currentOverrides = editorOverrideProvider.overrides self.workingCopy = currentOverrides + self.baseline = currentOverrides + + // Start watching providers + startWatching(namedProviders: namedProviders, registeredVariables: registeredVariables) + } + + + deinit { + watchTask.withLock { $0?.cancel() } } } -// MARK: - Working Copy +// MARK: - Provider Watching + +extension EditorDocument { + /// Starts watching providers for snapshot changes. + private func startWatching( + namedProviders: [NamedConfigProvider], + registeredVariables: [RegisteredConfigVariable] + ) { + guard !namedProviders.isEmpty else { + return + } + + let task = Task { + await withTaskGroup(of: Void.self) { [weak self] group in + for (index, namedProvider) in namedProviders.enumerated() { + let provider = namedProvider.provider + group.addTask { [weak self] in + guard let self else { + return + } + + do { + try await provider.watchSnapshot { updates in + for await snapshot in updates { + guard !Task.isCancelled else { + return + } + + var values: [ConfigKey: ConfigContent] = [:] + for variable in registeredVariables { + if let content = snapshot.configContent( + forKey: variable.key, + preferredType: variable.defaultContent.configType + ) { + values[variable.key] = content + } + } + + await updateProviderSnapshot(at: index, values: values) + } + } + } catch { + // Provider watching ended; nothing to do + } + } + } + + await group.waitForAll() + } + } + + watchTask.withLock { $0 = task } + } + + + /// Updates the values in the provider snapshot at the given index. + private func updateProviderSnapshot(at index: Int, values: [ConfigKey: ConfigContent]) { + providerSnapshots[index].values = values + } +} + + +// MARK: - Value Resolution extension EditorDocument { - /// Returns the override value for the given key in the working copy. + /// Resolves the winning value for the given configuration key. /// - /// - Parameter key: The configuration key to look up. - /// - Returns: The override content, or `nil` if no override exists. - func override(forKey key: ConfigKey) -> ConfigContent? { - workingCopy[key] + /// Resolution order: working copy first, then provider snapshots (including defaults) in order. A snapshot's value + /// wins only if its ``ConfigContent/configType`` matches the registered variable's expected content type. + /// + /// - Parameter key: The configuration key to resolve. + /// - Returns: The resolved value, or `nil` if no value is found. + func resolvedValue(forKey key: ConfigKey) -> ResolvedValue? { + guard let registeredVariable = registeredVariables[key] else { return nil } + let expectedType = registeredVariable.defaultContent.configType + + // Check working copy first + if let content = workingCopy[key], content.configType == expectedType { + return ResolvedValue( + content: content, + providerDisplayName: workingCopyDisplayName, + providerIndex: nil + ) + } + + // Check provider snapshots in order + for snapshot in providerSnapshots { + if let content = snapshot.values[key], content.configType == expectedType { + return ResolvedValue( + content: content, + providerDisplayName: snapshot.displayName, + providerIndex: snapshot.index + ) + } + } + + return nil } - /// Whether the working copy contains an override for the given key. + /// Returns all provider values for the given configuration key. /// - /// - Parameter key: The configuration key to check. - /// - Returns: `true` if the working copy has an override for the key. + /// Each entry includes the provider's display name, index, value string, whether it is the active winner, and + /// whether its content type matches the registered variable's expected type. + /// + /// - Parameter key: The configuration key to query. + /// - Returns: An array of ``ProviderValue`` instances for providers that have a value for the key. + func providerValues(forKey key: ConfigKey) -> [ProviderValue] { + guard let registeredVariable = registeredVariables[key] else { return [] } + let expectedType = registeredVariable.defaultContent.configType + let resolved = resolvedValue(forKey: key) + + var result: [ProviderValue] = [] + + // Include working copy if it has a value + if let content = workingCopy[key] { + let isActive = resolved?.providerIndex == nil + result.append( + ProviderValue( + providerName: workingCopyDisplayName, + providerIndex: nil, + isActive: isActive, + valueString: content.displayString, + contentTypeMatches: content.configType == expectedType + ) + ) + } + + // Include snapshots that have a value + for snapshot in providerSnapshots { + if let content = snapshot.values[key] { + let isActive = resolved?.providerIndex == snapshot.index + result.append( + ProviderValue( + providerName: snapshot.displayName, + providerIndex: snapshot.index, + isActive: isActive, + valueString: content.displayString, + contentTypeMatches: content.configType == expectedType + ) + ) + } + } + + return result + } +} + + +// MARK: - Working Copy + +extension EditorDocument { + /// Whether the working copy has an override for the given key. func hasOverride(forKey key: ConfigKey) -> Bool { workingCopy[key] != nil } - /// Sets an override in the working copy. + /// Returns the override content for the given key, if any. + func override(forKey key: ConfigKey) -> ConfigContent? { + workingCopy[key] + } + + + /// Sets an override value in the working copy. /// - /// If an undo manager is set, an undo action is registered that restores the previous value (or removes the - /// override if there was none). + /// If the new content is the same as the existing override, no change is made. /// /// - Parameters: /// - content: The override content value. /// - key: The configuration key to override. func setOverride(_ content: ConfigContent, forKey key: ConfigKey) { - let previousContent = workingCopy[key] + let oldContent = workingCopy[key] + guard oldContent != content else { return } + workingCopy[key] = content - registerUndoForSet(previousContent: previousContent, key: key) + + undoManager.registerUndo(withTarget: self) { document in + if let oldContent { + document.setOverride(oldContent, forKey: key) + } else { + document.removeOverride(forKey: key) + } + } } /// Removes the override for the given key from the working copy. /// - /// If an undo manager is set and an override existed, an undo action is registered that restores the previous - /// value. + /// If no override exists for the key, no change is made. /// /// - Parameter key: The configuration key whose override should be removed. func removeOverride(forKey key: ConfigKey) { - let previousContent = workingCopy.removeValue(forKey: key) - if let previousContent { - registerUndoForRemove(previousContent: previousContent, key: key) + guard let oldContent = workingCopy.removeValue(forKey: key) else { + return + } + + undoManager.registerUndo(withTarget: self) { document in + document.setOverride(oldContent, forKey: key) } } /// Removes all overrides from the working copy. - /// - /// If an undo manager is set and overrides existed, a single undo action is registered that restores all - /// previous overrides. func removeAllOverrides() { - let previousWorkingCopy = workingCopy + let oldOverrides = workingCopy + guard !oldOverrides.isEmpty else { + return + } + workingCopy.removeAll() - if !previousWorkingCopy.isEmpty { - registerUndoForRemoveAll(previousWorkingCopy: previousWorkingCopy) + undoManager.registerUndo(withTarget: self) { document in + for (key, content) in oldOverrides { + document.setOverride(content, forKey: key) + } } } } -// MARK: - Dirty Tracking +// MARK: - Dirty Tracking and Save extension EditorDocument { - /// Whether the working copy differs from the committed baseline. + /// Whether the working copy has unsaved changes. var isDirty: Bool { workingCopy != baseline } - /// The set of keys that differ between the working copy and the committed baseline. - /// - /// This includes keys that were added, removed, or changed relative to the baseline. + /// The keys whose overrides have changed since the last save. var changedKeys: Set { - var changed = Set() + var keys = Set() - // Keys in working copy that are new or changed for (key, content) in workingCopy where baseline[key] != content { - changed.insert(key) + keys.insert(key) } - // Keys in baseline that were removed from working copy for key in baseline.keys where workingCopy[key] == nil { - changed.insert(key) + keys.insert(key) } - return changed + return keys } -} -// MARK: - Save - -extension EditorDocument { - /// Saves the working copy to the editor override provider and persists to UserDefaults. + /// Commits the working copy to the editor override provider and persists the changes. /// - /// This computes the delta between the working copy and baseline, updates the provider to match the working - /// copy, persists the overrides, and resets the baseline to match the working copy. - /// - /// - Returns: The set of keys that changed relative to the previous committed state. - @discardableResult - func save() -> Set { - let changed = changedKeys - baseline = workingCopy - - // Update the provider to match the working copy - provider.removeAllOverrides() - for (key, content) in workingCopy { - provider.setOverride(content, forKey: key) - } - provider.persist(to: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) - - return changed - } -} - - -// MARK: - Undo Registration - -extension EditorDocument { - /// Registers an undo action for a `setOverride` call. - private func registerUndoForSet(previousContent: ConfigContent?, key: ConfigKey) { - guard let undoManager else { return } - - if let previousContent { - undoManager.registerUndo(withTarget: self) { document in - document.setOverride(previousContent, forKey: key) - } - } else { - undoManager.registerUndo(withTarget: self) { document in - document.removeOverride(forKey: key) - } + /// After saving, the baseline is updated to match the working copy and the dirty state is reset. + func save() { + // Determine what changed + let currentKeys = Set(workingCopy.keys) + let baselineKeys = Set(baseline.keys) + + // Remove overrides that were deleted + for key in baselineKeys.subtracting(currentKeys) { + editorOverrideProvider.removeOverride(forKey: key) } - } - - - /// Registers an undo action for a `removeOverride` call. - private func registerUndoForRemove(previousContent: ConfigContent, key: ConfigKey) { - guard let undoManager else { return } - undoManager.registerUndo(withTarget: self) { document in - document.setOverride(previousContent, forKey: key) + // Set overrides that were added or changed + for (key, content) in workingCopy { + editorOverrideProvider.setOverride(content, forKey: key) } - } - - /// Registers an undo action for a `removeAllOverrides` call. - private func registerUndoForRemoveAll(previousWorkingCopy: [ConfigKey: ConfigContent]) { - guard let undoManager else { return } + // Persist + editorOverrideProvider.persist(to: userDefaults) - undoManager.registerUndo(withTarget: self) { document in - let currentWorkingCopy = document.workingCopy - document.workingCopy = previousWorkingCopy - document.registerUndoForRemoveAll(previousWorkingCopy: currentWorkingCopy) - } + // Update baseline + baseline = workingCopy } } diff --git a/Sources/DevConfiguration/Editor/Data Models/ProviderEditorSnapshot.swift b/Sources/DevConfiguration/Editor/Data Models/ProviderEditorSnapshot.swift new file mode 100644 index 0000000..7900081 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Data Models/ProviderEditorSnapshot.swift @@ -0,0 +1,24 @@ +// +// ProviderEditorSnapshot.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +import Configuration + +/// A uniform representation of a provider's state for the editor UI. +/// +/// All providers — including the "Default" pseudo-provider built from registered variable defaults — are represented +/// as `ProviderEditorSnapshot` values. Each snapshot has a display name, an index (for color assignment), and a map +/// of configuration keys to their content values. +struct ProviderEditorSnapshot { + /// The human-readable display name for this provider. + let displayName: String + + /// The position of this provider in the provider list, used for color assignment. + let index: Int + + /// The current values for registered configuration keys. + var values: [ConfigKey: ConfigContent] +} diff --git a/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift b/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift index 1244d8c..23d240d 100644 --- a/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift +++ b/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift @@ -41,19 +41,24 @@ struct ProviderBadge: View { /// Returns a color for the provider at the given index. /// -/// Colors are assigned from a fixed palette and wrap around if there are more providers than colors. +/// Colors are assigned from a fixed palette and wrap around if there are more providers than colors. When `index` is +/// `nil` (i.e., the working copy), the color is ``Color/blue``. /// -/// - Parameter index: The provider's index in the reader's provider list. +/// - Parameter index: The provider's index in the reader's provider list, or `nil` for the working copy. /// - Returns: A color for the provider. -func providerColor(at index: Int) -> Color { - let palette: [Color] = [.blue, .green, .indigo, .gray, .cyan, .yellow, .orange, .purple, .mint, .red] +func providerColor(at index: Int?) -> Color { + guard let index else { + return .blue + } + + let palette: [Color] = [.cyan, .green, .yellow, .orange, .pink, .indigo, .purple] return palette[index % palette.count] } #Preview { VStack(spacing: 8) { - ForEach(Array(0 ..< 9), id: \.self) { index in + ForEach(Array(0 ..< 7), id: \.self) { index in ProviderBadge(providerName: "Provider \(index)", color: providerColor(at: index)) } diff --git a/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift b/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift index 79e7b3c..9b7129c 100644 --- a/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift +++ b/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift @@ -14,11 +14,16 @@ struct ProviderValue: Hashable, Sendable { let providerName: String /// The index of the provider in the reader's provider list, used for color assignment. - let providerIndex: Int + /// + /// This is `nil` for the working copy (editor override provider). + let providerIndex: Int? /// Whether this provider is the one currently supplying the resolved value. let isActive: Bool /// The provider's value for the variable, formatted as a display string. let valueString: String + + /// Whether this provider's value has a content type that matches the registered variable's expected type. + let contentTypeMatches: Bool } diff --git a/Sources/DevConfiguration/Extensions/ConfigSnapshot+ConfigContent.swift b/Sources/DevConfiguration/Extensions/ConfigSnapshot+ConfigContent.swift new file mode 100644 index 0000000..70d4038 --- /dev/null +++ b/Sources/DevConfiguration/Extensions/ConfigSnapshot+ConfigContent.swift @@ -0,0 +1,43 @@ +// +// ConfigSnapshot+ConfigContent.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +import Configuration + +extension ConfigSnapshot { + /// Returns the ``ConfigContent`` for the given key, regardless of its type. + /// + /// This method first tries the preferred type, then probes the snapshot with all other configuration types to find + /// a value. It is intended for editor use where we need to discover a provider's value without knowing its type in + /// advance. + /// + /// - Parameters: + /// - key: The configuration key to look up. + /// - preferredType: The expected configuration type to try first. + /// - Returns: The content value, or `nil` if the snapshot has no value for the key. + func configContent(forKey key: ConfigKey, preferredType: ConfigType) -> ConfigContent? { + let absoluteKey = AbsoluteConfigKey(key) + + // Try the preferred type first + if let result = try? value(forKey: absoluteKey, type: preferredType), let configValue = result.value { + return configValue.content + } + + // Fall back to all other types + let allTypes: [ConfigType] = [ + .bool, .int, .double, .string, .bytes, + .boolArray, .intArray, .doubleArray, .stringArray, .byteChunkArray, + ] + + for type in allTypes where type != preferredType { + if let result = try? value(forKey: absoluteKey, type: type), let configValue = result.value { + return configValue.content + } + } + + return nil + } +} diff --git a/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift b/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift index 27f3af6..aaa9d97 100644 --- a/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift +++ b/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift @@ -6,10 +6,11 @@ // import Configuration -import DevConfiguration import DevTesting import Foundation +@testable import DevConfiguration + extension RandomValueGenerating { mutating func randomAbsoluteConfigKey() -> AbsoluteConfigKey { return AbsoluteConfigKey(randomConfigKey()) @@ -90,11 +91,6 @@ extension RandomValueGenerating { } - mutating func randomConfigVariableSecrecy() -> ConfigVariableSecrecy { - return randomCase(of: ConfigVariableSecrecy.self)! - } - - mutating func randomError() -> MockError { return MockError(id: randomAlphanumericString()) } @@ -110,6 +106,27 @@ extension RandomValueGenerating { } + mutating func randomRegisteredVariable( + key: ConfigKey? = nil, + defaultContent: ConfigContent? = nil, + isSecret: Bool? = nil, + metadata: ConfigVariableMetadata? = nil, + destinationTypeName: String? = nil, + editorControl: EditorControl? = nil, + parse: (@Sendable (_ input: String) -> ConfigContent?)? = nil + ) -> RegisteredConfigVariable { + RegisteredConfigVariable( + key: key ?? randomConfigKey(), + defaultContent: defaultContent ?? randomConfigContent(), + isSecret: isSecret ?? randomBool(), + metadata: metadata ?? ConfigVariableMetadata(), + destinationTypeName: destinationTypeName ?? randomAlphanumericString(), + editorControl: editorControl ?? .none, + parse: parse + ) + } + + mutating func randomProviderResult( providerName: String? = nil, result: Result? = nil diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift index a3b9799..fe775bb 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift @@ -90,7 +90,7 @@ struct ConfigVariableReaderEditorTests: RandomValueGenerating { let variable = ConfigVariable( key: key, defaultValue: randomAlphanumericString(), - secrecy: .public + isSecret: false ) // Verify the provider value is returned before any override diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift index 62e6bfa..7295ff4 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift @@ -27,8 +27,8 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { let key = randomConfigKey() let defaultValue = randomInt(in: .min ... .max) - let secrecy = randomConfigVariableSecrecy() - let variable = ConfigVariable(key: key, defaultValue: defaultValue, secrecy: secrecy) + let isSecret = randomBool() + let variable = ConfigVariable(key: key, defaultValue: defaultValue, isSecret: isSecret) .metadata(\.testTeam, metadata[TestTeamMetadataKey.self]) // exercise @@ -38,8 +38,9 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { let registered = try #require(reader.registeredVariables[key]) #expect(registered.key == key) #expect(registered.defaultContent == .int(defaultValue)) - #expect(registered.isSecret == reader.isSecret(variable)) + #expect(registered.isSecret == isSecret) #expect(registered.testTeam == metadata[TestTeamMetadataKey.self]) + #expect(registered.destinationTypeName == "Int") #expect(registered.editorControl == .numberField) #expect(registered.parse?("42") == .int(42)) #expect(registered.parse?("notAnInt") == nil) @@ -94,7 +95,6 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { key: "encode.failure", defaultValue: UnencodableValue(), content: ConfigVariableContent( - isAutoSecret: false, read: { _, _, _, defaultValue, _, _, _ in defaultValue }, fetch: { _, _, _, defaultValue, _, _, _ in defaultValue }, startWatching: { _, _, _, _, _, _, _, _ in }, diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift index 242d8a4..b915261 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift @@ -28,102 +28,6 @@ struct ConfigVariableReaderTests: RandomValueGenerating { }() - // MARK: - isSecret - - @Test(arguments: ConfigVariableSecrecy.allCases) - mutating func isSecret(secrecy: ConfigVariableSecrecy) { - let intVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: randomInt(in: .min ... .max), - secrecy: secrecy - ) - - let stringVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: randomAlphanumericString(), - secrecy: secrecy - ) - - let stringArrayVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: Array(count: randomInt(in: 0 ... 5)) { randomAlphanumericString() }, - secrecy: secrecy - ) - - let rawRepresentableStringVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: MockStringEnum.allCases.randomElement(using: &randomNumberGenerator)!, - secrecy: secrecy - ) - - let rawRepresentableStringArrayVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: Array(count: randomInt(in: 0 ... 5)) { - MockStringEnum.allCases.randomElement(using: &randomNumberGenerator)! - }, - secrecy: secrecy - ) - - let expressibleByConfigStringVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: MockConfigStringValue(configString: randomAlphanumericString())!, - secrecy: secrecy - ) - - let expressibleByConfigStringArrayVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: Array(count: randomInt(in: 0 ... 5)) { - MockConfigStringValue(configString: randomAlphanumericString())! - }, - secrecy: secrecy - ) - - let rawRepresentableIntVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: MockIntEnum.allCases.randomElement(using: &randomNumberGenerator)!, - secrecy: secrecy - ) - - let rawRepresentableIntArrayVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: Array(count: randomInt(in: 0 ... 5)) { - MockIntEnum.allCases.randomElement(using: &randomNumberGenerator)! - }, - secrecy: secrecy - ) - - let expressibleByConfigIntVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: MockConfigIntValue(configInt: randomInt(in: .min ... .max))!, - secrecy: secrecy - ) - - let expressibleByConfigIntArrayVariable = ConfigVariable( - key: randomConfigKey(), - defaultValue: Array(count: randomInt(in: 0 ... 5)) { - MockConfigIntValue(configInt: randomInt(in: .min ... .max))! - }, - secrecy: secrecy - ) - - let isNotPublic = [.secret, .auto].contains(secrecy) - let isSecret = secrecy == .secret - - let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) - #expect(reader.isSecret(intVariable) == isSecret) - #expect(reader.isSecret(stringVariable) == isNotPublic) - #expect(reader.isSecret(stringArrayVariable) == isNotPublic) - #expect(reader.isSecret(rawRepresentableStringVariable) == isNotPublic) - #expect(reader.isSecret(rawRepresentableStringArrayVariable) == isNotPublic) - #expect(reader.isSecret(expressibleByConfigStringVariable) == isNotPublic) - #expect(reader.isSecret(expressibleByConfigStringArrayVariable) == isNotPublic) - #expect(reader.isSecret(rawRepresentableIntVariable) == isSecret) - #expect(reader.isSecret(rawRepresentableIntArrayVariable) == isSecret) - #expect(reader.isSecret(expressibleByConfigIntVariable) == isSecret) - #expect(reader.isSecret(expressibleByConfigIntArrayVariable) == isSecret) - } - - // MARK: - Event Bus Integration @Test diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift index 89571a6..1ffea90 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableTests.swift @@ -16,20 +16,19 @@ struct ConfigVariableTests: RandomValueGenerating { // MARK: - init(key: ConfigKey, …) - @Test - mutating func initWithConfigKeyStoresParameters() { - // set up the test by creating random parameters + @Test(arguments: [false, true]) + mutating func initWithConfigKeyStoresParameters(isSecret: Bool) { + // set up let configKey = randomConfigKey() let defaultValue = randomInt(in: .min ... .max) - let secrecy = randomConfigVariableSecrecy() - // exercise the test by creating the config variable - let variable = ConfigVariable(key: configKey, defaultValue: defaultValue, secrecy: secrecy) + // exercise + let variable = ConfigVariable(key: configKey, defaultValue: defaultValue, isSecret: isSecret) - // expect that the variable stores the parameters + // expect #expect(variable.key == configKey) #expect(variable.defaultValue == defaultValue) - #expect(variable.secrecy == secrecy) + #expect(variable.isSecret == isSecret) } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift index 297cb2f..f03dc3c 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift @@ -27,6 +27,7 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { defaultContent: randomConfigContent(), isSecret: randomBool(), metadata: metadata, + destinationTypeName: randomAlphanumericString(), editorControl: .none, parse: nil ) @@ -44,6 +45,7 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { defaultContent: randomConfigContent(), isSecret: randomBool(), metadata: ConfigVariableMetadata(), + destinationTypeName: randomAlphanumericString(), editorControl: .none, parse: nil ) diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift index ae7ca1a..1e5c660 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift @@ -2,11 +2,9 @@ // ConfigVariableDetailViewModelTests.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/8/2026. +// Created by Prachi Gauriar on 3/9/2026. // -#if canImport(SwiftUI) - import Configuration import DevTesting import Foundation @@ -18,360 +16,364 @@ import Testing struct ConfigVariableDetailViewModelTests: RandomValueGenerating { var randomNumberGenerator = makeRandomNumberGenerator() + let editorOverrideProvider = EditorOverrideProvider() + var userDefaults: UserDefaults! + var workingCopyDisplayName: String! + let undoManager = UndoManager() - // MARK: - Properties - - @Test - mutating func keyReturnsVariableKey() { - // set up - let key = randomConfigKey() - let viewModel = makeDetailViewModel(key: key) - // expect - #expect(viewModel.key == key) + init() { + workingCopyDisplayName = randomAlphanumericString() + userDefaults = UserDefaults(suiteName: randomAlphanumericString())! } - @Test - mutating func displayNameReturnsMetadataDisplayName() { - // set up - let displayName = randomAlphanumericString() - var metadata = ConfigVariableMetadata() - metadata.displayName = displayName + // MARK: - Helpers + + mutating func makeDocument( + namedProviders: [NamedConfigProvider] = [], + registeredVariables: [RegisteredConfigVariable] + ) -> EditorDocument { + EditorDocument( + editorOverrideProvider: editorOverrideProvider, + workingCopyDisplayName: workingCopyDisplayName, + namedProviders: namedProviders, + registeredVariables: registeredVariables, + userDefaults: userDefaults, + undoManager: undoManager + ) + } - let viewModel = makeDetailViewModel(metadata: metadata) - // expect - #expect(viewModel.displayName == displayName) + mutating func makeViewModel( + document: EditorDocument, + registeredVariable: RegisteredConfigVariable + ) -> ConfigVariableDetailViewModel { + ConfigVariableDetailViewModel(document: document, registeredVariable: registeredVariable) } + // MARK: - init + @Test - mutating func displayNameFallsBackToKeyDescription() { + mutating func initSetsConstantProperties() { // set up - let key = randomConfigKey() - let viewModel = makeDetailViewModel(key: key) + var metadata = ConfigVariableMetadata() + metadata.displayName = randomAlphanumericString() + let destinationTypeName = randomAlphanumericString() + let isSecret = randomBool() + + let variable = randomRegisteredVariable( + isSecret: isSecret, + metadata: metadata, + destinationTypeName: destinationTypeName, + editorControl: .textField + ) + + let document = makeDocument(registeredVariables: [variable]) + + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) - // expect - #expect(viewModel.displayName == key.description) + // expect all constant properties are set from the registered variable + #expect(viewModel.key == variable.key) + #expect(viewModel.displayName == metadata.displayName) + #expect(viewModel.typeName == destinationTypeName) + #expect(viewModel.metadataEntries == metadata.displayTextEntries) + #expect(viewModel.isSecret == isSecret) + #expect(viewModel.editorControl == .textField) } @Test - mutating func metadataEntriesReturnsVariableMetadata() { - // set up - let displayName = randomAlphanumericString() - var metadata = ConfigVariableMetadata() - metadata.displayName = displayName + mutating func initUsesKeyDescriptionWhenDisplayNameIsNil() { + // set up with no display name metadata + let variable = randomRegisteredVariable() + let document = makeDocument(registeredVariables: [variable]) - let viewModel = makeDetailViewModel(metadata: metadata) + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) - // expect - #expect(viewModel.metadataEntries == metadata.displayTextEntries) + // expect the key's description is used + #expect(viewModel.displayName == variable.key.description) } @Test - mutating func editorControlReturnsVariableEditorControl() { - // set up - let editorControl = randomElement(in: [EditorControl.toggle, .textField, .numberField, .decimalField, .none])! - let viewModel = makeDetailViewModel(editorControl: editorControl) + mutating func initSetsOverrideTextFromExistingOverride() { + // set up with an override in the document + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let document = makeDocument(registeredVariables: [variable]) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) - // expect - #expect(viewModel.editorControl == editorControl) + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // expect override text comes from the override content + #expect(viewModel.overrideText == overrideContent.displayString) } - @Test(arguments: [false, true]) - mutating func isSecretReturnsVariableIsSecret(isSecret: Bool) { - // set up - let viewModel = makeDetailViewModel(isSecret: isSecret) + @Test + mutating func initSetsOverrideTextFromResolvedValue() { + // set up with no override but a resolved value from defaults + let defaultContent = ConfigContent.int(randomInt(in: .min ... .max)) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let document = makeDocument(registeredVariables: [variable]) - // expect - #expect(viewModel.isSecret == isSecret) + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // expect override text comes from the resolved default value + #expect(viewModel.overrideText == defaultContent.displayString) } - // MARK: - Provider Values + // MARK: - providerValues @Test - mutating func providerValuesQueriesProviders() throws { - // set up - let key = randomConfigKey() - let content = ConfigContent.string(randomAlphanumericString()) - let providerName = randomAlphanumericString() - let inMemoryProvider = InMemoryProvider( - name: providerName, - values: [AbsoluteConfigKey(key): ConfigValue(content, isSecret: false)] + mutating func providerValuesDelegatesToDocument() { + // set up with a provider and a default + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + + let providerContent = ConfigContent.string(randomAlphanumericString()) + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(providerContent, isSecret: false) + ] ) + let providerDisplayName = randomAlphanumericString() - let viewModel = makeDetailViewModel( - key: key, defaultContent: .string(""), editorControl: .textField, providers: [inMemoryProvider] + let document = makeDocument( + namedProviders: [.init(provider, displayName: providerDisplayName)], + registeredVariables: [variable] ) + let viewModel = makeViewModel(document: document, registeredVariable: variable) // exercise - let value = try #require(viewModel.providerValues.first) + let values = viewModel.providerValues - // expect - #expect(value.providerName == inMemoryProvider.providerName) - #expect(value.valueString == content.displayString) + // expect the values match what the document returns + #expect(values == document.providerValues(forKey: variable.key)) } + // MARK: - isOverrideEnabled + @Test - mutating func providerValuesExcludesProvidersWithNoValue() { - // set up - let key = randomConfigKey() - let providerWithValue = InMemoryProvider( - name: randomAlphanumericString(), - values: [AbsoluteConfigKey(key): ConfigValue(.int(randomInt(in: -100 ... 100)), isSecret: false)] - ) - let providerWithoutValue = InMemoryProvider(name: randomAlphanumericString(), values: [:]) + mutating func isOverrideEnabledReturnsTrueWhenOverrideExists() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) - let viewModel = makeDetailViewModel( - key: key, - defaultContent: .int(0), - editorControl: .numberField, - providers: [providerWithValue, providerWithoutValue] - ) + let viewModel = makeViewModel(document: document, registeredVariable: variable) - // expect - #expect(viewModel.providerValues.map(\.providerName) == [providerWithValue.providerName]) + // exercise and expect + #expect(viewModel.isOverrideEnabled) } - // MARK: - Override Enable/Disable - @Test mutating func isOverrideEnabledReturnsFalseWhenNoOverride() { - // set up - let viewModel = makeDetailViewModel() + // set up with no override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - // expect + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect #expect(!viewModel.isOverrideEnabled) } @Test - mutating func settingIsOverrideEnabledToTrueSetsDefaultContent() { - // set up - let key = randomConfigKey() - let defaultContent = ConfigContent.int(randomInt(in: -100 ... 100)) - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + mutating func settingIsOverrideEnabledToTrueSetsOverrideFromResolvedValue() { + // set up with a provider value that will be the resolved value + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + + let providerContent = ConfigContent.string(randomAlphanumericString()) + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(providerContent, isSecret: false) + ] + ) - let viewModel = makeDetailViewModel(key: key, defaultContent: defaultContent, document: document) + let document = makeDocument( + namedProviders: [.init(provider, displayName: randomAlphanumericString())], + registeredVariables: [variable] + ) + let viewModel = makeViewModel(document: document, registeredVariable: variable) // exercise viewModel.isOverrideEnabled = true - // expect - #expect(viewModel.isOverrideEnabled) - #expect(document.override(forKey: key) == defaultContent) + // expect the override is set to the resolved value (provider content wins over default) + #expect(document.override(forKey: variable.key) == providerContent) } @Test - mutating func settingIsOverrideEnabledToFalseRemovesOverride() { - // set up - let key = randomConfigKey() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(randomConfigContent(), forKey: key) + mutating func settingIsOverrideEnabledToTrueUsesDefaultContentWhenResolvedValueIsNil() { + // set up with a variable that is not registered in the document, so resolvedValue returns nil + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let otherVariable = randomRegisteredVariable() - let viewModel = makeDetailViewModel(key: key, document: document) + let document = makeDocument(registeredVariables: [otherVariable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) // exercise - viewModel.isOverrideEnabled = false - - // expect - #expect(!viewModel.isOverrideEnabled) - #expect(!document.hasOverride(forKey: key)) - } - - - // MARK: - Override Text - - @Test - mutating func overrideTextReturnsEmptyStringWhenNoOverride() { - // set up - let viewModel = makeDetailViewModel() + viewModel.isOverrideEnabled = true - // expect - #expect(viewModel.overrideText == "") + // expect the override is set to the registered variable's default content + #expect(document.override(forKey: variable.key) == defaultContent) + #expect(viewModel.overrideText == defaultContent.displayString) } @Test - mutating func overrideTextReturnsDisplayStringOfOverride() { + mutating func settingIsOverrideEnabledToTrueUpdatesOverrideText() { // set up - let key = randomConfigKey() - let value = randomAlphanumericString() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(.string(value), forKey: key) + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) - let viewModel = makeDetailViewModel(key: key, document: document) + // exercise + viewModel.isOverrideEnabled = true - // expect - #expect(viewModel.overrideText == value) + // expect override text is updated to the resolved value's display string + #expect(viewModel.overrideText == defaultContent.displayString) } @Test - mutating func settingOverrideTextParsesAndUpdatesDocument() { - // set up - let key = randomConfigKey() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(.int(0), forKey: key) - - let inputValue = randomInt(in: 1 ... 100) - let viewModel = makeDetailViewModel( - key: key, - defaultContent: .int(0), - editorControl: .numberField, - parse: { Int($0).map { .int($0) } }, - document: document - ) + mutating func settingIsOverrideEnabledToFalseRemovesOverride() { + // set up with an existing override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + let viewModel = makeViewModel(document: document, registeredVariable: variable) // exercise - viewModel.overrideText = String(inputValue) + viewModel.isOverrideEnabled = false - // expect - #expect(document.override(forKey: key) == .int(inputValue)) + // expect the override is removed + #expect(!document.hasOverride(forKey: variable.key)) } + // MARK: - overrideBool + @Test - mutating func settingOverrideTextWithInvalidInputDoesNotUpdate() { - // set up - let key = randomConfigKey() - let originalContent = ConfigContent.int(randomInt(in: -100 ... 100)) - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(originalContent, forKey: key) - - let viewModel = makeDetailViewModel( - key: key, - defaultContent: .int(0), - editorControl: .numberField, - parse: { Int($0).map { .int($0) } }, - document: document - ) + mutating func overrideBoolReturnsBoolValue() { + // set up with a bool override + let boolValue = randomBool() + let variable = randomRegisteredVariable(defaultContent: .bool(boolValue)) + let document = makeDocument(registeredVariables: [variable]) + document.setOverride(.bool(boolValue), forKey: variable.key) - // exercise - viewModel.overrideText = randomAlphanumericString() + let viewModel = makeViewModel(document: document, registeredVariable: variable) - // expect - #expect(document.override(forKey: key) == originalContent) + // exercise and expect + #expect(viewModel.overrideBool == boolValue) } - // MARK: - Override Bool - @Test - mutating func overrideBoolReturnsFalseWhenNoOverride() { - // set up - let viewModel = makeDetailViewModel() + mutating func overrideBoolReturnsFalseWhenNotBool() { + // set up with a non-bool override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + let viewModel = makeViewModel(document: document, registeredVariable: variable) - // expect + // exercise and expect #expect(!viewModel.overrideBool) } @Test - mutating func overrideBoolReturnsValueFromDocument() { + mutating func settingOverrideBoolSetsDocumentOverride() { // set up - let key = randomConfigKey() - let value = randomBool() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(.bool(value), forKey: key) + let variable = randomRegisteredVariable(defaultContent: .bool(false)) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) - let viewModel = makeDetailViewModel(key: key, document: document) + // exercise + viewModel.overrideBool = true - // expect - #expect(viewModel.overrideBool == value) + // expect the document has a bool override + #expect(document.override(forKey: variable.key) == .bool(true)) } + // MARK: - commitOverrideText + @Test - mutating func settingOverrideBoolUpdatesDocument() { - // set up - let key = randomConfigKey() - let value = randomBool() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(.bool(!value), forKey: key) + mutating func commitOverrideTextParsesAndSetsOverride() { + // set up with a parse function that parses strings to .string content + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { .string($0) } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) - let viewModel = makeDetailViewModel(key: key, document: document) + let inputText = randomAlphanumericString() + viewModel.overrideText = inputText // exercise - viewModel.overrideBool = value + viewModel.commitOverrideText() - // expect - #expect(document.override(forKey: key) == .bool(value)) + // expect the parsed content is set as an override + #expect(document.override(forKey: variable.key) == .string(inputText)) } - // MARK: - Secret Reveal - @Test - mutating func isSecretRevealedDefaultsToFalse() { - // set up - let viewModel = makeDetailViewModel() - - // expect - #expect(!viewModel.isSecretRevealed) - } - + mutating func commitOverrideTextDoesNothingWhenParseIsNil() { + // set up with no parse function + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) - @Test - mutating func isSecretRevealedCanBeToggled() { - // set up - let viewModel = makeDetailViewModel() + viewModel.overrideText = randomAlphanumericString() // exercise - viewModel.isSecretRevealed = true + viewModel.commitOverrideText() - // expect - #expect(viewModel.isSecretRevealed) + // expect no override is set + #expect(!document.hasOverride(forKey: variable.key)) } -} -// MARK: - Helpers - -extension ConfigVariableDetailViewModelTests { - private mutating func makeDetailViewModel( - key: ConfigKey? = nil, - defaultContent: ConfigContent = .bool(false), - isSecret: Bool? = nil, - metadata: ConfigVariableMetadata = ConfigVariableMetadata(), - editorControl: EditorControl = .toggle, - parse: (@Sendable (String) -> ConfigContent?)? = nil, - document: EditorDocument? = nil, - providers: [any ConfigProvider] = [] - ) -> ConfigVariableDetailViewModel { - let effectiveKey = key ?? randomConfigKey() - let variable = RegisteredConfigVariable( - key: effectiveKey, - defaultContent: defaultContent, - isSecret: isSecret ?? randomBool(), - metadata: metadata, - editorControl: editorControl, - parse: parse + @Test + mutating func commitOverrideTextDoesNothingWhenParseReturnsNil() { + // set up with a parse function that always returns nil + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { _ in nil } ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) - let effectiveDocument = document ?? EditorDocument(provider: EditorOverrideProvider()) + viewModel.overrideText = randomAlphanumericString() - return ConfigVariableDetailViewModel( - variable: variable, - document: effectiveDocument, - namedProviders: providers.map { NamedConfigProvider($0) } - ) + // exercise + viewModel.commitOverrideText() + + // expect no override is set + #expect(!document.hasOverride(forKey: variable.key)) } } - -#endif diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift index 65a58f2..5796422 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift @@ -2,11 +2,9 @@ // ConfigVariableListViewModelTests.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/8/2026. +// Created by Prachi Gauriar on 3/9/2026. // -#if canImport(SwiftUI) - import Configuration import DevTesting import Foundation @@ -18,384 +16,433 @@ import Testing struct ConfigVariableListViewModelTests: RandomValueGenerating { var randomNumberGenerator = makeRandomNumberGenerator() + let editorOverrideProvider = EditorOverrideProvider() + var userDefaults: UserDefaults! + var workingCopyDisplayName: String! + let undoManager = UndoManager() - // MARK: - Variables + nonisolated(unsafe) var onSaveStub: Stub<[RegisteredConfigVariable], Void>! - @Test - mutating func variablesSortedByDisplayName() { - // set up - let displayNames = Array(count: 3) { randomAlphanumericString() } - let sortedNames = displayNames.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - - var variables: [ConfigKey: RegisteredConfigVariable] = [:] - for name in displayNames { - let key = randomConfigKey() - var metadata = ConfigVariableMetadata() - metadata.displayName = name - variables[key] = randomRegisteredVariable(key: key, metadata: metadata) - } - let viewModel = makeListViewModel(registeredVariables: variables) + init() { + workingCopyDisplayName = randomAlphanumericString() + userDefaults = UserDefaults(suiteName: randomAlphanumericString())! + onSaveStub = Stub() + } - // exercise - let resultNames = viewModel.variables.map(\.displayName) - // expect - #expect(resultNames == sortedNames) + // MARK: - Helpers + + mutating func makeDocument( + namedProviders: [NamedConfigProvider] = [], + registeredVariables: [RegisteredConfigVariable]? = nil + ) -> EditorDocument { + EditorDocument( + editorOverrideProvider: editorOverrideProvider, + workingCopyDisplayName: workingCopyDisplayName, + namedProviders: namedProviders, + registeredVariables: registeredVariables ?? [randomRegisteredVariable()], + userDefaults: userDefaults, + undoManager: undoManager + ) } + func makeViewModel(document: EditorDocument) -> ConfigVariableListViewModel { + ConfigVariableListViewModel(document: document, onSave: { self.onSaveStub($0) }) + } + + + // MARK: - variables + @Test - mutating func variableUsesKeyDescriptionWhenNoDisplayName() throws { - // set up - let key = randomConfigKey() - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key: randomRegisteredVariable(key: key) - ] + mutating func variablesMapsItemsFromDocument() { + // set up with two registered variables that have display names + var metadata1 = ConfigVariableMetadata() + metadata1.displayName = "Alpha" + let defaultContent1 = ConfigContent.string(randomAlphanumericString()) + let variable1 = randomRegisteredVariable(defaultContent: defaultContent1, metadata: metadata1) - let viewModel = makeListViewModel(registeredVariables: variables) + var metadata2 = ConfigVariableMetadata() + metadata2.displayName = "Beta" + let defaultContent2 = ConfigContent.int(randomInt(in: .min ... .max)) + let variable2 = randomRegisteredVariable(defaultContent: defaultContent2, metadata: metadata2) - // exercise - let item = try #require(viewModel.variables.first) + let document = makeDocument(registeredVariables: [variable1, variable2]) + let viewModel = makeViewModel(document: document) - // expect - #expect(item.displayName == key.description) + // exercise + let items = viewModel.variables + + // expect items sorted by display name with correct fields + let expected = [ + VariableListItem( + key: variable1.key, + displayName: "Alpha", + currentValue: defaultContent1.displayString, + providerName: localizedString("editor.defaultProviderName"), + providerIndex: 0, + isSecret: variable1.isSecret, + hasOverride: false, + editorControl: variable1.editorControl + ), + VariableListItem( + key: variable2.key, + displayName: "Beta", + currentValue: defaultContent2.displayString, + providerName: localizedString("editor.defaultProviderName"), + providerIndex: 0, + isSecret: variable2.isSecret, + hasOverride: false, + editorControl: variable2.editorControl + ), + ] + #expect(items == expected) } @Test - mutating func variableShowsOverrideValueWhenOverrideExists() throws { - // set up - let key = randomConfigKey() - let overrideContent = ConfigContent.int(randomInt(in: -100 ... 100)) - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key: randomRegisteredVariable(key: key, defaultContent: .int(0), editorControl: .numberField) + mutating func variablesUsesKeyDescriptionWhenDisplayNameIsNil() { + // set up with a variable that has no display name metadata + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + + // exercise + let items = viewModel.variables + + // expect the item uses the key's description as the display name + let expected = [ + VariableListItem( + key: variable.key, + displayName: variable.key.description, + currentValue: defaultContent.displayString, + providerName: localizedString("editor.defaultProviderName"), + providerIndex: 0, + isSecret: variable.isSecret, + hasOverride: false, + editorControl: variable.editorControl + ) ] + #expect(items == expected) + } - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(overrideContent, forKey: key) - let viewModel = makeListViewModel(document: document, registeredVariables: variables, providers: [provider]) + @Test + mutating func variablesFiltersByDisplayName() { + // set up with two variables, one matching the search text + var metadata1 = ConfigVariableMetadata() + metadata1.displayName = "ServerURL" + let variable1 = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + metadata: metadata1 + ) + + var metadata2 = ConfigVariableMetadata() + metadata2.displayName = "Timeout" + let variable2 = randomRegisteredVariable( + defaultContent: .int(randomInt(in: .min ... .max)), + metadata: metadata2 + ) + + let document = makeDocument(registeredVariables: [variable1, variable2]) + let viewModel = makeViewModel(document: document) + viewModel.searchText = "Server" // exercise - let item = try #require(viewModel.variables.first) + let items = viewModel.variables - // expect - #expect(item.currentValue == overrideContent.displayString) - #expect(item.providerName == EditorOverrideProvider.providerName) - #expect(item.hasOverride) + // expect only the matching variable is returned + #expect(items.count == 1) + #expect(items.first?.displayName == "ServerURL") } @Test - mutating func variableShowsProviderValueWhenNoOverride() throws { - // set up - let key = randomConfigKey() - let content = ConfigContent.string(randomAlphanumericString()) - let inMemoryProvider = InMemoryProvider( - name: randomAlphanumericString(), - values: [AbsoluteConfigKey(key): ConfigValue(content, isSecret: false)] + mutating func variablesFiltersByKeyDescription() { + // set up with a variable whose display name doesn't match but key does + var metadata = ConfigVariableMetadata() + metadata.displayName = "Something Else" + let key = ConfigKey(["server", "url"]) + let variable = randomRegisteredVariable( + key: key, + defaultContent: .string(randomAlphanumericString()), + metadata: metadata ) - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key: randomRegisteredVariable(key: key, defaultContent: .string(""), editorControl: .textField) - ] + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + viewModel.searchText = "server" + + // exercise + let items = viewModel.variables + + // expect the variable is returned because the key matches + #expect(items.count == 1) + #expect(items.first?.key == key) + } - let viewModel = makeListViewModel(registeredVariables: variables, providers: [inMemoryProvider]) + + @Test + mutating func variablesReturnsAllWhenSearchTextIsEmpty() { + // set up with two variables and empty search text + let variable1 = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let variable2 = randomRegisteredVariable(defaultContent: .int(randomInt(in: .min ... .max))) + + let document = makeDocument(registeredVariables: [variable1, variable2]) + let viewModel = makeViewModel(document: document) // exercise - let item = try #require(viewModel.variables.first) + let items = viewModel.variables - // expect - #expect(item.currentValue == content.displayString) - #expect(item.providerName == inMemoryProvider.providerName) - #expect(!item.hasOverride) + // expect all variables are returned + #expect(items.count == 2) } @Test - mutating func variableShowsDefaultWhenNoProviderHasValue() throws { - // set up - let key = randomConfigKey() - let defaultContent = ConfigContent.bool(randomBool()) + mutating func variablesSortsByDisplayName() { + // set up with variables whose display names sort in a specific order + var metadataC = ConfigVariableMetadata() + metadataC.displayName = "Charlie" + let variableC = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + metadata: metadataC + ) - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key: randomRegisteredVariable(key: key, defaultContent: defaultContent, editorControl: .toggle) - ] + var metadataA = ConfigVariableMetadata() + metadataA.displayName = "Alpha" + let variableA = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + metadata: metadataA + ) - let viewModel = makeListViewModel(registeredVariables: variables) + var metadataB = ConfigVariableMetadata() + metadataB.displayName = "Bravo" + let variableB = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + metadata: metadataB + ) + + // register in non-sorted order + let document = makeDocument(registeredVariables: [variableC, variableA, variableB]) + let viewModel = makeViewModel(document: document) // exercise - let item = try #require(viewModel.variables.first) + let items = viewModel.variables - // expect - #expect(item.currentValue == defaultContent.displayString) - #expect(item.providerName != "editor.defaultProviderName") + // expect items are sorted by display name + let displayNames = items.map(\.displayName) + #expect(displayNames == ["Alpha", "Bravo", "Charlie"]) } - // MARK: - Search + // MARK: - isDirty @Test - mutating func searchFiltersVariablesByDisplayName() { + mutating func isDirtyDelegatesToDocument() { // set up - let targetName = randomAlphanumericString() - let otherName = randomAlphanumericString() + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - let key1 = randomConfigKey() - let key2 = randomConfigKey() + // expect clean initially + #expect(!viewModel.isDirty) - var metadata1 = ConfigVariableMetadata() - metadata1.displayName = targetName - var metadata2 = ConfigVariableMetadata() - metadata2.displayName = otherName + // exercise by adding an override + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key1: randomRegisteredVariable(key: key1, metadata: metadata1), - key2: randomRegisteredVariable(key: key2, metadata: metadata2), - ] + // expect dirty + #expect(viewModel.isDirty) + } - let viewModel = makeListViewModel(registeredVariables: variables) - // exercise - viewModel.searchText = targetName + // MARK: - canUndo + + @Test + mutating func canUndoDelegatesToUndoManager() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - // expect - #expect(viewModel.variables.map(\.displayName) == [targetName]) + // expect can't undo initially + #expect(!viewModel.canUndo) + + // exercise by adding an override + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // expect can undo + #expect(viewModel.canUndo) } - @Test - mutating func searchFiltersVariablesByCurrentValue() { - // set up - let key = randomConfigKey() - let searchableValue = randomAlphanumericString() - let content = ConfigContent.string(searchableValue) - let inMemoryProvider = InMemoryProvider( - values: [AbsoluteConfigKey(key): ConfigValue(content, isSecret: false)] - ) + // MARK: - canRedo - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key: randomRegisteredVariable(key: key, defaultContent: .string(""), editorControl: .textField) - ] + @Test + mutating func canRedoDelegatesToUndoManager() { + // set up with an override then undo + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - let viewModel = makeListViewModel(registeredVariables: variables, providers: [inMemoryProvider]) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + undoManager.undo() // exercise - viewModel.searchText = searchableValue + let canRedo = viewModel.canRedo - // expect - #expect(viewModel.variables.count == 1) + // expect can redo after undo + #expect(canRedo) } - @Test - mutating func searchWithNoMatchReturnsEmpty() { - // set up - let key = randomConfigKey() - var metadata = ConfigVariableMetadata() - metadata.displayName = randomAlphanumericString() - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key: randomRegisteredVariable(key: key, metadata: metadata) - ] + // MARK: - requestDismiss - let viewModel = makeListViewModel(registeredVariables: variables) + @Test + mutating func requestDismissCallsDismissWhenClean() async { + // set up with no overrides + let document = makeDocument() + let viewModel = makeViewModel(document: document) // exercise - viewModel.searchText = randomAlphanumericString() + await confirmation { dismissed in + viewModel.requestDismiss { dismissed() } + } - // expect - #expect(viewModel.variables.isEmpty) + // expect save alert is not showing + #expect(!viewModel.isShowingSaveAlert) } @Test - mutating func emptySearchReturnsAllVariables() { - // set up - let key1 = randomConfigKey() - let key2 = randomConfigKey() - let variables: [ConfigKey: RegisteredConfigVariable] = [ - key1: randomRegisteredVariable(key: key1), - key2: randomRegisteredVariable(key: key2), - ] + mutating func requestDismissShowsSaveAlertWhenDirty() { + // set up with an override to make the document dirty + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - let viewModel = makeListViewModel(registeredVariables: variables) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) // exercise - viewModel.searchText = "" + viewModel.requestDismiss {} - // expect - #expect(viewModel.variables.count == 2) + // expect save alert is showing and dismiss was not called + #expect(viewModel.isShowingSaveAlert) } - // MARK: - Dirty Tracking + // MARK: - save @Test - mutating func isDirtyDelegatesToDocument() { - // set up - let key = randomConfigKey() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let viewModel = makeListViewModel(document: document) + mutating func saveCallsOnSaveWithChangedVariables() throws { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - #expect(!viewModel.isDirty) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) // exercise - document.setOverride(randomConfigContent(), forKey: key) + viewModel.save() - // expect - #expect(viewModel.isDirty) + // expect onSave was called with the changed variable and document is no longer dirty + let savedVariables = try #require(onSaveStub.callArguments.first) + #expect(savedVariables.map(\.key) == [variable.key]) + #expect(!viewModel.isDirty) } - // MARK: - Save + // MARK: - requestClearAllOverrides @Test - mutating func saveReturnsChangedRegisteredVariables() { + mutating func requestClearAllOverridesShowsClearAlert() { // set up - let key1 = randomConfigKey() - let key2 = randomConfigKey() - - let variable1 = randomRegisteredVariable(key: key1) - let variable2 = randomRegisteredVariable(key: key2) - - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(randomConfigContent(), forKey: key1) - - let viewModel = makeListViewModel( - document: document, - registeredVariables: [key1: variable1, key2: variable2], - providers: [provider] - ) + let document = makeDocument() + let viewModel = makeViewModel(document: document) // exercise - let changed = viewModel.save() + viewModel.requestClearAllOverrides() - // expect - #expect(changed.map(\.key) == [key1]) + // expect clear alert is showing + #expect(viewModel.isShowingClearAlert) } - // MARK: - Clear All Overrides + // MARK: - confirmClearAllOverrides @Test - mutating func clearAllOverridesDelegatesToDocument() { - // set up - let key = randomConfigKey() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(randomConfigContent(), forKey: key) + mutating func confirmClearAllOverridesDelegatesToDocument() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - let viewModel = makeListViewModel(document: document) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) // exercise - viewModel.clearAllOverrides() + viewModel.confirmClearAllOverrides() - // expect + // expect working copy is empty #expect(document.workingCopy.isEmpty) } - // MARK: - Undo/Redo + // MARK: - undo @Test mutating func undoDelegatesToUndoManager() { - // set up - let key = randomConfigKey() - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider, undoManager: undoManager) - document.setOverride(randomConfigContent(), forKey: key) + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - let viewModel = makeListViewModel(document: document, undoManager: undoManager) - #expect(viewModel.canUndo) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) // exercise viewModel.undo() - // expect - #expect(!document.hasOverride(forKey: key)) + // expect override is removed + #expect(!document.hasOverride(forKey: variable.key)) } + // MARK: - redo + @Test mutating func redoDelegatesToUndoManager() { - // set up - let key = randomConfigKey() - let content = randomConfigContent() - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider, undoManager: undoManager) - document.setOverride(content, forKey: key) - undoManager.undo() + // set up with an override then undo + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) - let viewModel = makeListViewModel(document: document, undoManager: undoManager) - #expect(viewModel.canRedo) + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) + undoManager.undo() // exercise viewModel.redo() - // expect - #expect(document.override(forKey: key) == content) + // expect override is restored + #expect(document.override(forKey: variable.key) == content) } - // MARK: - Detail View Model + // MARK: - makeDetailViewModel @Test - mutating func makeDetailViewModelReturnsViewModel() { + mutating func makeDetailViewModelReturnsViewModelForKey() { // set up - let key = randomConfigKey() - let variable = randomRegisteredVariable(key: key) - - let viewModel = makeListViewModel(registeredVariables: [key: variable]) + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) // exercise - let detailVM = viewModel.makeDetailViewModel(for: key) + let detailViewModel = viewModel.makeDetailViewModel(for: variable.key) - // expect - #expect(detailVM.key == key) + // expect the detail view model has the correct key + #expect(detailViewModel.key == variable.key) } } - - -// MARK: - Helpers - -extension ConfigVariableListViewModelTests { - private func makeListViewModel( - document: EditorDocument? = nil, - registeredVariables: [ConfigKey: RegisteredConfigVariable] = [:], - providers: [any ConfigProvider] = [], - undoManager: UndoManager = UndoManager() - ) -> ConfigVariableListViewModel { - let effectiveDocument = document ?? EditorDocument(provider: EditorOverrideProvider()) - return ConfigVariableListViewModel( - document: effectiveDocument, - registeredVariables: registeredVariables, - namedProviders: providers.map { NamedConfigProvider($0) }, - undoManager: undoManager - ) - } - - - private mutating func randomRegisteredVariable( - key: ConfigKey? = nil, - defaultContent: ConfigContent = .bool(false), - metadata: ConfigVariableMetadata = ConfigVariableMetadata(), - editorControl: EditorControl = .toggle - ) -> RegisteredConfigVariable { - RegisteredConfigVariable( - key: key ?? randomConfigKey(), - defaultContent: defaultContent, - isSecret: randomBool(), - metadata: metadata, - editorControl: editorControl, - parse: nil - ) - } -} - -#endif diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift index 85ec308..58b9d70 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift @@ -2,7 +2,7 @@ // EditorDocumentTests.swift // DevConfiguration // -// Created by Prachi Gauriar on 3/7/2026. +// Created by Prachi Gauriar on 3/9/2026. // import Configuration @@ -16,606 +16,607 @@ import Testing struct EditorDocumentTests: RandomValueGenerating { var randomNumberGenerator = makeRandomNumberGenerator() + let editorOverrideProvider = EditorOverrideProvider() + var userDefaults: UserDefaults! + var workingCopyDisplayName: String! + let undoManager = UndoManager() - // MARK: - Init - @Test - func initWithEmptyProvider() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - - // expect - #expect(document.workingCopy.isEmpty) - #expect(!document.isDirty) + init() { + workingCopyDisplayName = randomAlphanumericString() + userDefaults = UserDefaults(suiteName: randomAlphanumericString())! } - @Test - mutating func initWithPopulatedProvider() { - // set up - let provider = EditorOverrideProvider() - let key1 = randomConfigKey() - let content1 = randomConfigContent() - let key2 = randomConfigKey() - let content2 = randomConfigContent() - provider.setOverride(content1, forKey: key1) - provider.setOverride(content2, forKey: key2) - - // exercise - let document = EditorDocument(provider: provider) + // MARK: - Helpers - // expect - #expect(document.workingCopy == [key1: content1, key2: content2]) - #expect(!document.isDirty) + mutating func makeDocument( + editorOverrideProvider: EditorOverrideProvider? = nil, + namedProviders: [NamedConfigProvider] = [], + registeredVariables: [RegisteredConfigVariable]? = nil + ) -> EditorDocument { + EditorDocument( + editorOverrideProvider: editorOverrideProvider ?? self.editorOverrideProvider, + workingCopyDisplayName: workingCopyDisplayName, + namedProviders: namedProviders, + registeredVariables: registeredVariables ?? [randomRegisteredVariable()], + userDefaults: userDefaults, + undoManager: undoManager + ) } - // MARK: - Working Copy + // MARK: - Initialization @Test - mutating func setOverrideThenRetrieve() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() - let content = randomConfigContent() + mutating func initStoresRegisteredVariablesByKey() throws { + // set up with multiple registered variables + let variable1 = randomRegisteredVariable() + let variable2 = randomRegisteredVariable() // exercise - document.setOverride(content, forKey: key) - - // expect - #expect(document.override(forKey: key) == content) - } - - - @Test - mutating func setOverrideOverwritesPreviousValue() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() - let content1 = ConfigContent.string(randomAlphanumericString()) - let content2 = ConfigContent.int(randomInt(in: .min ... .max)) + let document = makeDocument(registeredVariables: [variable1, variable2]) - document.setOverride(content1, forKey: key) + // expect each variable is stored keyed by its config key + #expect(document.registeredVariables.count == 2) - // exercise - document.setOverride(content2, forKey: key) + let registered1 = try #require(document.registeredVariables[variable1.key]) + #expect(registered1.key == variable1.key) + #expect(registered1.defaultContent == variable1.defaultContent) - // expect - #expect(document.override(forKey: key) == content2) + let registered2 = try #require(document.registeredVariables[variable2.key]) + #expect(registered2.key == variable2.key) + #expect(registered2.defaultContent == variable2.defaultContent) } @Test - mutating func overrideForNonexistentKeyReturnsNil() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - - // expect - #expect(document.override(forKey: randomConfigKey()) == nil) - } + mutating func initSnapshotsProviders() throws { + // set up with a registered variable and an InMemoryProvider that has a value for it + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let providerContent = ConfigContent.string(randomAlphanumericString()) + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(providerContent, isSecret: false) + ] + ) + let displayName = randomAlphanumericString() - @Test - mutating func hasOverrideReturnsTrueForExistingKey() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() - document.setOverride(randomConfigContent(), forKey: key) + // exercise + let document = makeDocument( + namedProviders: [.init(provider, displayName: displayName)], + registeredVariables: [variable] + ) - // expect - #expect(document.hasOverride(forKey: key)) + // expect first snapshot has correct display name, index, and value + let snapshot = try #require(document.providerSnapshots.first) + #expect(snapshot.displayName == displayName) + #expect(snapshot.index == 0) + #expect(snapshot.values[variable.key] == providerContent) } @Test - mutating func hasOverrideReturnsFalseForNonexistentKey() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - - // expect - #expect(!document.hasOverride(forKey: randomConfigKey())) - } + mutating func initAppendsDefaultSnapshot() throws { + // set up with a registered variable and one named provider + let defaultContent = ConfigContent.int(randomInt(in: .min ... .max)) + let variable = randomRegisteredVariable(defaultContent: defaultContent) - - @Test - mutating func removeOverrideClearsValue() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() - document.setOverride(randomConfigContent(), forKey: key) + let provider = InMemoryProvider(values: [:]) // exercise - document.removeOverride(forKey: key) + let document = makeDocument( + namedProviders: [.init(provider, displayName: randomAlphanumericString())], + registeredVariables: [variable] + ) - // expect - #expect(document.override(forKey: key) == nil) - #expect(!document.hasOverride(forKey: key)) + // expect last snapshot is "Default" with index = namedProviders.count and default values + let defaultSnapshot = try #require(document.providerSnapshots.last) + #expect(defaultSnapshot.displayName == localizedString("editor.defaultProviderName")) + #expect(defaultSnapshot.index == 1) + #expect(defaultSnapshot.values[variable.key] == defaultContent) } @Test - mutating func removeOverrideForNonexistentKeyIsNoOp() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + mutating func initCopiesExistingOverridesToWorkingCopy() { + // set up by pre-populating the editor override provider let key = randomConfigKey() - document.setOverride(randomConfigContent(), forKey: key) + let content = ConfigContent.string(randomAlphanumericString()) + editorOverrideProvider.setOverride(content, forKey: key) + + let variable = randomRegisteredVariable(key: key, defaultContent: .string(randomAlphanumericString())) // exercise - document.removeOverride(forKey: randomConfigKey()) + let document = makeDocument(registeredVariables: [variable]) - // expect — original override is untouched - #expect(document.workingCopy.count == 1) + // expect working copy contains the pre-existing override + #expect(document.workingCopy[key] == content) } - @Test - mutating func removeAllOverridesClearsWorkingCopy() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(randomConfigContent(), forKey: randomConfigKey()) - document.setOverride(randomConfigContent(), forKey: randomConfigKey()) + // MARK: - Value Resolution - // exercise - document.removeAllOverrides() + @Test + mutating func resolvedValuePrefersWorkingCopyOverProviders() throws { + // set up with a provider value and a working copy override for the same key + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) - // expect - #expect(document.workingCopy.isEmpty) - } + let providerContent = ConfigContent.string(randomAlphanumericString()) + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(providerContent, isSecret: false) + ] + ) + let document = makeDocument( + namedProviders: [.init(provider, displayName: randomAlphanumericString())], + registeredVariables: [variable] + ) - @Test - func removeAllOverridesWhenEmptyIsNoOp() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) // exercise - document.removeAllOverrides() + let resolved = try #require(document.resolvedValue(forKey: variable.key)) - // expect - #expect(document.workingCopy.isEmpty) - #expect(!document.isDirty) + // expect working copy wins + #expect(resolved.content == overrideContent) + #expect(resolved.providerDisplayName == workingCopyDisplayName) + #expect(resolved.providerIndex == nil) } - // MARK: - Dirty Tracking - @Test - func isNotDirtyAfterInit() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + mutating func resolvedValueSkipsMismatchedTypes() throws { + // set up with a string variable but an int override in working copy + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) - // expect - #expect(!document.isDirty) - } + let providerContent = ConfigContent.string(randomAlphanumericString()) + let providerDisplayName = randomAlphanumericString() + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(providerContent, isSecret: false) + ] + ) + let document = makeDocument( + namedProviders: [.init(provider, displayName: providerDisplayName)], + registeredVariables: [variable] + ) - @Test - mutating func isDirtyAfterSetOverride() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + // set a mismatched type in the working copy + document.setOverride(.int(randomInt(in: .min ... .max)), forKey: variable.key) // exercise - document.setOverride(randomConfigContent(), forKey: randomConfigKey()) + let resolved = try #require(document.resolvedValue(forKey: variable.key)) - // expect - #expect(document.isDirty) + // expect the provider value wins since working copy type doesn't match + #expect(resolved.content == providerContent) + #expect(resolved.providerDisplayName == providerDisplayName) + #expect(resolved.providerIndex == 0) } @Test - mutating func isNotDirtyAfterRevertingToBaseline() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() + mutating func resolvedValueFallsThroughToDefault() throws { + // set up with no provider values and no working copy override + let defaultContent = ConfigContent.bool(randomBool()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) - document.setOverride(randomConfigContent(), forKey: key) - #expect(document.isDirty) + let document = makeDocument(registeredVariables: [variable]) // exercise - document.removeOverride(forKey: key) + let resolved = try #require(document.resolvedValue(forKey: variable.key)) - // expect - #expect(!document.isDirty) + // expect the default snapshot value wins + #expect(resolved.content == defaultContent) + #expect(resolved.providerDisplayName == localizedString("editor.defaultProviderName")) + #expect(resolved.providerIndex == 0) } @Test - mutating func isDirtyAfterRemovingBaselineOverride() { - // set up - let provider = EditorOverrideProvider() - let key = randomConfigKey() - provider.setOverride(randomConfigContent(), forKey: key) - let document = EditorDocument(provider: provider) + mutating func resolvedValueReturnsNilForUnregisteredKey() { + // set up with a document that has no variable for the queried key + let document = makeDocument() // exercise - document.removeOverride(forKey: key) + let resolved = document.resolvedValue(forKey: randomConfigKey()) - // expect - #expect(document.isDirty) + // expect nil for an unregistered key + #expect(resolved == nil) } - @Test - mutating func isDirtyAfterChangingBaselineValue() { - // set up - let provider = EditorOverrideProvider() - let key = randomConfigKey() - provider.setOverride(.bool(true), forKey: key) - let document = EditorDocument(provider: provider) - - // exercise - document.setOverride(.bool(false), forKey: key) - - // expect - #expect(document.isDirty) - } - + // MARK: - Provider Values @Test - func changedKeysIsEmptyAfterInit() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + mutating func providerValuesIncludesAllProvidersWithValues() { + // set up with a working copy override, a provider value, and a default value + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) - // expect - #expect(document.changedKeys.isEmpty) - } + let providerContent = ConfigContent.string(randomAlphanumericString()) + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(providerContent, isSecret: false) + ] + ) + let providerDisplayName = randomAlphanumericString() + let document = makeDocument( + namedProviders: [.init(provider, displayName: providerDisplayName)], + registeredVariables: [variable] + ) - @Test - mutating func changedKeysIncludesAddedKey() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) // exercise - document.setOverride(randomConfigContent(), forKey: key) - - // expect - #expect(document.changedKeys == [key]) - } + let values = document.providerValues(forKey: variable.key) + + // expect three entries: working copy, provider, and default + let expected = [ + ProviderValue( + providerName: workingCopyDisplayName, + providerIndex: nil, + isActive: true, + valueString: overrideContent.displayString, + contentTypeMatches: true + ), + ProviderValue( + providerName: providerDisplayName, + providerIndex: 0, + isActive: false, + valueString: providerContent.displayString, + contentTypeMatches: true + ), + ProviderValue( + providerName: localizedString("editor.defaultProviderName"), + providerIndex: 1, + isActive: false, + valueString: defaultContent.displayString, + contentTypeMatches: true + ), + ] + #expect(values == expected) + } + + + @Test + mutating func providerValuesMarksActiveAndContentTypeMatch() { + // set up with a matching working copy override and a mismatched provider value + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + + let mismatchedContent = ConfigContent.int(randomInt(in: .min ... .max)) + let provider = InMemoryProvider( + values: [ + AbsoluteConfigKey(variable.key): ConfigValue(mismatchedContent, isSecret: false) + ] + ) + let providerDisplayName = randomAlphanumericString() + + let document = makeDocument( + namedProviders: [.init(provider, displayName: providerDisplayName)], + registeredVariables: [variable] + ) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) - - @Test - mutating func changedKeysIncludesRemovedKey() { - // set up - let provider = EditorOverrideProvider() - let key = randomConfigKey() - provider.setOverride(randomConfigContent(), forKey: key) - let document = EditorDocument(provider: provider) + // exercise + let values = document.providerValues(forKey: variable.key) + + // expect working copy active and matching, provider mismatched, default matching but inactive + let expected = [ + ProviderValue( + providerName: workingCopyDisplayName, + providerIndex: nil, + isActive: true, + valueString: overrideContent.displayString, + contentTypeMatches: true + ), + ProviderValue( + providerName: providerDisplayName, + providerIndex: 0, + isActive: false, + valueString: mismatchedContent.displayString, + contentTypeMatches: false + ), + ProviderValue( + providerName: localizedString("editor.defaultProviderName"), + providerIndex: 1, + isActive: false, + valueString: defaultContent.displayString, + contentTypeMatches: true + ), + ] + #expect(values == expected) + } + + + @Test + mutating func providerValuesReturnsEmptyForUnregisteredKey() { + // set up + let document = makeDocument() // exercise - document.removeOverride(forKey: key) + let values = document.providerValues(forKey: randomConfigKey()) - // expect - #expect(document.changedKeys == [key]) + // expect empty for an unregistered key + #expect(values.isEmpty) } + // MARK: - Working Copy + @Test - mutating func changedKeysIncludesModifiedKey() { + mutating func setAndRemoveOverride() { // set up - let provider = EditorOverrideProvider() - let key = randomConfigKey() - provider.setOverride(.bool(true), forKey: key) - let document = EditorDocument(provider: provider) + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - // exercise - document.setOverride(.bool(false), forKey: key) + let overrideContent = ConfigContent.string(randomAlphanumericString()) - // expect - #expect(document.changedKeys == [key]) - } + // exercise set + document.setOverride(overrideContent, forKey: variable.key) + // expect override is present + #expect(document.workingCopy[variable.key] == overrideContent) - @Test - mutating func changedKeysExcludesUnchangedKey() { - // set up - let provider = EditorOverrideProvider() - let unchangedKey = randomConfigKey() - let changedKey = randomConfigKey() - provider.setOverride(.bool(true), forKey: unchangedKey) - let document = EditorDocument(provider: provider) - - // exercise - document.setOverride(randomConfigContent(), forKey: changedKey) + // exercise remove + document.removeOverride(forKey: variable.key) - // expect - #expect(document.changedKeys == [changedKey]) + // expect override is gone + #expect(document.workingCopy[variable.key] == nil) } - // MARK: - Save - @Test - mutating func saveReturnsChangedKeys() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key1 = randomConfigKey() - let key2 = randomConfigKey() - document.setOverride(randomConfigContent(), forKey: key1) - document.setOverride(randomConfigContent(), forKey: key2) + mutating func setOverrideWithSameValueIsNoOp() { + // set up with an existing override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - // exercise - let changed = document.save() + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) + undoManager.removeAllActions() + + // exercise by setting the same value again + document.setOverride(content, forKey: variable.key) - // expect - #expect(changed == [key1, key2]) + // expect no undo action was registered + #expect(!undoManager.canUndo) } @Test - mutating func saveResetsBaselineSoDocumentIsClean() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - document.setOverride(randomConfigContent(), forKey: randomConfigKey()) - #expect(document.isDirty) + mutating func removeAllOverrides() { + // set up with multiple overrides + let variable1 = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let variable2 = randomRegisteredVariable(defaultContent: .int(randomInt(in: .min ... .max))) + let document = makeDocument(registeredVariables: [variable1, variable2]) + + document.setOverride(.string(randomAlphanumericString()), forKey: variable1.key) + document.setOverride(.int(randomInt(in: .min ... .max)), forKey: variable2.key) // exercise - document.save() + document.removeAllOverrides() - // expect - #expect(!document.isDirty) - #expect(document.changedKeys.isEmpty) + // expect working copy is empty + #expect(document.workingCopy.isEmpty) } @Test - mutating func saveUpdatesProviderOverrides() { + mutating func hasOverrideReturnsTrueWhenOverrideExists() { // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() - let content = randomConfigContent() - document.setOverride(content, forKey: key) + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + // expect false before setting an override + #expect(!document.hasOverride(forKey: variable.key)) // exercise - document.save() + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) - // expect - #expect(provider.overrides == [key: content]) + // expect true after setting an override + #expect(document.hasOverride(forKey: variable.key)) } @Test - mutating func savePersistsToUserDefaults() { + mutating func overrideReturnsContentWhenOverrideExists() { // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() - let content = randomConfigContent() - document.setOverride(content, forKey: key) + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - // exercise - document.save() + // expect nil before setting an override + #expect(document.override(forKey: variable.key) == nil) - // expect — verify persistence by loading into a fresh provider - let freshProvider = EditorOverrideProvider() - freshProvider.load(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) - #expect(freshProvider.overrides[key] == content) + // exercise + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) - // clean up - provider.clearPersistence(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) + // expect the override content is returned + #expect(document.override(forKey: variable.key) == content) } @Test - func saveWithNoChangesReturnsEmptySet() { + mutating func removeOverrideForMissingKeyIsNoOp() { // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) + let document = makeDocument() + undoManager.removeAllActions() - // exercise - let changed = document.save() + // exercise by removing an override for a key that has none + document.removeOverride(forKey: randomConfigKey()) - // expect - #expect(changed.isEmpty) + // expect no undo action was registered + #expect(!undoManager.canUndo) } @Test - mutating func saveWithRemovedBaselineKeyIncludesItInChangedKeys() { - // set up - let provider = EditorOverrideProvider() - let key = randomConfigKey() - provider.setOverride(randomConfigContent(), forKey: key) - let document = EditorDocument(provider: provider) - document.removeOverride(forKey: key) + mutating func removeAllOverridesWhenEmptyIsNoOp() { + // set up with no overrides + let document = makeDocument() + undoManager.removeAllActions() // exercise - let changed = document.save() + document.removeAllOverrides() - // expect - #expect(changed == [key]) - #expect(provider.overrides.isEmpty) + // expect no undo action was registered + #expect(!undoManager.canUndo) } - // MARK: - Undo/Redo - @Test - mutating func undoSetOverrideRestoresPreviousValue() { - // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let key = randomConfigKey() - let originalContent = ConfigContent.string(randomAlphanumericString()) - provider.setOverride(originalContent, forKey: key) - let document = EditorDocument(provider: provider, undoManager: undoManager) + mutating func undoRemoveAllOverridesRestoresValues() async { + // set up with multiple overrides, yielding to close the undo group before removing + let variable1 = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let variable2 = randomRegisteredVariable(defaultContent: .int(randomInt(in: .min ... .max))) + let document = makeDocument(registeredVariables: [variable1, variable2]) - document.setOverride(.bool(true), forKey: key) - #expect(document.override(forKey: key) == .bool(true)) + let content1 = ConfigContent.string(randomAlphanumericString()) + let content2 = ConfigContent.int(randomInt(in: .min ... .max)) + document.setOverride(content1, forKey: variable1.key) + document.setOverride(content2, forKey: variable2.key) + await Task.yield() + + document.removeAllOverrides() // exercise undoManager.undo() - // expect - #expect(document.override(forKey: key) == originalContent) + // expect both overrides are restored + #expect(document.workingCopy[variable1.key] == content1) + #expect(document.workingCopy[variable2.key] == content2) } + // MARK: - Undo/Redo + @Test - mutating func undoSetOverrideRemovesNewKey() { - // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider, undoManager: undoManager) - let key = randomConfigKey() + mutating func undoSetOverrideRestoresPreviousState() { + // set up by setting an override on a fresh key + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - document.setOverride(randomConfigContent(), forKey: key) - #expect(document.hasOverride(forKey: key)) + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) // exercise undoManager.undo() - // expect - #expect(!document.hasOverride(forKey: key)) + // expect the override is removed + #expect(document.workingCopy[variable.key] == nil) } @Test - mutating func redoSetOverrideReapplies() { - // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider, undoManager: undoManager) - let key = randomConfigKey() - let content = randomConfigContent() + mutating func undoSetOverrideRestoresOldValue() async { + // set up by setting an override, yielding to close the undo group, then overwriting + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - document.setOverride(content, forKey: key) - undoManager.undo() - #expect(!document.hasOverride(forKey: key)) + let originalContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(originalContent, forKey: variable.key) + await Task.yield() + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) // exercise - undoManager.redo() + undoManager.undo() - // expect - #expect(document.override(forKey: key) == content) + // expect the original value is restored + #expect(document.workingCopy[variable.key] == originalContent) } @Test - mutating func undoRemoveOverrideRestoresValue() { - // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let key = randomConfigKey() - let content = randomConfigContent() - provider.setOverride(content, forKey: key) - let document = EditorDocument(provider: provider, undoManager: undoManager) + mutating func undoRemoveOverrideRestoresValue() async { + // set up by setting an override, yielding to close the undo group, then removing + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) + await Task.yield() - document.removeOverride(forKey: key) - #expect(!document.hasOverride(forKey: key)) + document.removeOverride(forKey: variable.key) // exercise undoManager.undo() - // expect - #expect(document.override(forKey: key) == content) + // expect the value is restored + #expect(document.workingCopy[variable.key] == content) } + // MARK: - Dirty Tracking and Save + @Test - mutating func redoRemoveOverrideRemovesAgain() { + mutating func dirtyTrackingReflectsWorkingCopyChanges() { // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let key = randomConfigKey() - let content = randomConfigContent() - provider.setOverride(content, forKey: key) - let document = EditorDocument(provider: provider, undoManager: undoManager) + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - document.removeOverride(forKey: key) - undoManager.undo() - #expect(document.hasOverride(forKey: key)) + // expect clean initially + #expect(!document.isDirty) + #expect(document.changedKeys.isEmpty) - // exercise - undoManager.redo() + // exercise by adding an override + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) - // expect - #expect(!document.hasOverride(forKey: key)) + // expect dirty with the changed key + #expect(document.isDirty) + #expect(document.changedKeys == [variable.key]) } @Test - mutating func undoRemoveAllOverridesRestoresAll() { - // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let key1 = randomConfigKey() - let content1 = randomConfigContent() - let key2 = randomConfigKey() - let content2 = randomConfigContent() - provider.setOverride(content1, forKey: key1) - provider.setOverride(content2, forKey: key2) - let document = EditorDocument(provider: provider, undoManager: undoManager) + mutating func saveCommitsToProviderAndResetsDirtyState() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) - document.removeAllOverrides() - #expect(document.workingCopy.isEmpty) + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) // exercise - undoManager.undo() + document.save() + + // expect dirty state is reset + #expect(!document.isDirty) + #expect(document.changedKeys.isEmpty) - // expect - #expect(document.workingCopy == [key1: content1, key2: content2]) + // expect the override was committed to the provider + #expect(editorOverrideProvider.overrides[variable.key] == overrideContent) } @Test - mutating func redoRemoveAllOverridesClearsAgain() { - // set up - let undoManager = UndoManager() - let provider = EditorOverrideProvider() - let key1 = randomConfigKey() - let content1 = randomConfigContent() - let key2 = randomConfigKey() - let content2 = randomConfigContent() - provider.setOverride(content1, forKey: key1) - provider.setOverride(content2, forKey: key2) - let document = EditorDocument(provider: provider, undoManager: undoManager) - - document.removeAllOverrides() - undoManager.undo() - #expect(document.workingCopy.count == 2) - - // exercise - undoManager.redo() - - // expect - #expect(document.workingCopy.isEmpty) - } + mutating func saveRemovesDeletedOverridesFromProvider() { + // set up by saving an override, then removing it from the working copy + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) + document.save() - @Test - mutating func noUndoManagerDoesNotCrash() { - // set up - let provider = EditorOverrideProvider() - let document = EditorDocument(provider: provider) - let key = randomConfigKey() + document.removeOverride(forKey: variable.key) - // exercise — all mutations should work without an undo manager - document.setOverride(randomConfigContent(), forKey: key) - document.removeOverride(forKey: key) - document.setOverride(randomConfigContent(), forKey: key) - document.removeAllOverrides() + // exercise + document.save() - // expect - #expect(document.workingCopy.isEmpty) + // expect the override is removed from the provider + #expect(!editorOverrideProvider.hasOverride(forKey: variable.key)) } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift index 27da5be..c484f69 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift @@ -35,6 +35,25 @@ struct ConfigContent_AdditionsTests: RandomValueGenerating { } + @Test( + arguments: [ + (ConfigContent.bool(true), "Bool"), + (.int(42), "Int"), + (.double(3.14), "Float64"), + (.string("hello"), "String"), + (.bytes([1, 2, 3]), "Data"), + (.boolArray([true, false]), "[Bool]"), + (.intArray([1, 2]), "[Int]"), + (.doubleArray([1.0, 2.0]), "[Float64]"), + (.stringArray(["a", "b"]), "[String]"), + (.byteChunkArray([[1], [2]]), "[Data]"), + ] + ) + func typeDisplayNameReturnsCorrectName(content: ConfigContent, expectedName: String) { + #expect(content.typeDisplayName == expectedName) + } + + @Test( arguments: [ ConfigContent.string("hello"), From a420f4be27a803ff4cf064b0cab8a3fd41759c95 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Mon, 9 Mar 2026 16:05:52 -0400 Subject: [PATCH 8/9] Add caveat about missing tests --- CLAUDE.md | 3 ++- README.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70fa475..6541f60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,4 +70,5 @@ External dependencies managed via Swift Package Manager: - Uses Swift 6.2 with `ExistentialAny` and `MemberImportVisibility` features enabled - Minimum deployment targets: iOS, macOS, tvOS, visionOS, and watchOS 26 - All public APIs must be documented and tested - - Test coverage target: >99% \ No newline at end of file + - Test coverage target: >99% + - SwiftUI views do not currently have automated tests \ No newline at end of file diff --git a/README.md b/README.md index fa6c9bb..444b9f9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ View our [changelog](CHANGELOG.md) to see what’s new. DevConfiguration requires a Swift 6.2 toolchain to build. We only test on Apple platforms. We follow the [Swift API Design Guidelines][SwiftAPIDesignGuidelines]. We take pride in the fact that our public interfaces are fully documented and tested. We aim for overall test coverage over 99%. +SwiftUI views do not currently have automated tests. [SwiftAPIDesignGuidelines]: https://swift.org/documentation/api-design-guidelines/ From 5b9319dda88a7fdc7030beb5663d4d1922338bb1 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Mon, 9 Mar 2026 16:34:05 -0400 Subject: [PATCH 9/9] Add distinction between content and variable types --- Documentation/EditorUI/ArchitecturePlan.md | 300 ------------------ Documentation/EditorUI/ImplementationPlan.md | 292 ----------------- .../Core/RegisteredConfigVariable.swift | 132 +++++++- .../ConfigVariableDetailView.swift | 9 +- .../ConfigVariableDetailViewModel.swift | 6 +- .../ConfigVariableDetailViewModeling.swift | 7 +- .../Resources/Localizable.xcstrings | 14 +- .../Core/RegisteredConfigVariableTests.swift | 43 +++ .../ConfigVariableDetailViewModelTests.swift | 3 +- 9 files changed, 204 insertions(+), 602 deletions(-) delete mode 100644 Documentation/EditorUI/ArchitecturePlan.md delete mode 100644 Documentation/EditorUI/ImplementationPlan.md diff --git a/Documentation/EditorUI/ArchitecturePlan.md b/Documentation/EditorUI/ArchitecturePlan.md deleted file mode 100644 index 1b095b5..0000000 --- a/Documentation/EditorUI/ArchitecturePlan.md +++ /dev/null @@ -1,300 +0,0 @@ -# Editor UI Architecture Plan - - -## Overview - -The Editor UI is a SwiftUI-based interface that allows users to inspect and override the values -of registered configuration variables in a `ConfigVariableReader`. It operates as a "document" -— changes are staged in a working copy, committed on save, and persisted across app launches -via a `ConfigProvider` backed by UserDefaults. - -The editor is opt-in: `ConfigVariableReader` accepts an `isEditorEnabled` flag at init. When -enabled, an internal override provider is prepended to the reader's provider list, taking -precedence over all other providers. - - -## Module Structure - -All editor code lives in the `DevConfiguration` target, guarded by `#if canImport(SwiftUI)`. - - - **View model protocols** (`*ViewModeling`) live **outside** the `#if` block so they can be - tested without SwiftUI. - - **Views** (`*View`) and **concrete view models** (`*ViewModel`) live inside the `#if` block. - - The full MVVM pattern is used: `*ViewModeling` protocol, generic `*View`, and `@Observable` - `*ViewModel`. - - -## Key Types - - -### EditorOverrideProvider - -A `ConfigProvider` implementation that stores override values in memory and persists them to -UserDefaults. - - - **Suite**: `devkit.DevConfiguration` - - **Provider name**: `"Editor"` - - Conforms to `ConfigProvider` (sync `value(forKey:type:)`, async `fetchValue`, `watchValue`) - - On init, loads any previously persisted overrides from UserDefaults - - On save, writes current overrides to UserDefaults - - On clear, removes all overrides from both memory and UserDefaults (after save) - - Prepended to the reader's `providers` array when `isEditorEnabled` is true - - Always assigned a distinctive color (e.g., `.orange`) for the provider capsule - - -### EditorDocument - -The working copy model that tracks staged overrides, enabling save, cancel, undo, and redo. - - - Initialized with the current committed overrides from `EditorOverrideProvider` - - Tracks a dictionary of `[ConfigKey: ConfigContent?]` where: - - A key with a `ConfigContent` value means "override this variable with this value" - - A key with `nil` means "remove the override for this variable" - - Absence of a key means "no change from committed state" - - **Dirty tracking**: compares working copy against committed state to determine if there - are unsaved changes - - **Undo/redo**: integrates with `UndoManager`; each override change (set, remove, clear - all) registers an undo action - - **Save**: computes the delta of changed keys, commits to `EditorOverrideProvider`, calls - the `onSave` closure with a collection of `SavedChange` values (each containing the key - and the variable's full `RegisteredConfigVariable`, giving consumers access to all - metadata including `requiresRelaunch`) - - **Clear all overrides**: removes all overrides in the working copy (undoable, requires - save to take effect) - - -### ConfigVariableReader Changes - - - New `isEditorEnabled: Bool` parameter on init (immutable, defaults to `false`) - - When enabled, creates an `EditorOverrideProvider` and prepends it to the providers list - - Stores a reference to the `EditorOverrideProvider` for use by the editor UI - - Exposes a method or property to get the editor view (exact API TBD — see - [Public API Surface](#public-api-surface)) - - -### ConfigVariableContent Additions - -New properties to support editing: - - - **`editorControl: EditorControl`** — describes which UI control to show: - - `.toggle` — for `Bool` - - `.textField` — for `String` - - `.numberField` — for `Int` - - `.decimalField` — for `Float64` - - `.none` — for types that don't support editing (bytes, arrays, codable) - - **`parse: @Sendable (String) -> ConfigContent?`** — for text-based editors, parses raw user - input into a `ConfigContent` value; `nil` if input is invalid - -Content factories set these automatically: - - - `.bool` → `.toggle`, parse: `Bool("true"/"false")` → `.bool(_)` - - `.string` → `.textField`, parse: identity → `.string(_)` - - `.int` → `.numberField`, parse: `Int(_)` → `.int(_)` - - `.float64` → `.decimalField`, parse: `Double(_)` → `.double(_)` - - `.rawRepresentableString()` → `.textField`, parse: identity → `.string(_)` - - `.rawRepresentableInt()` → `.numberField`, parse: `Int(_)` → `.int(_)` - - All others → `.none`, parse: `nil` - -These fields are stored on `RegisteredConfigVariable` at registration time. - - -### EditorControl - -A struct with a private backing enum, allowing new control types to be added in the future -without breaking existing consumers. - - public struct EditorControl: Hashable, Sendable { - private enum Kind: Hashable, Sendable { - case toggle - case textField - case numberField - case decimalField - case none - } - - private let kind: Kind - - public static var toggle: EditorControl { .init(kind: .toggle) } - public static var textField: EditorControl { .init(kind: .textField) } - public static var numberField: EditorControl { .init(kind: .numberField) } - public static var decimalField: EditorControl { .init(kind: .decimalField) } - public static var none: EditorControl { .init(kind: .none) } - } - - -### ConfigVariableMetadata Additions - -Two new metadata keys: - - - **`displayName: String?`** — a human-readable label for the variable, shown in the list and - detail views. When `nil`, the variable's key is used as the display text. - - **`requiresRelaunch: Bool`** — indicates that changes to this variable don't take effect - until the app is relaunched. The editor does not act on this directly; it's provided to the - `onSave` closure's changed keys so the consumer can prompt as appropriate. - - -## Views - - -### ConfigVariableListView (List View) - -The top-level editor view showing all registered variables. - -**Layout:** - - - **Toolbar**: Cancel button (leading), title ("Configuration Editor"), Save button - (trailing), overflow menu (`...`) containing Undo, Redo, and Clear Editor Overrides - - **Search bar**: filters variables by display name, key, current value, and metadata - - **List**: one row per registered variable, sorted by display name (falling back to key) - -**List row contents:** - - - Display name and key (both always shown; if no display name is set, the key is used as - the display name, so it appears twice) - - Current value (from working copy state — override if set, otherwise resolved value) - - Provider capsule — colored rounded rect with the provider name; color is deterministic - based on provider index (editor override provider always gets its own color) - -**Actions:** - - - Tap row → navigates to detail view - - Cancel → if dirty, shows "Discard Changes?" alert; otherwise dismisses - - Save → commits working copy, calls `onSave` with changed variables, dismisses - - Toolbar overflow menu (`...`): Undo, Redo, and Clear Editor Overrides - - Clear Editor Overrides → shows confirmation alert, then clears all overrides in working - copy (undoable, still requires save) - - -### ConfigVariableDetailView - -The detail view for a single variable. - -**Layout (sections):** - - - **Header**: display name, key - - **Current Value**: the resolved value with its provider capsule - - **Override section**: - - "Enable Override" toggle - - When enabled, shows the appropriate editor control based on `EditorControl` - - Changes register with `UndoManager` - - **Values**: value from each provider, each with its provider capsule - - Incompatible values (wrong `ConfigContent` case for the variable's type) shown with - strikethrough - - Secret values redacted by default with tap-to-reveal (detail view only) - - **Metadata**: all metadata entries from `displayTextEntries` - -**Editor controls by type:** - - - `.toggle` — `Toggle` bound to the override value - - `.textField` — `TextField` (strings are treated as single-line; multiline support can be - added later if a use case arises or a new `EditorControl` type is introduced) - - `.numberField` — `TextField` with `.numberPad` keyboard, rejects fractional input - - `.decimalField` — `TextField` with `.decimalPad` keyboard - - `.none` — no override section (read-only) - - -## Provider Colors - -Each provider is assigned a deterministic color from a fixed palette of SwiftUI system colors. -The assignment is based on the provider's index in the reader's `providers` array: - - private static let providerColors: [Color] = [ - .blue, .green, .purple, .pink, .teal, .indigo, .mint, .cyan, .brown, .gray - ] - -The editor override provider always uses `.orange`, regardless of index. If there are more -providers than colors, colors wrap around. - - -## Provider Value Display - -To show the value from each provider in the detail view, the editor queries each provider -individually using `value(forKey:type:)`. The result is displayed as: - - - The raw `ConfigContent` value formatted as a string - - A colored provider capsule with the provider's name - - If the `ConfigContent` case doesn't match the variable's expected type (e.g., - `.string("hello")` for a `Bool` variable), the value text is shown with strikethrough - - -## Undo/Redo - -The editor uses SwiftUI's `UndoManager`, scoped to the editor session. - - - Each override change (enable, modify value, disable, clear all) registers an undo action - - Undo/redo actions are available in the toolbar overflow menu (`...`) - - The undo stack is discarded when the editor is dismissed - - -## Persistence - -The `EditorOverrideProvider` persists its committed overrides to UserDefaults: - - - **Suite name**: `devkit.DevConfiguration` - - **Storage format**: `ConfigContent` is `Codable` (it's an enum with associated values that - are all codable), so overrides are stored as a `[String: Data]` dictionary where keys are - config key strings and values are JSON-encoded `ConfigContent` - - **Load**: on init, reads from UserDefaults and populates in-memory storage - - **Save**: on commit, writes the full override dictionary to UserDefaults - - **Clear**: on clear + save, removes the key from UserDefaults - - -## Public API Surface - -The minimal public API for consumers: - - // On ConfigVariableReader - public init( - providers: [any ConfigProvider], - eventBus: EventBus, - isEditorEnabled: Bool = false - ) - - // Public view (inside #if canImport(SwiftUI)) - public struct ConfigVariableEditor: View { - public init( - reader: ConfigVariableReader, - onSave: @escaping ([RegisteredConfigVariable]) -> Void - ) - } - -`ConfigVariableEditor` is a public SwiftUI view that consumers initialize directly with a -`ConfigVariableReader` and an `onSave` closure. The consumer is responsible for presentation -(sheet, full-screen cover, navigation push, etc.). The `onSave` closure receives an array of -`RegisteredConfigVariable` values for variables whose overrides changed, giving the consumer -access to all metadata (including `requiresRelaunch`) to decide on post-save behavior. - - -## Config Variable Issues (Future Integration) - -The editor is designed to accommodate a future `ConfigVariableIssueEvaluator` system: - - - **`ConfigVariableIssueEvaluator` protocol**: given a snapshot of providers, their values, - and registered variables, returns an array of issues - - **`ConfigVariableIssue`**: has a kind (identifying string), affected variable (key), - severity (warning/error), and human-readable description - - **Evaluators** are passed to `ConfigVariableReader` at init - - **Editor integration**: issues would appear as warning/error indicators in the list view - rows and detail view, with a filter for "Variables with Issues" - - **Non-editor usage**: a public function on the reader evaluates all issues on demand, which - can be used for config hygiene checks in code - -To prepare for this, the editor's list and detail views should be designed with space for -status indicators, and the filtering system should be extensible to support issue-based -filters. - - -## Design Decisions - - - **`#if canImport(SwiftUI)`** keeps everything in one target, avoiding a separate module - and the public API surface it would require. View model protocols live outside the guard - for testability. - - **Working copy model** ensures the editor behaves like a document — changes are staged, - can be undone, and only take effect on explicit save. - - **`EditorControl` on `ConfigVariableContent`** lets each content type declare its editing - capabilities at the type level, keeping the view layer free of type-switching logic. - - **Deterministic provider colors** ensure a consistent visual identity across editor - sessions without requiring providers to declare their own colors. - - **`onSave` closure with changed `RegisteredConfigVariable` values** gives consumers full - control over post-save behavior (relaunch prompts, analytics, etc.) without the editor - needing to know about those concerns. diff --git a/Documentation/EditorUI/ImplementationPlan.md b/Documentation/EditorUI/ImplementationPlan.md deleted file mode 100644 index 3107c8c..0000000 --- a/Documentation/EditorUI/ImplementationPlan.md +++ /dev/null @@ -1,292 +0,0 @@ -# Editor UI Implementation Plan - -This document breaks the Editor UI feature into incremental implementation slices. Each slice -is a self-contained unit of work that builds on the previous ones, is independently testable, -and results in a working (if incomplete) system. - - -## Slice 1: Metadata & Content Additions - -Add the new metadata keys and editor control infrastructure to the existing types. No UI code. - -### 1a: `displayName` Metadata Key - - - Define `DisplayNameMetadataKey` (private struct conforming to `ConfigVariableMetadataKey`) - - Add `displayName: String?` computed property on `ConfigVariableMetadata` - - Tests: set/get display name, verify display text, verify default is `nil` - -### 1b: `requiresRelaunch` Metadata Key - - - Define `RequiresRelaunchMetadataKey` (private struct conforming to - `ConfigVariableMetadataKey`) - - Add `requiresRelaunch: Bool` computed property on `ConfigVariableMetadata` - - Tests: set/get, verify display text, verify default is `false` - -### 1c: `EditorControl` Enum - - - Define `EditorControl` enum with cases: `.toggle`, `.textField`, `.numberField`, - `.decimalField`, `.none` - - No conformances needed beyond `Sendable` (and `Hashable` for testing convenience) - -### 1d: Editor Support on `ConfigVariableContent` - - - Add `editorControl: EditorControl` property to `ConfigVariableContent` - - Add `parse: (@Sendable (String) -> ConfigContent?)?` property to `ConfigVariableContent` - - Update all content factories to set these: - - `.bool` → `.toggle`, parse: `{ Bool($0).map { .bool($0) } }` - - `.string` → `.textField`, parse: `{ .string($0) }` - - `.int` → `.numberField`, parse: `{ Int($0).map { .int($0) } }` - - `.float64` → `.decimalField`, parse: `{ Double($0).map { .double($0) } }` - - `.rawRepresentableString()` → `.textField`, parse: `{ .string($0) }` - - `.rawRepresentableInt()` → `.numberField`, parse: `{ Int($0).map { .int($0) } }` - - `.expressibleByConfigString()` → `.textField`, parse: `{ .string($0) }` - - `.expressibleByConfigInt()` → `.numberField`, parse: `{ Int($0).map { .int($0) } }` - - All array and codable variants → `.none`, parse: `nil` - - Tests: verify each factory sets the correct editor control and parse behavior - -### 1e: Editor Support on `RegisteredConfigVariable` - - - Add `editorControl: EditorControl` and `parse` closure to `RegisteredConfigVariable` - - Update `ConfigVariableReader.register(_:)` to capture these from the content - - Tests: verify registration captures editor control and parse - - -## Slice 2: EditorOverrideProvider - -Build the `ConfigProvider` that stores and persists editor overrides. - -### 2a: In-Memory Storage - - - Create `EditorOverrideProvider` conforming to `ConfigProvider` - - `providerName` returns `"Editor"` - - Internal storage: `[ConfigKey: ConfigContent]` - - Implement `value(forKey:type:)` — returns the stored content if present and type-compatible - - Implement `fetchValue(forKey:type:)` — same logic, async - - Implement `watchValue(forKey:type:updatesHandler:)` — yields values when overrides change - - Implement `snapshot()` — returns current state - - Public methods: `setOverride(_:forKey:)`, `removeOverride(forKey:)`, `removeAllOverrides()`, - `overrides` (current dictionary), `hasOverride(forKey:)` - - Tests: full coverage of storage, retrieval, removal, type compatibility - -### 2b: UserDefaults Persistence - - - Add `load()` method that reads overrides from `UserDefaults(suiteName:)` - - Add `persist()` method that writes overrides to UserDefaults - - Add `clearPersistence()` method that removes the key from UserDefaults - - Storage format: `[String: Data]` where values are JSON-encoded `ConfigContent` - - `load()` is called on init; `persist()` is called externally after save - - Tests: verify round-trip persistence, verify load on init, verify clear - -### 2c: Integration with ConfigVariableReader - - - Add `isEditorEnabled: Bool` parameter to both `ConfigVariableReader` inits (default - `false`) - - When enabled, create `EditorOverrideProvider`, call `load()`, prepend to providers - - Store reference to the provider as an optional internal property - - Tests: verify provider is prepended when enabled, absent when disabled, overrides take - precedence - - -## Slice 3: EditorDocument - -Build the working copy model with undo/redo support. - -### 3a: Core Working Copy - - - Create `EditorDocument` (or `ConfigEditorDocument`) - - Initialized with `EditorOverrideProvider`'s current committed overrides - - Tracks working copy as `[ConfigKey: ConfigContent]` (the full desired override state) - - Methods: - - `setOverride(_:forKey:)` — sets an override in the working copy - - `removeOverride(forKey:)` — removes an override from the working copy - - `removeAllOverrides()` — clears all overrides in the working copy - - `override(forKey:) -> ConfigContent?` — returns the working copy's override - - `hasOverride(forKey:) -> Bool` - - `isDirty: Bool` — whether working copy differs from committed state - - `changedKeys: Set` — keys that differ from committed state - - Tests: full coverage of working copy operations and dirty tracking - -### 3b: Save & Commit - - - `save()` method: - - Computes delta (changed keys only) - - Updates `EditorOverrideProvider` with the working copy state - - Calls `persist()` on the provider - - Updates the committed baseline to match the working copy - - Returns the changed keys as a `Set` - - The view model layer maps these keys to `RegisteredConfigVariable` values for the - `onSave` closure - - Tests: verify delta computation, provider update, persistence, baseline reset - -### 3c: Undo/Redo Integration - - - `EditorDocument` accepts an `UndoManager?` - - Each mutation method registers an undo action before applying the change - - `removeAllOverrides()` registers a single undo action that restores the full prior state - - Tests: verify undo/redo for set, remove, and clear-all operations - - -## Slice 4: View Model Layer - -Build the view model protocols and concrete implementations. All testable without SwiftUI. - -### 4a: Variable List View Model - - - **Protocol** `ConfigVariableListViewModeling` (outside `#if canImport(SwiftUI)`): - - `var variables: [VariableListItem] { get }` — filtered/sorted list - - `var searchText: String { get set }` - - `func save() -> [RegisteredConfigVariable]` - - `func cancel()` - - `var isDirty: Bool { get }` - - `func clearAllOverrides()` - - `func undo()` / `func redo()` - - `var canUndo: Bool { get }` / `var canRedo: Bool { get }` - - Associated type for detail view model - - **`VariableListItem`**: key, display name (defaults to key if not set), current value - (as string), provider name, provider color index, has override (bool), editor control - - **Concrete `ConfigVariableListViewModel`** (inside `#if canImport(SwiftUI)`): - - `@Observable`, owns the `EditorDocument` - - Queries each provider for current values to determine which provider is responsible - - Sorts by display name (falling back to key) - - Filters by search text across name, key, value, metadata - - Tests: sorting, filtering, save/cancel, dirty tracking, undo/redo delegation - -### 4b: Variable Detail View Model - - - **Protocol** `ConfigVariableDetailViewModeling` (outside `#if canImport(SwiftUI)`): - - `var key: ConfigKey { get }` - - `var displayName: String { get }` - - `var metadata: [ConfigVariableMetadata.DisplayText] { get }` - - `var providerValues: [ProviderValue] { get }` — value from each provider - - `var isOverrideEnabled: Bool { get set }` - - `var overrideText: String { get set }` — for text-based editors - - `var overrideBool: Bool { get set }` — for toggle - - `var editorControl: EditorControl { get }` - - `var isSecretRevealed: Bool { get set }` — tap-to-reveal state - - **`ProviderValue`**: provider name, color index, raw value string, is compatible (bool) - - **Concrete `ConfigVariableDetailViewModel`**: - - Reads from providers via `value(forKey:type:)` on each - - Determines compatibility by checking if the `ConfigContent` case matches expected type - - Override toggle delegates to `EditorDocument.setOverride` / `removeOverride` - - Text/number changes parse via the stored `parse` closure and update the document - - Tests: provider value display, compatibility detection, override enable/disable, parse - validation, secret reveal toggle - - -## Slice 5: SwiftUI Views - -Build the views. All inside `#if canImport(SwiftUI)`. - -### 5a: Supporting Views - - - **`ProviderCapsuleView`** — colored rounded rect with provider name text - - **Provider color assignment** — static function mapping provider index to system color; - editor override provider always returns `.orange` - -### 5b: ConfigVariableListView (List) - - - Generic on `ViewModel: ConfigVariableListViewModeling` - - `NavigationStack` with `List` - - Search bar via `.searchable` modifier - - Each row: display name, key, value, provider capsule - - Tap row → push `ConfigVariableDetailView` - - Toolbar: Cancel (leading), Save (trailing), overflow menu with Undo, Redo, and Clear - Editor Overrides - - Cancel shows alert if dirty - - Clear Editor Overrides shows confirmation alert - -### 5c: ConfigVariableDetailView - - - Generic on `ViewModel: ConfigVariableDetailViewModeling` - - Sections: Header, Override, Values, Metadata - - Override section: - - "Enable Override" toggle - - When enabled, shows editor control based on `editorControl` - - Toggle for `.toggle` - - `TextField` for `.textField` / `.numberField` / `.decimalField` with appropriate - keyboard types - - Provider values section: - - Each provider's value with capsule - - Strikethrough for incompatible values - - Tap-to-reveal for secret values - - Metadata section: list of key-value pairs from `displayTextEntries` - -### 5d: Public Entry Point - - - `ConfigVariableEditor` — a public `View` struct (inside `#if canImport(SwiftUI)`) - - Initialized with a `ConfigVariableReader` and an - `onSave: ([RegisteredConfigVariable]) -> Void` closure - - Creates the list view model internally and wraps the list view - - Asserts that `isEditorEnabled` is true on the reader - -### 5e: View Tests - -Tests use **Swift Snapshot Testing** for visual regression and **ViewInspector** for -structural and behavioral verification. Views are generic on their view model protocols, so -tests inject mock view models. - - - **Snapshot tests** (visual regression): - - List view: empty state, populated list, list with overrides, list with search active - - Detail view: read-only variable, variable with override enabled (each editor control - type), secret value redacted vs revealed, incompatible provider values with - strikethrough - - Provider capsule: each provider color, editor override provider color - - Snapshots captured for both iOS and Mac to verify cross-platform layout - - **ViewInspector tests** (structural/behavioral): - - List view: verify rows render correct display name, key, value, and provider capsule; - verify search filters rows; verify cancel alert appears when dirty; verify save calls - view model; verify overflow menu contains undo, redo, and clear actions - - Detail view: verify sections are present; verify "Enable Override" toggle shows/hides - editor control; verify toggle control binds to `overrideBool`; verify text field - controls bind to `overrideText`; verify tap-to-reveal toggles `isSecretRevealed`; - verify incompatible values have strikethrough - - -## Slice 6: Polish & Integration - -### 6a: Accessibility - - - Ensure all interactive elements have accessibility labels - - Provider capsules should be distinguishable without color (include provider name text) - - Override controls should announce state changes - -### 6b: Mac Compatibility - - - Verify layout works on macOS (wider layout, no `.numberPad` keyboard) - - Adjust text fields to use appropriate styling per platform - -### 6c: Documentation - - - DocC documentation for all public types and methods - - Usage guide with code examples - - Add to existing architecture documentation - - -## Dependencies Between Slices - - Slice 1 (Metadata & Content) - │ - ├──▶ Slice 2 (EditorOverrideProvider) - │ │ - │ └──▶ Slice 3 (EditorDocument) - │ │ - │ └──▶ Slice 4 (View Models) - │ │ - │ └──▶ Slice 5 (Views) - │ │ - │ └──▶ Slice 6 (Polish) - │ - └──▶ Slice 4 can begin protocol design in parallel with Slices 2–3 - -Slices 1 and 2a–2b can proceed in parallel. Slice 4's protocol definitions can be drafted -alongside Slices 2–3, with concrete implementations depending on those slices. - - -## New Package Dependencies - -Slice 5e requires two new test dependencies in `Package.swift`: - - - **swift-snapshot-testing** (Point-Free): visual regression tests for views - - **ViewInspector**: structural and behavioral verification of SwiftUI view hierarchies - -These are added only to the test target. diff --git a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift index dc2d418..48741c2 100644 --- a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift +++ b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift @@ -6,6 +6,7 @@ // import Configuration +import Foundation /// A non-generic representation of a registered ``ConfigVariable``. /// @@ -30,9 +31,19 @@ public struct RegisteredConfigVariable: Sendable { /// /// This is captured at registration time via `String(describing: Value.self)` and may differ from the content type /// name when the variable uses a type that maps to a primitive content type (e.g., an `Int`-backed enum stored as - /// ``ConfigContent/int(_:)``). + /// ``ConfigContent/int(_:)``). Standard generic types are normalized to use Swift shorthand syntax (e.g., + /// `Array` becomes `[Int]`, `Optional` becomes `String?`, and `Dictionary` becomes + /// `[String: Int]`). public let destinationTypeName: String + /// A human-readable name for this variable's content type (e.g., `"Bool"`, `"[Int]"`). + /// + /// This is derived from the variable's ``defaultContent`` and represents the primitive configuration type used for + /// storage, which may differ from ``destinationTypeName``. + public var contentTypeName: String { + defaultContent.typeDisplayName + } + /// The editor control to use when editing this variable's value in the editor UI. public let editorControl: EditorControl @@ -43,6 +54,35 @@ public struct RegisteredConfigVariable: Sendable { let parse: (@Sendable (_ input: String) -> ConfigContent?)? + /// Creates a new registered config variable. + /// + /// - Parameters: + /// - key: The configuration key. + /// - defaultContent: The default value as a ``ConfigContent``. + /// - isSecret: Whether the variable's value should be treated as secret. + /// - metadata: The variable's metadata. + /// - destinationTypeName: The name of the variable's Swift value type. + /// - editorControl: The editor control to use for this variable. + /// - parse: A function that parses a raw string into a ``ConfigContent`` value. + init( + key: ConfigKey, + defaultContent: ConfigContent, + isSecret: Bool, + metadata: ConfigVariableMetadata, + destinationTypeName: String, + editorControl: EditorControl, + parse: (@Sendable (_ input: String) -> ConfigContent?)? + ) { + self.key = key + self.defaultContent = defaultContent + self.isSecret = isSecret + self.metadata = metadata + self.destinationTypeName = Self.normalizedTypeName(destinationTypeName) + self.editorControl = editorControl + self.parse = parse + } + + /// Provides dynamic member lookup access to metadata properties. /// /// This subscript enables dot-syntax access to metadata properties, mirroring the access pattern on @@ -55,4 +95,94 @@ public struct RegisteredConfigVariable: Sendable { ) -> MetadataValue { metadata[keyPath: keyPath] } + + + /// Normalizes a Swift type name to use shorthand syntax for standard generic types. + /// + /// Converts `Array` to `[X]`, `Optional` to `X?`, `Dictionary` to `[K: V]`, and `Double` to `Float64`. + private static func normalizedTypeName(_ name: String) -> String { + var result = name.replacing(/\bDouble\b/, with: "Float64") + // Normalize Array<...> to [...] + while let range = result.range(of: "Array<") { + let openIndex = range.upperBound + guard let closeIndex = findMatchingClosingAngleBracket(in: result, from: openIndex) else { + break + } + let inner = result[openIndex ..< closeIndex] + let prefix = result[result.startIndex ..< range.lowerBound] + let suffix = result[result.index(after: closeIndex)...] + result = prefix + "[\(inner)]" + suffix + } + + // Normalize Dictionary to [K: V] + while let range = result.range(of: "Dictionary<") { + let openIndex = range.upperBound + guard let closeIndex = findMatchingClosingAngleBracket(in: result, from: openIndex) else { + break + } + let inner = result[openIndex ..< closeIndex] + // Split on the first top-level comma + guard let commaIndex = findTopLevelComma(in: inner) else { + break + } + let key = inner[inner.startIndex ..< commaIndex] + let value = inner[inner.index(after: commaIndex)...].drop(while: { $0 == " " }) + result = + result[result.startIndex ..< range.lowerBound] + "[\(key): \(value)]" + + result[result.index(after: closeIndex)...] + } + + // Normalize Optional<...> to ...? + while let range = result.range(of: "Optional<") { + let openIndex = range.upperBound + guard let closeIndex = findMatchingClosingAngleBracket(in: result, from: openIndex) else { + break + } + let inner = result[openIndex ..< closeIndex] + result = + result[result.startIndex ..< range.lowerBound] + "\(inner)?" + + result[result.index(after: closeIndex)...] + } + + return result + } + + + /// Finds the index of the closing `>` that matches the opening `<` whose content starts at `startIndex`. + private static func findMatchingClosingAngleBracket( + in string: String, + from startIndex: String.Index + ) -> String.Index? { + var depth = 1 + var index = startIndex + while index < string.endIndex { + switch string[index] { + case "<": depth += 1 + case ">": + depth -= 1 + if depth == 0 { return index } + default: break + } + index = string.index(after: index) + } + return nil + } + + + /// Finds the index of the first comma at the top level (depth 0) within a substring. + private static func findTopLevelComma(in string: some StringProtocol) -> String.Index? { + var depth = 0 + for index in string.indices { + switch string[index] { + case "<": + depth += 1 + case ">": + depth -= 1 + case "," where depth == 0: + return index + default: break + } + } + return nil + } } diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift index 606d40a..1a45ea4 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift @@ -40,8 +40,13 @@ extension ConfigVariableDetailView { .font(.caption.monospaced()) } - LabeledContent(localizedStringResource("detailView.headerSection.type")) { - Text(viewModel.typeName) + LabeledContent(localizedStringResource("detailView.headerSection.contentType")) { + Text(viewModel.contentTypeName) + .font(.caption.monospaced()) + } + + LabeledContent(localizedStringResource("detailView.headerSection.variableType")) { + Text(viewModel.variableTypeName) .font(.caption.monospaced()) } } diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift index 3eda2b1..a32f641 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift @@ -25,7 +25,8 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { let key: ConfigKey let displayName: String - let typeName: String + let contentTypeName: String + let variableTypeName: String let metadataEntries: [ConfigVariableMetadata.DisplayText] let isSecret: Bool let editorControl: EditorControl @@ -44,7 +45,8 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { self.registeredVariable = registeredVariable self.key = registeredVariable.key self.displayName = registeredVariable.displayName ?? registeredVariable.key.description - self.typeName = registeredVariable.destinationTypeName + self.contentTypeName = registeredVariable.contentTypeName + self.variableTypeName = registeredVariable.destinationTypeName self.metadataEntries = registeredVariable.metadata.displayTextEntries self.isSecret = registeredVariable.isSecret self.editorControl = registeredVariable.editorControl diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift index f22b604..83ed84a 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift @@ -23,8 +23,11 @@ protocol ConfigVariableDetailViewModeling: Observable { /// The human-readable display name for this variable. var displayName: String { get } - /// The type name to display in the header (e.g., `"Int"` or `"CardSuit"`). - var typeName: String { get } + /// The content type name to display in the header (e.g., `"Bool"` or `"[Int]"`). + var contentTypeName: String { get } + + /// The variable type name to display in the header (e.g., `"Int"` or `"CardSuit"`). + var variableTypeName: String { get } /// The metadata entries to display in the metadata section. var metadataEntries: [ConfigVariableMetadata.DisplayText] { get } diff --git a/Sources/DevConfiguration/Resources/Localizable.xcstrings b/Sources/DevConfiguration/Resources/Localizable.xcstrings index dff81f3..763c403 100644 --- a/Sources/DevConfiguration/Resources/Localizable.xcstrings +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -11,12 +11,22 @@ } } }, - "detailView.headerSection.type" : { + "detailView.headerSection.contentType" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Type" + "value" : "Content Type" + } + } + } + }, + "detailView.headerSection.variableType" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variable Type" } } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift index f03dc3c..08fb9ce 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift @@ -37,6 +37,49 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { } + @Test( + arguments: [ + ("Int", "Int"), + ("CardSuit", "CardSuit"), + ("Array", "[Int]"), + ("Optional", "String?"), + ("Dictionary", "[String: Int]"), + ("Optional>", "[String]?"), + ("Array>", "[Int?]"), + ("Dictionary>", "[String: [Int]]"), + ("Array>>", "[[String: Int?]]"), + ("[Int]", "[Int]"), + ("Array", "Dictionary"), + ("Dictionary>", "Dictionary>"), + ("Double", "Float64"), + ("Array", "[Float64]"), + ("Dictionary>", "[Float64: [Float64]]"), + ("DoubleMeaning", "DoubleMeaning"), + ] + ) + mutating func initNormalizesDestinationTypeName( + input: String, + expected: String + ) { + // set up + let variable = RegisteredConfigVariable( + key: randomConfigKey(), + defaultContent: randomConfigContent(), + isSecret: randomBool(), + metadata: ConfigVariableMetadata(), + destinationTypeName: input, + editorControl: .none, + parse: nil + ) + + // expect + #expect(variable.destinationTypeName == expected) + } + + @Test mutating func dynamicMemberLookupReturnsDefaultWhenNotSet() { // set up diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift index 1e5c660..d895458 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift @@ -78,7 +78,8 @@ struct ConfigVariableDetailViewModelTests: RandomValueGenerating { // expect all constant properties are set from the registered variable #expect(viewModel.key == variable.key) #expect(viewModel.displayName == metadata.displayName) - #expect(viewModel.typeName == destinationTypeName) + #expect(viewModel.contentTypeName == variable.contentTypeName) + #expect(viewModel.variableTypeName == variable.destinationTypeName) #expect(viewModel.metadataEntries == metadata.displayTextEntries) #expect(viewModel.isSecret == isSecret) #expect(viewModel.editorControl == .textField)