use std::collections::BTreeSet;
use std::io::IsTerminal;
use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GlobalFlags {
pub output_format: String,
pub verbose: String,
pub dry_run: bool,
pub fields: String,
pub filter: String,
pub expr: String,
pub limit: i64,
pub offset: i64,
pub schema: bool,
pub reason: String,
pub timeout: String,
pub debug: String,
pub search: String,
pub credential_store: Option<crate::config::CredentialStore>,
}
impl Default for GlobalFlags {
fn default() -> Self {
Self {
output_format: "json".to_owned(),
verbose: String::new(),
dry_run: false,
fields: String::new(),
filter: String::new(),
expr: String::new(),
limit: 0,
offset: 0,
schema: false,
reason: String::new(),
timeout: "0s".to_owned(),
debug: String::new(),
search: String::new(),
credential_store: None,
}
}
}
pub fn register_global_flags(command: Command) -> Command {
command
.arg(
Arg::new("output")
.long("output")
.short('o')
.global(true)
.value_name("FORMAT")
.default_value("json")
.help("Output format: toon|json|human"),
)
.arg(
Arg::new("verbose")
.long("verbose")
.global(true)
.num_args(0..=1)
.default_missing_value("all")
.value_name("FIELDS")
.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())
.help("Preview mutations without executing"),
)
.arg(
Arg::new("fields")
.long("fields")
.global(true)
.value_name("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")
.help("Per-item JMESPath predicate for list data"),
)
.arg(
Arg::new("expr")
.long("expr")
.global(true)
.value_name("EXPR")
.help("JMESPath query applied to the whole result"),
)
.arg(
Arg::new("limit")
.long("limit")
.global(true)
.value_parser(value_parser!(i64))
.allow_hyphen_values(true)
.default_value("0")
.help("Max items to return (client-side, 0=all)"),
)
.arg(
Arg::new("offset")
.long("offset")
.global(true)
.value_parser(value_parser!(i64))
.allow_hyphen_values(true)
.default_value("0")
.help("Skip N items before applying limit"),
)
.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())
.help("Dump output field metadata instead of running the command"),
)
.arg(
Arg::new("reason")
.long("reason")
.global(true)
.value_name("TEXT")
.help("Short explanation of why this command is being run (required for destructive commands)"),
)
.arg(
Arg::new("timeout")
.long("timeout")
.global(true)
.allow_hyphen_values(true)
.default_value("0s")
.value_name("DURATION")
.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")
.help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"),
)
.arg(
Arg::new("search")
.long("search")
.global(true)
.value_name("KEYWORD")
.help("Search commands and guides by keyword"),
)
.arg(
Arg::new("credential-store")
.long("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("json")
.long("json")
.global(true)
.action(ArgAction::SetTrue)
.help("Shorthand for --output json"),
)
.arg(
Arg::new("toon")
.long("toon")
.global(true)
.action(ArgAction::SetTrue)
.help("Shorthand for --output toon"),
)
.arg(
Arg::new("human")
.long("human")
.global(true)
.action(ArgAction::SetTrue)
.help("Shorthand for --output human"),
)
}
#[must_use]
pub fn resolve_default_output_format(env_override: Option<&str>, is_tty: bool) -> String {
if let Some(value) = env_override {
let normalized = value.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 default_output_format(app_id: &str) -> String {
let env = std::env::var(output_env_var(app_id)).ok();
resolve_default_output_format(env.as_deref(), std::io::stdout().is_terminal())
}
#[must_use]
pub fn global_flags_from_matches(matches: &ArgMatches, default_format: &str) -> 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(),
filter: matches
.get_one::<String>("filter")
.cloned()
.unwrap_or_default(),
expr: matches
.get_one::<String>("expr")
.cloned()
.unwrap_or_default(),
limit: matches.get_one::<i64>("limit").copied().unwrap_or(0),
offset: matches.get_one::<i64>("offset").copied().unwrap_or(0),
schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
reason: matches
.get_one::<String>("reason")
.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(),
search: matches
.get_one::<String>("search")
.cloned()
.unwrap_or_default(),
credential_store: matches
.get_one::<crate::config::CredentialStore>("credential-store")
.copied(),
}
}
#[must_use]
pub fn extract_search_query(args: &[impl AsRef<str>]) -> String {
for index in 0..args.len() {
let arg = args[index].as_ref();
if arg == "--search" {
return args
.get(index + 1)
.map_or_else(String::new, |value| value.as_ref().to_owned());
}
if let Some(value) = arg.strip_prefix("--search=") {
return value.to_owned();
}
}
String::new()
}
#[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
}
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);
}
}
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 super::{output_env_var, resolve_default_output_format};
#[test]
fn default_output_format_follows_env_override_then_tty() {
assert_eq!(resolve_default_output_format(None, true), "human");
assert_eq!(resolve_default_output_format(None, false), "json");
assert_eq!(resolve_default_output_format(Some("json"), true), "json");
assert_eq!(resolve_default_output_format(Some("human"), false), "human");
assert_eq!(resolve_default_output_format(Some("JSON"), true), "json");
assert_eq!(
resolve_default_output_format(Some(" Human "), false),
"human"
);
assert_eq!(resolve_default_output_format(Some(" "), false), "json");
assert_eq!(resolve_default_output_format(Some(""), true), "human");
assert_eq!(resolve_default_output_format(Some("yaml"), false), "json");
assert_eq!(resolve_default_output_format(Some("yaml"), true), "human");
}
#[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");
}
}