From 9677b751e63a235d4e41b0b726736bd8f8fab66b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Jan 2026 17:47:03 -0500 Subject: [PATCH] feat(configmanager): add AutoDiscoveryService with tests Add service for auto-discovering configuration file locations. The service searches in prioritized order: 1. JDESCOPING_CONFIG_PATH environment variable 2. Same directory as executable 3. ../JdeScoping.Host/ relative to executable 4. User config directory (~/.jdescoping on Unix, %LOCALAPPDATA%\JdeScoping on Windows) Includes 9 unit tests covering all search locations, priority order, edge cases (missing directory, missing appsettings.json), and cancellation token support. --- .../Services/AutoDiscoveryService.cs | 86 +++++++ .../Services/IAutoDiscoveryService.cs | 19 ++ .../Services/AutoDiscoveryServiceTests.cs | 210 ++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/AutoDiscoveryService.cs create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/IAutoDiscoveryService.cs create mode 100644 NEW/tests/JdeScoping.ConfigManager.Tests/Services/AutoDiscoveryServiceTests.cs diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/AutoDiscoveryService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AutoDiscoveryService.cs new file mode 100644 index 0000000..5e2f0c8 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AutoDiscoveryService.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; + +namespace JdeScoping.ConfigManager.Services; + +/// +/// Service for auto-discovering configuration file locations. +/// Searches in prioritized order: +/// 1. JDESCOPING_CONFIG_PATH environment variable +/// 2. Same directory as executable +/// 3. ../JdeScoping.Host/ relative to executable +/// 4. User config directory (~/.jdescoping on Unix, %LOCALAPPDATA%\JdeScoping on Windows) +/// +public class AutoDiscoveryService : IAutoDiscoveryService +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger? _logger; + private const string EnvVarName = "JDESCOPING_CONFIG_PATH"; + private const string AppSettingsFileName = "appsettings.json"; + + public AutoDiscoveryService(IFileSystem fileSystem, ILogger? logger = null) + { + _fileSystem = fileSystem; + _logger = logger; + } + + public Task FindConfigFolderAsync(CancellationToken ct = default) + { + // 1. Check environment variable + var envPath = Environment.GetEnvironmentVariable(EnvVarName); + if (!string.IsNullOrEmpty(envPath) && IsValidConfigFolder(envPath)) + { + _logger?.LogInformation("Found config folder from environment variable: {Path}", envPath); + return Task.FromResult(envPath); + } + + // 2. Check same directory as executable + var exeDir = AppContext.BaseDirectory; + if (IsValidConfigFolder(exeDir)) + { + _logger?.LogInformation("Found config folder in executable directory: {Path}", exeDir); + return Task.FromResult(exeDir); + } + + // 3. Check ../JdeScoping.Host/ relative to executable + var hostDir = _fileSystem.Combine(exeDir, "..", "JdeScoping.Host"); + if (IsValidConfigFolder(hostDir)) + { + _logger?.LogInformation("Found config folder in host directory: {Path}", hostDir); + return Task.FromResult(hostDir); + } + + // 4. Check user config directory + var userConfigDir = GetUserConfigDirectory(); + if (userConfigDir != null && IsValidConfigFolder(userConfigDir)) + { + _logger?.LogInformation("Found config folder in user directory: {Path}", userConfigDir); + return Task.FromResult(userConfigDir); + } + + _logger?.LogWarning("Could not find config folder in any standard location"); + return Task.FromResult(null); + } + + private bool IsValidConfigFolder(string path) + { + if (!_fileSystem.DirectoryExists(path)) + return false; + + var appSettingsPath = _fileSystem.Combine(path, AppSettingsFileName); + return _fileSystem.FileExists(appSettingsPath); + } + + private string? GetUserConfigDirectory() + { + if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return _fileSystem.Combine(localAppData, "JdeScoping"); + } + else + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return _fileSystem.Combine(home, ".jdescoping"); + } + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IAutoDiscoveryService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IAutoDiscoveryService.cs new file mode 100644 index 0000000..4372214 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IAutoDiscoveryService.cs @@ -0,0 +1,19 @@ +namespace JdeScoping.ConfigManager.Services; + +/// +/// Service for auto-discovering configuration file locations. +/// +public interface IAutoDiscoveryService +{ + /// + /// Searches for a configuration folder containing appsettings.json. + /// Search order: + /// 1. JDESCOPING_CONFIG_PATH environment variable + /// 2. Same directory as executable + /// 3. ../JdeScoping.Host/ relative to executable + /// 4. User config directory (~/.jdescoping on Unix, %LOCALAPPDATA%\JdeScoping on Windows) + /// + /// Cancellation token. + /// The path to the config folder, or null if not found. + Task FindConfigFolderAsync(CancellationToken ct = default); +} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/AutoDiscoveryServiceTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/AutoDiscoveryServiceTests.cs new file mode 100644 index 0000000..6d8c8b3 --- /dev/null +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/AutoDiscoveryServiceTests.cs @@ -0,0 +1,210 @@ +using JdeScoping.ConfigManager.Services; + +namespace JdeScoping.ConfigManager.Tests.Services; + +public class AutoDiscoveryServiceTests : IDisposable +{ + private readonly IFileSystem _fileSystem; + private readonly AutoDiscoveryService _sut; + private readonly string? _originalEnvVar; + + public AutoDiscoveryServiceTests() + { + _fileSystem = Substitute.For(); + _sut = new AutoDiscoveryService(_fileSystem); + // Save original env var to restore later + _originalEnvVar = Environment.GetEnvironmentVariable("JDESCOPING_CONFIG_PATH"); + + // Set up default Combine behavior to work like Path.Combine + _fileSystem.Combine(Arg.Any()).Returns(callInfo => + { + var paths = callInfo.Arg(); + return Path.Combine(paths); + }); + } + + public void Dispose() + { + // Restore original env var + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", _originalEnvVar); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenEnvVarSet_ReturnsEnvPath() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config"); + _fileSystem.DirectoryExists("/custom/config").Returns(true); + _fileSystem.FileExists(Arg.Is(s => s.Contains("appsettings.json") && s.Contains("/custom/config"))).Returns(true); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBe("/custom/config"); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenEnvVarSetButDirectoryInvalid_SearchesOtherLocations() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/invalid/config"); + _fileSystem.DirectoryExists(Arg.Any()).Returns(false); + _fileSystem.FileExists(Arg.Any()).Returns(false); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenEnvVarSetButNoAppSettings_SearchesOtherLocations() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config"); + // Directory exists but no appsettings.json + _fileSystem.DirectoryExists("/custom/config").Returns(true); + _fileSystem.FileExists(Arg.Any()).Returns(false); + // All other locations also don't have config + _fileSystem.DirectoryExists(Arg.Is(s => s != "/custom/config")).Returns(false); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenNotFound_ReturnsNull() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null); + _fileSystem.DirectoryExists(Arg.Any()).Returns(false); + _fileSystem.FileExists(Arg.Any()).Returns(false); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenExeDirectoryHasConfig_ReturnsExeDirectory() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null); + var exeDir = AppContext.BaseDirectory; + + _fileSystem.DirectoryExists(exeDir).Returns(true); + _fileSystem.FileExists(Arg.Is(s => s.Contains(exeDir) && s.Contains("appsettings.json"))).Returns(true); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBe(exeDir); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenHostDirectoryHasConfig_ReturnsHostDirectory() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null); + var exeDir = AppContext.BaseDirectory; + var hostDir = Path.Combine(exeDir, "..", "JdeScoping.Host"); + + // Exe directory doesn't have config + _fileSystem.DirectoryExists(exeDir).Returns(false); + + // Host directory has config + _fileSystem.DirectoryExists(hostDir).Returns(true); + _fileSystem.FileExists(Arg.Is(s => s.Contains("JdeScoping.Host") && s.Contains("appsettings.json"))).Returns(true); + + // Other directories don't have config + _fileSystem.DirectoryExists(Arg.Is(s => s != exeDir && s != hostDir)).Returns(false); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBe(hostDir); + } + + [Fact] + public async Task FindConfigFolderAsync_WhenUserConfigDirectoryHasConfig_ReturnsUserConfigDirectory() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null); + var exeDir = AppContext.BaseDirectory; + var hostDir = Path.Combine(exeDir, "..", "JdeScoping.Host"); + + // Determine user config path based on OS + string userConfigDir; + if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + userConfigDir = Path.Combine(localAppData, "JdeScoping"); + } + else + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + userConfigDir = Path.Combine(home, ".jdescoping"); + } + + // Exe directory doesn't have config + _fileSystem.DirectoryExists(exeDir).Returns(false); + + // Host directory doesn't have config + _fileSystem.DirectoryExists(hostDir).Returns(false); + + // User config directory has config + _fileSystem.DirectoryExists(userConfigDir).Returns(true); + _fileSystem.FileExists(Arg.Is(s => s.Contains(userConfigDir) && s.Contains("appsettings.json"))).Returns(true); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert + result.ShouldBe(userConfigDir); + } + + [Fact] + public async Task FindConfigFolderAsync_SearchesPrioritizedOrder() + { + // Arrange - all locations have config, but env var should be returned first + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/env/config"); + var exeDir = AppContext.BaseDirectory; + + // Both env var path and exe dir have valid config + _fileSystem.DirectoryExists("/env/config").Returns(true); + _fileSystem.DirectoryExists(exeDir).Returns(true); + _fileSystem.FileExists(Arg.Any()).Returns(true); + + // Act + var result = await _sut.FindConfigFolderAsync(); + + // Assert - should return env var path, not exe dir + result.ShouldBe("/env/config"); + } + + [Fact] + public async Task FindConfigFolderAsync_WithCancellationToken_DoesNotThrow() + { + // Arrange + Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config"); + _fileSystem.DirectoryExists("/custom/config").Returns(true); + _fileSystem.FileExists(Arg.Any()).Returns(true); + + using var cts = new CancellationTokenSource(); + + // Act + var result = await _sut.FindConfigFolderAsync(cts.Token); + + // Assert + result.ShouldBe("/custom/config"); + } +}