Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion FileDeduplicator.Test/VerbOutputTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -181,6 +181,31 @@ public void StatsReportsAFileItCannotHashAsUnreadable()
Assert.Contains("Duplicate groups: 0", output);
}

/// <summary>
/// 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.
/// </summary>
[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);
}
}

/// <summary>
/// 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.
Expand Down
48 changes: 34 additions & 14 deletions FileDeduplicator/Verbs/Stats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,10 @@
Console.WriteLine($"Wasted space: {DuplicateReport.FormatBytes(wastedSpace)}");
Console.WriteLine();

// Extension breakdown
Dictionary<string, int> 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<string, int> extensionCounts = CountRedundantCopiesByExtension(duplicates);

Console.WriteLine("Duplicate files by extension:");
foreach (KeyValuePair<string, int> kvp in extensionCounts.OrderByDescending(kvp => kvp.Value))
foreach (KeyValuePair<string, int> kvp in extensionCounts.OrderByDescending(kvp => kvp.Value).ThenBy(kvp => kvp.Key, StringComparer.Ordinal))
{
Console.WriteLine($" {kvp.Key}: {kvp.Value} file(s)");
}
Expand All @@ -114,4 +102,36 @@
}
}
}

/// <summary>
/// Counts each redundant copy under its own extension, leaving out the copy that is kept, so the
/// breakdown sums to "Duplicate files".
/// </summary>
/// <remarks>
/// Crediting a whole group to <c>Files[0]</c> 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.
/// </remarks>
/// <param name="duplicates">The duplicate groups to break down.</param>
/// <returns>The number of redundant copies per extension.</returns>
private static Dictionary<string, int> CountRedundantCopiesByExtension(IReadOnlyList<DuplicateGroup> duplicates)
{
Dictionary<string, int> extensionCounts = [];
foreach (DuplicateGroup group in duplicates)

Check warning on line 120 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)

Check warning on line 120 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)

Check warning on line 120 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)

Check warning on line 120 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)
{
AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files);
IEnumerable<string> 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;
}
}
Loading