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<&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 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 let resume = build_resume_command(app_name, &already_supplied[1..]);
208 return Some(RecoveryResult::Cancelled { resume });
209 }
210 };
211
212 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum CommandCorrection {
247 Accepted,
249 Declined,
251 Cancelled,
253}
254
255pub 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#[derive(Debug)]
274pub enum RecoveryResult {
275 Recovered { args: Vec<String> },
277 Cancelled { resume: String },
280}
281
282fn 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
296fn 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
319fn infer_and_prompt(message: &str, arg_def: Option<&clap::Arg>) -> crate::Result<String> {
325 if let Some(arg) = arg_def {
326 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 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 prompt_text(message, None)
351}
352
353fn strip_arg_decoration(raw: &str) -> &str {
357 raw.trim_start_matches('-')
358 .trim_matches(['<', '>', '[', ']'])
359}
360
361fn 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
373pub 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
382fn 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 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 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 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 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 let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", false);
549 assert!(result.is_none());
550 }
551}