diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index d9ebe66..1cfc36a 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -67,7 +67,7 @@ These features appear across multiple libraries and depend only on string operat | `NO_COLOR` env variable | — | — | — | — | I need it personally | **Done** | | Response file (`@args.txt`) | ✓ | — | — | — | javac, MSBuild | **Done** | | Argument parents (shared args) | ✓ | — | — | — | | Phase 5 | -| Interactive prompting | — | ✓ | — | — | | Phase 5 | +| Interactive prompting | — | ✓ | — | — | | **Done** | | Password / masked input | — | ✓ | — | — | | Phase 5 | | Confirmation (`--yes` / `-y`) | — | ✓ | — | — | | Phase 5 | | Pre/Post run hooks | — | — | ✓ | — | | Phase 5 | @@ -166,7 +166,8 @@ tests/ ├── test_response_file.mojo # response file (@args.txt) expansion tests ├── test_remainder_known.mojo # remainder, parse_known_arguments, allow_hyphen_values tests ├── test_fullwidth.mojo # full-width → half-width auto-correction tests -└── test_groups_help.mojo # argument groups in help + value_name wrapping tests +├── test_groups_help.mojo # argument groups in help + value_name wrapping tests +└── test_prompt.mojo # interactive prompting tests examples/ ├── demo.mojo # comprehensive showcase of all ArgMojo features ├── mgrep.mojo # grep-like CLI example (no subcommands) @@ -235,6 +236,7 @@ examples/ | CJK punctuation auto-correction (em-dash `U+2014` → hyphen-minus) | ✓ | ✓ | | Compile-time `StringLiteral` builder params (`.long[]`, `.short[]`, `.choice[]`, colours, etc.) | ✓ | — | | Registration-time validation for group constraints (`mutually_exclusive`, `required_together`, etc.) | ✓ | ✓ | +| Interactive prompting (`.prompt()`, `.prompt["..."]()` → prompt for missing args) | ✓ | ✓ | > ⚠ Response file support is temporarily disabled due to a Mojo compiler deadlock under `-D ASSERT=all`. The implementation is preserved and will be re-enabled when the compiler bug is fixed. diff --git a/docs/changelog.md b/docs/changelog.md index 30c59ed..5933305 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -23,6 +23,7 @@ Comment out unreleased changes here. This file will be edited just before each r 11. **Argument groups in help.** Add `.group["name"]()` builder method on `Argument`. Arguments assigned to the same group are displayed under a dedicated heading in `--help` output, in first-appearance order. Ungrouped arguments remain under the default "Options:" heading. Persistent arguments are collected under "Global Options:" as before (PR #17). 12. **Value-name wrapping control.** Change `.value_name()` to accept compile-time parameters: `.value_name["NAME"]()` or `.value_name["NAME", False]()`. When `wrapped` is `True` (the default), the custom value name is displayed in angle brackets (``) in help output — matching the convention used by clap, cargo, pixi, and git. When `wrapped` is `False`, the value name is displayed bare (`NAME`). The auto-generated default placeholder (``) is not affected (PR #17). 13. **Registration-time validation for group constraints.** `mutually_exclusive()`, `required_together()`, `one_required()`, and `required_if()` now validate argument names against `self.args` at the moment they are called. An `Error` is raised immediately if any name is unknown, empty lists are rejected, and duplicates are silently deduplicated. `required_if()` additionally rejects self-referential rules (`target == condition`). This catches developer typos on the very first `mojo run`, without waiting for end-user input (PR #22). +14. **Interactive prompting.** Add `.prompt()` and `.prompt["text"]()` builder methods on `Argument`. When an argument marked with `.prompt()` is not provided on the command line, the user is interactively prompted for its value before validation runs. Use `.prompt()` to prompt with the argument's help text, or `.prompt["Custom text"]()` to set a custom message. Works on both required and optional arguments. Prompts show valid choices for `.choice[]()` arguments and show default values in parentheses. For flag arguments, `y`/`n` input is accepted. When stdin is not a terminal (e.g., piped input, CI environments, `/dev/null`), or when `input()` otherwise raises, the exception is caught, prompting stops gracefully, and any values collected so far are preserved (PR #23). ### 🦋 Changed in v0.4.0 @@ -57,6 +58,7 @@ Comment out unreleased changes here. This file will be edited just before each r - Add 5 tests to `tests/test_groups.mojo` covering registration-time validation: unknown argument detection for `mutually_exclusive`, `required_together`, `one_required`, and `required_if` (both target and condition) (PR #22). - Add Developer Validation section to user manual documenting the two-layer validation model (compile-time `StringLiteral` + runtime registration-time `raises`) with recommended workflow (PR #22). - Add `pixi run debug` task that runs all examples under `-D ASSERT=all` with `--help` to exercise registration-time validation in CI (PR #22). +- Add `tests/test_prompt.mojo` with tests covering interactive prompting builder methods, optional/required prompt arguments, prompting skipped when values are provided, choices and defaults integration, field propagation through copy, and combined features (PR #23). --- diff --git a/docs/user_manual.md b/docs/user_manual.md index 1a09bc2..b57364b 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -73,6 +73,19 @@ from argmojo import Argument, Command - [Remainder Positional (`.remainder()`)](#remainder-positional-remainder) - [Allow Hyphen Values (`.allow_hyphen_values()`)](#allow-hyphen-values-allow_hyphen_values) - [Partial Parsing (`parse_known_arguments()`)](#partial-parsing-parse_known_arguments) +- [Interactive Prompting](#interactive-prompting) + - [Setup Example](#setup-example) + - [Enabling Prompting](#enabling-prompting) + - [Interactive Session Examples](#interactive-session-examples) + - [All arguments missing — full prompting](#all-arguments-missing--full-prompting) + - [Partial arguments — only missing ones are prompted](#partial-arguments--only-missing-ones-are-prompted) + - [All arguments provided — no prompting at all](#all-arguments-provided--no-prompting-at-all) + - [Empty input with a default — default value is used](#empty-input-with-a-default--default-value-is-used) + - [Flag argument — y/n prompt](#flag-argument--yn-prompt) + - [Argument with choices — choices are shown](#argument-with-choices--choices-are-shown) + - [Prompt Format](#prompt-format) + - [Interaction with Other Features](#interaction-with-other-features) + - [Non-Interactive Use (CI / Piped Input)](#non-interactive-use-ci--piped-input) - [Shell Completion](#shell-completion) - [Built-in `--completions` Flag](#built-in---completions-flag) - [Disabling the Built-in Flag](#disabling-the-built-in-flag) @@ -398,6 +411,8 @@ Argument("name", help="...") ║ .persistent() inherit to subcommands (named only) ║ .default_if_no_value["val"]() default-if-no-value (value only) ║ .require_equals() force --key=value syntax (named value only) +║ .prompt() prompt interactively (any) +║ .prompt["msg"]() custom prompt message (any; implies .prompt()) ║ ╠══ Command-level constraints (called on Command, not Argument) ════════════════ ║ command.mutually_exclusive(["a","b"]) at most one from the group @@ -466,6 +481,8 @@ The table below shows which builder methods can be used with each argument mode. | `.default_if_no_value["val"]()` | ✓ | — | — | — | | `.allow_hyphen_values()` | ✓ | — | — | ✓ | | `.remainder()` | — | — | — | ✓ | +| `.prompt()` | ✓ | ✓ | ✓ | ✓ | +| `.prompt["msg"]()` | ✓ | ✓ | ✓ | ✓ | | `.require_equals()` | ✓ | — | — | — | | `command.mutually_exclusive()` ³ | ✓ | ✓ | ✓ | — | | `command.one_required()` ³ | ✓ | ✓ | ✓ | — | @@ -2899,6 +2916,191 @@ command.response_file_prefix("+") end of Response Files section --> +## Interactive Prompting + +ArgMojo supports **interactive prompting** for missing arguments. When an argument marked with `.prompt()` is not provided on the command line, the user is asked to enter its value interactively before validation runs. + +This is useful for required credentials, configuration wizards, or any scenario where guided input improves the user experience. + +### Setup Example + +The examples below use this `login` command: + +```mojo +from argmojo import Argument, Command + +fn main() raises: + var command = Command("login", "Authenticate with the service") + command.add_argument( + Argument("user", help="Username") + .long["user"]() + .required() + .prompt() + ) + command.add_argument( + Argument("token", help="API token") + .long["token"]() + .required() + .prompt["Enter your API token"]() + ) + command.add_argument( + Argument("region", help="Server region") + .long["region"]() + .choice["us"]() + .choice["eu"]() + .choice["ap"]() + .default["us"]() + .prompt() + ) + var result = command.parse() +``` + +Three arguments are prompt-enabled: + +- `--user` — required, prompt uses the help text `"Username"`. +- `--token` — required, prompt uses custom text `"Enter your API token"`. +- `--region` — optional with choices and a default, prompt shows choices and default. + +### Enabling Prompting + +Use `.prompt()` on any argument — both required and optional — to enable interactive prompting: + +```mojo +# Prompt using the argument's help text (or name as fallback). +Argument("user", help="Username").long["user"]().prompt() + +# Prompt with custom text. +Argument("token", help="API token").long["token"]().prompt["Enter your API token"]() +``` + +`.prompt()` and `.prompt["custom text"]()` are the same builder method. When no text is given, the argument's help text is displayed. When custom text is provided, it overrides the help text in the prompt. + +### Interactive Session Examples + +#### All arguments missing — full prompting + +When none of the prompt-enabled arguments are provided, the user is prompted for each one in order: + +```console +$ ./login +Username: alice +Enter your API token: secret-123 +Server region [us/eu/ap] (us): eu +``` + +The parsed result contains `user="alice"`, `token="secret-123"`, `region="eu"`. + +#### Partial arguments — only missing ones are prompted + +When some arguments are already provided on the command line, only the missing ones trigger a prompt: + +```console +$ ./login --user alice +Enter your API token: secret-123 +Server region [us/eu/ap] (us): ap +``` + +`--user` was given on the CLI, so `Username:` is **not** asked. + +#### All arguments provided — no prompting at all + +```console +$ ./login --user alice --token secret-123 --region eu +``` + +No prompts appear. The CLI values are used directly. + +#### Empty input with a default — default value is used + +When the user presses Enter without typing anything and the argument has a `.default[]()`, the default is applied: + +```console +$ ./login +Username: alice +Enter your API token: secret-123 +Server region [us/eu/ap] (us): +``` + +The user pressed Enter at `Server region`, so `region` gets the default value `"us"`. + +#### Flag argument — y/n prompt + +Flag arguments accept `y`/`n`/`yes`/`no` (case-insensitive): + +```mojo +Argument("verbose", help="Enable verbose output") + .long["verbose"]() + .flag() + .prompt() +``` + +```console +$ ./app +Enable verbose output [y/n]: y +``` + +Answering `y` or `yes` sets the flag to `True`. Answering `n` or `no` sets it to `False`. + +#### Argument with choices — choices are shown + +When a prompt-enabled argument has `.choice[]()` values, they are displayed in brackets. If a default exists, it is shown in parentheses: + +```console +$ ./login --user alice --token secret +Server region [us/eu/ap] (us): eu +``` + +The user sees the valid options and the default before typing. + +### Prompt Format + +The prompt message is built automatically from the argument's metadata: + +```text + [choice1/choice2/choice3] (default_value): _ +``` + +Where: + +- **``** — custom prompt text if given via `.prompt["..."]()`, otherwise the argument's help text, otherwise the argument name. +- **`[choices]`** — shown only when `.choice[]()` values exist. +- **`(default)`** — shown only when `.default[]()` is set. +- **`[y/n]`** — shown instead of choices for `.flag()` arguments. + +Examples of prompt lines: + +```console +Username: ← help text, no choices, no default +Enter your API token: ← custom prompt text +Server region [us/eu/ap] (us): ← help text + choices + default +Enable verbose output [y/n]: ← flag prompt +``` + +### Interaction with Other Features + +- **`.required()`**: Prompting happens *before* validation. If the user provides a value via the prompt, the required check passes. `.prompt()` does **not** require `.required()` — it works on any argument. +- **`.default[]()` **: If the user presses Enter (empty input), the default is applied by the normal default-filling phase. +- **`.choice[]()` **: Choices are displayed in the prompt. If the user enters an invalid choice, a validation error is raised after prompting. +- **Subcommands**: Each subcommand can have its own prompt-enabled arguments. +- **Persistent flags**: Persistent arguments with `.prompt()` are prompted at the level where they are missing. +- **`help_on_no_arguments()`**: Cannot be combined with `.prompt()` on the same command. When no arguments are given, `help_on_no_arguments()` prints help and exits *before* prompting runs, making prompt-enabled arguments unreachable. ArgMojo raises a registration-time error if you attempt this combination. + +### Non-Interactive Use (CI / Piped Input) + +When stdin is not a terminal (piped input, CI environments, `< /dev/null`), the `input()` call raises on EOF. ArgMojo catches this gracefully and stops prompting — any values collected so far are preserved, defaults are then applied normally, and validation proceeds as usual. + +```console +$ echo "" | ./login --user alice --token secret +``` + +Prompts are still printed to stdout, but `input()` reads from the pipe. Once the pipe is exhausted, `input()` raises and prompting stops. `--region` gets its default `"us"`. + +To avoid prompting entirely, always provide all arguments on the command line: + +```console +$ ./login --user alice --token secret --region eu +``` + ## Shell Completion ArgMojo can generate **shell completion scripts** for Bash, Zsh, and Fish. These scripts enable tab-completion for your CLI's options, flags, subcommands, and choice values — with zero runtime overhead. @@ -3170,6 +3372,8 @@ The table below maps every ArgMojo builder method / command-level method to its | `.require_equals()` | — | — | `.require_equals(true)` | — | | `.remainder()` | `nargs=argparse.REMAINDER` | — | `.trailing_var_arg(true)` ¹¹ | `TraverseChildren` ¹² | | `.allow_hyphen_values()` | — | — | `.allow_hyphen_values(true)` | — | +| `.prompt()` | — | `prompt=True` | — | — | +| `.prompt["msg"]()` | — | `prompt="msg"` | — | — | ### Command-Level Constraint Methods diff --git a/examples/demo.mojo b/examples/demo.mojo index 8f4cb26..d587834 100644 --- a/examples/demo.mojo +++ b/examples/demo.mojo @@ -12,8 +12,9 @@ clamping, value delimiter, nargs, key-value map, aliases, deprecated args, negative number passthrough, allow_positional_with_subcommands, custom tips, help_on_no_arguments, default_if_no_value, require_equals, response files, remainder positionals, allow_hyphen_values, parse_known_arguments, -argument groups in help (.group()), and value_name wrapping control -(.value_name["NAME"]() or .value_name["NAME", False]()). +argument groups in help (.group()), value_name wrapping control +(.value_name["NAME"]() or .value_name["NAME", False]()), and interactive +prompting (.prompt(), .prompt["..."]()). Note: This demo looks very strange, but useful :D @@ -98,6 +99,11 @@ Try these (build first with: pixi run package && mojo build -I src -o demo examp ./demo run myapp --verbose -x --output=foo.txt ./demo run - # stdin convention via allow_hyphen_values ./demo run myapp # no extra args → empty remainder + + # ── Subcommand: login (interactive prompting) ──────────────────────── + ./demo login # prompts for user and token interactively + ./demo login --user alice --token secret # no prompts needed + ./demo login --user alice # prompts only for token """ from argmojo import Argument, Command @@ -345,6 +351,35 @@ fn main() raises: run.help_on_no_arguments() app.add_subcommand(run^) + # ── Subcommand: login (interactive prompting) ──────────────────────── + # Demonstrates .prompt() and .prompt["..."]() for interactive input. + # Missing arguments are prompted for interactively. + var login = Command("login", "Authenticate with the service") + login.add_argument( + Argument("user", help="Username") + .long["user"]() + .short["u"]() + .required() + .prompt() + ) + login.add_argument( + Argument("token", help="API token") + .long["token"]() + .short["t"]() + .required() + .prompt["Enter your API token"]() + ) + login.add_argument( + Argument("region", help="Server region") + .long["region"]() + .choice["us"]() + .choice["eu"]() + .choice["ap"]() + .default["us"]() + .prompt() + ) + app.add_subcommand(login^) + # ── Show help when invoked with no arguments ───────────────────────── app.help_on_no_arguments() diff --git a/pixi.toml b/pixi.toml index 25b21b1..caff0a5 100644 --- a/pixi.toml +++ b/pixi.toml @@ -41,7 +41,8 @@ test = """\ && mojo run -I src -D ASSERT=all tests/test_const_require_equals.mojo \ && mojo run -I src -D ASSERT=all tests/test_remainder_known.mojo \ && mojo run -I src -D ASSERT=all tests/test_fullwidth.mojo \ - && mojo run -I src -D ASSERT=all tests/test_groups_help.mojo""" + && mojo run -I src -D ASSERT=all tests/test_groups_help.mojo \ + && mojo run -I src -D ASSERT=all tests/test_prompt.mojo < /dev/null""" # NOTE: test_response_file.mojo is excluded — response file expansion # is temporarily disabled to work around a Mojo compiler deadlock # with -D ASSERT=all. Re-enable when the compiler bug is fixed. diff --git a/src/argmojo/argument.mojo b/src/argmojo/argument.mojo index f8eea3c..0401cd9 100644 --- a/src/argmojo/argument.mojo +++ b/src/argmojo/argument.mojo @@ -141,6 +141,12 @@ struct Argument(Copyable, Movable, Stringable, Writable): """Help-output group name for this argument. Arguments with the same group name are displayed together under a shared heading. Empty string means ungrouped (shown under the default 'Options:' heading).""" + var _prompt: Bool + """If True, the user is interactively prompted for this argument's + value when it is not provided on the command line.""" + var _prompt_text: String + """Custom prompt message. When empty, a default message is built + from the argument's help text or name.""" # ===------------------------------------------------------------------=== # # Life cycle methods @@ -187,6 +193,8 @@ struct Argument(Copyable, Movable, Stringable, Writable): self._allow_hyphen_values = False self._value_name_wrapped = True self._group = "" + self._prompt = False + self._prompt_text = "" fn __copyinit__(out self, copy: Self): """Creates a copy of this argument. @@ -232,6 +240,8 @@ struct Argument(Copyable, Movable, Stringable, Writable): self._allow_hyphen_values = copy._allow_hyphen_values self._value_name_wrapped = copy._value_name_wrapped self._group = copy._group + self._prompt = copy._prompt + self._prompt_text = copy._prompt_text fn __moveinit__(out self, deinit move: Self): """Moves the value from another Argument. @@ -273,6 +283,8 @@ struct Argument(Copyable, Movable, Stringable, Writable): self._allow_hyphen_values = move._allow_hyphen_values self._value_name_wrapped = move._value_name_wrapped self._group = move._group^ + self._prompt = move._prompt + self._prompt_text = move._prompt_text^ # ===------------------------------------------------------------------=== # # Builder methods for configuring the argument @@ -866,6 +878,64 @@ struct Argument(Copyable, Movable, Stringable, Writable): self._group = name return self^ + fn prompt(var self) -> Self: + """Enables interactive prompting for this argument. + + When prompting is enabled, the user is interactively asked to + provide a value if the argument was not supplied on the command + line. This works with any argument — required or optional, + named or positional. + + The prompt message is derived from the argument's help text + (or name as fallback). Use ``prompt["custom text"]()`` to + set a custom prompt message instead. + + For flag arguments, the prompt accepts ``y``/``n`` (case-insensitive). + For arguments with choices, the valid choices are displayed in + the prompt. For arguments with a default, the default is shown + in parentheses and used when the user enters nothing. + + Returns: + Self with prompting enabled. + + Examples: + + ```mojo + from argmojo import Argument + _ = Argument("name", help="Your name").long["name"]().prompt() + ``` + """ + self._prompt = True + return self^ + + fn prompt[text: StringLiteral](var self) -> Self: + """Enables interactive prompting with custom text. + + When the argument is not supplied on the command line, the + custom ``text`` is displayed instead of the default message + (which is derived from help text or argument name). + + Parameters: + text: Custom prompt message. + + Returns: + Self with prompting enabled and custom text set. + + Constraints: + The prompt text must not be empty. + + Examples: + + ```mojo + from argmojo import Argument + _ = Argument("token", help="API token").long["token"]().prompt["Enter your API token"]() + ``` + """ + constrained[len(text) > 0, "prompt text must not be empty"]() + self._prompt = True + self._prompt_text = text + return self^ + # ===------------------------------------------------------------------=== # # String representation methods # ===------------------------------------------------------------------=== # diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index 6792daf..50579f5 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -469,6 +469,16 @@ struct Command(Copyable, Movable, Stringable, Writable): + self.args[_ri].name + "')" ) + # Guard: .prompt() conflicts with help_on_no_arguments(). + if argument._prompt and self._help_on_no_arguments: + self._error( + "Argument '" + + argument.name + + "': .prompt() cannot be used on a command with" + " help_on_no_arguments() — when no arguments are" + " provided, help is shown and prompting never runs." + " Remove help_on_no_arguments() or .prompt()" + ) self.args.append(argument^) fn add_subcommand(mut self, var sub: Command) raises: @@ -1185,12 +1195,16 @@ struct Command(Copyable, Movable, Stringable, Writable): var imp_triple: List[String] = [trigger, implied, implied_kind] self._implications.append(imp_triple^) - fn help_on_no_arguments(mut self): + fn help_on_no_arguments(mut self) raises: """Enables showing help when invoked with no arguments. When enabled, calling the command with no arguments (only the program name) will print the help message and exit. + Raises: + Error if any registered argument has ``.prompt()`` enabled, + since prompting is unreachable when help is shown on no args. + Example: ```mojo @@ -1200,6 +1214,17 @@ struct Command(Copyable, Movable, Stringable, Writable): command.help_on_no_arguments() ``` """ + # Guard: conflict with .prompt() arguments. + for _pi in range(len(self.args)): + if self.args[_pi]._prompt: + self._error( + "help_on_no_arguments() cannot be used on a command" + " that has .prompt() arguments (argument '" + + self.args[_pi].name + + "'). When no arguments are provided, help is" + " shown and prompting never runs. Remove" + " help_on_no_arguments() or .prompt()" + ) self._help_on_no_arguments = True # [Mojo Miji] @@ -1732,9 +1757,13 @@ struct Command(Copyable, Movable, Stringable, Writable): result._positionals.append(arg) i += 1 - # Apply defaults, propagate implications, then validate constraints. + # Apply defaults, propagate implications, prompt for remaining + # values, re-apply implications (in case prompts triggered new + # ones), then validate constraints. self._apply_defaults(result) self._apply_implications(result) + self._prompt_missing_args(result) + self._apply_implications(result) self._validate(result) return result^ @@ -2455,6 +2484,139 @@ struct Command(Copyable, Movable, Stringable, Writable): ) return -1 + # ===------------------------------------------------------------------=== # + # Interactive prompting + # ===------------------------------------------------------------------=== # + + fn _prompt_missing_args(self, mut result: ParseResult) raises: + """Prompts the user interactively for arguments marked with ``.prompt()`` + that were not provided on the command line. + + Called after the parsing loop but before ``_apply_defaults()``, so + that user-provided prompt responses take priority and defaults + fill in only remaining gaps. For flag arguments, the prompt + accepts ``y``/``n`` (case-insensitive). For arguments with + choices, valid options are shown. For arguments with a default, + the default is displayed in parentheses and used when the user + enters nothing. + + Prompting is skipped entirely when no arguments have + ``.prompt()`` enabled. When stdin is not a terminal (e.g., + piped input, CI environments, ``/dev/null``), or when ``input()`` + otherwise raises, the exception is caught, prompting stops + gracefully, and any values collected so far are preserved. + + Args: + result: The parse result to mutate in-place. + + Raises: + Error: If validation or conversion of a prompted value fails. + """ + # Fast path: skip entirely if no args have prompting enabled. + var has_prompt_args = False + for j in range(len(self.args)): + if self.args[j]._prompt: + has_prompt_args = True + break + if not has_prompt_args: + return + + for j in range(len(self.args)): + var a = self.args[j].copy() + if not a._prompt: + continue + if result.has(a.name): + continue + + # ── Build prompt message ───────────────────────────────── + var msg: String + if a._prompt_text: + msg = a._prompt_text + elif a.help_text: + msg = a.help_text + else: + msg = a.name + + # Show choices if defined. + if len(a._choice_values) > 0: + msg += " [" + for c in range(len(a._choice_values)): + if c > 0: + msg += "/" + msg += a._choice_values[c] + msg += "]" + + # Show default if available. + if a._has_default: + msg += " (" + a._default_value + ")" + + # Flags get a y/n hint. + if a._is_flag: + msg += " [y/n]" + + msg += ": " + + # ── Read input ─────────────────────────────────────────── + var value: String + try: + value = input(msg) + except e: + # EOF or stdin error — stop prompting entirely. + # This handles non-interactive / piped usage gracefully. + return + + if len(value) == 0: + # Empty input — fall through to _apply_defaults. + continue + if a._is_flag: + var lower = value.lower() + if ( + lower == "y" + or lower == "yes" + or lower == "true" + or lower == "1" + ): + result._flags[a.name] = True + elif ( + lower == "n" + or lower == "no" + or lower == "false" + or lower == "0" + ): + result._flags[a.name] = False + else: + self._warn( + "Invalid input for flag '" + + a.name + + "': expected y/n, got '" + + value + + "'" + ) + elif a._is_count: + try: + result._counts[a.name] = Int(atol(value)) + except: + self._warn( + "Invalid count for '" + a.name + "': '" + value + "'" + ) + elif a._is_positional: + # Fill the correct positional slot. + for k in range(len(result._positional_names)): + if result._positional_names[k] == a.name: + while len(result._positionals) <= k: + result._positionals.append("") + result._positionals[k] = value + break + elif a._is_map: + self._store_map_value(a, value, result) + elif a._is_append or a._number_of_values > 1: + self._store_append_value(a, value, result) + else: + # Validate choices before storing. + if len(a._choice_values) > 0: + self._validate_choices(a, value) + result._values[a.name] = value + # ===------------------------------------------------------------------=== # # Defaults & validation helpers (extracted for subcommand reuse) # ===------------------------------------------------------------------=== # @@ -3739,7 +3901,8 @@ struct Command(Copyable, Movable, Stringable, Writable): were actually provided. Args: - result: The ParseResult returned by ``parse()`` or ``parse_arguments()``. + result: The ParseResult returned by ``parse()`` or + ``parse_arguments()``. """ print("=== Parsed Arguments ===") diff --git a/tests/test_prompt.mojo b/tests/test_prompt.mojo new file mode 100644 index 0000000..0630b68 --- /dev/null +++ b/tests/test_prompt.mojo @@ -0,0 +1,333 @@ +"""Tests for argmojo — interactive prompting feature. + +Since interactive prompting reads from stdin, these tests focus on: +1. Builder method correctness (`.prompt()`, `.prompt["custom text"]()`) +2. Prompting is SKIPPED when arguments are provided on the command line +3. Prompt fields are set correctly on Argument instances +4. Choices/defaults appear in the prompt (tested via field inspection) +""" + +from testing import assert_true, assert_false, assert_equal, TestSuite +import argmojo +from argmojo import Argument, Command, ParseResult + +# ── Builder method tests ───────────────────────────────────────────────────── + + +fn test_prompt_builder_default() raises: + """Tests that .prompt() enables prompting.""" + var arg = Argument("name", help="Your name").long["name"]().prompt() + assert_true(arg._prompt, msg=".prompt() should enable prompting") + assert_equal( + arg._prompt_text, "", msg="prompt text should be empty by default" + ) + + +fn test_prompt_builder_with_text() raises: + """Tests that .prompt["..."]() sets custom text and enables prompting.""" + var arg = ( + Argument("name", help="Your name") + .long["name"]() + .prompt["Enter your full name"]() + ) + assert_true(arg._prompt, msg=".prompt[text] should enable prompting") + assert_equal( + arg._prompt_text, + "Enter your full name", + msg="prompt text should match", + ) + + +fn test_prompt_with_custom_text_standalone() raises: + """Tests that .prompt["..."]() works as a standalone builder.""" + var arg = ( + Argument("email", help="Email address") + .long["email"]() + .prompt["Please enter your email"]() + ) + assert_true(arg._prompt, msg="prompt should be enabled") + assert_equal( + arg._prompt_text, + "Please enter your email", + msg="prompt text should be set", + ) + + +# ── Prompting skipped when value provided ──────────────────────────────────── + + +fn test_prompt_skipped_when_value_provided() raises: + """Tests that prompting is skipped when the argument is on the command line. + """ + var command = Command("test", "Test app") + command.add_argument( + Argument("name", help="Your name").long["name"]().prompt() + ) + + # Value provided on command line — no stdin interaction needed. + var args: List[String] = ["test", "--name", "Alice"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("name"), + "Alice", + msg="provided value should be used", + ) + + +fn test_prompt_skipped_for_flag_provided() raises: + """Tests that a prompt-enabled flag is skipped when provided.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("verbose", help="Enable verbose output") + .long["verbose"]() + .flag() + .prompt() + ) + + var args: List[String] = ["test", "--verbose"] + var result = command.parse_arguments(args) + assert_true( + result.get_flag("verbose"), + msg="flag should be True from command line", + ) + + +fn test_prompt_skipped_for_positional_provided() raises: + """Tests that a prompt-enabled positional is skipped when provided.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("file", help="Input file").positional().prompt() + ) + + var args: List[String] = ["test", "data.txt"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("file"), + "data.txt", + msg="positional should be set from command line", + ) + + +fn test_prompt_skipped_when_short_used() raises: + """Tests that prompting is skipped when the short option is used.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("output", help="Output file") + .long["output"]() + .short["o"]() + .prompt() + ) + + var args: List[String] = ["test", "-o", "out.txt"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("output"), + "out.txt", + msg="short option value should be used", + ) + + +fn test_prompt_skipped_when_equals_used() raises: + """Tests that prompting is skipped when --key=value syntax is used.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("format", help="Output format").long["format"]().prompt() + ) + + var args: List[String] = ["test", "--format=json"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("format"), + "json", + msg="equals value should be used", + ) + + +# ── Prompt with choices and defaults ───────────────────────────────────────── + + +fn test_prompt_with_choices_skipped_when_provided() raises: + """Tests that a prompt arg with choices works when value is on CLI.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("format", help="Output format") + .long["format"]() + .choice["json"]() + .choice["csv"]() + .choice["table"]() + .prompt() + ) + + var args: List[String] = ["test", "--format", "json"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("format"), + "json", + msg="choice value should be accepted", + ) + + +fn test_prompt_with_default_skipped_when_provided() raises: + """Tests that a prompt arg with default uses CLI value over default.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("level", help="Log level") + .long["level"]() + .default["info"]() + .prompt() + ) + + var args: List[String] = ["test", "--level", "debug"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("level"), + "debug", + msg="CLI value should override default", + ) + + +fn test_prompt_default_applied_when_no_prompt_input() raises: + """Tests that defaults still apply for prompt args when stdin is not a TTY. + + When stdin is not a TTY (piped/closed), prompting is skipped entirely + and defaults are applied normally. + """ + var command = Command("test", "Test app") + command.add_argument( + Argument("level", help="Log level") + .long["level"]() + .default["info"]() + .prompt() + ) + + # No --level on CLI → prompting is skipped (stdin is pipe in test + # runner), _apply_defaults fills in "info". + var args: List[String] = ["test"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("level"), + "info", + msg="default should be applied when prompting is skipped", + ) + + +# ── Prompt field propagation through copy/move ─────────────────────────────── + + +fn test_prompt_field_copy() raises: + """Tests that prompt fields survive Argument copy.""" + var original = ( + Argument("name", help="Your name").long["name"]().prompt["Enter name"]() + ) + var copy = original.copy() + assert_true(copy._prompt, msg="copy should preserve _prompt") + assert_equal( + copy._prompt_text, + "Enter name", + msg="copy should preserve _prompt_text", + ) + + +# ── Combined features ─────────────────────────────────────────────────────── + + +fn test_prompt_combined_with_required() raises: + """Tests that prompt works alongside .required() when value is given.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("name", help="Your name").long["name"]().required().prompt() + ) + + var args: List[String] = ["test", "--name", "Bob"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("name"), + "Bob", + msg="required+prompt should work when value provided", + ) + + +fn test_prompt_combined_with_group() raises: + """Tests that prompt works alongside .group[]().""" + var arg = ( + Argument("user", help="Username") + .long["user"]() + .prompt() + .group["Auth"]() + ) + assert_true(arg._prompt, msg="prompt should be set") + assert_equal(arg._group, "Auth", msg="group should be set") + + +fn test_prompt_on_optional_arg_with_default() raises: + """Tests that prompt works on non-required args (no .required()).""" + var command = Command("test", "Test app") + command.add_argument( + Argument("color", help="Output color") + .long["color"]() + .default["auto"]() + .prompt() + ) + + # Provide value on CLI — prompt is skipped. + var args: List[String] = ["test", "--color", "red"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("color"), + "red", + msg="optional prompt arg should accept CLI value", + ) + + +fn test_prompt_on_optional_arg_default_applied() raises: + """Tests that non-required prompt arg falls back to default when + stdin is not interactive (piped/closed).""" + var command = Command("test", "Test app") + command.add_argument( + Argument("color", help="Output color") + .long["color"]() + .default["auto"]() + .prompt() + ) + + # No --color on CLI, stdin is pipe → default "auto" used. + var args: List[String] = ["test"] + var result = command.parse_arguments(args) + assert_equal( + result.get_string("color"), + "auto", + msg="default should apply for optional prompt arg when not prompted", + ) + + +fn test_multiple_prompt_args_all_provided() raises: + """Tests that multiple prompt-enabled args work when all provided.""" + var command = Command("test", "Test app") + command.add_argument( + Argument("name", help="Your name").long["name"]().prompt() + ) + command.add_argument( + Argument("email", help="Email").long["email"]().prompt() + ) + command.add_argument(Argument("age", help="Age").long["age"]().prompt()) + + var args: List[String] = [ + "test", + "--name", + "Alice", + "--email", + "alice@example.com", + "--age", + "30", + ] + var result = command.parse_arguments(args) + assert_equal(result.get_string("name"), "Alice") + assert_equal(result.get_string("email"), "alice@example.com") + assert_equal(result.get_string("age"), "30") + + +# ── Test runner ────────────────────────────────────────────────────────────── + + +fn main() raises: + TestSuite.discover_tests[__functions_in_module()]().run()