1use std::collections::BTreeSet;
2
3use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
4
5#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct GlobalFlags {
11 pub output_format: String,
13 pub verbose: String,
15 pub dry_run: bool,
17 pub fields: String,
19 pub filter: String,
21 pub expr: String,
23 pub limit: i64,
25 pub offset: i64,
27 pub schema: bool,
29 pub reason: String,
31 pub timeout: String,
33 pub debug: String,
35 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
59pub 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]
197pub 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]
252pub 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]
269pub 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]
298pub 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]
333pub 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]
360pub 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]
377pub 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}