From a28772d1bbedfebf004db9a821e2ae7f1cdf6246 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:28:45 +0000 Subject: [PATCH 1/3] Count each duplicate under its own extension in Stats [patch] The extension breakdown credited a whole group to the extension of Files[0], which included the copy that is kept and was whichever copy the parallel hash listed first. Mixed-extension duplicates landed under one extension that changed between runs, and the totals exceeded "Duplicate files". Each redundant copy is now counted under its own extension, the keeper is left out, and ties are ordered by extension so the output is stable. Fixes #146 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5ZFyLG9PgeKn5RpzhWEUQ --- FileDeduplicator.Test/VerbOutputTests.cs | 27 +++++++++++++++++++++++- FileDeduplicator/Verbs/Stats.cs | 24 ++++++++++++++------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/FileDeduplicator.Test/VerbOutputTests.cs b/FileDeduplicator.Test/VerbOutputTests.cs index f650ade..b2f7d57 100644 --- a/FileDeduplicator.Test/VerbOutputTests.cs +++ b/FileDeduplicator.Test/VerbOutputTests.cs @@ -113,7 +113,7 @@ public void StatsReportsTotalsForTheTree() 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(".txt: 2 file(s)", output); Assert.Contains("Largest duplicate groups (by wasted space):", output); Assert.Contains("3 copies, 5 B each, 10 B wasted", output); @@ -152,6 +152,31 @@ public void StatsReportsAFileItCannotHashAsUnreadable() Assert.Contains("Duplicate groups: 0", output); } + /// + /// The extension breakdown counts each redundant copy under its own extension, leaves out the + /// copy that is kept, and so sums to "Duplicate files" and reads the same on every run. + /// + [TestMethod] + public void StatsBreaksDuplicatesDownByEachCopysOwnExtension() + { + // Arrange -- the keeper is photo.jpg, the shortest name + using TempTree tree = new(); + _ = tree.Write("photo.jpg", "same picture"); + _ = tree.Write("photo-copy.png", "same picture"); + _ = tree.Write("backup.bak", "same picture"); + + // Act + string[] outputs = [.. Enumerable.Range(0, 3).Select(_ => ConsoleCapture.Normalize(ConsoleCapture.Run(new Stats { PathString = tree.Root.WeakString })))]; + + // Assert + foreach (string output in outputs) + { + Assert.Contains("Duplicate files: 2", output); + Assert.Contains("Duplicate files by extension:\n.bak: 1 file(s)\n.png: 1 file(s)\n", output); + Assert.DoesNotContain(".jpg:", output); + } + } + /// /// A tree where every file hashes has no unreadable line at all. /// diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 04fe298..ab1ded7 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -82,22 +82,30 @@ internal override void Run(Stats options) Console.WriteLine($"Wasted space: {DuplicateReport.FormatBytes(wastedSpace)}"); Console.WriteLine(); - // Extension breakdown + // Extension breakdown. Each redundant copy is counted under its own extension, and the + // copy that would be kept is left out, so the breakdown sums to "Duplicate files" above. + // Crediting a whole group to Files[0] counted the keeper too, and picked whichever copy + // the parallel hash happened to list first, so the same tree reported a different + // extension from run to run. Dictionary extensionCounts = []; foreach (DuplicateGroup group in duplicates) { - string ext = System.IO.Path.GetExtension(group.Files[0].WeakString); - if (string.IsNullOrEmpty(ext)) + AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); + foreach (AbsoluteFilePath file in group.Files.Where(f => f != keeper)) { - ext = "(no extension)"; + string ext = System.IO.Path.GetExtension(file.WeakString); + if (string.IsNullOrEmpty(ext)) + { + ext = "(no extension)"; + } + + extensionCounts.TryGetValue(ext, out int count); + extensionCounts[ext] = count + 1; } - - extensionCounts.TryGetValue(ext, out int count); - extensionCounts[ext] = count + group.Files.Count; } Console.WriteLine("Duplicate files by extension:"); - foreach (KeyValuePair kvp in extensionCounts.OrderByDescending(kvp => kvp.Value)) + foreach (KeyValuePair kvp in extensionCounts.OrderByDescending(kvp => kvp.Value).ThenBy(kvp => kvp.Key, StringComparer.Ordinal)) { Console.WriteLine($" {kvp.Key}: {kvp.Value} file(s)"); } From 38a2897a1f0eb9571828ba5d7995adb02f59e045 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:35:08 +0000 Subject: [PATCH 2/3] Extract the Stats extension breakdown into its own method Brings Stats.Run back under SonarCloud's cognitive complexity limit (S3776). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5ZFyLG9PgeKn5RpzhWEUQ --- FileDeduplicator/Verbs/Stats.cs | 55 ++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index ab1ded7..bd964fb 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -82,27 +82,7 @@ internal override void Run(Stats options) Console.WriteLine($"Wasted space: {DuplicateReport.FormatBytes(wastedSpace)}"); Console.WriteLine(); - // Extension breakdown. Each redundant copy is counted under its own extension, and the - // copy that would be kept is left out, so the breakdown sums to "Duplicate files" above. - // Crediting a whole group to Files[0] counted the keeper too, and picked whichever copy - // the parallel hash happened to list first, so the same tree reported a different - // extension from run to run. - Dictionary extensionCounts = []; - foreach (DuplicateGroup group in duplicates) - { - AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); - foreach (AbsoluteFilePath file in group.Files.Where(f => f != keeper)) - { - string ext = System.IO.Path.GetExtension(file.WeakString); - if (string.IsNullOrEmpty(ext)) - { - ext = "(no extension)"; - } - - extensionCounts.TryGetValue(ext, out int count); - extensionCounts[ext] = count + 1; - } - } + Dictionary extensionCounts = CountRedundantCopiesByExtension(duplicates); Console.WriteLine("Duplicate files by extension:"); foreach (KeyValuePair kvp in extensionCounts.OrderByDescending(kvp => kvp.Value).ThenBy(kvp => kvp.Key, StringComparer.Ordinal)) @@ -122,4 +102,37 @@ internal override void Run(Stats options) } } } + + /// + /// Counts each redundant copy under its own extension, leaving out the copy that is kept, so the + /// breakdown sums to "Duplicate files". + /// + /// + /// Crediting a whole group to Files[0] counted the keeper too, and picked whichever copy + /// the parallel hash happened to list first, so the same tree reported a different extension + /// from run to run. + /// + /// The duplicate groups to break down. + /// The number of redundant copies per extension. + private static Dictionary CountRedundantCopiesByExtension(IReadOnlyList duplicates) + { + Dictionary extensionCounts = []; + foreach (DuplicateGroup group in duplicates) + { + AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); + foreach (AbsoluteFilePath file in group.Files.Where(f => f != keeper)) + { + string ext = System.IO.Path.GetExtension(file.WeakString); + if (string.IsNullOrEmpty(ext)) + { + ext = "(no extension)"; + } + + extensionCounts.TryGetValue(ext, out int count); + extensionCounts[ext] = count + 1; + } + } + + return extensionCounts; + } } From 1df38c55800091dd8c86530056ed017a56fe5274 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:37:04 +0000 Subject: [PATCH 3/3] Project redundant copies to their extensions with Select Addresses the code-quality review on this PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5ZFyLG9PgeKn5RpzhWEUQ --- FileDeduplicator/Verbs/Stats.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index bd964fb..097a25b 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -120,14 +120,13 @@ private static Dictionary CountRedundantCopiesByExtension(IReadOnly foreach (DuplicateGroup group in duplicates) { AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files); - foreach (AbsoluteFilePath file in group.Files.Where(f => f != keeper)) - { - string ext = System.IO.Path.GetExtension(file.WeakString); - if (string.IsNullOrEmpty(ext)) - { - ext = "(no extension)"; - } + IEnumerable extensions = group.Files + .Where(f => f != keeper) + .Select(f => System.IO.Path.GetExtension(f.WeakString)) + .Select(ext => string.IsNullOrEmpty(ext) ? "(no extension)" : ext); + foreach (string ext in extensions) + { extensionCounts.TryGetValue(ext, out int count); extensionCounts[ext] = count + 1; }