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 |