use std::{
io::{self, Write},
process::Command as ProcessCommand,
thread,
time::Duration,
};
use clap::{error::ErrorKind, Parser};
use gloves::{
cli::{emit_version_output, run, Cli, ErrorFormatArg},
error::{classify_error_code, GlovesError, ValidationError},
};
const CLI_GLOBAL_HELP_HINT: &str =
"help: run `gloves --help` for global usage or `gloves help [topic...]` for command details";
const CLI_TTL_HINT: &str =
"hint: use a positive day count, `--ttl never`, or omit `--ttl` to use the configured default";
const CLI_FORBIDDEN_HINT: &str = "hint: this action is blocked by policy. check ACLs with `gloves access paths --agent <id> --json` and review `.gloves.toml`";
const CLI_NOT_FOUND_HINT: &str =
"hint: check existing secrets with `gloves list` or pending requests with `gloves requests list`";
const CLI_ALREADY_EXISTS_HINT: &str =
"hint: entry already exists. choose a new name or remove the old one with `gloves secrets revoke <name>`";
const CLI_UNAUTHORIZED_HINT: &str = "hint: this caller is not authorized for the operation. check `--agent` and request/approval state";
const CLI_EXPIRED_HINT: &str =
"hint: the item has expired. rotate by creating a new value (`gloves secrets set <name> ...`) and retry";
const CLI_GPG_DENIED_HINT: &str =
"hint: `pass`/GPG denied access. verify your session can read it with `pass show <secret-name>`";
const CLI_INTEGRITY_HINT: &str =
"hint: integrity verification failed. run `gloves verify`; if needed, rotate the secret";
const CLI_NAME_RULE_HINT: &str =
"hint: secret names must be 1..=128 chars, no traversal, and only `[A-Za-z0-9._/-]`";
const CLI_PATH_TRAVERSAL_HINT: &str =
"hint: secret names cannot start with `/`, contain `..`, or contain `//`";
const CLI_IO_HINT: &str =
"hint: check path existence and permissions for `--root` (default `.openclaw/secrets`)";
const CLI_REQUEST_PENDING_HINT: &str =
"hint: request may already be resolved. check current pending requests with `gloves requests list`";
const CLI_PIPE_POLICY_HINT: &str = "hint: for safe secret piping, configure `GLOVES_GET_PIPE_ALLOWLIST` or command policy in `.gloves.toml`";
const CLI_MISSING_RUNTIME_HINT: &str =
"hint: install the missing runtime binary and ensure it is available in PATH";
const PARSE_ERROR_CODE: &str = "E001";
const PARSE_SUGGESTION_MARKER: &str = "a similar subcommand exists: '";
const PARSE_SUGGESTIONS_MARKER: &str = "some similar subcommands exist:";
const PARSE_UNKNOWN_SUBCOMMAND_MARKER: &str = "unrecognized subcommand '";
const AUTORUN_ENV: &str = "GLOVES_SUGGEST_AUTORUN";
const AUTORUN_RISKY_ENV: &str = "GLOVES_SUGGEST_AUTORUN_RISKY";
const AUTORUN_DELAY_ENV: &str = "GLOVES_SUGGEST_AUTORUN_DELAY_MS";
const AUTORUN_DEFAULT_DELAY_MS: u64 = 1200;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum CliErrorFormat {
Text,
Json,
}
#[derive(Debug, Clone)]
struct SubcommandSuggestion {
unknown: String,
suggested: String,
corrected_args: Vec<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum AutoRunDecision {
Disabled,
BlockedRisky,
Enabled { delay_ms: u64 },
}
impl From<ErrorFormatArg> for CliErrorFormat {
fn from(value: ErrorFormatArg) -> Self {
match value {
ErrorFormatArg::Text => CliErrorFormat::Text,
ErrorFormatArg::Json => CliErrorFormat::Json,
}
}
}
fn main() {
let invocation_args = std::env::args().skip(1).collect::<Vec<_>>();
let fallback_error_format = parse_error_format_from_args(&invocation_args);
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(parse_error) => {
let exit_code =
handle_parse_error(parse_error, fallback_error_format, &invocation_args);
std::process::exit(exit_code);
}
};
let error_format = if cli.json {
CliErrorFormat::Json
} else {
CliErrorFormat::from(cli.error_format)
};
match run(cli) {
Ok(code) => std::process::exit(code),
Err(error) => {
let exit_code = write_runtime_error(error_format, &error);
std::process::exit(exit_code);
}
}
}
fn write_runtime_error(error_format: CliErrorFormat, error: &GlovesError) -> i32 {
match error_format {
CliErrorFormat::Text => write_runtime_error_text(error),
CliErrorFormat::Json => write_runtime_error_json(error),
}
1
}
fn write_runtime_error_text(error: &GlovesError) {
let error_code = classify_error_code(error);
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let _ = handle.write_all(format!("error[{error_code}]: {error}\n").as_bytes());
let _ = handle.write_all(
format!("explain: run `gloves explain {error_code}` for detailed recovery\n").as_bytes(),
);
for hint in collect_error_hints(error) {
let _ = handle.write_all(format!("{hint}\n").as_bytes());
}
let _ = handle.flush();
}
fn write_runtime_error_json(error: &GlovesError) {
let error_code = classify_error_code(error);
let payload = serde_json::json!({
"kind": "runtime_error",
"code": error_code,
"message": error.to_string(),
"explain": format!("gloves explain {error_code}"),
"hints": collect_error_hints(error),
});
write_json_stderr(&payload);
}
fn collect_error_hints(error: &GlovesError) -> Vec<String> {
let mut hints = vec![CLI_GLOBAL_HELP_HINT.to_owned()];
match error {
GlovesError::InvalidInput(message) => {
if message.contains("--ttl must be greater than zero") {
hints.push(CLI_TTL_HINT.to_owned());
}
if message.contains("request is not pending") {
hints.push(CLI_REQUEST_PENDING_HINT.to_owned());
}
if message.contains("required binary not found:") {
hints.push(CLI_MISSING_RUNTIME_HINT.to_owned());
}
if message.contains("not allowlisted") || message.contains("secret piping is disabled")
{
hints.push(CLI_PIPE_POLICY_HINT.to_owned());
}
}
GlovesError::Validation(validation_error) => match validation_error {
ValidationError::InvalidName | ValidationError::InvalidCharacter => {
hints.push(CLI_NAME_RULE_HINT.to_owned());
}
ValidationError::PathTraversal => {
hints.push(CLI_PATH_TRAVERSAL_HINT.to_owned());
}
},
GlovesError::Forbidden => {
hints.push(CLI_FORBIDDEN_HINT.to_owned());
}
GlovesError::NotFound => {
hints.push(CLI_NOT_FOUND_HINT.to_owned());
}
GlovesError::AlreadyExists => {
hints.push(CLI_ALREADY_EXISTS_HINT.to_owned());
}
GlovesError::Unauthorized => {
hints.push(CLI_UNAUTHORIZED_HINT.to_owned());
}
GlovesError::Expired => {
hints.push(CLI_EXPIRED_HINT.to_owned());
}
GlovesError::GpgDenied => {
hints.push(CLI_GPG_DENIED_HINT.to_owned());
}
GlovesError::IntegrityViolation => {
hints.push(CLI_INTEGRITY_HINT.to_owned());
}
GlovesError::Io(_) => {
hints.push(CLI_IO_HINT.to_owned());
}
GlovesError::Serde(_) | GlovesError::Utf8(_) | GlovesError::Crypto(_) => {}
}
hints
}
fn handle_parse_error(
parse_error: clap::Error,
error_format: CliErrorFormat,
invocation_args: &[String],
) -> i32 {
if matches!(parse_error.kind(), ErrorKind::DisplayHelp) {
if matches!(error_format, CliErrorFormat::Json) {
let payload = serde_json::json!({
"status": "ok",
"command": "help",
"result": {
"topic": "",
"content": parse_error.to_string(),
},
});
return write_json_stdout(&payload);
}
return write_stdout_message(&parse_error.to_string());
}
if matches!(parse_error.kind(), ErrorKind::DisplayVersion) {
let json_output = matches!(error_format, CliErrorFormat::Json);
return match emit_version_output(json_output) {
Ok(code) => code,
Err(error) => write_runtime_error(error_format, &error),
};
}
let message = parse_error.to_string();
let suggestion = parse_subcommand_suggestion(&message, invocation_args);
let autorun_decision = suggestion
.as_ref()
.map(|value| decide_autorun(&value.corrected_args))
.unwrap_or(AutoRunDecision::Disabled);
match error_format {
CliErrorFormat::Text => {
eprint!("{message}");
if let Some(value) = suggestion.as_ref() {
eprintln!(
"hint: rerun as `gloves {}`",
shell_words_join(&value.corrected_args)
);
eprintln!(
"hint: enable safe auto-run with `{AUTORUN_ENV}=1`; allow risky auto-run with `{AUTORUN_RISKY_ENV}=1`"
);
}
match autorun_decision {
AutoRunDecision::Enabled { delay_ms } => {
if let Some(value) = suggestion.as_ref() {
eprintln!(
"auto-run: executing corrected command in {delay_ms}ms: gloves {}",
shell_words_join(&value.corrected_args)
);
}
}
AutoRunDecision::BlockedRisky => {
eprintln!(
"auto-run: suggestion detected but blocked because the command can mutate state; set `{AUTORUN_RISKY_ENV}=1` to allow"
);
}
AutoRunDecision::Disabled => {}
}
}
CliErrorFormat::Json => {
let payload = serde_json::json!({
"kind": "parse_error",
"code": PARSE_ERROR_CODE,
"message": message.trim_end(),
"suggestion": suggestion.as_ref().map(|value| serde_json::json!({
"unknown": value.unknown,
"suggested": value.suggested,
"corrected_command": format!("gloves {}", shell_words_join(&value.corrected_args)),
})),
"autorun": match autorun_decision {
AutoRunDecision::Disabled => serde_json::json!({"status": "disabled"}),
AutoRunDecision::BlockedRisky => serde_json::json!({"status": "blocked_risky"}),
AutoRunDecision::Enabled { delay_ms } => serde_json::json!({"status": "enabled", "delay_ms": delay_ms}),
},
"hints": [
CLI_GLOBAL_HELP_HINT,
format!("set {AUTORUN_ENV}=1 for safe typo auto-run"),
],
});
write_json_stderr(&payload);
}
}
if let (Some(value), AutoRunDecision::Enabled { delay_ms }) =
(suggestion.as_ref(), autorun_decision)
{
thread::sleep(Duration::from_millis(delay_ms));
return run_corrected_command(&value.corrected_args);
}
2
}
fn write_stdout_message(message: &str) -> i32 {
let stdout = io::stdout();
let mut handle = stdout.lock();
match handle
.write_all(message.as_bytes())
.and_then(|_| handle.flush())
{
Ok(()) => 0,
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => 0,
Err(_) => 1,
}
}
fn write_json_stdout(payload: &serde_json::Value) -> i32 {
let serialized =
serde_json::to_string_pretty(payload).expect("serializing JSON values should not fail");
write_stdout_message(&format!("{serialized}\n"))
}
fn write_json_stderr(payload: &serde_json::Value) {
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let serialized =
serde_json::to_string_pretty(payload).expect("serializing JSON values should not fail");
let _ = handle.write_all(format!("{serialized}\n").as_bytes());
let _ = handle.flush();
}
fn parse_error_format_from_args(args: &[String]) -> CliErrorFormat {
let mut format = CliErrorFormat::Text;
let mut json_flag_set = false;
let mut index = 0usize;
while index < args.len() {
let argument = &args[index];
if argument == "--json" {
json_flag_set = true;
format = CliErrorFormat::Json;
index += 1;
continue;
}
if let Some(value) = argument.strip_prefix("--error-format=") {
if !json_flag_set {
format = parse_error_format_value(value);
}
index += 1;
continue;
}
if argument == "--error-format" {
if let Some(value) = args.get(index + 1) {
if !json_flag_set {
format = parse_error_format_value(value);
}
index += 2;
continue;
}
return format;
}
index += 1;
}
format
}
fn parse_error_format_value(value: &str) -> CliErrorFormat {
if value.eq_ignore_ascii_case("json") {
CliErrorFormat::Json
} else {
CliErrorFormat::Text
}
}
fn parse_subcommand_suggestion(
parse_error_message: &str,
invocation_args: &[String],
) -> Option<SubcommandSuggestion> {
let unknown =
extract_single_quoted_value(parse_error_message, PARSE_UNKNOWN_SUBCOMMAND_MARKER)?;
let candidates = parse_subcommand_candidates(parse_error_message);
let suggested = choose_best_suggestion(&unknown, &candidates)?;
let corrected_args = corrected_args_for_subcommand(invocation_args, &unknown, &suggested)?;
Some(SubcommandSuggestion {
unknown,
suggested,
corrected_args,
})
}
fn parse_subcommand_candidates(parse_error_message: &str) -> Vec<String> {
if let Some(suggested) =
extract_single_quoted_value(parse_error_message, PARSE_SUGGESTION_MARKER)
{
return vec![suggested];
}
let Some(start_index) = parse_error_message.find(PARSE_SUGGESTIONS_MARKER) else {
return Vec::new();
};
let start = start_index + PARSE_SUGGESTIONS_MARKER.len();
let line = parse_error_message
.get(start..)
.and_then(|remainder| remainder.lines().next())
.unwrap_or_default();
extract_single_quoted_values(line)
}
fn choose_best_suggestion(unknown: &str, candidates: &[String]) -> Option<String> {
let mut best: Option<(usize, String)> = None;
for candidate in candidates {
let distance = levenshtein_distance(unknown, candidate);
match best.as_ref() {
Some((best_distance, _)) if distance >= *best_distance => {}
_ => {
best = Some((distance, candidate.clone()));
}
}
}
best.map(|(_, candidate)| candidate)
}
fn extract_single_quoted_values(line: &str) -> Vec<String> {
let mut values = Vec::new();
let mut cursor = line;
loop {
let Some(start) = cursor.find('\'') else {
break;
};
let tail = &cursor[start + 1..];
let Some(end) = tail.find('\'') else {
break;
};
values.push(tail[..end].to_owned());
cursor = &tail[end + 1..];
}
values
}
fn levenshtein_distance(left: &str, right: &str) -> usize {
let left_chars = left.chars().collect::<Vec<_>>();
let right_chars = right.chars().collect::<Vec<_>>();
if left_chars.is_empty() {
return right_chars.len();
}
if right_chars.is_empty() {
return left_chars.len();
}
let mut previous_row = (0..=right_chars.len()).collect::<Vec<_>>();
let mut current_row = vec![0usize; right_chars.len() + 1];
for (left_index, left_char) in left_chars.iter().enumerate() {
current_row[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let insertion = current_row[right_index] + 1;
let deletion = previous_row[right_index + 1] + 1;
let substitution =
previous_row[right_index] + if left_char == right_char { 0 } else { 1 };
current_row[right_index + 1] = insertion.min(deletion).min(substitution);
}
std::mem::swap(&mut previous_row, &mut current_row);
}
previous_row[right_chars.len()]
}
fn extract_single_quoted_value(message: &str, marker: &str) -> Option<String> {
let start_index = message.find(marker)?;
let start = start_index + marker.len();
let remainder = message.get(start..)?;
let end = remainder.find('\'')?;
remainder.get(..end).map(ToOwned::to_owned)
}
fn corrected_args_for_subcommand(
invocation_args: &[String],
unknown: &str,
suggested: &str,
) -> Option<Vec<String>> {
let command_index = top_level_command_index(invocation_args)?;
if invocation_args.get(command_index)? != unknown {
return None;
}
let mut corrected = invocation_args.to_vec();
corrected[command_index] = suggested.to_owned();
Some(corrected)
}
fn top_level_command_index(invocation_args: &[String]) -> Option<usize> {
let mut index = 0usize;
while index < invocation_args.len() {
let argument = invocation_args.get(index)?;
if argument == "--" {
return None;
}
if let Some(step) = option_token_len(argument, invocation_args, index) {
index += step;
continue;
}
if argument.starts_with('-') {
index += 1;
continue;
}
return Some(index);
}
None
}
fn option_token_len(argument: &str, invocation_args: &[String], index: usize) -> Option<usize> {
if argument.starts_with("--root=")
|| argument.starts_with("--agent=")
|| argument.starts_with("--config=")
|| argument.starts_with("--vault-mode=")
|| argument.starts_with("--error-format=")
{
return Some(1);
}
if matches!(
argument,
"--root" | "--agent" | "--config" | "--vault-mode" | "--error-format"
) {
if invocation_args.get(index + 1).is_some() {
return Some(2);
}
return Some(1);
}
None
}
fn decide_autorun(corrected_args: &[String]) -> AutoRunDecision {
if !env_truthy(AUTORUN_ENV) {
return AutoRunDecision::Disabled;
}
if !is_autorun_safe_command(corrected_args) && !env_truthy(AUTORUN_RISKY_ENV) {
return AutoRunDecision::BlockedRisky;
}
let delay_ms = std::env::var(AUTORUN_DELAY_ENV)
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(AUTORUN_DEFAULT_DELAY_MS)
.min(10_000);
AutoRunDecision::Enabled { delay_ms }
}
fn env_truthy(key: &str) -> bool {
std::env::var(key)
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
fn is_autorun_safe_command(corrected_args: &[String]) -> bool {
let Some(command_index) = top_level_command_index(corrected_args) else {
return false;
};
let command = corrected_args
.get(command_index)
.map(String::as_str)
.unwrap_or_default();
match command {
"help" | "explain" | "list" | "ls" | "audit" | "tui" | "ui" => true,
"secrets" => matches!(
corrected_args.get(command_index + 1).map(String::as_str),
Some("help" | "get" | "status")
),
"requests" | "req" => matches!(
corrected_args.get(command_index + 1).map(String::as_str),
Some("list")
),
"config" => matches!(
corrected_args.get(command_index + 1).map(String::as_str),
Some("validate")
),
"access" => matches!(
corrected_args.get(command_index + 1).map(String::as_str),
Some("paths")
),
"vault" => matches!(
corrected_args.get(command_index + 1).map(String::as_str),
Some("status" | "list")
),
"gpg" => matches!(
corrected_args.get(command_index + 1).map(String::as_str),
Some("fingerprint")
),
_ => false,
}
}
fn run_corrected_command(corrected_args: &[String]) -> i32 {
let current_executable = match std::env::current_exe() {
Ok(path) => path,
Err(error) => {
eprintln!("auto-run failed: unable to locate current executable: {error}");
return 1;
}
};
let status = match ProcessCommand::new(current_executable)
.args(corrected_args)
.status()
{
Ok(status) => status,
Err(error) => {
eprintln!("auto-run failed: unable to execute corrected command: {error}");
return 1;
}
};
status.code().unwrap_or(1)
}
fn shell_words_join(args: &[String]) -> String {
args.iter()
.map(|arg| match shlex::try_quote(arg) {
Ok(value) => value.into_owned(),
Err(_) => arg.replace('\0', ""),
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod unit_tests {
use super::{
choose_best_suggestion, collect_error_hints, corrected_args_for_subcommand, decide_autorun,
env_truthy, extract_single_quoted_value, is_autorun_safe_command, levenshtein_distance,
option_token_len, parse_error_format_from_args, parse_subcommand_candidates,
parse_subcommand_suggestion, shell_words_join, top_level_command_index, AutoRunDecision,
CliErrorFormat, AUTORUN_DELAY_ENV, AUTORUN_ENV, AUTORUN_RISKY_ENV, PARSE_SUGGESTION_MARKER,
PARSE_UNKNOWN_SUBCOMMAND_MARKER,
};
use gloves::error::{GlovesError, ValidationError};
use std::{
env, io,
sync::{Mutex, OnceLock},
};
static AUTORUN_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn clear_autorun_env() {
env::remove_var(AUTORUN_ENV);
env::remove_var(AUTORUN_RISKY_ENV);
env::remove_var(AUTORUN_DELAY_ENV);
}
#[test]
fn parse_error_format_detects_json_long_form() {
let format = parse_error_format_from_args(&[
"--error-format".to_owned(),
"json".to_owned(),
"version".to_owned(),
]);
assert_eq!(format, CliErrorFormat::Json);
}
#[test]
fn parse_error_format_detects_json_equals_form() {
let format = parse_error_format_from_args(&["--error-format=json".to_owned()]);
assert_eq!(format, CliErrorFormat::Json);
}
#[test]
fn parse_error_format_detects_json_shorthand_flag() {
let format = parse_error_format_from_args(&["--json".to_owned(), "init".to_owned()]);
assert_eq!(format, CliErrorFormat::Json);
}
#[test]
fn parse_error_format_defaults_to_text_for_missing_or_unknown_values() {
let missing_value = parse_error_format_from_args(&["--error-format".to_owned()]);
assert_eq!(missing_value, CliErrorFormat::Text);
let unknown_value =
parse_error_format_from_args(&["--error-format=yaml".to_owned(), "list".to_owned()]);
assert_eq!(unknown_value, CliErrorFormat::Text);
}
#[test]
fn extract_single_quoted_value_extracts_marker_value() {
let text = "error: unrecognized subcommand 'aproov'";
let value = extract_single_quoted_value(text, PARSE_UNKNOWN_SUBCOMMAND_MARKER).unwrap();
assert_eq!(value, "aproov");
}
#[test]
fn extract_single_quoted_value_returns_none_when_marker_is_missing_or_unterminated() {
assert!(
extract_single_quoted_value("no quoted value", PARSE_UNKNOWN_SUBCOMMAND_MARKER)
.is_none()
);
assert!(extract_single_quoted_value(
"error: unrecognized subcommand 'aproov",
PARSE_UNKNOWN_SUBCOMMAND_MARKER,
)
.is_none());
}
#[test]
fn parse_subcommand_suggestion_extracts_replacement() {
let error = "error: unrecognized subcommand 'aproov'\n\n tip: a similar subcommand exists: 'approve'\n";
let suggestion =
parse_subcommand_suggestion(error, &["aproov".to_owned(), "x".to_owned()]).unwrap();
assert_eq!(suggestion.unknown, "aproov");
assert_eq!(suggestion.suggested, "approve");
assert_eq!(
suggestion.corrected_args,
vec!["approve".to_owned(), "x".to_owned()]
);
}
#[test]
fn parse_subcommand_suggestion_chooses_best_candidate_from_plural_tip() {
let error = "error: unrecognized subcommand 'versoin'\n\n tip: some similar subcommands exist: 'verify', 'ver', 'version'\n";
let suggestion = parse_subcommand_suggestion(error, &["versoin".to_owned()]).unwrap();
assert_eq!(suggestion.suggested, "version");
assert_eq!(suggestion.corrected_args, vec!["version".to_owned()]);
}
#[test]
fn parse_subcommand_candidates_extracts_plural_list() {
let error = "tip: some similar subcommands exist: 'verify', 'ver', 'version'";
let candidates = parse_subcommand_candidates(error);
assert_eq!(
candidates,
vec!["verify".to_owned(), "ver".to_owned(), "version".to_owned()]
);
}
#[test]
fn parse_subcommand_candidates_returns_empty_when_no_marker_matches() {
assert!(parse_subcommand_candidates("plain error").is_empty());
assert!(
parse_subcommand_candidates("tip: some similar subcommands exist: 'verify").is_empty()
);
}
#[test]
fn choose_best_suggestion_prefers_smallest_distance() {
let best = choose_best_suggestion(
"versoin",
&["verify".to_owned(), "ver".to_owned(), "version".to_owned()],
)
.unwrap();
assert_eq!(best, "version");
}
#[test]
fn choose_best_suggestion_and_distance_cover_empty_inputs() {
assert!(choose_best_suggestion("value", &[]).is_none());
assert_eq!(levenshtein_distance("", "value"), 5);
assert_eq!(levenshtein_distance("value", ""), 5);
}
#[test]
fn corrected_args_requires_command_position_match() {
let corrected = corrected_args_for_subcommand(
&["set".to_owned(), "aproov".to_owned()],
"aproov",
"approve",
);
assert!(corrected.is_none());
}
#[test]
fn corrected_args_updates_top_level_command_after_global_options() {
let corrected = corrected_args_for_subcommand(
&[
"--root".to_owned(),
"/tmp/gloves".to_owned(),
"aproov".to_owned(),
"request-id".to_owned(),
],
"aproov",
"approve",
)
.unwrap();
assert_eq!(
corrected,
vec![
"--root".to_owned(),
"/tmp/gloves".to_owned(),
"approve".to_owned(),
"request-id".to_owned(),
]
);
assert!(parse_subcommand_suggestion(
"error: unrecognized subcommand 'aproov'\n\n tip: some similar subcommands exist: 'approve'\n",
&["--".to_owned(), "aproov".to_owned()],
)
.is_none());
}
#[test]
fn decide_autorun_defaults_to_disabled() {
let _lock = AUTORUN_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_autorun_env();
let decision = decide_autorun(&["version".to_owned()]);
assert_eq!(decision, AutoRunDecision::Disabled);
clear_autorun_env();
}
#[test]
fn decide_autorun_enables_safe_commands_and_blocks_risky_ones() {
let _lock = AUTORUN_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_autorun_env();
env::set_var(AUTORUN_ENV, "1");
env::remove_var(AUTORUN_RISKY_ENV);
env::set_var(AUTORUN_DELAY_ENV, "2500");
let safe = decide_autorun(&["help".to_owned()]);
assert_eq!(safe, AutoRunDecision::Enabled { delay_ms: 2500 });
let unsupported = decide_autorun(&["version".to_owned()]);
assert_eq!(unsupported, AutoRunDecision::BlockedRisky);
let risky = decide_autorun(&["set".to_owned()]);
assert_eq!(risky, AutoRunDecision::BlockedRisky);
env::set_var(AUTORUN_RISKY_ENV, "1");
env::set_var(AUTORUN_DELAY_ENV, "25000");
let risky_enabled = decide_autorun(&["set".to_owned()]);
assert_eq!(risky_enabled, AutoRunDecision::Enabled { delay_ms: 10_000 });
clear_autorun_env();
}
#[test]
fn autorun_safe_command_classification_covers_nested_read_only_paths() {
assert!(is_autorun_safe_command(&["help".to_owned()]));
assert!(is_autorun_safe_command(&[
"secrets".to_owned(),
"status".to_owned()
]));
assert!(is_autorun_safe_command(&[
"requests".to_owned(),
"list".to_owned()
]));
assert!(!is_autorun_safe_command(&[
"requests".to_owned(),
"approve".to_owned()
]));
assert!(!is_autorun_safe_command(&["set".to_owned()]));
}
#[test]
fn collect_error_hints_covers_runtime_variants() {
let invalid_ttl = collect_error_hints(&GlovesError::InvalidInput(
"--ttl must be greater than zero".to_owned(),
));
assert!(invalid_ttl
.iter()
.any(|hint| hint.contains("positive day count")));
let invalid_pipe = collect_error_hints(&GlovesError::InvalidInput(
"secret piping is disabled until policy is configured".to_owned(),
));
assert!(invalid_pipe
.iter()
.any(|hint| hint.contains("safe secret piping")));
let invalid_runtime = collect_error_hints(&GlovesError::InvalidInput(
"required binary not found: gocryptfs".to_owned(),
));
assert!(invalid_runtime
.iter()
.any(|hint| hint.contains("missing runtime binary")));
let name_validation =
collect_error_hints(&GlovesError::Validation(ValidationError::InvalidName));
assert!(name_validation
.iter()
.any(|hint| hint.contains("secret names must")));
let traversal_validation =
collect_error_hints(&GlovesError::Validation(ValidationError::PathTraversal));
assert!(traversal_validation
.iter()
.any(|hint| hint.contains("cannot start with `/`")));
let forbidden = collect_error_hints(&GlovesError::Forbidden);
assert!(forbidden
.iter()
.any(|hint| hint.contains("blocked by policy")));
let not_found = collect_error_hints(&GlovesError::NotFound);
assert!(not_found
.iter()
.any(|hint| hint.contains("check existing secrets")));
let already_exists = collect_error_hints(&GlovesError::AlreadyExists);
assert!(already_exists
.iter()
.any(|hint| hint.contains("entry already exists")));
let unauthorized = collect_error_hints(&GlovesError::Unauthorized);
assert!(unauthorized
.iter()
.any(|hint| hint.contains("not authorized")));
let expired = collect_error_hints(&GlovesError::Expired);
assert!(expired.iter().any(|hint| hint.contains("has expired")));
let gpg_denied = collect_error_hints(&GlovesError::GpgDenied);
assert!(gpg_denied
.iter()
.any(|hint| hint.contains("GPG denied access")));
let integrity = collect_error_hints(&GlovesError::IntegrityViolation);
assert!(integrity
.iter()
.any(|hint| hint.contains("integrity verification failed")));
let io_hints = collect_error_hints(&GlovesError::Io(io::Error::other("disk")));
assert!(io_hints
.iter()
.any(|hint| hint.contains("path existence and permissions")));
}
#[test]
fn shell_join_and_option_token_len_handle_edge_cases() {
let joined = shell_words_join(&[
"gloves".to_owned(),
"value with spaces".to_owned(),
"nul\0trim".to_owned(),
]);
assert!(joined.contains("'value with spaces'"));
assert!(!joined.contains('\0'));
let args = vec![
"--root".to_owned(),
"/tmp/gloves".to_owned(),
"--error-format=json".to_owned(),
];
assert_eq!(option_token_len("--root", &args, 0), Some(2));
assert_eq!(option_token_len("--error-format=json", &args, 2), Some(1));
assert_eq!(option_token_len("--unknown", &args, 0), None);
}
#[test]
fn option_token_len_and_top_level_command_index_cover_missing_values_and_short_flags() {
let args = vec![
"-v".to_owned(),
"--root".to_owned(),
"/tmp/gloves".to_owned(),
"list".to_owned(),
];
assert_eq!(top_level_command_index(&args), Some(3));
assert_eq!(
option_token_len("--root", &["--root".to_owned()], 0),
Some(1)
);
assert_eq!(
top_level_command_index(&["--".to_owned(), "list".to_owned()]),
None
);
}
#[test]
fn env_truthy_recognizes_supported_literals() {
let _lock = AUTORUN_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_autorun_env();
env::set_var(AUTORUN_ENV, "true");
assert!(env_truthy(AUTORUN_ENV));
env::set_var(AUTORUN_ENV, " yes ");
assert!(env_truthy(AUTORUN_ENV));
env::set_var(AUTORUN_ENV, "ON");
assert!(env_truthy(AUTORUN_ENV));
env::set_var(AUTORUN_ENV, "nope");
assert!(!env_truthy(AUTORUN_ENV));
clear_autorun_env();
}
#[test]
fn autorun_safe_command_classification_covers_supported_nested_commands() {
assert!(is_autorun_safe_command(&[
"secrets".to_owned(),
"help".to_owned()
]));
assert!(is_autorun_safe_command(&[
"secrets".to_owned(),
"get".to_owned()
]));
assert!(is_autorun_safe_command(&[
"config".to_owned(),
"validate".to_owned()
]));
assert!(is_autorun_safe_command(&[
"access".to_owned(),
"paths".to_owned()
]));
assert!(is_autorun_safe_command(&[
"vault".to_owned(),
"status".to_owned()
]));
assert!(is_autorun_safe_command(&[
"vault".to_owned(),
"list".to_owned()
]));
assert!(is_autorun_safe_command(&[
"gpg".to_owned(),
"fingerprint".to_owned()
]));
}
#[test]
fn suggestion_marker_constant_is_expected() {
assert_eq!(PARSE_SUGGESTION_MARKER, "a similar subcommand exists: '");
}
}