diff --git a/Cargo.lock b/Cargo.lock index e9de78e..0fe41b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ dependencies = [ "clap", "clap_complete", "cli-engine-macros", + "inquire", "jmespath", "keyring", "open", @@ -918,6 +919,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1279,6 +1289,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2473,7 +2497,7 @@ dependencies = [ "minimad", "serde", "thiserror", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -2496,6 +2520,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2801,6 +2834,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/cli-engine/Cargo.toml b/cli-engine/Cargo.toml index ae84269..82b1e36 100644 --- a/cli-engine/Cargo.toml +++ b/cli-engine/Cargo.toml @@ -39,6 +39,7 @@ toml_edit = { version = "0.22", features = ["serde"] } tokio = { version = "1.48.0", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tracing = "0.1.43" termimad = "0.34.1" +inquire = "0.9.4" [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3.6.1", optional = true, default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] } diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index c1869f1..a1bdbef 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -1696,13 +1696,41 @@ impl Cli { }); } - let matches = match self.root.clone().try_get_matches_from(clap_args) { + let matches = match self.root.clone().try_get_matches_from(&clap_args) { Ok(matches) => matches, Err(err) => { - return self.finish_run(CliRunOutput { - exit_code: err.exit_code(), - rendered: err.to_string(), - }); + // Attempt interactive recovery for missing required arguments. + if let Some(recovery) = crate::prompt::try_recover_missing_args( + &err, + &clap_args, + &self.root, + &self.config.name, + ) { + match recovery { + crate::prompt::RecoveryResult::Recovered { args } => { + match self.root.clone().try_get_matches_from(args) { + Ok(m) => m, + Err(retry_err) => { + return self.finish_run(CliRunOutput { + exit_code: retry_err.exit_code(), + rendered: retry_err.to_string(), + }); + } + } + } + crate::prompt::RecoveryResult::Cancelled { resume } => { + return self.finish_run(CliRunOutput { + exit_code: 130, + rendered: format!("Cancelled. Resume with:\n {resume}\n"), + }); + } + } + } else { + return self.finish_run(CliRunOutput { + exit_code: err.exit_code(), + rendered: err.to_string(), + }); + } } }; @@ -2583,6 +2611,7 @@ fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: middleware.schema = flags.schema; middleware.timeout = timeout; middleware.debug = flags.debug.clone(); + middleware.interactive = flags.interactive; } /// Sets `middleware.limit`/`middleware.offset` from a paginating command's own diff --git a/cli-engine/src/command.rs b/cli-engine/src/command.rs index 55ff658..bd6734b 100644 --- a/cli-engine/src/command.rs +++ b/cli-engine/src/command.rs @@ -164,6 +164,25 @@ impl CommandContext { self.middleware.dry_run } + /// Returns the resolved interactivity mode for this invocation. + /// + /// Use this to decide whether to prompt for missing inputs, show progress + /// spinners, or offer interactive choices. When `false`, the command should + /// fail with a descriptive error if required inputs are missing. + #[must_use] + pub fn is_interactive(&self) -> bool { + self.middleware.interactive + } + + /// Returns the resolved [`InteractivityMode`](crate::InteractivityMode). + /// + /// Equivalent to [`is_interactive`](Self::is_interactive) but returns the + /// enum for pattern matching. + #[must_use] + pub fn interactivity_mode(&self) -> crate::InteractivityMode { + self.middleware.interactive.into() + } + /// Resolves the active environment's merged TOML table for this /// invocation, as an [`EnvSource`](crate::env_config::EnvSource). /// diff --git a/cli-engine/src/flags.rs b/cli-engine/src/flags.rs index e1d9c5b..09b0912 100644 --- a/cli-engine/src/flags.rs +++ b/cli-engine/src/flags.rs @@ -3,6 +3,55 @@ use std::io::IsTerminal; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; +/// Returns `true` when the process appears to be running interactively: +/// stdin and stderr are both TTYs. +/// +/// Checking stdin ensures that piped input (`echo "" | gddy ...`) is detected +/// as non-interactive. Checking stderr ensures prompts can be displayed (since +/// `inquire` renders to stderr). Stdout is intentionally not checked — a user +/// piping output (`gddy ... | jq`) still has an interactive terminal for +/// prompts. +/// +/// Used as the default for `GlobalFlags::interactive` when the user does not +/// pass `--interactive` or `--non-interactive` explicitly. +#[must_use] +pub fn detect_interactive() -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() +} + +/// Interactivity mode for a CLI invocation. +/// +/// Commands and middleware can inspect this to decide whether to prompt for +/// missing inputs, display progress spinners, or fall back to error messages +/// suitable for scripts and CI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InteractivityMode { + /// The user explicitly requested interactive prompts (`--interactive`), or + /// the process is running in a TTY without CI indicators. + Interactive, + /// The user explicitly disabled prompts (`--non-interactive`), or the + /// process is running in a non-TTY / CI context. + NonInteractive, +} + +impl InteractivityMode { + /// Returns `true` when prompts and interactive flows are appropriate. + #[must_use] + pub fn is_interactive(self) -> bool { + self == Self::Interactive + } +} + +impl From for InteractivityMode { + fn from(interactive: bool) -> Self { + if interactive { + Self::Interactive + } else { + Self::NonInteractive + } + } +} + /// Parsed framework-global flags. /// /// Applications can add their own global flags, but these are the built-in @@ -31,6 +80,9 @@ pub struct GlobalFlags { pub debug: String, /// Credential storage override from `--credential-store`, if supplied. pub credential_store: Option, + /// Interactivity mode: `true` enables prompts for missing inputs, + /// `false` disables them. Auto-detected from TTY when neither flag is given. + pub interactive: bool, } impl Default for GlobalFlags { @@ -47,6 +99,7 @@ impl Default for GlobalFlags { timeout: "0s".to_owned(), debug: String::new(), credential_store: None, + interactive: detect_interactive(), } } } @@ -103,8 +156,9 @@ pub(crate) mod global_flag_order { pub(crate) const JSON: usize = 1013; pub(crate) const TOON: usize = 1014; pub(crate) const HUMAN: usize = 1015; - pub(crate) const REASON: usize = 1016; - pub(crate) const ENV: usize = 1017; + pub(crate) const INTERACTIVE: usize = 1016; + pub(crate) const REASON: usize = 1017; + pub(crate) const ENV: usize = 1018; } /// Registers framework-global flags on a `clap` command. @@ -244,6 +298,26 @@ pub fn register_global_flags(command: Command) -> Command { .value_parser(|s: &str| s.parse::()) .help("Credential storage: auto|keyring|file (overrides env and config)"), ) + .arg( + Arg::new("interactive") + .long("interactive") + .short('i') + .global(true) + .action(ArgAction::SetTrue) + .conflicts_with("non-interactive") + .display_order(global_flag_order::INTERACTIVE) + .help("Force interactive prompts for missing inputs (default when TTY is detected)"), + ) + .arg( + Arg::new("non-interactive") + .long("non-interactive") + .global(true) + .action(ArgAction::SetTrue) + .conflicts_with("interactive") + .hide(true) + .display_order(global_flag_order::INTERACTIVE) + .help("Disable interactive prompts; fail on missing required inputs"), + ) .arg( Arg::new("json") .long("json") @@ -511,6 +585,13 @@ pub fn global_flags_from_matches(matches: &ArgMatches, default_format: &str) -> credential_store: matches .get_one::("credential-store") .copied(), + interactive: if matches.get_flag("non-interactive") { + false + } else if matches.get_flag("interactive") { + true + } else { + detect_interactive() + }, } } @@ -852,4 +933,60 @@ mod tests { help_text(&["testcli", "sub", "--help"]) ); } + + #[test] + fn interactivity_mode_from_bool() { + use super::InteractivityMode; + assert_eq!( + InteractivityMode::from(true), + InteractivityMode::Interactive + ); + assert_eq!( + InteractivityMode::from(false), + InteractivityMode::NonInteractive + ); + assert!(InteractivityMode::Interactive.is_interactive()); + assert!(!InteractivityMode::NonInteractive.is_interactive()); + } + + #[test] + fn interactive_flag_parsing_explicit_interactive() { + use super::global_flags_from_matches; + let cmd = register_global_flags(Command::new("test")); + let matches = cmd + .try_get_matches_from(["test", "--interactive"]) + .expect("should parse"); + let flags = global_flags_from_matches(&matches, "json"); + assert!(flags.interactive); + } + + #[test] + fn interactive_flag_parsing_explicit_non_interactive() { + use super::global_flags_from_matches; + let cmd = register_global_flags(Command::new("test")); + let matches = cmd + .try_get_matches_from(["test", "--non-interactive"]) + .expect("should parse"); + let flags = global_flags_from_matches(&matches, "json"); + assert!(!flags.interactive); + } + + #[test] + fn interactive_flag_conflicts() { + let cmd = register_global_flags(Command::new("test")); + let result = cmd.try_get_matches_from(["test", "--interactive", "--non-interactive"]); + assert!(result.is_err()); + } + + #[test] + fn detect_interactive_is_consistent_with_tty_state() { + // detect_interactive checks stdin + stderr TTY state. + // In CI (no real TTY), both are typically non-terminals → false. + // Locally in a real terminal, both are terminals → true. + // Either way, it should not panic and should be consistent. + let result = super::detect_interactive(); + let stdin_tty = std::io::IsTerminal::is_terminal(&std::io::stdin()); + let stderr_tty = std::io::IsTerminal::is_terminal(&std::io::stderr()); + assert_eq!(result, stdin_tty && stderr_tty); + } } diff --git a/cli-engine/src/lib.rs b/cli-engine/src/lib.rs index 8125eb3..914495a 100644 --- a/cli-engine/src/lib.rs +++ b/cli-engine/src/lib.rs @@ -105,6 +105,7 @@ pub mod module; /// Structured output envelopes, renderers, schemas, and field projection. pub mod output; /// Search indexing for commands, guides, and extra documents. +pub mod prompt; pub mod search; /// Command risk tiers used by authentication, authorization, and dry-run. pub mod tier; @@ -148,10 +149,11 @@ pub use error::{ }; pub use feature_flags::{FeatureFlag, FlagEntry, FlagPolicy, FlagRegistry, Stage}; pub use flags::{ - GlobalFlags, app_id_env_prefix, debug_component_enabled, default_output_format, - derive_bool_flags, derive_value_flags, extract_command_path, extract_output_format, - global_flags_from_matches, has_true_schema_flag, min_stage_env_var, output_env_var, - register_global_flags, register_reason_flag, resolve_default_output_format, + GlobalFlags, InteractivityMode, app_id_env_prefix, debug_component_enabled, + default_output_format, derive_bool_flags, derive_value_flags, detect_interactive, + extract_command_path, extract_output_format, global_flags_from_matches, has_true_schema_flag, + min_stage_env_var, output_env_var, register_global_flags, register_reason_flag, + resolve_default_output_format, }; pub use guide::{GuideEntry, parse_guides, parse_guides_from_markdown}; pub use middleware::{ diff --git a/cli-engine/src/middleware.rs b/cli-engine/src/middleware.rs index 8c4257f..3b5ce15 100644 --- a/cli-engine/src/middleware.rs +++ b/cli-engine/src/middleware.rs @@ -505,6 +505,8 @@ pub struct Middleware { pub timeout: Option, /// Debug selector, interpreted by applications. pub debug: String, + /// Whether the invocation is running in interactive mode. + pub interactive: bool, /// Output schema registry. pub schema_registry: SchemaRegistry, /// Human output view registry. diff --git a/cli-engine/src/prompt.rs b/cli-engine/src/prompt.rs new file mode 100644 index 0000000..1017872 --- /dev/null +++ b/cli-engine/src/prompt.rs @@ -0,0 +1,506 @@ +//! Interactive prompt helpers for CLI commands. +//! +//! These functions wrap [`inquire`] to provide consistent prompts that respect +//! the global interactivity mode. Each helper returns a [`Result`] that +//! produces a user-cancelled error when the user presses Escape or Ctrl+C. +//! +//! All functions require an interactive TTY. Call them only when +//! [`CommandContext::is_interactive`](crate::command::CommandContext::is_interactive) +//! returns `true`. + +use std::io::Write; + +use crate::error::CliCoreError; + +/// Prompt the user for a free-text string input. +/// +/// Returns the trimmed user input, or an error if the user cancelled. +/// +/// # Errors +/// +/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs. +pub fn prompt_text(message: &str, default: Option<&str>) -> crate::Result { + 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) +} + +/// Prompt the user for a free-text string with input validation. +/// +/// The `validator` closure should return `Ok(())` if the input is valid, or +/// `Err(message)` with a user-facing explanation if invalid. +/// +/// # Errors +/// +/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs. +pub fn prompt_text_with_validation( + message: &str, + default: Option<&str>, + validator: impl Fn(&str) -> Result<(), String> + Clone + 'static, +) -> crate::Result { + 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) +} + +/// Prompt the user to select one option from a list. +/// +/// Returns the index of the selected option. +/// +/// # Errors +/// +/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs. +pub fn prompt_select(message: &str, options: &[String]) -> crate::Result { + 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")) +} + +/// Prompt the user for a yes/no confirmation. +/// +/// Returns `true` for yes, `false` for no. +/// +/// # Errors +/// +/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs. +pub fn prompt_confirm(message: &str, default: bool) -> crate::Result { + inquire::Confirm::new(message) + .with_default(default) + .prompt() + .map_err(inquire_error_to_cli) +} + +/// Prompt the user to select multiple options from a list. +/// +/// Returns the indices of the selected options. The `defaults` slice +/// indicates which items are pre-selected (by index). +/// +/// # Errors +/// +/// Returns [`CliCoreError`] if the prompt is cancelled or a terminal error occurs. +pub fn prompt_multi_select( + message: &str, + options: &[String], + defaults: &[bool], +) -> crate::Result> { + let defaults_vec: Vec = 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::>(), + ) + .prompt() + .map_err(inquire_error_to_cli)?; + + Ok(selected + .iter() + .filter_map(|s| options.iter().position(|o| o == s)) + .collect()) +} + +/// Attempt to interactively recover from a clap `MissingRequiredArgument` error. +/// +/// When the CLI is running interactively and clap reports missing required +/// arguments, this function prompts the user for each missing value (in +/// declaration order), appends them to the original args, and returns `Some` +/// with the augmented arg list so the caller can re-parse. +/// +/// Returns `None` if recovery is not possible (non-interactive or not a +/// missing-arg error). Returns `Some(RecoveryResult::Cancelled { .. })` if +/// the user cancels mid-prompt. +/// +/// # Arguments +/// +/// * `err` — the clap error from `try_get_matches_from` +/// * `original_args` — the args that were passed to clap +/// * `command` — the root `clap::Command` (for arg introspection) +/// * `app_name` — the CLI binary name (first arg) +pub fn try_recover_missing_args( + err: &clap::error::Error, + original_args: &[String], + command: &clap::Command, + app_name: &str, +) -> Option { + use clap::error::{ContextKind, ContextValue, ErrorKind}; + + if err.kind() != ErrorKind::MissingRequiredArgument { + return None; + } + + // Check interactivity from raw args (clap hasn't fully parsed yet). + if !is_interactive_from_raw_args(original_args) { + return None; + } + + // Extract the missing arg names from clap error context. + let missing_names = match err.get(ContextKind::InvalidArg)? { + ContextValue::Strings(names) => names.clone(), + ContextValue::String(name) => vec![name.clone()], + _ => return None, + }; + + // Resolve the leaf command from the args to get arg metadata. + let leaf_command = resolve_leaf_command(command, original_args, app_name)?; + + // Collect prompted values, respecting arg declaration order. + let mut prompted_args: Vec = Vec::new(); + let mut already_supplied: Vec = original_args.to_vec(); + + // Show a clear error header so the user knows why they're being prompted. + let missing_list: Vec<&str> = missing_names + .iter() + .map(|n| n.split_whitespace().next().unwrap_or(n.as_str())) + .collect(); + drop(writeln!( + std::io::stderr(), + "⚠ Missing required argument(s): {}\n", + missing_list.join(", ") + )); + + for raw_name in &missing_names { + // Clap reports missing args as e.g. "--quote-token "; split on + // whitespace to isolate the flag portion from the value-name placeholder. + let flag_portion = raw_name.split_whitespace().next().unwrap_or(raw_name); + let clean_name = strip_arg_decoration(flag_portion); + 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(_) => { + // User cancelled — build a resume command hint. + let resume = build_resume_command(app_name, &already_supplied[1..]); + return Some(RecoveryResult::Cancelled { resume }); + } + }; + + // Append the prompted value to args. + 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 + ) { + // Boolean flags: clap expects `--flag` alone, no value. + 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()); + } + + // Track all tokens added this iteration for the resume command. + already_supplied.extend_from_slice(&prompted_args[start..]); + } + + let mut augmented = original_args.to_vec(); + augmented.extend(prompted_args); + Some(RecoveryResult::Recovered { args: augmented }) +} + +/// Result of attempting interactive recovery for missing args. +#[derive(Debug)] +pub enum RecoveryResult { + /// Successfully prompted for all missing values; `args` has the augmented list. + Recovered { args: Vec }, + /// User cancelled mid-prompt; `resume` is the command to resume with + /// already-supplied flags. + Cancelled { resume: String }, +} + +/// Determine interactivity from raw args (before full clap parse). +fn is_interactive_from_raw_args(args: &[String]) -> bool { + if args.iter().any(|a| a == "--non-interactive") { + return false; + } + if args.iter().any(|a| a == "--interactive" || a == "-i") { + return true; + } + crate::flags::detect_interactive() +} + +/// Walk the command tree to find the leaf command the user was targeting. +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) +} + +/// Infer the prompt type from clap arg metadata and prompt accordingly. +/// +/// - If the arg has `possible_values`, use a Select prompt. +/// - If the arg is boolean-like (action is SetTrue/SetFalse), use Confirm. +/// - Otherwise, use a Text prompt. +fn infer_and_prompt(message: &str, arg_def: Option<&clap::Arg>) -> crate::Result { + if let Some(arg) = arg_def { + // Check for possible values (enum-like). + let possible: Vec = 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()); + } + + // Check for boolean action. + if matches!( + arg.get_action(), + clap::ArgAction::SetTrue | clap::ArgAction::SetFalse + ) { + let confirmed = prompt_confirm(message, true)?; + return Ok(confirmed.to_string()); + } + } + + // Default: free text input. + prompt_text(message, None) +} + +/// Strip clap decoration (`--`, `<>`, `[]`) from a raw arg identifier, +/// yielding the bare name (e.g. `"--team-name"` → `"team-name"`, +/// `""` → `"domain"`). +fn strip_arg_decoration(raw: &str) -> &str { + raw.trim_start_matches('-') + .trim_matches(['<', '>', '[', ']']) +} + +/// Format a human-friendly prompt message from a raw clap arg identifier. +/// Prepends "Enter" and appends a colon for clarity. +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 + } else { + strip_arg_decoration(raw_name).replace('-', " ") + }; + // Capitalize first letter after "Enter ". + let mut chars = base.chars(); + let capitalized = match chars.next() { + Some(c) => format!("{}{}", c.to_lowercase(), chars.as_str()), + None => base.clone(), + }; + format!("Enter {capitalized}:") +} + +/// Build a resume command string from the already-supplied args. +/// +/// Shows the user what to run to continue where they left off. +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(" ") +} + +/// Convert an `inquire` error into a CLI-engine error. +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 = vec![ + "my-cli".into(), + "project".into(), + "list".into(), + "--non-interactive".into(), + ]; + assert!(!is_interactive_from_raw_args(&args)); + } + + #[test] + fn is_interactive_from_raw_args_interactive_flag() { + let args: Vec = vec![ + "my-cli".into(), + "project".into(), + "list".into(), + "--interactive".into(), + ]; + assert!(is_interactive_from_raw_args(&args)); + } + + #[test] + fn is_interactive_from_raw_args_short_flag() { + let args: Vec = vec!["my-cli".into(), "-i".into(), "project".into()]; + assert!(is_interactive_from_raw_args(&args)); + } + + #[test] + fn format_prompt_message_from_flag_name() { + let msg = format_prompt_message("--team-name", None); + assert_eq!(msg, "Enter team name:"); + } + + #[test] + fn format_prompt_message_from_positional() { + let msg = format_prompt_message("", None); + assert_eq!(msg, "Enter 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, "Enter 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 = 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 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 = vec!["test".into(), "--name".into(), "invalid".into()]; + let result = try_recover_missing_args(&err, &args, &clap::Command::new("test"), "test"); + assert!(result.is_none()); + } + + #[test] + fn try_recover_returns_none_when_non_interactive() { + // Build a command that knows about --non-interactive (like the real CLI) + // so clap produces a MissingRequiredArgument error, not UnknownArgument. + 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 = 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"); + assert!(result.is_none()); + } + + #[test] + fn strip_arg_decoration_handles_clap_flag_with_value_name() { + // Clap reports missing required flags as "--flag-name ". + // strip_arg_decoration must work on just the flag portion. + assert_eq!(strip_arg_decoration("--quote-token"), "quote-token"); + assert_eq!(strip_arg_decoration(""), "DOMAIN"); + assert_eq!(strip_arg_decoration("[optional]"), "optional"); + // The split-before-strip pattern used in try_recover_missing_args: + let raw = "--quote-token "; + let flag_portion = raw.split_whitespace().next().unwrap_or(raw); + assert_eq!(strip_arg_decoration(flag_portion), "quote-token"); + } +} diff --git a/cli-engine/tests/exhaustive_public_api.rs b/cli-engine/tests/exhaustive_public_api.rs index 9c729b4..9de1cd8 100644 --- a/cli-engine/tests/exhaustive_public_api.rs +++ b/cli-engine/tests/exhaustive_public_api.rs @@ -153,6 +153,7 @@ fn parsed_global_flags_cover_defaults_short_aliases_and_optional_values() { timeout: "5m".to_owned(), debug: "transport".to_owned(), credential_store: Some(cli_engine::CredentialStore::File), + interactive: cli_engine::detect_interactive(), } ); } diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index 8a8821e..da85253 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -4492,6 +4492,7 @@ fn global_flag_defaults_and_derived_flag_classes_cover_common_clap_actions() { timeout: "0s".to_owned(), debug: String::new(), credential_store: None, + interactive: cli_engine::detect_interactive(), } ); diff --git a/cli-engine/tests/interactivity.rs b/cli-engine/tests/interactivity.rs new file mode 100644 index 0000000..c31d11f --- /dev/null +++ b/cli-engine/tests/interactivity.rs @@ -0,0 +1,138 @@ +//! Integration tests for the generalized interactivity framework. +//! +//! These tests verify that: +//! - Missing required args + non-interactive mode → error with helpful message +//! - All args supplied + interactive mode → no prompts, executes directly +//! - The `--interactive` and `--non-interactive` flags are respected +//! +//! Note: Tests that actually trigger interactive prompts (mocked stdin) are not +//! practical in this test harness because `inquire` reads directly from the +//! terminal. Instead, we verify the non-interactive error paths and the +//! recovery logic via unit tests in `prompt.rs`. + +use clap::Arg; +use cli_engine::{ + BuildInfo, Cli, CliConfig, CommandResult, CommandSpec, GroupSpec, Module, RuntimeCommandSpec, + RuntimeGroupSpec, +}; +use serde_json::json; + +fn test_cli() -> Cli { + Cli::new( + CliConfig::new("test-cli", "Test CLI", "test-cli") + .with_build(BuildInfo::new("0.1.0")) + .with_modules(vec![Module::new("Test", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects")).with_command( + RuntimeCommandSpec::new( + CommandSpec::new("create", "Create a project") + .no_auth(true) + .with_arg(Arg::new("name").long("name").required(true)) + .with_arg( + Arg::new("env") + .long("env") + .required(true) + .value_parser(["dev", "staging", "prod"]), + ), + async |_credential, args| { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let env = args + .get("env") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Ok(CommandResult::new(json!({ + "name": name, + "env": env, + "status": "created" + }))) + }, + ), + ) + })]), + ) +} + +#[tokio::test] +async fn missing_required_arg_non_interactive_shows_error() { + let cli = test_cli(); + let output = cli + .run(["test-cli", "project", "create", "--non-interactive"]) + .await; + // Should fail because --name and --env are required. + assert_ne!(output.exit_code, 0); + // The error should mention the missing arguments. + assert!( + output.rendered.contains("--name") || output.rendered.contains("required"), + "Expected error to mention missing args, got: {}", + output.rendered + ); +} + +#[tokio::test] +async fn all_args_supplied_interactive_mode_executes_directly() { + let cli = test_cli(); + let output = cli + .run([ + "test-cli", + "project", + "create", + "--name", + "my-project", + "--env", + "prod", + "--interactive", + ]) + .await; + assert_eq!(output.exit_code, 0, "output: {}", output.rendered); + assert!( + output.rendered.contains("my-project"), + "Expected result to contain project name, got: {}", + output.rendered + ); +} + +#[tokio::test] +async fn all_args_supplied_non_interactive_mode_executes_directly() { + let cli = test_cli(); + let output = cli + .run([ + "test-cli", + "project", + "create", + "--name", + "my-project", + "--env", + "dev", + "--non-interactive", + ]) + .await; + assert_eq!(output.exit_code, 0, "output: {}", output.rendered); + assert!( + output.rendered.contains("my-project"), + "Expected result to contain project name, got: {}", + output.rendered + ); +} + +#[tokio::test] +async fn interactive_and_non_interactive_conflict() { + let cli = test_cli(); + let output = cli + .run([ + "test-cli", + "project", + "create", + "--interactive", + "--non-interactive", + ]) + .await; + // Clap should reject conflicting flags. + assert_ne!(output.exit_code, 0); + assert!( + output.rendered.contains("cannot be used with"), + "Expected conflict error, got: {}", + output.rendered + ); +}