diff --git a/FileDeduplicator.Test/FileScannerAndHasherTests.cs b/FileDeduplicator.Test/FileScannerAndHasherTests.cs index cb97c57..e059b5e 100644 --- a/FileDeduplicator.Test/FileScannerAndHasherTests.cs +++ b/FileDeduplicator.Test/FileScannerAndHasherTests.cs @@ -68,6 +68,25 @@ public void ScanOfAMissingDirectoryReturnsNothing() Assert.IsEmpty(files); } + /// + /// A path to a file, rather than the folder holding it, must return an empty list rather than + /// throwing, so pasting a file path does not crash the tool. + /// + [TestMethod] + public void ScanOfAFileReturnsNothing() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath file = tree.Write("a/f.txt", "content"); + AbsoluteDirectoryPath notADirectory = file.WeakString.As(); + + // Act + IReadOnlyList files = FileScanner.ScanForFiles(notADirectory); + + // Assert + Assert.IsEmpty(files); + } + /// /// One unreadable directory must not abort the scan. Enumeration is lazy, so the refusal /// arrives partway through the walk, after files elsewhere in the tree have already been found diff --git a/FileDeduplicator.Test/VerbOutputTests.cs b/FileDeduplicator.Test/VerbOutputTests.cs index f650ade..4aad89d 100644 --- a/FileDeduplicator.Test/VerbOutputTests.cs +++ b/FileDeduplicator.Test/VerbOutputTests.cs @@ -36,6 +36,35 @@ private static void WriteOneGroup(TempTree tree, out AbsoluteFilePath keeper, ou _ = tree.Write("unique.txt", "beta"); } + /// + /// Every verb given a file instead of a directory says so in one line and deletes nothing. + /// + [TestMethod] + public void EveryVerbReportsAFilePathAsNotADirectory() + { + // Arrange + using TempTree tree = new(); + AbsoluteFilePath file = tree.Write("a/f.txt", "content"); + string path = file.WeakString; + + // Act + string[] outputs = + [ + ConsoleCapture.Run(new Scan { PathString = path }), + ConsoleCapture.Run(new DryRun { PathString = path }), + ConsoleCapture.Run(new Stats { PathString = path }), + ConsoleCapture.Run(new Deduplicate { PathString = path }, "y"), + ]; + + // Assert + foreach (string output in outputs) + { + Assert.Contains($"Not a directory: {path}", output); + } + + Assert.IsTrue(TempTree.Exists(file), "The file named as the root must be left alone."); + } + /// /// DryRun names the keeper and every other copy, totals them, and deletes nothing. /// diff --git a/FileDeduplicator/FileScanner.cs b/FileDeduplicator/FileScanner.cs index c9db82f..ed4ccca 100644 --- a/FileDeduplicator/FileScanner.cs +++ b/FileDeduplicator/FileScanner.cs @@ -44,7 +44,16 @@ internal static class FileScanner internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path) { - if (!path.Exists) + // path.Exists is also true for a file, which Directory.EnumerateFiles then rejects with an + // unhandled DirectoryNotFoundException. A path tab-completed one level too far is an easy + // mistake, so it gets the same one-line error as a path that is not there at all. + if (File.Exists(path.WeakString)) + { + Console.WriteLine($"Not a directory: {path}"); + return []; + } + + if (!Directory.Exists(path.WeakString)) { Console.WriteLine($"Directory not found: {path}"); return [];