diff --git a/.gitignore b/.gitignore index 06566855..1b70325d 100644 --- a/.gitignore +++ b/.gitignore @@ -370,3 +370,6 @@ src/WindowsFormsApp1/Properties/Settings.Designer.cs src/WindowsFormsApp1/Properties/Settings.settings src/keys/acat-dev-signingcert.snk src/keys/acat-rel-signingcert.snk + +# Local configuration overrides (developer-specific, not for source control) +*.local.json diff --git a/docs/CONFIGURATION_ENHANCEMENT_GUIDE.md b/docs/CONFIGURATION_ENHANCEMENT_GUIDE.md index 7fd5e6e8..113edc76 100644 --- a/docs/CONFIGURATION_ENHANCEMENT_GUIDE.md +++ b/docs/CONFIGURATION_ENHANCEMENT_GUIDE.md @@ -136,6 +136,18 @@ loader.Dispose(); The `EnvironmentConfiguration` class supports loading different configuration files based on the environment (Development, Testing, Staging, Production). +### Configuration Hierarchy + +Configuration is loaded and merged in the following priority order (lowest to highest): + +1. **Base configuration** – `config.json` — shared defaults for all environments +2. **Environment-specific** – `config.{Environment}.json` — overrides for the active environment +3. **Local override** – `config.local.json` — developer-specific overrides (**gitignored**) +4. **Environment variables** – `ACAT_` — runtime overrides + +Use `GetConfigurationFiles(baseFilePath)` to retrieve the ordered list of existing files +that should be loaded and merged for the active environment. + ### Supported Environments ```csharp @@ -186,6 +198,7 @@ Examples: - Testing: `config.Testing.json` - Staging: `config.Staging.json` - Production: `config.Production.json` +- Local override: `config.local.json` (gitignored — never commit this file) ### Usage @@ -201,10 +214,30 @@ string envPath = envConfig.GetEnvironmentFilePath(basePath); // Returns: "config/settings.Development.json" if in Development and file exists // "config/settings.json" otherwise +// Get the local override file path (e.g. for developer-specific settings) +string localPath = envConfig.GetLocalOverrideFilePath(basePath); +// Returns: "config/settings.local.json" (regardless of whether the file exists) + +// Get all files in the configuration hierarchy (only existing files, lowest to highest priority) +IReadOnlyList files = envConfig.GetConfigurationFiles(basePath); +// Returns e.g.: ["config/settings.json", "config/settings.Development.json", "config/settings.local.json"] + // Load configuration with environment overrides var config = envConfig.LoadWithEnvironmentOverrides(basePath); ``` +### Local Override Files + +`config.local.json` files allow individual developers to maintain machine-specific settings +without affecting shared configuration or polluting source control. + +**Setup:** +1. Copy the relevant base file: `cp config.json config.local.json` +2. Edit `config.local.json` with your local overrides +3. The file is automatically ignored by `.gitignore` (`*.local.json`) + +**Important:** Never commit `*.local.json` files to source control. + ### Environment Variable Overrides You can override individual configuration properties using environment variables: diff --git a/src/Libraries/ACATCore.Tests.Configuration/ConfigurationEnhancementsTests.cs b/src/Libraries/ACATCore.Tests.Configuration/ConfigurationEnhancementsTests.cs index a5b84d05..0d1418cb 100644 --- a/src/Libraries/ACATCore.Tests.Configuration/ConfigurationEnhancementsTests.cs +++ b/src/Libraries/ACATCore.Tests.Configuration/ConfigurationEnhancementsTests.cs @@ -539,5 +539,286 @@ public void JsonConfigurationLoader_LoadWithEnvironment_LoadsDevConfig() } #endregion + + #region Environment Detection Tests + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_FromACATEnvironmentVar_Development() + { + // Arrange + string previousValue = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", "Development"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Development, envConfig.CurrentEnvironment, + "Should detect Development from ACAT_ENVIRONMENT"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousValue); + } + } + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_FromACATEnvironmentVar_Testing() + { + // Arrange + string previousValue = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", "Testing"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Testing, envConfig.CurrentEnvironment, + "Should detect Testing from ACAT_ENVIRONMENT"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousValue); + } + } + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_FromACATEnvironmentVar_Staging() + { + // Arrange + string previousValue = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", "Staging"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Staging, envConfig.CurrentEnvironment, + "Should detect Staging from ACAT_ENVIRONMENT"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousValue); + } + } + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_FromACATEnvironmentVar_Production() + { + // Arrange + string previousValue = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", "Production"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Production, envConfig.CurrentEnvironment, + "Should detect Production from ACAT_ENVIRONMENT"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousValue); + } + } + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_CaseInsensitive() + { + // Arrange + string previousValue = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", "development"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Development, envConfig.CurrentEnvironment, + "Environment detection should be case-insensitive"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousValue); + } + } + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_FallsBackToDotnetEnvironment() + { + // Arrange + string previousAcat = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + string previousDotnet = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", null); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Testing, envConfig.CurrentEnvironment, + "Should fall back to DOTNET_ENVIRONMENT when ACAT_ENVIRONMENT is not set"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousAcat); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousDotnet); + } + } + + [TestMethod] + public void EnvironmentConfiguration_DetectEnvironment_FallsBackToAspNetCoreEnvironment() + { + // Arrange + string previousAcat = Environment.GetEnvironmentVariable("ACAT_ENVIRONMENT"); + string previousDotnet = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + string previousAspNet = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", null); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", null); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Staging"); + + try + { + // Act + var envConfig = new EnvironmentConfiguration(_logger); + + // Assert + Assert.AreEqual(ConfigurationEnvironment.Staging, envConfig.CurrentEnvironment, + "Should fall back to ASPNETCORE_ENVIRONMENT when neither ACAT_ENVIRONMENT nor DOTNET_ENVIRONMENT is set"); + } + finally + { + Environment.SetEnvironmentVariable("ACAT_ENVIRONMENT", previousAcat); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousDotnet); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", previousAspNet); + } + } + + [TestMethod] + public void EnvironmentConfiguration_GetLocalOverrideFilePath_ReturnsLocalJson() + { + // Arrange + var envConfig = new EnvironmentConfiguration(_logger); + string basePath = Path.Combine(_testDirectory, "config.json"); + + // Act + string localPath = envConfig.GetLocalOverrideFilePath(basePath); + + // Assert + string expectedLocalPath = Path.Combine(_testDirectory, "config.local.json"); + Assert.AreEqual(expectedLocalPath, localPath, + "Local override path should insert '.local' before the extension"); + } + + [TestMethod] + public void EnvironmentConfiguration_GetLocalOverrideFilePath_NullInput_ReturnsNull() + { + // Arrange + var envConfig = new EnvironmentConfiguration(_logger); + + // Act + string localPath = envConfig.GetLocalOverrideFilePath(null); + + // Assert + Assert.IsNull(localPath, "Should return null for null input"); + } + + [TestMethod] + public void EnvironmentConfiguration_GetConfigurationFiles_BaseOnly_ReturnsBase() + { + // Arrange + var envConfig = new EnvironmentConfiguration(_logger); + envConfig.SetEnvironment(ConfigurationEnvironment.Development); + string basePath = Path.Combine(_testDirectory, "config.json"); + + // Only base file exists + File.WriteAllText(basePath, @"{""name"": ""base""}"); + + // Act + IReadOnlyList files = envConfig.GetConfigurationFiles(basePath); + + // Assert + Assert.AreEqual(1, files.Count, "Should return only base file when no env or local files exist"); + Assert.AreEqual(basePath, files[0]); + } + + [TestMethod] + public void EnvironmentConfiguration_GetConfigurationFiles_BaseAndEnv_ReturnsBoth() + { + // Arrange + var envConfig = new EnvironmentConfiguration(_logger); + envConfig.SetEnvironment(ConfigurationEnvironment.Development); + string basePath = Path.Combine(_testDirectory, "config.json"); + string devPath = Path.Combine(_testDirectory, "config.Development.json"); + + File.WriteAllText(basePath, @"{""name"": ""base""}"); + File.WriteAllText(devPath, @"{""name"": ""dev""}"); + + // Act + IReadOnlyList files = envConfig.GetConfigurationFiles(basePath); + + // Assert + Assert.AreEqual(2, files.Count, "Should return base and env-specific files"); + Assert.AreEqual(basePath, files[0], "Base file should be first"); + Assert.AreEqual(devPath, files[1], "Dev-specific file should be second"); + } + + [TestMethod] + public void EnvironmentConfiguration_GetConfigurationFiles_AllThreeLayers_ReturnsAll() + { + // Arrange + var envConfig = new EnvironmentConfiguration(_logger); + envConfig.SetEnvironment(ConfigurationEnvironment.Development); + string basePath = Path.Combine(_testDirectory, "config.json"); + string devPath = Path.Combine(_testDirectory, "config.Development.json"); + string localPath = Path.Combine(_testDirectory, "config.local.json"); + + File.WriteAllText(basePath, @"{""name"": ""base""}"); + File.WriteAllText(devPath, @"{""name"": ""dev""}"); + File.WriteAllText(localPath, @"{""name"": ""local""}"); + + // Act + IReadOnlyList files = envConfig.GetConfigurationFiles(basePath); + + // Assert + Assert.AreEqual(3, files.Count, "Should return all three layers"); + Assert.AreEqual(basePath, files[0], "Base file should be first"); + Assert.AreEqual(devPath, files[1], "Env-specific file should be second"); + Assert.AreEqual(localPath, files[2], "Local override should be third (loaded last, highest priority)"); + } + + [TestMethod] + public void EnvironmentConfiguration_GetConfigurationFiles_LocalOverrideOnly_ReturnsBaseAndLocal() + { + // Arrange + var envConfig = new EnvironmentConfiguration(_logger); + envConfig.SetEnvironment(ConfigurationEnvironment.Production); + string basePath = Path.Combine(_testDirectory, "config.json"); + string localPath = Path.Combine(_testDirectory, "config.local.json"); + + File.WriteAllText(basePath, @"{""name"": ""base""}"); + File.WriteAllText(localPath, @"{""name"": ""local""}"); + + // Act + IReadOnlyList files = envConfig.GetConfigurationFiles(basePath); + + // Assert + Assert.AreEqual(2, files.Count, "Should return base and local override files"); + Assert.AreEqual(basePath, files[0]); + Assert.AreEqual(localPath, files[1]); + } + + #endregion } } diff --git a/src/Libraries/ACATCore/Configuration/EnvironmentConfiguration.cs b/src/Libraries/ACATCore/Configuration/EnvironmentConfiguration.cs index 31e99e01..03314d92 100644 --- a/src/Libraries/ACATCore/Configuration/EnvironmentConfiguration.cs +++ b/src/Libraries/ACATCore/Configuration/EnvironmentConfiguration.cs @@ -15,6 +15,7 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; +using System.Collections.ObjectModel; namespace ACAT.Core.Configuration { @@ -305,5 +306,96 @@ public Dictionary GetAllOverrides() { return new Dictionary(_environmentOverrides); } + + /// + /// Get the local override configuration file path (e.g., config.local.json). + /// Local override files are intended for developer-specific settings and should + /// be excluded from source control via .gitignore. + /// + /// Base configuration file path (e.g., "config.json") + /// Local override file path, regardless of whether the file exists + public string GetLocalOverrideFilePath(string baseFilePath) + { + if (string.IsNullOrEmpty(baseFilePath)) + { + return baseFilePath; + } + + try + { + string directory = Path.GetDirectoryName(baseFilePath); + string fileName = Path.GetFileNameWithoutExtension(baseFilePath); + string extension = Path.GetExtension(baseFilePath); + + string localFileName = $"{fileName}.local{extension}"; + return string.IsNullOrEmpty(directory) + ? localFileName + : Path.Combine(directory, localFileName); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error determining local override file path for: {FilePath}", baseFilePath); + return baseFilePath; + } + } + + /// + /// Returns the ordered list of configuration files that should be loaded + /// and merged for the current environment. Files appear in priority order + /// (lowest to highest): base, environment-specific, local override. + /// Only files that actually exist on disk are included. + /// + /// Base configuration file path (e.g., "config.json") + /// Ordered list of existing file paths starting from the base file + public IReadOnlyList GetConfigurationFiles(string baseFilePath) + { + var files = new List(); + + if (string.IsNullOrEmpty(baseFilePath)) + { + return files.AsReadOnly(); + } + + try + { + // 1. Base configuration + if (File.Exists(baseFilePath)) + { + files.Add(baseFilePath); + } + + // 2. Environment-specific configuration (e.g., config.Development.json) + string directory = Path.GetDirectoryName(baseFilePath); + string fileName = Path.GetFileNameWithoutExtension(baseFilePath); + string extension = Path.GetExtension(baseFilePath); + + string envFileName = $"{fileName}.{_currentEnvironment}{extension}"; + string envFilePath = string.IsNullOrEmpty(directory) + ? envFileName + : Path.Combine(directory, envFileName); + + if (File.Exists(envFilePath) && + !string.Equals(Path.GetFullPath(envFilePath), Path.GetFullPath(baseFilePath), StringComparison.OrdinalIgnoreCase)) + { + files.Add(envFilePath); + } + + // 3. Local overrides (e.g., config.local.json) + string localFilePath = GetLocalOverrideFilePath(baseFilePath); + if (File.Exists(localFilePath)) + { + files.Add(localFilePath); + } + + _logger?.LogDebug("Configuration file hierarchy for {BaseFilePath}: [{Files}]", + baseFilePath, string.Join(", ", files)); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error building configuration file hierarchy for: {FilePath}", baseFilePath); + } + + return files.AsReadOnly(); + } } } diff --git a/src/Libraries/ACATCore/Configuration/IConfigurationManager.cs b/src/Libraries/ACATCore/Configuration/IConfigurationManager.cs index de552e1c..fa164d84 100644 --- a/src/Libraries/ACATCore/Configuration/IConfigurationManager.cs +++ b/src/Libraries/ACATCore/Configuration/IConfigurationManager.cs @@ -71,5 +71,27 @@ public interface IConfigurationManager /// /// Dictionary of key/value override pairs Dictionary GetAllOverrides(); + + /// + /// Gets the local override configuration file path (e.g., config.local.json). + /// Local override files are intended for developer-specific settings and should + /// be excluded from source control. + /// + /// Base configuration file path (e.g., "config.json") + /// Local override file path (e.g., "config.local.json"), regardless of whether it exists + string GetLocalOverrideFilePath(string baseFilePath); + + /// + /// Returns the ordered list of configuration files that should be loaded + /// and merged for the current environment. Files appear in priority order + /// (lowest to highest): base, environment-specific, local override. + /// Only files that actually exist on disk are included. + /// + /// Base configuration file path (e.g., "config.json") + /// + /// Ordered list of existing file paths from which configuration should be + /// loaded and merged, starting from the base file. + /// + IReadOnlyList GetConfigurationFiles(string baseFilePath); } } diff --git a/src/Libraries/ACATCore/Configuration/README.md b/src/Libraries/ACATCore/Configuration/README.md index 7b603201..33d3f0d2 100644 --- a/src/Libraries/ACATCore/Configuration/README.md +++ b/src/Libraries/ACATCore/Configuration/README.md @@ -30,6 +30,8 @@ This directory contains the enhanced configuration system components for ACAT Ph - **Key Features**: - Automatic environment detection from environment variables - Environment-specific file path resolution + - Local override file support (`config.local.json`) — gitignored for developer-specific settings + - Full configuration hierarchy: base → environment-specific → local override → env vars - Environment variable overrides (ACAT_*) - Configuration override management @@ -165,6 +167,14 @@ export ACAT_ENABLED=true ## File Naming Conventions +### Configuration Hierarchy (lowest to highest priority) + +Files are loaded and merged in the following order: +1. **Base**: `config.json` — shared defaults +2. **Environment-specific**: `config.{Environment}.json` — environment overrides +3. **Local override**: `config.local.json` — developer-specific overrides (**gitignored**) +4. **Environment variables**: `ACAT_*` — runtime overrides + ### Environment-Specific Files - Base: `config.json` - Development: `config.Development.json` @@ -172,6 +182,13 @@ export ACAT_ENABLED=true - Staging: `config.Staging.json` - Production: `config.Production.json` +### Local Override Files (gitignored) +- `config.local.json` + +Local override files allow individual developers to maintain machine-specific settings +without polluting the repository. They are automatically excluded from source control +via `.gitignore` (`*.local.json`). + ### Backup Files Migration creates automatic backups: `config.json.backup.20260218123456`