# 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** ```xml WinExe net10.0 enable enable true app.manifest true ``` **Step 2: Create Program.cs** ```csharp using Avalonia; namespace JdeScoping.ConfigManager; class Program { [STAThread] public static void Main(string[] args) => BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); } ``` **Step 3: Create App.axaml** ```xml ``` **Step 4: Create App.axaml.cs** ```csharp 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 ``` **Step 6: Verify project builds** Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/` Expected: Build succeeded **Step 7: Commit** ```bash 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** ```xml net10.0 enable enable false true ``` **Step 2: Create GlobalUsings.cs** ```csharp 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** ```bash 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** ```csharp 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** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Abstraction for file system operations to enable testing. /// public interface IFileSystem { bool FileExists(string path); bool DirectoryExists(string path); Task ReadAllTextAsync(string path, CancellationToken ct = default); Task WriteAllTextAsync(string path, string content, CancellationToken ct = default); Task 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** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Real file system implementation. /// public class FileSystem : IFileSystem { public bool FileExists(string path) => File.Exists(path); public bool DirectoryExists(string path) => Directory.Exists(path); public async Task 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 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** ```bash 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** ```csharp using System.ComponentModel; using System.Runtime.CompilerServices; namespace JdeScoping.ConfigManager.ViewModels; /// /// Base class for all view models providing INotifyPropertyChanged implementation. /// 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(ref T field, T value, [CallerMemberName] string? propertyName = null) { if (EqualityComparer.Default.Equals(field, value)) return false; field = value; OnPropertyChanged(propertyName); return true; } } ``` **Step 2: Create RelayCommand** ```csharp using System.Windows.Input; namespace JdeScoping.ConfigManager.ViewModels; /// /// A command implementation that delegates to action methods. /// public class RelayCommand : ICommand { private readonly Action _execute; private readonly Predicate? _canExecute; private EventHandler? _canExecuteChanged; public event EventHandler? CanExecuteChanged { add => _canExecuteChanged += value; remove => _canExecuteChanged -= value; } public RelayCommand(Action execute, Predicate? canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } public RelayCommand(Action execute, Func? 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** ```csharp using System.Windows.Input; namespace JdeScoping.ConfigManager.ViewModels; /// /// An async command implementation that properly handles async operations. /// public class AsyncRelayCommand : ICommand { private readonly Func _execute; private readonly Func? _canExecute; private bool _isExecuting; private EventHandler? _canExecuteChanged; public event EventHandler? CanExecuteChanged { add => _canExecuteChanged += value; remove => _canExecuteChanged -= value; } public AsyncRelayCommand(Func execute, Func? 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** ```bash 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** ```csharp using System.Text.Json.Serialization; namespace JdeScoping.ConfigManager.Models; /// /// Root model for appsettings.json configuration. /// 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 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** ```csharp namespace JdeScoping.ConfigManager.Models; /// /// Root model for pipelines.json configuration. /// public class PipelinesConfigModel { public PipelineSettings Settings { get; set; } = new(); public ScheduleDefaults ScheduleDefaults { get; set; } = new(); public Dictionary 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 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** ```csharp namespace JdeScoping.ConfigManager.Models; /// /// Model for schedule configuration. /// 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** ```bash 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** ```csharp 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(); _sut = new ConfigFileService(_fileSystem); } [Fact] public async Task LoadAppSettingsAsync_WithValidJson_ReturnsConfigModel() { // Arrange var json = """ { "DataSync": { "Enabled": true, "MaxDegreeOfParallelism": 8 } } """; _fileSystem.ReadAllTextAsync(Arg.Any(), Arg.Any()) .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(), Arg.Any()) .Returns(Task.FromResult(json)); // Act & Assert var ex = await Should.ThrowAsync( () => _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** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.Services; /// /// Service for loading and saving configuration files. /// public interface IConfigFileService { Task LoadAppSettingsAsync(string path, CancellationToken ct = default); Task 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** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Exception thrown when configuration file loading fails. /// 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** ```csharp using System.Text.Json; using System.Text.Json.Serialization; using JdeScoping.ConfigManager.Models; using Microsoft.Extensions.Logging; namespace JdeScoping.ConfigManager.Services; /// /// Service for loading and saving configuration files. /// public class ConfigFileService : IConfigFileService { private readonly IFileSystem _fileSystem; private readonly ILogger? _logger; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true }; public ConfigFileService(IFileSystem fileSystem, ILogger? logger = null) { _fileSystem = fileSystem; _logger = logger; } public async Task 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(json, JsonOptions); return config ?? new ConfigModel(); } catch (JsonException ex) { throw new ConfigLoadException(path, $"Failed to parse appsettings.json: {ex.Message}", ex); } } public async Task 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(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** ```bash 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** ```csharp 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(); _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()); } [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()) .Returns(Task.FromResult(backups)); // Act await _sut.CleanupOldBackupsAsync(filePath, keepCount: 10); // Assert - should delete 5 oldest backups await _fileSystem.Received(5).DeleteFileAsync(Arg.Any(), Arg.Any()); } } ``` **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** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Represents backup file information. /// public class BackupInfo { public required string Path { get; init; } public required DateTime Timestamp { get; init; } public required long Size { get; init; } } /// /// Service for managing configuration file backups. /// public interface IBackupService { Task CreateBackupAsync(string filePath, CancellationToken ct = default); Task> 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** ```csharp using System.Globalization; using Microsoft.Extensions.Logging; namespace JdeScoping.ConfigManager.Services; /// /// Service for managing configuration file backups. /// public class BackupService : IBackupService { private readonly IFileSystem _fileSystem; private readonly ILogger? _logger; private const string TimestampFormat = "yyyy-MM-dd_HHmmss"; public BackupService(IFileSystem fileSystem, ILogger? logger = null) { _fileSystem = fileSystem; _logger = logger; } public async Task 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> 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(); 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** ```bash 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** ```csharp 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 { [""] = 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 { ["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** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.Services; /// /// Result of a validation operation. /// public class ValidationResult { public bool IsValid => Errors.Count == 0; public List Errors { get; } = []; public List Warnings { get; } = []; public void AddError(string message) => Errors.Add(message); public void AddWarning(string message) => Warnings.Add(message); } /// /// Service for validating configuration files. /// public interface IValidationService { ValidationResult ValidateAppSettings(ConfigModel config); ValidationResult ValidatePipelines(PipelinesConfigModel config); } ``` **Step 4: Create ValidationService** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.Services; /// /// Service for validating configuration files. /// 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** ```bash 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** ```csharp 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** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Represents a line in a diff output. /// 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 } /// /// Result of a diff operation. /// public class DiffResult { public bool HasChanges { get; init; } public List Lines { get; init; } = []; public int Insertions { get; init; } public int Deletions { get; init; } } /// /// Service for generating diffs between text content. /// public interface IDiffService { DiffResult GenerateDiff(string original, string modified); } ``` **Step 4: Create DiffService** ```csharp using DiffPlex; using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder.Model; namespace JdeScoping.ConfigManager.Services; /// /// Service for generating diffs between text content. /// 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(); 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** ```bash 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** ```csharp 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(); _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()).Returns(false); _fileSystem.FileExists(Arg.Any()).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** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Service for auto-discovering configuration file locations. /// public interface IAutoDiscoveryService { Task FindConfigFolderAsync(CancellationToken ct = default); } ``` **Step 4: Create AutoDiscoveryService** ```csharp using Microsoft.Extensions.Logging; namespace JdeScoping.ConfigManager.Services; /// /// Service for auto-discovering configuration file locations. /// public class AutoDiscoveryService : IAutoDiscoveryService { private readonly IFileSystem _fileSystem; private readonly ILogger? _logger; private const string EnvVarName = "JDESCOPING_CONFIG_PATH"; private const string AppSettingsFileName = "appsettings.json"; public AutoDiscoveryService(IFileSystem fileSystem, ILogger? logger = null) { _fileSystem = fileSystem; _logger = logger; } public Task FindConfigFolderAsync(CancellationToken ct = default) { // 1. Check environment variable var envPath = Environment.GetEnvironmentVariable(EnvVarName); if (!string.IsNullOrEmpty(envPath) && IsValidConfigFolder(envPath)) { _logger?.LogInformation("Found config folder from environment variable: {Path}", envPath); return Task.FromResult(envPath); } // 2. Check same directory as executable var exeDir = AppContext.BaseDirectory; if (IsValidConfigFolder(exeDir)) { _logger?.LogInformation("Found config folder in executable directory: {Path}", exeDir); return Task.FromResult(exeDir); } // 3. Check ../JdeScoping.Host/ relative to executable var hostDir = _fileSystem.Combine(exeDir, "..", "JdeScoping.Host"); if (IsValidConfigFolder(hostDir)) { _logger?.LogInformation("Found config folder in host directory: {Path}", hostDir); return Task.FromResult(hostDir); } // 4. Check user config directory var userConfigDir = GetUserConfigDirectory(); if (userConfigDir != null && IsValidConfigFolder(userConfigDir)) { _logger?.LogInformation("Found config folder in user directory: {Path}", userConfigDir); return Task.FromResult(userConfigDir); } _logger?.LogWarning("Could not find config folder in any standard location"); return Task.FromResult(null); } private bool IsValidConfigFolder(string path) { if (!_fileSystem.DirectoryExists(path)) return false; var appSettingsPath = _fileSystem.Combine(path, AppSettingsFileName); return _fileSystem.FileExists(appSettingsPath); } private string? GetUserConfigDirectory() { if (OperatingSystem.IsWindows()) { var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); return _fileSystem.Combine(localAppData, "JdeScoping"); } else { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); return _fileSystem.Combine(home, ".jdescoping"); } } } ``` **Step 5: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "AutoDiscoveryServiceTests"` Expected: PASS **Step 6: Commit** ```bash 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** ```xml