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
69 changes: 69 additions & 0 deletions FileDeduplicator.Test/DeduplicatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -432,4 +432,73 @@
Assert.IsEmpty(result.Errors);
Assert.IsEmpty(result.SkippedFiles);
}

/// <summary>
/// A copy removed between the hash pass and grouping must not abort the run: the grouping
/// has to go on without it and still report the copies that remain.
/// </summary>
[TestMethod]
public void ACopyThatVanishedAfterHashingIsDroppedFromItsGroup()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath first = tree.Write("a.txt", "shared");
AbsoluteFilePath second = tree.Write("bb.txt", "shared");
AbsoluteFilePath vanishing = tree.Write("ccc.txt", "shared");
Dictionary<AbsoluteFilePath, string> hashes = FileHasher.HashFiles([first, second, vanishing]);

// Arrange -- something else removes it while the rest of a long hash pass runs
File.Delete(vanishing.WeakString);

// Act
IReadOnlyList<DuplicateGroup> duplicates = Duplicates(hashes);

// Assert
Assert.ContainsSingle(duplicates);
CollectionAssert.AreEquivalent(new[] { first, second }, duplicates[0].Files);

Check warning on line 458 in FileDeduplicator.Test/DeduplicatorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEquivalent'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_FileDeduplicator&issues=AaDuClyOzt-OTVCfju0q&open=AaDuClyOzt-OTVCfju0q&pullRequest=156
Assert.AreEqual("shared".Length, duplicates[0].FileSize);
}

/// <summary>
/// Whichever copy vanishes, including the one the group would have been sized from, a group
/// left with a single copy is no longer a duplicate group.
/// </summary>
/// <param name="vanishingIndex">Which of the two copies disappears.</param>
[TestMethod]
[DataRow(0)]
[DataRow(1)]
public void AGroupLeftWithOneCopyIsDropped(int vanishingIndex)
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath[] copies = [tree.Write("a.txt", "shared"), tree.Write("bb.txt", "shared")];
Dictionary<AbsoluteFilePath, string> hashes = FileHasher.HashFiles(copies);
File.Delete(copies[vanishingIndex].WeakString);

// Act
IReadOnlyList<DuplicateGroup> duplicates = Duplicates(hashes);

// Assert
Assert.IsEmpty(duplicates);
}

/// <summary>
/// Stats sizes every file that hashed; one that has since disappeared contributes nothing
/// rather than aborting the report.
/// </summary>
[TestMethod]
public void TotalSizeSkipsAFileThatVanishedAfterHashing()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath kept = tree.Write("a.txt", "four");
AbsoluteFilePath vanishing = tree.Write("c.txt", "unrelated");
File.Delete(vanishing.WeakString);

// Act
long total = Deduplicator.TotalSize([kept, vanishing]);

// Assert
Assert.AreEqual("four".Length, total);
}
}
73 changes: 67 additions & 6 deletions FileDeduplicator/Deduplicator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,71 @@ internal static Dictionary<string, List<AbsoluteFilePath>> GroupByHash(Dictionar
return groups;
}

internal static IReadOnlyList<DuplicateGroup> FindDuplicates(Dictionary<string, List<AbsoluteFilePath>> hashGroups) =>
[.. hashGroups
.Where(kvp => kvp.Value.Count > 1)
.Select(kvp => new DuplicateGroup(kvp.Key, kvp.Value))];
/// <summary>
/// Builds the duplicate groups from the hash groups, sizing each from the copies still on disk.
/// </summary>
/// <remarks>
/// The hash pass can take minutes on a large tree, and the directories this is pointed at are
/// often live, so a file that hashed may be gone by now. A vanished copy is dropped rather than
/// allowed to abort the run, and a group it leaves with a single copy is no longer a group.
/// </remarks>
/// <param name="hashGroups">The files that hashed, grouped by hash.</param>
/// <returns>Every group that still has at least two copies.</returns>
internal static IReadOnlyList<DuplicateGroup> FindDuplicates(Dictionary<string, List<AbsoluteFilePath>> hashGroups)
{
List<DuplicateGroup> duplicates = [];

foreach (KeyValuePair<string, List<AbsoluteFilePath>> kvp in hashGroups.Where(kvp => kvp.Value.Count > 1))
{
// Each copy is sized exactly once, so the filter and the size it reports cannot disagree
// about a file that disappears between two reads.
(AbsoluteFilePath File, long Size)[] present = [.. kvp.Value
.Select(file => (File: file, Size: TryGetSize(file, out long size) ? size : (long?)null))
.Where(copy => copy.Size.HasValue)
.Select(copy => (copy.File, copy.Size!.Value))];

if (present.Length > 1)
{
duplicates.Add(new DuplicateGroup(kvp.Key, [.. present.Select(copy => copy.File)], present[0].Size));
}
}

return duplicates;
}

/// <summary>
/// Sums the sizes of the given files, counting nothing for one that is no longer there.
/// </summary>
/// <param name="files">The files to size.</param>
/// <returns>The total size in bytes of the files that could still be sized.</returns>
internal static long TotalSize(IEnumerable<AbsoluteFilePath> files) =>
files.Sum(file => TryGetSize(file, out long size) ? size : 0);

/// <summary>
/// Reads a file's size, tolerating a file that has disappeared or can no longer be reached.
/// </summary>
/// <param name="file">The file to size.</param>
/// <param name="size">The size in bytes, when it could be read.</param>
/// <returns><see langword="true"/> if the file is still there and its size was read.</returns>
private static bool TryGetSize(AbsoluteFilePath file, out long size)
{
try
{
size = new FileInfo(file.WeakString).Length;
return true;
}
catch (IOException)
{
// FileNotFoundException and DirectoryNotFoundException both derive from this.
size = 0;
return false;
}
catch (UnauthorizedAccessException)
{
size = 0;
return false;
}
}

internal static AbsoluteFilePath SelectFileToKeep(List<AbsoluteFilePath> duplicates) =>
duplicates.OrderBy(f => f.FileName.WeakString.Length).ThenBy(f => f.WeakString, StringComparer.Ordinal).First();
Expand Down Expand Up @@ -155,11 +216,11 @@ private static void Skip(AbsoluteFilePath file, string? reason, List<SkippedFile
}
}

internal sealed class DuplicateGroup(string hash, List<AbsoluteFilePath> files)
internal sealed class DuplicateGroup(string hash, List<AbsoluteFilePath> files, long fileSize)
{
internal string Hash { get; } = hash;
internal List<AbsoluteFilePath> Files { get; } = files;
internal long FileSize { get; } = new FileInfo(files[0].WeakString).Length;
internal long FileSize { get; } = fileSize;
}

internal sealed class DeduplicationResult(int deletedCount, long bytesReclaimed, List<string> errors, List<SkippedFile> skippedFiles)
Expand Down
2 changes: 1 addition & 1 deletion FileDeduplicator/Verbs/Stats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
// Every count below is taken over the files that hashed. 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 = fileHashes.Keys.Sum(f => new FileInfo(f.WeakString).Length);
long totalSize = Deduplicator.TotalSize(fileHashes.Keys);
int uniqueFiles = hashGroups.Count;
int duplicateFiles = fileHashes.Count - uniqueFiles;
int unreadableFiles = files.Count - fileHashes.Count;
Expand All @@ -84,7 +84,7 @@

// Extension breakdown
Dictionary<string, int> extensionCounts = [];
foreach (DuplicateGroup group in duplicates)

Check warning on line 87 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 87 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 87 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 87 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)
{
string ext = System.IO.Path.GetExtension(group.Files[0].WeakString);
if (string.IsNullOrEmpty(ext))
Expand Down
Loading