From 43baeaa2ddbbd60537060e4378048cb0e3b0569c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:27:45 +0000 Subject: [PATCH 1/2] Exit non-zero when a run fails, is aborted, or cannot parse its arguments [patch] Main and every verb returned void, so the process exited 0 for a missing directory, an abort, failed deletions and unknown options alike. Main now returns the verb's exit code through MapResult: 0 on success (and for --help/--version), 1 when the root is missing or not a directory, the user aborts at a prompt, or Deduplicate finishes with deletion errors, and 2 when the arguments cannot be parsed. FileScanner.TryScanForFiles tells a root that could not be scanned apart from an empty one. Fixes ktsu-dev/FileDeduplicator#144 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5RBnMaTjZdMDRNttjeZ3u --- FileDeduplicator.Test/ConsoleCapture.cs | 15 ++- FileDeduplicator.Test/ExitCodeTests.cs | 148 ++++++++++++++++++++++++ FileDeduplicator/FileScanner.cs | 26 ++++- FileDeduplicator/Program.cs | 25 +++- FileDeduplicator/Verbs/BaseVerb.cs | 21 +++- FileDeduplicator/Verbs/Deduplicate.cs | 17 ++- FileDeduplicator/Verbs/DryRun.cs | 13 ++- FileDeduplicator/Verbs/Menu.cs | 2 +- FileDeduplicator/Verbs/Scan.cs | 13 ++- FileDeduplicator/Verbs/Stats.cs | 12 +- 10 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 FileDeduplicator.Test/ExitCodeTests.cs diff --git a/FileDeduplicator.Test/ConsoleCapture.cs b/FileDeduplicator.Test/ConsoleCapture.cs index e8b5971..56cd236 100644 --- a/FileDeduplicator.Test/ConsoleCapture.cs +++ b/FileDeduplicator.Test/ConsoleCapture.cs @@ -8,7 +8,7 @@ namespace ktsu.FileDeduplicator.Test; /// Runs a verb with the console redirected, and hands back everything it wrote. /// /// -/// The verbs have no return value and no output abstraction -- what they do is what they print -- +/// The verbs return only an exit code and have no output abstraction -- what they do is what they print -- /// so the console is the only surface a test can assert against. Redirecting it is global state, /// which is why every class using this is marked [DoNotParallelize]. /// @@ -20,7 +20,16 @@ internal static class ConsoleCapture /// The verb to run. /// Lines the verb's prompts will read, or nothing. /// Everything the verb wrote to the console. - internal static string Run(BaseVerb verb, string stdin = "") + internal static string Run(BaseVerb verb, string stdin = "") => Run(verb, stdin, out _); + + /// + /// Runs a verb, feeding it the given answers on stdin, and hands back its exit code too. + /// + /// The verb to run. + /// Lines the verb's prompts will read, or nothing. + /// The exit code the verb returned. + /// Everything the verb wrote to the console. + internal static string Run(BaseVerb verb, string stdin, out int exitCode) { TextWriter originalOut = Console.Out; TextReader originalIn = Console.In; @@ -32,7 +41,7 @@ internal static string Run(BaseVerb verb, string stdin = "") Console.SetOut(captured); Console.SetIn(answers); - verb.Run(); + exitCode = verb.Run(); return captured.ToString(); } diff --git a/FileDeduplicator.Test/ExitCodeTests.cs b/FileDeduplicator.Test/ExitCodeTests.cs new file mode 100644 index 0000000..0716e51 --- /dev/null +++ b/FileDeduplicator.Test/ExitCodeTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using CommandLine; + +using ktsu.FileDeduplicator.Verbs; +using ktsu.Semantics.Paths; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a failed or aborted run exits non-zero, so a script can tell it from a successful one. +/// +/// +/// Main and every verb returned , so the process exited 0 for a missing +/// directory, an abort, failed deletions and unparseable arguments alike, and +/// Deduplicate -p /data && echo done reported success when nothing was deleted +/// (ktsu-dev/FileDeduplicator#144). +/// +[TestClass] +[DoNotParallelize] +public sealed class ExitCodeTests +{ + private static int ExitCodeOf(BaseVerb verb, string stdin = "") + { + _ = ConsoleCapture.Run(verb, stdin, out int exitCode); + return exitCode; + } + + private static int ExitCodeOfCommandLine(params string[] args) + { + TextWriter originalOut = Console.Out; + TextReader originalIn = Console.In; + try + { + using StringWriter captured = new(); + using StringReader noInput = new(string.Empty); + Console.SetOut(captured); + Console.SetIn(noInput); + + using Parser parser = new(settings => settings.HelpWriter = captured); + return Program.Run(parser, args); + } + finally + { + Console.SetOut(originalOut); + Console.SetIn(originalIn); + } + } + + [TestMethod] + public void EveryVerbExitsOneForAMissingDirectory() + { + using TempTree tree = new(); + string missing = Path.Combine(tree.Root.WeakString, "not-there"); + + Assert.AreEqual(1, ExitCodeOf(new Scan { PathString = missing }), "Scan"); + Assert.AreEqual(1, ExitCodeOf(new DryRun { PathString = missing }), "DryRun"); + Assert.AreEqual(1, ExitCodeOf(new Stats { PathString = missing }), "Stats"); + Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = missing }, "y"), "Deduplicate"); + } + + [TestMethod] + public void EveryVerbExitsOneForAFileGivenAsTheRoot() + { + using TempTree tree = new(); + string file = tree.Write("f.txt", "content").WeakString; + + Assert.AreEqual(1, ExitCodeOf(new Scan { PathString = file }), "Scan"); + Assert.AreEqual(1, ExitCodeOf(new DryRun { PathString = file }), "DryRun"); + Assert.AreEqual(1, ExitCodeOf(new Stats { PathString = file }), "Stats"); + Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = file }, "y"), "Deduplicate"); + } + + [TestMethod] + public void EveryVerbExitsZeroForADirectoryItCouldScan() + { + using TempTree empty = new(); + using TempTree duplicates = new(); + _ = duplicates.Write("a.txt", "alpha"); + _ = duplicates.Write("aa.txt", "alpha"); + + Assert.AreEqual(0, ExitCodeOf(new Scan { PathString = empty.Root.WeakString }), "Scan of an empty directory"); + Assert.AreEqual(0, ExitCodeOf(new Scan { PathString = duplicates.Root.WeakString }), "Scan"); + Assert.AreEqual(0, ExitCodeOf(new DryRun { PathString = duplicates.Root.WeakString }), "DryRun"); + Assert.AreEqual(0, ExitCodeOf(new Stats { PathString = duplicates.Root.WeakString }), "Stats"); + } + + [TestMethod] + public void AbortingAtThePathPromptExitsOne() => + Assert.AreEqual(1, ExitCodeOf(new Scan(), "\n")); + + [TestMethod] + public void DecliningTheDeletionExitsOne() + { + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("aa.txt", "alpha"); + + Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = tree.Root.WeakString }, "n")); + } + + [TestMethod] + public void DeduplicateExitsZeroWhenEveryDeletionSucceeds() + { + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + AbsoluteFilePath copy = tree.Write("aa.txt", "alpha"); + + Assert.AreEqual(0, ExitCodeOf(new Deduplicate { PathString = tree.Root.WeakString }, "y")); + Assert.IsFalse(TempTree.Exists(copy)); + } + + [TestMethod] + public void DeduplicateExitsOneWhenADeletionFails() + { + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + AbsoluteFilePath undeletable = tree.Write("locked/aa.txt", "alpha"); + + using DeletionBlock block = new(undeletable); + if (!block.IsEnforced) + { + Assert.Inconclusive("This process deletes through a write-protected directory, so the refusal under test cannot be staged. Run the tests as an unprivileged user."); + } + + Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = tree.Root.WeakString }, "y")); + } + + [TestMethod] + public void AnUnknownOptionExitsTwo() => + Assert.AreEqual(2, ExitCodeOfCommandLine("Scan", "--nope")); + + [TestMethod] + public void AskingForHelpExitsZero() => + Assert.AreEqual(0, ExitCodeOfCommandLine("--help")); + + [TestMethod] + public void AParsedVerbReturnsItsOwnExitCode() + { + using TempTree tree = new(); + string missing = Path.Combine(tree.Root.WeakString, "not-there"); + + Assert.AreEqual(1, ExitCodeOfCommandLine("Scan", "-p", missing)); + Assert.AreEqual(0, ExitCodeOfCommandLine("Scan", "-p", tree.Root.WeakString)); + } +} diff --git a/FileDeduplicator/FileScanner.cs b/FileDeduplicator/FileScanner.cs index ed4ccca..5ab3b43 100644 --- a/FileDeduplicator/FileScanner.cs +++ b/FileDeduplicator/FileScanner.cs @@ -44,22 +44,37 @@ internal static class FileScanner internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path) { + _ = TryScanForFiles(path, out IReadOnlyList files); + return files; + } + + /// + /// Lists the regular files under , telling a root that could not be scanned + /// apart from one that holds no files. + /// + /// The directory to scan. + /// The files found, or none when the root could not be scanned. + /// when the root is missing or is not a directory. + internal static bool TryScanForFiles(AbsoluteDirectoryPath path, out IReadOnlyList files) + { + files = []; + // 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 []; + return false; } if (!Directory.Exists(path.WeakString)) { Console.WriteLine($"Directory not found: {path}"); - return []; + return false; } - List files = []; + List found = []; foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", WalkOptions)) { // On Unix the enumeration also yields named pipes, sockets and device nodes. Opening a @@ -69,9 +84,10 @@ internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPa continue; } - files.Add(file.As()); + found.Add(file.As()); } - return files; + files = found; + return true; } } diff --git a/FileDeduplicator/Program.cs b/FileDeduplicator/Program.cs index 2dc6ce5..f67ada2 100644 --- a/FileDeduplicator/Program.cs +++ b/FileDeduplicator/Program.cs @@ -13,13 +13,30 @@ internal static class Program { internal static Type[] Verbs { get; } = LoadVerbs(); - private static void Main(string[] args) + /// + /// Runs the verb named on the command line. + /// + /// The command-line arguments. + /// + /// 0 on success, 1 when the verb failed or the user aborted, and 2 when the arguments could not be + /// parsed. Asking for help or the version is a success. + /// + private static int Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; - - _ = Parser.Default.ParseArguments(args, Verbs) - .WithParsed(task => task.Run()); + return Run(Parser.Default, args); } + /// + /// Parses with and runs the verb they name. + /// + /// The parser to use. + /// The command-line arguments. + /// The process exit code, as describes it. + internal static int Run(Parser parser, string[] args) => + parser.ParseArguments(args, Verbs).MapResult( + (BaseVerb verb) => verb.Run(), + errors => errors.IsHelp() || errors.IsVersion() ? 0 : 2); + private static Type[] LoadVerbs() => [.. Assembly.GetExecutingAssembly().GetTypes().Where(t => t.GetCustomAttribute() != null)]; } diff --git a/FileDeduplicator/Verbs/BaseVerb.cs b/FileDeduplicator/Verbs/BaseVerb.cs index 34c0bcf..1b661cd 100644 --- a/FileDeduplicator/Verbs/BaseVerb.cs +++ b/FileDeduplicator/Verbs/BaseVerb.cs @@ -18,11 +18,15 @@ internal abstract class BaseVerb : ICommand internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString).As(); - public abstract void Run(); + /// + /// Runs the verb. + /// + /// The process exit code: 0 on success, 1 when the run failed or the user aborted. + public abstract int Run(); internal virtual bool ValidateArgs() => true; - public void Execute() => Run(); + public void Execute() => _ = Run(); } internal abstract class BaseVerb : BaseVerb where T : BaseVerb @@ -30,7 +34,7 @@ internal abstract class BaseVerb : BaseVerb where T : BaseVerb private bool isActive = true; public override bool IsActive => isActive; - public override void Run() + public override int Run() { // The interactive menu runs the same instance every time its item is chosen, so the path is // put back on every exit, early returns and exceptions included. Otherwise the next run skips @@ -39,11 +43,11 @@ public override void Run() { if (!ValidateArgs()) { - return; + return 1; } isActive = false; - Run((T)this); + return Run((T)this); } finally { @@ -52,5 +56,10 @@ public override void Run() } } - internal abstract void Run(T options); + /// + /// Runs the verb once its arguments are validated. + /// + /// This verb, with its options parsed. + /// The process exit code: 0 on success, 1 when the run failed or the user aborted. + internal abstract int Run(T options); } diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs index e53eac2..9361a4d 100644 --- a/FileDeduplicator/Verbs/Deduplicate.cs +++ b/FileDeduplicator/Verbs/Deduplicate.cs @@ -29,20 +29,24 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override void Run(Deduplicate options) + internal override int Run(Deduplicate options) { Console.WriteLine($"Deduplicating: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - IReadOnlyList files = FileScanner.ScanForFiles(options.Path); + if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) + { + return 1; + } + Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return; + return 0; } // Step 2: Hash all files in parallel @@ -57,7 +61,7 @@ internal override void Run(Deduplicate options) if (duplicates.Count == 0) { Console.WriteLine("No duplicate files found."); - return; + return 0; } // Step 4: Show exactly which copies go and which one stays. "Shortest filename wins" is @@ -82,7 +86,7 @@ internal override void Run(Deduplicate options) if (!string.Equals(confirmation, "y", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("Aborted."); - return; + return 1; } Console.WriteLine(); @@ -108,6 +112,9 @@ internal override void Run(Deduplicate options) if (result.Errors.Count > 0) { Console.WriteLine($"Encountered {result.Errors.Count} error(s) during deletion."); + return 1; } + + return 0; } } diff --git a/FileDeduplicator/Verbs/DryRun.cs b/FileDeduplicator/Verbs/DryRun.cs index 3b5d70f..c69eedd 100644 --- a/FileDeduplicator/Verbs/DryRun.cs +++ b/FileDeduplicator/Verbs/DryRun.cs @@ -29,20 +29,24 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override void Run(DryRun options) + internal override int Run(DryRun options) { Console.WriteLine($"Dry run for: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - IReadOnlyList files = FileScanner.ScanForFiles(options.Path); + if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) + { + return 1; + } + Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return; + return 0; } // Step 2: Hash all files in parallel @@ -57,7 +61,7 @@ internal override void Run(DryRun options) if (duplicates.Count == 0) { Console.WriteLine("No duplicate files found."); - return; + return 0; } DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates); @@ -71,5 +75,6 @@ internal override void Run(DryRun options) Console.WriteLine($"Duplicate groups: {duplicates.Count}"); Console.WriteLine($"Files to delete: {plan.FileCount}"); Console.WriteLine($"Space to reclaim: {DuplicateReport.FormatBytes(plan.BytesReclaimable)}"); + return 0; } } diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs index 81bd611..984ea1d 100644 --- a/FileDeduplicator/Verbs/Menu.cs +++ b/FileDeduplicator/Verbs/Menu.cs @@ -15,7 +15,7 @@ namespace ktsu.FileDeduplicator.Verbs; [Verb("Menu", isDefault: true)] internal sealed class Menu : BaseVerb { - internal override void Run(Menu options) + internal override int Run(Menu options) { ScrollMenu scrollMenu = new() { diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs index a519d91..f133f89 100644 --- a/FileDeduplicator/Verbs/Scan.cs +++ b/FileDeduplicator/Verbs/Scan.cs @@ -29,20 +29,24 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override void Run(Scan options) + internal override int Run(Scan options) { Console.WriteLine($"Scanning: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - IReadOnlyList files = FileScanner.ScanForFiles(options.Path); + if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) + { + return 1; + } + Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return; + return 0; } // Step 2: Hash all files in parallel @@ -57,7 +61,7 @@ internal override void Run(Scan options) if (duplicates.Count == 0) { Console.WriteLine("No duplicate files found."); - return; + return 0; } // Step 4: Display results @@ -92,5 +96,6 @@ internal override void Run(Scan options) Console.WriteLine($"Total wasted space: {DuplicateReport.FormatBytes(totalWastedBytes)}"); Console.WriteLine(); Console.WriteLine("Run the 'Deduplicate' command to remove duplicates."); + return 0; } } diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 097a25b..52cb3ce 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -30,20 +30,24 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override void Run(Stats options) + internal override int Run(Stats options) { Console.WriteLine($"Analyzing: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - IReadOnlyList files = FileScanner.ScanForFiles(options.Path); + if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) + { + return 1; + } + Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return; + return 0; } // Step 2: Hash all files @@ -101,6 +105,8 @@ internal override void Run(Stats options) Console.WriteLine($" {group.Hash[..12]}... - {group.Files.Count} copies, {DuplicateReport.FormatBytes(group.FileSize)} each, {DuplicateReport.FormatBytes(wasted)} wasted"); } } + + return 0; } /// From f1d82b3c78e77fcbba8cee37bd2a5d4bb0726336 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:36:15 +0000 Subject: [PATCH 2/2] Check the root before a verb runs, so the exit code adds no lines to the copied pipeline SonarCloud failed the PR on 10.5% duplication on new code: the new return-value lines sat inside the scan pipeline that Scan, DryRun, Stats and Deduplicate each carry a copy of (#157). BaseVerb.Run now checks the root with FileScanner.IsScannableDirectory before the verb runs and returns the verb's ExitCode, which Deduplicate sets when the user declines or a deletion fails. Scan, DryRun and Stats go back to their main versions. The exit-code tests share their console capture and assertions instead of repeating them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5RBnMaTjZdMDRNttjeZ3u --- FileDeduplicator.Test/ConsoleCapture.cs | 14 ++++++-- FileDeduplicator.Test/ExitCodeTests.cs | 46 ++++++++++--------------- FileDeduplicator/FileScanner.cs | 34 +++++++++--------- FileDeduplicator/Verbs/BaseVerb.cs | 25 +++++++++----- FileDeduplicator/Verbs/Deduplicate.cs | 19 ++++------ FileDeduplicator/Verbs/DryRun.cs | 13 +++---- FileDeduplicator/Verbs/Menu.cs | 4 ++- FileDeduplicator/Verbs/Scan.cs | 13 +++---- FileDeduplicator/Verbs/Stats.cs | 12 ++----- 9 files changed, 85 insertions(+), 95 deletions(-) diff --git a/FileDeduplicator.Test/ConsoleCapture.cs b/FileDeduplicator.Test/ConsoleCapture.cs index 56cd236..fd06746 100644 --- a/FileDeduplicator.Test/ConsoleCapture.cs +++ b/FileDeduplicator.Test/ConsoleCapture.cs @@ -29,7 +29,17 @@ internal static class ConsoleCapture /// Lines the verb's prompts will read, or nothing. /// The exit code the verb returned. /// Everything the verb wrote to the console. - internal static string Run(BaseVerb verb, string stdin, out int exitCode) + internal static string Run(BaseVerb verb, string stdin, out int exitCode) => + Run(_ => verb.Run(), stdin, out exitCode); + + /// + /// Runs an action that returns an exit code, feeding it the given answers on stdin. + /// + /// The action to run. It is handed the writer standing in for the console. + /// Lines the action's prompts will read, or nothing. + /// The exit code the action returned. + /// Everything the action wrote to the console. + internal static string Run(Func action, string stdin, out int exitCode) { TextWriter originalOut = Console.Out; TextReader originalIn = Console.In; @@ -41,7 +51,7 @@ internal static string Run(BaseVerb verb, string stdin, out int exitCode) Console.SetOut(captured); Console.SetIn(answers); - exitCode = verb.Run(); + exitCode = action(captured); return captured.ToString(); } diff --git a/FileDeduplicator.Test/ExitCodeTests.cs b/FileDeduplicator.Test/ExitCodeTests.cs index 0716e51..90a69c7 100644 --- a/FileDeduplicator.Test/ExitCodeTests.cs +++ b/FileDeduplicator.Test/ExitCodeTests.cs @@ -30,47 +30,37 @@ private static int ExitCodeOf(BaseVerb verb, string stdin = "") private static int ExitCodeOfCommandLine(params string[] args) { - TextWriter originalOut = Console.Out; - TextReader originalIn = Console.In; - try - { - using StringWriter captured = new(); - using StringReader noInput = new(string.Empty); - Console.SetOut(captured); - Console.SetIn(noInput); + _ = ConsoleCapture.Run( + captured => + { + using Parser parser = new(settings => settings.HelpWriter = captured); + return Program.Run(parser, args); + }, + string.Empty, + out int exitCode); + return exitCode; + } - using Parser parser = new(settings => settings.HelpWriter = captured); - return Program.Run(parser, args); - } - finally - { - Console.SetOut(originalOut); - Console.SetIn(originalIn); - } + private static void AssertEveryVerbExitsOne(string path) + { + Assert.AreEqual(1, ExitCodeOf(new Scan { PathString = path }), "Scan"); + Assert.AreEqual(1, ExitCodeOf(new DryRun { PathString = path }), "DryRun"); + Assert.AreEqual(1, ExitCodeOf(new Stats { PathString = path }), "Stats"); + Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = path }, "y"), "Deduplicate"); } [TestMethod] public void EveryVerbExitsOneForAMissingDirectory() { using TempTree tree = new(); - string missing = Path.Combine(tree.Root.WeakString, "not-there"); - - Assert.AreEqual(1, ExitCodeOf(new Scan { PathString = missing }), "Scan"); - Assert.AreEqual(1, ExitCodeOf(new DryRun { PathString = missing }), "DryRun"); - Assert.AreEqual(1, ExitCodeOf(new Stats { PathString = missing }), "Stats"); - Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = missing }, "y"), "Deduplicate"); + AssertEveryVerbExitsOne(Path.Combine(tree.Root.WeakString, "not-there")); } [TestMethod] public void EveryVerbExitsOneForAFileGivenAsTheRoot() { using TempTree tree = new(); - string file = tree.Write("f.txt", "content").WeakString; - - Assert.AreEqual(1, ExitCodeOf(new Scan { PathString = file }), "Scan"); - Assert.AreEqual(1, ExitCodeOf(new DryRun { PathString = file }), "DryRun"); - Assert.AreEqual(1, ExitCodeOf(new Stats { PathString = file }), "Stats"); - Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = file }, "y"), "Deduplicate"); + AssertEveryVerbExitsOne(tree.Write("f.txt", "content").WeakString); } [TestMethod] diff --git a/FileDeduplicator/FileScanner.cs b/FileDeduplicator/FileScanner.cs index 5ab3b43..b6aea0a 100644 --- a/FileDeduplicator/FileScanner.cs +++ b/FileDeduplicator/FileScanner.cs @@ -42,23 +42,14 @@ internal static class FileScanner AttributesToSkip = FileAttributes.ReparsePoint, }; - internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path) - { - _ = TryScanForFiles(path, out IReadOnlyList files); - return files; - } - /// - /// Lists the regular files under , telling a root that could not be scanned - /// apart from one that holds no files. + /// Checks that is a directory that can be scanned, and says why in one + /// line when it is not. /// - /// The directory to scan. - /// The files found, or none when the root could not be scanned. + /// The root to check. /// when the root is missing or is not a directory. - internal static bool TryScanForFiles(AbsoluteDirectoryPath path, out IReadOnlyList files) + internal static bool IsScannableDirectory(AbsoluteDirectoryPath path) { - files = []; - // 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. @@ -74,7 +65,17 @@ internal static bool TryScanForFiles(AbsoluteDirectoryPath path, out IReadOnlyLi return false; } - List found = []; + return true; + } + + internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path) + { + if (!IsScannableDirectory(path)) + { + return []; + } + + List files = []; foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", WalkOptions)) { // On Unix the enumeration also yields named pipes, sockets and device nodes. Opening a @@ -84,10 +85,9 @@ internal static bool TryScanForFiles(AbsoluteDirectoryPath path, out IReadOnlyLi continue; } - found.Add(file.As()); + files.Add(file.As()); } - files = found; - return true; + return files; } } diff --git a/FileDeduplicator/Verbs/BaseVerb.cs b/FileDeduplicator/Verbs/BaseVerb.cs index 1b661cd..ec1626d 100644 --- a/FileDeduplicator/Verbs/BaseVerb.cs +++ b/FileDeduplicator/Verbs/BaseVerb.cs @@ -24,6 +24,18 @@ internal abstract class BaseVerb : ICommand /// The process exit code: 0 on success, 1 when the run failed or the user aborted. public abstract int Run(); + /// + /// Gets or sets the exit code of the current run. It starts at 0, and a verb sets it to 1 when the + /// run fails or the user aborts. + /// + internal int ExitCode { get; set; } + + /// + /// Gets whether the verb works on the directory named by , which then has to + /// exist before the verb runs. + /// + internal virtual bool ScansADirectory => true; + internal virtual bool ValidateArgs() => true; public void Execute() => _ = Run(); @@ -41,13 +53,15 @@ public override int Run() // the prompt and works on the previous directory. try { - if (!ValidateArgs()) + ExitCode = 0; + if (!ValidateArgs() || (ScansADirectory && !FileScanner.IsScannableDirectory(Path))) { return 1; } isActive = false; - return Run((T)this); + Run((T)this); + return ExitCode; } finally { @@ -56,10 +70,5 @@ public override int Run() } } - /// - /// Runs the verb once its arguments are validated. - /// - /// This verb, with its options parsed. - /// The process exit code: 0 on success, 1 when the run failed or the user aborted. - internal abstract int Run(T options); + internal abstract void Run(T options); } diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs index 9361a4d..2c67ef6 100644 --- a/FileDeduplicator/Verbs/Deduplicate.cs +++ b/FileDeduplicator/Verbs/Deduplicate.cs @@ -29,24 +29,20 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override int Run(Deduplicate options) + internal override void Run(Deduplicate options) { Console.WriteLine($"Deduplicating: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) - { - return 1; - } - + IReadOnlyList files = FileScanner.ScanForFiles(options.Path); Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return 0; + return; } // Step 2: Hash all files in parallel @@ -61,7 +57,7 @@ internal override int Run(Deduplicate options) if (duplicates.Count == 0) { Console.WriteLine("No duplicate files found."); - return 0; + return; } // Step 4: Show exactly which copies go and which one stays. "Shortest filename wins" is @@ -86,7 +82,8 @@ internal override int Run(Deduplicate options) if (!string.Equals(confirmation, "y", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("Aborted."); - return 1; + ExitCode = 1; + return; } Console.WriteLine(); @@ -112,9 +109,7 @@ internal override int Run(Deduplicate options) if (result.Errors.Count > 0) { Console.WriteLine($"Encountered {result.Errors.Count} error(s) during deletion."); - return 1; + ExitCode = 1; } - - return 0; } } diff --git a/FileDeduplicator/Verbs/DryRun.cs b/FileDeduplicator/Verbs/DryRun.cs index c69eedd..3b5d70f 100644 --- a/FileDeduplicator/Verbs/DryRun.cs +++ b/FileDeduplicator/Verbs/DryRun.cs @@ -29,24 +29,20 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override int Run(DryRun options) + internal override void Run(DryRun options) { Console.WriteLine($"Dry run for: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) - { - return 1; - } - + IReadOnlyList files = FileScanner.ScanForFiles(options.Path); Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return 0; + return; } // Step 2: Hash all files in parallel @@ -61,7 +57,7 @@ internal override int Run(DryRun options) if (duplicates.Count == 0) { Console.WriteLine("No duplicate files found."); - return 0; + return; } DeletionPlan plan = DuplicateReport.PlanDeletions(duplicates); @@ -75,6 +71,5 @@ internal override int Run(DryRun options) Console.WriteLine($"Duplicate groups: {duplicates.Count}"); Console.WriteLine($"Files to delete: {plan.FileCount}"); Console.WriteLine($"Space to reclaim: {DuplicateReport.FormatBytes(plan.BytesReclaimable)}"); - return 0; } } diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs index 984ea1d..4fdde2a 100644 --- a/FileDeduplicator/Verbs/Menu.cs +++ b/FileDeduplicator/Verbs/Menu.cs @@ -15,7 +15,9 @@ namespace ktsu.FileDeduplicator.Verbs; [Verb("Menu", isDefault: true)] internal sealed class Menu : BaseVerb { - internal override int Run(Menu options) + internal override bool ScansADirectory => false; + + internal override void Run(Menu options) { ScrollMenu scrollMenu = new() { diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs index f133f89..a519d91 100644 --- a/FileDeduplicator/Verbs/Scan.cs +++ b/FileDeduplicator/Verbs/Scan.cs @@ -29,24 +29,20 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override int Run(Scan options) + internal override void Run(Scan options) { Console.WriteLine($"Scanning: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) - { - return 1; - } - + IReadOnlyList files = FileScanner.ScanForFiles(options.Path); Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return 0; + return; } // Step 2: Hash all files in parallel @@ -61,7 +57,7 @@ internal override int Run(Scan options) if (duplicates.Count == 0) { Console.WriteLine("No duplicate files found."); - return 0; + return; } // Step 4: Display results @@ -96,6 +92,5 @@ internal override int Run(Scan options) Console.WriteLine($"Total wasted space: {DuplicateReport.FormatBytes(totalWastedBytes)}"); Console.WriteLine(); Console.WriteLine("Run the 'Deduplicate' command to remove duplicates."); - return 0; } } diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs index 52cb3ce..097a25b 100644 --- a/FileDeduplicator/Verbs/Stats.cs +++ b/FileDeduplicator/Verbs/Stats.cs @@ -30,24 +30,20 @@ internal override bool ValidateArgs() return base.ValidateArgs(); } - internal override int Run(Stats options) + internal override void Run(Stats options) { Console.WriteLine($"Analyzing: {options.Path}"); Console.WriteLine(); // Step 1: Discover all files Console.WriteLine("Discovering files..."); - if (!FileScanner.TryScanForFiles(options.Path, out IReadOnlyList files)) - { - return 1; - } - + IReadOnlyList files = FileScanner.ScanForFiles(options.Path); Console.WriteLine($"Found {files.Count} file(s)."); Console.WriteLine(); if (files.Count == 0) { - return 0; + return; } // Step 2: Hash all files @@ -105,8 +101,6 @@ internal override int Run(Stats options) Console.WriteLine($" {group.Hash[..12]}... - {group.Files.Count} copies, {DuplicateReport.FormatBytes(group.FileSize)} each, {DuplicateReport.FormatBytes(wasted)} wasted"); } } - - return 0; } ///