diff --git a/FileDeduplicator.Test/ConsoleCapture.cs b/FileDeduplicator.Test/ConsoleCapture.cs new file mode 100644 index 0000000..e8b5971 --- /dev/null +++ b/FileDeduplicator.Test/ConsoleCapture.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.FileDeduplicator.Verbs; + +/// +/// Runs a verb with the console redirected, and hands back everything it wrote. +/// +/// +/// The verbs have no return value and no output abstraction -- what they do is what they print -- +/// so the console is the only surface a test can assert against. Redirecting it is global state, +/// which is why every class using this is marked [DoNotParallelize]. +/// +internal static class ConsoleCapture +{ + /// + /// Runs a verb, feeding it the given answers on stdin. + /// + /// The verb to run. + /// Lines the verb's prompts will read, or nothing. + /// Everything the verb wrote to the console. + internal static string Run(BaseVerb verb, string stdin = "") + { + TextWriter originalOut = Console.Out; + TextReader originalIn = Console.In; + + try + { + using StringWriter captured = new(); + using StringReader answers = new(stdin); + Console.SetOut(captured); + Console.SetIn(answers); + + verb.Run(); + + return captured.ToString(); + } + finally + { + Console.SetOut(originalOut); + Console.SetIn(originalIn); + } + } + + /// + /// Collapses runs of whitespace so assertions describe what a line says rather than the column + /// its markers happen to sit in, and so a CRLF platform reads the same as an LF one. + /// + /// The captured output. + /// The output with each line trimmed and its internal whitespace collapsed. + internal static string Normalize(string output) => + string.Join('\n', output.Split('\n').Select(line => string.Join(' ', line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)))); +} diff --git a/FileDeduplicator.Test/DeduplicateConfirmationTests.cs b/FileDeduplicator.Test/DeduplicateConfirmationTests.cs new file mode 100644 index 0000000..25a9bde --- /dev/null +++ b/FileDeduplicator.Test/DeduplicateConfirmationTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.FileDeduplicator.Verbs; +using ktsu.Semantics.Paths; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that the Deduplicate verb shows which copies it is about to delete before it asks for +/// permission to delete them, and that the answer it gets is the one it acts on. +/// +/// +/// The confirmation prompt is the only gate in front of an irreversible deletion, and "keep the +/// shortest filename" is a blunt enough policy to pick the wrong survivor -- r.pdf over +/// report-final-DO-NOT-DELETE.pdf. A prompt that states only a count asks the user to approve an +/// outcome they cannot see, which makes the gate decorative. These tests pin the ordering: the +/// paths come first, the question second. +/// +/// They drive the verb through with the console redirected, because +/// the ordering is a property of that method and of nothing else -- a unit test of the listing +/// helper alone would still pass if the call were left out or moved below the prompt. +/// +[TestClass] +[DoNotParallelize] +public sealed class DeduplicateConfirmationTests +{ + private static string RunDeclining(AbsoluteDirectoryPath root) => + ConsoleCapture.Normalize(ConsoleCapture.Run(new Deduplicate { PathString = root.WeakString }, "n")); + + private static string RunConfirming(AbsoluteDirectoryPath root) => + ConsoleCapture.Normalize(ConsoleCapture.Run(new Deduplicate { PathString = root.WeakString }, "y")); + + private static string BeforeThePrompt(string output) + { + int prompt = output.IndexOf("Proceed with deletion?", StringComparison.Ordinal); + Assert.AreNotEqual(-1, prompt, $"The verb never reached the confirmation prompt. Output was:\n{output}"); + return output[..prompt]; + } + + /// + /// Every copy that would be deleted has to be named before the prompt, not after the deletion. + /// + [TestMethod] + public void EveryPathToBeDeletedIsNamedBeforeTheConfirmationPrompt() + { + // Arrange -- the blunt-policy case: the descriptive name loses to the short one + using TempTree tree = new(); + AbsoluteFilePath keeper = tree.Write("r.pdf", "the only copy that survives"); + AbsoluteFilePath doomed = tree.Write("report-final-DO-NOT-DELETE.pdf", "the only copy that survives"); + AbsoluteFilePath alsoDoomed = tree.Write("archive/report-2026-backup.pdf", "the only copy that survives"); + + // Act + string beforePrompt = BeforeThePrompt(RunDeclining(tree.Root)); + + // Assert + Assert.Contains($"DELETE: {doomed}", beforePrompt, $"The prompt was shown without naming {doomed}. Output before it was:\n{beforePrompt}"); + Assert.Contains($"DELETE: {alsoDoomed}", beforePrompt, $"The prompt was shown without naming {alsoDoomed}. Output before it was:\n{beforePrompt}"); + Assert.Contains($"KEEP: {keeper}", beforePrompt, $"The prompt was shown without naming the copy being kept. Output before it was:\n{beforePrompt}"); + } + + /// + /// The listing has to cover every group, not just the first one -- a user scrolling past a + /// truncated listing would approve deletions they never saw. + /// + [TestMethod] + public void EveryDuplicateGroupAppearsInTheListing() + { + // Arrange + using TempTree tree = new(); + List doomed = + [ + tree.Write("group1/aa.txt", "alpha"), + tree.Write("group2/bb.txt", "beta"), + tree.Write("group3/nested/cc.txt", "gamma"), + ]; + _ = tree.Write("group1/a.txt", "alpha"); + _ = tree.Write("group2/b.txt", "beta"); + _ = tree.Write("group3/c.txt", "gamma"); + + // Act + string beforePrompt = BeforeThePrompt(RunDeclining(tree.Root)); + + // Assert + foreach (AbsoluteFilePath file in doomed) + { + Assert.Contains($"DELETE: {file}", beforePrompt, $"{file} was not listed before the prompt."); + } + } + + /// + /// The totals printed with the listing describe the same deletions the listing names. + /// + [TestMethod] + public void TheTotalsMatchTheListingShownAboveThem() + { + // Arrange -- two groups of two 5-byte files, so 2 deletions and 10 bytes + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("aa.txt", "alpha"); + _ = tree.Write("b.txt", "bravo"); + _ = tree.Write("bb.txt", "bravo"); + + // Act + string beforePrompt = BeforeThePrompt(RunDeclining(tree.Root)); + + // Assert + Assert.AreEqual(2, beforePrompt.Split("DELETE:").Length - 1, $"Expected two DELETE lines in:\n{beforePrompt}"); + Assert.Contains("Files to delete: 2", beforePrompt); + Assert.Contains("Space to reclaim: 10 B", beforePrompt); + } + + /// + /// Showing the listing must not have turned the preview into the deletion: declining still + /// leaves every file on disk. + /// + [TestMethod] + public void DecliningTheConfirmationLeavesEveryFileOnDisk() + { + // Arrange + using TempTree tree = new(); + List all = + [ + tree.Write("a.txt", "alpha"), + tree.Write("aa.txt", "alpha"), + tree.Write("b.txt", "beta"), + tree.Write("bb.txt", "beta"), + ]; + + // Act + string output = RunDeclining(tree.Root); + + // Assert + Assert.Contains("Aborted.", output); + + foreach (AbsoluteFilePath file in all) + { + Assert.IsTrue(TempTree.Exists(file), $"{file} was deleted despite the confirmation being declined."); + } + } + + /// + /// Confirming deletes exactly the copies the listing named, and no others -- the listing is a + /// promise about what the next step does, so it has to be kept. + /// + [TestMethod] + public void ConfirmingDeletesExactlyTheCopiesTheListingNamed() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath keeper = tree.Write("a.txt", "alpha"); + AbsoluteFilePath doomed = tree.Write("aa.txt", "alpha"); + AbsoluteFilePath unique = tree.Write("b.txt", "beta"); + + // Act + string output = RunConfirming(tree.Root); + string beforePrompt = BeforeThePrompt(output); + + // Assert -- what the listing named is gone; what it did not name is not + Assert.Contains($"DELETE: {doomed}", beforePrompt); + Assert.IsFalse(TempTree.Exists(doomed), $"{doomed} was listed for deletion but survived."); + Assert.IsTrue(TempTree.Exists(keeper), $"{keeper} was listed as the kept copy but was deleted."); + Assert.IsTrue(TempTree.Exists(unique), $"{unique} has no duplicate and should never have been touched."); + + Assert.Contains("Deleted 1 file(s).", output); + Assert.Contains("Reclaimed 5 B of disk space.", output); + } +} diff --git a/FileDeduplicator.Test/DuplicateReportTests.cs b/FileDeduplicator.Test/DuplicateReportTests.cs new file mode 100644 index 0000000..8d8c3e9 --- /dev/null +++ b/FileDeduplicator.Test/DuplicateReportTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.Semantics.Paths; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests the listing and totals that Deduplicate and DryRun both print. +/// +/// +/// Both verbs now read their numbers from here, so an error in this one place is an error the +/// preview and the confirmation would agree on -- which is exactly the kind of wrong that nobody +/// catches by reading the output. +/// +[TestClass] +public sealed class DuplicateReportTests +{ + private static IReadOnlyList DuplicatesIn(TempTree tree) => + Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles(FileScanner.ScanForFiles(tree.Root)))); + + /// + /// The plan counts every copy except each group's keeper, and the bytes those copies occupy. + /// + [TestMethod] + public void PlanCountsEveryCopyExceptEachGroupsKeeper() + { + // Arrange -- 3 copies of a 5-byte content and 2 of a 4-byte one, so 2 + 1 deletions + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("aa.txt", "alpha"); + _ = tree.Write("aaa.txt", "alpha"); + _ = tree.Write("b.txt", "beta"); + _ = tree.Write("bb.txt", "beta"); + _ = tree.Write("unique.txt", "gamma-and-then-some"); + + // Act + DeletionPlan plan = DuplicateReport.PlanDeletions(DuplicatesIn(tree)); + + // Assert + Assert.AreEqual(3, plan.FileCount); + Assert.AreEqual((2 * 5L) + 4L, plan.BytesReclaimable); + } + + /// + /// The listing names one keeper and every other copy in each group, so the two sets together + /// account for every file the group holds. + /// + [TestMethod] + public void ListingNamesOneKeeperAndEveryOtherCopyPerGroup() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("aa.txt", "alpha"); + _ = tree.Write("nested/aaa.txt", "alpha"); + + IReadOnlyList duplicates = DuplicatesIn(tree); + + // Act + DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates); + List keeps = [.. plan.Listing.Where(l => l.Contains("KEEP:", StringComparison.Ordinal))]; + List deletes = [.. plan.Listing.Where(l => l.Contains("DELETE:", StringComparison.Ordinal))]; + + // Assert + Assert.HasCount(1, duplicates); + Assert.HasCount(1, keeps); + Assert.HasCount(2, deletes); + + AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(duplicates[0].Files); + Assert.Contains(keeper.WeakString, keeps[0]); + + foreach (AbsoluteFilePath file in duplicates[0].Files.Where(f => f != keeper)) + { + Assert.IsTrue( + deletes.Exists(l => l.Contains(file.WeakString, StringComparison.Ordinal)), + $"{file} was not listed for deletion."); + } + } + + /// + /// Sizes are rendered in the largest unit that leaves the number above one, so a listing of + /// large files does not ask the reader to count digits. + /// + /// The count to render. + /// What it should read as. + [TestMethod] + [DataRow(0L, "0 B")] + [DataRow(1023L, "1023 B")] + [DataRow(1024L, "1.0 KB")] + [DataRow(1024L * 1024, "1.0 MB")] + [DataRow((1024L * 1024 * 1024) + (512L * 1024 * 1024), "1.5 GB")] + public void FormatBytesUsesTheLargestUnitThatFits(long bytes, string expected) => + Assert.AreEqual(expected, DuplicateReport.FormatBytes(bytes)); + + /// + /// A run with nothing to delete produces nothing to read. + /// + [TestMethod] + public void PlanForNoDuplicatesIsEmpty() + { + // Act + DeletionPlan plan = DuplicateReport.PlanDeletions([]); + + // Assert + Assert.IsEmpty(plan.Listing); + Assert.AreEqual(0, plan.FileCount); + Assert.AreEqual(0L, plan.BytesReclaimable); + } +} diff --git a/FileDeduplicator.Test/VerbOutputTests.cs b/FileDeduplicator.Test/VerbOutputTests.cs new file mode 100644 index 0000000..42d8f80 --- /dev/null +++ b/FileDeduplicator.Test/VerbOutputTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.FileDeduplicator.Verbs; +using ktsu.Semantics.Paths; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests what the read-only verbs report, and that they stay read-only. +/// +/// +/// Scan, DryRun and Stats exist to be read before anything is deleted, so what they print is their +/// entire contract -- and Deduplicate's listing is now built from the same helper DryRun uses, which +/// makes "DryRun still says what it used to" something worth holding still rather than assuming. +/// +[TestClass] +[DoNotParallelize] +public sealed class VerbOutputTests +{ + /// + /// Writes a tree with one group of three copies and one unique file. + /// + /// The tree to write into. + /// The copy the shortest-name policy will keep. + /// The copies it will not. + private static void WriteOneGroup(TempTree tree, out AbsoluteFilePath keeper, out List doomed) + { + keeper = tree.Write("a.txt", "alpha"); + doomed = + [ + tree.Write("aa.txt", "alpha"), + tree.Write("nested/aaa.txt", "alpha"), + ]; + _ = tree.Write("unique.txt", "beta"); + } + + /// + /// DryRun names the keeper and every other copy, totals them, and deletes nothing. + /// + [TestMethod] + public void DryRunListsTheGroupAndLeavesEveryFileOnDisk() + { + // Arrange + using TempTree tree = new(); + WriteOneGroup(tree, out AbsoluteFilePath keeper, out List doomed); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new DryRun { PathString = tree.Root.WeakString })); + + // Assert + Assert.Contains("Found 1 group(s) of duplicate files:", output); + Assert.Contains($"KEEP: {keeper}", output); + Assert.Contains("--- Dry Run Summary ---", output); + Assert.Contains("Duplicate groups: 1", output); + Assert.Contains("Files to delete: 2", output); + Assert.Contains("Space to reclaim: 10 B", output); + + foreach (AbsoluteFilePath file in doomed) + { + Assert.Contains($"DELETE: {file}", output); + Assert.IsTrue(TempTree.Exists(file), $"DryRun deleted {file}."); + } + + Assert.IsTrue(TempTree.Exists(keeper), $"DryRun deleted {keeper}."); + } + + /// + /// Scan marks each copy in a group, and points at the verb that acts on them. + /// + [TestMethod] + public void ScanMarksEveryCopyKeepOrDelete() + { + // Arrange + using TempTree tree = new(); + WriteOneGroup(tree, out AbsoluteFilePath keeper, out List doomed); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Scan { PathString = tree.Root.WeakString })); + + // Assert + Assert.Contains($"{keeper} [KEEP]", output); + Assert.Contains("Total duplicate groups: 1", output); + Assert.Contains("Total wasted space: 10 B", output); + Assert.Contains("Run the 'Deduplicate' command to remove duplicates.", output); + + foreach (AbsoluteFilePath file in doomed) + { + Assert.Contains($"{file} [DELETE]", output); + Assert.IsTrue(TempTree.Exists(file), $"Scan deleted {file}."); + } + } + + /// + /// Stats counts the tree and breaks the duplicates down without touching anything. + /// + [TestMethod] + public void StatsReportsTotalsForTheTree() + { + // Arrange + using TempTree tree = new(); + WriteOneGroup(tree, out AbsoluteFilePath keeper, out List doomed); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Stats { PathString = tree.Root.WeakString })); + + // Assert -- three 5-byte copies plus a 4-byte unique file, so 19 B held and 10 B wasted + Assert.Contains("Total files: 4", output); + Assert.Contains("Total size: 19 B", output); + Assert.Contains("Unique files: 2", output); + Assert.Contains("Duplicate files: 2", output); + Assert.Contains("Duplicate groups: 1", output); + Assert.Contains("Wasted space: 10 B", output); + Assert.Contains("Duplicate files by extension:", output); + Assert.Contains(".txt: 3 file(s)", output); + Assert.Contains("Largest duplicate groups (by wasted space):", output); + Assert.Contains("3 copies, 5 B each, 10 B wasted", output); + + Assert.IsTrue(TempTree.Exists(keeper), $"Stats deleted {keeper}."); + Assert.IsTrue(doomed.TrueForAll(TempTree.Exists), "Stats deleted a file."); + } + + /// + /// A tree with nothing duplicated says so, in every verb that looks for duplicates. + /// + [TestMethod] + public void NoDuplicatesIsReportedByEveryVerb() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("b.txt", "beta"); + + // Act + string scan = ConsoleCapture.Run(new Scan { PathString = tree.Root.WeakString }); + string dryRun = ConsoleCapture.Run(new DryRun { PathString = tree.Root.WeakString }); + string deduplicate = ConsoleCapture.Run(new Deduplicate { PathString = tree.Root.WeakString }, "y"); + + // Assert + Assert.Contains("No duplicate files found.", scan); + Assert.Contains("No duplicate files found.", dryRun); + Assert.Contains("No duplicate files found.", deduplicate); + Assert.DoesNotContain("Proceed with deletion?", deduplicate, "Nothing was found, so nothing should have been asked."); + } + + /// + /// An empty directory stops before hashing, rather than reporting on nothing. + /// + [TestMethod] + public void AnEmptyDirectoryStopsAfterDiscovery() + { + // Arrange + using TempTree tree = new(); + + // Act + string output = ConsoleCapture.Run(new Scan { PathString = tree.Root.WeakString }); + + // Assert + Assert.Contains("Found 0 file(s).", output); + Assert.DoesNotContain("Hashing files...", output); + } +} diff --git a/FileDeduplicator/DuplicateReport.cs b/FileDeduplicator/DuplicateReport.cs new file mode 100644 index 0000000..f14d4b9 --- /dev/null +++ b/FileDeduplicator/DuplicateReport.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator; + +using System.Collections.Generic; + +using ktsu.Semantics.Paths; + +/// +/// Renders what deduplication would do to a set of duplicate groups, without doing any of it. +/// +/// +/// Every verb that talks about duplicates has to answer the same two questions -- which copy +/// survives each group, and which copies do not -- and answer them identically, because the user +/// reads one verb's answer and then acts on another's. Building the listing here, from the same +/// the delete path uses, is what keeps the preview and +/// the deletion from drifting apart. +/// +internal static class DuplicateReport +{ + /// + /// Works out which copies would be deleted, and renders the per-group KEEP/DELETE listing. + /// + /// The duplicate groups to describe. + /// The listing, plus the totals that go with it. + internal static DeletionPlan PlanDeletions(IReadOnlyList duplicates) + { + List listing = []; + int fileCount = 0; + long bytesReclaimable = 0; + + foreach (DuplicateGroup group in duplicates) + { + AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); + + listing.Add($" Hash: {group.Hash[..12]}... ({FormatBytes(group.FileSize)}, {group.Files.Count} copies)"); + listing.Add($" KEEP: {keeper}"); + + foreach (AbsoluteFilePath file in group.Files) + { + if (file == keeper) + { + continue; + } + + listing.Add($" DELETE: {file}"); + fileCount++; + bytesReclaimable += group.FileSize; + } + + listing.Add(string.Empty); + } + + return new DeletionPlan(listing, fileCount, bytesReclaimable); + } + + /// + /// Writes a listing produced by to the console. + /// + /// The lines to write. + internal static void WriteListing(IReadOnlyList listing) + { + foreach (string line in listing) + { + Console.WriteLine(line); + } + } + + /// + /// Renders a byte count in the largest unit that leaves it above one. + /// + /// The count to render. + /// The rendered size. + internal static string FormatBytes(long bytes) => bytes switch + { + < 1024L => $"{bytes} B", + < 1024L * 1024 => $"{bytes / 1024.0:F1} KB", + < 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", + _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB", + }; +} + +/// +/// What deduplication would delete, described but not yet done. +/// +/// The per-group KEEP/DELETE lines, ready to print. +/// How many copies would be deleted. +/// How many bytes deleting them would free. +internal sealed class DeletionPlan(IReadOnlyList listing, int fileCount, long bytesReclaimable) +{ + /// + /// Gets the per-group KEEP/DELETE lines, in the order they should be printed. + /// + internal IReadOnlyList Listing { get; } = listing; + + /// + /// Gets the number of copies that would be deleted. + /// + internal int FileCount { get; } = fileCount; + + /// + /// Gets the number of bytes deleting them would free. + /// + internal long BytesReclaimable { get; } = bytesReclaimable; +} diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs index de3e770..6341f03 100644 --- a/FileDeduplicator/Verbs/Deduplicate.cs +++ b/FileDeduplicator/Verbs/Deduplicate.cs @@ -60,11 +60,23 @@ internal override void Run(Deduplicate options) return; } + // Step 4: Show exactly which copies go and which one stays. "Shortest filename wins" is + // a blunt enough policy -- report-final-DO-NOT-DELETE.pdf loses to r.pdf -- that seeing + // the paths is the only way to catch a bad outcome while it is still reversible. Scan + // and DryRun already print this; the verb that actually deletes is the one that needs it. + DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates); + Console.WriteLine($"Found {duplicates.Count} group(s) of duplicate files."); Console.WriteLine("Keeping the copy with the shortest filename in each group."); Console.WriteLine(); - // Step 4: Confirm with user + DuplicateReport.WriteListing(plan.Listing); + + Console.WriteLine($"Files to delete: {plan.FileCount}"); + Console.WriteLine($"Space to reclaim: {DuplicateReport.FormatBytes(plan.BytesReclaimable)}"); + Console.WriteLine(); + + // Step 5: Confirm with user Console.Write("Proceed with deletion? (y/N): "); string? confirmation = Console.ReadLine()?.Trim(); if (!string.Equals(confirmation, "y", StringComparison.OrdinalIgnoreCase)) @@ -75,13 +87,13 @@ internal override void Run(Deduplicate options) Console.WriteLine(); - // Step 5: Delete duplicates + // Step 6: Delete duplicates Console.WriteLine("Deleting duplicates..."); DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates); Console.WriteLine(); Console.WriteLine($"Deleted {result.DeletedCount} file(s)."); - Console.WriteLine($"Reclaimed {FormatBytes(result.BytesReclaimed)} of disk space."); + Console.WriteLine($"Reclaimed {DuplicateReport.FormatBytes(result.BytesReclaimed)} of disk space."); if (result.SkippedFiles.Count > 0) { @@ -100,12 +112,4 @@ internal override void Run(Deduplicate options) PathString = "."; } - - private static string FormatBytes(long bytes) => bytes switch - { - < 1024L => $"{bytes} B", - < 1024L * 1024 => $"{bytes / 1024.0:F1} KB", - < 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", - _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB", - }; } diff --git a/FileDeduplicator/Verbs/DryRun.cs b/FileDeduplicator/Verbs/DryRun.cs index 0b82186..f241d47 100644 --- a/FileDeduplicator/Verbs/DryRun.cs +++ b/FileDeduplicator/Verbs/DryRun.cs @@ -60,47 +60,18 @@ internal override void Run(DryRun options) return; } - long totalReclaimable = 0; - int totalDeletions = 0; + DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates); Console.WriteLine($"Found {duplicates.Count} group(s) of duplicate files:"); Console.WriteLine(); - foreach (DuplicateGroup group in duplicates) - { - AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); - - Console.WriteLine($" Hash: {group.Hash[..12]}... ({FormatBytes(group.FileSize)}, {group.Files.Count} copies)"); - Console.WriteLine($" KEEP: {keeper}"); - - foreach (AbsoluteFilePath file in group.Files) - { - if (file == keeper) - { - continue; - } - - Console.WriteLine($" DELETE: {file}"); - totalDeletions++; - totalReclaimable += group.FileSize; - } - - Console.WriteLine(); - } + DuplicateReport.WriteListing(plan.Listing); Console.WriteLine("--- Dry Run Summary ---"); Console.WriteLine($"Duplicate groups: {duplicates.Count}"); - Console.WriteLine($"Files to delete: {totalDeletions}"); - Console.WriteLine($"Space to reclaim: {FormatBytes(totalReclaimable)}"); + Console.WriteLine($"Files to delete: {plan.FileCount}"); + Console.WriteLine($"Space to reclaim: {DuplicateReport.FormatBytes(plan.BytesReclaimable)}"); PathString = "."; } - - private static string FormatBytes(long bytes) => bytes switch - { - < 1024L => $"{bytes} B", - < 1024L * 1024 => $"{bytes / 1024.0:F1} KB", - < 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", - _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB", - }; } diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs index 1a68f00..62bedc6 100644 --- a/FileDeduplicator/Verbs/Scan.cs +++ b/FileDeduplicator/Verbs/Scan.cs @@ -71,7 +71,7 @@ internal override void Run(Scan options) long wastedBytes = group.FileSize * (group.Files.Count - 1); totalWastedBytes += wastedBytes; - Console.WriteLine($" Hash: {group.Hash[..12]}... ({FormatBytes(group.FileSize)}, {group.Files.Count} copies)"); + Console.WriteLine($" Hash: {group.Hash[..12]}... ({DuplicateReport.FormatBytes(group.FileSize)}, {group.Files.Count} copies)"); foreach (AbsoluteFilePath file in group.Files) { @@ -83,18 +83,10 @@ internal override void Run(Scan options) } Console.WriteLine($"Total duplicate groups: {duplicates.Count}"); - Console.WriteLine($"Total wasted space: {FormatBytes(totalWastedBytes)}"); + Console.WriteLine($"Total wasted space: {DuplicateReport.FormatBytes(totalWastedBytes)}"); Console.WriteLine(); Console.WriteLine("Run the 'Deduplicate' command to remove duplicates."); PathString = "."; } - - private static string FormatBytes(long bytes) => bytes switch - { - < 1024L => $"{bytes} B", - < 1024L * 1024 => $"{bytes / 1024.0:F1} KB", - < 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", - _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB", - }; } diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 9b256eb..2ad684f 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -62,7 +62,7 @@ internal override void Run(Stats options) Console.WriteLine("=== FileDeduplicator Statistics ==="); Console.WriteLine(); Console.WriteLine($"Total files: {files.Count}"); - Console.WriteLine($"Total size: {FormatBytes(totalSize)}"); + Console.WriteLine($"Total size: {DuplicateReport.FormatBytes(totalSize)}"); Console.WriteLine($"Unique files: {uniqueFiles}"); Console.WriteLine($"Duplicate files: {duplicateFiles}"); Console.WriteLine($"Duplicate groups: {duplicates.Count}"); @@ -70,7 +70,7 @@ internal override void Run(Stats options) if (duplicates.Count > 0) { long wastedSpace = duplicates.Sum(g => g.FileSize * (g.Files.Count - 1)); - Console.WriteLine($"Wasted space: {FormatBytes(wastedSpace)}"); + Console.WriteLine($"Wasted space: {DuplicateReport.FormatBytes(wastedSpace)}"); Console.WriteLine(); // Extension breakdown @@ -101,18 +101,10 @@ internal override void Run(Stats options) foreach (DuplicateGroup group in largestGroups) { long wasted = group.FileSize * (group.Files.Count - 1); - Console.WriteLine($" {group.Hash[..12]}... - {group.Files.Count} copies, {FormatBytes(group.FileSize)} each, {FormatBytes(wasted)} wasted"); + Console.WriteLine($" {group.Hash[..12]}... - {group.Files.Count} copies, {DuplicateReport.FormatBytes(group.FileSize)} each, {DuplicateReport.FormatBytes(wasted)} wasted"); } } PathString = "."; } - - private static string FormatBytes(long bytes) => bytes switch - { - < 1024L => $"{bytes} B", - < 1024L * 1024 => $"{bytes / 1024.0:F1} KB", - < 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", - _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB", - }; } diff --git a/README.md b/README.md index 3cccd2e..1523afe 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Identical to Scan but formatted as a deletion preview with a summary of how many ### Deduplicate -Performs the actual deduplication. After scanning and displaying results, prompts for confirmation (`y/N`) before deleting any files. Reports the number of files deleted and disk space reclaimed. +Performs the actual deduplication. Prints the same per-group `KEEP`/`DELETE` listing DryRun does, so every path that is about to be removed is on screen before the confirmation (`y/N`) prompt is asked. Reports the number of files deleted and disk space reclaimed. ### Stats