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: "60s".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("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]
176pub 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]
223pub 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]
240pub 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]
257pub 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]
292pub 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]
319pub 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]
336pub 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}