Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<!-- There is always Unreleased section on the top. Subsections (Add, Changed, Fix, Removed) should be Add as needed. -->
## Unreleased
- Add `fallbackCommand` to allow a command-less `executable <value>` 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>` 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) |
Expand Down
14 changes: 14 additions & 0 deletions src/Builder.fs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type internal DefinitionParts = {
Ask: string -> string
Commands: Commands
DefaultCommand: CommandName
FallbackCommand: CommandName option
OptionDecorationLevel: OptionDecorationLevel
}

Expand All @@ -45,6 +46,7 @@ module internal DefinitionParts =
Ask = output.Ask
Commands = Map.empty
DefaultCommand = CommandName (Name CommandNames.List)
FallbackCommand = None
OptionDecorationLevel = Minimal
}

Expand Down Expand Up @@ -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.
[<CustomOperation("fallbackCommand")>]
member _.FallbackCommand(state, fallbackCommand): Definition =
state >>= fun parts ->
result {
let! commandName =
fallbackCommand
|> CommandName.create <@> ConsoleApplicationError.CommandNameError

return { parts with FallbackCommand = Some commandName }
}

/// <summary>
/// When options are shown, this `decorationLevel` is used to determine, how much information should be shown.
/// <para>Minimal is just: [options]</para>
Expand Down
25 changes: 25 additions & 0 deletions src/ConsoleApplication.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions tests/FallbackCommandTests.fs
Original file line number Diff line number Diff line change
@@ -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}"

[<Tests>]
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"
]
1 change: 1 addition & 0 deletions tests/tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<Compile Include="InputTests.fs" />
<Compile Include="ArgsTests.fs" />
<Compile Include="CommandDefinitionTests.fs" />
<Compile Include="FallbackCommandTests.fs" />
<Compile Include="DefaultCommandsTests.fs" />
<Compile Include="Tests.fs" />
</ItemGroup>
Expand Down