1use std::io::Write as _;
12
13use crate::error::CliCoreError;
14
15pub 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
33pub 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
62pub 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
79pub 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
93pub 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
130pub 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 if !is_interactive_from_raw_args(original_args, auto_interactive) {
163 return None;
164 }
165
166 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 let leaf_command = resolve_leaf_command(command, original_args, app_name)?;
175
176 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 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 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 let resume = build_resume_command(app_name, &already_supplied[1..]);
238 return Some(RecoveryResult::Cancelled { resume });
239 }
240 };
241
242 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum CommandCorrection {
262 Accepted,
264 Declined,
266 Cancelled,
268}
269
270pub 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#[derive(Debug)]
289pub enum RecoveryResult {
290 Recovered { args: Vec<String> },
292 Cancelled { resume: String },
295}
296
297fn 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
311fn 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
334fn infer_and_prompt(message: &str, arg_def: Option<&clap::Arg>) -> crate::Result<String> {
340 if let Some(arg) = arg_def {
341 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 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 prompt_text(message, None)
366}
367
368fn 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
381fn 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
423fn 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 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
471fn 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
481fn 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
495pub 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
504fn 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 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 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 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 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 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 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}