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