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: "60s".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("60s")
154                .value_name("DURATION")
155                .help("Overall command timeout (e.g. 60s, 5m); use 0s to disable"),
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}
174
175#[must_use]
176/// Extracts framework-global flags from parsed `clap` matches.
177pub fn global_flags_from_matches(matches: &ArgMatches) -> GlobalFlags {
178    GlobalFlags {
179        output_format: matches
180            .get_one::<String>("output")
181            .cloned()
182            .unwrap_or_else(|| "json".to_owned()),
183        verbose: matches
184            .get_one::<String>("verbose")
185            .cloned()
186            .unwrap_or_default(),
187        dry_run: matches.get_one::<bool>("dry-run").copied().unwrap_or(false),
188        fields: matches
189            .get_one::<String>("fields")
190            .cloned()
191            .unwrap_or_default(),
192        filter: matches
193            .get_one::<String>("filter")
194            .cloned()
195            .unwrap_or_default(),
196        expr: matches
197            .get_one::<String>("expr")
198            .cloned()
199            .unwrap_or_default(),
200        limit: matches.get_one::<i64>("limit").copied().unwrap_or(0),
201        offset: matches.get_one::<i64>("offset").copied().unwrap_or(0),
202        schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
203        reason: matches
204            .get_one::<String>("reason")
205            .cloned()
206            .unwrap_or_default(),
207        timeout: matches
208            .get_one::<String>("timeout")
209            .cloned()
210            .unwrap_or_else(|| "60s".to_owned()),
211        debug: matches
212            .get_one::<String>("debug")
213            .cloned()
214            .unwrap_or_default(),
215        search: matches
216            .get_one::<String>("search")
217            .cloned()
218            .unwrap_or_default(),
219    }
220}
221
222#[must_use]
223/// Extracts `--search` from raw args before normal parsing.
224pub fn extract_search_query(args: &[impl AsRef<str>]) -> String {
225    for index in 0..args.len() {
226        let arg = args[index].as_ref();
227        if arg == "--search" {
228            return args
229                .get(index + 1)
230                .map_or_else(String::new, |value| value.as_ref().to_owned());
231        }
232        if let Some(value) = arg.strip_prefix("--search=") {
233            return value.to_owned();
234        }
235    }
236    String::new()
237}
238
239#[must_use]
240/// Extracts `--output`/`-o` from raw args before normal parsing.
241pub fn extract_output_format(args: &[impl AsRef<str>]) -> String {
242    for index in 0..args.len() {
243        let arg = args[index].as_ref();
244        if arg == "--output" || arg == "-o" {
245            return args
246                .get(index + 1)
247                .map_or_else(|| "json".to_owned(), |value| value.as_ref().to_owned());
248        }
249        if let Some(value) = arg.strip_prefix("--output=") {
250            return value.to_owned();
251        }
252    }
253    "json".to_owned()
254}
255
256#[must_use]
257/// Extracts a colon-separated command path from raw args.
258pub fn extract_command_path(
259    args: &[impl AsRef<str>],
260    bool_flags: &BTreeSet<String>,
261    value_flags: &BTreeSet<String>,
262) -> String {
263    let mut parts = Vec::new();
264    let mut index = 1;
265    while index < args.len() {
266        let arg = args[index].as_ref();
267        if arg == "--schema" {
268            index += 1;
269            continue;
270        }
271        if arg.starts_with('-') {
272            if bool_flags.contains(arg) || arg.contains('=') {
273                index += 1;
274                continue;
275            }
276            if value_flags.contains(arg)
277                || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-'))
278            {
279                index += 2;
280                continue;
281            }
282            index += 1;
283            continue;
284        }
285        parts.push(arg.to_owned());
286        index += 1;
287    }
288    parts.join(":")
289}
290
291#[must_use]
292/// Reports whether raw args contain a true `--schema` flag.
293pub fn has_true_schema_flag(args: &[impl AsRef<str>]) -> bool {
294    for arg in args {
295        let arg = arg.as_ref();
296        if arg == "--schema" {
297            return true;
298        }
299        if let Some(value) = arg.strip_prefix("--schema=") {
300            return parse_compat_bool(value).unwrap_or(false);
301        }
302    }
303    false
304}
305
306fn compat_bool_value_parser() -> ValueParser {
307    ValueParser::new(parse_compat_bool)
308}
309
310fn parse_compat_bool(raw: &str) -> Result<bool, String> {
311    match raw {
312        "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
313        "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
314        _ => Err(format!("invalid boolean value {raw:?}")),
315    }
316}
317
318#[must_use]
319/// Derives flag names that do not consume the following token.
320pub fn derive_bool_flags(command: &Command) -> BTreeSet<String> {
321    let mut flags = BTreeSet::from([
322        "--help".to_owned(),
323        "-h".to_owned(),
324        "--verbose".to_owned(),
325        "--debug".to_owned(),
326    ]);
327    collect_flag_names(command, &mut |arg, name| {
328        if !arg_requires_value(arg) {
329            flags.insert(name);
330        }
331    });
332    flags
333}
334
335#[must_use]
336/// Derives flag names that consume the following token.
337pub fn derive_value_flags(command: &Command) -> BTreeSet<String> {
338    let mut flags = BTreeSet::new();
339    collect_flag_names(command, &mut |arg, name| {
340        if arg_requires_value(arg) {
341            flags.insert(name);
342        }
343    });
344    flags
345}
346
347fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) {
348    for arg in command.get_arguments() {
349        if arg.is_positional() {
350            continue;
351        }
352        if let Some(long) = arg.get_long() {
353            visit(arg, format!("--{long}"));
354        }
355        if let Some(short) = arg.get_short() {
356            visit(arg, format!("-{short}"));
357        }
358    }
359    for child in command.get_subcommands() {
360        collect_flag_names(child, visit);
361    }
362}
363
364fn arg_requires_value(arg: &Arg) -> bool {
365    match arg.get_action() {
366        ArgAction::Set | ArgAction::Append => arg
367            .get_num_args()
368            .is_none_or(|range| range.takes_values() && range.min_values() > 0),
369        ArgAction::SetTrue
370        | ArgAction::SetFalse
371        | ArgAction::Count
372        | ArgAction::Help
373        | ArgAction::HelpShort
374        | ArgAction::HelpLong
375        | ArgAction::Version => false,
376        _ => arg
377            .get_num_args()
378            .is_some_and(|range| range.takes_values() && range.min_values() > 0),
379    }
380}