Files
jdescopingtool/docs/plans/2026-01-19-configmanager-implementation.md
T
Joseph Doherty d49330e697 docs: add XML documentation and ConfigManager implementation plans
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.
2026-01-20 02:26:26 -05:00

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:

  1. Create {Section}FormViewModel.cs with properties bound to the model
  2. Create corresponding XAML view
  3. Wire up two-way binding with change tracking
  4. 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/