1use std::collections::BTreeSet;
2use std::io::IsTerminal;
3
4use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
5
6#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct GlobalFlags {
12 pub output_format: String,
14 pub verbose: String,
16 pub dry_run: bool,
18 pub fields: String,
20 pub filter: String,
22 pub expr: String,
24 pub limit: i64,
26 pub offset: i64,
28 pub schema: bool,
30 pub reason: String,
32 pub timeout: String,
34 pub debug: String,
36 pub search: String,
38 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
63pub 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#[must_use]
215pub fn resolve_default_output_format(env_override: Option<&str>, is_tty: bool) -> String {
216 if let Some(value) = env_override {
217 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#[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#[must_use]
252pub fn output_env_var(app_id: &str) -> String {
253 format!("{}_OUTPUT", app_id_env_prefix(app_id))
254}
255
256#[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]
267pub 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]
330pub 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]
347pub 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]
378pub 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]
413pub 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]
440pub 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]
457pub 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#[must_use]
509pub fn debug_component_enabled(pattern: &str, component: &str) -> bool {
510 let component = component.trim().to_ascii_lowercase();
511 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 assert!(!debug_component_enabled("", "transport"));
558 assert!(debug_component_enabled("*", "transport"));
560 assert!(debug_component_enabled("*", "auth"));
561 assert!(debug_component_enabled("transport", "transport"));
563 assert!(!debug_component_enabled("transport", "auth"));
564 assert!(!debug_component_enabled("*,-transport", "transport"));
566 assert!(debug_component_enabled("*,-auth", "transport"));
567 assert!(!debug_component_enabled("*,-*", "transport"));
569 assert!(debug_component_enabled("-*,transport", "transport"));
570 assert!(debug_component_enabled(" Transport , -auth ", "transport"));
572 assert!(!debug_component_enabled("*", ""));
574 assert!(!debug_component_enabled("*", " "));
575 }
576
577 #[test]
578 fn default_output_format_follows_env_override_then_tty() {
579 assert_eq!(resolve_default_output_format(None, true), "human");
581 assert_eq!(resolve_default_output_format(None, false), "json");
582 assert_eq!(resolve_default_output_format(Some("json"), true), "json");
584 assert_eq!(resolve_default_output_format(Some("human"), false), "human");
585 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 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}