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;
+ }
}