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}
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
60pub 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#[must_use]
204pub fn resolve_default_output_format(env_override: Option<&str>, is_tty: bool) -> String {
205 if let Some(value) = env_override {
206 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#[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#[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]
245pub 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]
305pub 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]
322pub 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]
353pub 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]
388pub 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]
415pub 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]
432pub 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 assert_eq!(resolve_default_output_format(None, true), "human");
486 assert_eq!(resolve_default_output_format(None, false), "json");
487 assert_eq!(resolve_default_output_format(Some("json"), true), "json");
489 assert_eq!(resolve_default_output_format(Some("human"), false), "human");
490 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 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}