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<String> = missing_names
178        .iter()
179        .map(|n| {
180            if n.contains('|') {
181                format_missing_group_label(n, leaf_command)
182            } else {
183                format_missing_arg_label(n, find_arg_def(leaf_command, n))
184            }
185        })
186        .collect();
187    drop(writeln!(
188        std::io::stderr(),
189        "\n  \u{26a0} missing required argument(s): {}",
190        missing_list.join(", ")
191    ));
192
193    // Collect prompted values, respecting arg declaration order.
194    let mut prompted_args: Vec<String> = Vec::new();
195    let mut already_supplied: Vec<String> = original_args.to_vec();
196
197    for raw_name in &missing_names {
198        let selected_raw = if raw_name.contains('|') {
199            let alternatives = split_missing_group_alternatives(raw_name);
200            let labels: Vec<String> = alternatives
201                .iter()
202                .map(|alt| format_missing_arg_label(alt, find_arg_def(leaf_command, alt)))
203                .collect();
204            match prompt_select("Choose one of the required options:", &labels) {
205                Ok(idx) => alternatives[idx].clone(),
206                Err(_) => {
207                    let resume = build_resume_command(app_name, &already_supplied[1..]);
208                    return Some(RecoveryResult::Cancelled { resume });
209                }
210            }
211        } else {
212            raw_name.clone()
213        };
214
215        let arg_def = find_arg_def(leaf_command, &selected_raw);
216
217        if let Some(arg) = arg_def
218            && matches!(
219                arg.get_action(),
220                clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
221            )
222        {
223            // Boolean flag chosen from a required group — no value prompt needed.
224            let start = prompted_args.len();
225            if let Some(long) = arg.get_long() {
226                prompted_args.push(format!("--{long}"));
227            }
228            already_supplied.extend_from_slice(&prompted_args[start..]);
229            continue;
230        }
231
232        let prompt_message = format_prompt_message(&selected_raw, arg_def);
233        let value = match infer_and_prompt(&prompt_message, arg_def) {
234            Ok(v) => v,
235            Err(_) => {
236                // User cancelled — build a resume command hint.
237                let resume = build_resume_command(app_name, &already_supplied[1..]);
238                return Some(RecoveryResult::Cancelled { resume });
239            }
240        };
241
242        // Append the prompted value to args.
243        let start = prompted_args.len();
244        if let Some(arg) = arg_def {
245            append_prompted_arg(&mut prompted_args, arg, &value);
246        } else {
247            prompted_args.push(value.clone());
248        }
249
250        // Track all tokens added this iteration for the resume command.
251        already_supplied.extend_from_slice(&prompted_args[start..]);
252    }
253
254    let mut augmented = original_args.to_vec();
255    augmented.extend(prompted_args);
256    Some(RecoveryResult::Recovered { args: augmented })
257}
258
259/// Outcome of a "did you mean X?" correction prompt.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum CommandCorrection {
262    /// User accepted; re-dispatch with the corrected token.
263    Accepted,
264    /// Don't rewrite args; caller renders the error (with hint when applicable).
265    Declined,
266    /// User aborted (Escape or Ctrl+C).
267    Cancelled,
268}
269
270/// Offer to correct an unknown command spelling. Never prompts when non-interactive;
271/// interactivity follows the same raw-args rules as [`try_recover_missing_args`].
272pub fn confirm_command_correction(
273    args: &[String],
274    suggestion: &str,
275    auto_interactive: bool,
276) -> CommandCorrection {
277    if !is_interactive_from_raw_args(args, auto_interactive) {
278        return CommandCorrection::Declined;
279    }
280    match prompt_confirm(&format!("Did you mean `{suggestion}`?"), true) {
281        Ok(true) => CommandCorrection::Accepted,
282        Ok(false) => CommandCorrection::Declined,
283        Err(_) => CommandCorrection::Cancelled,
284    }
285}
286
287/// Result of attempting interactive recovery for missing args.
288#[derive(Debug)]
289pub enum RecoveryResult {
290    /// Successfully prompted for all missing values; `args` has the augmented list.
291    Recovered { args: Vec<String> },
292    /// User cancelled mid-prompt; `resume` is the command to resume with
293    /// already-supplied flags.
294    Cancelled { resume: String },
295}
296
297/// Determine interactivity from raw args (before full clap parse).
298///
299/// Explicit flags always win. When neither is present, falls back to TTY
300/// auto-detection only if the CLI opted in via `auto_interactive`.
301fn is_interactive_from_raw_args(args: &[String], auto_interactive: bool) -> bool {
302    if args.iter().any(|a| a == "--non-interactive") {
303        return false;
304    }
305    if args.iter().any(|a| a == "--interactive") {
306        return true;
307    }
308    auto_interactive && crate::flags::detect_interactive()
309}
310
311/// Walk the command tree to find the leaf command the user was targeting.
312fn resolve_leaf_command<'cmd>(
313    root: &'cmd clap::Command,
314    args: &[String],
315    app_name: &str,
316) -> Option<&'cmd clap::Command> {
317    let mut current = root;
318    for arg in args.iter().skip(1) {
319        if arg.starts_with('-') {
320            continue;
321        }
322        if arg == app_name {
323            continue;
324        }
325        if let Some(sub) = current.find_subcommand(arg) {
326            current = sub;
327        } else {
328            break;
329        }
330    }
331    Some(current)
332}
333
334/// Infer the prompt type from clap arg metadata and prompt accordingly.
335///
336/// - If the arg has `possible_values`, use a Select prompt.
337/// - If the arg is boolean-like (action is SetTrue/SetFalse), use Confirm.
338/// - Otherwise, use a Text prompt.
339fn infer_and_prompt(message: &str, arg_def: Option<&clap::Arg>) -> crate::Result<String> {
340    if let Some(arg) = arg_def {
341        // Check for possible values (enum-like).
342        let possible: Vec<String> = arg
343            .get_possible_values()
344            .iter()
345            .filter(|pv| !pv.is_hide_set())
346            .map(|pv| pv.get_name().to_owned())
347            .collect();
348
349        if !possible.is_empty() {
350            let idx = prompt_select(message, &possible)?;
351            return Ok(possible[idx].clone());
352        }
353
354        // Check for boolean action.
355        if matches!(
356            arg.get_action(),
357            clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
358        ) {
359            let confirmed = prompt_confirm(message, true)?;
360            return Ok(confirmed.to_string());
361        }
362    }
363
364    // Default: free text input.
365    prompt_text(message, None)
366}
367
368/// Strip clap decoration from a single token (`--team-name` → `team-name`,
369/// `<domain>` → `domain`). Does not mangle clap's `flag <VALUE>` form.
370fn strip_arg_decoration(raw: &str) -> &str {
371    let stripped = raw.trim_start_matches('-');
372    if stripped.starts_with('<') && stripped.ends_with('>') && stripped.len() > 2 {
373        return &stripped[1..stripped.len() - 1];
374    }
375    if stripped.starts_with('[') && stripped.ends_with(']') && stripped.len() > 2 {
376        return &stripped[1..stripped.len() - 1];
377    }
378    stripped
379}
380
381/// Lookup key for a clap missing-arg identifier (`tld <TLD>` → `tld`).
382fn missing_arg_lookup_key(raw: &str) -> &str {
383    let stripped = raw.trim_start_matches('-');
384    strip_arg_decoration(stripped.split_whitespace().next().unwrap_or(stripped))
385}
386
387fn find_arg_def<'cmd>(command: &'cmd clap::Command, raw_name: &str) -> Option<&'cmd clap::Arg> {
388    let clean_name = missing_arg_lookup_key(raw_name);
389    command.get_arguments().find(|a| {
390        a.get_id().as_str() == clean_name
391            || a.get_long().is_some_and(|l| l == clean_name)
392            || a.get_value_names().is_some_and(|vn| {
393                vn.iter().any(|v| {
394                    v.eq_ignore_ascii_case(strip_arg_decoration(
395                        raw_name.split_whitespace().nth(1).unwrap_or(raw_name),
396                    ))
397                })
398            })
399    })
400}
401
402fn format_missing_arg_label(raw_name: &str, arg_def: Option<&clap::Arg>) -> String {
403    if let Some(arg) = arg_def {
404        if let Some(long) = arg.get_long() {
405            return format!("--{long}");
406        }
407        if let Some(help) = arg.get_help() {
408            return help.to_string().trim_end_matches('.').to_owned();
409        }
410    }
411    if let Some(value_name) = extract_value_name_suffix(raw_name) {
412        return value_name;
413    }
414    missing_arg_lookup_key(raw_name).replace('-', " ")
415}
416
417fn extract_value_name_suffix(raw_name: &str) -> Option<String> {
418    let stripped = raw_name.trim_start_matches('-');
419    let (_, value) = stripped.split_once(' ')?;
420    Some(strip_arg_decoration(value).to_owned())
421}
422
423/// Split clap's combined missing-arg group identifier into individual alternatives.
424///
425/// Clap reports required `ArgGroup`s as a single token such as
426/// `<--a <a>|--b <b>>` or `<--one|--two>`.
427fn split_missing_group_alternatives(raw_name: &str) -> Vec<String> {
428    let trimmed = raw_name.trim();
429    let inner = trimmed
430        .strip_prefix('<')
431        .and_then(|s| s.strip_suffix('>'))
432        .unwrap_or(trimmed);
433    if !inner.contains('|') {
434        return vec![raw_name.to_owned()];
435    }
436    inner
437        .split('|')
438        .map(str::trim)
439        .filter(|part| !part.is_empty())
440        .map(str::to_owned)
441        .collect()
442}
443
444fn format_missing_group_label(raw_name: &str, command: &clap::Command) -> String {
445    let labels: Vec<String> = split_missing_group_alternatives(raw_name)
446        .iter()
447        .map(|alt| format_missing_arg_label(alt, find_arg_def(command, alt)))
448        .collect();
449    format!("one of: {}", labels.join(", "))
450}
451
452fn append_prompted_arg(prompted_args: &mut Vec<String>, arg: &clap::Arg, value: &str) {
453    if let Some(long) = arg.get_long() {
454        if matches!(
455            arg.get_action(),
456            clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
457        ) {
458            // Boolean flags: clap expects `--flag` alone, no value.
459            if value == "true" {
460                prompted_args.push(format!("--{long}"));
461            }
462        } else {
463            prompted_args.push(format!("--{long}"));
464            prompted_args.push(value.to_owned());
465        }
466    } else {
467        prompted_args.push(value.to_owned());
468    }
469}
470
471/// Normalize prompt/help text and append a single trailing colon.
472fn normalize_prompt_base(text: &str) -> String {
473    let trimmed = text.trim_end_matches('.').trim_end();
474    if trimmed.ends_with(':') {
475        trimmed.to_owned()
476    } else {
477        format!("{trimmed}:")
478    }
479}
480
481/// Format a human-friendly prompt message from a raw clap arg identifier.
482fn format_prompt_message(raw_name: &str, arg_def: Option<&clap::Arg>) -> String {
483    let base = if let Some(arg) = arg_def
484        && let Some(help) = arg.get_help().map(|s| s.to_string())
485    {
486        help.trim_end_matches('.').to_owned()
487    } else if let Some(value_name) = extract_value_name_suffix(raw_name) {
488        value_name
489    } else {
490        missing_arg_lookup_key(raw_name).replace('-', " ")
491    };
492    normalize_prompt_base(&base)
493}
494
495/// Build a resume command string from the already-supplied args.
496///
497/// Shows the user what to run to continue where they left off.
498pub fn build_resume_command(app_name: &str, supplied_args: &[String]) -> String {
499    let mut parts = vec![app_name.to_owned()];
500    parts.extend(supplied_args.iter().cloned());
501    parts.join(" ")
502}
503
504/// Convert an `inquire` error into a CLI-engine error.
505fn inquire_error_to_cli(err: inquire::InquireError) -> CliCoreError {
506    match err {
507        inquire::InquireError::OperationCanceled | inquire::InquireError::OperationInterrupted => {
508            CliCoreError::message("prompt cancelled")
509        }
510        other => CliCoreError::message(format!("prompt error: {other}")),
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn is_interactive_from_raw_args_non_interactive_flag() {
520        let args: Vec<String> = vec![
521            "my-cli".into(),
522            "project".into(),
523            "list".into(),
524            "--non-interactive".into(),
525        ];
526        // --non-interactive wins even if auto_interactive is true
527        assert!(!is_interactive_from_raw_args(&args, true));
528    }
529
530    #[test]
531    fn is_interactive_from_raw_args_interactive_flag() {
532        let args: Vec<String> = vec![
533            "my-cli".into(),
534            "project".into(),
535            "list".into(),
536            "--interactive".into(),
537        ];
538        // --interactive works even without auto_interactive
539        assert!(is_interactive_from_raw_args(&args, false));
540    }
541
542    #[test]
543    fn is_interactive_no_flags_auto_disabled() {
544        let args: Vec<String> = vec!["my-cli".into(), "project".into(), "list".into()];
545        // Without auto_interactive and no explicit flag, never interactive
546        assert!(!is_interactive_from_raw_args(&args, false));
547    }
548
549    #[test]
550    fn format_prompt_message_from_flag_name() {
551        let msg = format_prompt_message("--team-name", None);
552        assert_eq!(msg, "team name:");
553    }
554
555    #[test]
556    fn format_prompt_message_from_positional() {
557        let msg = format_prompt_message("<domain>", None);
558        assert_eq!(msg, "domain:");
559    }
560
561    #[test]
562    fn strip_arg_decoration_preserves_flag_value_name_pairs() {
563        assert_eq!(strip_arg_decoration("tld <TLD>"), "tld <TLD>");
564    }
565
566    #[test]
567    fn missing_arg_lookup_key_extracts_flag_id() {
568        assert_eq!(missing_arg_lookup_key("tld <TLD>"), "tld");
569        assert_eq!(missing_arg_lookup_key("--team-name"), "team-name");
570        assert_eq!(missing_arg_lookup_key("<domain>"), "domain");
571    }
572
573    #[test]
574    fn format_prompt_message_from_flag_value_name_pair() {
575        let msg = format_prompt_message("tld <TLD>", None);
576        assert_eq!(msg, "TLD:");
577    }
578
579    #[test]
580    fn format_prompt_message_uses_help_text() {
581        let arg = clap::Arg::new("team").long("team").help("Team identifier");
582        let msg = format_prompt_message("--team", Some(&arg));
583        assert_eq!(msg, "Team identifier:");
584    }
585
586    #[test]
587    fn build_resume_command_with_partial_args() {
588        let resume = build_resume_command(
589            "gddy",
590            &[
591                "domain".into(),
592                "register".into(),
593                "--period".into(),
594                "2".into(),
595            ],
596        );
597        assert_eq!(resume, "gddy domain register --period 2");
598    }
599
600    #[test]
601    fn resolve_leaf_command_walks_subcommands() {
602        let root = clap::Command::new("my-cli").subcommand(
603            clap::Command::new("project")
604                .subcommand(clap::Command::new("list").arg(clap::Arg::new("team").long("team"))),
605        );
606        let args: Vec<String> = vec![
607            "my-cli".into(),
608            "project".into(),
609            "list".into(),
610            "--team".into(),
611            "dev".into(),
612        ];
613        let leaf = resolve_leaf_command(&root, &args, "my-cli");
614        assert!(leaf.is_some());
615        assert_eq!(leaf.expect("tested").get_name(), "list");
616    }
617
618    #[test]
619    fn confirm_command_correction_declines_when_non_interactive() {
620        let args: Vec<String> = vec!["my-cli".into(), "projet".into()];
621        assert_eq!(
622            confirm_command_correction(&args, "project", false),
623            CommandCorrection::Declined
624        );
625
626        let args: Vec<String> = vec!["my-cli".into(), "projet".into(), "--non-interactive".into()];
627        assert_eq!(
628            confirm_command_correction(&args, "project", true),
629            CommandCorrection::Declined
630        );
631    }
632
633    #[test]
634    fn try_recover_returns_none_for_non_missing_arg_error() {
635        let cmd = clap::Command::new("test").arg(
636            clap::Arg::new("name")
637                .long("name")
638                .value_parser(["alpha", "beta"]),
639        );
640        let err = cmd
641            .try_get_matches_from(["test", "--name", "invalid"])
642            .expect_err("should fail");
643        let args: Vec<String> = vec!["test".into(), "--name".into(), "invalid".into()];
644        let result =
645            try_recover_missing_args(&err, &args, &clap::Command::new("test"), "test", true);
646        assert!(result.is_none());
647    }
648
649    #[test]
650    fn try_recover_returns_none_when_non_interactive() {
651        // Build a command that knows about --non-interactive (like the real CLI)
652        // so clap produces a MissingRequiredArgument error, not UnknownArgument.
653        let cmd = clap::Command::new("test")
654            .arg(clap::Arg::new("name").long("name").required(true))
655            .arg(
656                clap::Arg::new("non-interactive")
657                    .long("non-interactive")
658                    .action(clap::ArgAction::SetTrue),
659            );
660        let err = cmd
661            .try_get_matches_from(["test", "--non-interactive"])
662            .expect_err("should fail with missing --name");
663        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
664        let args: Vec<String> = vec!["test".into(), "--non-interactive".into()];
665        let lookup_cmd = clap::Command::new("test")
666            .arg(clap::Arg::new("name").long("name").required(true))
667            .arg(
668                clap::Arg::new("non-interactive")
669                    .long("non-interactive")
670                    .action(clap::ArgAction::SetTrue),
671            );
672        let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", true);
673        assert!(result.is_none());
674    }
675
676    #[test]
677    fn try_recover_returns_none_when_auto_interactive_disabled() {
678        let cmd =
679            clap::Command::new("test").arg(clap::Arg::new("name").long("name").required(true));
680        let err = cmd
681            .try_get_matches_from(["test"])
682            .expect_err("should fail with missing --name");
683        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
684        let args: Vec<String> = vec!["test".into()];
685        let lookup_cmd =
686            clap::Command::new("test").arg(clap::Arg::new("name").long("name").required(true));
687        // auto_interactive = false, no explicit --interactive flag → no recovery
688        let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", false);
689        assert!(result.is_none());
690    }
691
692    #[allow(clippy::panic)]
693    fn missing_arg_names(cmd: &clap::Command, argv: &[&str]) -> Vec<String> {
694        use clap::error::{ContextKind, ContextValue, ErrorKind};
695        let err = cmd
696            .clone()
697            .try_get_matches_from(argv)
698            .expect_err("expected missing required argument");
699        assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
700        match err.get(ContextKind::InvalidArg) {
701            Some(ContextValue::Strings(names)) => names.clone(),
702            Some(ContextValue::String(name)) => vec![name.clone()],
703            other => panic!("unexpected InvalidArg context: {other:?}"),
704        }
705    }
706
707    #[test]
708    fn clap_reports_flag_value_name_pair_for_missing_flag_value() {
709        let cmd = clap::Command::new("agreements").arg(
710            clap::Arg::new("tld")
711                .long("tld")
712                .value_name("TLD")
713                .required(true),
714        );
715        let names = missing_arg_names(&cmd, &["agreements"]);
716        assert_eq!(names, vec!["--tld <TLD>"]);
717        let arg_def = find_arg_def(&cmd, &names[0]);
718        assert!(arg_def.is_some());
719        assert_eq!(format_prompt_message(&names[0], arg_def), "TLD:");
720        assert_eq!(format_missing_arg_label(&names[0], arg_def), "--tld");
721    }
722
723    #[test]
724    fn clap_reports_positional_value_name_for_missing_positional() {
725        let cmd = clap::Command::new("suggest").arg(
726            clap::Arg::new("query")
727                .value_name("QUERY")
728                .help("Seed domain or keywords to base suggestions on")
729                .required(true),
730        );
731        let names = missing_arg_names(&cmd, &["suggest"]);
732        assert_eq!(names, vec!["<QUERY>"]);
733        let arg_def = find_arg_def(&cmd, &names[0]);
734        assert!(arg_def.is_some());
735        assert_eq!(
736            format_prompt_message(&names[0], arg_def),
737            "Seed domain or keywords to base suggestions on:"
738        );
739        assert_eq!(
740            format_missing_arg_label(&names[0], arg_def),
741            "Seed domain or keywords to base suggestions on"
742        );
743    }
744
745    #[test]
746    fn clap_reports_flag_value_name_pair_with_dashed_long_flag() {
747        let cmd = clap::Command::new("test").arg(
748            clap::Arg::new("team_name")
749                .long("team-name")
750                .value_name("TEAM")
751                .required(true),
752        );
753        let names = missing_arg_names(&cmd, &["test"]);
754        assert_eq!(names, vec!["--team-name <TEAM>"]);
755        let arg_def = find_arg_def(&cmd, &names[0]);
756        assert!(arg_def.is_some());
757        assert_eq!(format_missing_arg_label(&names[0], arg_def), "--team-name");
758        assert_eq!(format_prompt_message(&names[0], arg_def), "TEAM:");
759    }
760
761    #[test]
762    fn format_prompt_message_handles_legacy_flag_value_name_without_dashes() {
763        // Older clap versions reported `tld <TLD>` without a `--` prefix.
764        let cmd = clap::Command::new("agreements").arg(
765            clap::Arg::new("tld")
766                .long("tld")
767                .value_name("TLD")
768                .required(true),
769        );
770        let arg_def = find_arg_def(&cmd, "tld <TLD>");
771        assert!(arg_def.is_some());
772        assert_eq!(format_prompt_message("tld <TLD>", arg_def), "TLD:");
773    }
774
775    #[test]
776    fn format_prompt_message_avoids_double_colon_when_help_ends_with_colon() {
777        let arg = clap::Arg::new("domain")
778            .long("domain")
779            .help("Enter domain:");
780        let msg = format_prompt_message("--domain", Some(&arg));
781        assert_eq!(msg, "Enter domain:");
782    }
783
784    #[test]
785    fn find_arg_def_matches_short_flag_value_name_pair() {
786        let cmd = clap::Command::new("test").arg(
787            clap::Arg::new("tld")
788                .short('t')
789                .long("tld")
790                .value_name("TLD")
791                .required(true),
792        );
793        let arg_def = find_arg_def(&cmd, "t <TLD>");
794        assert!(arg_def.is_some());
795        assert_eq!(format_missing_arg_label("t <TLD>", arg_def), "--tld");
796    }
797
798    #[test]
799    fn required_arg_group_lists_member_flags_in_missing_context() {
800        use clap::ArgGroup;
801        let cmd = clap::Command::new("update")
802            .arg(clap::Arg::new("a").long("a"))
803            .arg(clap::Arg::new("b").long("b"))
804            .group(ArgGroup::new("ab").args(["a", "b"]).required(true));
805        let names = missing_arg_names(&cmd, &["update"]);
806        assert_eq!(names.len(), 1);
807        assert!(names[0].contains('|'));
808        let alternatives = split_missing_group_alternatives(&names[0]);
809        assert_eq!(alternatives, vec!["--a <a>", "--b <b>"]);
810        assert!(find_arg_def(&cmd, &alternatives[0]).is_some());
811        assert!(find_arg_def(&cmd, &alternatives[1]).is_some());
812    }
813
814    #[test]
815    fn derive_exclusive_group_reports_alternatives() {
816        use clap::CommandFactory;
817
818        #[derive(clap::Parser)]
819        #[command(name = "bump")]
820        struct Bump {
821            #[command(flatten)]
822            args: ExclusiveArgs,
823        }
824
825        #[derive(clap::Args)]
826        #[group(required = true, multiple = false)]
827        struct ExclusiveArgs {
828            #[arg(long)]
829            one: bool,
830            #[arg(long)]
831            two: bool,
832        }
833
834        let names = missing_arg_names(&Bump::command(), &["bump"]);
835        assert_eq!(names.len(), 1);
836        let alternatives = split_missing_group_alternatives(&names[0]);
837        assert_eq!(alternatives, vec!["--one", "--two"]);
838    }
839}