Skip to main content

cli_engine/
flags.rs

1use std::collections::BTreeSet;
2
3use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
4
5/// Parsed framework-global flags.
6///
7/// Applications can add their own global flags, but these are the built-in
8/// controls understood by middleware and the output pipeline.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct GlobalFlags {
11    /// Output format: `json`, `human`, or `toon`.
12    pub output_format: String,
13    /// Metadata verbosity selector.
14    pub verbose: String,
15    /// Whether mutating commands should short-circuit.
16    pub dry_run: bool,
17    /// Field projection.
18    pub fields: String,
19    /// JMESPath per-item filter.
20    pub filter: String,
21    /// JMESPath whole-result expression.
22    pub expr: String,
23    /// Client-side page size.
24    pub limit: i64,
25    /// Client-side page offset.
26    pub offset: i64,
27    /// Whether schema rendering was requested.
28    pub schema: bool,
29    /// User-provided command reason.
30    pub reason: String,
31    /// Raw timeout string.
32    pub timeout: String,
33    /// Debug selector.
34    pub debug: String,
35    /// Search query.
36    pub search: String,
37}
38
39impl Default for GlobalFlags {
40    fn default() -> Self {
41        Self {
42            output_format: "json".to_owned(),
43            verbose: String::new(),
44            dry_run: false,
45            fields: String::new(),
46            filter: String::new(),
47            expr: String::new(),
48            limit: 0,
49            offset: 0,
50            schema: false,
51            reason: String::new(),
52            timeout: "0s".to_owned(),
53            debug: String::new(),
54            search: String::new(),
55        }
56    }
57}
58
59/// Registers framework-global flags on a `clap` command.
60pub fn register_global_flags(command: Command) -> Command {
61    command
62        .arg(
63            Arg::new("output")
64                .long("output")
65                .short('o')
66                .global(true)
67                .value_name("FORMAT")
68                .default_value("json")
69                .help("Output format: toon|json|human"),
70        )
71        .arg(
72            Arg::new("verbose")
73                .long("verbose")
74                .global(true)
75                .num_args(0..=1)
76                .default_missing_value("all")
77                .value_name("FIELDS")
78                .help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"),
79        )
80        .arg(
81            Arg::new("dry-run")
82                .long("dry-run")
83                .global(true)
84                .num_args(0..=1)
85                .require_equals(true)
86                .default_missing_value("true")
87                .default_value("false")
88                .value_parser(compat_bool_value_parser())
89                .help("Preview mutations without executing"),
90        )
91        .arg(
92            Arg::new("fields")
93                .long("fields")
94                .global(true)
95                .value_name("FIELDS")
96                .help("Comma-separated fields to include in output (use 'all' or '*' for everything)"),
97        )
98        .arg(
99            Arg::new("filter")
100                .long("filter")
101                .global(true)
102                .value_name("EXPR")
103                .help("Per-item JMESPath predicate for list data"),
104        )
105        .arg(
106            Arg::new("expr")
107                .long("expr")
108                .global(true)
109                .value_name("EXPR")
110                .help("JMESPath query applied to the whole result"),
111        )
112        .arg(
113            Arg::new("limit")
114                .long("limit")
115                .global(true)
116                .value_parser(value_parser!(i64))
117                .allow_hyphen_values(true)
118                .default_value("0")
119                .help("Max items to return (client-side, 0=all)"),
120        )
121        .arg(
122            Arg::new("offset")
123                .long("offset")
124                .global(true)
125                .value_parser(value_parser!(i64))
126                .allow_hyphen_values(true)
127                .default_value("0")
128                .help("Skip N items before applying limit"),
129        )
130        .arg(
131            Arg::new("schema")
132                .long("schema")
133                .global(true)
134                .num_args(0..=1)
135                .require_equals(true)
136                .default_missing_value("true")
137                .default_value("false")
138                .value_parser(compat_bool_value_parser())
139                .help("Dump output field metadata instead of running the command"),
140        )
141        .arg(
142            Arg::new("reason")
143                .long("reason")
144                .global(true)
145                .value_name("TEXT")
146                .help("Short explanation of why this command is being run (required for destructive commands)"),
147        )
148        .arg(
149            Arg::new("timeout")
150                .long("timeout")
151                .global(true)
152                .allow_hyphen_values(true)
153                .default_value("0s")
154                .value_name("DURATION")
155                .help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"),
156        )
157        .arg(
158            Arg::new("debug")
159                .long("debug")
160                .global(true)
161                .num_args(0..=1)
162                .default_missing_value("*")
163                .value_name("PATTERN")
164                .help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"),
165        )
166        .arg(
167            Arg::new("search")
168                .long("search")
169                .global(true)
170                .value_name("KEYWORD")
171                .help("Search commands and guides by keyword"),
172        )
173        .arg(
174            Arg::new("json")
175                .long("json")
176                .global(true)
177                .action(ArgAction::SetTrue)
178                .help("Shorthand for --output json"),
179        )
180        .arg(
181            Arg::new("toon")
182                .long("toon")
183                .global(true)
184                .action(ArgAction::SetTrue)
185                .help("Shorthand for --output toon"),
186        )
187        .arg(
188            Arg::new("human")
189                .long("human")
190                .global(true)
191                .action(ArgAction::SetTrue)
192                .help("Shorthand for --output human"),
193        )
194}
195
196#[must_use]
197/// Extracts framework-global flags from parsed `clap` matches.
198pub fn global_flags_from_matches(matches: &ArgMatches) -> GlobalFlags {
199    let output_format = if matches.get_flag("toon") {
200        "toon".to_owned()
201    } else if matches.get_flag("human") {
202        "human".to_owned()
203    } else {
204        matches
205            .get_one::<String>("output")
206            .cloned()
207            .unwrap_or_else(|| "json".to_owned())
208    };
209
210    GlobalFlags {
211        output_format,
212        verbose: matches
213            .get_one::<String>("verbose")
214            .cloned()
215            .unwrap_or_default(),
216        dry_run: matches.get_one::<bool>("dry-run").copied().unwrap_or(false),
217        fields: matches
218            .get_one::<String>("fields")
219            .cloned()
220            .unwrap_or_default(),
221        filter: matches
222            .get_one::<String>("filter")
223            .cloned()
224            .unwrap_or_default(),
225        expr: matches
226            .get_one::<String>("expr")
227            .cloned()
228            .unwrap_or_default(),
229        limit: matches.get_one::<i64>("limit").copied().unwrap_or(0),
230        offset: matches.get_one::<i64>("offset").copied().unwrap_or(0),
231        schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
232        reason: matches
233            .get_one::<String>("reason")
234            .cloned()
235            .unwrap_or_default(),
236        timeout: matches
237            .get_one::<String>("timeout")
238            .cloned()
239            .unwrap_or_else(|| "0s".to_owned()),
240        debug: matches
241            .get_one::<String>("debug")
242            .cloned()
243            .unwrap_or_default(),
244        search: matches
245            .get_one::<String>("search")
246            .cloned()
247            .unwrap_or_default(),
248    }
249}
250
251#[must_use]
252/// Extracts `--search` from raw args before normal parsing.
253pub fn extract_search_query(args: &[impl AsRef<str>]) -> String {
254    for index in 0..args.len() {
255        let arg = args[index].as_ref();
256        if arg == "--search" {
257            return args
258                .get(index + 1)
259                .map_or_else(String::new, |value| value.as_ref().to_owned());
260        }
261        if let Some(value) = arg.strip_prefix("--search=") {
262            return value.to_owned();
263        }
264    }
265    String::new()
266}
267
268#[must_use]
269/// Extracts output format from raw args.
270///
271/// Recognizes `--output <format>` / `-o <format>` / `--output=<format>`,
272/// plus `--json`, `--toon`, and `--human` as shorthand for their respective formats.
273pub fn extract_output_format(args: &[impl AsRef<str>]) -> String {
274    for index in 0..args.len() {
275        let arg = args[index].as_ref();
276        if arg == "--output" || arg == "-o" {
277            return args
278                .get(index + 1)
279                .map_or_else(|| "json".to_owned(), |value| value.as_ref().to_owned());
280        }
281        if let Some(value) = arg.strip_prefix("--output=") {
282            return value.to_owned();
283        }
284        if arg == "--json" {
285            return "json".to_owned();
286        }
287        if arg == "--toon" {
288            return "toon".to_owned();
289        }
290        if arg == "--human" {
291            return "human".to_owned();
292        }
293    }
294    "json".to_owned()
295}
296
297#[must_use]
298/// Extracts a colon-separated command path from raw args.
299pub fn extract_command_path(
300    args: &[impl AsRef<str>],
301    bool_flags: &BTreeSet<String>,
302    value_flags: &BTreeSet<String>,
303) -> String {
304    let mut parts = Vec::new();
305    let mut index = 1;
306    while index < args.len() {
307        let arg = args[index].as_ref();
308        if arg == "--schema" {
309            index += 1;
310            continue;
311        }
312        if arg.starts_with('-') {
313            if bool_flags.contains(arg) || arg.contains('=') {
314                index += 1;
315                continue;
316            }
317            if value_flags.contains(arg)
318                || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-'))
319            {
320                index += 2;
321                continue;
322            }
323            index += 1;
324            continue;
325        }
326        parts.push(arg.to_owned());
327        index += 1;
328    }
329    parts.join(":")
330}
331
332#[must_use]
333/// Reports whether raw args contain a true `--schema` flag.
334pub fn has_true_schema_flag(args: &[impl AsRef<str>]) -> bool {
335    for arg in args {
336        let arg = arg.as_ref();
337        if arg == "--schema" {
338            return true;
339        }
340        if let Some(value) = arg.strip_prefix("--schema=") {
341            return parse_compat_bool(value).unwrap_or(false);
342        }
343    }
344    false
345}
346
347fn compat_bool_value_parser() -> ValueParser {
348    ValueParser::new(parse_compat_bool)
349}
350
351fn parse_compat_bool(raw: &str) -> Result<bool, String> {
352    match raw {
353        "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
354        "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
355        _ => Err(format!("invalid boolean value {raw:?}")),
356    }
357}
358
359#[must_use]
360/// Derives flag names that do not consume the following token.
361pub fn derive_bool_flags(command: &Command) -> BTreeSet<String> {
362    let mut flags = BTreeSet::from([
363        "--help".to_owned(),
364        "-h".to_owned(),
365        "--verbose".to_owned(),
366        "--debug".to_owned(),
367    ]);
368    collect_flag_names(command, &mut |arg, name| {
369        if !arg_requires_value(arg) {
370            flags.insert(name);
371        }
372    });
373    flags
374}
375
376#[must_use]
377/// Derives flag names that consume the following token.
378pub fn derive_value_flags(command: &Command) -> BTreeSet<String> {
379    let mut flags = BTreeSet::new();
380    collect_flag_names(command, &mut |arg, name| {
381        if arg_requires_value(arg) {
382            flags.insert(name);
383        }
384    });
385    flags
386}
387
388fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) {
389    for arg in command.get_arguments() {
390        if arg.is_positional() {
391            continue;
392        }
393        if let Some(long) = arg.get_long() {
394            visit(arg, format!("--{long}"));
395        }
396        if let Some(short) = arg.get_short() {
397            visit(arg, format!("-{short}"));
398        }
399    }
400    for child in command.get_subcommands() {
401        collect_flag_names(child, visit);
402    }
403}
404
405fn arg_requires_value(arg: &Arg) -> bool {
406    match arg.get_action() {
407        ArgAction::Set | ArgAction::Append => arg
408            .get_num_args()
409            .is_none_or(|range| range.takes_values() && range.min_values() > 0),
410        ArgAction::SetTrue
411        | ArgAction::SetFalse
412        | ArgAction::Count
413        | ArgAction::Help
414        | ArgAction::HelpShort
415        | ArgAction::HelpLong
416        | ArgAction::Version => false,
417        _ => arg
418            .get_num_args()
419            .is_some_and(|range| range.takes_values() && range.min_values() > 0),
420    }
421}