Skip to main content

cli_engine/
flags.rs

1use std::collections::BTreeSet;
2use std::io::IsTerminal;
3
4use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
5
6/// Parsed framework-global flags.
7///
8/// Applications can add their own global flags, but these are the built-in
9/// controls understood by middleware and the output pipeline.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct GlobalFlags {
12    /// Output format: `json`, `human`, or `toon`.
13    pub output_format: String,
14    /// Metadata verbosity selector.
15    pub verbose: String,
16    /// Whether mutating commands should short-circuit.
17    pub dry_run: bool,
18    /// Field projection.
19    pub fields: String,
20    /// JMESPath per-item filter.
21    pub filter: String,
22    /// JMESPath whole-result expression.
23    pub expr: String,
24    /// Client-side page size.
25    pub limit: i64,
26    /// Client-side page offset.
27    pub offset: i64,
28    /// Whether schema rendering was requested.
29    pub schema: bool,
30    /// User-provided command reason.
31    pub reason: String,
32    /// Raw timeout string.
33    pub timeout: String,
34    /// Debug selector.
35    pub debug: String,
36    /// Search query.
37    pub search: String,
38    /// Credential storage override from `--credential-store`, if supplied.
39    pub credential_store: Option<crate::config::CredentialStore>,
40}
41
42impl Default for GlobalFlags {
43    fn default() -> Self {
44        Self {
45            output_format: "json".to_owned(),
46            verbose: String::new(),
47            dry_run: false,
48            fields: String::new(),
49            filter: String::new(),
50            expr: String::new(),
51            limit: 0,
52            offset: 0,
53            schema: false,
54            reason: String::new(),
55            timeout: "0s".to_owned(),
56            debug: String::new(),
57            search: String::new(),
58            credential_store: None,
59        }
60    }
61}
62
63/// Registers framework-global flags on a `clap` command.
64pub fn register_global_flags(command: Command) -> Command {
65    command
66        .arg(
67            Arg::new("output")
68                .long("output")
69                .short('o')
70                .global(true)
71                .value_name("FORMAT")
72                .default_value("json")
73                .help("Output format: toon|json|human"),
74        )
75        .arg(
76            Arg::new("verbose")
77                .long("verbose")
78                .global(true)
79                .num_args(0..=1)
80                .default_missing_value("all")
81                .value_name("FIELDS")
82                .help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"),
83        )
84        .arg(
85            Arg::new("dry-run")
86                .long("dry-run")
87                .global(true)
88                .num_args(0..=1)
89                .require_equals(true)
90                .default_missing_value("true")
91                .default_value("false")
92                .value_parser(compat_bool_value_parser())
93                .help("Preview mutations without executing"),
94        )
95        .arg(
96            Arg::new("fields")
97                .long("fields")
98                .global(true)
99                .value_name("FIELDS")
100                .help("Comma-separated fields to include in output (use 'all' or '*' for everything)"),
101        )
102        .arg(
103            Arg::new("filter")
104                .long("filter")
105                .global(true)
106                .value_name("EXPR")
107                .help("Per-item JMESPath predicate for list data"),
108        )
109        .arg(
110            Arg::new("expr")
111                .long("expr")
112                .global(true)
113                .value_name("EXPR")
114                .help("JMESPath query applied to the whole result"),
115        )
116        .arg(
117            Arg::new("limit")
118                .long("limit")
119                .global(true)
120                .value_parser(value_parser!(i64))
121                .allow_hyphen_values(true)
122                .default_value("0")
123                .help("Max items to return (client-side, 0=all)"),
124        )
125        .arg(
126            Arg::new("offset")
127                .long("offset")
128                .global(true)
129                .value_parser(value_parser!(i64))
130                .allow_hyphen_values(true)
131                .default_value("0")
132                .help("Skip N items before applying limit"),
133        )
134        .arg(
135            Arg::new("schema")
136                .long("schema")
137                .global(true)
138                .num_args(0..=1)
139                .require_equals(true)
140                .default_missing_value("true")
141                .default_value("false")
142                .value_parser(compat_bool_value_parser())
143                .help("Dump output field metadata instead of running the command"),
144        )
145        .arg(
146            Arg::new("reason")
147                .long("reason")
148                .global(true)
149                .value_name("TEXT")
150                .help("Short explanation of why this command is being run (required for destructive commands)"),
151        )
152        .arg(
153            Arg::new("timeout")
154                .long("timeout")
155                .global(true)
156                .allow_hyphen_values(true)
157                .default_value("0s")
158                .value_name("DURATION")
159                .help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"),
160        )
161        .arg(
162            Arg::new("debug")
163                .long("debug")
164                .global(true)
165                .num_args(0..=1)
166                .default_missing_value("*")
167                .value_name("PATTERN")
168                .help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"),
169        )
170        .arg(
171            Arg::new("search")
172                .long("search")
173                .global(true)
174                .value_name("KEYWORD")
175                .help("Search commands and guides by keyword"),
176        )
177        .arg(
178            Arg::new("credential-store")
179                .long("credential-store")
180                .global(true)
181                .value_name("MODE")
182                .value_parser(|s: &str| s.parse::<crate::config::CredentialStore>())
183                .help("Credential storage: auto|keyring|file (overrides env and config)"),
184        )
185        .arg(
186            Arg::new("json")
187                .long("json")
188                .global(true)
189                .action(ArgAction::SetTrue)
190                .help("Shorthand for --output json"),
191        )
192        .arg(
193            Arg::new("toon")
194                .long("toon")
195                .global(true)
196                .action(ArgAction::SetTrue)
197                .help("Shorthand for --output toon"),
198        )
199        .arg(
200            Arg::new("human")
201                .long("human")
202                .global(true)
203                .action(ArgAction::SetTrue)
204                .help("Shorthand for --output human"),
205        )
206}
207
208/// Resolves the default output format when the user gave no explicit format.
209///
210/// Precedence here is env-override first, then a TTY policy: an interactive
211/// terminal gets human-friendly output, everything else (pipes, files, CI,
212/// most agents) gets machine-readable JSON. Pure so it can be unit-tested
213/// without a real terminal.
214#[must_use]
215pub fn resolve_default_output_format(env_override: Option<&str>, is_tty: bool) -> String {
216    if let Some(value) = env_override {
217        // Normalize case (env vars are commonly upper/mixed case) and ignore
218        // blank or unrecognized values, so a stray or miscased override can't
219        // break all command output — only a valid format is honored.
220        let normalized = value.trim().to_ascii_lowercase();
221        if crate::output::is_valid_output_format(&normalized) {
222            return normalized;
223        }
224    }
225    if is_tty { "human" } else { "json" }.to_owned()
226}
227
228/// Sanitizes an app id into an environment-variable prefix: ASCII alphanumerics
229/// are uppercased and every other character becomes `_`, e.g. `godaddy` ->
230/// `GODADDY`, `my-cli` -> `MY_CLI`.
231///
232/// Shared by the framework's app-scoped env vars (for example
233/// [`output_env_var`] and `${PREFIX}_CREDENTIAL_STORE`) so they derive the same
234/// prefix from a given app id.
235#[must_use]
236pub fn app_id_env_prefix(app_id: &str) -> String {
237    app_id
238        .chars()
239        .map(|c| {
240            if c.is_ascii_alphanumeric() {
241                c.to_ascii_uppercase()
242            } else {
243                '_'
244            }
245        })
246        .collect()
247}
248
249/// Derives the per-application output-format override env var from an app id,
250/// e.g. `godaddy` -> `GODADDY_OUTPUT`, `gdx` -> `GDX_OUTPUT`.
251#[must_use]
252pub fn output_env_var(app_id: &str) -> String {
253    format!("{}_OUTPUT", app_id_env_prefix(app_id))
254}
255
256/// Computes the default output format for `app_id`, consulting the
257/// `${APP_ID}_OUTPUT` env override and whether stdout is an interactive
258/// terminal. Used as the fallback when no explicit `--output`/`--json`/
259/// `--toon`/`--human` is given.
260#[must_use]
261pub fn default_output_format(app_id: &str) -> String {
262    let env = std::env::var(output_env_var(app_id)).ok();
263    resolve_default_output_format(env.as_deref(), std::io::stdout().is_terminal())
264}
265
266#[must_use]
267/// Extracts framework-global flags from parsed `clap` matches, falling back to
268/// `default_format` when the user gave no explicit output format.
269pub fn global_flags_from_matches(matches: &ArgMatches, default_format: &str) -> GlobalFlags {
270    let output_format = if matches.get_flag("toon") {
271        "toon".to_owned()
272    } else if matches.get_flag("human") {
273        "human".to_owned()
274    } else if matches.get_flag("json") {
275        "json".to_owned()
276    } else if matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine) {
277        matches
278            .get_one::<String>("output")
279            .cloned()
280            .unwrap_or_else(|| default_format.to_owned())
281    } else {
282        default_format.to_owned()
283    };
284
285    GlobalFlags {
286        output_format,
287        verbose: matches
288            .get_one::<String>("verbose")
289            .cloned()
290            .unwrap_or_default(),
291        dry_run: matches.get_one::<bool>("dry-run").copied().unwrap_or(false),
292        fields: matches
293            .get_one::<String>("fields")
294            .cloned()
295            .unwrap_or_default(),
296        filter: matches
297            .get_one::<String>("filter")
298            .cloned()
299            .unwrap_or_default(),
300        expr: matches
301            .get_one::<String>("expr")
302            .cloned()
303            .unwrap_or_default(),
304        limit: matches.get_one::<i64>("limit").copied().unwrap_or(0),
305        offset: matches.get_one::<i64>("offset").copied().unwrap_or(0),
306        schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
307        reason: matches
308            .get_one::<String>("reason")
309            .cloned()
310            .unwrap_or_default(),
311        timeout: matches
312            .get_one::<String>("timeout")
313            .cloned()
314            .unwrap_or_else(|| "0s".to_owned()),
315        debug: matches
316            .get_one::<String>("debug")
317            .cloned()
318            .unwrap_or_default(),
319        search: matches
320            .get_one::<String>("search")
321            .cloned()
322            .unwrap_or_default(),
323        credential_store: matches
324            .get_one::<crate::config::CredentialStore>("credential-store")
325            .copied(),
326    }
327}
328
329#[must_use]
330/// Extracts `--search` from raw args before normal parsing.
331pub fn extract_search_query(args: &[impl AsRef<str>]) -> String {
332    for index in 0..args.len() {
333        let arg = args[index].as_ref();
334        if arg == "--search" {
335            return args
336                .get(index + 1)
337                .map_or_else(String::new, |value| value.as_ref().to_owned());
338        }
339        if let Some(value) = arg.strip_prefix("--search=") {
340            return value.to_owned();
341        }
342    }
343    String::new()
344}
345
346#[must_use]
347/// Extracts output format from raw args.
348///
349/// Recognizes `--output <format>` / `-o <format>` / `--output=<format>`,
350/// plus `--json`, `--toon`, and `--human` as shorthand for their respective
351/// formats. Falls back to `default_format` when none is present.
352pub fn extract_output_format(args: &[impl AsRef<str>], default_format: &str) -> String {
353    for index in 0..args.len() {
354        let arg = args[index].as_ref();
355        if arg == "--output" || arg == "-o" {
356            return args.get(index + 1).map_or_else(
357                || default_format.to_owned(),
358                |value| value.as_ref().to_owned(),
359            );
360        }
361        if let Some(value) = arg.strip_prefix("--output=") {
362            return value.to_owned();
363        }
364        if arg == "--json" {
365            return "json".to_owned();
366        }
367        if arg == "--toon" {
368            return "toon".to_owned();
369        }
370        if arg == "--human" {
371            return "human".to_owned();
372        }
373    }
374    default_format.to_owned()
375}
376
377#[must_use]
378/// Extracts a colon-separated command path from raw args.
379pub fn extract_command_path(
380    args: &[impl AsRef<str>],
381    bool_flags: &BTreeSet<String>,
382    value_flags: &BTreeSet<String>,
383) -> String {
384    let mut parts = Vec::new();
385    let mut index = 1;
386    while index < args.len() {
387        let arg = args[index].as_ref();
388        if arg == "--schema" {
389            index += 1;
390            continue;
391        }
392        if arg.starts_with('-') {
393            if bool_flags.contains(arg) || arg.contains('=') {
394                index += 1;
395                continue;
396            }
397            if value_flags.contains(arg)
398                || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-'))
399            {
400                index += 2;
401                continue;
402            }
403            index += 1;
404            continue;
405        }
406        parts.push(arg.to_owned());
407        index += 1;
408    }
409    parts.join(":")
410}
411
412#[must_use]
413/// Reports whether raw args contain a true `--schema` flag.
414pub fn has_true_schema_flag(args: &[impl AsRef<str>]) -> bool {
415    for arg in args {
416        let arg = arg.as_ref();
417        if arg == "--schema" {
418            return true;
419        }
420        if let Some(value) = arg.strip_prefix("--schema=") {
421            return parse_compat_bool(value).unwrap_or(false);
422        }
423    }
424    false
425}
426
427fn compat_bool_value_parser() -> ValueParser {
428    ValueParser::new(parse_compat_bool)
429}
430
431fn parse_compat_bool(raw: &str) -> Result<bool, String> {
432    match raw {
433        "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
434        "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
435        _ => Err(format!("invalid boolean value {raw:?}")),
436    }
437}
438
439#[must_use]
440/// Derives flag names that do not consume the following token.
441pub fn derive_bool_flags(command: &Command) -> BTreeSet<String> {
442    let mut flags = BTreeSet::from([
443        "--help".to_owned(),
444        "-h".to_owned(),
445        "--verbose".to_owned(),
446        "--debug".to_owned(),
447    ]);
448    collect_flag_names(command, &mut |arg, name| {
449        if !arg_requires_value(arg) {
450            flags.insert(name);
451        }
452    });
453    flags
454}
455
456#[must_use]
457/// Derives flag names that consume the following token.
458pub fn derive_value_flags(command: &Command) -> BTreeSet<String> {
459    let mut flags = BTreeSet::new();
460    collect_flag_names(command, &mut |arg, name| {
461        if arg_requires_value(arg) {
462            flags.insert(name);
463        }
464    });
465    flags
466}
467
468fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) {
469    for arg in command.get_arguments() {
470        if arg.is_positional() {
471            continue;
472        }
473        if let Some(long) = arg.get_long() {
474            visit(arg, format!("--{long}"));
475        }
476        if let Some(short) = arg.get_short() {
477            visit(arg, format!("-{short}"));
478        }
479    }
480    for child in command.get_subcommands() {
481        collect_flag_names(child, visit);
482    }
483}
484
485/// Reports whether a `--debug` pattern enables a named component.
486///
487/// The pattern is a comma-separated list of tokens applied left to right, so
488/// later tokens override earlier ones:
489///
490/// - `*` enables every component; `-*` disables every component.
491/// - `name` enables that component; `-name` disables it.
492/// - whitespace around tokens is ignored and matching is case-insensitive.
493///
494/// An empty pattern enables nothing. Tokens that name other components are
495/// ignored for the queried `component`.
496///
497/// # Examples
498///
499/// ```
500/// use cli_engine::debug_component_enabled;
501///
502/// assert!(debug_component_enabled("*", "transport"));
503/// assert!(debug_component_enabled("transport", "transport"));
504/// assert!(!debug_component_enabled("*,-transport", "transport"));
505/// assert!(debug_component_enabled("*,-auth", "transport"));
506/// assert!(!debug_component_enabled("", "transport"));
507/// ```
508#[must_use]
509pub fn debug_component_enabled(pattern: &str, component: &str) -> bool {
510    let component = component.trim().to_ascii_lowercase();
511    // Fail closed: an empty component name is never enabled, not even by `*`.
512    if component.is_empty() {
513        return false;
514    }
515    let mut enabled = false;
516    for raw in pattern.split(',') {
517        let token = raw.trim();
518        if token.is_empty() {
519            continue;
520        }
521        let (negated, name) = token
522            .strip_prefix('-')
523            .map_or((false, token), |rest| (true, rest));
524        let name = name.trim().to_ascii_lowercase();
525        if name == "*" || name == component {
526            enabled = !negated;
527        }
528    }
529    enabled
530}
531
532fn arg_requires_value(arg: &Arg) -> bool {
533    match arg.get_action() {
534        ArgAction::Set | ArgAction::Append => arg
535            .get_num_args()
536            .is_none_or(|range| range.takes_values() && range.min_values() > 0),
537        ArgAction::SetTrue
538        | ArgAction::SetFalse
539        | ArgAction::Count
540        | ArgAction::Help
541        | ArgAction::HelpShort
542        | ArgAction::HelpLong
543        | ArgAction::Version => false,
544        _ => arg
545            .get_num_args()
546            .is_some_and(|range| range.takes_values() && range.min_values() > 0),
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::{debug_component_enabled, output_env_var, resolve_default_output_format};
553
554    #[test]
555    fn debug_component_matcher_handles_wildcards_and_negation() {
556        // Empty pattern enables nothing.
557        assert!(!debug_component_enabled("", "transport"));
558        // Wildcard enables everything.
559        assert!(debug_component_enabled("*", "transport"));
560        assert!(debug_component_enabled("*", "auth"));
561        // Bare name enables only that component.
562        assert!(debug_component_enabled("transport", "transport"));
563        assert!(!debug_component_enabled("transport", "auth"));
564        // Negation after a wildcard removes one component but keeps the rest.
565        assert!(!debug_component_enabled("*,-transport", "transport"));
566        assert!(debug_component_enabled("*,-auth", "transport"));
567        // `-*` disables everything; later tokens still win.
568        assert!(!debug_component_enabled("*,-*", "transport"));
569        assert!(debug_component_enabled("-*,transport", "transport"));
570        // Whitespace and case are ignored.
571        assert!(debug_component_enabled(" Transport , -auth ", "transport"));
572        // An empty component fails closed, even against a wildcard.
573        assert!(!debug_component_enabled("*", ""));
574        assert!(!debug_component_enabled("*", "   "));
575    }
576
577    #[test]
578    fn default_output_format_follows_env_override_then_tty() {
579        // TTY policy when no env override.
580        assert_eq!(resolve_default_output_format(None, true), "human");
581        assert_eq!(resolve_default_output_format(None, false), "json");
582        // A valid env override wins over the TTY policy in both directions.
583        assert_eq!(resolve_default_output_format(Some("json"), true), "json");
584        assert_eq!(resolve_default_output_format(Some("human"), false), "human");
585        // Env override is case-insensitive (env vars are commonly upper-cased).
586        assert_eq!(resolve_default_output_format(Some("JSON"), true), "json");
587        assert_eq!(
588            resolve_default_output_format(Some(" Human "), false),
589            "human"
590        );
591        // Blank or unrecognized env overrides are ignored (fall back to TTY).
592        assert_eq!(resolve_default_output_format(Some("   "), false), "json");
593        assert_eq!(resolve_default_output_format(Some(""), true), "human");
594        assert_eq!(resolve_default_output_format(Some("yaml"), false), "json");
595        assert_eq!(resolve_default_output_format(Some("yaml"), true), "human");
596    }
597
598    #[test]
599    fn output_env_var_is_derived_from_app_id() {
600        assert_eq!(output_env_var("godaddy"), "GODADDY_OUTPUT");
601        assert_eq!(output_env_var("gdx"), "GDX_OUTPUT");
602        assert_eq!(output_env_var("my-cli"), "MY_CLI_OUTPUT");
603    }
604}