diff --git a/FileDeduplicator.Test/ConsoleCapture.cs b/FileDeduplicator.Test/ConsoleCapture.cs index e8b5971..fd06746 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,26 @@ 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) => + 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; @@ -32,7 +51,7 @@ internal static string Run(BaseVerb verb, string stdin = "") Console.SetOut(captured); Console.SetIn(answers); - verb.Run(); + exitCode = action(captured); return captured.ToString(); } diff --git a/FileDeduplicator.Test/ExitCodeTests.cs b/FileDeduplicator.Test/ExitCodeTests.cs new file mode 100644 index 0000000..90a69c7 --- /dev/null +++ b/FileDeduplicator.Test/ExitCodeTests.cs @@ -0,0 +1,138 @@ +// 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) + { + _ = ConsoleCapture.Run( + captured => + { + using Parser parser = new(settings => settings.HelpWriter = captured); + return Program.Run(parser, args); + }, + string.Empty, + out int exitCode); + return exitCode; + } + + 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(); + AssertEveryVerbExitsOne(Path.Combine(tree.Root.WeakString, "not-there")); + } + + [TestMethod] + public void EveryVerbExitsOneForAFileGivenAsTheRoot() + { + using TempTree tree = new(); + AssertEveryVerbExitsOne(tree.Write("f.txt", "content").WeakString); + } + + [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..b6aea0a 100644 --- a/FileDeduplicator/FileScanner.cs +++ b/FileDeduplicator/FileScanner.cs @@ -42,7 +42,13 @@ internal static class FileScanner AttributesToSkip = FileAttributes.ReparsePoint, }; - internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path) + /// + /// Checks that is a directory that can be scanned, and says why in one + /// line when it is not. + /// + /// The root to check. + /// when the root is missing or is not a directory. + internal static bool IsScannableDirectory(AbsoluteDirectoryPath path) { // 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 @@ -50,12 +56,22 @@ internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPa 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 false; + } + + return true; + } + + internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path) + { + if (!IsScannableDirectory(path)) + { return []; } 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 8017ecf..4f63d8b 100644 --- a/FileDeduplicator/Verbs/BaseVerb.cs +++ b/FileDeduplicator/Verbs/BaseVerb.cs @@ -23,11 +23,27 @@ 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(); + + /// + /// 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(); + public void Execute() => _ = Run(); } internal abstract class BaseVerb : BaseVerb where T : BaseVerb @@ -35,20 +51,22 @@ 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 // the prompt and works on the previous directory. try { - if (!ValidateArgs()) + ExitCode = 0; + if (!ValidateArgs() || (ScansADirectory && !FileScanner.IsScannableDirectory(Path))) { - return; + return 1; } isActive = false; Run((T)this); + return ExitCode; } finally { diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs index 96efc49..7fbb099 100644 --- a/FileDeduplicator/Verbs/Deduplicate.cs +++ b/FileDeduplicator/Verbs/Deduplicate.cs @@ -82,6 +82,7 @@ internal override void Run(Deduplicate options) if (!string.Equals(confirmation, "y", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("Aborted."); + ExitCode = 1; return; } @@ -108,6 +109,7 @@ internal override void Run(Deduplicate options) if (result.Errors.Count > 0) { Console.WriteLine($"Encountered {result.Errors.Count} error(s) during deletion."); + ExitCode = 1; } } } diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs index 81bd611..4fdde2a 100644 --- a/FileDeduplicator/Verbs/Menu.cs +++ b/FileDeduplicator/Verbs/Menu.cs @@ -15,6 +15,8 @@ namespace ktsu.FileDeduplicator.Verbs; [Verb("Menu", isDefault: true)] internal sealed class Menu : BaseVerb { + internal override bool ScansADirectory => false; + internal override void Run(Menu options) { ScrollMenu scrollMenu = new()