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..54b7adb --- /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..627508c --- /dev/null +++ b/App/Sources/App/ContentViewModel.swift @@ -0,0 +1,112 @@ +// +// 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") + .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"] + ).metadata(\.displayName, "String Array Example") + + let jsonVariable = ConfigVariable( + key: "complexConfig", + defaultValue: ComplexConfiguration(field1: "a", field2: 1), + content: .json(representation: .data) + ).metadata(\.displayName, "Complex Config") + + let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades, isSecret: true) + .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/CLAUDE.md b/CLAUDE.md index 135162f..6541f60 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 @@ -62,4 +71,4 @@ External dependencies managed via Swift Package Manager: - 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 + - SwiftUI views do not currently have automated tests \ No newline at end of file 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/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/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/ 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 5dffea7..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 ( @@ -68,6 +64,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?)? } @@ -77,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) }, @@ -103,7 +107,9 @@ extension ConfigVariableContent where Value == Bool { } } }, - encode: { .bool($0) } + encode: { .bool($0) }, + editorControl: .toggle, + parse: { Bool($0).map { .bool($0) } } ) } } @@ -113,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) }, @@ -139,7 +144,9 @@ extension ConfigVariableContent where Value == [Bool] { } } }, - encode: { .boolArray($0) } + encode: { .boolArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -149,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) }, @@ -175,7 +181,9 @@ extension ConfigVariableContent where Value == Float64 { } } }, - encode: { .double($0) } + encode: { .double($0) }, + editorControl: .decimalField, + parse: { Double($0).map { .double($0) } } ) } } @@ -185,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) }, @@ -211,7 +218,9 @@ extension ConfigVariableContent where Value == [Float64] { } } }, - encode: { .doubleArray($0) } + encode: { .doubleArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -221,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) }, @@ -247,7 +255,9 @@ extension ConfigVariableContent where Value == Int { } } }, - encode: { .int($0) } + encode: { .int($0) }, + editorControl: .numberField, + parse: { Int($0).map { .int($0) } } ) } } @@ -257,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) }, @@ -283,7 +292,9 @@ extension ConfigVariableContent where Value == [Int] { } } }, - encode: { .intArray($0) } + encode: { .intArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -293,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) }, @@ -319,7 +329,9 @@ extension ConfigVariableContent where Value == String { } } }, - encode: { .string($0) } + encode: { .string($0) }, + editorControl: .textField, + parse: { .string($0) } ) } } @@ -329,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) }, @@ -355,7 +366,9 @@ extension ConfigVariableContent where Value == [String] { } } }, - encode: { .stringArray($0) } + encode: { .stringArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -365,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) }, @@ -391,7 +403,9 @@ extension ConfigVariableContent where Value == [UInt8] { } } }, - encode: { .bytes($0) } + encode: { .bytes($0) }, + editorControl: .none, + parse: nil ) } } @@ -401,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, @@ -433,7 +446,9 @@ extension ConfigVariableContent where Value == [[UInt8]] { } } }, - encode: { .byteChunkArray($0) } + encode: { .byteChunkArray($0) }, + editorControl: .none, + parse: nil ) } } @@ -446,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, @@ -481,7 +495,9 @@ extension ConfigVariableContent { } } }, - encode: { .string($0.rawValue) } + encode: { .string($0.rawValue) }, + editorControl: .textField, + parse: { .string($0) } ) } @@ -490,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, @@ -525,7 +540,9 @@ extension ConfigVariableContent { } } }, - encode: { .stringArray($0.map(\.rawValue)) } + encode: { .stringArray($0.map(\.rawValue)) }, + editorControl: .none, + parse: nil ) } @@ -533,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, @@ -568,7 +584,9 @@ extension ConfigVariableContent { } } }, - encode: { .string($0.description) } + encode: { .string($0.description) }, + editorControl: .textField, + parse: { .string($0) } ) } @@ -577,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, @@ -612,7 +629,9 @@ extension ConfigVariableContent { } } }, - encode: { .stringArray($0.map(\.description)) } + encode: { .stringArray($0.map(\.description)) }, + editorControl: .none, + parse: nil ) } } @@ -625,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, @@ -660,7 +678,9 @@ extension ConfigVariableContent { } } }, - encode: { .int($0.rawValue) } + encode: { .int($0.rawValue) }, + editorControl: .numberField, + parse: { Int($0).map { .int($0) } } ) } @@ -669,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, @@ -704,7 +723,9 @@ extension ConfigVariableContent { } } }, - encode: { .intArray($0.map(\.rawValue)) } + encode: { .intArray($0.map(\.rawValue)) }, + editorControl: .none, + parse: nil ) } @@ -712,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, @@ -747,7 +767,9 @@ extension ConfigVariableContent { } } }, - encode: { .int($0.configInt) } + encode: { .int($0.configInt) }, + editorControl: .numberField, + parse: { Int($0).map { .int($0) } } ) } @@ -756,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, @@ -791,7 +812,9 @@ extension ConfigVariableContent { } } }, - encode: { .intArray($0.map(\.configInt)) } + encode: { .intArray($0.map(\.configInt)) }, + editorControl: .none, + parse: nil ) } } @@ -845,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( @@ -931,7 +953,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..10dd9f9 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,14 +54,20 @@ 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 ``namedProviders`` and takes precedence over all other + /// providers. + let editorOverrideProvider: EditorOverrideProvider? + /// The mutable state protected by a mutex. private let mutableState = Mutex(MutableState()) @@ -74,13 +80,19 @@ 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. - public convenience init(providers: [any ConfigProvider], eventBus: EventBus) { + /// - isEditorEnabled: Whether editor override support is enabled. Defaults to `false`. + public convenience init( + namedProviders: [NamedConfigProvider], + eventBus: EventBus, + isEditorEnabled: Bool = false + ) { self.init( - providers: providers, + namedProviders: namedProviders, accessReporter: EventBusAccessReporter(eventBus: eventBus), - eventBus: eventBus + eventBus: eventBus, + isEditorEnabled: isEditorEnabled ) } @@ -90,13 +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. - public init(providers: [any ConfigProvider], accessReporter: any AccessReporter, eventBus: EventBus) { + /// - isEditorEnabled: Whether editor override support is enabled. Defaults to `false`. + public init( + namedProviders: [NamedConfigProvider], + accessReporter: any AccessReporter, + eventBus: EventBus, + isEditorEnabled: Bool = false + ) { + var editorOverrideProvider: EditorOverrideProvider? + var namedProviders = namedProviders + + if isEditorEnabled { + let provider = EditorOverrideProvider() + provider.load(from: UserDefaults(suiteName: EditorOverrideProvider.suiteName)!) + editorOverrideProvider = provider + namedProviders.insert(.init(provider, displayName: localizedString("editorOverrideProvider.name")), at: 0) + } + + self.editorOverrideProvider = editorOverrideProvider self.accessReporter = accessReporter - self.reader = ConfigReader(providers: providers, accessReporter: accessReporter) - self.providers = providers + self.reader = ConfigReader( + providers: namedProviders.map(\.provider), + accessReporter: accessReporter + ) + self.namedProviders = namedProviders self.eventBus = eventBus } @@ -140,8 +172,11 @@ extension ConfigVariableReader { state.registeredVariables[variable.key] = RegisteredConfigVariable( key: variable.key, defaultContent: defaultContent, - secrecy: variable.secrecy, - metadata: variable.metadata + isSecret: variable.isSecret, + metadata: variable.metadata, + destinationTypeName: String(describing: Value.self), + editorControl: variable.content.editorControl, + parse: variable.content.parse ) } } @@ -163,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) } @@ -198,7 +233,7 @@ extension ConfigVariableReader { try await variable.content.fetch( reader, variable.key, - isSecret(variable), + variable.isSecret, variable.defaultValue, eventBus, fileID, @@ -227,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 @@ -272,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/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/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/Core/RegisteredConfigVariable.swift b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift index 0f16524..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``. /// @@ -13,18 +14,73 @@ 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. + public let isSecret: Bool /// The configuration variable's metadata. - let metadata: ConfigVariableMetadata + 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(_:)``). 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 + + /// 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?)? + + + /// 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. @@ -39,4 +95,94 @@ 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/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 new file mode 100644 index 0000000..1a45ea4 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift @@ -0,0 +1,180 @@ +// +// 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 { + LabeledContent(localizedStringResource("detailView.headerSection.key")) { + Text(viewModel.key.description) + .font(.caption.monospaced()) + } + + LabeledContent(localizedStringResource("detailView.headerSection.contentType")) { + Text(viewModel.contentTypeName) + .font(.caption.monospaced()) + } + + LabeledContent(localizedStringResource("detailView.headerSection.variableType")) { + Text(viewModel.variableTypeName) + .font(.caption.monospaced()) + } + } + } + + + @ViewBuilder + private var overrideSection: some View { + if viewModel.editorControl != .none { + 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 + } + } + } + } + + + @ViewBuilder + private var overrideControl: some View { + 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 + ) + .onSubmit { viewModel.commitOverrideText() } + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + #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(localizedStringResource("detailView.providerValuesSection.header")) { + if viewModel.isSecret && !viewModel.isSecretRevealed { + Button(localizedStringResource("detailView.providerValuesSection.tapToReveal")) { + viewModel.isSecretRevealed = true + } + } else { + ForEach(viewModel.providerValues, id: \.self) { providerValue in + 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) + } + } + + if viewModel.isSecret { + Button(localizedStringResource("detailView.providerValuesSection.hideValues")) { + viewModel.isSecretRevealed = false + } + } + } + } + } + + + @ViewBuilder + private var metadataSection: some View { + let entries = viewModel.metadataEntries + if !entries.isEmpty { + Section(localizedStringResource("detailView.metadataSection.header")) { + ForEach(entries, id: \.key) { entry in + LabeledContent(entry.key, value: entry.value ?? "—") + } + } + } + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift new file mode 100644 index 0000000..a32f641 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift @@ -0,0 +1,128 @@ +// +// ConfigVariableDetailViewModel.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import Foundation + +/// The concrete view model for the configuration variable detail view. +/// +/// `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 document that owns the variable data. + private let document: EditorDocument + + /// The registered variable this view model represents. + private let registeredVariable: RegisteredConfigVariable + + let key: ConfigKey + let displayName: String + let contentTypeName: String + let variableTypeName: String + let metadataEntries: [ConfigVariableMetadata.DisplayText] + let isSecret: Bool + let editorControl: EditorControl + + var overrideText = "" + var isSecretRevealed = false + + + /// Creates a new detail view model. + /// + /// - Parameters: + /// - document: The editor document. + /// - registeredVariable: The registered variable to display. + init(document: EditorDocument, registeredVariable: RegisteredConfigVariable) { + self.document = document + self.registeredVariable = registeredVariable + self.key = registeredVariable.key + self.displayName = registeredVariable.displayName ?? registeredVariable.key.description + self.contentTypeName = registeredVariable.contentTypeName + self.variableTypeName = 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 + } + } + + + // MARK: - Provider Values + + var providerValues: [ProviderValue] { + document.providerValues(forKey: key) + } + + + // MARK: - Override Management + + var isOverrideEnabled: Bool { + get { + document.hasOverride(forKey: key) + } + set { + if newValue { + enableOverride() + } else { + document.removeOverride(forKey: key) + } + } + } + + + var overrideBool: Bool { + get { + guard case .bool(let value) = document.override(forKey: key) else { + return false + } + return value + } + set { + document.setOverride(.bool(newValue), forKey: key) + } + } + + + func commitOverrideText() { + guard let parse = registeredVariable.parse else { + return + } + + let text = overrideText + guard let content = parse(text) else { + return + } + + document.setOverride(content, forKey: key) + } + + + // 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) + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift new file mode 100644 index 0000000..83ed84a --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift @@ -0,0 +1,63 @@ +// +// ConfigVariableDetailViewModeling.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import Foundation + +/// The interface for a configuration variable detail view's view model. +/// +/// `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. + var key: ConfigKey { get } + + /// The human-readable display name for this variable. + var displayName: 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 } + + /// The provider values to display in the provider values section. + var providerValues: [ProviderValue] { get } + + /// 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 text value for the override, used with text field and number field controls. + var overrideText: String { get set } + + /// The boolean value for the override, used with toggle controls. + var overrideBool: Bool { get set } + + /// 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 new file mode 100644 index 0000000..cb2990b --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListView.swift @@ -0,0 +1,167 @@ +// +// ConfigVariableListView.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. +/// +/// `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 ConfigVariableListView: View { + @State var viewModel: ViewModel + + @Environment(\.dismiss) private var dismiss + + + var body: some View { + NavigationStack { + List { + Section(localizedStringResource("editorView.variablesSection.header")) { + ForEach(viewModel.variables, id: \.key) { item in + NavigationLink(value: item.key) { + VariableRow(item: item) + } + } + } + } + .navigationTitle(localizedStringResource("editorView.navigationTitle")) + #if os(iOS) || os(watchOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .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: $viewModel.isShowingSaveAlert) { + Button(localizedStringResource("editorView.saveAlert.saveButton")) { + viewModel.save() + dismiss() + } + .keyboardShortcut(.defaultAction) + + Button(localizedStringResource("editorView.saveAlert.dontSaveButton"), role: .destructive) { + dismiss() + } + + Button(localizedStringResource("editorView.saveAlert.cancelButton"), role: .cancel) {} + } message: { + Text(localizedStringResource("editorView.saveAlert.message")) + } + .alert( + localizedStringResource("editorView.clearAlert.title"), + isPresented: $viewModel.isShowingClearAlert + ) { + Button(localizedStringResource("editorView.clearAlert.clearButton"), role: .destructive) { + viewModel.confirmClearAllOverrides() + } + + Button(localizedStringResource("editorView.saveAlert.cancelButton"), role: .cancel) {} + } message: { + Text(localizedStringResource("editorView.clearAlert.message")) + } + } + } +} + + +// MARK: - Toolbar + +extension ConfigVariableListView { + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + viewModel.requestDismiss { dismiss() } + } label: { + Label(localizedStringResource("editorView.dismissButton"), systemImage: "xmark") + } + } + + ToolbarItem(placement: .confirmationAction) { + Button { + viewModel.save() + dismiss() + } label: { + Label(localizedStringResource("editorView.saveButton"), systemImage: "checkmark") + } + .disabled(!viewModel.isDirty) + } + + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + viewModel.undo() + } label: { + Label(localizedStringResource("editorView.undoButton"), systemImage: "arrow.uturn.backward") + } + .disabled(!viewModel.canUndo) + + Button { + viewModel.redo() + } label: { + Label(localizedStringResource("editorView.redoButton"), systemImage: "arrow.uturn.forward") + } + .disabled(!viewModel.canRedo) + + Divider() + + Button(role: .destructive) { + viewModel.requestClearAllOverrides() + } label: { + Label(localizedStringResource("editorView.clearOverridesButton"), systemImage: "trash") + } + } label: { + Label(localizedStringResource("editorView.overflowMenu.label"), systemImage: "ellipsis") + } + } + } +} + + +// MARK: - Variable Row + +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: 8) { + Text(item.displayName) + .font(.headline) + + Text(item.key.description) + .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) + } + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift new file mode 100644 index 0000000..2868c94 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModel.swift @@ -0,0 +1,138 @@ +// +// ConfigVariableListViewModel.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import Foundation + +/// The concrete view model for the configuration variable list view. +/// +/// `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 document that owns the variable data. + private let document: EditorDocument + + /// The closure to call with the changed variables when the user saves. + private let onSave: ([RegisteredConfigVariable]) -> Void + + var searchText = "" + var isShowingSaveAlert = false + var isShowingClearAlert = false + + + /// Creates a new list view model. + /// + /// - Parameters: + /// - 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.onSave = onSave + } + + + // MARK: - Variables + + var variables: [VariableListItem] { + 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: 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: [VariableListItem] + if searchText.isEmpty { + filtered = items + } else { + filtered = items.filter { item in + item.displayName.localizedStandardContains(searchText) + || item.key.description.localizedStandardContains(searchText) + } + } + + return filtered.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } + } + + + // MARK: - Dirty Tracking + + var isDirty: Bool { + document.isDirty + } + + + var canUndo: Bool { + document.undoManager.canUndo + } + + + var canRedo: Bool { + document.undoManager.canRedo + } + + + // MARK: - Actions + + func requestDismiss(_ dismiss: () -> Void) { + if isDirty { + isShowingSaveAlert = true + } else { + dismiss() + } + } + + + func save() { + let changedKeys = document.changedKeys + document.save() + onSave(changedKeys.compactMap { document.registeredVariables[$0] }) + } + + + func requestClearAllOverrides() { + isShowingClearAlert = true + } + + + func confirmClearAllOverrides() { + document.removeAllOverrides() + } + + + func undo() { + document.undoManager.undo() + } + + + func redo() { + document.undoManager.redo() + } + + + // MARK: - Detail View Model + + func makeDetailViewModel(for key: ConfigKey) -> ConfigVariableDetailViewModel { + let registeredVariable = document.registeredVariables[key]! + return ConfigVariableDetailViewModel(document: document, registeredVariable: registeredVariable) + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift new file mode 100644 index 0000000..7dced20 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable List/ConfigVariableListViewModeling.swift @@ -0,0 +1,74 @@ +// +// ConfigVariableListViewModeling.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +#if canImport(SwiftUI) + +import Configuration +import Foundation + +/// The interface for a configuration variable list view's view model. +/// +/// `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 associated detail view model type. + associatedtype DetailViewModel: ConfigVariableDetailViewModeling + + /// The filtered and sorted list of variable items to display. + var variables: [VariableListItem] { get } + + /// The current search text for filtering variables. + var searchText: String { get set } + + /// Whether the working copy has unsaved changes. + var isDirty: Bool { get } + + /// Whether the undo manager can undo. + var canUndo: Bool { get } + + /// Whether the undo manager can redo. + var canRedo: Bool { get } + + /// 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 } + + /// 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) + + /// 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 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 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 new file mode 100644 index 0000000..73ef865 --- /dev/null +++ b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift @@ -0,0 +1,43 @@ +// +// 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 + + /// The index of the provider in the reader's provider list, used for color assignment. + /// + /// 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 + + /// 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/Editor/ConfigVariableEditor.swift b/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift new file mode 100644 index 0000000..c687fb0 --- /dev/null +++ b/Sources/DevConfiguration/Editor/ConfigVariableEditor.swift @@ -0,0 +1,67 @@ +// +// 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? + + + /// Creates a new configuration variable editor. + /// + /// - Parameters: + /// - 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 + ) { + if let editorOverrideProvider = reader.editorOverrideProvider { + // 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, onSave: onSave) + ) + } + } + + + public var body: some View { + if let viewModel { + ConfigVariableListView(viewModel: viewModel) + } + } +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift new file mode 100644 index 0000000..7cdfd5f --- /dev/null +++ b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift @@ -0,0 +1,424 @@ +// +// EditorDocument.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +import Configuration +import Foundation +import Synchronization + +/// The central domain model for the configuration variable editor. +/// +/// `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 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 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 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. + /// + /// - Parameters: + /// - 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 + + // 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: - 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 { + /// Resolves the winning value for the given configuration 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 + } + + + /// Returns all provider values for the given configuration 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 + } + + + /// 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 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 oldContent = workingCopy[key] + guard oldContent != content else { return } + + workingCopy[key] = content + + 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 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) { + 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. + func removeAllOverrides() { + let oldOverrides = workingCopy + guard !oldOverrides.isEmpty else { + return + } + + workingCopy.removeAll() + + undoManager.registerUndo(withTarget: self) { document in + for (key, content) in oldOverrides { + document.setOverride(content, forKey: key) + } + } + } +} + + +// MARK: - Dirty Tracking and Save + +extension EditorDocument { + /// Whether the working copy has unsaved changes. + var isDirty: Bool { + workingCopy != baseline + } + + + /// The keys whose overrides have changed since the last save. + var changedKeys: Set { + var keys = Set() + + for (key, content) in workingCopy where baseline[key] != content { + keys.insert(key) + } + + for key in baseline.keys where workingCopy[key] == nil { + keys.insert(key) + } + + return keys + } + + + /// Commits the working copy to the editor override provider and persists the changes. + /// + /// 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) + } + + // Set overrides that were added or changed + for (key, content) in workingCopy { + editorOverrideProvider.setOverride(content, forKey: key) + } + + // Persist + editorOverrideProvider.persist(to: userDefaults) + + // Update baseline + baseline = workingCopy + } +} diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift new file mode 100644 index 0000000..dee519e --- /dev/null +++ b/Sources/DevConfiguration/Editor/Data Models/EditorOverrideProvider.swift @@ -0,0 +1,410 @@ +// +// EditorOverrideProvider.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/7/2026. +// + +import Configuration +import Foundation +import Synchronization +import os + +/// 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 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 name used to identify this provider. + static let providerName = "EditorOverrideProvider" + + /// 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()) +} + + +// 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 { + Self.providerName + } + + + 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/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 new file mode 100644 index 0000000..23d240d --- /dev/null +++ b/Sources/DevConfiguration/Editor/Utilities/ProviderBadge.swift @@ -0,0 +1,70 @@ +// +// 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 + + /// Whether this badge represents the active provider. + /// + /// Inactive badges use a muted gray style. + var isActive: Bool = true + + + var body: some View { + Text(providerName) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(isActive ? .white : .secondary) + .background(isActive ? color : Color(white: 0.9), 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. 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, or `nil` for the working copy. +/// - Returns: A color for the provider. +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 ..< 7), id: \.self) { index in + ProviderBadge(providerName: "Provider \(index)", color: providerColor(at: index)) + } + + ProviderBadge(providerName: "Provider 9", color: .red, isActive: false) + } + .padding() +} + +#endif diff --git a/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift b/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift new file mode 100644 index 0000000..9b7129c --- /dev/null +++ b/Sources/DevConfiguration/Editor/Utilities/ProviderValue.swift @@ -0,0 +1,29 @@ +// +// 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 index of the provider in the reader's provider list, used for color assignment. + /// + /// 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/ConfigContent+Additions.swift b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift new file mode 100644 index 0000000..84557f0 --- /dev/null +++ b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift @@ -0,0 +1,161 @@ +// +// 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: - 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 { + /// 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 { + 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/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/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..d26ff7d --- /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 = localizedString("displayNameMetadata.keyDisplayText") +} + + +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..7f0bdfa --- /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 = localizedString("requiresRelaunchMetadata.keyDisplayText") +} + + +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..763c403 --- /dev/null +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -0,0 +1,356 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "detailView.headerSection.key" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Key" + } + } + } + }, + "detailView.headerSection.contentType" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content Type" + } + } + } + }, + "detailView.headerSection.variableType" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variable Type" + } + } + } + }, + "detailView.metadataSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metadata" + } + } + } + }, + "detailView.overridenSection.valuePickerFalse" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "False" + } + } + } + }, + "detailView.overridenSection.valuePickerTrue" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "True" + } + } + } + }, + "detailView.overrideSection.addOverride" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "detailView.overrideSection.editorOverrideLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editor Override" + } + } + } + }, + "detailView.overrideSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + } + } + }, + "detailView.overrideSection.removeOverride" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove" + } + } + } + }, + "detailView.overrideSection.valueLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + } + } + }, + "detailView.overrideSection.valueTextField" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + } + } + }, + "detailView.providerValuesSection.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Values" + } + } + } + }, + "detailView.providerValuesSection.hideValues" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Values" + } + } + } + }, + "detailView.providerValuesSection.tapToReveal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Values" + } + } + } + }, + "displayNameMetadata.keyDisplayText" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display Name" + } + } + } + }, + "editor.defaultProviderName" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default" + } + } + } + }, + "editorOverrideProvider.name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editor" + } + } + } + }, + "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 Overrides" + } + } + } + }, + "editorView.dismissButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss" + } + } + } + }, + "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.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" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file 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/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/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/Core/ConfigVariableReaderEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift new file mode 100644 index 0000000..fe775bb --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderEditorTests.swift @@ -0,0 +1,115 @@ +// +// 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( + namedProviders: [.init(InMemoryProvider(values: [:]))], + eventBus: EventBus() + ) + + // expect + #expect(reader.editorOverrideProvider == nil) + } + + + @Test + func editorDisabledExplicitly() { + // set up + let reader = ConfigVariableReader( + namedProviders: [.init(InMemoryProvider(values: [:]))], + eventBus: EventBus(), + isEditorEnabled: false + ) + + // expect + #expect(reader.editorOverrideProvider == nil) + } + + + @Test + func editorEnabledCreatesProvider() { + // set up + let reader = ConfigVariableReader(namedProviders: [], eventBus: EventBus(), isEditorEnabled: true) + + // expect + #expect(reader.editorOverrideProvider != nil) + } + + + @Test + func editorProviderIsFirstInProviders() { + // set up + let otherProvider = InMemoryProvider(values: [:]) + let reader = ConfigVariableReader( + namedProviders: [.init(otherProvider)], + eventBus: EventBus(), + isEditorEnabled: true + ) + + // expect + #expect(reader.namedProviders.count == 2) + #expect(reader.namedProviders.first?.provider 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( + namedProviders: [.init(otherProvider)], + eventBus: EventBus(), + isEditorEnabled: true + ) + + let variable = ConfigVariable( + key: key, + defaultValue: randomAlphanumericString(), + isSecret: false + ) + + // 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(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 8f55bd1..7295ff4 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift @@ -18,36 +18,39 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { @Test - mutating func registerStoresVariableWithCorrectProperties() { + 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() 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 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]) + let registered = try #require(reader.registeredVariables[key]) + #expect(registered.key == key) + #expect(registered.defaultContent == .int(defaultValue)) + #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) } @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()) @@ -69,7 +72,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) @@ -85,14 +88,13 @@ 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( key: "encode.failure", defaultValue: UnencodableValue(), content: ConfigVariableContent( - isAutoSecret: false, read: { _, _, _, defaultValue, _, _, _ in defaultValue }, fetch: { _, _, _, defaultValue, _, _, _ in defaultValue }, startWatching: { _, _, _, _, _, _, _, _ in }, @@ -101,7 +103,9 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { "", .init(codingPath: [], debugDescription: "") ) - } + }, + editorControl: .none, + parse: nil ) ) 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..b915261 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderTests.swift @@ -24,106 +24,10 @@ struct ConfigVariableReaderTests: RandomValueGenerating { /// The reader under test. lazy var reader: ConfigVariableReader = { - ConfigVariableReader(providers: [provider], eventBus: eventBus) + ConfigVariableReader(namedProviders: [.init(provider)], eventBus: eventBus) }() - // 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(providers: [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 16118ad..08fb9ce 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift @@ -25,8 +25,11 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { let variable = RegisteredConfigVariable( key: randomConfigKey(), defaultContent: randomConfigContent(), - secrecy: randomConfigVariableSecrecy(), - metadata: metadata + isSecret: randomBool(), + metadata: metadata, + destinationTypeName: randomAlphanumericString(), + editorControl: .none, + parse: nil ) // expect @@ -34,14 +37,60 @@ 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 let variable = RegisteredConfigVariable( key: randomConfigKey(), defaultContent: randomConfigContent(), - secrecy: randomConfigVariableSecrecy(), - metadata: ConfigVariableMetadata() + isSecret: randomBool(), + metadata: ConfigVariableMetadata(), + destinationTypeName: randomAlphanumericString(), + editorControl: .none, + parse: nil ) // expect diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift new file mode 100644 index 0000000..d895458 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift @@ -0,0 +1,380 @@ +// +// ConfigVariableDetailViewModelTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +@MainActor +struct ConfigVariableDetailViewModelTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + let editorOverrideProvider = EditorOverrideProvider() + var userDefaults: UserDefaults! + var workingCopyDisplayName: String! + let undoManager = UndoManager() + + + init() { + workingCopyDisplayName = randomAlphanumericString() + userDefaults = UserDefaults(suiteName: randomAlphanumericString())! + } + + + // MARK: - Helpers + + mutating func makeDocument( + namedProviders: [NamedConfigProvider] = [], + registeredVariables: [RegisteredConfigVariable] + ) -> EditorDocument { + EditorDocument( + editorOverrideProvider: editorOverrideProvider, + workingCopyDisplayName: workingCopyDisplayName, + namedProviders: namedProviders, + registeredVariables: registeredVariables, + userDefaults: userDefaults, + undoManager: undoManager + ) + } + + + mutating func makeViewModel( + document: EditorDocument, + registeredVariable: RegisteredConfigVariable + ) -> ConfigVariableDetailViewModel { + ConfigVariableDetailViewModel(document: document, registeredVariable: registeredVariable) + } + + + // MARK: - init + + @Test + mutating func initSetsConstantProperties() { + // set up + 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 all constant properties are set from the registered variable + #expect(viewModel.key == variable.key) + #expect(viewModel.displayName == metadata.displayName) + #expect(viewModel.contentTypeName == variable.contentTypeName) + #expect(viewModel.variableTypeName == variable.destinationTypeName) + #expect(viewModel.metadataEntries == metadata.displayTextEntries) + #expect(viewModel.isSecret == isSecret) + #expect(viewModel.editorControl == .textField) + } + + + @Test + mutating func initUsesKeyDescriptionWhenDisplayNameIsNil() { + // set up with no display name metadata + let variable = randomRegisteredVariable() + let document = makeDocument(registeredVariables: [variable]) + + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // expect the key's description is used + #expect(viewModel.displayName == variable.key.description) + } + + + @Test + 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) + + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // expect override text comes from the override content + #expect(viewModel.overrideText == overrideContent.displayString) + } + + + @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]) + + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // expect override text comes from the resolved default value + #expect(viewModel.overrideText == defaultContent.displayString) + } + + + // MARK: - providerValues + + @Test + 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 document = makeDocument( + namedProviders: [.init(provider, displayName: providerDisplayName)], + registeredVariables: [variable] + ) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise + let values = viewModel.providerValues + + // expect the values match what the document returns + #expect(values == document.providerValues(forKey: variable.key)) + } + + + // MARK: - isOverrideEnabled + + @Test + 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 = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect + #expect(viewModel.isOverrideEnabled) + } + + + @Test + mutating func isOverrideEnabledReturnsFalseWhenNoOverride() { + // set up with no override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect + #expect(!viewModel.isOverrideEnabled) + } + + + @Test + 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 document = makeDocument( + namedProviders: [.init(provider, displayName: randomAlphanumericString())], + registeredVariables: [variable] + ) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise + viewModel.isOverrideEnabled = true + + // expect the override is set to the resolved value (provider content wins over default) + #expect(document.override(forKey: variable.key) == providerContent) + } + + + @Test + 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 document = makeDocument(registeredVariables: [otherVariable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise + viewModel.isOverrideEnabled = true + + // 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 settingIsOverrideEnabledToTrueUpdatesOverrideText() { + // set up + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise + viewModel.isOverrideEnabled = true + + // expect override text is updated to the resolved value's display string + #expect(viewModel.overrideText == defaultContent.displayString) + } + + + @Test + 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.isOverrideEnabled = false + + // expect the override is removed + #expect(!document.hasOverride(forKey: variable.key)) + } + + + // MARK: - overrideBool + + @Test + 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) + + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect + #expect(viewModel.overrideBool == boolValue) + } + + + @Test + 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) + + // exercise and expect + #expect(!viewModel.overrideBool) + } + + + @Test + mutating func settingOverrideBoolSetsDocumentOverride() { + // set up + let variable = randomRegisteredVariable(defaultContent: .bool(false)) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise + viewModel.overrideBool = true + + // expect the document has a bool override + #expect(document.override(forKey: variable.key) == .bool(true)) + } + + + // MARK: - commitOverrideText + + @Test + 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 inputText = randomAlphanumericString() + viewModel.overrideText = inputText + + // exercise + viewModel.commitOverrideText() + + // expect the parsed content is set as an override + #expect(document.override(forKey: variable.key) == .string(inputText)) + } + + + @Test + 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) + + viewModel.overrideText = randomAlphanumericString() + + // exercise + viewModel.commitOverrideText() + + // expect no override is set + #expect(!document.hasOverride(forKey: variable.key)) + } + + + @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) + + viewModel.overrideText = randomAlphanumericString() + + // exercise + viewModel.commitOverrideText() + + // expect no override is set + #expect(!document.hasOverride(forKey: variable.key)) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift new file mode 100644 index 0000000..5796422 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable List/ConfigVariableListViewModelTests.swift @@ -0,0 +1,448 @@ +// +// ConfigVariableListViewModelTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +@MainActor +struct ConfigVariableListViewModelTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + let editorOverrideProvider = EditorOverrideProvider() + var userDefaults: UserDefaults! + var workingCopyDisplayName: String! + let undoManager = UndoManager() + + nonisolated(unsafe) var onSaveStub: Stub<[RegisteredConfigVariable], Void>! + + + init() { + workingCopyDisplayName = randomAlphanumericString() + userDefaults = UserDefaults(suiteName: randomAlphanumericString())! + onSaveStub = Stub() + } + + + // 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 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) + + var metadata2 = ConfigVariableMetadata() + metadata2.displayName = "Beta" + let defaultContent2 = ConfigContent.int(randomInt(in: .min ... .max)) + let variable2 = randomRegisteredVariable(defaultContent: defaultContent2, metadata: metadata2) + + let document = makeDocument(registeredVariables: [variable1, variable2]) + let viewModel = makeViewModel(document: document) + + // 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 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) + } + + + @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 items = viewModel.variables + + // expect only the matching variable is returned + #expect(items.count == 1) + #expect(items.first?.displayName == "ServerURL") + } + + + @Test + 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 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) + } + + + @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 items = viewModel.variables + + // expect all variables are returned + #expect(items.count == 2) + } + + + @Test + 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 + ) + + var metadataA = ConfigVariableMetadata() + metadataA.displayName = "Alpha" + let variableA = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + metadata: metadataA + ) + + 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 items = viewModel.variables + + // expect items are sorted by display name + let displayNames = items.map(\.displayName) + #expect(displayNames == ["Alpha", "Bravo", "Charlie"]) + } + + + // MARK: - isDirty + + @Test + mutating func isDirtyDelegatesToDocument() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + + // expect clean initially + #expect(!viewModel.isDirty) + + // exercise by adding an override + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // expect dirty + #expect(viewModel.isDirty) + } + + + // 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 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) + } + + + // MARK: - canRedo + + @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) + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + undoManager.undo() + + // exercise + let canRedo = viewModel.canRedo + + // expect can redo after undo + #expect(canRedo) + } + + + // MARK: - requestDismiss + + @Test + mutating func requestDismissCallsDismissWhenClean() async { + // set up with no overrides + let document = makeDocument() + let viewModel = makeViewModel(document: document) + + // exercise + await confirmation { dismissed in + viewModel.requestDismiss { dismissed() } + } + + // expect save alert is not showing + #expect(!viewModel.isShowingSaveAlert) + } + + + @Test + 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) + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // exercise + viewModel.requestDismiss {} + + // expect save alert is showing and dismiss was not called + #expect(viewModel.isShowingSaveAlert) + } + + + // MARK: - save + + @Test + 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) + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // exercise + viewModel.save() + + // 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: - requestClearAllOverrides + + @Test + mutating func requestClearAllOverridesShowsClearAlert() { + // set up + let document = makeDocument() + let viewModel = makeViewModel(document: document) + + // exercise + viewModel.requestClearAllOverrides() + + // expect clear alert is showing + #expect(viewModel.isShowingClearAlert) + } + + + // MARK: - confirmClearAllOverrides + + @Test + mutating func confirmClearAllOverridesDelegatesToDocument() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // exercise + viewModel.confirmClearAllOverrides() + + // expect working copy is empty + #expect(document.workingCopy.isEmpty) + } + + + // MARK: - undo + + @Test + mutating func undoDelegatesToUndoManager() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // exercise + viewModel.undo() + + // expect override is removed + #expect(!document.hasOverride(forKey: variable.key)) + } + + + // MARK: - redo + + @Test + mutating func redoDelegatesToUndoManager() { + // set up with an override then undo + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) + undoManager.undo() + + // exercise + viewModel.redo() + + // expect override is restored + #expect(document.override(forKey: variable.key) == content) + } + + + // MARK: - makeDetailViewModel + + @Test + mutating func makeDetailViewModelReturnsViewModelForKey() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document) + + // exercise + let detailViewModel = viewModel.makeDetailViewModel(for: variable.key) + + // expect the detail view model has the correct key + #expect(detailViewModel.key == variable.key) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift new file mode 100644 index 0000000..58b9d70 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorDocumentTests.swift @@ -0,0 +1,622 @@ +// +// EditorDocumentTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/9/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +@MainActor +struct EditorDocumentTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + let editorOverrideProvider = EditorOverrideProvider() + var userDefaults: UserDefaults! + var workingCopyDisplayName: String! + let undoManager = UndoManager() + + + init() { + workingCopyDisplayName = randomAlphanumericString() + userDefaults = UserDefaults(suiteName: randomAlphanumericString())! + } + + + // MARK: - Helpers + + 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: - Initialization + + @Test + mutating func initStoresRegisteredVariablesByKey() throws { + // set up with multiple registered variables + let variable1 = randomRegisteredVariable() + let variable2 = randomRegisteredVariable() + + // exercise + let document = makeDocument(registeredVariables: [variable1, variable2]) + + // expect each variable is stored keyed by its config key + #expect(document.registeredVariables.count == 2) + + let registered1 = try #require(document.registeredVariables[variable1.key]) + #expect(registered1.key == variable1.key) + #expect(registered1.defaultContent == variable1.defaultContent) + + let registered2 = try #require(document.registeredVariables[variable2.key]) + #expect(registered2.key == variable2.key) + #expect(registered2.defaultContent == variable2.defaultContent) + } + + + @Test + 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() + + // exercise + let document = makeDocument( + namedProviders: [.init(provider, displayName: displayName)], + registeredVariables: [variable] + ) + + // 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 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) + + let provider = InMemoryProvider(values: [:]) + + // exercise + let document = makeDocument( + namedProviders: [.init(provider, displayName: randomAlphanumericString())], + registeredVariables: [variable] + ) + + // 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 initCopiesExistingOverridesToWorkingCopy() { + // set up by pre-populating the editor override provider + let key = randomConfigKey() + let content = ConfigContent.string(randomAlphanumericString()) + editorOverrideProvider.setOverride(content, forKey: key) + + let variable = randomRegisteredVariable(key: key, defaultContent: .string(randomAlphanumericString())) + + // exercise + let document = makeDocument(registeredVariables: [variable]) + + // expect working copy contains the pre-existing override + #expect(document.workingCopy[key] == content) + } + + + // MARK: - Value Resolution + + @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) + + 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] + ) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) + + // exercise + let resolved = try #require(document.resolvedValue(forKey: variable.key)) + + // expect working copy wins + #expect(resolved.content == overrideContent) + #expect(resolved.providerDisplayName == workingCopyDisplayName) + #expect(resolved.providerIndex == nil) + } + + + @Test + 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) + + 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] + ) + + // set a mismatched type in the working copy + document.setOverride(.int(randomInt(in: .min ... .max)), forKey: variable.key) + + // exercise + let resolved = try #require(document.resolvedValue(forKey: variable.key)) + + // 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 resolvedValueFallsThroughToDefault() throws { + // set up with no provider values and no working copy override + let defaultContent = ConfigContent.bool(randomBool()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + + let document = makeDocument(registeredVariables: [variable]) + + // exercise + let resolved = try #require(document.resolvedValue(forKey: variable.key)) + + // expect the default snapshot value wins + #expect(resolved.content == defaultContent) + #expect(resolved.providerDisplayName == localizedString("editor.defaultProviderName")) + #expect(resolved.providerIndex == 0) + } + + + @Test + mutating func resolvedValueReturnsNilForUnregisteredKey() { + // set up with a document that has no variable for the queried key + let document = makeDocument() + + // exercise + let resolved = document.resolvedValue(forKey: randomConfigKey()) + + // expect nil for an unregistered key + #expect(resolved == nil) + } + + + // MARK: - Provider Values + + @Test + 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) + + 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] + ) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) + + // exercise + 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) + + // 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 + let values = document.providerValues(forKey: randomConfigKey()) + + // expect empty for an unregistered key + #expect(values.isEmpty) + } + + + // MARK: - Working Copy + + @Test + mutating func setAndRemoveOverride() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + + // exercise set + document.setOverride(overrideContent, forKey: variable.key) + + // expect override is present + #expect(document.workingCopy[variable.key] == overrideContent) + + // exercise remove + document.removeOverride(forKey: variable.key) + + // expect override is gone + #expect(document.workingCopy[variable.key] == nil) + } + + + @Test + mutating func setOverrideWithSameValueIsNoOp() { + // set up with an existing override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + 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 no undo action was registered + #expect(!undoManager.canUndo) + } + + + @Test + 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.removeAllOverrides() + + // expect working copy is empty + #expect(document.workingCopy.isEmpty) + } + + + @Test + mutating func hasOverrideReturnsTrueWhenOverrideExists() { + // set up + 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.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // expect true after setting an override + #expect(document.hasOverride(forKey: variable.key)) + } + + + @Test + mutating func overrideReturnsContentWhenOverrideExists() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + // expect nil before setting an override + #expect(document.override(forKey: variable.key) == nil) + + // exercise + let content = ConfigContent.string(randomAlphanumericString()) + document.setOverride(content, forKey: variable.key) + + // expect the override content is returned + #expect(document.override(forKey: variable.key) == content) + } + + + @Test + mutating func removeOverrideForMissingKeyIsNoOp() { + // set up + let document = makeDocument() + undoManager.removeAllActions() + + // exercise by removing an override for a key that has none + document.removeOverride(forKey: randomConfigKey()) + + // expect no undo action was registered + #expect(!undoManager.canUndo) + } + + + @Test + mutating func removeAllOverridesWhenEmptyIsNoOp() { + // set up with no overrides + let document = makeDocument() + undoManager.removeAllActions() + + // exercise + document.removeAllOverrides() + + // expect no undo action was registered + #expect(!undoManager.canUndo) + } + + + @Test + 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]) + + 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 both overrides are restored + #expect(document.workingCopy[variable1.key] == content1) + #expect(document.workingCopy[variable2.key] == content2) + } + + + // MARK: - Undo/Redo + + @Test + 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(.string(randomAlphanumericString()), forKey: variable.key) + + // exercise + undoManager.undo() + + // expect the override is removed + #expect(document.workingCopy[variable.key] == nil) + } + + + @Test + 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]) + + let originalContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(originalContent, forKey: variable.key) + await Task.yield() + + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // exercise + undoManager.undo() + + // expect the original value is restored + #expect(document.workingCopy[variable.key] == originalContent) + } + + + @Test + 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: variable.key) + + // exercise + undoManager.undo() + + // expect the value is restored + #expect(document.workingCopy[variable.key] == content) + } + + + // MARK: - Dirty Tracking and Save + + @Test + mutating func dirtyTrackingReflectsWorkingCopyChanges() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + // expect clean initially + #expect(!document.isDirty) + #expect(document.changedKeys.isEmpty) + + // exercise by adding an override + document.setOverride(.string(randomAlphanumericString()), forKey: variable.key) + + // expect dirty with the changed key + #expect(document.isDirty) + #expect(document.changedKeys == [variable.key]) + } + + + @Test + mutating func saveCommitsToProviderAndResetsDirtyState() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) + + // exercise + document.save() + + // expect dirty state is reset + #expect(!document.isDirty) + #expect(document.changedKeys.isEmpty) + + // expect the override was committed to the provider + #expect(editorOverrideProvider.overrides[variable.key] == overrideContent) + } + + + @Test + 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() + + document.removeOverride(forKey: variable.key) + + // exercise + document.save() + + // expect the override is removed from the provider + #expect(!editorOverrideProvider.hasOverride(forKey: variable.key)) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/EditorOverrideProviderTests.swift new file mode 100644 index 0000000..7a77435 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Data Models/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 != "editorOverrideProvider.name") + } + + + @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 != "editorOverrideProvider.name") + 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 != "editorOverrideProvider.name") + 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..c484f69 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift @@ -0,0 +1,92 @@ +// +// 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.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"), + .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) + } + } +} 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/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..75c0a1f --- /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) + + // exercise + metadata.requiresRelaunch = true + + // expect that the value is stored and retrieved correctly + #expect(metadata.requiresRelaunch) + } + + + @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") + } +}