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.
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace JdeScoping.ConfigManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
/// </summary>
|
||||||
|
public class AutoDiscoveryService : IAutoDiscoveryService
|
||||||
|
{
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly ILogger<AutoDiscoveryService>? _logger;
|
||||||
|
private const string EnvVarName = "JDESCOPING_CONFIG_PATH";
|
||||||
|
private const string AppSettingsFileName = "appsettings.json";
|
||||||
|
|
||||||
|
public AutoDiscoveryService(IFileSystem fileSystem, ILogger<AutoDiscoveryService>? logger = null)
|
||||||
|
{
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> 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<string?>(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<string?>(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<string?>(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<string?>(userConfigDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger?.LogWarning("Could not find config folder in any standard location");
|
||||||
|
return Task.FromResult<string?>(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace JdeScoping.ConfigManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for auto-discovering configuration file locations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAutoDiscoveryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>The path to the config folder, or null if not found.</returns>
|
||||||
|
Task<string?> FindConfigFolderAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -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<IFileSystem>();
|
||||||
|
_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<string[]>()).Returns(callInfo =>
|
||||||
|
{
|
||||||
|
var paths = callInfo.Arg<string[]>();
|
||||||
|
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<string>(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<string>()).Returns(false);
|
||||||
|
_fileSystem.FileExists(Arg.Any<string>()).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<string>()).Returns(false);
|
||||||
|
// All other locations also don't have config
|
||||||
|
_fileSystem.DirectoryExists(Arg.Is<string>(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<string>()).Returns(false);
|
||||||
|
_fileSystem.FileExists(Arg.Any<string>()).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<string>(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<string>(s => s.Contains("JdeScoping.Host") && s.Contains("appsettings.json"))).Returns(true);
|
||||||
|
|
||||||
|
// Other directories don't have config
|
||||||
|
_fileSystem.DirectoryExists(Arg.Is<string>(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<string>(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<string>()).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<string>()).Returns(true);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.FindConfigFolderAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.ShouldBe("/custom/config");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user