1use std::ffi::OsString;
41
42use clap::parser::ValueSource;
43use clap::{Arg, ArgAction, ArgMatches, Command, Parser};
44
45const NO_DEFAULTS: &str = "no-defaults";
47
48pub fn parse_with_defaults<T: Parser>(tool: &str) -> T {
54 let argv: Vec<OsString> = std::env::args_os().collect();
55 let cmd = augment_command(T::command());
56
57 let matches = match cmd.clone().try_get_matches_from(&argv) {
58 Ok(m) => m,
59 Err(e) => e.exit(),
61 };
62
63 let final_matches = if matches.get_flag(NO_DEFAULTS) {
64 matches
65 } else {
66 match load_defaults(tool) {
67 Ok(None) => matches,
68 Ok(Some((table, sources))) => {
69 let extra = match plan_injections(&cmd, &matches, &table) {
70 Ok(extra) => extra,
71 Err(e) => fail(&sources, &e),
72 };
73 if extra.is_empty() {
74 matches
75 } else {
76 let mut full = argv;
77 full.extend(extra);
78 match cmd.try_get_matches_from(full) {
79 Ok(m) => m,
80 Err(e) => fail(&sources, &e.to_string()),
81 }
82 }
83 }
84 Err(e) => {
85 eprintln!("error: {e}");
86 std::process::exit(2);
87 }
88 }
89 };
90
91 match T::from_arg_matches(&final_matches) {
92 Ok(t) => t,
93 Err(e) => e.exit(),
94 }
95}
96
97fn fail(sources: &str, msg: &str) -> ! {
98 eprintln!(
99 "error: applying [defaults] from {sources}: {}",
100 msg.trim_end()
101 );
102 eprintln!("(pass --no-defaults to skip them for this run)");
103 std::process::exit(2);
104}
105
106fn augment_command(cmd: Command) -> Command {
108 cmd.arg(
109 Arg::new(NO_DEFAULTS)
110 .long(NO_DEFAULTS)
111 .global(true)
112 .action(ArgAction::SetTrue)
113 .help("Ignore the config file's [defaults] table"),
114 )
115}
116
117type DefaultsTable = (toml::Table, String);
123fn load_defaults(tool: &str) -> Result<Option<DefaultsTable>, String> {
124 let Some(cfg) = sandogasa_config::ConfigFile::try_for_tool(tool) else {
125 return Ok(None);
126 };
127 let sources = cfg.describe_sources();
128 let Some(table) = cfg.read_merged()? else {
129 return Ok(None);
130 };
131 match table.get("defaults") {
132 None => Ok(None),
133 Some(toml::Value::Table(t)) => Ok(Some((t.clone(), sources))),
134 Some(_) => Err(format!("{sources}: [defaults] must be a table")),
135 }
136}
137
138fn plan_injections(
142 cmd: &Command,
143 matches: &ArgMatches,
144 defaults: &toml::Table,
145) -> Result<Vec<OsString>, String> {
146 let mut extra = Vec::new();
147
148 for (key, value) in defaults {
153 if let toml::Value::Table(sub_table) = value {
154 let Some(sub_cmd) = cmd.find_subcommand(key) else {
157 return Err(format!("[defaults.{key}]: no such subcommand"));
158 };
159 let Some((invoked, sub_matches)) = matches.subcommand() else {
161 continue;
162 };
163 if invoked != key {
164 continue;
165 }
166 for (sub_key, sub_value) in sub_table {
167 plan_one(
168 sub_cmd,
169 sub_matches,
170 Some((cmd, matches)),
171 &format!("{key}."),
172 sub_key,
173 sub_value,
174 &mut extra,
175 )?;
176 }
177 } else if find_arg(cmd, key).is_some() {
178 plan_one(cmd, matches, None, "", key, value, &mut extra)?;
179 } else if let Some((invoked, sub_matches)) = matches.subcommand().filter(|(name, _)| {
180 cmd.find_subcommand(name)
181 .and_then(|s| find_arg(s, key))
182 .is_some()
183 }) {
184 let sub_cmd = cmd.find_subcommand(invoked).expect("filtered above");
185 plan_one(sub_cmd, sub_matches, None, "", key, value, &mut extra)?;
186 } else if !cmd.get_subcommands().any(|s| find_arg(s, key).is_some()) {
187 return Err(format!("[defaults.{key}]: no such flag --{key}"));
191 }
192 }
193 Ok(extra)
194}
195
196fn plan_one(
200 cmd: &Command,
201 matches: &ArgMatches,
202 parent: Option<(&Command, &ArgMatches)>,
203 scope: &str,
204 key: &str,
205 value: &toml::Value,
206 extra: &mut Vec<OsString>,
207) -> Result<(), String> {
208 let found = find_arg(cmd, key).map(|a| (a, cmd, matches)).or_else(|| {
211 parent.and_then(|(p_cmd, p_matches)| {
212 find_arg(p_cmd, key)
213 .filter(|a| a.is_global_set())
214 .map(|a| (a, p_cmd, p_matches))
215 })
216 });
217 let Some((arg, arg_cmd, arg_matches)) = found else {
218 return Err(format!("[defaults.{scope}{key}]: no such flag --{key}"));
219 };
220 if arg.get_id().as_str() == NO_DEFAULTS {
221 return Err(format!(
222 "[defaults.{scope}{key}]: --{key} cannot be a default"
223 ));
224 }
225
226 if given(arg_matches, arg.get_id().as_str()) {
228 return Ok(());
229 }
230 let conflicts_with_given = arg_cmd
235 .get_arg_conflicts_with(arg)
236 .iter()
237 .any(|c| given(arg_matches, c.get_id().as_str()))
238 || arg_cmd.get_arguments().any(|g| {
239 given(arg_matches, g.get_id().as_str())
240 && arg_cmd
241 .get_arg_conflicts_with(g)
242 .iter()
243 .any(|c| c.get_id() == arg.get_id())
244 });
245 if conflicts_with_given {
246 return Ok(());
247 }
248
249 let long = format!("--{key}");
250 let is_switch = matches!(
251 arg.get_action(),
252 ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count
253 );
254 match value {
255 toml::Value::Boolean(true) if is_switch => extra.push(long.into()),
256 toml::Value::Boolean(false) if is_switch => {}
258 toml::Value::String(s) if !is_switch => {
259 extra.push(long.into());
260 extra.push(s.into());
261 }
262 toml::Value::Integer(n) if !is_switch => {
263 extra.push(long.into());
264 extra.push(n.to_string().into());
265 }
266 toml::Value::Float(n) if !is_switch => {
267 extra.push(long.into());
268 extra.push(n.to_string().into());
269 }
270 toml::Value::Array(items) if !is_switch => {
271 for item in items {
272 let s = match item {
273 toml::Value::String(s) => s.clone(),
274 toml::Value::Integer(n) => n.to_string(),
275 toml::Value::Float(n) => n.to_string(),
276 other => {
277 return Err(format!(
278 "[defaults.{scope}{key}]: unsupported array element {other}"
279 ));
280 }
281 };
282 extra.push(long.clone().into());
283 extra.push(s.into());
284 }
285 }
286 other => {
287 let kind = if is_switch {
288 "a boolean flag (use true)"
289 } else {
290 "a value flag (use a string, number, or array)"
291 };
292 return Err(format!(
293 "[defaults.{scope}{key}]: --{key} is {kind}, got {other}"
294 ));
295 }
296 }
297 Ok(())
298}
299
300fn find_arg<'c>(cmd: &'c Command, long: &str) -> Option<&'c Arg> {
302 cmd.get_arguments().find(|a| a.get_long() == Some(long))
303}
304
305fn given(matches: &ArgMatches, id: &str) -> bool {
308 matches!(
309 matches.value_source(id),
310 Some(ValueSource::CommandLine) | Some(ValueSource::EnvVariable)
311 )
312}
313
314#[cfg(test)]
315mod tests {
316 use clap::{CommandFactory, FromArgMatches};
317
318 use super::*;
319
320 #[derive(Parser, Debug)]
321 #[command(name = "demo")]
322 struct DemoCli {
323 #[arg(short, long, global = true)]
325 verbose: bool,
326
327 #[command(subcommand)]
328 command: DemoCommand,
329 }
330
331 #[derive(clap::Subcommand, Debug)]
332 enum DemoCommand {
333 Update {
334 #[arg(long)]
335 explain: bool,
336 #[arg(short, long, conflicts_with = "explain")]
337 quiet: bool,
338 #[arg(long)]
339 branch: Vec<String>,
340 #[arg(long, default_value_t = 3)]
341 retries: u32,
342 },
343 Show,
344 }
345
346 fn plan(argv: &[&str], defaults: &str) -> Result<Vec<String>, String> {
347 let cmd = augment_command(DemoCli::command());
348 let matches = cmd.clone().try_get_matches_from(argv).unwrap();
349 let table: toml::Table = defaults.parse().unwrap();
350 plan_injections(&cmd, &matches, &table)
351 .map(|v| v.into_iter().map(|s| s.into_string().unwrap()).collect())
352 }
353
354 #[test]
355 fn injects_bool_flag_for_invoked_subcommand() {
356 let extra = plan(&["demo", "update"], "[update]\nexplain = true").unwrap();
357 assert_eq!(extra, vec!["--explain"]);
358 }
359
360 #[test]
361 fn other_subcommands_defaults_do_not_apply() {
362 let extra = plan(&["demo", "show"], "[update]\nexplain = true").unwrap();
363 assert!(extra.is_empty());
364 }
365
366 #[test]
367 fn command_line_wins_over_default() {
368 let extra = plan(
370 &["demo", "update", "--retries", "5"],
371 "[update]\nretries = 9",
372 )
373 .unwrap();
374 assert!(extra.is_empty());
375 }
376
377 #[test]
378 fn conflicting_explicit_flag_suppresses_default() {
379 let extra = plan(&["demo", "update", "--quiet"], "[update]\nexplain = true").unwrap();
382 assert!(extra.is_empty());
383 }
384
385 #[test]
386 fn global_flag_default_applies_from_top_table() {
387 let extra = plan(&["demo", "update"], "verbose = true").unwrap();
388 assert_eq!(extra, vec!["--verbose"]);
389 }
390
391 #[test]
392 fn top_level_key_reaches_subcommand_flag() {
393 let extra = plan(&["demo", "update"], "explain = true").unwrap();
397 assert_eq!(extra, vec!["--explain"]);
398 let extra = plan(&["demo", "show"], "explain = true").unwrap();
400 assert!(extra.is_empty());
401 let extra = plan(&["demo", "update", "--quiet"], "explain = true").unwrap();
403 assert!(extra.is_empty());
404 }
405
406 #[test]
407 fn top_level_typo_still_errors() {
408 let err = plan(&["demo", "show"], "explian = true").unwrap_err();
409 assert!(err.contains("no such flag --explian"), "{err}");
410 }
411
412 #[test]
413 fn subcommand_table_can_set_global_flag() {
414 let extra = plan(&["demo", "update"], "[update]\nverbose = true").unwrap();
415 assert_eq!(extra, vec!["--verbose"]);
416 }
417
418 #[test]
419 fn arrays_repeat_value_flags() {
420 let extra = plan(
421 &["demo", "update"],
422 "[update]\nbranch = [\"epel9\", \"epel10\"]",
423 )
424 .unwrap();
425 assert_eq!(extra, vec!["--branch", "epel9", "--branch", "epel10"]);
426 }
427
428 #[test]
429 fn numbers_become_values() {
430 let extra = plan(&["demo", "update"], "[update]\nretries = 9").unwrap();
431 assert_eq!(extra, vec!["--retries", "9"]);
432 }
433
434 #[test]
435 fn false_is_a_no_op_for_switches() {
436 let extra = plan(&["demo", "update"], "[update]\nexplain = false").unwrap();
437 assert!(extra.is_empty());
438 }
439
440 #[test]
441 fn unknown_flag_is_an_error() {
442 let err = plan(&["demo", "update"], "[update]\nexplian = true").unwrap_err();
443 assert!(err.contains("no such flag --explian"), "{err}");
444 }
445
446 #[test]
447 fn unknown_subcommand_table_is_an_error() {
448 let err = plan(&["demo", "show"], "[updaet]\nexplain = true").unwrap_err();
449 assert!(err.contains("no such subcommand"), "{err}");
450 }
451
452 #[test]
453 fn wrong_value_shape_is_an_error() {
454 let err = plan(&["demo", "update"], "[update]\nexplain = \"yes\"").unwrap_err();
455 assert!(err.contains("boolean flag"), "{err}");
456 let err = plan(&["demo", "update"], "[update]\nretries = true").unwrap_err();
457 assert!(err.contains("value flag"), "{err}");
458 }
459
460 #[test]
461 fn no_defaults_flag_cannot_be_defaulted() {
462 let err = plan(&["demo", "update"], "no-defaults = true").unwrap_err();
463 assert!(err.contains("cannot be a default"), "{err}");
464 }
465
466 #[test]
467 fn end_to_end_reparse_applies_defaults() {
468 let cmd = augment_command(DemoCli::command());
471 let argv = vec!["demo", "update"];
472 let matches = cmd.clone().try_get_matches_from(&argv).unwrap();
473 let table: toml::Table = "[update]\nexplain = true\nretries = 9".parse().unwrap();
474 let extra = plan_injections(&cmd, &matches, &table).unwrap();
475 let full: Vec<OsString> = argv.iter().map(OsString::from).chain(extra).collect();
476 let final_matches = cmd.clone().try_get_matches_from(full).unwrap();
477 let cli = DemoCli::from_arg_matches(&final_matches).unwrap();
478 match cli.command {
479 DemoCommand::Update {
480 explain, retries, ..
481 } => {
482 assert!(explain);
483 assert_eq!(retries, 9);
484 }
485 other => panic!("unexpected {other:?}"),
486 }
487 }
488}