diff --git a/FileDeduplicator.Test/ExitCodeTests.cs b/FileDeduplicator.Test/ExitCodeTests.cs
index 90a69c7..ce9db04 100644
--- a/FileDeduplicator.Test/ExitCodeTests.cs
+++ b/FileDeduplicator.Test/ExitCodeTests.cs
@@ -33,7 +33,7 @@ private static int ExitCodeOfCommandLine(params string[] args)
_ = ConsoleCapture.Run(
captured =>
{
- using Parser parser = new(settings => settings.HelpWriter = captured);
+ using Parser parser = Program.CreateParser(captured);
return Program.Run(parser, args);
},
string.Empty,
@@ -122,6 +122,48 @@ public void DeduplicateExitsOneWhenADeletionFails()
public void AnUnknownOptionExitsTwo() =>
Assert.AreEqual(2, ExitCodeOfCommandLine("Scan", "--nope"));
+ [TestMethod]
+ public void ALowercaseVerbRunsThatVerb()
+ {
+ using TempTree tree = new();
+ string missing = Path.Combine(tree.Root.WeakString, "not-there");
+
+ // Scan of a missing directory exits 1; the Menu it used to fall through to exits 0 at end of input
+ Assert.AreEqual(1, ExitCodeOfCommandLine("scan", "-p", missing));
+ Assert.AreEqual(0, ExitCodeOfCommandLine("scan", "-p", tree.Root.WeakString));
+ }
+
+ [TestMethod]
+ public void AnUnknownVerbExitsTwo()
+ {
+ using TempTree tree = new();
+ Assert.AreEqual(2, ExitCodeOfCommandLine("dedupe", "-p", tree.Root.WeakString));
+ }
+
+ [TestMethod]
+ public void AStrayPositionalArgumentExitsTwo()
+ {
+ using TempTree tree = new();
+ Assert.AreEqual(2, ExitCodeOfCommandLine("Scan", "-p", tree.Root.WeakString, "extra"));
+ }
+
+ [TestMethod]
+ public void APathGivenWithoutDashPExitsTwoWithoutPrompting()
+ {
+ using TempTree tree = new();
+ string output = ConsoleCapture.Run(
+ captured =>
+ {
+ using Parser parser = Program.CreateParser(captured);
+ return Program.Run(parser, ["Deduplicate", tree.Root.WeakString]);
+ },
+ "y",
+ out int exitCode);
+
+ Assert.AreEqual(2, exitCode);
+ Assert.IsFalse(output.Contains("Enter the path", StringComparison.Ordinal), output);
+ }
+
[TestMethod]
public void AskingForHelpExitsZero() =>
Assert.AreEqual(0, ExitCodeOfCommandLine("--help"));
diff --git a/FileDeduplicator/Program.cs b/FileDeduplicator/Program.cs
index f67ada2..e2cd608 100644
--- a/FileDeduplicator/Program.cs
+++ b/FileDeduplicator/Program.cs
@@ -24,9 +24,25 @@ internal static class Program
private static int Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
- return Run(Parser.Default, args);
+ using Parser parser = CreateParser(Console.Error);
+ return Run(parser, args);
}
+ ///
+ /// Creates the parser uses.
+ ///
+ ///
+ /// Verb names match in any case. Otherwise scan -p dir names no verb, falls through to the
+ /// default Menu verb, and exits 0 without scanning (ktsu-dev/FileDeduplicator#195).
+ ///
+ /// Where help and parse errors are written.
+ /// The parser.
+ internal static Parser CreateParser(TextWriter helpWriter) => new(settings =>
+ {
+ settings.CaseSensitive = false;
+ settings.HelpWriter = helpWriter;
+ });
+
///
/// Parses with and runs the verb they name.
///
diff --git a/FileDeduplicator/Verbs/BaseVerb.cs b/FileDeduplicator/Verbs/BaseVerb.cs
index 56baa30..c154508 100644
--- a/FileDeduplicator/Verbs/BaseVerb.cs
+++ b/FileDeduplicator/Verbs/BaseVerb.cs
@@ -19,6 +19,14 @@ internal abstract class BaseVerb : ICommand
[Option('p', "path", Required = false, HelpText = "The root path to scan for files.")]
public string? PathString { get; set; }
+ ///
+ /// Gets or sets the positional arguments on the command line, which no verb takes. The parser would
+ /// otherwise drop them silently: an unknown verb name lands here on the default Menu verb, and a
+ /// path given without -p would leave the verb prompting for one.
+ ///
+ [Value(0, Hidden = true)]
+ public IEnumerable UnexpectedArguments { get; set; } = [];
+
public abstract bool IsActive { get; }
internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString ?? ".").As();
@@ -26,7 +34,10 @@ internal abstract class BaseVerb : ICommand
///
/// Runs the verb.
///
- /// The process exit code: 0 on success, 1 when the run failed or the user aborted.
+ ///
+ /// The process exit code: 0 on success, 1 when the run failed or the user aborted, and 2 when the
+ /// command line had .
+ ///
public abstract int Run();
///
@@ -59,6 +70,13 @@ public override int Run()
try
{
ExitCode = 0;
+ if (UnexpectedArguments.Any())
+ {
+ string commands = string.Join(", ", Program.Verbs.Where(verb => verb != typeof(Menu)).Select(verb => verb.Name));
+ Console.WriteLine($"Unexpected argument: {string.Join(' ', UnexpectedArguments)}. Commands: {commands}. Give the path with -p.");
+ return 2;
+ }
+
if (!ValidateArgs() || (ScansADirectory && (!IsRepresentablePath() || !FileScanner.IsScannableDirectory(Path))))
{
return 1;
diff --git a/README.md b/README.md
index d133e20..7ba486e 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,9 @@ FileDeduplicator Deduplicate -p "C:\path\to\directory"
FileDeduplicator Stats -p "C:\path\to\directory"
```
+Command names match in any case, so `scan` runs `Scan`. An unknown command, or any argument other
+than an option such as a path given without `-p`, is an error and exits 2 rather than being ignored.
+
### Options
| Option | Description |