diff --git a/CHANGELOG.md b/CHANGELOG.md index e92349c..516fa63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `fallbackCommand` to allow a command-less `executable ` usage - the fallback command is run with all the given arguments, when the first argument does not match any command name ## 2.0.0 - 2025-12-04 - [**BC**] Use net10 diff --git a/README.md b/README.md index c08152c..48d5091 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Command: `dotnet example.dll my:first-command --help` | showOptions | `OptionDecorationLevel` | It will define, how options will be shown in the command help output. (Default is `Minimal`) | | command | `commandName: string`, `CommandDefinition` | It will register a command to the application. | | defaultCommand | `commandName: string` | It will set a name of default command. Default command is run when no command name is pass to the arguments. (_Default is `list`._) | +| fallbackCommand | `commandName: string` | It will set a name of fallback command. Fallback command is run with all the given arguments, when the first argument does not match any command name - it allows a command-less `executable ` usage. (_Default is none - an unknown command name results in an error._) | | useOutput | `Output` | It will override `Output` in `IO`, which gets every command life-cycle function. (_Default is implemented by [ConsoleStyle](https://github.com/FeatherTools/console-style)_) | | useAsk | `question: string -> answer: string` | It will override an Ask function, which is used in `Interact` life-cycle stage. (_Default is implemented by [ConsoleStyle](https://github.com/FeatherTools/console-application#ask))_ | | updateOutput | `Output -> Output` | Function which allows to change the output (set style, different outputInterface for a ConsoleStyle and more) | diff --git a/src/Builder.fs b/src/Builder.fs index 0d5f94e..c324afc 100644 --- a/src/Builder.fs +++ b/src/Builder.fs @@ -20,6 +20,7 @@ type internal DefinitionParts = { Ask: string -> string Commands: Commands DefaultCommand: CommandName + FallbackCommand: CommandName option OptionDecorationLevel: OptionDecorationLevel } @@ -45,6 +46,7 @@ module internal DefinitionParts = Ask = output.Ask Commands = Map.empty DefaultCommand = CommandName (Name CommandNames.List) + FallbackCommand = None OptionDecorationLevel = Minimal } @@ -164,6 +166,18 @@ type ConsoleApplicationBuilder<'Application> internal (buildApplication: Definit return { parts with DefaultCommand = commandName } } + /// Fallback command is run with all the given args, when the first argument does not match any command name. + [] + member _.FallbackCommand(state, fallbackCommand): Definition = + state >>= fun parts -> + result { + let! commandName = + fallbackCommand + |> CommandName.create <@> ConsoleApplicationError.CommandNameError + + return { parts with FallbackCommand = Some commandName } + } + /// /// When options are shown, this `decorationLevel` is used to determine, how much information should be shown. /// Minimal is just: [options] diff --git a/src/ConsoleApplication.fs b/src/ConsoleApplication.fs index e4a000f..7dd0c92 100644 --- a/src/ConsoleApplication.fs +++ b/src/ConsoleApplication.fs @@ -156,6 +156,31 @@ module MFConsoleApplication = |] | _ -> args + let args = + match parts.FallbackCommand with + | Some fallbackCommand -> + let isKnownCommand rawName = + match rawName |> CommandName.createInRuntime with + | Ok name -> + match parts.Commands |> Commands.find name with + | NoCommand _ -> false + | _ -> true + | Error _ -> false + + let firstPositionalArg = + args + |> Array.takeWhile ((<>) Arguments.Separator) + |> Array.tryFind (Option.isOptionOrShortcut >> not) + + match firstPositionalArg with + | Some commandCandidate when commandCandidate |> isKnownCommand -> args + | _ -> + [| + yield fallbackCommand |> CommandName.value + yield! args + |] + | None -> args + match args with | Args.ContainsOption OptionsDefinitions.help -> parts |> showApplicationInfo diff --git a/tests/FallbackCommandTests.fs b/tests/FallbackCommandTests.fs new file mode 100644 index 0000000..45f9b5d --- /dev/null +++ b/tests/FallbackCommandTests.fs @@ -0,0 +1,123 @@ +module Feather.ConsoleApplication.Tests.FallbackCommand + +open Expecto +open Feather.ConsoleApplication +open Feather.ConsoleApplication.Tests.Commands + +let private runWithFallback argv = + let mutable (input: Input option) = None + let setInput parsedInput = input <- Some parsedInput + + let result = + consoleApplication { + useAsk (fun _question -> "answer") + + command "two" (commandTwo setInput) + command "six" (commandSix setInput) + + fallbackCommand "two" + } + |> runResult argv + + result + |> Result.map (fun _ -> input) + +let private runWithoutFallback argv = + consoleApplication { + useAsk (fun _question -> "answer") + + command "two" (commandTwo ignore) + } + |> runResult argv + +let private expectInput expectedOptions expectedArguments result message = + match result with + | Ok (Some (input: Input)) -> + Expect.equal input.Options (expectedOptions |> Map.ofList) $"{message} - options should match" + Expect.equal input.Arguments (expectedArguments |> Map.ofList) $"{message} - arguments should match" + | Ok None -> failtest $"{message} - command did not execute" + | Error error -> failtest $"{message} - unexpected error: %A{error}" + +[] +let fallbackCommandTests = + testList "ConsoleApplication - fallback command" [ + testCase "should run fallback command with value as argument when first argument matches no command" <| fun _ -> + let result = runWithFallback [| "foo" |] + + expectInput + [] + [ + "command", ArgumentValue.Required "two" + "mandatoryArg", ArgumentValue.Required "foo" + "argumentList", ArgumentValue.Array [] + ] + result + "args: foo" + + testCase "should pass all values and options to fallback command when first argument matches no command" <| fun _ -> + let result = runWithFallback [| "foo"; "bar"; "-o"; "value1" |] + + expectInput + [ "opt1", OptionValue.ValueOptional (Some "value1") ] + [ + "command", ArgumentValue.Required "two" + "mandatoryArg", ArgumentValue.Required "foo" + "argumentList", ArgumentValue.Array [ "bar" ] + ] + result + "args: foo bar -o value1" + + testCase "should run fallback command when first argument is not a valid command name" <| fun _ -> + let result = runWithFallback [| "foo bar" |] + + expectInput + [] + [ + "command", ArgumentValue.Required "two" + "mandatoryArg", ArgumentValue.Required "foo bar" + "argumentList", ArgumentValue.Array [] + ] + result + "args: 'foo bar'" + + testCase "should run fallback command when arguments are given only after separator" <| fun _ -> + let result = runWithFallback [| "--"; "foo" |] + + expectInput + [] + [ + "command", ArgumentValue.Required "two" + "mandatoryArg", ArgumentValue.Required "foo" + "argumentList", ArgumentValue.Array [] + ] + result + "args: -- foo" + + testCase "should run matched command when first argument matches a command name" <| fun _ -> + let result = runWithFallback [| "six" |] + + expectInput + [ "bar", OptionValue.ValueRequired "" ] + [ + "command", ArgumentValue.Required "six" + "arg", ArgumentValue.Optional None + ] + result + "args: six" + + testCase "should return CommandNotFound error when no fallback command is set" <| fun _ -> + let result = runWithoutFallback [| "foo" |] + + let expected = CommandNotFound.create "foo" |> ConsoleApplicationError.ArgsError + Expect.equal result (Error expected) "Unknown first argument should still be an error without a fallback command" + + testCase "should return Reserved error when fallback command name is reserved" <| fun _ -> + let result = + consoleApplication { + fallbackCommand "list" + } + |> runResult [| "foo" |] + + let expected = ConsoleApplicationError.CommandNameError (CommandNameError.Reserved "list") + Expect.equal result (Error expected) "Reserved fallback command name should fail application definition" + ] diff --git a/tests/tests.fsproj b/tests/tests.fsproj index 27021fd..a98e397 100644 --- a/tests/tests.fsproj +++ b/tests/tests.fsproj @@ -22,6 +22,7 @@ +