diff --git a/KtsuTools.FileDedupe/FileDedupeService.cs b/KtsuTools.FileDedupe/FileDedupeService.cs new file mode 100644 index 0000000..b60ea4c --- /dev/null +++ b/KtsuTools.FileDedupe/FileDedupeService.cs @@ -0,0 +1,172 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.FileDedupe; + +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using ktsu.Semantics.Paths; +using Spectre.Console; + +/// +/// A set of files with byte-identical content, identified by SHA256. +/// +public sealed record DuplicateGroup(string Hash, long FileSize, Collection Files); + +/// +/// Result of a dedupe planning pass. +/// +public sealed record DedupePlan( + IReadOnlyList Groups, + IReadOnlyList Keepers, + IReadOnlyList Removals) +{ + public long WastedBytes { get; } = ComputeWastedBytes(Groups); + + private static long ComputeWastedBytes(IReadOnlyList groups) + { + long total = 0; + foreach (DuplicateGroup g in groups) + { + total += g.FileSize * (g.Files.Count - 1); + } + + return total; + } +} + +public sealed record DedupeStats( + int FilesScanned, + int DuplicateGroups, + int RedundantFiles, + long WastedBytes, + Dictionary CountByExtension); + +/// +/// Scans a directory tree, groups files by SHA256, and applies (or previews) +/// "shortest filename wins" deduplication. +/// +public class FileDedupeService +{ +#pragma warning disable CA1822 // instance method required for DI injection + public async Task PlanAsync(AbsoluteDirectoryPath path, CancellationToken ct = default) +#pragma warning restore CA1822 + { + Ensure.NotNull(path); + + string root = path.ToString(); + if (!Directory.Exists(root)) + { + AnsiConsole.MarkupLine($"[red]Error: Directory '{root.EscapeMarkup()}' does not exist.[/]"); + return new DedupePlan([], [], []); + } + + string[] files = Directory.GetFiles(root, "*", SearchOption.AllDirectories); + + ConcurrentDictionary> byHash = new(); + + await Parallel.ForEachAsync( + files, + new ParallelOptions { CancellationToken = ct, MaxDegreeOfParallelism = Environment.ProcessorCount }, + async (file, token) => + { + try + { + await using FileStream stream = File.OpenRead(file); + byte[] hashBytes = await SHA256.HashDataAsync(stream, token).ConfigureAwait(false); + string hash = Convert.ToHexString(hashBytes); + long size = new FileInfo(file).Length; + + ConcurrentBag<(string Path, long Size)> bag = byHash.GetOrAdd(hash, _ => []); + bag.Add((file, size)); + } + catch (IOException) + { + // Skip unreadable files (locked, deleted mid-scan, etc.). + } + catch (UnauthorizedAccessException) + { + } + }).ConfigureAwait(false); + + List groups = []; + List keepers = []; + List removals = []; + + foreach ((string hash, ConcurrentBag<(string Path, long Size)> entries) in byHash) + { + if (entries.Count < 2) + { + continue; + } + + List paths = [.. entries.Select(e => e.Path) + .OrderBy(p => Path.GetFileName(p).Length) + .ThenBy(p => p, StringComparer.OrdinalIgnoreCase)]; + + long size = entries.First().Size; + groups.Add(new DuplicateGroup(hash, size, [.. paths])); + + keepers.Add(paths[0]); + removals.AddRange(paths.Skip(1)); + } + + return new DedupePlan(groups, keepers, removals); + } + +#pragma warning disable CA1822 + public DedupeStats Summarize(DedupePlan plan, int filesScanned) +#pragma warning restore CA1822 + { + Ensure.NotNull(plan); + + Dictionary byExt = new(StringComparer.OrdinalIgnoreCase); + int redundant = 0; + + foreach (DuplicateGroup group in plan.Groups) + { + redundant += group.Files.Count - 1; + foreach (string file in group.Files) + { + string ext = Path.GetExtension(file); + if (string.IsNullOrEmpty(ext)) + { + ext = "(none)"; + } + + byExt[ext] = byExt.TryGetValue(ext, out int c) ? c + 1 : 1; + } + } + + return new DedupeStats(filesScanned, plan.Groups.Count, redundant, plan.WastedBytes, byExt); + } + +#pragma warning disable CA1822 + public int DeleteRedundant(DedupePlan plan) +#pragma warning restore CA1822 + { + Ensure.NotNull(plan); + + int deleted = 0; + foreach (string path in plan.Removals) + { + try + { + File.Delete(path); + deleted++; + } + catch (IOException ex) + { + AnsiConsole.MarkupLine($" [yellow]skip[/] {path.EscapeMarkup()}: {ex.Message.EscapeMarkup()}"); + } + catch (UnauthorizedAccessException ex) + { + AnsiConsole.MarkupLine($" [yellow]skip[/] {path.EscapeMarkup()}: {ex.Message.EscapeMarkup()}"); + } + } + + return deleted; + } +} diff --git a/KtsuTools.FileDedupe/KtsuTools.FileDedupe.csproj b/KtsuTools.FileDedupe/KtsuTools.FileDedupe.csproj new file mode 100644 index 0000000..a5832d2 --- /dev/null +++ b/KtsuTools.FileDedupe/KtsuTools.FileDedupe.csproj @@ -0,0 +1,19 @@ + + + + + + net10.0 + + + + + + + + + + + + + diff --git a/KtsuTools.Merge/DiffStyleParser.cs b/KtsuTools.Merge/DiffStyleParser.cs new file mode 100644 index 0000000..9018b71 --- /dev/null +++ b/KtsuTools.Merge/DiffStyleParser.cs @@ -0,0 +1,48 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Merge; + +using System; + +/// +/// Maps the string form of used in CLI flags and persisted batch +/// configs (e.g. "side-by-side", "git") to the enum and back. +/// +public static class DiffStyleParser +{ + public const string SideBySideName = "side-by-side"; + public const string GitName = "git"; + + public static bool TryParse(string? value, out DiffStyle style) + { + if (string.IsNullOrWhiteSpace(value)) + { + style = DiffStyle.SideBySide; + return true; + } + + switch (value.Trim().ToLowerInvariant()) + { + case SideBySideName: + case "sidebyside": + case "side": + style = DiffStyle.SideBySide; + return true; + case GitName: + case "unified": + style = DiffStyle.Git; + return true; + default: + style = DiffStyle.SideBySide; + return false; + } + } + + public static string ToCanonicalString(DiffStyle style) => style switch + { + DiffStyle.Git => GitName, + _ => SideBySideName, + }; +} diff --git a/KtsuTools.Merge/MergeService.cs b/KtsuTools.Merge/MergeService.cs index 2503e84..d8d6f59 100644 --- a/KtsuTools.Merge/MergeService.cs +++ b/KtsuTools.Merge/MergeService.cs @@ -41,6 +41,18 @@ public enum BlockChoice Skip, } +/// +/// Diff rendering style for conflict display. +/// +public enum DiffStyle +{ + /// DiffPlex side-by-side renderer (default; preserves prior behaviour). + SideBySide, + + /// Git-style unified diff via DiffPlex UnifiedDiffBuilder. + Git, +} + /// /// Service for N-way iterative file merging with interactive conflict resolution. /// @@ -51,10 +63,11 @@ public class MergeService /// /// Absolute root directory under which to search. /// Glob pattern to match against filenames. + /// How to render conflict diffs. /// Cancellation token. /// Exit code (0 for success). #pragma warning disable CA1822 // Mark members as static - instance method required for DI injection - public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string filename, CancellationToken ct = default) + public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string filename, DiffStyle diffStyle = DiffStyle.SideBySide, CancellationToken ct = default) #pragma warning restore CA1822 { Ensure.NotNull(directory); @@ -113,7 +126,7 @@ public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string fil string content2 = await File.ReadAllTextAsync(bestPair.FilePath2, ct).ConfigureAwait(false); // Show diff - ShowDiff(content1, content2, bestPair.FilePath1, bestPair.FilePath2); + ShowDiff(content1, content2, bestPair.FilePath1, bestPair.FilePath2, diffStyle); // Interactive merge string mergedContent = InteractiveMerge(content1, content2); @@ -235,15 +248,34 @@ private static double CalculateSimilarity(string content1, string content2) return Math.Max(0.0, (double)unchangedLines / totalLines); } - private static void ShowDiff(string content1, string content2, string path1, string path2) - { - SideBySideDiffBuilder diffBuilder = new(new Differ()); - SideBySideDiffModel diff = diffBuilder.BuildDiffModel(content1, content2); + private const int DiffLineCap = 50; + private static void ShowDiff(string content1, string content2, string path1, string path2, DiffStyle style) + { AnsiConsole.MarkupLine($"[dim]--- {Path.GetFileName(path1).EscapeMarkup()}[/]"); AnsiConsole.MarkupLine($"[dim]+++ {Path.GetFileName(path2).EscapeMarkup()}[/]"); - int maxLines = Math.Min(50, Math.Max(diff.OldText.Lines.Count, diff.NewText.Lines.Count)); + switch (style) + { + case DiffStyle.Git: + ShowUnifiedDiff(content1, content2); + break; + case DiffStyle.SideBySide: + default: + ShowSideBySideDiff(content1, content2); + break; + } + + AnsiConsole.WriteLine(); + } + + private static void ShowSideBySideDiff(string content1, string content2) + { + SideBySideDiffBuilder diffBuilder = new(new Differ()); + SideBySideDiffModel diff = diffBuilder.BuildDiffModel(content1, content2); + + int total = Math.Max(diff.OldText.Lines.Count, diff.NewText.Lines.Count); + int maxLines = Math.Min(DiffLineCap, total); for (int i = 0; i < maxLines; i++) { @@ -268,12 +300,46 @@ private static void ShowDiff(string content1, string content2, string path1, str } } - if (Math.Max(diff.OldText.Lines.Count, diff.NewText.Lines.Count) > maxLines) + if (total > maxLines) { AnsiConsole.MarkupLine("[dim]... (truncated)[/]"); } + } - AnsiConsole.WriteLine(); + private static void ShowUnifiedDiff(string content1, string content2) + { + DiffPaneModel diff = InlineDiffBuilder.Diff(content1, content2); + + int rendered = 0; + foreach (DiffPiece line in diff.Lines) + { + if (rendered >= DiffLineCap) + { + AnsiConsole.MarkupLine("[dim]... (truncated)[/]"); + return; + } + + string text = line.Text?.EscapeMarkup() ?? string.Empty; + switch (line.Type) + { + case ChangeType.Inserted: + AnsiConsole.MarkupLine($"[green]+{text}[/]"); + break; + case ChangeType.Deleted: + AnsiConsole.MarkupLine($"[red]-{text}[/]"); + break; + case ChangeType.Modified: + AnsiConsole.MarkupLine($"[red]-{text}[/]"); + break; + case ChangeType.Unchanged: + AnsiConsole.MarkupLine($"[dim] {text}[/]"); + break; + case ChangeType.Imaginary: + continue; + } + + rendered++; + } } private static string InteractiveMerge(string content1, string content2) diff --git a/KtsuTools.Repo/RepoService.cs b/KtsuTools.Repo/RepoService.cs index bc522c4..b418442 100644 --- a/KtsuTools.Repo/RepoService.cs +++ b/KtsuTools.Repo/RepoService.cs @@ -88,7 +88,6 @@ await AnsiConsole.Status() /// public async Task BuildAndTestAsync(AbsoluteDirectoryPath path, bool parallel = false, CancellationToken ct = default) { - _ = parallel; Ensure.NotNull(path); string fullPath = path.ToString(); @@ -102,7 +101,8 @@ public async Task BuildAndTestAsync(AbsoluteDirectoryPath path, bool parall AnsiConsole.MarkupLine($"[blue]Found {solutionFiles.Count} solution(s).[/]"); - int failCount = 0; + ConcurrentBag failedSolutions = []; + object consoleLock = new(); await AnsiConsole.Progress() .AutoClear(false) @@ -111,50 +111,97 @@ await AnsiConsole.Progress() { ProgressTask task = progressContext.AddTask("[green]Building and testing[/]", maxValue: solutionFiles.Count); - foreach (string sln in solutionFiles) + if (parallel) { - ct.ThrowIfCancellationRequested(); - - string slnDir = Path.GetDirectoryName(sln) ?? fullPath; - string slnName = Path.GetFileNameWithoutExtension(sln); - - task.Description = $"[green]Building {slnName.EscapeMarkup()}[/]"; + // dotnet build already uses multiple cores internally; half of ProcessorCount keeps us from thrashing. + int dop = Math.Max(1, Environment.ProcessorCount / 2); + ParallelOptions options = new() { CancellationToken = ct, MaxDegreeOfParallelism = dop }; - // Build - ProcessResult buildResult = await processService.RunAsync(DotnetCommand, "build --nologo -v q", slnDir, ct).ConfigureAwait(false); - - if (buildResult.ExitCode != 0) + await Parallel.ForEachAsync(solutionFiles, options, async (sln, token) => { - AnsiConsole.MarkupLine($" [red]FAIL[/] {slnName.EscapeMarkup()} - build failed"); - failCount++; - task.Increment(1); - continue; - } - - // Test - ProcessResult testResult = await processService.RunAsync(DotnetCommand, "test --nologo --no-build -v q", slnDir, ct).ConfigureAwait(false); - - if (testResult.ExitCode != 0) - { - AnsiConsole.MarkupLine($" [yellow]WARN[/] {slnName.EscapeMarkup()} - build OK, tests failed"); - failCount++; - } - else + await BuildAndTestSolutionAsync(sln, fullPath, task, consoleLock, failedSolutions, token).ConfigureAwait(false); + }).ConfigureAwait(false); + } + else + { + foreach (string sln in solutionFiles) { - AnsiConsole.MarkupLine($" [green]OK[/] {slnName.EscapeMarkup()}"); + ct.ThrowIfCancellationRequested(); + await BuildAndTestSolutionAsync(sln, fullPath, task, consoleLock, failedSolutions, ct).ConfigureAwait(false); } - - task.Increment(1); } }).ConfigureAwait(false); - AnsiConsole.MarkupLine(failCount > 0 - ? $"[yellow]Done. {failCount} solution(s) had failures.[/]" - : "[green]Done. All solutions built and tested successfully.[/]"); + int failCount = failedSolutions.Count; + + if (failCount > 0) + { + AnsiConsole.MarkupLine($"[yellow]Done. {failCount} solution(s) had failures:[/]"); + foreach (string name in failedSolutions.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine($" [red]-[/] {name.EscapeMarkup()}"); + } + } + else + { + AnsiConsole.MarkupLine("[green]Done. All solutions built and tested successfully.[/]"); + } return failCount > 0 ? 1 : 0; } + private async Task BuildAndTestSolutionAsync( + string sln, + string fullPath, + ProgressTask task, + object consoleLock, + ConcurrentBag failedSolutions, + CancellationToken ct) + { + string slnDir = Path.GetDirectoryName(sln) ?? fullPath; + string slnName = Path.GetFileNameWithoutExtension(sln); + + lock (consoleLock) + { + task.Description = $"[green]Building {slnName.EscapeMarkup()}[/]"; + } + + ProcessResult buildResult = await processService.RunAsync(DotnetCommand, "build --nologo -v q", slnDir, ct).ConfigureAwait(false); + + if (buildResult.ExitCode != 0) + { + lock (consoleLock) + { + AnsiConsole.MarkupLine($" [red]FAIL[/] {slnName.EscapeMarkup()} - build failed"); + task.Increment(1); + } + + failedSolutions.Add(slnName); + return; + } + + ProcessResult testResult = await processService.RunAsync(DotnetCommand, "test --nologo --no-build -v q", slnDir, ct).ConfigureAwait(false); + + lock (consoleLock) + { + if (testResult.ExitCode != 0) + { + AnsiConsole.MarkupLine($" [yellow]WARN[/] {slnName.EscapeMarkup()} - build OK, tests failed"); + } + else + { + AnsiConsole.MarkupLine($" [green]OK[/] {slnName.EscapeMarkup()}"); + } + + task.Increment(1); + } + + if (testResult.ExitCode != 0) + { + failedSolutions.Add(slnName); + } + } + /// /// Pulls all repositories under the given path. /// diff --git a/KtsuTools.Test/FileDedupeServiceTests.cs b/KtsuTools.Test/FileDedupeServiceTests.cs new file mode 100644 index 0000000..a2c4704 --- /dev/null +++ b/KtsuTools.Test/FileDedupeServiceTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Test; + +using System.IO; +using System.Linq; +using ktsu.Semantics.Paths; +using KtsuTools.FileDedupe; + +[TestClass] +public class FileDedupeServiceTests +{ + [TestMethod] + public async Task PlanAsyncMissingDirectoryReturnsEmptyPlan() + { + FileDedupeService service = new(); + string missing = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_missing_{Guid.NewGuid():N}"); + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(missing); + DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false); + Assert.AreEqual(0, plan.Groups.Count); + Assert.AreEqual(0, plan.Removals.Count); + } + + [TestMethod] + public async Task PlanAsyncGroupsByContentAndPicksShortestFilenameAsKeeper() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + string shortName = Path.Combine(root, "a.txt"); + string mediumName = Path.Combine(root, "aaa.txt"); + string longName = Path.Combine(root, "aaaaa.txt"); + string unique = Path.Combine(root, "b.txt"); + + await File.WriteAllTextAsync(shortName, "same").ConfigureAwait(false); + await File.WriteAllTextAsync(mediumName, "same").ConfigureAwait(false); + await File.WriteAllTextAsync(longName, "same").ConfigureAwait(false); + await File.WriteAllTextAsync(unique, "different").ConfigureAwait(false); + + FileDedupeService service = new(); + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(root); + DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false); + + Assert.AreEqual(1, plan.Groups.Count, "Only the three duplicates form a group; the unique file is excluded."); + DuplicateGroup group = plan.Groups[0]; + Assert.AreEqual(3, group.Files.Count); + Assert.AreEqual(shortName, plan.Keepers.Single(), "Keeper is the shortest filename."); + CollectionAssert.AreEquivalent(new[] { mediumName, longName }, plan.Removals.ToArray()); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public async Task PlanAsyncIgnoresSingletonGroups() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_solo_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + await File.WriteAllTextAsync(Path.Combine(root, "x.txt"), "x").ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(root, "y.txt"), "y").ConfigureAwait(false); + + FileDedupeService service = new(); + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(root); + DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false); + + Assert.AreEqual(0, plan.Groups.Count); + Assert.AreEqual(0, plan.WastedBytes); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public async Task DeleteRedundantRemovesAllButTheKeeper() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_dedup_del_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + string keeper = Path.Combine(root, "a.txt"); + string dup1 = Path.Combine(root, "aaa.txt"); + string dup2 = Path.Combine(root, "aaaaa.txt"); + await File.WriteAllTextAsync(keeper, "same").ConfigureAwait(false); + await File.WriteAllTextAsync(dup1, "same").ConfigureAwait(false); + await File.WriteAllTextAsync(dup2, "same").ConfigureAwait(false); + + FileDedupeService service = new(); + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(root); + DedupePlan plan = await service.PlanAsync(path).ConfigureAwait(false); + + int deleted = service.DeleteRedundant(plan); + + Assert.AreEqual(2, deleted); + Assert.IsTrue(File.Exists(keeper)); + Assert.IsFalse(File.Exists(dup1)); + Assert.IsFalse(File.Exists(dup2)); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } +} diff --git a/KtsuTools.Test/KtsuTools.Test.csproj b/KtsuTools.Test/KtsuTools.Test.csproj index f8fe6ec..f35a7fd 100644 --- a/KtsuTools.Test/KtsuTools.Test.csproj +++ b/KtsuTools.Test/KtsuTools.Test.csproj @@ -26,6 +26,7 @@ + diff --git a/KtsuTools.Test/MergeServiceTests.cs b/KtsuTools.Test/MergeServiceTests.cs index a7638ea..c790070 100644 --- a/KtsuTools.Test/MergeServiceTests.cs +++ b/KtsuTools.Test/MergeServiceTests.cs @@ -62,4 +62,38 @@ public async Task RunMergeAsyncAllMatchesIdenticalReturnsZeroWithoutPrompting() Directory.Delete(root, recursive: true); } } + + [TestMethod] + public void DiffStyleParserAcceptsCanonicalNames() + { + Assert.IsTrue(DiffStyleParser.TryParse("side-by-side", out DiffStyle side)); + Assert.AreEqual(DiffStyle.SideBySide, side); + + Assert.IsTrue(DiffStyleParser.TryParse("git", out DiffStyle git)); + Assert.AreEqual(DiffStyle.Git, git); + } + + [TestMethod] + public void DiffStyleParserDefaultsForNullOrEmpty() + { + Assert.IsTrue(DiffStyleParser.TryParse(null, out DiffStyle a)); + Assert.AreEqual(DiffStyle.SideBySide, a); + + Assert.IsTrue(DiffStyleParser.TryParse(" ", out DiffStyle b)); + Assert.AreEqual(DiffStyle.SideBySide, b); + } + + [TestMethod] + public void DiffStyleParserRejectsUnknownValues() + { + Assert.IsFalse(DiffStyleParser.TryParse("rainbow", out DiffStyle _)); + Assert.IsFalse(DiffStyleParser.TryParse("ColoredDeluxe", out DiffStyle _)); + } + + [TestMethod] + public void DiffStyleParserRoundTripsCanonicalForm() + { + Assert.AreEqual("side-by-side", DiffStyleParser.ToCanonicalString(DiffStyle.SideBySide)); + Assert.AreEqual("git", DiffStyleParser.ToCanonicalString(DiffStyle.Git)); + } } diff --git a/KtsuTools.Test/RepoServiceTests.cs b/KtsuTools.Test/RepoServiceTests.cs index 6334850..7273393 100644 --- a/KtsuTools.Test/RepoServiceTests.cs +++ b/KtsuTools.Test/RepoServiceTests.cs @@ -4,7 +4,11 @@ namespace KtsuTools.Test; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Threading; +using System.Threading.Tasks; using ktsu.Semantics.Paths; using KtsuTools.Core.Services.Git; using KtsuTools.Core.Services.Process; @@ -50,4 +54,93 @@ public async Task DiscoverRepositoriesAsyncFindsGitDirectories() Directory.Delete(root, recursive: true); } } + + [TestMethod] + public async Task BuildAndTestAsyncParallelAggregatesExitCodes() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_par_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + // Two top-level solutions in separate subdirs. + string slnAPath = Path.Combine(root, "a", "A.sln"); + string slnBPath = Path.Combine(root, "b", "B.sln"); + Directory.CreateDirectory(Path.GetDirectoryName(slnAPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(slnBPath)!); + await File.WriteAllTextAsync(slnAPath, string.Empty).ConfigureAwait(false); + await File.WriteAllTextAsync(slnBPath, string.Empty).ConfigureAwait(false); + + DelayedFakeProcessService fake = new(delayMs: 50, failBuildInDirNamed: "b"); + RepoService service = new(new Mock().Object, fake); + AbsoluteDirectoryPath rootPath = AbsoluteDirectoryPath.Create(root); + + int exit = await service.BuildAndTestAsync(rootPath, parallel: true).ConfigureAwait(false); + + Assert.AreEqual(1, exit, "Aggregated exit code should be non-zero when any solution fails."); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public async Task BuildAndTestAsyncParallelIsFasterThanSequential() + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_parspeed_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + for (int i = 0; i < 4; i++) + { + string dir = Path.Combine(root, $"s{i}"); + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(Path.Combine(dir, $"S{i}.sln"), string.Empty).ConfigureAwait(false); + } + + AbsoluteDirectoryPath rootPath = AbsoluteDirectoryPath.Create(root); + + DelayedFakeProcessService fakeSeq = new(delayMs: 120); + RepoService seqService = new(new Mock().Object, fakeSeq); + Stopwatch swSeq = Stopwatch.StartNew(); + await seqService.BuildAndTestAsync(rootPath, parallel: false).ConfigureAwait(false); + swSeq.Stop(); + + DelayedFakeProcessService fakePar = new(delayMs: 120); + RepoService parService = new(new Mock().Object, fakePar); + Stopwatch swPar = Stopwatch.StartNew(); + await parService.BuildAndTestAsync(rootPath, parallel: true).ConfigureAwait(false); + swPar.Stop(); + + Assert.IsTrue( + swPar.ElapsedMilliseconds < swSeq.ElapsedMilliseconds, + $"Parallel ({swPar.ElapsedMilliseconds} ms) should be faster than sequential ({swSeq.ElapsedMilliseconds} ms)."); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class DelayedFakeProcessService(int delayMs, string? failBuildInDirNamed = null) : IProcessService + { + public Task RunAsync(string command, string arguments, string? workingDirectory = null, CancellationToken ct = default) => + RunAsync(command, arguments, workingDirectory, null, ct); + + public async Task RunAsync(string command, string arguments, string? workingDirectory, IDictionary? environmentVariables, CancellationToken ct = default) + { + await Task.Delay(delayMs, ct).ConfigureAwait(false); + + int exit = 0; + if (failBuildInDirNamed is not null && + arguments.StartsWith("build", StringComparison.Ordinal) && + workingDirectory is not null && + string.Equals(Path.GetFileName(workingDirectory), failBuildInDirNamed, StringComparison.OrdinalIgnoreCase)) + { + exit = 1; + } + + return new ProcessResult(exit, [], []); + } + } } diff --git a/KtsuTools.slnx b/KtsuTools.slnx index b661b30..929f794 100644 --- a/KtsuTools.slnx +++ b/KtsuTools.slnx @@ -2,6 +2,7 @@ + diff --git a/KtsuTools/Commands/DedupDeleteCommand.cs b/KtsuTools/Commands/DedupDeleteCommand.cs new file mode 100644 index 0000000..3081b82 --- /dev/null +++ b/KtsuTools/Commands/DedupDeleteCommand.cs @@ -0,0 +1,63 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.ComponentModel; +using System.IO; +using ktsu.Semantics.Paths; +using KtsuTools.Core.UI; +using KtsuTools.FileDedupe; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class DedupDeleteCommand(FileDedupeService dedupeService) : AsyncCommand +{ + private readonly FileDedupeService dedupeService = dedupeService; + + public sealed class Settings : CommandSettings + { + [CommandOption("-p|--path ")] + [Description("Directory to scan for duplicates")] + public required string Path { get; init; } + + [CommandOption("-y|--yes")] + [Description("Skip confirmation prompt")] + [DefaultValue(false)] + public bool AssumeYes { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + Ensure.NotNull(settings); + using CtrlCScope scope = new(); + + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path)); + AnsiConsole.MarkupLine($"[bold]Dedup delete[/] - {path.ToString().EscapeMarkup()}"); + + DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false); + + if (plan.Removals.Count == 0) + { + AnsiConsole.MarkupLine("[green]Nothing to delete.[/]"); + return 0; + } + + AnsiConsole.MarkupLine($"[yellow]Will delete {plan.Removals.Count} file(s); shortest-filename winners are kept.[/]"); + + if (!settings.AssumeYes) + { + bool ok = AnsiConsole.Confirm("Proceed with deletion?", defaultValue: false); + if (!ok) + { + AnsiConsole.MarkupLine("[dim]Cancelled.[/]"); + return 0; + } + } + + int deleted = dedupeService.DeleteRedundant(plan); + AnsiConsole.MarkupLine($"[green]Deleted {deleted} file(s).[/]"); + return 0; + } +} diff --git a/KtsuTools/Commands/DedupDryRunCommand.cs b/KtsuTools/Commands/DedupDryRunCommand.cs new file mode 100644 index 0000000..28890cb --- /dev/null +++ b/KtsuTools/Commands/DedupDryRunCommand.cs @@ -0,0 +1,56 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.ComponentModel; +using System.IO; +using ktsu.Semantics.Paths; +using KtsuTools.Core.UI; +using KtsuTools.FileDedupe; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class DedupDryRunCommand(FileDedupeService dedupeService) : AsyncCommand +{ + private readonly FileDedupeService dedupeService = dedupeService; + + public sealed class Settings : CommandSettings + { + [CommandOption("-p|--path ")] + [Description("Directory to scan for duplicates")] + public required string Path { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + Ensure.NotNull(settings); + using CtrlCScope scope = new(); + + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path)); + AnsiConsole.MarkupLine($"[bold]Dedup dry-run[/] - {path.ToString().EscapeMarkup()}"); + + DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false); + + if (plan.Removals.Count == 0) + { + AnsiConsole.MarkupLine("[green]Nothing to delete.[/]"); + return 0; + } + + AnsiConsole.MarkupLine("[yellow]Would delete (shortest-filename winners are kept):[/]"); + foreach (string keeper in plan.Keepers) + { + AnsiConsole.MarkupLine($" [green]keep[/] {keeper.EscapeMarkup()}"); + } + + foreach (string removal in plan.Removals) + { + AnsiConsole.MarkupLine($" [red]drop[/] {removal.EscapeMarkup()}"); + } + + AnsiConsole.MarkupLine($"[blue]{plan.Removals.Count} file(s), {plan.WastedBytes} byte(s) would be reclaimed.[/]"); + return 0; + } +} diff --git a/KtsuTools/Commands/DedupScanCommand.cs b/KtsuTools/Commands/DedupScanCommand.cs new file mode 100644 index 0000000..5e59ae9 --- /dev/null +++ b/KtsuTools/Commands/DedupScanCommand.cs @@ -0,0 +1,54 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.ComponentModel; +using System.IO; +using ktsu.Semantics.Paths; +using KtsuTools.Core.UI; +using KtsuTools.FileDedupe; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class DedupScanCommand(FileDedupeService dedupeService) : AsyncCommand +{ + private readonly FileDedupeService dedupeService = dedupeService; + + public sealed class Settings : CommandSettings + { + [CommandOption("-p|--path ")] + [Description("Directory to scan for duplicates")] + public required string Path { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + Ensure.NotNull(settings); + using CtrlCScope scope = new(); + + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path)); + AnsiConsole.MarkupLine($"[bold]Dedup scan[/] - {path.ToString().EscapeMarkup()}"); + + DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false); + + if (plan.Groups.Count == 0) + { + AnsiConsole.MarkupLine("[green]No duplicate groups found.[/]"); + return 0; + } + + foreach (DuplicateGroup group in plan.Groups) + { + AnsiConsole.MarkupLine($"[yellow]{group.Files.Count}[/] files, {group.FileSize} bytes each ({group.Hash[..12]})"); + foreach (string file in group.Files) + { + AnsiConsole.MarkupLine($" {file.EscapeMarkup()}"); + } + } + + AnsiConsole.MarkupLine($"[blue]{plan.Groups.Count} duplicate group(s), {plan.WastedBytes} wasted byte(s).[/]"); + return 0; + } +} diff --git a/KtsuTools/Commands/DedupStatsCommand.cs b/KtsuTools/Commands/DedupStatsCommand.cs new file mode 100644 index 0000000..22d6060 --- /dev/null +++ b/KtsuTools/Commands/DedupStatsCommand.cs @@ -0,0 +1,62 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace KtsuTools.Commands; + +using System.ComponentModel; +using System.IO; +using System.Linq; +using ktsu.Semantics.Paths; +using KtsuTools.Core.UI; +using KtsuTools.FileDedupe; +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class DedupStatsCommand(FileDedupeService dedupeService) : AsyncCommand +{ + private readonly FileDedupeService dedupeService = dedupeService; + + public sealed class Settings : CommandSettings + { + [CommandOption("-p|--path ")] + [Description("Directory to summarize")] + public required string Path { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + Ensure.NotNull(settings); + using CtrlCScope scope = new(); + + AbsoluteDirectoryPath path = AbsoluteDirectoryPath.Create(Path.GetFullPath(settings.Path)); + AnsiConsole.MarkupLine($"[bold]Dedup stats[/] - {path.ToString().EscapeMarkup()}"); + + int filesScanned = Directory.Exists(path.ToString()) + ? Directory.GetFiles(path.ToString(), "*", SearchOption.AllDirectories).Length + : 0; + + DedupePlan plan = await dedupeService.PlanAsync(path, scope.Token).ConfigureAwait(false); + DedupeStats stats = dedupeService.Summarize(plan, filesScanned); + + Table table = new(); + table.AddColumn("Metric"); + table.AddColumn("Value"); + table.AddRow("Files scanned", stats.FilesScanned.ToString()); + table.AddRow("Duplicate groups", stats.DuplicateGroups.ToString()); + table.AddRow("Redundant files", stats.RedundantFiles.ToString()); + table.AddRow("Wasted bytes", stats.WastedBytes.ToString()); + AnsiConsole.Write(table); + + if (stats.CountByExtension.Count > 0) + { + AnsiConsole.MarkupLine("[bold]Duplicate files by extension:[/]"); + foreach ((string ext, int count) in stats.CountByExtension.OrderByDescending(kv => kv.Value)) + { + AnsiConsole.MarkupLine($" {ext.EscapeMarkup()}: {count}"); + } + } + + return 0; + } +} diff --git a/KtsuTools/Commands/MergeBatchSaveCommand.cs b/KtsuTools/Commands/MergeBatchSaveCommand.cs index c7af4f5..e4eea8f 100644 --- a/KtsuTools/Commands/MergeBatchSaveCommand.cs +++ b/KtsuTools/Commands/MergeBatchSaveCommand.cs @@ -29,8 +29,18 @@ public sealed class Settings : CommandSettings public required string Filename { get; init; } [CommandOption("--diff-style