diff --git a/FileDeduplicator.Test/VerbOutputTests.cs b/FileDeduplicator.Test/VerbOutputTests.cs index 1f696f4..c2f1d05 100644 --- a/FileDeduplicator.Test/VerbOutputTests.cs +++ b/FileDeduplicator.Test/VerbOutputTests.cs @@ -142,7 +142,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); @@ -181,6 +181,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 file that cannot be hashed is named by its full path, so the failing copy can be found /// among others sharing its file name. diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 04fe298..097a25b 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -82,22 +82,10 @@ internal override void Run(Stats options) Console.WriteLine($"Wasted space: {DuplicateReport.FormatBytes(wastedSpace)}"); Console.WriteLine(); - // Extension breakdown - Dictionary extensionCounts = []; - foreach (DuplicateGroup group in duplicates) - { - string ext = System.IO.Path.GetExtension(group.Files[0].WeakString); - if (string.IsNullOrEmpty(ext)) - { - ext = "(no extension)"; - } - - extensionCounts.TryGetValue(ext, out int count); - extensionCounts[ext] = count + group.Files.Count; - } + Dictionary extensionCounts = CountRedundantCopiesByExtension(duplicates); 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)"); } @@ -114,4 +102,36 @@ 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); + 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; + } + } + + return extensionCounts; + } }