From 033577993292a25c6ec34b0f763b91887581156e Mon Sep 17 00:00:00 2001 From: Satyam Kulkarni Date: Fri, 19 Jun 2026 20:28:42 +0530 Subject: [PATCH 1/3] Add source path metadata to config backups --- src/Interfaces/IConfigBackupService.cs | 1 + src/Program.cs | 1 + src/Services/System/ConfigBackupService.cs | 3 + .../WinHome.Tests/ConfigBackupServiceTests.cs | 95 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 tests/WinHome.Tests/ConfigBackupServiceTests.cs diff --git a/src/Interfaces/IConfigBackupService.cs b/src/Interfaces/IConfigBackupService.cs index df4c4324..bc0684ab 100644 --- a/src/Interfaces/IConfigBackupService.cs +++ b/src/Interfaces/IConfigBackupService.cs @@ -7,6 +7,7 @@ public interface IConfigBackupService Task BackupAsync( string provider, Configuration config, + string sourcePath, string output); Task<(string Provider, object? Settings)> RestoreAsync( diff --git a/src/Program.cs b/src/Program.cs index 7af63449..39e01d42 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -234,6 +234,7 @@ static async Task Main(string[] args) await backupService.BackupAsync( provider, config, + configFile.FullName, path!); logger.LogSuccess($"[Config] Backup created: {path}"); diff --git a/src/Services/System/ConfigBackupService.cs b/src/Services/System/ConfigBackupService.cs index 5213da40..0c21511a 100644 --- a/src/Services/System/ConfigBackupService.cs +++ b/src/Services/System/ConfigBackupService.cs @@ -25,6 +25,7 @@ public ConfigBackupService() public async Task BackupAsync( string provider, Configuration config, + string sourcePath, string output) { if (!config.Extensions.TryGetValue(provider, out var settings)) @@ -37,6 +38,7 @@ public async Task BackupAsync( { Provider = provider, Version = config.Version, + SourcePath = sourcePath, CreatedAt = DateTime.UtcNow, Settings = settings }; @@ -82,6 +84,7 @@ private class ConfigBackupModel { public string Provider { get; set; } = ""; public string Version { get; set; } = ""; + public string SourcePath { get; set; } = ""; public DateTime CreatedAt { get; set; } public object? Settings { get; set; } } diff --git a/tests/WinHome.Tests/ConfigBackupServiceTests.cs b/tests/WinHome.Tests/ConfigBackupServiceTests.cs new file mode 100644 index 00000000..cec0bfe9 --- /dev/null +++ b/tests/WinHome.Tests/ConfigBackupServiceTests.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using WinHome.Models; +using WinHome.Services.System; +using Xunit; + +namespace WinHome.Tests +{ + public class ConfigBackupServiceTests + { + [Fact] + public async Task BackupAsync_CreatesBackupFile() + { + var service = new ConfigBackupService(); + + var config = new Configuration + { + Version = "1.0" + }; + + config.Extensions["test-provider"] = new + { + Theme = "Dark" + }; + + string output = Path.GetTempFileName(); + + try + { + await service.BackupAsync( + "test-provider", + config, + "config.yaml", + output); + + Assert.True(File.Exists(output)); + } + finally + { + if (File.Exists(output)) + { + File.Delete(output); + } + } + } + + [Fact] + public async Task RestoreAsync_MissingFile_ThrowsFileNotFoundException() + { + var service = new ConfigBackupService(); + + await Assert.ThrowsAsync( + () => service.RestoreAsync("does-not-exist.yaml")); + } + + [Fact] + public async Task BackupAsync_StoresSourcePathMetadata() + { + var service = new ConfigBackupService(); + + var config = new Configuration + { + Version = "1.0" + }; + + config.Extensions["test-provider"] = new + { + Theme = "Dark" + }; + + string output = Path.GetTempFileName(); + + try + { + await service.BackupAsync( + "test-provider", + config, + "config.yaml", + output); + + string content = await File.ReadAllTextAsync(output); + + Assert.Contains("sourcePath: config.yaml", content); + } + finally + { + if (File.Exists(output)) + { + File.Delete(output); + } + } + } + } +} \ No newline at end of file From 001d9232bed3d0320f7b50134515b32e4ecf54c8 Mon Sep 17 00:00:00 2001 From: Satyam Kulkarni Date: Sun, 21 Jun 2026 11:39:26 +0530 Subject: [PATCH 2/3] feat: add configuration drift detection and reporting --- src/Infrastructure/AppHost.cs | 1 + src/Infrastructure/CliBuilder.cs | 36 +++++- src/Interfaces/IConfigDriftService.cs | 9 ++ src/Models/ConfigDriftResult.cs | 12 ++ src/Program.cs | 21 ++++ src/Services/System/ConfigDriftService.cs | 98 +++++++++++++++++ .../WinHome.Tests/ConfigDriftServiceTests.cs | 104 ++++++++++++++++++ 7 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 src/Interfaces/IConfigDriftService.cs create mode 100644 src/Models/ConfigDriftResult.cs create mode 100644 src/Services/System/ConfigDriftService.cs create mode 100644 tests/WinHome.Tests/ConfigDriftServiceTests.cs diff --git a/src/Infrastructure/AppHost.cs b/src/Infrastructure/AppHost.cs index 079ce00f..85577c92 100644 --- a/src/Infrastructure/AppHost.cs +++ b/src/Infrastructure/AppHost.cs @@ -56,6 +56,7 @@ public static void ConfigureServices(IConfiguration configuration, IServiceColle // Domain Services services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure/CliBuilder.cs b/src/Infrastructure/CliBuilder.cs index 322cdab7..7eb58725 100644 --- a/src/Infrastructure/CliBuilder.cs +++ b/src/Infrastructure/CliBuilder.cs @@ -15,14 +15,14 @@ public static class CliBuilder /// Constructs the root command with all options, subcommands (run, generate, state, completion), and their handlers. /// Handler for the default run action (applies configuration). /// Handler for the generate subcommand (generates config from system state). - /// Handler for configuration backup and restore. + /// Handler for configuration backup and restore. /// Handler for the state subcommand (manages tracking state). /// The configured root . public static RootCommand BuildRootCommand( Func> runAction, Func> generateAction, Func> stateAction, - Func>? configBackupAction = null) + Func>? configAction = null) { var configOption = new Option("--config"); configOption.Description = "Path to the YAML configuration file"; @@ -276,9 +276,9 @@ public static RootCommand BuildRootCommand( var provider = result.GetValue(providerArgument)!; var output = result.GetValue(backupOutputOption)!; - return configBackupAction == null + return configAction == null ? 1 - : await configBackupAction( + : await configAction( provider, output, null, @@ -300,18 +300,42 @@ public static RootCommand BuildRootCommand( { var input = result.GetValue(restoreInput)!; - return configBackupAction == null + return configAction == null ? 1 - : await configBackupAction( + : await configAction( "restore", input, null, ComputeLogLevel(false, false)); }); + var configDriftCommand = new Command("drift"); + configDriftCommand.Description = "Detect configuration drift"; + + var driftInput = new Argument("input") + { + Description = "Backup file path" + }; + + configDriftCommand.Arguments.Add(driftInput); + + configDriftCommand.SetAction(async (ParseResult result) => + { + var input = result.GetValue(driftInput)!; + + return configAction == null + ? 1 + : await configAction( + "drift", + input, + null, + ComputeLogLevel(false, false)); + }); + configCommand.Subcommands.Add(configBackupCommand); configCommand.Subcommands.Add(configRestoreCommand); + configCommand.Subcommands.Add(configDriftCommand); rootCommand.Add(configCommand); diff --git a/src/Interfaces/IConfigDriftService.cs b/src/Interfaces/IConfigDriftService.cs new file mode 100644 index 00000000..5cccc5dc --- /dev/null +++ b/src/Interfaces/IConfigDriftService.cs @@ -0,0 +1,9 @@ +using WinHome.Models; + +namespace WinHome.Interfaces +{ + public interface IConfigDriftService + { + Task> DetectDriftAsync(string backupFile); + } +} diff --git a/src/Models/ConfigDriftResult.cs b/src/Models/ConfigDriftResult.cs new file mode 100644 index 00000000..e92aa0c5 --- /dev/null +++ b/src/Models/ConfigDriftResult.cs @@ -0,0 +1,12 @@ +namespace WinHome.Models; + +public class ConfigDriftResult +{ + public string Provider { get; set; } = ""; + + public string Key { get; set; } = ""; + + public string? Expected { get; set; } + + public string? Actual { get; set; } +} diff --git a/src/Program.cs b/src/Program.cs index 39e01d42..a9f3cd10 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -159,9 +159,30 @@ static async Task Main(string[] args) logger.SetMinLevel(minLogLevel); var backupService = host.Services.GetRequiredService(); + var driftService = host.Services.GetRequiredService(); try { + if (provider == "drift") + { + var drifts = await driftService.DetectDriftAsync(path!); + + if (drifts.Count == 0) + { + logger.LogSuccess("[Config] No configuration drift detected."); + return 0; + } + + foreach (var drift in drifts) + { + logger.LogWarning( + $"[DRIFT] {drift.Provider}.{drift.Key} Expected='{drift.Expected}' Actual='{drift.Actual}'"); + } + + logger.LogWarning($"[Config] {drifts.Count} drift(s) detected."); + + return 0; + } if (provider == "restore") { var (restoredProvider, restoredSettings) = diff --git a/src/Services/System/ConfigDriftService.cs b/src/Services/System/ConfigDriftService.cs new file mode 100644 index 00000000..344b936f --- /dev/null +++ b/src/Services/System/ConfigDriftService.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using WinHome.Interfaces; +using WinHome.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace WinHome.Services.System +{ + public class ConfigDriftService : IConfigDriftService + { + private readonly IDeserializer _deserializer; + + public ConfigDriftService() + { + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + } + + public async Task> DetectDriftAsync(string backupFile) + { + var drifts = new List(); + + if (!File.Exists(backupFile)) + { + throw new FileNotFoundException("Backup file not found.", backupFile); + } + + var backupYaml = await File.ReadAllTextAsync(backupFile); + + var backup = + _deserializer.Deserialize(backupYaml); + + if (backup == null) + { + return drifts; + } + + if (!File.Exists("config.yaml")) + { + throw new FileNotFoundException("config.yaml not found."); + } + + var configYaml = await File.ReadAllTextAsync("config.yaml"); + + var config = + _deserializer.Deserialize(configYaml); + + if (config == null) + { + return drifts; + } + + if (!config.Extensions.TryGetValue( + backup.Provider, + out var currentSettings)) + { + drifts.Add(new ConfigDriftResult + { + Provider = backup.Provider, + Key = "__provider__", + Expected = "Present", + Actual = "Missing" + }); + + return drifts; + } + + string expectedJson = + JsonSerializer.Serialize(backup.Settings); + + string actualJson = + JsonSerializer.Serialize(currentSettings); + + if (expectedJson != actualJson) + { + drifts.Add(new ConfigDriftResult + { + Provider = backup.Provider, + Key = "settings", + Expected = expectedJson, + Actual = actualJson + }); + } + + return drifts; + } + + private class ConfigBackupModel + { + public string Provider { get; set; } = ""; + public string Version { get; set; } = ""; + public string SourcePath { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public object? Settings { get; set; } + } + } +} diff --git a/tests/WinHome.Tests/ConfigDriftServiceTests.cs b/tests/WinHome.Tests/ConfigDriftServiceTests.cs new file mode 100644 index 00000000..88feab4a --- /dev/null +++ b/tests/WinHome.Tests/ConfigDriftServiceTests.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using WinHome.Services.System; +using Xunit; + +namespace WinHome.Tests +{ + public class ConfigDriftServiceTests + { + [Fact] + public async Task DetectDriftAsync_MissingBackup_Throws() + { + var service = new ConfigDriftService(); + + await Assert.ThrowsAsync( + () => service.DetectDriftAsync("missing.yaml")); + } + + [Fact] + public async Task DetectDriftAsync_NoDrift_ReturnsEmptyList() + { + var service = new ConfigDriftService(); + + string backupFile = Path.GetTempFileName(); + string configFile = Path.Combine( + Directory.GetCurrentDirectory(), + "config.yaml"); + + try + { + await File.WriteAllTextAsync( + backupFile, + "provider: test-provider\n" + + "version: 1.0\n" + + "sourcePath: config.yaml\n" + + "createdAt: 2026-01-01\n" + + "settings:\n" + + " theme: Dark\n"); + + await File.WriteAllTextAsync( + configFile, + "version: 1.0\n" + + "extensions:\n" + + " test-provider:\n" + + " theme: Dark\n"); + + var result = await service.DetectDriftAsync(backupFile); + + Assert.Empty(result); + } + finally + { + if (File.Exists(backupFile)) + File.Delete(backupFile); + + if (File.Exists(configFile)) + File.Delete(configFile); + } + } + + [Fact] + public async Task DetectDriftAsync_WhenSettingsDiffer_ReturnsDrift() + { + var service = new ConfigDriftService(); + + string backupFile = Path.GetTempFileName(); + string configFile = Path.Combine( + Directory.GetCurrentDirectory(), + "config.yaml"); + + try + { + await File.WriteAllTextAsync( + backupFile, + "provider: test-provider\n" + + "version: 1.0\n" + + "sourcePath: config.yaml\n" + + "createdAt: 2026-01-01\n" + + "settings:\n" + + " theme: Dark\n"); + + await File.WriteAllTextAsync( + configFile, + "version: 1.0\n" + + "extensions:\n" + + " test-provider:\n" + + " theme: Light\n"); + + var result = await service.DetectDriftAsync(backupFile); + + Assert.Single(result); + } + finally + { + if (File.Exists(backupFile)) + File.Delete(backupFile); + + if (File.Exists(configFile)) + File.Delete(configFile); + } + } + } +} \ No newline at end of file From ca8a962c935e233e0bb36b03b50c5d61bc07a329 Mon Sep 17 00:00:00 2001 From: Satyam Kulkarni Date: Sun, 21 Jun 2026 11:54:52 +0530 Subject: [PATCH 3/3] style: apply code formatting --- src/Engine.cs | 10 +- src/Interfaces/IConfigDriftService.cs | 18 +- src/Models/ConfigDriftResult.cs | 24 +- src/Models/ResourceBase.cs | 46 +-- src/Program.cs | 2 +- .../Bootstrappers/ChocolateyBootstrapper.cs | 16 +- .../Bootstrappers/ScoopBootstrapper.cs | 16 +- src/Services/DependencyResolver.cs | 232 ++++++++-------- src/Services/System/ConfigDriftService.cs | 196 ++++++------- .../WinHome.Tests/ConfigBackupServiceTests.cs | 190 ++++++------- .../WinHome.Tests/ConfigDriftServiceTests.cs | 208 +++++++------- .../WinHome.Tests/DependencyResolverTests.cs | 262 +++++++++--------- 12 files changed, 610 insertions(+), 610 deletions(-) diff --git a/src/Engine.cs b/src/Engine.cs index 86d85d70..69705768 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -257,11 +257,11 @@ await Task.Run(() => Parallel.ForEach(itemsToRemove, uniqueId => // Passed to DependencyResolver.Sort so cross-type dependsOn references // (e.g. a service depending on an app) are validated without error. var globalResourceIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var r in config.Apps) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); - foreach (var r in config.EnvVars) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); - foreach (var r in config.Dotfiles) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); + foreach (var r in config.Apps) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); + foreach (var r in config.EnvVars) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); + foreach (var r in config.Dotfiles) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); foreach (var r in config.RegistryTweaks) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); - foreach (var r in config.Services) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); + foreach (var r in config.Services) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); foreach (var r in config.ScheduledTasks) if (r.ResourceId is not null) globalResourceIds.Add(r.ResourceId); // Install Apps @@ -734,4 +734,4 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok return false; } } -} \ No newline at end of file +} diff --git a/src/Interfaces/IConfigDriftService.cs b/src/Interfaces/IConfigDriftService.cs index 5cccc5dc..90b10e1a 100644 --- a/src/Interfaces/IConfigDriftService.cs +++ b/src/Interfaces/IConfigDriftService.cs @@ -1,9 +1,9 @@ -using WinHome.Models; - -namespace WinHome.Interfaces -{ - public interface IConfigDriftService - { - Task> DetectDriftAsync(string backupFile); - } -} +using WinHome.Models; + +namespace WinHome.Interfaces +{ + public interface IConfigDriftService + { + Task> DetectDriftAsync(string backupFile); + } +} diff --git a/src/Models/ConfigDriftResult.cs b/src/Models/ConfigDriftResult.cs index e92aa0c5..9ff839e4 100644 --- a/src/Models/ConfigDriftResult.cs +++ b/src/Models/ConfigDriftResult.cs @@ -1,12 +1,12 @@ -namespace WinHome.Models; - -public class ConfigDriftResult -{ - public string Provider { get; set; } = ""; - - public string Key { get; set; } = ""; - - public string? Expected { get; set; } - - public string? Actual { get; set; } -} +namespace WinHome.Models; + +public class ConfigDriftResult +{ + public string Provider { get; set; } = ""; + + public string Key { get; set; } = ""; + + public string? Expected { get; set; } + + public string? Actual { get; set; } +} diff --git a/src/Models/ResourceBase.cs b/src/Models/ResourceBase.cs index 48727b8b..2dc382f1 100644 --- a/src/Models/ResourceBase.cs +++ b/src/Models/ResourceBase.cs @@ -4,29 +4,29 @@ namespace WinHome.Models { + /// + /// Base class for all resource config types that participate in the + /// dependency graph. Provides optional and + /// fields without touching existing functional + /// properties (e.g. AppConfig.Id which is the package identifier). + /// + public abstract class ResourceBase + { /// - /// Base class for all resource config types that participate in the - /// dependency graph. Provides optional and - /// fields without touching existing functional - /// properties (e.g. AppConfig.Id which is the package identifier). + /// Optional unique identifier for this resource within the config file. + /// Used by other resources to reference this one in . + /// Must be unique across ALL resources in the config if specified. /// - public abstract class ResourceBase - { - /// - /// Optional unique identifier for this resource within the config file. - /// Used by other resources to reference this one in . - /// Must be unique across ALL resources in the config if specified. - /// - [YamlMember(Alias = "resourceId")] - [JsonPropertyName("resourceId")] - public string? ResourceId { get; set; } + [YamlMember(Alias = "resourceId")] + [JsonPropertyName("resourceId")] + public string? ResourceId { get; set; } - /// - /// Optional list of values that must be fully - /// applied before this resource is processed. - /// - [YamlMember(Alias = "dependsOn")] - [JsonPropertyName("dependsOn")] - public List? DependsOn { get; set; } - } -} \ No newline at end of file + /// + /// Optional list of values that must be fully + /// applied before this resource is processed. + /// + [YamlMember(Alias = "dependsOn")] + [JsonPropertyName("dependsOn")] + public List? DependsOn { get; set; } + } +} diff --git a/src/Program.cs b/src/Program.cs index a9f3cd10..07a9233f 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -255,7 +255,7 @@ static async Task Main(string[] args) await backupService.BackupAsync( provider, config, - configFile.FullName, + configFile.FullName, path!); logger.LogSuccess($"[Config] Backup created: {path}"); diff --git a/src/Services/Bootstrappers/ChocolateyBootstrapper.cs b/src/Services/Bootstrappers/ChocolateyBootstrapper.cs index bf3289b0..c72954ec 100644 --- a/src/Services/Bootstrappers/ChocolateyBootstrapper.cs +++ b/src/Services/Bootstrappers/ChocolateyBootstrapper.cs @@ -81,14 +81,14 @@ public void Install(bool dryRun) Console.WriteLine($"[Bootstrapper] {Name} installed successfully."); // Issue #392 Fix: Refresh the environment PATH for the current process so it can see the newly installed manager - if (OperatingSystem.IsWindows()) - { - string userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; - string machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; - string newPath = $"{machinePath};{userPath}"; - Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); - } - + if (OperatingSystem.IsWindows()) + { + string userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; + string machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; + string newPath = $"{machinePath};{userPath}"; + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); + } + } } } diff --git a/src/Services/Bootstrappers/ScoopBootstrapper.cs b/src/Services/Bootstrappers/ScoopBootstrapper.cs index 006d6866..e162070e 100644 --- a/src/Services/Bootstrappers/ScoopBootstrapper.cs +++ b/src/Services/Bootstrappers/ScoopBootstrapper.cs @@ -92,13 +92,13 @@ public void Install(bool dryRun) Console.WriteLine($"[Bootstrapper] {Name} installed successfully."); // Issue #392 Fix: Refresh the environment PATH for the current process so it can see the newly installed manager - if (OperatingSystem.IsWindows()) - { - string userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; - string machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; - string newPath = $"{machinePath};{userPath}"; - Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); - } + if (OperatingSystem.IsWindows()) + { + string userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; + string machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; + string newPath = $"{machinePath};{userPath}"; + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); + } } } -} \ No newline at end of file +} diff --git a/src/Services/DependencyResolver.cs b/src/Services/DependencyResolver.cs index beb7cd3b..b8a96f52 100644 --- a/src/Services/DependencyResolver.cs +++ b/src/Services/DependencyResolver.cs @@ -5,136 +5,136 @@ namespace WinHome.Services { + /// + /// Topologically sorts a list of items using + /// Kahn's algorithm, respecting edges. + /// Resources without a are fully + /// backward-compatible and are appended after the sorted group unchanged. + /// + public static class DependencyResolver + { /// - /// Topologically sorts a list of items using - /// Kahn's algorithm, respecting edges. - /// Resources without a are fully - /// backward-compatible and are appended after the sorted group unchanged. + /// Returns sorted so that every resource + /// appears after all resources it declares in + /// . /// - public static class DependencyResolver + /// Any type that extends . + /// The flat list to sort. + /// + /// Optional set of all resource IDs declared across ALL collections in + /// the config. When provided, dependsOn entries are validated + /// against this global set so cross-type references (e.g. a service + /// depending on an app) are recognised as valid rather than causing a + /// missing-ID error. Dependencies on IDs outside the local collection + /// are ignored during local sorting because the engine's fixed pipeline + /// order already guarantees they run first. + /// + /// A new list in dependency order. + /// + /// Thrown when a duplicate resourceId is found, when a + /// dependsOn entry references an ID that exists in neither the + /// local collection nor , or when a + /// circular dependency is detected within the local collection. + /// + public static List Sort(List resources, HashSet? globalIds = null) + where T : ResourceBase { - /// - /// Returns sorted so that every resource - /// appears after all resources it declares in - /// . - /// - /// Any type that extends . - /// The flat list to sort. - /// - /// Optional set of all resource IDs declared across ALL collections in - /// the config. When provided, dependsOn entries are validated - /// against this global set so cross-type references (e.g. a service - /// depending on an app) are recognised as valid rather than causing a - /// missing-ID error. Dependencies on IDs outside the local collection - /// are ignored during local sorting because the engine's fixed pipeline - /// order already guarantees they run first. - /// - /// A new list in dependency order. - /// - /// Thrown when a duplicate resourceId is found, when a - /// dependsOn entry references an ID that exists in neither the - /// local collection nor , or when a - /// circular dependency is detected within the local collection. - /// - public static List Sort(List resources, HashSet? globalIds = null) - where T : ResourceBase - { - // Split into participants (have a ResourceId) and pass-through (no ResourceId) - var participants = resources.Where(r => r.ResourceId is not null).ToList(); - var passThrough = resources.Where(r => r.ResourceId is null).ToList(); + // Split into participants (have a ResourceId) and pass-through (no ResourceId) + var participants = resources.Where(r => r.ResourceId is not null).ToList(); + var passThrough = resources.Where(r => r.ResourceId is null).ToList(); - // Nothing to sort — fast path (also handles backward-compat configs) - if (participants.Count == 0) - return resources; + // Nothing to sort — fast path (also handles backward-compat configs) + if (participants.Count == 0) + return resources; - // Build id → resource map for the LOCAL collection, catching duplicates - var idMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var resource in participants) - { - if (!idMap.TryAdd(resource.ResourceId!, resource)) - throw new InvalidOperationException( - $"[DependencyResolver] Duplicate resourceId '{resource.ResourceId}'. " + - $"Each resourceId must be unique across the entire config."); - } + // Build id → resource map for the LOCAL collection, catching duplicates + var idMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var resource in participants) + { + if (!idMap.TryAdd(resource.ResourceId!, resource)) + throw new InvalidOperationException( + $"[DependencyResolver] Duplicate resourceId '{resource.ResourceId}'. " + + $"Each resourceId must be unique across the entire config."); + } - // Validate all dependsOn entries: - // - Must exist in the local idMap OR in the global set (cross-type reference) - // - If neither, it is a typo/missing id — throw a clear error - foreach (var resource in participants) - { - if (resource.DependsOn is null) continue; - foreach (var dep in resource.DependsOn) - { - var existsLocally = idMap.ContainsKey(dep); - var existsGlobally = globalIds?.Contains(dep) ?? false; - if (!existsLocally && !existsGlobally) - throw new InvalidOperationException( - $"[DependencyResolver] Resource '{resource.ResourceId}' declares " + - $"dependsOn: '{dep}', but no resource with that resourceId exists " + - $"in the configuration."); - } - } + // Validate all dependsOn entries: + // - Must exist in the local idMap OR in the global set (cross-type reference) + // - If neither, it is a typo/missing id — throw a clear error + foreach (var resource in participants) + { + if (resource.DependsOn is null) continue; + foreach (var dep in resource.DependsOn) + { + var existsLocally = idMap.ContainsKey(dep); + var existsGlobally = globalIds?.Contains(dep) ?? false; + if (!existsLocally && !existsGlobally) + throw new InvalidOperationException( + $"[DependencyResolver] Resource '{resource.ResourceId}' declares " + + $"dependsOn: '{dep}', but no resource with that resourceId exists " + + $"in the configuration."); + } + } - // ── Kahn's algorithm ───────────────────────────────────────────── - // Only add graph edges for dependencies that exist in the LOCAL - // collection. Cross-type deps (in globalIds but not idMap) are - // already satisfied by the engine's fixed pipeline execution order. - var inDegree = new Dictionary(StringComparer.OrdinalIgnoreCase); - var adjacency = new Dictionary>(StringComparer.OrdinalIgnoreCase); + // ── Kahn's algorithm ───────────────────────────────────────────── + // Only add graph edges for dependencies that exist in the LOCAL + // collection. Cross-type deps (in globalIds but not idMap) are + // already satisfied by the engine's fixed pipeline execution order. + var inDegree = new Dictionary(StringComparer.OrdinalIgnoreCase); + var adjacency = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var id in idMap.Keys) - { - inDegree[id] = 0; - adjacency[id] = new List(); - } + foreach (var id in idMap.Keys) + { + inDegree[id] = 0; + adjacency[id] = new List(); + } - foreach (var resource in participants) - { - if (resource.DependsOn is null) continue; - foreach (var dep in resource.DependsOn) - { - // Only wire local edges — cross-type deps are skipped here - if (!idMap.ContainsKey(dep)) continue; + foreach (var resource in participants) + { + if (resource.DependsOn is null) continue; + foreach (var dep in resource.DependsOn) + { + // Only wire local edges — cross-type deps are skipped here + if (!idMap.ContainsKey(dep)) continue; - adjacency[dep].Add(resource.ResourceId!); - inDegree[resource.ResourceId!]++; - } - } + adjacency[dep].Add(resource.ResourceId!); + inDegree[resource.ResourceId!]++; + } + } - // Start with every node that has no unresolved local dependencies - var queue = new Queue( - inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); + // Start with every node that has no unresolved local dependencies + var queue = new Queue( + inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); - var sorted = new List(); + var sorted = new List(); - while (queue.Count > 0) - { - var currentId = queue.Dequeue(); - sorted.Add(idMap[currentId]); + while (queue.Count > 0) + { + var currentId = queue.Dequeue(); + sorted.Add(idMap[currentId]); - foreach (var neighborId in adjacency[currentId]) - { - inDegree[neighborId]--; - if (inDegree[neighborId] == 0) - queue.Enqueue(neighborId); - } - } + foreach (var neighborId in adjacency[currentId]) + { + inDegree[neighborId]--; + if (inDegree[neighborId] == 0) + queue.Enqueue(neighborId); + } + } - // If not all participants were sorted, a cycle exists - if (sorted.Count != participants.Count) - { - var cycleIds = inDegree - .Where(kv => kv.Value > 0) - .Select(kv => kv.Key); - throw new InvalidOperationException( - $"[DependencyResolver] Circular dependency detected among resources: " + - $"{string.Join(", ", cycleIds)}. " + - $"Check the 'dependsOn' fields for these resourceIds."); - } + // If not all participants were sorted, a cycle exists + if (sorted.Count != participants.Count) + { + var cycleIds = inDegree + .Where(kv => kv.Value > 0) + .Select(kv => kv.Key); + throw new InvalidOperationException( + $"[DependencyResolver] Circular dependency detected among resources: " + + $"{string.Join(", ", cycleIds)}. " + + $"Check the 'dependsOn' fields for these resourceIds."); + } - // Append pass-through resources at the end (backward-compatible) - sorted.AddRange(passThrough); - return sorted; - } + // Append pass-through resources at the end (backward-compatible) + sorted.AddRange(passThrough); + return sorted; } -} \ No newline at end of file + } +} diff --git a/src/Services/System/ConfigDriftService.cs b/src/Services/System/ConfigDriftService.cs index 344b936f..372611de 100644 --- a/src/Services/System/ConfigDriftService.cs +++ b/src/Services/System/ConfigDriftService.cs @@ -1,98 +1,98 @@ -using System.Text.Json; -using WinHome.Interfaces; -using WinHome.Models; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace WinHome.Services.System -{ - public class ConfigDriftService : IConfigDriftService - { - private readonly IDeserializer _deserializer; - - public ConfigDriftService() - { - _deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - } - - public async Task> DetectDriftAsync(string backupFile) - { - var drifts = new List(); - - if (!File.Exists(backupFile)) - { - throw new FileNotFoundException("Backup file not found.", backupFile); - } - - var backupYaml = await File.ReadAllTextAsync(backupFile); - - var backup = - _deserializer.Deserialize(backupYaml); - - if (backup == null) - { - return drifts; - } - - if (!File.Exists("config.yaml")) - { - throw new FileNotFoundException("config.yaml not found."); - } - - var configYaml = await File.ReadAllTextAsync("config.yaml"); - - var config = - _deserializer.Deserialize(configYaml); - - if (config == null) - { - return drifts; - } - - if (!config.Extensions.TryGetValue( - backup.Provider, - out var currentSettings)) - { - drifts.Add(new ConfigDriftResult - { - Provider = backup.Provider, - Key = "__provider__", - Expected = "Present", - Actual = "Missing" - }); - - return drifts; - } - - string expectedJson = - JsonSerializer.Serialize(backup.Settings); - - string actualJson = - JsonSerializer.Serialize(currentSettings); - - if (expectedJson != actualJson) - { - drifts.Add(new ConfigDriftResult - { - Provider = backup.Provider, - Key = "settings", - Expected = expectedJson, - Actual = actualJson - }); - } - - return drifts; - } - - private class ConfigBackupModel - { - public string Provider { get; set; } = ""; - public string Version { get; set; } = ""; - public string SourcePath { get; set; } = ""; - public DateTime CreatedAt { get; set; } - public object? Settings { get; set; } - } - } -} +using System.Text.Json; +using WinHome.Interfaces; +using WinHome.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace WinHome.Services.System +{ + public class ConfigDriftService : IConfigDriftService + { + private readonly IDeserializer _deserializer; + + public ConfigDriftService() + { + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + } + + public async Task> DetectDriftAsync(string backupFile) + { + var drifts = new List(); + + if (!File.Exists(backupFile)) + { + throw new FileNotFoundException("Backup file not found.", backupFile); + } + + var backupYaml = await File.ReadAllTextAsync(backupFile); + + var backup = + _deserializer.Deserialize(backupYaml); + + if (backup == null) + { + return drifts; + } + + if (!File.Exists("config.yaml")) + { + throw new FileNotFoundException("config.yaml not found."); + } + + var configYaml = await File.ReadAllTextAsync("config.yaml"); + + var config = + _deserializer.Deserialize(configYaml); + + if (config == null) + { + return drifts; + } + + if (!config.Extensions.TryGetValue( + backup.Provider, + out var currentSettings)) + { + drifts.Add(new ConfigDriftResult + { + Provider = backup.Provider, + Key = "__provider__", + Expected = "Present", + Actual = "Missing" + }); + + return drifts; + } + + string expectedJson = + JsonSerializer.Serialize(backup.Settings); + + string actualJson = + JsonSerializer.Serialize(currentSettings); + + if (expectedJson != actualJson) + { + drifts.Add(new ConfigDriftResult + { + Provider = backup.Provider, + Key = "settings", + Expected = expectedJson, + Actual = actualJson + }); + } + + return drifts; + } + + private class ConfigBackupModel + { + public string Provider { get; set; } = ""; + public string Version { get; set; } = ""; + public string SourcePath { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public object? Settings { get; set; } + } + } +} diff --git a/tests/WinHome.Tests/ConfigBackupServiceTests.cs b/tests/WinHome.Tests/ConfigBackupServiceTests.cs index cec0bfe9..cce55ce2 100644 --- a/tests/WinHome.Tests/ConfigBackupServiceTests.cs +++ b/tests/WinHome.Tests/ConfigBackupServiceTests.cs @@ -1,95 +1,95 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using WinHome.Models; -using WinHome.Services.System; -using Xunit; - -namespace WinHome.Tests -{ - public class ConfigBackupServiceTests - { - [Fact] - public async Task BackupAsync_CreatesBackupFile() - { - var service = new ConfigBackupService(); - - var config = new Configuration - { - Version = "1.0" - }; - - config.Extensions["test-provider"] = new - { - Theme = "Dark" - }; - - string output = Path.GetTempFileName(); - - try - { - await service.BackupAsync( - "test-provider", - config, - "config.yaml", - output); - - Assert.True(File.Exists(output)); - } - finally - { - if (File.Exists(output)) - { - File.Delete(output); - } - } - } - - [Fact] - public async Task RestoreAsync_MissingFile_ThrowsFileNotFoundException() - { - var service = new ConfigBackupService(); - - await Assert.ThrowsAsync( - () => service.RestoreAsync("does-not-exist.yaml")); - } - - [Fact] - public async Task BackupAsync_StoresSourcePathMetadata() - { - var service = new ConfigBackupService(); - - var config = new Configuration - { - Version = "1.0" - }; - - config.Extensions["test-provider"] = new - { - Theme = "Dark" - }; - - string output = Path.GetTempFileName(); - - try - { - await service.BackupAsync( - "test-provider", - config, - "config.yaml", - output); - - string content = await File.ReadAllTextAsync(output); - - Assert.Contains("sourcePath: config.yaml", content); - } - finally - { - if (File.Exists(output)) - { - File.Delete(output); - } - } - } - } -} \ No newline at end of file +using System; +using System.IO; +using System.Threading.Tasks; +using WinHome.Models; +using WinHome.Services.System; +using Xunit; + +namespace WinHome.Tests +{ + public class ConfigBackupServiceTests + { + [Fact] + public async Task BackupAsync_CreatesBackupFile() + { + var service = new ConfigBackupService(); + + var config = new Configuration + { + Version = "1.0" + }; + + config.Extensions["test-provider"] = new + { + Theme = "Dark" + }; + + string output = Path.GetTempFileName(); + + try + { + await service.BackupAsync( + "test-provider", + config, + "config.yaml", + output); + + Assert.True(File.Exists(output)); + } + finally + { + if (File.Exists(output)) + { + File.Delete(output); + } + } + } + + [Fact] + public async Task RestoreAsync_MissingFile_ThrowsFileNotFoundException() + { + var service = new ConfigBackupService(); + + await Assert.ThrowsAsync( + () => service.RestoreAsync("does-not-exist.yaml")); + } + + [Fact] + public async Task BackupAsync_StoresSourcePathMetadata() + { + var service = new ConfigBackupService(); + + var config = new Configuration + { + Version = "1.0" + }; + + config.Extensions["test-provider"] = new + { + Theme = "Dark" + }; + + string output = Path.GetTempFileName(); + + try + { + await service.BackupAsync( + "test-provider", + config, + "config.yaml", + output); + + string content = await File.ReadAllTextAsync(output); + + Assert.Contains("sourcePath: config.yaml", content); + } + finally + { + if (File.Exists(output)) + { + File.Delete(output); + } + } + } + } +} diff --git a/tests/WinHome.Tests/ConfigDriftServiceTests.cs b/tests/WinHome.Tests/ConfigDriftServiceTests.cs index 88feab4a..daa5b0f4 100644 --- a/tests/WinHome.Tests/ConfigDriftServiceTests.cs +++ b/tests/WinHome.Tests/ConfigDriftServiceTests.cs @@ -1,104 +1,104 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using WinHome.Services.System; -using Xunit; - -namespace WinHome.Tests -{ - public class ConfigDriftServiceTests - { - [Fact] - public async Task DetectDriftAsync_MissingBackup_Throws() - { - var service = new ConfigDriftService(); - - await Assert.ThrowsAsync( - () => service.DetectDriftAsync("missing.yaml")); - } - - [Fact] - public async Task DetectDriftAsync_NoDrift_ReturnsEmptyList() - { - var service = new ConfigDriftService(); - - string backupFile = Path.GetTempFileName(); - string configFile = Path.Combine( - Directory.GetCurrentDirectory(), - "config.yaml"); - - try - { - await File.WriteAllTextAsync( - backupFile, - "provider: test-provider\n" + - "version: 1.0\n" + - "sourcePath: config.yaml\n" + - "createdAt: 2026-01-01\n" + - "settings:\n" + - " theme: Dark\n"); - - await File.WriteAllTextAsync( - configFile, - "version: 1.0\n" + - "extensions:\n" + - " test-provider:\n" + - " theme: Dark\n"); - - var result = await service.DetectDriftAsync(backupFile); - - Assert.Empty(result); - } - finally - { - if (File.Exists(backupFile)) - File.Delete(backupFile); - - if (File.Exists(configFile)) - File.Delete(configFile); - } - } - - [Fact] - public async Task DetectDriftAsync_WhenSettingsDiffer_ReturnsDrift() - { - var service = new ConfigDriftService(); - - string backupFile = Path.GetTempFileName(); - string configFile = Path.Combine( - Directory.GetCurrentDirectory(), - "config.yaml"); - - try - { - await File.WriteAllTextAsync( - backupFile, - "provider: test-provider\n" + - "version: 1.0\n" + - "sourcePath: config.yaml\n" + - "createdAt: 2026-01-01\n" + - "settings:\n" + - " theme: Dark\n"); - - await File.WriteAllTextAsync( - configFile, - "version: 1.0\n" + - "extensions:\n" + - " test-provider:\n" + - " theme: Light\n"); - - var result = await service.DetectDriftAsync(backupFile); - - Assert.Single(result); - } - finally - { - if (File.Exists(backupFile)) - File.Delete(backupFile); - - if (File.Exists(configFile)) - File.Delete(configFile); - } - } - } -} \ No newline at end of file +using System; +using System.IO; +using System.Threading.Tasks; +using WinHome.Services.System; +using Xunit; + +namespace WinHome.Tests +{ + public class ConfigDriftServiceTests + { + [Fact] + public async Task DetectDriftAsync_MissingBackup_Throws() + { + var service = new ConfigDriftService(); + + await Assert.ThrowsAsync( + () => service.DetectDriftAsync("missing.yaml")); + } + + [Fact] + public async Task DetectDriftAsync_NoDrift_ReturnsEmptyList() + { + var service = new ConfigDriftService(); + + string backupFile = Path.GetTempFileName(); + string configFile = Path.Combine( + Directory.GetCurrentDirectory(), + "config.yaml"); + + try + { + await File.WriteAllTextAsync( + backupFile, + "provider: test-provider\n" + + "version: 1.0\n" + + "sourcePath: config.yaml\n" + + "createdAt: 2026-01-01\n" + + "settings:\n" + + " theme: Dark\n"); + + await File.WriteAllTextAsync( + configFile, + "version: 1.0\n" + + "extensions:\n" + + " test-provider:\n" + + " theme: Dark\n"); + + var result = await service.DetectDriftAsync(backupFile); + + Assert.Empty(result); + } + finally + { + if (File.Exists(backupFile)) + File.Delete(backupFile); + + if (File.Exists(configFile)) + File.Delete(configFile); + } + } + + [Fact] + public async Task DetectDriftAsync_WhenSettingsDiffer_ReturnsDrift() + { + var service = new ConfigDriftService(); + + string backupFile = Path.GetTempFileName(); + string configFile = Path.Combine( + Directory.GetCurrentDirectory(), + "config.yaml"); + + try + { + await File.WriteAllTextAsync( + backupFile, + "provider: test-provider\n" + + "version: 1.0\n" + + "sourcePath: config.yaml\n" + + "createdAt: 2026-01-01\n" + + "settings:\n" + + " theme: Dark\n"); + + await File.WriteAllTextAsync( + configFile, + "version: 1.0\n" + + "extensions:\n" + + " test-provider:\n" + + " theme: Light\n"); + + var result = await service.DetectDriftAsync(backupFile); + + Assert.Single(result); + } + finally + { + if (File.Exists(backupFile)) + File.Delete(backupFile); + + if (File.Exists(configFile)) + File.Delete(configFile); + } + } + } +} diff --git a/tests/WinHome.Tests/DependencyResolverTests.cs b/tests/WinHome.Tests/DependencyResolverTests.cs index bdc80a6b..52c5ea21 100644 --- a/tests/WinHome.Tests/DependencyResolverTests.cs +++ b/tests/WinHome.Tests/DependencyResolverTests.cs @@ -8,91 +8,91 @@ namespace WinHome.Tests { - public class DependencyResolverTests + public class DependencyResolverTests + { + // Minimal concrete ResourceBase for testing — no production coupling + private class TestResource : ResourceBase { - // Minimal concrete ResourceBase for testing — no production coupling - private class TestResource : ResourceBase - { - public string Name { get; set; } = string.Empty; - } + public string Name { get; set; } = string.Empty; + } - private static TestResource R(string name, string? resourceId = null, params string[] dependsOn) - => new TestResource - { - Name = name, - ResourceId = resourceId, - DependsOn = dependsOn.Length > 0 ? new List(dependsOn) : null - }; + private static TestResource R(string name, string? resourceId = null, params string[] dependsOn) + => new TestResource + { + Name = name, + ResourceId = resourceId, + DependsOn = dependsOn.Length > 0 ? new List(dependsOn) : null + }; - // ── Happy-path tests ───────────────────────────────────────────────── + // ── Happy-path tests ───────────────────────────────────────────────── - [Fact] - public void Sort_ResourcesWithoutIds_ReturnUnchanged() - { - var resources = new List + [Fact] + public void Sort_ResourcesWithoutIds_ReturnUnchanged() + { + var resources = new List { R("legacy-1"), R("legacy-2"), R("legacy-3"), }; - var result = DependencyResolver.Sort(resources); + var result = DependencyResolver.Sort(resources); - Assert.Equal(3, result.Count); - Assert.Equal("legacy-1", result[0].Name); - Assert.Equal("legacy-2", result[1].Name); - Assert.Equal("legacy-3", result[2].Name); - } + Assert.Equal(3, result.Count); + Assert.Equal("legacy-1", result[0].Name); + Assert.Equal("legacy-2", result[1].Name); + Assert.Equal("legacy-3", result[2].Name); + } - [Fact] - public void Sort_SingleNode_NoDeps_ReturnsSameOrder() - { - var resources = new List { R("only", "a") }; + [Fact] + public void Sort_SingleNode_NoDeps_ReturnsSameOrder() + { + var resources = new List { R("only", "a") }; - var result = DependencyResolver.Sort(resources); + var result = DependencyResolver.Sort(resources); - Assert.Single(result); - Assert.Equal("a", result[0].ResourceId); - } + Assert.Single(result); + Assert.Equal("a", result[0].ResourceId); + } - [Fact] - public void Sort_TwoNodes_CorrectOrder_WhenDeclaredReversed() - { - // configure-git is declared first but depends on install-git - var resources = new List + [Fact] + public void Sort_TwoNodes_CorrectOrder_WhenDeclaredReversed() + { + // configure-git is declared first but depends on install-git + var resources = new List { R("configure-git", "configure-git", "install-git"), R("install-git", "install-git"), }; - var result = DependencyResolver.Sort(resources); + var result = DependencyResolver.Sort(resources); - Assert.Equal("install-git", result[0].ResourceId); - Assert.Equal("configure-git", result[1].ResourceId); - } + Assert.Equal("install-git", result[0].ResourceId); + Assert.Equal("configure-git", result[1].ResourceId); + } - [Fact] - public void Sort_LinearChain_ABC_SortsCorrectly() - { - var resources = new List + [Fact] + public void Sort_LinearChain_ABC_SortsCorrectly() + { + var resources = new List { R("C", "c", "b"), R("A", "a"), R("B", "b", "a"), }; - var result = DependencyResolver.Sort(resources); + var result = DependencyResolver.Sort(resources); - Assert.Equal("a", result[0].ResourceId); - Assert.Equal("b", result[1].ResourceId); - Assert.Equal("c", result[2].ResourceId); - } + Assert.Equal("a", result[0].ResourceId); + Assert.Equal("b", result[1].ResourceId); + Assert.Equal("c", result[2].ResourceId); + } - [Fact] - public void Sort_DiamondDependency_ResolvedCorrectly() - { - // A → B, A → C, B → D, C → D - var resources = new List + [Fact] + public void Sort_DiamondDependency_ResolvedCorrectly() + { + // A → B, A → C, B → D, C → D + var resources = new List { R("D", "d", "b", "c"), R("B", "b", "a"), @@ -100,21 +100,21 @@ public void Sort_DiamondDependency_ResolvedCorrectly() R("A", "a"), }; - var result = DependencyResolver.Sort(resources); + var result = DependencyResolver.Sort(resources); - // A must be first, D must be last - Assert.Equal("a", result[0].ResourceId); - Assert.Equal("d", result[3].ResourceId); - // B and C appear between A and D (order between them is not mandated) - var middle = new[] { result[1].ResourceId, result[2].ResourceId }; - Assert.Contains("b", middle); - Assert.Contains("c", middle); - } + // A must be first, D must be last + Assert.Equal("a", result[0].ResourceId); + Assert.Equal("d", result[3].ResourceId); + // B and C appear between A and D (order between them is not mandated) + var middle = new[] { result[1].ResourceId, result[2].ResourceId }; + Assert.Contains("b", middle); + Assert.Contains("c", middle); + } - [Fact] - public void Sort_MixedResourcesWithAndWithoutIds_PassThroughAppendedAtEnd() - { - var resources = new List + [Fact] + public void Sort_MixedResourcesWithAndWithoutIds_PassThroughAppendedAtEnd() + { + var resources = new List { R("no-id-1"), R("B", "b", "a"), @@ -122,104 +122,104 @@ public void Sort_MixedResourcesWithAndWithoutIds_PassThroughAppendedAtEnd() R("A", "a"), }; - var result = DependencyResolver.Sort(resources); + var result = DependencyResolver.Sort(resources); - // Sorted participants first - Assert.Equal("a", result[0].ResourceId); - Assert.Equal("b", result[1].ResourceId); - // Pass-through at end, original order preserved - Assert.Null(result[2].ResourceId); - Assert.Equal("no-id-1", result[2].Name); - Assert.Null(result[3].ResourceId); - Assert.Equal("no-id-2", result[3].Name); - } + // Sorted participants first + Assert.Equal("a", result[0].ResourceId); + Assert.Equal("b", result[1].ResourceId); + // Pass-through at end, original order preserved + Assert.Null(result[2].ResourceId); + Assert.Equal("no-id-1", result[2].Name); + Assert.Null(result[3].ResourceId); + Assert.Equal("no-id-2", result[3].Name); + } - [Fact] - public void Sort_EmptyList_ReturnsEmpty() - { - var result = DependencyResolver.Sort(new List()); - Assert.Empty(result); - } + [Fact] + public void Sort_EmptyList_ReturnsEmpty() + { + var result = DependencyResolver.Sort(new List()); + Assert.Empty(result); + } - // ── Error-path tests ───────────────────────────────────────────────── + // ── Error-path tests ───────────────────────────────────────────────── - [Fact] - public void Sort_DirectCycle_ThrowsWithClearMessage() - { - var resources = new List + [Fact] + public void Sort_DirectCycle_ThrowsWithClearMessage() + { + var resources = new List { R("A", "a", "b"), R("B", "b", "a"), }; - var ex = Assert.Throws( - () => DependencyResolver.Sort(resources)); + var ex = Assert.Throws( + () => DependencyResolver.Sort(resources)); - Assert.Contains("Circular dependency", ex.Message); - Assert.Contains("a", ex.Message); - Assert.Contains("b", ex.Message); - } + Assert.Contains("Circular dependency", ex.Message); + Assert.Contains("a", ex.Message); + Assert.Contains("b", ex.Message); + } - [Fact] - public void Sort_IndirectCycle_ThrowsWithClearMessage() - { - // A → B → C → A - var resources = new List + [Fact] + public void Sort_IndirectCycle_ThrowsWithClearMessage() + { + // A → B → C → A + var resources = new List { R("A", "a", "c"), R("B", "b", "a"), R("C", "c", "b"), }; - var ex = Assert.Throws( - () => DependencyResolver.Sort(resources)); + var ex = Assert.Throws( + () => DependencyResolver.Sort(resources)); - Assert.Contains("Circular dependency", ex.Message); - } + Assert.Contains("Circular dependency", ex.Message); + } - [Fact] - public void Sort_MissingDependencyId_ThrowsWithOffendingIds() - { - var resources = new List + [Fact] + public void Sort_MissingDependencyId_ThrowsWithOffendingIds() + { + var resources = new List { R("A", "a", "nonexistent"), }; - var ex = Assert.Throws( - () => DependencyResolver.Sort(resources)); + var ex = Assert.Throws( + () => DependencyResolver.Sort(resources)); - Assert.Contains("nonexistent", ex.Message); - Assert.Contains("a", ex.Message); - } + Assert.Contains("nonexistent", ex.Message); + Assert.Contains("a", ex.Message); + } - [Fact] - public void Sort_DuplicateResourceId_Throws() - { - var resources = new List + [Fact] + public void Sort_DuplicateResourceId_Throws() + { + var resources = new List { R("First", "duplicate-id"), R("Second", "duplicate-id"), }; - var ex = Assert.Throws( - () => DependencyResolver.Sort(resources)); + var ex = Assert.Throws( + () => DependencyResolver.Sort(resources)); - Assert.Contains("duplicate-id", ex.Message); - Assert.Contains("Duplicate", ex.Message); - } + Assert.Contains("duplicate-id", ex.Message); + Assert.Contains("Duplicate", ex.Message); + } - [Fact] - public void Sort_SelfReference_ThrowsCycleError() - { - var resources = new List + [Fact] + public void Sort_SelfReference_ThrowsCycleError() + { + var resources = new List { R("A", "a", "a"), // depends on itself }; - var ex = Assert.Throws( - () => DependencyResolver.Sort(resources)); + var ex = Assert.Throws( + () => DependencyResolver.Sort(resources)); - Assert.Contains("Circular dependency", ex.Message); - } + Assert.Contains("Circular dependency", ex.Message); } -} \ No newline at end of file + } +}