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}
39
40impl Default for GlobalFlags {
41    fn default() -> Self {
42        Self {
43            output_format: "json".to_owned(),
44            verbose: String::new(),
45            dry_run: false,
46            fields: String::new(),
47            filter: String::new(),
48            expr: String::new(),
49            limit: 0,
50            offset: 0,
51            schema: false,
52            reason: String::new(),
53            timeout: "0s".to_owned(),
54            debug: String::new(),
55            search: String::new(),
56        }
57    }
58}
59
60/// Registers framework-global flags on a `clap` command.
61pub fn register_global_flags(command: Command) -> Command {
62    command
63        .arg(
64            Arg::new("output")
65                .long("output")
66                .short('o')
67                .global(true)
68                .value_name("FORMAT")
69                .default_value("json")
70                .help("Output format: toon|json|human"),
71        )
72        .arg(
73            Arg::new("verbose")
74                .long("verbose")
75                .global(true)
76                .num_args(0..=1)
77                .default_missing_value("all")
78                .value_name("FIELDS")
79                .help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"),
80        )
81        .arg(
82            Arg::new("dry-run")
83                .long("dry-run")
84                .global(true)
85                .num_args(0..=1)
86                .require_equals(true)
87                .default_missing_value("true")
88                .default_value("false")
89                .value_parser(compat_bool_value_parser())
90                .help("Preview mutations without executing"),
91        )
92        .arg(
93            Arg::new("fields")
94                .long("fields")
95                .global(true)
96                .value_name("FIELDS")
97                .help("Comma-separated fields to include in output (use 'all' or '*' for everything)"),
98        )
99        .arg(
100            Arg::new("filter")
101                .long("filter")
102                .global(true)
103                .value_name("EXPR")
104                .help("Per-item JMESPath predicate for list data"),
105        )
106        .arg(
107            Arg::new("expr")
108                .long("expr")
109                .global(true)
110                .value_name("EXPR")
111                .help("JMESPath query applied to the whole result"),
112        )
113        .arg(
114            Arg::new("limit")
115                .long("limit")
116                .global(true)
117                .value_parser(value_parser!(i64))
118                .allow_hyphen_values(true)
119                .default_value("0")
120                .help("Max items to return (client-side, 0=all)"),
121        )
122        .arg(
123            Arg::new("offset")
124                .long("offset")
125                .global(true)
126                .value_parser(value_parser!(i64))
127                .allow_hyphen_values(true)
128                .default_value("0")
129                .help("Skip N items before applying limit"),
130        )
131        .arg(
132            Arg::new("schema")
133                .long("schema")
134                .global(true)
135                .num_args(0..=1)
136                .require_equals(true)
137                .default_missing_value("true")
138                .default_value("false")
139                .value_parser(compat_bool_value_parser())
140                .help("Dump output field metadata instead of running the command"),
141        )
142        .arg(
143            Arg::new("reason")
144                .long("reason")
145                .global(true)
146                .value_name("TEXT")
147                .help("Short explanation of why this command is being run (required for destructive commands)"),
148        )
149        .arg(
150            Arg::new("timeout")
151                .long("timeout")
152                .global(true)
153                .allow_hyphen_values(true)
154                .default_value("0s")
155                .value_name("DURATION")
156                .help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"),
157        )
158        .arg(
159            Arg::new("debug")
160                .long("debug")
161                .global(true)
162                .num_args(0..=1)
163                .default_missing_value("*")
164                .value_name("PATTERN")
165                .help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"),
166        )
167        .arg(
168            Arg::new("search")
169                .long("search")
170                .global(true)
171                .value_name("KEYWORD")
172                .help("Search commands and guides by keyword"),
173        )
174        .arg(
175            Arg::new("json")
176                .long("json")
177                .global(true)
178                .action(ArgAction::SetTrue)
179                .help("Shorthand for --output json"),
180        )
181        .arg(
182            Arg::new("toon")
183                .long("toon")
184                .global(true)
185                .action(ArgAction::SetTrue)
186                .help("Shorthand for --output toon"),
187        )
188        .arg(
189            Arg::new("human")
190                .long("human")
191                .global(true)
192                .action(ArgAction::SetTrue)
193                .help("Shorthand for --output human"),
194        )
195}
196
197/// Resolves the default output format when the user gave no explicit format.
198///
199/// Precedence here is env-override first, then a TTY policy: an interactive
200/// terminal gets human-friendly output, everything else (pipes, files, CI,
201/// most agents) gets machine-readable JSON. Pure so it can be unit-tested
202/// without a real terminal.
203#[must_use]
204pub fn resolve_default_output_format(env_override: Option<&str>, is_tty: bool) -> String {
205    if let Some(value) = env_override {
206        // Normalize case (env vars are commonly upper/mixed case) and ignore
207        // blank or unrecognized values, so a stray or miscased override can't
208        // break all command output — only a valid format is honored.
209        let normalized = value.trim().to_ascii_lowercase();
210        if crate::output::is_valid_output_format(&normalized) {
211            return normalized;
212        }
213    }
214    if is_tty { "human" } else { "json" }.to_owned()
215}
216
217/// Derives the per-application output-format override env var from an app id,
218/// e.g. `godaddy` -> `GODADDY_OUTPUT`, `gdx` -> `GDX_OUTPUT`.
219#[must_use]
220pub fn output_env_var(app_id: &str) -> String {
221    let sanitized: String = app_id
222        .chars()
223        .map(|c| {
224            if c.is_ascii_alphanumeric() {
225                c.to_ascii_uppercase()
226            } else {
227                '_'
228            }
229        })
230        .collect();
231    format!("{sanitized}_OUTPUT")
232}
233
234/// Computes the default output format for `app_id`, consulting the
235/// `${APP_ID}_OUTPUT` env override and whether stdout is an interactive
236/// terminal. Used as the fallback when no explicit `--output`/`--json`/
237/// `--toon`/`--human` is given.
238#[must_use]
239pub fn default_output_format(app_id: &str) -> String {
240    let env = std::env::var(output_env_var(app_id)).ok();
241    resolve_default_output_format(env.as_deref(), std::io::stdout().is_terminal())
242}
243
244#[must_use]
245/// Extracts framework-global flags from parsed `clap` matches, falling back to
246/// `default_format` when the user gave no explicit output format.
247pub fn global_flags_from_matches(matches: &ArgMatches, default_format: &str) -> GlobalFlags {
248    let output_format = if matches.get_flag("toon") {
249        "toon".to_owned()
250    } else if matches.get_flag("human") {
251        "human".to_owned()
252    } else if matches.get_flag("json") {
253        "json".to_owned()
254    } else if matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine) {
255        matches
256            .get_one::<String>("output")
257            .cloned()
258            .unwrap_or_else(|| default_format.to_owned())
259    } else {
260        default_format.to_owned()
261    };
262
263    GlobalFlags {
264        output_format,
265        verbose: matches
266            .get_one::<String>("verbose")
267            .cloned()
268            .unwrap_or_default(),
269        dry_run: matches.get_one::<bool>("dry-run").copied().unwrap_or(false),
270        fields: matches
271            .get_one::<String>("fields")
272            .cloned()
273            .unwrap_or_default(),
274        filter: matches
275            .get_one::<String>("filter")
276            .cloned()
277            .unwrap_or_default(),
278        expr: matches
279            .get_one::<String>("expr")
280            .cloned()
281            .unwrap_or_default(),
282        limit: matches.get_one::<i64>("limit").copied().unwrap_or(0),
283        offset: matches.get_one::<i64>("offset").copied().unwrap_or(0),
284        schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
285        reason: matches
286            .get_one::<String>("reason")
287            .cloned()
288            .unwrap_or_default(),
289        timeout: matches
290            .get_one::<String>("timeout")
291            .cloned()
292            .unwrap_or_else(|| "0s".to_owned()),
293        debug: matches
294            .get_one::<String>("debug")
295            .cloned()
296            .unwrap_or_default(),
297        search: matches
298            .get_one::<String>("search")
299            .cloned()
300            .unwrap_or_default(),
301    }
302}
303
304#[must_use]
305/// Extracts `--search` from raw args before normal parsing.
306pub fn extract_search_query(args: &[impl AsRef<str>]) -> String {
307    for index in 0..args.len() {
308        let arg = args[index].as_ref();
309        if arg == "--search" {
310            return args
311                .get(index + 1)
312                .map_or_else(String::new, |value| value.as_ref().to_owned());
313        }
314        if let Some(value) = arg.strip_prefix("--search=") {
315            return value.to_owned();
316        }
317    }
318    String::new()
319}
320
321#[must_use]
322/// Extracts output format from raw args.
323///
324/// Recognizes `--output <format>` / `-o <format>` / `--output=<format>`,
325/// plus `--json`, `--toon`, and `--human` as shorthand for their respective
326/// formats. Falls back to `default_format` when none is present.
327pub fn extract_output_format(args: &[impl AsRef<str>], default_format: &str) -> String {
328    for index in 0..args.len() {
329        let arg = args[index].as_ref();
330        if arg == "--output" || arg == "-o" {
331            return args.get(index + 1).map_or_else(
332                || default_format.to_owned(),
333                |value| value.as_ref().to_owned(),
334            );
335        }
336        if let Some(value) = arg.strip_prefix("--output=") {
337            return value.to_owned();
338        }
339        if arg == "--json" {
340            return "json".to_owned();
341        }
342        if arg == "--toon" {
343            return "toon".to_owned();
344        }
345        if arg == "--human" {
346            return "human".to_owned();
347        }
348    }
349    default_format.to_owned()
350}
351
352#[must_use]
353/// Extracts a colon-separated command path from raw args.
354pub fn extract_command_path(
355    args: &[impl AsRef<str>],
356    bool_flags: &BTreeSet<String>,
357    value_flags: &BTreeSet<String>,
358) -> String {
359    let mut parts = Vec::new();
360    let mut index = 1;
361    while index < args.len() {
362        let arg = args[index].as_ref();
363        if arg == "--schema" {
364            index += 1;
365            continue;
366        }
367        if arg.starts_with('-') {
368            if bool_flags.contains(arg) || arg.contains('=') {
369                index += 1;
370                continue;
371            }
372            if value_flags.contains(arg)
373                || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-'))
374            {
375                index += 2;
376                continue;
377            }
378            index += 1;
379            continue;
380        }
381        parts.push(arg.to_owned());
382        index += 1;
383    }
384    parts.join(":")
385}
386
387#[must_use]
388/// Reports whether raw args contain a true `--schema` flag.
389pub fn has_true_schema_flag(args: &[impl AsRef<str>]) -> bool {
390    for arg in args {
391        let arg = arg.as_ref();
392        if arg == "--schema" {
393            return true;
394        }
395        if let Some(value) = arg.strip_prefix("--schema=") {
396            return parse_compat_bool(value).unwrap_or(false);
397        }
398    }
399    false
400}
401
402fn compat_bool_value_parser() -> ValueParser {
403    ValueParser::new(parse_compat_bool)
404}
405
406fn parse_compat_bool(raw: &str) -> Result<bool, String> {
407    match raw {
408        "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
409        "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
410        _ => Err(format!("invalid boolean value {raw:?}")),
411    }
412}
413
414#[must_use]
415/// Derives flag names that do not consume the following token.
416pub fn derive_bool_flags(command: &Command) -> BTreeSet<String> {
417    let mut flags = BTreeSet::from([
418        "--help".to_owned(),
419        "-h".to_owned(),
420        "--verbose".to_owned(),
421        "--debug".to_owned(),
422    ]);
423    collect_flag_names(command, &mut |arg, name| {
424        if !arg_requires_value(arg) {
425            flags.insert(name);
426        }
427    });
428    flags
429}
430
431#[must_use]
432/// Derives flag names that consume the following token.
433pub fn derive_value_flags(command: &Command) -> BTreeSet<String> {
434    let mut flags = BTreeSet::new();
435    collect_flag_names(command, &mut |arg, name| {
436        if arg_requires_value(arg) {
437            flags.insert(name);
438        }
439    });
440    flags
441}
442
443fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) {
444    for arg in command.get_arguments() {
445        if arg.is_positional() {
446            continue;
447        }
448        if let Some(long) = arg.get_long() {
449            visit(arg, format!("--{long}"));
450        }
451        if let Some(short) = arg.get_short() {
452            visit(arg, format!("-{short}"));
453        }
454    }
455    for child in command.get_subcommands() {
456        collect_flag_names(child, visit);
457    }
458}
459
460fn arg_requires_value(arg: &Arg) -> bool {
461    match arg.get_action() {
462        ArgAction::Set | ArgAction::Append => arg
463            .get_num_args()
464            .is_none_or(|range| range.takes_values() && range.min_values() > 0),
465        ArgAction::SetTrue
466        | ArgAction::SetFalse
467        | ArgAction::Count
468        | ArgAction::Help
469        | ArgAction::HelpShort
470        | ArgAction::HelpLong
471        | ArgAction::Version => false,
472        _ => arg
473            .get_num_args()
474            .is_some_and(|range| range.takes_values() && range.min_values() > 0),
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::{output_env_var, resolve_default_output_format};
481
482    #[test]
483    fn default_output_format_follows_env_override_then_tty() {
484        // TTY policy when no env override.
485        assert_eq!(resolve_default_output_format(None, true), "human");
486        assert_eq!(resolve_default_output_format(None, false), "json");
487        // A valid env override wins over the TTY policy in both directions.
488        assert_eq!(resolve_default_output_format(Some("json"), true), "json");
489        assert_eq!(resolve_default_output_format(Some("human"), false), "human");
490        // Env override is case-insensitive (env vars are commonly upper-cased).
491        assert_eq!(resolve_default_output_format(Some("JSON"), true), "json");
492        assert_eq!(
493            resolve_default_output_format(Some(" Human "), false),
494            "human"
495        );
496        // Blank or unrecognized env overrides are ignored (fall back to TTY).
497        assert_eq!(resolve_default_output_format(Some("   "), false), "json");
498        assert_eq!(resolve_default_output_format(Some(""), true), "human");
499        assert_eq!(resolve_default_output_format(Some("yaml"), false), "json");
500        assert_eq!(resolve_default_output_format(Some("yaml"), true), "human");
501    }
502
503    #[test]
504    fn output_env_var_is_derived_from_app_id() {
505        assert_eq!(output_env_var("godaddy"), "GODADDY_OUTPUT");
506        assert_eq!(output_env_var("gdx"), "GDX_OUTPUT");
507        assert_eq!(output_env_var("my-cli"), "MY_CLI_OUTPUT");
508    }
509}