d49330e697
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.
2485 lines
78 KiB
Markdown
2485 lines
78 KiB
Markdown
# 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
|
|
<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```xml
|
|
<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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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;
|
|
|
|
/// <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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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;
|
|
|
|
/// <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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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;
|
|
|
|
/// <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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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;
|
|
|
|
/// <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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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:**
|
|
|
|
```csharp
|
|
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/`
|