Add comprehensive XML documentation (param/returns tags) across 132 source files to improve IntelliSense and API discoverability. Include ConfigManager design documents and implementation plans for phases 1-9.
78 KiB
ConfigManager Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build an Avalonia desktop application that provides a GUI for editing appsettings.json and pipelines.json configuration files.
Architecture: MVVM pattern with services layer. Tree view for navigation, dynamic forms for editing. File operations through abstracted IFileSystem for testability. Validation at schema and business-rule levels.
Tech Stack: Avalonia 11.2, .NET 10, Microsoft.Extensions.DependencyInjection, DiffPlex, Serilog
Phase 1: Project Setup & Infrastructure
Task 1: Create Project Structure
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Program.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/App.axaml - Create:
NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs
Step 1: Create project file
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.*" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.*" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.*" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.*" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.*" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.*" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="DiffPlex" Version="1.7.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.*" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.*" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
</Project>
Step 2: Create Program.cs
using Avalonia;
namespace JdeScoping.ConfigManager;
class Program
{
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
Step 3: Create App.axaml
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="JdeScoping.ConfigManager.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
Step 4: Create App.axaml.cs
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager;
public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new Views.MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureServices(IServiceCollection services)
{
services.AddLogging(builder => builder
.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
}
}
Step 5: Create app.manifest
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="JdeScoping.ConfigManager"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
Step 6: Verify project builds
Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/
Expected: Build succeeded
Step 7: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/
git commit -m "feat(configmanager): create initial project structure"
Task 2: Create Test Project
Files:
- Create:
NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/GlobalUsings.cs
Step 1: Create test project file
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Utils\JdeScoping.ConfigManager\JdeScoping.ConfigManager.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
</Project>
Step 2: Create GlobalUsings.cs
global using Xunit;
global using Shouldly;
global using NSubstitute;
Step 3: Verify test project builds
Run: dotnet build NEW/tests/JdeScoping.ConfigManager.Tests/
Expected: Build succeeded
Step 4: Commit
git add NEW/tests/JdeScoping.ConfigManager.Tests/
git commit -m "feat(configmanager): add test project"
Task 3: Create IFileSystem Abstraction
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/IFileSystem.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/FileSystem.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/Services/FileSystemTests.cs
Step 1: Write the failing test
namespace JdeScoping.ConfigManager.Tests.Services;
public class FileSystemTests
{
[Fact]
public void FileExists_WithExistingFile_ReturnsTrue()
{
// Arrange
var sut = new FileSystem();
var tempFile = Path.GetTempFileName();
try
{
// Act
var result = sut.FileExists(tempFile);
// Assert
result.ShouldBeTrue();
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public void FileExists_WithNonExistingFile_ReturnsFalse()
{
// Arrange
var sut = new FileSystem();
// Act
var result = sut.FileExists("/nonexistent/path/file.txt");
// Assert
result.ShouldBeFalse();
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "FileSystemTests"
Expected: FAIL with "FileSystem not found"
Step 3: Create IFileSystem interface
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Abstraction for file system operations to enable testing.
/// </summary>
public interface IFileSystem
{
bool FileExists(string path);
bool DirectoryExists(string path);
Task<string> ReadAllTextAsync(string path, CancellationToken ct = default);
Task WriteAllTextAsync(string path, string content, CancellationToken ct = default);
Task<string[]> GetFilesAsync(string directory, string pattern, CancellationToken ct = default);
Task CopyFileAsync(string source, string destination, CancellationToken ct = default);
Task DeleteFileAsync(string path, CancellationToken ct = default);
string GetDirectoryName(string path);
string GetFileName(string path);
string GetFileNameWithoutExtension(string path);
string Combine(params string[] paths);
}
Step 4: Create FileSystem implementation
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Real file system implementation.
/// </summary>
public class FileSystem : IFileSystem
{
public bool FileExists(string path) => File.Exists(path);
public bool DirectoryExists(string path) => Directory.Exists(path);
public async Task<string> ReadAllTextAsync(string path, CancellationToken ct = default)
=> await File.ReadAllTextAsync(path, ct);
public async Task WriteAllTextAsync(string path, string content, CancellationToken ct = default)
=> await File.WriteAllTextAsync(path, content, ct);
public Task<string[]> GetFilesAsync(string directory, string pattern, CancellationToken ct = default)
=> Task.FromResult(Directory.GetFiles(directory, pattern));
public async Task CopyFileAsync(string source, string destination, CancellationToken ct = default)
{
var content = await File.ReadAllBytesAsync(source, ct);
await File.WriteAllBytesAsync(destination, content, ct);
}
public Task DeleteFileAsync(string path, CancellationToken ct = default)
{
File.Delete(path);
return Task.CompletedTask;
}
public string GetDirectoryName(string path) => Path.GetDirectoryName(path) ?? string.Empty;
public string GetFileName(string path) => Path.GetFileName(path);
public string GetFileNameWithoutExtension(string path) => Path.GetFileNameWithoutExtension(path);
public string Combine(params string[] paths) => Path.Combine(paths);
}
Step 5: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "FileSystemTests"
Expected: PASS
Step 6: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Services/
git add NEW/tests/JdeScoping.ConfigManager.Tests/Services/
git commit -m "feat(configmanager): add IFileSystem abstraction"
Task 4: Create ViewModelBase and Command Classes
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/ViewModelBase.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/RelayCommand.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/AsyncRelayCommand.cs
Step 1: Create ViewModelBase
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace JdeScoping.ConfigManager.ViewModels;
/// <summary>
/// Base class for all view models providing INotifyPropertyChanged implementation.
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
Step 2: Create RelayCommand
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels;
/// <summary>
/// A command implementation that delegates to action methods.
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
private EventHandler? _canExecuteChanged;
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public RelayCommand(Action execute, Func<bool>? canExecute = null)
: this(_ => execute(), canExecute != null ? _ => canExecute() : null)
{
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
Step 3: Create AsyncRelayCommand
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels;
/// <summary>
/// An async command implementation that properly handles async operations.
/// </summary>
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isExecuting;
private EventHandler? _canExecuteChanged;
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter)) return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _execute();
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
Step 4: Verify build
Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/
Expected: Build succeeded
Step 5: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/
git commit -m "feat(configmanager): add MVVM base classes"
Phase 2: Configuration Models
Task 5: Create Configuration Models
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Models/ScheduleModel.cs
Step 1: Create ConfigModel
using System.Text.Json.Serialization;
namespace JdeScoping.ConfigManager.Models;
/// <summary>
/// Root model for appsettings.json configuration.
/// </summary>
public class ConfigModel
{
public DataSyncSection DataSync { get; set; } = new();
public DataAccessSection DataAccess { get; set; } = new();
public AuthSection Auth { get; set; } = new();
public LdapSection Ldap { get; set; } = new();
public SearchSection Search { get; set; } = new();
public ExcelExportSection ExcelExport { get; set; } = new();
public Dictionary<string, string> ConnectionStrings { get; set; } = new();
}
public class DataSyncSection
{
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1);
public int MaxDegreeOfParallelism { get; set; } = 4;
public int BatchSize { get; set; } = 50000;
public int BulkCopyBatchSize { get; set; } = 5000;
public double LookbackMultiplier { get; set; } = 1.5;
public int PurgeRetentionDays { get; set; } = 90;
public int SyncTimeoutSeconds { get; set; } = 3600;
public bool Enabled { get; set; } = true;
}
public class DataAccessSection
{
public int DefaultTimeoutSeconds { get; set; } = 30;
public int LotUsageTimeoutSeconds { get; set; } = 120;
public int MisDataTimeoutSeconds { get; set; } = 300;
public string ProductionSchema { get; set; } = "prod";
public string ArchiveSchema { get; set; } = "archive";
public string StageSchema { get; set; } = "stage";
public bool EnableDetailedLogging { get; set; } = false;
}
public class AuthSection
{
public string CookieName { get; set; } = ".JdeScoping.Auth";
public int CookieExpirationMinutes { get; set; } = 480;
}
public class LdapSection
{
public string[] ServerUrls { get; set; } = [];
public string GroupDn { get; set; } = string.Empty;
public string SearchBase { get; set; } = string.Empty;
public int ConnectionTimeoutSeconds { get; set; } = 30;
public bool UseFakeAuth { get; set; } = false;
public string[] AdminBypassUsers { get; set; } = [];
}
public class SearchSection
{
public int MaxResultRows { get; set; } = 100000;
public int TimeoutSeconds { get; set; } = 300;
public int MaxConcurrentSearches { get; set; } = 5;
}
public class ExcelExportSection
{
public string CriteriaSheetPassword { get; set; } = string.Empty;
public string DataSheetPassword { get; set; } = string.Empty;
public int MaxRowsPerSheet { get; set; } = 1000000;
public string DefaultDateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";
public bool DebugWriteToFile { get; set; } = false;
public string DebugOutputDirectory { get; set; } = string.Empty;
public string TimezoneId { get; set; } = "America/Chicago";
public string TimezoneAbbreviation { get; set; } = "CT";
}
Step 2: Create PipelineModel
namespace JdeScoping.ConfigManager.Models;
/// <summary>
/// Root model for pipelines.json configuration.
/// </summary>
public class PipelinesConfigModel
{
public PipelineSettings Settings { get; set; } = new();
public ScheduleDefaults ScheduleDefaults { get; set; } = new();
public Dictionary<string, PipelineModel> Pipelines { get; set; } = new();
}
public class PipelineSettings
{
public string Timezone { get; set; } = "UTC";
}
public class ScheduleDefaults
{
public ScheduleModel Mass { get; set; } = new() { Enabled = true, IntervalMinutes = 10080, PrePurge = true, ReIndex = true };
public ScheduleModel Daily { get; set; } = new() { Enabled = true, IntervalMinutes = 1440 };
public ScheduleModel Hourly { get; set; } = new() { Enabled = true, IntervalMinutes = 60 };
}
public class PipelineModel
{
public PipelineSource Source { get; set; } = new();
public PipelineSchedules Schedules { get; set; } = new();
public PipelineDestination Destination { get; set; } = new();
public string[]? PostScripts { get; set; }
}
public class PipelineSource
{
public string Connection { get; set; } = string.Empty;
public string Query { get; set; } = string.Empty;
public string? MassQuery { get; set; }
public Dictionary<string, ParameterDefinition> Parameters { get; set; } = new();
}
public class ParameterDefinition
{
public string Name { get; set; } = string.Empty;
public string? Format { get; set; }
public string? Source { get; set; }
}
public class PipelineSchedules
{
public ScheduleModel? Mass { get; set; }
public ScheduleModel? Daily { get; set; }
public ScheduleModel? Hourly { get; set; }
}
public class PipelineDestination
{
public string Table { get; set; } = string.Empty;
public string[] MatchColumns { get; set; } = [];
public string[] ExcludeFromUpdate { get; set; } = [];
}
Step 3: Create ScheduleModel
namespace JdeScoping.ConfigManager.Models;
/// <summary>
/// Model for schedule configuration.
/// </summary>
public class ScheduleModel
{
public bool Enabled { get; set; } = true;
public int IntervalMinutes { get; set; } = 60;
public bool PrePurge { get; set; } = false;
public bool ReIndex { get; set; } = false;
}
Step 4: Verify build
Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/
Expected: Build succeeded
Step 5: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Models/
git commit -m "feat(configmanager): add configuration models"
Task 6: Create ConfigFileService with Tests
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/IConfigFileService.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigFileService.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/Services/ConfigFileServiceTests.cs
Step 1: Write the failing test
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class ConfigFileServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly ConfigFileService _sut;
public ConfigFileServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new ConfigFileService(_fileSystem);
}
[Fact]
public async Task LoadAppSettingsAsync_WithValidJson_ReturnsConfigModel()
{
// Arrange
var json = """
{
"DataSync": {
"Enabled": true,
"MaxDegreeOfParallelism": 8
}
}
""";
_fileSystem.ReadAllTextAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(json));
// Act
var result = await _sut.LoadAppSettingsAsync("/config/appsettings.json");
// Assert
result.ShouldNotBeNull();
result.DataSync.Enabled.ShouldBeTrue();
result.DataSync.MaxDegreeOfParallelism.ShouldBe(8);
}
[Fact]
public async Task LoadAppSettingsAsync_WithInvalidJson_ThrowsWithHelpfulMessage()
{
// Arrange
var json = "{ invalid json }";
_fileSystem.ReadAllTextAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(json));
// Act & Assert
var ex = await Should.ThrowAsync<ConfigLoadException>(
() => _sut.LoadAppSettingsAsync("/config/appsettings.json"));
ex.Message.ShouldContain("parse");
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "ConfigFileServiceTests"
Expected: FAIL with "ConfigFileService not found"
Step 3: Create IConfigFileService interface
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for loading and saving configuration files.
/// </summary>
public interface IConfigFileService
{
Task<ConfigModel> LoadAppSettingsAsync(string path, CancellationToken ct = default);
Task<PipelinesConfigModel> LoadPipelinesAsync(string path, CancellationToken ct = default);
Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default);
Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default);
}
Step 4: Create ConfigLoadException
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Exception thrown when configuration file loading fails.
/// </summary>
public class ConfigLoadException : Exception
{
public string FilePath { get; }
public ConfigLoadException(string filePath, string message, Exception? inner = null)
: base(message, inner)
{
FilePath = filePath;
}
}
Step 5: Create ConfigFileService
using System.Text.Json;
using System.Text.Json.Serialization;
using JdeScoping.ConfigManager.Models;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for loading and saving configuration files.
/// </summary>
public class ConfigFileService : IConfigFileService
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<ConfigFileService>? _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true
};
public ConfigFileService(IFileSystem fileSystem, ILogger<ConfigFileService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
public async Task<ConfigModel> LoadAppSettingsAsync(string path, CancellationToken ct = default)
{
_logger?.LogInformation("Loading appsettings from {Path}", path);
try
{
var json = await _fileSystem.ReadAllTextAsync(path, ct);
var config = JsonSerializer.Deserialize<ConfigModel>(json, JsonOptions);
return config ?? new ConfigModel();
}
catch (JsonException ex)
{
throw new ConfigLoadException(path, $"Failed to parse appsettings.json: {ex.Message}", ex);
}
}
public async Task<PipelinesConfigModel> LoadPipelinesAsync(string path, CancellationToken ct = default)
{
_logger?.LogInformation("Loading pipelines from {Path}", path);
try
{
var json = await _fileSystem.ReadAllTextAsync(path, ct);
var config = JsonSerializer.Deserialize<PipelinesConfigModel>(json, JsonOptions);
return config ?? new PipelinesConfigModel();
}
catch (JsonException ex)
{
throw new ConfigLoadException(path, $"Failed to parse pipelines.json: {ex.Message}", ex);
}
}
public async Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default)
{
_logger?.LogInformation("Saving appsettings to {Path}", path);
var json = JsonSerializer.Serialize(config, JsonOptions);
await _fileSystem.WriteAllTextAsync(path, json, ct);
}
public async Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default)
{
_logger?.LogInformation("Saving pipelines to {Path}", path);
var json = JsonSerializer.Serialize(config, JsonOptions);
await _fileSystem.WriteAllTextAsync(path, json, ct);
}
}
Step 6: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "ConfigFileServiceTests"
Expected: PASS
Step 7: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Services/
git add NEW/tests/JdeScoping.ConfigManager.Tests/Services/
git commit -m "feat(configmanager): add ConfigFileService with tests"
Task 7: Create BackupService with Tests
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/IBackupService.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/BackupService.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/Services/BackupServiceTests.cs
Step 1: Write the failing test
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class BackupServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly BackupService _sut;
public BackupServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new BackupService(_fileSystem);
}
[Fact]
public async Task CreateBackupAsync_CreatesTimestampedBackup()
{
// Arrange
var sourcePath = "/config/appsettings.json";
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetDirectoryName(sourcePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(sourcePath).Returns("appsettings");
// Act
var backupPath = await _sut.CreateBackupAsync(sourcePath);
// Assert
backupPath.ShouldStartWith("/config/appsettings.");
backupPath.ShouldEndWith(".bak");
await _fileSystem.Received(1).CopyFileAsync(sourcePath, backupPath, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CleanupOldBackupsAsync_KeepsOnlySpecifiedCount()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
var backups = Enumerable.Range(1, 15)
.Select(i => $"/config/appsettings.2026-01-{i:D2}_120000.bak")
.ToArray();
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backups));
// Act
await _sut.CleanupOldBackupsAsync(filePath, keepCount: 10);
// Assert - should delete 5 oldest backups
await _fileSystem.Received(5).DeleteFileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "BackupServiceTests"
Expected: FAIL with "BackupService not found"
Step 3: Create IBackupService interface
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Represents backup file information.
/// </summary>
public class BackupInfo
{
public required string Path { get; init; }
public required DateTime Timestamp { get; init; }
public required long Size { get; init; }
}
/// <summary>
/// Service for managing configuration file backups.
/// </summary>
public interface IBackupService
{
Task<string> CreateBackupAsync(string filePath, CancellationToken ct = default);
Task<IReadOnlyList<BackupInfo>> GetBackupsAsync(string filePath, CancellationToken ct = default);
Task RestoreBackupAsync(string backupPath, string targetPath, CancellationToken ct = default);
Task CleanupOldBackupsAsync(string filePath, int keepCount = 10, CancellationToken ct = default);
}
Step 4: Create BackupService
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for managing configuration file backups.
/// </summary>
public class BackupService : IBackupService
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<BackupService>? _logger;
private const string TimestampFormat = "yyyy-MM-dd_HHmmss";
public BackupService(IFileSystem fileSystem, ILogger<BackupService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
public async Task<string> CreateBackupAsync(string filePath, CancellationToken ct = default)
{
if (!_fileSystem.FileExists(filePath))
throw new FileNotFoundException("Source file not found", filePath);
var directory = _fileSystem.GetDirectoryName(filePath);
var baseName = _fileSystem.GetFileNameWithoutExtension(filePath);
var timestamp = DateTime.Now.ToString(TimestampFormat);
var backupPath = _fileSystem.Combine(directory, $"{baseName}.{timestamp}.bak");
await _fileSystem.CopyFileAsync(filePath, backupPath, ct);
_logger?.LogInformation("Created backup at {BackupPath}", backupPath);
return backupPath;
}
public async Task<IReadOnlyList<BackupInfo>> GetBackupsAsync(string filePath, CancellationToken ct = default)
{
var directory = _fileSystem.GetDirectoryName(filePath);
var baseName = _fileSystem.GetFileNameWithoutExtension(filePath);
var pattern = $"{baseName}.*.bak";
var files = await _fileSystem.GetFilesAsync(directory, pattern, ct);
var backups = new List<BackupInfo>();
foreach (var file in files)
{
if (TryParseTimestamp(file, baseName, out var timestamp))
{
backups.Add(new BackupInfo
{
Path = file,
Timestamp = timestamp,
Size = 0 // Would need file info for actual size
});
}
}
return backups.OrderByDescending(b => b.Timestamp).ToList();
}
public async Task RestoreBackupAsync(string backupPath, string targetPath, CancellationToken ct = default)
{
_logger?.LogInformation("Restoring backup from {BackupPath} to {TargetPath}", backupPath, targetPath);
await _fileSystem.CopyFileAsync(backupPath, targetPath, ct);
}
public async Task CleanupOldBackupsAsync(string filePath, int keepCount = 10, CancellationToken ct = default)
{
var backups = await GetBackupsAsync(filePath, ct);
var toDelete = backups.Skip(keepCount).ToList();
foreach (var backup in toDelete)
{
await _fileSystem.DeleteFileAsync(backup.Path, ct);
_logger?.LogInformation("Deleted old backup {BackupPath}", backup.Path);
}
}
private bool TryParseTimestamp(string filePath, string baseName, out DateTime timestamp)
{
timestamp = default;
var fileName = _fileSystem.GetFileNameWithoutExtension(filePath);
// Expected format: baseName.yyyy-MM-dd_HHmmss
var prefix = $"{baseName}.";
if (!fileName.StartsWith(prefix))
return false;
var timestampPart = fileName[prefix.Length..];
return DateTime.TryParseExact(timestampPart, TimestampFormat,
CultureInfo.InvariantCulture, DateTimeStyles.None, out timestamp);
}
}
Step 5: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "BackupServiceTests"
Expected: PASS
Step 6: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Services/
git add NEW/tests/JdeScoping.ConfigManager.Tests/Services/
git commit -m "feat(configmanager): add BackupService with tests"
Phase 3: Validation Services
Task 8: Create ValidationService with Tests
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/IValidationService.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/ValidationService.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/Services/ValidationServiceTests.cs
Step 1: Write the failing test
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class ValidationServiceTests
{
private readonly ValidationService _sut;
public ValidationServiceTests()
{
_sut = new ValidationService();
}
[Fact]
public void ValidateAppSettings_WithValidConfig_ReturnsNoErrors()
{
// Arrange
var config = new ConfigModel
{
DataSync = new DataSyncSection { MaxDegreeOfParallelism = 4 }
};
// Act
var result = _sut.ValidateAppSettings(config);
// Assert
result.IsValid.ShouldBeTrue();
result.Errors.ShouldBeEmpty();
}
[Fact]
public void ValidateAppSettings_WithInvalidParallelism_ReturnsError()
{
// Arrange
var config = new ConfigModel
{
DataSync = new DataSyncSection { MaxDegreeOfParallelism = 0 }
};
// Act
var result = _sut.ValidateAppSettings(config);
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("MaxDegreeOfParallelism"));
}
[Fact]
public void ValidatePipelines_WithDuplicateNames_ReturnsError()
{
// Arrange - duplicate keys not possible in dictionary, but empty names are invalid
var config = new PipelinesConfigModel
{
Pipelines = new Dictionary<string, PipelineModel>
{
[""] = new PipelineModel()
}
};
// Act
var result = _sut.ValidatePipelines(config);
// Assert
result.IsValid.ShouldBeFalse();
}
[Fact]
public void ValidatePipelines_WithInvalidConnection_ReturnsError()
{
// Arrange
var config = new PipelinesConfigModel
{
Pipelines = new Dictionary<string, PipelineModel>
{
["Test"] = new PipelineModel
{
Source = new PipelineSource { Connection = "invalid" }
}
}
};
// Act
var result = _sut.ValidatePipelines(config);
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("Connection"));
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "ValidationServiceTests"
Expected: FAIL with "ValidationService not found"
Step 3: Create IValidationService interface
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Result of a validation operation.
/// </summary>
public class ValidationResult
{
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; } = [];
public List<string> Warnings { get; } = [];
public void AddError(string message) => Errors.Add(message);
public void AddWarning(string message) => Warnings.Add(message);
}
/// <summary>
/// Service for validating configuration files.
/// </summary>
public interface IValidationService
{
ValidationResult ValidateAppSettings(ConfigModel config);
ValidationResult ValidatePipelines(PipelinesConfigModel config);
}
Step 4: Create ValidationService
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for validating configuration files.
/// </summary>
public class ValidationService : IValidationService
{
private static readonly string[] ValidConnections = ["jde", "cms", "giw", "lotfinderdb"];
public ValidationResult ValidateAppSettings(ConfigModel config)
{
var result = new ValidationResult();
// DataSync validation
if (config.DataSync.MaxDegreeOfParallelism < 1 || config.DataSync.MaxDegreeOfParallelism > 32)
result.AddError("DataSync.MaxDegreeOfParallelism must be between 1 and 32");
if (config.DataSync.BatchSize < 1000 || config.DataSync.BatchSize > 10_000_000)
result.AddError("DataSync.BatchSize must be between 1,000 and 10,000,000");
if (config.DataSync.BulkCopyBatchSize < 100 || config.DataSync.BulkCopyBatchSize > 100_000)
result.AddError("DataSync.BulkCopyBatchSize must be between 100 and 100,000");
if (config.DataSync.LookbackMultiplier < 1 || config.DataSync.LookbackMultiplier > 10)
result.AddError("DataSync.LookbackMultiplier must be between 1 and 10");
if (config.DataSync.PurgeRetentionDays < 1 || config.DataSync.PurgeRetentionDays > 365)
result.AddError("DataSync.PurgeRetentionDays must be between 1 and 365");
if (config.DataSync.SyncTimeoutSeconds < 60 || config.DataSync.SyncTimeoutSeconds > 86400)
result.AddError("DataSync.SyncTimeoutSeconds must be between 60 and 86,400");
// DataAccess validation
if (config.DataAccess.DefaultTimeoutSeconds < 1)
result.AddError("DataAccess.DefaultTimeoutSeconds must be at least 1");
// Ldap validation
if (config.Ldap.ConnectionTimeoutSeconds < 1 || config.Ldap.ConnectionTimeoutSeconds > 300)
result.AddError("Ldap.ConnectionTimeoutSeconds must be between 1 and 300");
// Search validation
if (config.Search.MaxResultRows < 1)
result.AddError("Search.MaxResultRows must be at least 1");
if (config.Search.MaxConcurrentSearches < 1)
result.AddError("Search.MaxConcurrentSearches must be at least 1");
return result;
}
public ValidationResult ValidatePipelines(PipelinesConfigModel config)
{
var result = new ValidationResult();
foreach (var (name, pipeline) in config.Pipelines)
{
if (string.IsNullOrWhiteSpace(name))
{
result.AddError("Pipeline name cannot be empty");
continue;
}
// Source validation
if (string.IsNullOrWhiteSpace(pipeline.Source.Connection))
{
result.AddError($"Pipeline '{name}': Source.Connection is required");
}
else if (!ValidConnections.Contains(pipeline.Source.Connection.ToLowerInvariant()))
{
result.AddError($"Pipeline '{name}': Source.Connection '{pipeline.Source.Connection}' is not valid. Must be one of: {string.Join(", ", ValidConnections)}");
}
if (string.IsNullOrWhiteSpace(pipeline.Source.Query))
{
result.AddError($"Pipeline '{name}': Source.Query is required");
}
// Destination validation
if (string.IsNullOrWhiteSpace(pipeline.Destination.Table))
{
result.AddError($"Pipeline '{name}': Destination.Table is required");
}
if (pipeline.Destination.MatchColumns.Length == 0)
{
result.AddWarning($"Pipeline '{name}': No MatchColumns specified - all rows will be inserted");
}
// Schedule validation
ValidateSchedule(result, name, "Mass", pipeline.Schedules.Mass, 60);
ValidateSchedule(result, name, "Daily", pipeline.Schedules.Daily, 60);
ValidateSchedule(result, name, "Hourly", pipeline.Schedules.Hourly, 15);
}
return result;
}
private void ValidateSchedule(ValidationResult result, string pipelineName, string scheduleName, ScheduleModel? schedule, int minInterval)
{
if (schedule == null) return;
if (schedule.Enabled && schedule.IntervalMinutes < minInterval)
{
result.AddError($"Pipeline '{pipelineName}': {scheduleName} schedule interval must be at least {minInterval} minutes");
}
}
}
Step 5: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "ValidationServiceTests"
Expected: PASS
Step 6: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Services/
git add NEW/tests/JdeScoping.ConfigManager.Tests/Services/
git commit -m "feat(configmanager): add ValidationService with tests"
Phase 4: Diff Service
Task 9: Create DiffService with Tests
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/IDiffService.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/DiffService.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/Services/DiffServiceTests.cs
Step 1: Write the failing test
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class DiffServiceTests
{
private readonly DiffService _sut;
public DiffServiceTests()
{
_sut = new DiffService();
}
[Fact]
public void GenerateDiff_WithNoChanges_ReturnsEmptyDiff()
{
// Arrange
var original = "line1\nline2\nline3";
var modified = "line1\nline2\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeFalse();
}
[Fact]
public void GenerateDiff_WithChanges_ReturnsDiffLines()
{
// Arrange
var original = "line1\nline2\nline3";
var modified = "line1\nmodified\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Lines.ShouldNotBeEmpty();
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "DiffServiceTests"
Expected: FAIL with "DiffService not found"
Step 3: Create IDiffService interface
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Represents a line in a diff output.
/// </summary>
public class DiffLine
{
public required int? OldLineNumber { get; init; }
public required int? NewLineNumber { get; init; }
public required string Text { get; init; }
public required DiffLineType Type { get; init; }
}
public enum DiffLineType
{
Unchanged,
Added,
Removed
}
/// <summary>
/// Result of a diff operation.
/// </summary>
public class DiffResult
{
public bool HasChanges { get; init; }
public List<DiffLine> Lines { get; init; } = [];
public int Insertions { get; init; }
public int Deletions { get; init; }
}
/// <summary>
/// Service for generating diffs between text content.
/// </summary>
public interface IDiffService
{
DiffResult GenerateDiff(string original, string modified);
}
Step 4: Create DiffService
using DiffPlex;
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for generating diffs between text content.
/// </summary>
public class DiffService : IDiffService
{
public DiffResult GenerateDiff(string original, string modified)
{
var diffBuilder = new InlineDiffBuilder(new Differ());
var diff = diffBuilder.BuildDiffModel(original, modified);
var lines = new List<DiffLine>();
int oldLineNum = 1;
int newLineNum = 1;
int insertions = 0;
int deletions = 0;
foreach (var line in diff.Lines)
{
var diffLine = new DiffLine
{
Text = line.Text,
Type = line.Type switch
{
ChangeType.Inserted => DiffLineType.Added,
ChangeType.Deleted => DiffLineType.Removed,
_ => DiffLineType.Unchanged
},
OldLineNumber = line.Type == ChangeType.Inserted ? null : oldLineNum,
NewLineNumber = line.Type == ChangeType.Deleted ? null : newLineNum
};
lines.Add(diffLine);
switch (line.Type)
{
case ChangeType.Inserted:
newLineNum++;
insertions++;
break;
case ChangeType.Deleted:
oldLineNum++;
deletions++;
break;
default:
oldLineNum++;
newLineNum++;
break;
}
}
return new DiffResult
{
HasChanges = insertions > 0 || deletions > 0,
Lines = lines,
Insertions = insertions,
Deletions = deletions
};
}
}
Step 5: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "DiffServiceTests"
Expected: PASS
Step 6: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Services/
git add NEW/tests/JdeScoping.ConfigManager.Tests/Services/
git commit -m "feat(configmanager): add DiffService with tests"
Phase 5: Auto-Discovery Service
Task 10: Create AutoDiscoveryService with Tests
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/IAutoDiscoveryService.cs - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Services/AutoDiscoveryService.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/Services/AutoDiscoveryServiceTests.cs
Step 1: Write the failing test
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class AutoDiscoveryServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly AutoDiscoveryService _sut;
public AutoDiscoveryServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new AutoDiscoveryService(_fileSystem);
}
[Fact]
public async Task FindConfigFolderAsync_WhenEnvVarSet_ReturnsEnvPath()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config");
_fileSystem.DirectoryExists("/custom/config").Returns(true);
_fileSystem.FileExists("/custom/config/appsettings.json").Returns(true);
try
{
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBe("/custom/config");
}
finally
{
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null);
}
}
[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();
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "AutoDiscoveryServiceTests"
Expected: FAIL with "AutoDiscoveryService not found"
Step 3: Create IAutoDiscoveryService interface
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for auto-discovering configuration file locations.
/// </summary>
public interface IAutoDiscoveryService
{
Task<string?> FindConfigFolderAsync(CancellationToken ct = default);
}
Step 4: Create AutoDiscoveryService
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for auto-discovering configuration file locations.
/// </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");
}
}
}
Step 5: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "AutoDiscoveryServiceTests"
Expected: PASS
Step 6: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Services/
git add NEW/tests/JdeScoping.ConfigManager.Tests/Services/
git commit -m "feat(configmanager): add AutoDiscoveryService with tests"
Phase 6: Basic UI Shell
Task 11: Create MainWindow View
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml - Create:
NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml.cs
Step 1: Create MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels"
x:Class="JdeScoping.ConfigManager.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="JdeScoping ConfigManager"
Width="1200" Height="800"
MinWidth="900" MinHeight="600"
Background="#0D0F12">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<DockPanel>
<!-- Menu Bar -->
<Menu DockPanel.Dock="Top" Background="#151920" Height="28">
<MenuItem Header="_File">
<MenuItem Header="_Open Folder..." Command="{Binding OpenFolderCommand}" InputGesture="Ctrl+O"/>
<MenuItem Header="_Save" Command="{Binding SaveCommand}" InputGesture="Ctrl+S"/>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
<MenuItem Header="_Edit">
<MenuItem Header="_Undo" Command="{Binding UndoCommand}" InputGesture="Ctrl+Z"/>
<MenuItem Header="_Redo" Command="{Binding RedoCommand}" InputGesture="Ctrl+Y"/>
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header="_Validate All" Command="{Binding ValidateCommand}" InputGesture="F5"/>
<MenuItem Header="_Test Connection" Command="{Binding TestConnectionCommand}" InputGesture="F6"/>
<Separator/>
<MenuItem Header="View _Backups..."/>
</MenuItem>
<MenuItem Header="_Help">
<MenuItem Header="_About ConfigManager"/>
</MenuItem>
</Menu>
<!-- Toolbar -->
<Border DockPanel.Dock="Top" Background="#151920" Height="40"
BorderBrush="#2D3540" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Margin="8,0" VerticalAlignment="Center" Spacing="4">
<Button Content="📁 Open" Command="{Binding OpenFolderCommand}" Classes="toolbar"/>
<Button Content="💾 Save" Command="{Binding SaveCommand}" Classes="toolbar"/>
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
<Button Content="↩ Undo" Command="{Binding UndoCommand}" Classes="toolbar"/>
<Button Content="↪ Redo" Command="{Binding RedoCommand}" Classes="toolbar"/>
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
<Button Content="🔌 Test" Command="{Binding TestConnectionCommand}" Classes="toolbar"/>
<Button Content="✓ Validate" Command="{Binding ValidateCommand}" Classes="toolbar"/>
</StackPanel>
</Border>
<!-- Status Bar -->
<Border DockPanel.Dock="Bottom" Background="#151920" Height="24"
BorderBrush="#2D3540" BorderThickness="0,1,0,0">
<Grid Margin="8,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding ConfigFolderPath}"
Foreground="#5C6A7A" FontFamily="JetBrains Mono" FontSize="11"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text=" | Modified"
Foreground="#5C9AFF" FontSize="11"
IsVisible="{Binding HasUnsavedChanges}"
VerticalAlignment="Center" Margin="8,0"/>
<TextBlock Grid.Column="3" Text="{Binding ValidationStatus}"
Foreground="{Binding ValidationStatusColor}"
FontFamily="JetBrains Mono" FontSize="11"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Main Content -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280" MinWidth="200" MaxWidth="400"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Tree View Panel -->
<Border Grid.Column="0" Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="0,0,1,0">
<DockPanel>
<Border DockPanel.Dock="Top" Background="#151920" Height="36">
<TextBlock Text="CONFIGURATION"
Foreground="#5C6A7A" FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="16,0"
LetterSpacing="0.5"/>
</Border>
<TreeView ItemsSource="{Binding TreeNodes}"
SelectedItem="{Binding SelectedNode}"
Background="Transparent"
Margin="8">
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Icon}" FontSize="14"/>
<TextBlock Text="{Binding Name}" Foreground="#E6EDF5"/>
<TextBlock Text="{Binding StatusIcon}" FontSize="12"/>
<TextBlock Text="*" Foreground="#5C9AFF"
IsVisible="{Binding IsModified}"/>
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</Border>
<!-- Splitter -->
<GridSplitter Grid.Column="1" Width="4" Background="Transparent"
ResizeDirection="Columns"/>
<!-- Form Panel -->
<Border Grid.Column="2" Background="#151920" Padding="24">
<ContentControl Content="{Binding SelectedFormViewModel}"/>
</Border>
</Grid>
</DockPanel>
</Window>
Step 2: Create MainWindow.axaml.cs
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
Step 3: Verify build
Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/
Expected: Build succeeded
Step 4: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/Views/
git commit -m "feat(configmanager): add MainWindow view"
Task 12: Create Tree Node ViewModels
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs - Create:
NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs
Step 1: Write the failing test
using JdeScoping.ConfigManager.ViewModels;
namespace JdeScoping.ConfigManager.Tests.ViewModels;
public class TreeNodeViewModelTests
{
[Fact]
public void Constructor_SetsProperties()
{
// Arrange & Act
var node = new TreeNodeViewModel("DataSync", "⟳", TreeNodeType.SettingsSection);
// Assert
node.Name.ShouldBe("DataSync");
node.Icon.ShouldBe("⟳");
node.NodeType.ShouldBe(TreeNodeType.SettingsSection);
}
[Fact]
public void IsModified_WhenSet_RaisesPropertyChanged()
{
// Arrange
var node = new TreeNodeViewModel("Test", "📁", TreeNodeType.Folder);
var propertyChangedRaised = false;
node.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(TreeNodeViewModel.IsModified))
propertyChangedRaised = true;
};
// Act
node.IsModified = true;
// Assert
propertyChangedRaised.ShouldBeTrue();
}
}
Step 2: Run test to verify it fails
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "TreeNodeViewModelTests"
Expected: FAIL with "TreeNodeViewModel not found"
Step 3: Create TreeNodeViewModel
using System.Collections.ObjectModel;
namespace JdeScoping.ConfigManager.ViewModels;
public enum TreeNodeType
{
Folder,
SettingsSection,
Pipeline
}
public enum ValidationState
{
Valid,
Warning,
Error,
Unknown
}
/// <summary>
/// ViewModel for a tree node in the configuration tree.
/// </summary>
public class TreeNodeViewModel : ViewModelBase
{
private bool _isModified;
private bool _isExpanded;
private bool _isSelected;
private ValidationState _validationState = ValidationState.Unknown;
public string Name { get; }
public string Icon { get; }
public TreeNodeType NodeType { get; }
public string? SectionKey { get; init; }
public ObservableCollection<TreeNodeViewModel> Children { get; } = [];
public bool IsModified
{
get => _isModified;
set => SetProperty(ref _isModified, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
public ValidationState ValidationState
{
get => _validationState;
set
{
if (SetProperty(ref _validationState, value))
OnPropertyChanged(nameof(StatusIcon));
}
}
public string StatusIcon => ValidationState switch
{
ValidationState.Valid => "✓",
ValidationState.Warning => "⚠",
ValidationState.Error => "✗",
_ => ""
};
public TreeNodeViewModel(string name, string icon, TreeNodeType nodeType)
{
Name = name;
Icon = icon;
NodeType = nodeType;
}
}
Step 4: Run test to verify it passes
Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "TreeNodeViewModelTests"
Expected: PASS
Step 5: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/
git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/
git commit -m "feat(configmanager): add TreeNodeViewModel"
Task 13: Create MainWindowViewModel
Files:
- Create:
NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs - Modify:
NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs
Step 1: Create MainWindowViewModel
using System.Collections.ObjectModel;
using System.Windows.Input;
using Avalonia.Media;
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.ViewModels;
/// <summary>
/// Main window view model.
/// </summary>
public class MainWindowViewModel : ViewModelBase
{
private readonly IConfigFileService _configFileService;
private readonly IValidationService _validationService;
private readonly IBackupService _backupService;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly ILogger<MainWindowViewModel> _logger;
private string _configFolderPath = "No folder selected";
private bool _hasUnsavedChanges;
private string _validationStatus = "✓ Valid";
private IBrush _validationStatusColor = Brushes.LightGreen;
private TreeNodeViewModel? _selectedNode;
private object? _selectedFormViewModel;
private ConfigModel? _appSettings;
private PipelinesConfigModel? _pipelines;
public string ConfigFolderPath
{
get => _configFolderPath;
set => SetProperty(ref _configFolderPath, value);
}
public bool HasUnsavedChanges
{
get => _hasUnsavedChanges;
set => SetProperty(ref _hasUnsavedChanges, value);
}
public string ValidationStatus
{
get => _validationStatus;
set => SetProperty(ref _validationStatus, value);
}
public IBrush ValidationStatusColor
{
get => _validationStatusColor;
set => SetProperty(ref _validationStatusColor, value);
}
public TreeNodeViewModel? SelectedNode
{
get => _selectedNode;
set
{
if (SetProperty(ref _selectedNode, value))
OnSelectedNodeChanged();
}
}
public object? SelectedFormViewModel
{
get => _selectedFormViewModel;
set => SetProperty(ref _selectedFormViewModel, value);
}
public ObservableCollection<TreeNodeViewModel> TreeNodes { get; } = [];
public ICommand OpenFolderCommand { get; }
public ICommand SaveCommand { get; }
public ICommand ExitCommand { get; }
public ICommand UndoCommand { get; }
public ICommand RedoCommand { get; }
public ICommand ValidateCommand { get; }
public ICommand TestConnectionCommand { get; }
public MainWindowViewModel(
IConfigFileService configFileService,
IValidationService validationService,
IBackupService backupService,
IAutoDiscoveryService autoDiscoveryService,
ILogger<MainWindowViewModel> logger)
{
_configFileService = configFileService;
_validationService = validationService;
_backupService = backupService;
_autoDiscoveryService = autoDiscoveryService;
_logger = logger;
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
SaveCommand = new AsyncRelayCommand(SaveAsync, () => HasUnsavedChanges);
ExitCommand = new RelayCommand(() => Environment.Exit(0));
UndoCommand = new RelayCommand(() => { }, () => false); // TODO: Implement
RedoCommand = new RelayCommand(() => { }, () => false); // TODO: Implement
ValidateCommand = new RelayCommand(Validate);
TestConnectionCommand = new AsyncRelayCommand(TestConnectionAsync);
_ = InitializeAsync();
}
// Design-time constructor
public MainWindowViewModel() : this(
new ConfigFileService(new FileSystem()),
new ValidationService(),
new BackupService(new FileSystem()),
new AutoDiscoveryService(new FileSystem()),
null!)
{
}
private async Task InitializeAsync()
{
var folder = await _autoDiscoveryService.FindConfigFolderAsync();
if (folder != null)
{
await LoadConfigAsync(folder);
}
}
private async Task OpenFolderAsync()
{
// TODO: Show folder picker dialog
_logger?.LogInformation("Open folder requested");
}
private async Task LoadConfigAsync(string folderPath)
{
try
{
ConfigFolderPath = folderPath;
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
var pipelinesPath = Path.Combine(folderPath, "Pipelines", "pipelines.json");
_appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath);
if (File.Exists(pipelinesPath))
{
_pipelines = await _configFileService.LoadPipelinesAsync(pipelinesPath);
}
BuildTreeNodes();
Validate();
_logger?.LogInformation("Loaded configuration from {Path}", folderPath);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to load configuration from {Path}", folderPath);
}
}
private void BuildTreeNodes()
{
TreeNodes.Clear();
// Settings folder
var settingsFolder = new TreeNodeViewModel("Settings", "📁", TreeNodeType.Folder) { IsExpanded = true };
settingsFolder.Children.Add(new TreeNodeViewModel("DataSync", "⟳", TreeNodeType.SettingsSection) { SectionKey = "DataSync" });
settingsFolder.Children.Add(new TreeNodeViewModel("DataAccess", "⛁", TreeNodeType.SettingsSection) { SectionKey = "DataAccess" });
settingsFolder.Children.Add(new TreeNodeViewModel("Auth", "🔐", TreeNodeType.SettingsSection) { SectionKey = "Auth" });
settingsFolder.Children.Add(new TreeNodeViewModel("Ldap", "👥", TreeNodeType.SettingsSection) { SectionKey = "Ldap" });
settingsFolder.Children.Add(new TreeNodeViewModel("Search", "🔍", TreeNodeType.SettingsSection) { SectionKey = "Search" });
settingsFolder.Children.Add(new TreeNodeViewModel("ExcelExport", "📊", TreeNodeType.SettingsSection) { SectionKey = "ExcelExport" });
TreeNodes.Add(settingsFolder);
// Pipelines folder
var pipelinesFolder = new TreeNodeViewModel("Pipelines", "📁", TreeNodeType.Folder) { IsExpanded = true };
if (_pipelines != null)
{
foreach (var (name, _) in _pipelines.Pipelines)
{
pipelinesFolder.Children.Add(new TreeNodeViewModel(name, "⚡", TreeNodeType.Pipeline) { SectionKey = name });
}
}
TreeNodes.Add(pipelinesFolder);
}
private void OnSelectedNodeChanged()
{
// TODO: Load appropriate form ViewModel based on selected node
SelectedFormViewModel = null;
}
private async Task SaveAsync()
{
if (_appSettings == null) return;
try
{
var appSettingsPath = Path.Combine(ConfigFolderPath, "appsettings.json");
// Create backup
await _backupService.CreateBackupAsync(appSettingsPath);
// Save
await _configFileService.SaveAppSettingsAsync(appSettingsPath, _appSettings);
if (_pipelines != null)
{
var pipelinesPath = Path.Combine(ConfigFolderPath, "Pipelines", "pipelines.json");
await _backupService.CreateBackupAsync(pipelinesPath);
await _configFileService.SavePipelinesAsync(pipelinesPath, _pipelines);
}
HasUnsavedChanges = false;
_logger?.LogInformation("Configuration saved");
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to save configuration");
}
}
private void Validate()
{
var errors = 0;
var warnings = 0;
if (_appSettings != null)
{
var result = _validationService.ValidateAppSettings(_appSettings);
errors += result.Errors.Count;
warnings += result.Warnings.Count;
}
if (_pipelines != null)
{
var result = _validationService.ValidatePipelines(_pipelines);
errors += result.Errors.Count;
warnings += result.Warnings.Count;
}
if (errors > 0)
{
ValidationStatus = $"✗ {errors} errors, {warnings} warnings";
ValidationStatusColor = new SolidColorBrush(Color.Parse("#FF6B6B"));
}
else if (warnings > 0)
{
ValidationStatus = $"⚠ {warnings} warnings";
ValidationStatusColor = new SolidColorBrush(Color.Parse("#FFB84D"));
}
else
{
ValidationStatus = "✓ Valid";
ValidationStatusColor = new SolidColorBrush(Color.Parse("#3DD68C"));
}
}
private async Task TestConnectionAsync()
{
// TODO: Implement connection testing
_logger?.LogInformation("Test connection requested");
}
}
Step 2: Update App.axaml.cs to register services
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using JdeScoping.ConfigManager.Services;
using JdeScoping.ConfigManager.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager;
public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new Views.MainWindow
{
DataContext = Services.GetRequiredService<MainWindowViewModel>()
};
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureServices(IServiceCollection services)
{
// Logging
services.AddLogging(builder => builder
.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
// Services
services.AddSingleton<IFileSystem, FileSystem>();
services.AddSingleton<IAutoDiscoveryService, AutoDiscoveryService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<IDiffService, DiffService>();
services.AddSingleton<IValidationService, ValidationService>();
services.AddScoped<IConfigFileService, ConfigFileService>();
// ViewModels
services.AddTransient<MainWindowViewModel>();
}
}
Step 3: Verify build
Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/
Expected: Build succeeded
Step 4: Commit
git add NEW/src/Utils/JdeScoping.ConfigManager/
git commit -m "feat(configmanager): add MainWindowViewModel with service wiring"
Phase 7: Form ViewModels (Remaining Tasks)
Task 14-20: Create Section Form ViewModels
For each settings section (DataSync, DataAccess, Auth, Ldap, Search, ExcelExport) and Pipeline forms:
- Create
{Section}FormViewModel.cswith properties bound to the model - Create corresponding XAML view
- Wire up two-way binding with change tracking
- Add validation display
Pattern to follow:
public class DataSyncFormViewModel : ViewModelBase
{
private readonly DataSyncSection _model;
private readonly Action _onChanged;
public int MaxDegreeOfParallelism
{
get => _model.MaxDegreeOfParallelism;
set
{
if (_model.MaxDegreeOfParallelism != value)
{
_model.MaxDegreeOfParallelism = value;
OnPropertyChanged();
_onChanged();
}
}
}
// ... other properties
}
Phase 8: Dialogs
Task 21: Create Diff Preview Dialog
Task 22: Create Validation Results Dialog
Task 23: Create Folder Picker Integration
Phase 9: Polish & Integration
Task 24: Add Keyboard Shortcuts
Task 25: Add Dark Theme Styling
Task 26: Integration Testing
Task 27: Final Documentation
Summary
Total Tasks: 27 Estimated Commits: 25+
Key Patterns:
- TDD for all services
- MVVM with ViewModelBase
- IFileSystem abstraction for testability
- NSubstitute for mocking
- Shouldly for assertions
References:
- Design spec:
docs/designs/configmanager-ui-design.md - Design plan:
docs/plans/2026-01-19-config-manager-design.md - Reference app:
NEW/src/Utils/JdeScoping.SecureStoreManager/