Skip to main content

cli_engine/
prompt.rs

1//! Interactive prompt helpers for CLI commands.
2//!
3//! These functions wrap [`inquire`] to provide consistent prompts that respect
4//! the global interactivity mode. Each helper returns a [`Result`] that
5//! produces a user-cancelled error when the user presses Escape or Ctrl+C.
6//!
7//! All functions require an interactive TTY. Call them only when
8//! [`CommandContext::is_interactive`](crate::command::CommandContext::is_interactive)
9//! returns `true`.
10
11use std::io::Write as _;
12
13use crate::error::CliCoreError;
14
15/// Prompt the user for a free-text string input.
16///
17/// Returns the trimmed user input, or an error if the user cancelled.
18///
19/// # Errors
20///
21/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs.
22pub fn prompt_text(message: &str, default: Option<&str>) -> crate::Result<String> {
23    let mut prompt = inquire::Text::new(message);
24    if let Some(d) = default {
25        prompt = prompt.with_default(d);
26    }
27    prompt
28        .prompt()
29        .map(|s| s.trim().to_owned())
30        .map_err(inquire_error_to_cli)
31}
32
33/// Prompt the user for a free-text string with input validation.
34///
35/// The `validator` closure should return `Ok(())` if the input is valid, or
36/// `Err(message)` with a user-facing explanation if invalid.
37///
38/// # Errors
39///
40/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs.
41pub fn prompt_text_with_validation(
42    message: &str,
43    default: Option<&str>,
44    validator: impl Fn(&str) -> Result<(), String> + Clone + 'static,
45) -> crate::Result<String> {
46    let mut prompt = inquire::Text::new(message);
47    if let Some(d) = default {
48        prompt = prompt.with_default(d);
49    }
50    prompt = prompt.with_validator(move |input: &str| {
51        Ok(match (validator)(input) {
52            Ok(()) => inquire::validator::Validation::Valid,
53            Err(msg) => inquire::validator::Validation::Invalid(msg.into()),
54        })
55    });
56    prompt
57        .prompt()
58        .map(|s| s.trim().to_owned())
59        .map_err(inquire_error_to_cli)
60}
61
62/// Prompt the user to select one option from a list.
63///
64/// Returns the index of the selected option.
65///
66/// # Errors
67///
68/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs.
69pub fn prompt_select(message: &str, options: &[String]) -> crate::Result<usize> {
70    let result = inquire::Select::new(message, options.to_vec())
71        .prompt()
72        .map_err(inquire_error_to_cli)?;
73    options
74        .iter()
75        .position(|o| o == &result)
76        .ok_or_else(|| CliCoreError::message("selected option not found in list"))
77}
78
79/// Prompt the user for a yes/no confirmation.
80///
81/// Returns `true` for yes, `false` for no.
82///
83/// # Errors
84///
85/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs.
86pub fn prompt_confirm(message: &str, default: bool) -> crate::Result<bool> {
87    inquire::Confirm::new(message)
88        .with_default(default)
89        .prompt()
90        .map_err(inquire_error_to_cli)
91}
92
93/// Prompt the user to select multiple options from a list.
94///
95/// Returns the indices of the selected options. The `defaults` slice
96/// indicates which items are pre-selected (by index).
97///
98/// # Errors
99///
100/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs.
101pub fn prompt_multi_select(
102    message: &str,
103    options: &[String],
104    defaults: &[bool],
105) -> crate::Result<Vec<usize>> {
106    let defaults_vec: Vec<bool> = if defaults.len() == options.len() {
107        defaults.to_vec()
108    } else {
109        vec![false; options.len()]
110    };
111
112    let selected = inquire::MultiSelect::new(message, options.to_vec())
113        .with_default(
114            &defaults_vec
115                .iter()
116                .copied()
117                .enumerate()
118                .filter_map(|(i, d)| d.then_some(i))
119                .collect::<Vec<_>>(),
120        )
121        .prompt()
122        .map_err(inquire_error_to_cli)?;
123
124    Ok(selected
125        .iter()
126        .filter_map(|s| options.iter().position(|o| o == s))
127        .collect())
128}
129
130/// Attempt to interactively recover from a clap `MissingRequiredArgument` error.
131///
132/// When the CLI is running interactively and clap reports missing required
133/// arguments, this function prompts the user for each missing value (in
134/// declaration order), appends them to the original args, and returns `Some`
135/// with the augmented arg list so the caller can re-parse.
136///
137/// Returns `None` if recovery is not possible (non-interactive or not a
138/// missing-arg error). Returns `Some(RecoveryResult::Cancelled { .. })` if
139/// the user cancels mid-prompt.
140///
141/// # Arguments
142///
143/// * `err` — the clap error from `try_get_matches_from`
144/// * `original_args` — the args that were passed to clap
145/// * `command` — the root `clap::Command` (for arg introspection)
146/// * `app_name` — the CLI binary name (first arg)
147/// * `auto_interactive` — whether the CLI opted into TTY auto-detection
148pub fn try_recover_missing_args(
149    err: &clap::error::Error,
150    original_args: &[String],
151    command: &clap::Command,
152    app_name: &str,
153    auto_interactive: bool,
154) -> Option<RecoveryResult> {
155    use clap::error::{ContextKind, ContextValue, ErrorKind};
156
157    if err.kind() != ErrorKind::MissingRequiredArgument {
158        return None;
159    }
160
161    // Check interactivity from raw args (clap hasn't fully parsed yet).
162    if !is_interactive_from_raw_args(original_args, auto_interactive) {
163        return None;
164    }
165
166    // Extract the missing arg names from clap error context.
167    let missing_names = match err.get(ContextKind::InvalidArg)? {
168        ContextValue::Strings(names) => names.clone(),
169        ContextValue::String(name) => vec![name.clone()],
170        _ => return None,
171    };
172
173    // Resolve the leaf command from the args to get arg metadata.
174    let leaf_command = resolve_leaf_command(command, original_args, app_name)?;
175
176    // Print a header so the user knows why they're being prompted.
177    let missing_list: Vec<&str> = missing_names
178        .iter()
179        .map(|n| strip_arg_decoration(n))
180        .collect();
181    drop(writeln!(
182        std::io::stderr(),
183        "\n  \u{26a0} missing required argument(s): {}",
184        missing_list.join(", ")
185    ));
186
187    // Collect prompted values, respecting arg declaration order.
188    let mut prompted_args: Vec<String> = Vec::new();
189    let mut already_supplied: Vec<String> = original_args.to_vec();
190
191    for raw_name in &missing_names {
192        let clean_name = strip_arg_decoration(raw_name);
193        let arg_def = leaf_command.get_arguments().find(|a| {
194            a.get_id().as_str() == clean_name
195                || a.get_long().is_some_and(|l| l == clean_name)
196                || a.get_value_names().is_some_and(|vn| {
197                    vn.iter()
198                        .any(|v| v.to_ascii_uppercase() == raw_name.trim_matches(['<', '>']))
199                })
200        });
201
202        let prompt_message = format_prompt_message(raw_name, arg_def);
203        let value = match infer_and_prompt(&prompt_message, arg_def) {
204            Ok(v) => v,
205            Err(_) => {
206                // User cancelled — build a resume command hint.
207                let resume = build_resume_command(app_name, &already_supplied[1..]);
208                return Some(RecoveryResult::Cancelled { resume });
209            }
210        };
211
212        // Append the prompted value to args.
213        let start = prompted_args.len();
214        if let Some(arg) = arg_def {
215            if let Some(long) = arg.get_long() {
216                if matches!(
217                    arg.get_action(),
218                    clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
219                ) {
220                    // Boolean flags: clap expects `--flag` alone, no value.
221                    if value == "true" {
222                        prompted_args.push(format!("--{long}"));
223                    }
224                } else {
225                    prompted_args.push(format!("--{long}"));
226                    prompted_args.push(value.clone());
227                }
228            } else {
229                prompted_args.push(value.clone());
230            }
231        } else {
232            prompted_args.push(value.clone());
233        }
234
235        // Track all tokens added this iteration for the resume command.
236        already_supplied.extend_from_slice(&prompted_args[start..]);
237    }
238
239    let mut augmented = original_args.to_vec();
240    augmented.extend(prompted_args);
241    Some(RecoveryResult::Recovered { args: augmented })
242}
243
244/// Outcome of a "did you mean X?" correction prompt.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum CommandCorrection {
247    /// User accepted; re-dispatch with the corrected token.
248    Accepted,
249    /// Don't rewrite args; caller renders the error (with hint when applicable).
250    Declined,
251    /// User aborted (Escape or Ctrl+C).
252    Cancelled,
253}
254
255/// Offer to correct an unknown command spelling. Never prompts when non-interactive;
256/// interactivity follows the same raw-args rules as [`try_recover_missing_args`].
257pub fn confirm_command_correction(
258    args: &[String],
259    suggestion: &str,
260    auto_interactive: bool,
261) -> CommandCorrection {
262    if !is_interactive_from_raw_args(args, auto_interactive) {
263        return CommandCorrection::Declined;
264    }
265    match prompt_confirm(&format!("Did you mean `{suggestion}`?"), true) {
266        Ok(true) => CommandCorrection::Accepted,
267        Ok(false) => CommandCorrection::Declined,
268        Err(_) => CommandCorrection::Cancelled,
269    }
270}
271
272/// Result of attempting interactive recovery for missing args.
273#[derive(Debug)]
274pub enum RecoveryResult {
275    /// Successfully prompted for all missing values; `args` has the augmented list.
276    Recovered { args: Vec<String> },
277    /// User cancelled mid-prompt; `resume` is the command to resume with
278    /// already-supplied flags.
279    Cancelled { resume: String },
280}
281
282/// Determine interactivity from raw args (before full clap parse).
283///
284/// Explicit flags always win. When neither is present, falls back to TTY
285/// auto-detection only if the CLI opted in via `auto_interactive`.
286fn is_interactive_from_raw_args(args: &[String], auto_interactive: bool) -> bool {
287    if args.iter().any(|a| a == "--non-interactive") {
288        return false;
289    }
290    if args.iter().any(|a| a == "--interactive") {
291        return true;
292    }
293    auto_interactive && crate::flags::detect_interactive()
294}
295
296/// Walk the command tree to find the leaf command the user was targeting.
297fn resolve_leaf_command<'cmd>(
298    root: &'cmd clap::Command,
299    args: &[String],
300    app_name: &str,
301) -> Option<&'cmd clap::Command> {
302    let mut current = root;
303    for arg in args.iter().skip(1) {
304        if arg.starts_with('-') {
305            continue;
306        }
307        if arg == app_name {
308            continue;
309        }
310        if let Some(sub) = current.find_subcommand(arg) {
311            current = sub;
312        } else {
313            break;
314        }
315    }
316    Some(current)
317}
318
319/// Infer the prompt type from clap arg metadata and prompt accordingly.
320///
321/// - If the arg has `possible_values`, use a Select prompt.
322/// - If the arg is boolean-like (action is SetTrue/SetFalse), use Confirm.
323/// - Otherwise, use a Text prompt.
324fn infer_and_prompt(message: &str, arg_def: Option<&clap::Arg>) -> crate::Result<String> {
325    if let Some(arg) = arg_def {
326        // Check for possible values (enum-like).
327        let possible: Vec<String> = arg
328            .get_possible_values()
329            .iter()
330            .filter(|pv| !pv.is_hide_set())
331            .map(|pv| pv.get_name().to_owned())
332            .collect();
333
334        if !possible.is_empty() {
335            let idx = prompt_select(message, &possible)?;
336            return Ok(possible[idx].clone());
337        }
338
339        // Check for boolean action.
340        if matches!(
341            arg.get_action(),
342            clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
343        ) {
344            let confirmed = prompt_confirm(message, true)?;
345            return Ok(confirmed.to_string());
346        }
347    }
348
349    // Default: free text input.
350    prompt_text(message, None)
351}
352
353/// Strip clap decoration (`--`, `<>`, `[]`) from a raw arg identifier,
354/// yielding the bare name (e.g. `"--team-name"` → `"team-name"`,
355/// `"<domain>"` → `"domain"`).
356fn strip_arg_decoration(raw: &str) -> &str {
357    raw.trim_start_matches('-')
358        .trim_matches(['<', '>', '[', ']'])
359}
360
361/// Format a human-friendly prompt message from a raw clap arg identifier.
362fn format_prompt_message(raw_name: &str, arg_def: Option<&clap::Arg>) -> String {
363    let base = if let Some(arg) = arg_def
364        && let Some(help) = arg.get_help().map(|s| s.to_string())
365    {
366        help.trim_end_matches('.').to_owned()
367    } else {
368        strip_arg_decoration(raw_name).replace('-', " ")
369    };
370    format!("{base}:")
371}
372
373/// Build a resume command string from the already-supplied args.
374///
375/// Shows the user what to run to continue where they left off.
376pub fn build_resume_command(app_name: &str, supplied_args: &[String]) -> String {
377    let mut parts = vec![app_name.to_owned()];
378    parts.extend(supplied_args.iter().cloned());
379    parts.join(" ")
380}
381
382/// Convert an `inquire` error into a CLI-engine error.
383fn inquire_error_to_cli(err: inquire::InquireError) -> CliCoreError {
384    match err {
385        inquire::InquireError::OperationCanceled | inquire::InquireError::OperationInterrupted => {
386            CliCoreError::message("prompt cancelled")
387        }
388        other => CliCoreError::message(format!("prompt error: {other}")),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn is_interactive_from_raw_args_non_interactive_flag() {
398        let args: Vec<String> = vec![
399            "my-cli".into(),
400            "project".into(),
401            "list".into(),
402            "--non-interactive".into(),
403        ];
404        // --non-interactive wins even if auto_interactive is true
405        assert!(!is_interactive_from_raw_args(&args, true));
406    }
407
408    #[test]
409    fn is_interactive_from_raw_args_interactive_flag() {
410        let args: Vec<String> = vec![
411            "my-cli".into(),
412            "project".into(),
413            "list".into(),
414            "--interactive".into(),
415        ];
416        // --interactive works even without auto_interactive
417        assert!(is_interactive_from_raw_args(&args, false));
418    }
419
420    #[test]
421    fn is_interactive_no_flags_auto_disabled() {
422        let args: Vec<String> = vec!["my-cli".into(), "project".into(), "list".into()];
423        // Without auto_interactive and no explicit flag, never interactive
424        assert!(!is_interactive_from_raw_args(&args, false));
425    }
426
427    #[test]
428    fn format_prompt_message_from_flag_name() {
429        let msg = format_prompt_message("--team-name", None);
430        assert_eq!(msg, "team name:");
431    }
432
433    #[test]
434    fn format_prompt_message_from_positional() {
435        let msg = format_prompt_message("<domain>", None);
436        assert_eq!(msg, "domain:");
437    }
438
439    #[test]
440    fn format_prompt_message_uses_help_text() {
441        let arg = clap::Arg::new("team").long("team").help("Team identifier");
442        let msg = format_prompt_message("--team", Some(&arg));
443        assert_eq!(msg, "Team identifier:");
444    }
445
446    #[test]
447    fn build_resume_command_with_partial_args() {
448        let resume = build_resume_command(
449            "gddy",
450            &[
451                "domain".into(),
452                "register".into(),
453                "--period".into(),
454                "2".into(),
455            ],
456        );
457        assert_eq!(resume, "gddy domain register --period 2");
458    }
459
460    #[test]
461    fn resolve_leaf_command_walks_subcommands() {
462        let root = clap::Command::new("my-cli").subcommand(
463            clap::Command::new("project")
464                .subcommand(clap::Command::new("list").arg(clap::Arg::new("team").long("team"))),
465        );
466        let args: Vec<String> = vec![
467            "my-cli".into(),
468            "project".into(),
469            "list".into(),
470            "--team".into(),
471            "dev".into(),
472        ];
473        let leaf = resolve_leaf_command(&root, &args, "my-cli");
474        assert!(leaf.is_some());
475        assert_eq!(leaf.expect("tested").get_name(), "list");
476    }
477
478    #[test]
479    fn confirm_command_correction_declines_when_non_interactive() {
480        let args: Vec<String> = vec!["my-cli".into(), "projet".into()];
481        assert_eq!(
482            confirm_command_correction(&args, "project", false),
483            CommandCorrection::Declined
484        );
485
486        let args: Vec<String> = vec!["my-cli".into(), "projet".into(), "--non-interactive".into()];
487        assert_eq!(
488            confirm_command_correction(&args, "project", true),
489            CommandCorrection::Declined
490        );
491    }
492
493    #[test]
494    fn try_recover_returns_none_for_non_missing_arg_error() {
495        let cmd = clap::Command::new("test").arg(
496            clap::Arg::new("name")
497                .long("name")
498                .value_parser(["alpha", "beta"]),
499        );
500        let err = cmd
501            .try_get_matches_from(["test", "--name", "invalid"])
502            .expect_err("should fail");
503        let args: Vec<String> = vec!["test".into(), "--name".into(), "invalid".into()];
504        let result =
505            try_recover_missing_args(&err, &args, &clap::Command::new("test"), "test", true);
506        assert!(result.is_none());
507    }
508
509    #[test]
510    fn try_recover_returns_none_when_non_interactive() {
511        // Build a command that knows about --non-interactive (like the real CLI)
512        // so clap produces a MissingRequiredArgument error, not UnknownArgument.
513        let cmd = clap::Command::new("test")
514            .arg(clap::Arg::new("name").long("name").required(true))
515            .arg(
516                clap::Arg::new("non-interactive")
517                    .long("non-interactive")
518                    .action(clap::ArgAction::SetTrue),
519            );
520        let err = cmd
521            .try_get_matches_from(["test", "--non-interactive"])
522            .expect_err("should fail with missing --name");
523        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
524        let args: Vec<String> = vec!["test".into(), "--non-interactive".into()];
525        let lookup_cmd = clap::Command::new("test")
526            .arg(clap::Arg::new("name").long("name").required(true))
527            .arg(
528                clap::Arg::new("non-interactive")
529                    .long("non-interactive")
530                    .action(clap::ArgAction::SetTrue),
531            );
532        let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", true);
533        assert!(result.is_none());
534    }
535
536    #[test]
537    fn try_recover_returns_none_when_auto_interactive_disabled() {
538        let cmd =
539            clap::Command::new("test").arg(clap::Arg::new("name").long("name").required(true));
540        let err = cmd
541            .try_get_matches_from(["test"])
542            .expect_err("should fail with missing --name");
543        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
544        let args: Vec<String> = vec!["test".into()];
545        let lookup_cmd =
546            clap::Command::new("test").arg(clap::Arg::new("name").long("name").required(true));
547        // auto_interactive = false, no explicit --interactive flag → no recovery
548        let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", false);
549        assert!(result.is_none());
550    }
551}