use std::collections::BTreeSet;
use std::io::IsTerminal;
use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser};
#[must_use]
pub fn detect_interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InteractivityMode {
Interactive,
NonInteractive,
}
impl InteractivityMode {
#[must_use]
pub fn is_interactive(self) -> bool {
self == Self::Interactive
}
}
impl From<bool> for InteractivityMode {
fn from(interactive: bool) -> Self {
if interactive {
Self::Interactive
} else {
Self::NonInteractive
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GlobalFlags {
pub output_format: String,
pub verbose: String,
pub dry_run: bool,
pub fields: String,
pub fields_explicit: bool,
pub filter: String,
pub expr: String,
pub schema: bool,
pub reason: String,
pub timeout: String,
pub debug: String,
pub credential_store: Option<crate::config::CredentialStore>,
pub interactive: bool,
}
impl Default for GlobalFlags {
fn default() -> Self {
Self {
output_format: "json".to_owned(),
verbose: String::new(),
dry_run: false,
fields: String::new(),
fields_explicit: false,
filter: String::new(),
expr: String::new(),
schema: false,
reason: String::new(),
timeout: "0s".to_owned(),
debug: String::new(),
credential_store: None,
interactive: detect_interactive(),
}
}
}
pub(crate) mod global_flag_order {
pub(crate) const HELP: usize = 1000;
pub(crate) const OUTPUT: usize = 1001;
pub(crate) const VERBOSE: usize = 1002;
pub(crate) const DRY_RUN: usize = 1003;
pub(crate) const FIELDS: usize = 1004;
pub(crate) const FILTER: usize = 1005;
pub(crate) const EXPR: usize = 1006;
pub(crate) const LIMIT: usize = 1007;
pub(crate) const OFFSET: usize = 1008;
pub(crate) const SCHEMA: usize = 1009;
pub(crate) const TIMEOUT: usize = 1010;
pub(crate) const DEBUG: usize = 1011;
pub(crate) const CREDENTIAL_STORE: usize = 1012;
pub(crate) const JSON: usize = 1013;
pub(crate) const TOON: usize = 1014;
pub(crate) const HUMAN: usize = 1015;
pub(crate) const INTERACTIVE: usize = 1016;
pub(crate) const REASON: usize = 1017;
pub(crate) const ENV: usize = 1018;
}
pub fn register_global_flags(command: Command) -> Command {
command
.disable_help_flag(true)
.arg(
Arg::new("help")
.short('h')
.long("help")
.action(ArgAction::HelpLong)
.global(true)
.display_order(global_flag_order::HELP)
.help("Print help"),
)
.arg(
Arg::new("output")
.long("output")
.short('o')
.global(true)
.display_order(global_flag_order::OUTPUT)
.value_name("FORMAT")
.default_value(if std::io::stdout().is_terminal() {
"human"
} else {
"json"
})
.conflicts_with_all(["json", "toon", "human"])
.help(
"Output format: toon|json|human (shorthand: --json, --toon, --human); \
defaults to human in an interactive terminal, json otherwise",
),
)
.arg(
Arg::new("verbose")
.long("verbose")
.global(true)
.num_args(0..=1)
.default_missing_value("all")
.value_name("FIELDS")
.display_order(global_flag_order::VERBOSE)
.help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.global(true)
.num_args(0..=1)
.require_equals(true)
.default_missing_value("true")
.default_value("false")
.value_parser(compat_bool_value_parser())
.display_order(global_flag_order::DRY_RUN)
.help("Preview mutations without executing"),
)
.arg(
Arg::new("fields")
.long("fields")
.global(true)
.value_name("FIELDS")
.display_order(global_flag_order::FIELDS)
.help("Comma-separated fields to include in output (use 'all' or '*' for everything)"),
)
.arg(
Arg::new("filter")
.long("filter")
.global(true)
.value_name("EXPR")
.display_order(global_flag_order::FILTER)
.help("Per-item JMESPath predicate for list data"),
)
.arg(
Arg::new("expr")
.long("expr")
.global(true)
.value_name("EXPR")
.display_order(global_flag_order::EXPR)
.help("JMESPath query applied to the whole result"),
)
.arg(
Arg::new("schema")
.long("schema")
.global(true)
.num_args(0..=1)
.require_equals(true)
.default_missing_value("true")
.default_value("false")
.value_parser(compat_bool_value_parser())
.display_order(global_flag_order::SCHEMA)
.help("Dump output field metadata instead of running the command"),
)
.arg(
Arg::new("timeout")
.long("timeout")
.global(true)
.allow_hyphen_values(true)
.default_value("0s")
.value_name("DURATION")
.display_order(global_flag_order::TIMEOUT)
.help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"),
)
.arg(
Arg::new("debug")
.long("debug")
.global(true)
.num_args(0..=1)
.default_missing_value("*")
.value_name("PATTERN")
.display_order(global_flag_order::DEBUG)
.help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"),
)
.arg(
Arg::new("credential-store")
.long("credential-store")
.display_order(global_flag_order::CREDENTIAL_STORE)
.global(true)
.value_name("MODE")
.value_parser(|s: &str| s.parse::<crate::config::CredentialStore>())
.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")
.global(true)
.action(ArgAction::SetTrue)
.conflicts_with_all(["toon", "human"])
.hide(true)
.display_order(global_flag_order::JSON)
.help("Shorthand for --output json"),
)
.arg(
Arg::new("toon")
.long("toon")
.global(true)
.action(ArgAction::SetTrue)
.conflicts_with_all(["json", "human"])
.hide(true)
.display_order(global_flag_order::TOON)
.help("Shorthand for --output toon"),
)
.arg(
Arg::new("human")
.long("human")
.global(true)
.action(ArgAction::SetTrue)
.conflicts_with_all(["json", "toon"])
.hide(true)
.display_order(global_flag_order::HUMAN)
.help("Shorthand for --output human"),
)
}
pub fn register_reason_flag(command: Command) -> Command {
command.arg(
Arg::new("reason")
.long("reason")
.global(true)
.value_name("TEXT")
.display_order(global_flag_order::REASON)
.help("Short explanation of why this command is being run (forwarded to your authorizer, auditor, or activity emitter)"),
)
}
pub(crate) fn apply_pagination_args(
command: Command,
default_limit: i64,
max_limit: i64,
) -> Command {
command
.arg(
Arg::new("limit")
.long("limit")
.value_parser(pagination_limit_value_parser(max_limit))
.allow_hyphen_values(true)
.default_value(default_limit.to_string())
.display_order(global_flag_order::LIMIT)
.help(pagination_limit_help(default_limit, max_limit)),
)
.arg(
Arg::new("offset")
.long("offset")
.value_parser(pagination_offset_value_parser())
.allow_hyphen_values(true)
.default_value("0")
.display_order(global_flag_order::OFFSET)
.help("Skip N items before applying limit"),
)
}
fn pagination_limit_help(default_limit: i64, max_limit: i64) -> String {
let mut help = format!("Max items to return (client-side, 0=all, default {default_limit}");
if max_limit > 0 {
help.push_str(&format!(", max {max_limit}"));
}
help.push(')');
help
}
fn pagination_limit_value_parser(max_limit: i64) -> ValueParser {
ValueParser::new(move |raw: &str| -> Result<i64, String> {
let value = raw
.parse::<i64>()
.map_err(|_| format!("invalid limit value {raw:?}"))?;
if max_limit > 0 && value > max_limit {
return Err(format!("limit {value} exceeds the maximum of {max_limit}"));
}
Ok(value)
})
}
fn pagination_offset_value_parser() -> ValueParser {
ValueParser::new(|raw: &str| -> Result<i64, String> {
let value = raw
.parse::<i64>()
.map_err(|_| format!("invalid offset value {raw:?}"))?;
if value < 0 {
return Err(format!("offset {value} must be non-negative"));
}
Ok(value)
})
}
#[must_use]
pub fn resolve_default_output_format(
env_override: Option<&str>,
config_override: Option<&str>,
is_tty: bool,
) -> String {
for candidate in [env_override, config_override].into_iter().flatten() {
let normalized = candidate.trim().to_ascii_lowercase();
if crate::output::is_valid_output_format(&normalized) {
return normalized;
}
}
if is_tty { "human" } else { "json" }.to_owned()
}
#[must_use]
pub fn app_id_env_prefix(app_id: &str) -> String {
app_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
#[must_use]
pub fn output_env_var(app_id: &str) -> String {
format!("{}_OUTPUT", app_id_env_prefix(app_id))
}
#[must_use]
pub fn min_stage_env_var(app_id: &str) -> String {
format!("{}_MIN_STAGE", app_id_env_prefix(app_id))
}
#[must_use]
pub fn default_output_format(app_id: &str) -> String {
let env = std::env::var(output_env_var(app_id)).ok();
let file = crate::config::load(app_id);
resolve_default_output_format(
env.as_deref(),
file.output.format.as_deref(),
std::io::stdout().is_terminal(),
)
}
#[must_use]
pub fn global_flags_from_matches(
matches: &ArgMatches,
default_format: &str,
auto_interactive: bool,
) -> GlobalFlags {
let output_format = if matches.get_flag("toon") {
"toon".to_owned()
} else if matches.get_flag("human") {
"human".to_owned()
} else if matches.get_flag("json") {
"json".to_owned()
} else if matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine) {
matches
.get_one::<String>("output")
.cloned()
.unwrap_or_else(|| default_format.to_owned())
} else {
default_format.to_owned()
};
GlobalFlags {
output_format,
verbose: matches
.get_one::<String>("verbose")
.cloned()
.unwrap_or_default(),
dry_run: matches.get_one::<bool>("dry-run").copied().unwrap_or(false),
fields: matches
.get_one::<String>("fields")
.cloned()
.unwrap_or_default(),
fields_explicit: matches.value_source("fields")
== Some(clap::parser::ValueSource::CommandLine),
filter: matches
.get_one::<String>("filter")
.cloned()
.unwrap_or_default(),
expr: matches
.get_one::<String>("expr")
.cloned()
.unwrap_or_default(),
schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
reason: matches
.try_get_one::<String>("reason")
.ok()
.flatten()
.cloned()
.unwrap_or_default(),
timeout: matches
.get_one::<String>("timeout")
.cloned()
.unwrap_or_else(|| "0s".to_owned()),
debug: matches
.get_one::<String>("debug")
.cloned()
.unwrap_or_default(),
credential_store: matches
.get_one::<crate::config::CredentialStore>("credential-store")
.copied(),
interactive: if matches.get_flag("non-interactive") {
false
} else if matches.get_flag("interactive") {
true
} else if auto_interactive {
detect_interactive()
} else {
false
},
}
}
#[must_use]
pub fn extract_output_format(args: &[impl AsRef<str>], default_format: &str) -> String {
for index in 0..args.len() {
let arg = args[index].as_ref();
if arg == "--output" || arg == "-o" {
return args.get(index + 1).map_or_else(
|| default_format.to_owned(),
|value| value.as_ref().to_owned(),
);
}
if let Some(value) = arg.strip_prefix("--output=") {
return value.to_owned();
}
if arg == "--json" {
return "json".to_owned();
}
if arg == "--toon" {
return "toon".to_owned();
}
if arg == "--human" {
return "human".to_owned();
}
}
default_format.to_owned()
}
#[must_use]
pub fn extract_command_path(
args: &[impl AsRef<str>],
bool_flags: &BTreeSet<String>,
value_flags: &BTreeSet<String>,
) -> String {
let mut parts = Vec::new();
let mut index = 1;
while index < args.len() {
let arg = args[index].as_ref();
if arg == "--schema" {
index += 1;
continue;
}
if arg.starts_with('-') {
if bool_flags.contains(arg) || arg.contains('=') {
index += 1;
continue;
}
if value_flags.contains(arg)
|| (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-'))
{
index += 2;
continue;
}
index += 1;
continue;
}
parts.push(arg.to_owned());
index += 1;
}
parts.join(":")
}
#[must_use]
pub fn has_true_schema_flag(args: &[impl AsRef<str>]) -> bool {
for arg in args {
let arg = arg.as_ref();
if arg == "--schema" {
return true;
}
if let Some(value) = arg.strip_prefix("--schema=") {
return parse_compat_bool(value).unwrap_or(false);
}
}
false
}
pub(crate) fn compat_bool_value_parser() -> ValueParser {
ValueParser::new(parse_compat_bool)
}
fn parse_compat_bool(raw: &str) -> Result<bool, String> {
match raw {
"1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
"0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
_ => Err(format!("invalid boolean value {raw:?}")),
}
}
#[must_use]
pub fn derive_bool_flags(command: &Command) -> BTreeSet<String> {
let mut flags = BTreeSet::from([
"--help".to_owned(),
"-h".to_owned(),
"--verbose".to_owned(),
"--debug".to_owned(),
]);
collect_flag_names(command, &mut |arg, name| {
if !arg_requires_value(arg) {
flags.insert(name);
}
});
flags
}
#[must_use]
pub fn derive_value_flags(command: &Command) -> BTreeSet<String> {
let mut flags = BTreeSet::new();
collect_flag_names(command, &mut |arg, name| {
if arg_requires_value(arg) {
flags.insert(name);
}
});
flags
}
fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) {
for arg in command.get_arguments() {
if arg.is_positional() {
continue;
}
if let Some(long) = arg.get_long() {
visit(arg, format!("--{long}"));
}
if let Some(short) = arg.get_short() {
visit(arg, format!("-{short}"));
}
}
for child in command.get_subcommands() {
collect_flag_names(child, visit);
}
}
#[must_use]
pub fn debug_component_enabled(pattern: &str, component: &str) -> bool {
let component = component.trim().to_ascii_lowercase();
if component.is_empty() {
return false;
}
let mut enabled = false;
for raw in pattern.split(',') {
let token = raw.trim();
if token.is_empty() {
continue;
}
let (negated, name) = token
.strip_prefix('-')
.map_or((false, token), |rest| (true, rest));
let name = name.trim().to_ascii_lowercase();
if name == "*" || name == component {
enabled = !negated;
}
}
enabled
}
fn arg_requires_value(arg: &Arg) -> bool {
match arg.get_action() {
ArgAction::Set | ArgAction::Append => arg
.get_num_args()
.is_none_or(|range| range.takes_values() && range.min_values() > 0),
ArgAction::SetTrue
| ArgAction::SetFalse
| ArgAction::Count
| ArgAction::Help
| ArgAction::HelpShort
| ArgAction::HelpLong
| ArgAction::Version => false,
_ => arg
.get_num_args()
.is_some_and(|range| range.takes_values() && range.min_values() > 0),
}
}
#[cfg(test)]
mod tests {
use clap::Command;
use super::{
debug_component_enabled, min_stage_env_var, output_env_var, register_global_flags,
resolve_default_output_format,
};
#[test]
fn debug_component_matcher_handles_wildcards_and_negation() {
assert!(!debug_component_enabled("", "transport"));
assert!(debug_component_enabled("*", "transport"));
assert!(debug_component_enabled("*", "auth"));
assert!(debug_component_enabled("transport", "transport"));
assert!(!debug_component_enabled("transport", "auth"));
assert!(!debug_component_enabled("*,-transport", "transport"));
assert!(debug_component_enabled("*,-auth", "transport"));
assert!(!debug_component_enabled("*,-*", "transport"));
assert!(debug_component_enabled("-*,transport", "transport"));
assert!(debug_component_enabled(" Transport , -auth ", "transport"));
assert!(!debug_component_enabled("*", ""));
assert!(!debug_component_enabled("*", " "));
}
#[test]
fn default_output_format_follows_env_override_then_tty() {
assert_eq!(resolve_default_output_format(None, None, true), "human");
assert_eq!(resolve_default_output_format(None, None, false), "json");
assert_eq!(
resolve_default_output_format(Some("json"), None, true),
"json"
);
assert_eq!(
resolve_default_output_format(Some("human"), None, false),
"human"
);
assert_eq!(
resolve_default_output_format(Some("JSON"), None, true),
"json"
);
assert_eq!(
resolve_default_output_format(Some(" Human "), None, false),
"human"
);
assert_eq!(
resolve_default_output_format(Some(" "), None, false),
"json"
);
assert_eq!(resolve_default_output_format(Some(""), None, true), "human");
assert_eq!(
resolve_default_output_format(Some("yaml"), None, false),
"json"
);
assert_eq!(
resolve_default_output_format(Some("yaml"), None, true),
"human"
);
}
#[test]
fn default_output_format_config_override_wins_over_tty_but_not_env() {
assert_eq!(
resolve_default_output_format(None, Some("json"), true),
"json"
);
assert_eq!(
resolve_default_output_format(None, Some("human"), false),
"human"
);
assert_eq!(
resolve_default_output_format(Some("human"), Some("json"), false),
"human"
);
assert_eq!(
resolve_default_output_format(None, Some("yaml"), true),
"human"
);
assert_eq!(
resolve_default_output_format(None, Some("yaml"), false),
"json"
);
}
#[test]
fn output_env_var_is_derived_from_app_id() {
assert_eq!(output_env_var("godaddy"), "GODADDY_OUTPUT");
assert_eq!(output_env_var("gdx"), "GDX_OUTPUT");
assert_eq!(output_env_var("my-cli"), "MY_CLI_OUTPUT");
}
#[test]
fn min_stage_env_var_is_derived_from_app_id() {
assert_eq!(min_stage_env_var("godaddy"), "GODADDY_MIN_STAGE");
assert_eq!(min_stage_env_var("gdx"), "GDX_MIN_STAGE");
assert_eq!(min_stage_env_var("my-cli"), "MY_CLI_MIN_STAGE");
}
#[test]
fn short_and_long_help_flags_render_identical_output() {
let build = || {
register_global_flags(Command::new("testcli"))
.subcommand(Command::new("sub").about("A subcommand"))
};
let help_text = |args: &[&str]| {
build()
.try_get_matches_from(args)
.expect_err("help action short-circuits parsing")
.to_string()
};
assert_eq!(
help_text(&["testcli", "-h"]),
help_text(&["testcli", "--help"])
);
assert_eq!(
help_text(&["testcli", "sub", "-h"]),
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", false);
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", true);
assert!(!flags.interactive);
}
#[test]
fn interactive_defaults_off_without_auto_interactive() {
use super::global_flags_from_matches;
let cmd = register_global_flags(Command::new("test"));
let matches = cmd.try_get_matches_from(["test"]).expect("should parse");
let flags = global_flags_from_matches(&matches, "json", false);
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() {
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);
}
}