From 742a657f064d8adca9762a915f2f78e903520188 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:37:57 +0000 Subject: [PATCH] Run a lowercase verb and reject stray arguments instead of ignoring them [patch] Verbs matched case-sensitively, so `scan -p /data` named no verb, fell through to the default Menu verb, read end of input and exited 0 with nothing scanned. Positional arguments were dropped silently too: `Scan -p dir extra` ignored "extra", and `Deduplicate dir` prompted for the path and consumed the piped "y" meant for the confirmation as the answer. Program.CreateParser builds the parser with CaseSensitive = false, and Main and the exit-code tests use it. BaseVerb collects positional arguments in a hidden catch-all, and BaseVerb.Run exits 2 with a one-line message naming the commands when there are any, before any prompt. Fixes ktsu-dev/FileDeduplicator#195 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3PPqgYMrM7WRJp2UF --- FileDeduplicator.Test/ExitCodeTests.cs | 44 +++++++++++++++++++++++++- FileDeduplicator/Program.cs | 18 ++++++++++- FileDeduplicator/Verbs/BaseVerb.cs | 20 +++++++++++- README.md | 3 ++ 4 files changed, 82 insertions(+), 3 deletions(-) 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 |