use std::io::Write as _;
use crate::error::CliCoreError;
pub fn prompt_text(message: &str, default: Option<&str>) -> crate::Result<String> {
let mut prompt = inquire::Text::new(message);
if let Some(d) = default {
prompt = prompt.with_default(d);
}
prompt
.prompt()
.map(|s| s.trim().to_owned())
.map_err(inquire_error_to_cli)
}
pub fn prompt_text_with_validation(
message: &str,
default: Option<&str>,
validator: impl Fn(&str) -> Result<(), String> + Clone + 'static,
) -> crate::Result<String> {
let mut prompt = inquire::Text::new(message);
if let Some(d) = default {
prompt = prompt.with_default(d);
}
prompt = prompt.with_validator(move |input: &str| {
Ok(match (validator)(input) {
Ok(()) => inquire::validator::Validation::Valid,
Err(msg) => inquire::validator::Validation::Invalid(msg.into()),
})
});
prompt
.prompt()
.map(|s| s.trim().to_owned())
.map_err(inquire_error_to_cli)
}
pub fn prompt_select(message: &str, options: &[String]) -> crate::Result<usize> {
let result = inquire::Select::new(message, options.to_vec())
.prompt()
.map_err(inquire_error_to_cli)?;
options
.iter()
.position(|o| o == &result)
.ok_or_else(|| CliCoreError::message("selected option not found in list"))
}
pub fn prompt_confirm(message: &str, default: bool) -> crate::Result<bool> {
inquire::Confirm::new(message)
.with_default(default)
.prompt()
.map_err(inquire_error_to_cli)
}
pub fn prompt_multi_select(
message: &str,
options: &[String],
defaults: &[bool],
) -> crate::Result<Vec<usize>> {
let defaults_vec: Vec<bool> = if defaults.len() == options.len() {
defaults.to_vec()
} else {
vec![false; options.len()]
};
let selected = inquire::MultiSelect::new(message, options.to_vec())
.with_default(
&defaults_vec
.iter()
.copied()
.enumerate()
.filter_map(|(i, d)| d.then_some(i))
.collect::<Vec<_>>(),
)
.prompt()
.map_err(inquire_error_to_cli)?;
Ok(selected
.iter()
.filter_map(|s| options.iter().position(|o| o == s))
.collect())
}
pub fn try_recover_missing_args(
err: &clap::error::Error,
original_args: &[String],
command: &clap::Command,
app_name: &str,
auto_interactive: bool,
) -> Option<RecoveryResult> {
use clap::error::{ContextKind, ContextValue, ErrorKind};
if err.kind() != ErrorKind::MissingRequiredArgument {
return None;
}
if !is_interactive_from_raw_args(original_args, auto_interactive) {
return None;
}
let missing_names = match err.get(ContextKind::InvalidArg)? {
ContextValue::Strings(names) => names.clone(),
ContextValue::String(name) => vec![name.clone()],
_ => return None,
};
let leaf_command = resolve_leaf_command(command, original_args, app_name)?;
let missing_list: Vec<&str> = missing_names
.iter()
.map(|n| strip_arg_decoration(n))
.collect();
drop(writeln!(
std::io::stderr(),
"\n \u{26a0} missing required argument(s): {}",
missing_list.join(", ")
));
let mut prompted_args: Vec<String> = Vec::new();
let mut already_supplied: Vec<String> = original_args.to_vec();
for raw_name in &missing_names {
let clean_name = strip_arg_decoration(raw_name);
let arg_def = leaf_command.get_arguments().find(|a| {
a.get_id().as_str() == clean_name
|| a.get_long().is_some_and(|l| l == clean_name)
|| a.get_value_names().is_some_and(|vn| {
vn.iter()
.any(|v| v.to_ascii_uppercase() == raw_name.trim_matches(['<', '>']))
})
});
let prompt_message = format_prompt_message(raw_name, arg_def);
let value = match infer_and_prompt(&prompt_message, arg_def) {
Ok(v) => v,
Err(_) => {
let resume = build_resume_command(app_name, &already_supplied[1..]);
return Some(RecoveryResult::Cancelled { resume });
}
};
let start = prompted_args.len();
if let Some(arg) = arg_def {
if let Some(long) = arg.get_long() {
if matches!(
arg.get_action(),
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
) {
if value == "true" {
prompted_args.push(format!("--{long}"));
}
} else {
prompted_args.push(format!("--{long}"));
prompted_args.push(value.clone());
}
} else {
prompted_args.push(value.clone());
}
} else {
prompted_args.push(value.clone());
}
already_supplied.extend_from_slice(&prompted_args[start..]);
}
let mut augmented = original_args.to_vec();
augmented.extend(prompted_args);
Some(RecoveryResult::Recovered { args: augmented })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandCorrection {
Accepted,
Declined,
Cancelled,
}
pub fn confirm_command_correction(
args: &[String],
suggestion: &str,
auto_interactive: bool,
) -> CommandCorrection {
if !is_interactive_from_raw_args(args, auto_interactive) {
return CommandCorrection::Declined;
}
match prompt_confirm(&format!("Did you mean `{suggestion}`?"), true) {
Ok(true) => CommandCorrection::Accepted,
Ok(false) => CommandCorrection::Declined,
Err(_) => CommandCorrection::Cancelled,
}
}
#[derive(Debug)]
pub enum RecoveryResult {
Recovered { args: Vec<String> },
Cancelled { resume: String },
}
fn is_interactive_from_raw_args(args: &[String], auto_interactive: bool) -> bool {
if args.iter().any(|a| a == "--non-interactive") {
return false;
}
if args.iter().any(|a| a == "--interactive") {
return true;
}
auto_interactive && crate::flags::detect_interactive()
}
fn resolve_leaf_command<'cmd>(
root: &'cmd clap::Command,
args: &[String],
app_name: &str,
) -> Option<&'cmd clap::Command> {
let mut current = root;
for arg in args.iter().skip(1) {
if arg.starts_with('-') {
continue;
}
if arg == app_name {
continue;
}
if let Some(sub) = current.find_subcommand(arg) {
current = sub;
} else {
break;
}
}
Some(current)
}
fn infer_and_prompt(message: &str, arg_def: Option<&clap::Arg>) -> crate::Result<String> {
if let Some(arg) = arg_def {
let possible: Vec<String> = arg
.get_possible_values()
.iter()
.filter(|pv| !pv.is_hide_set())
.map(|pv| pv.get_name().to_owned())
.collect();
if !possible.is_empty() {
let idx = prompt_select(message, &possible)?;
return Ok(possible[idx].clone());
}
if matches!(
arg.get_action(),
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
) {
let confirmed = prompt_confirm(message, true)?;
return Ok(confirmed.to_string());
}
}
prompt_text(message, None)
}
fn strip_arg_decoration(raw: &str) -> &str {
raw.trim_start_matches('-')
.trim_matches(['<', '>', '[', ']'])
}
fn format_prompt_message(raw_name: &str, arg_def: Option<&clap::Arg>) -> String {
let base = if let Some(arg) = arg_def
&& let Some(help) = arg.get_help().map(|s| s.to_string())
{
help.trim_end_matches('.').to_owned()
} else {
strip_arg_decoration(raw_name).replace('-', " ")
};
format!("{base}:")
}
pub fn build_resume_command(app_name: &str, supplied_args: &[String]) -> String {
let mut parts = vec![app_name.to_owned()];
parts.extend(supplied_args.iter().cloned());
parts.join(" ")
}
fn inquire_error_to_cli(err: inquire::InquireError) -> CliCoreError {
match err {
inquire::InquireError::OperationCanceled | inquire::InquireError::OperationInterrupted => {
CliCoreError::message("prompt cancelled")
}
other => CliCoreError::message(format!("prompt error: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_interactive_from_raw_args_non_interactive_flag() {
let args: Vec<String> = vec![
"my-cli".into(),
"project".into(),
"list".into(),
"--non-interactive".into(),
];
assert!(!is_interactive_from_raw_args(&args, true));
}
#[test]
fn is_interactive_from_raw_args_interactive_flag() {
let args: Vec<String> = vec![
"my-cli".into(),
"project".into(),
"list".into(),
"--interactive".into(),
];
assert!(is_interactive_from_raw_args(&args, false));
}
#[test]
fn is_interactive_no_flags_auto_disabled() {
let args: Vec<String> = vec!["my-cli".into(), "project".into(), "list".into()];
assert!(!is_interactive_from_raw_args(&args, false));
}
#[test]
fn format_prompt_message_from_flag_name() {
let msg = format_prompt_message("--team-name", None);
assert_eq!(msg, "team name:");
}
#[test]
fn format_prompt_message_from_positional() {
let msg = format_prompt_message("<domain>", None);
assert_eq!(msg, "domain:");
}
#[test]
fn format_prompt_message_uses_help_text() {
let arg = clap::Arg::new("team").long("team").help("Team identifier");
let msg = format_prompt_message("--team", Some(&arg));
assert_eq!(msg, "Team identifier:");
}
#[test]
fn build_resume_command_with_partial_args() {
let resume = build_resume_command(
"gddy",
&[
"domain".into(),
"register".into(),
"--period".into(),
"2".into(),
],
);
assert_eq!(resume, "gddy domain register --period 2");
}
#[test]
fn resolve_leaf_command_walks_subcommands() {
let root = clap::Command::new("my-cli").subcommand(
clap::Command::new("project")
.subcommand(clap::Command::new("list").arg(clap::Arg::new("team").long("team"))),
);
let args: Vec<String> = vec![
"my-cli".into(),
"project".into(),
"list".into(),
"--team".into(),
"dev".into(),
];
let leaf = resolve_leaf_command(&root, &args, "my-cli");
assert!(leaf.is_some());
assert_eq!(leaf.expect("tested").get_name(), "list");
}
#[test]
fn confirm_command_correction_declines_when_non_interactive() {
let args: Vec<String> = vec!["my-cli".into(), "projet".into()];
assert_eq!(
confirm_command_correction(&args, "project", false),
CommandCorrection::Declined
);
let args: Vec<String> = vec!["my-cli".into(), "projet".into(), "--non-interactive".into()];
assert_eq!(
confirm_command_correction(&args, "project", true),
CommandCorrection::Declined
);
}
#[test]
fn try_recover_returns_none_for_non_missing_arg_error() {
let cmd = clap::Command::new("test").arg(
clap::Arg::new("name")
.long("name")
.value_parser(["alpha", "beta"]),
);
let err = cmd
.try_get_matches_from(["test", "--name", "invalid"])
.expect_err("should fail");
let args: Vec<String> = vec!["test".into(), "--name".into(), "invalid".into()];
let result =
try_recover_missing_args(&err, &args, &clap::Command::new("test"), "test", true);
assert!(result.is_none());
}
#[test]
fn try_recover_returns_none_when_non_interactive() {
let cmd = clap::Command::new("test")
.arg(clap::Arg::new("name").long("name").required(true))
.arg(
clap::Arg::new("non-interactive")
.long("non-interactive")
.action(clap::ArgAction::SetTrue),
);
let err = cmd
.try_get_matches_from(["test", "--non-interactive"])
.expect_err("should fail with missing --name");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
let args: Vec<String> = vec!["test".into(), "--non-interactive".into()];
let lookup_cmd = clap::Command::new("test")
.arg(clap::Arg::new("name").long("name").required(true))
.arg(
clap::Arg::new("non-interactive")
.long("non-interactive")
.action(clap::ArgAction::SetTrue),
);
let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", true);
assert!(result.is_none());
}
#[test]
fn try_recover_returns_none_when_auto_interactive_disabled() {
let cmd =
clap::Command::new("test").arg(clap::Arg::new("name").long("name").required(true));
let err = cmd
.try_get_matches_from(["test"])
.expect_err("should fail with missing --name");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
let args: Vec<String> = vec!["test".into()];
let lookup_cmd =
clap::Command::new("test").arg(clap::Arg::new("name").long("name").required(true));
let result = try_recover_missing_args(&err, &args, &lookup_cmd, "test", false);
assert!(result.is_none());
}
}