diff --git a/FileDeduplicator.Test/SameFileTests.cs b/FileDeduplicator.Test/SameFileTests.cs index 3d4553b..de4aab9 100644 --- a/FileDeduplicator.Test/SameFileTests.cs +++ b/FileDeduplicator.Test/SameFileTests.cs @@ -117,6 +117,55 @@ public void AHardlinkIsCollapsedIntoItsFileAlongsideAGenuineCopy() CollectionAssert.AreEquivalent(new[] { original, copy }, duplicates[0].Files); } + /// + /// Stats counts a file and its hardlink as one file: no duplicate, and its size once. + /// + [TestMethod] + public void StatsCountsAFileAndItsHardlinkAsOneFile() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath original = tree.Write("a.txt", "hello\n"); + _ = MakeHardLink(tree, original, "b.txt"); + _ = tree.Write("c.txt", "world-unique-123\n"); + + // Act + string output = ConsoleCapture.Normalize(ConsoleCapture.Run(new Stats { PathString = tree.Root.WeakString })); + + // Assert -- 6 B for a.txt once, plus 17 B for c.txt + Assert.Contains("Total files: 3", output); + Assert.Contains("Total size: 23 B", output); + Assert.Contains("Unique files: 2", output); + Assert.Contains("Duplicate files: 0", output); + Assert.Contains("Duplicate groups: 0", output); + Assert.Contains("Extra paths to the same file (counted once): 1", output); + } + + /// + /// With a genuine copy and a hardlink pair, Stats' duplicate count agrees with its own extension + /// breakdown and with what DryRun would delete. + /// + [TestMethod] + public void StatsAgreesWithDryRunForACopyAndAHardlinkPair() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("k.bin", "duplicate content"); + AbsoluteFilePath copy = tree.Write("sub/copy-long-name.bin", "duplicate content"); + _ = MakeHardLink(tree, copy, "sub/copy-long-name-2.bin"); + + // Act + string stats = ConsoleCapture.Normalize(ConsoleCapture.Run(new Stats { PathString = tree.Root.WeakString })); + string dryRun = ConsoleCapture.Normalize(ConsoleCapture.Run(new DryRun { PathString = tree.Root.WeakString })); + + // Assert -- two 17-byte files on disk, one of them redundant + Assert.Contains("Total size: 34 B", stats); + Assert.Contains("Duplicate files: 1", stats); + Assert.Contains("Duplicate groups: 1", stats); + Assert.Contains(".bin: 1 file(s)", stats); + Assert.Contains("Files to delete: 1", dryRun); + } + /// /// The deletion loop refuses a path that leads to the keeper's own file, whatever grouping it is /// handed, and reports it instead. diff --git a/FileDeduplicator/Deduplicator.cs b/FileDeduplicator/Deduplicator.cs index a3320f2..3af2feb 100644 --- a/FileDeduplicator/Deduplicator.cs +++ b/FileDeduplicator/Deduplicator.cs @@ -75,7 +75,7 @@ internal static IReadOnlyList FindDuplicates(Dictionary /// still stands between it and the keeper. /// - private static IEnumerable CollapseSameFile(IEnumerable paths) + internal static IEnumerable CollapseSameFile(IEnumerable paths) { HashSet seen = []; diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 14d7cae..ac11972 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -54,9 +54,15 @@ internal override void Run(Stats options) // size is unique and so are unique files without being read. HashFiles drops a file it cannot // read, so counting against the scanned list would report each one as a duplicate of nothing, // and sizing it would throw if it had vanished since the scan. - long totalSize = Deduplicator.TotalSize(scan.FileHashes.Keys.Concat(scan.UniqueSize)); + // + // Paths that lead to one file (a hardlink, a bind mount) are counted and sized once, as the + // other verbs do through FindDuplicates. Counting them per path reported a hardlink as a + // duplicate that DryRun and Deduplicate would never delete, and its size twice. + AbsoluteFilePath[] hashedFiles = [.. scan.HashGroups.Values.SelectMany(Deduplicator.CollapseSameFile)]; + long totalSize = Deduplicator.TotalSize(hashedFiles.Concat(scan.UniqueSize)); int uniqueFiles = scan.HashGroups.Count + scan.UniqueSize.Count; - int duplicateFiles = scan.FileHashes.Count - scan.HashGroups.Count - emptyCopies; + int duplicateFiles = duplicates.Sum(g => g.Files.Count - 1); + int sameFilePaths = scan.FileHashes.Count - hashedFiles.Length; int unreadableFiles = scan.Files.Count - scan.FileHashes.Count - scan.UniqueSize.Count; Console.WriteLine("=== FileDeduplicator Statistics ==="); @@ -76,6 +82,11 @@ internal override void Run(Stats options) Console.WriteLine($"Empty duplicates (never deleted): {emptyCopies}"); } + if (sameFilePaths > 0) + { + Console.WriteLine($"Extra paths to the same file (counted once): {sameFilePaths}"); + } + if (duplicates.Length > 0) { long wastedSpace = duplicates.Sum(g => g.FileSize * (g.Files.Count - 1));