1use std::ffi::OsString;
41
42use clap::parser::ValueSource;
43use clap::{Arg, ArgAction, ArgMatches, Command, Parser};
44
45const NO_DEFAULTS: &str = "no-defaults";
47
48const NEVER_DEFAULTED: &[&str] = &["apply", "claim", "give-karma", "prune", "submit", "yes"];
59
60pub fn parse_with_defaults<T: Parser>(tool: &str) -> T {
66 parse_with_defaults_and::<T>(tool, |_| Ok(None))
67}
68
69pub fn parse_with_defaults_and<T: Parser>(
76 tool: &str,
77 extra: impl FnOnce(&ArgMatches) -> Result<Option<DefaultsTable>, String>,
78) -> T {
79 let argv: Vec<OsString> = std::env::args_os().collect();
80 let cmd = augment_command(T::command());
81
82 let (matches, strict_error) = match cmd.clone().try_get_matches_from(&argv) {
87 Ok(m) => (m, None),
88 Err(e)
89 if matches!(
90 e.kind(),
91 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
92 ) =>
93 {
94 e.exit()
95 }
96 Err(e) => match cmd.clone().ignore_errors(true).try_get_matches_from(&argv) {
97 Ok(m) => (m, Some(e)),
98 Err(_) => e.exit(),
99 },
100 };
101
102 let final_matches = if matches.get_flag(NO_DEFAULTS) {
103 match strict_error {
104 Some(e) => e.exit(),
105 None => matches,
106 }
107 } else {
108 let combined = load_defaults(tool).and_then(|config| match extra(&matches)? {
109 None => Ok(config),
110 Some((over, over_sources)) => Ok(Some(match config {
111 None => (over, over_sources),
112 Some((mut base, sources)) => {
113 merge_over(&mut base, over);
114 (base, format!("{sources}, {over_sources}"))
115 }
116 })),
117 });
118 match combined {
119 Ok(None) => match strict_error {
120 Some(e) => e.exit(),
121 None => matches,
122 },
123 Ok(Some((table, sources))) => {
124 let extra = match plan_injections(&cmd, &matches, &table) {
125 Ok(extra) => extra,
126 Err(e) => fail(&sources, &e),
127 };
128 if extra.is_empty() {
129 match strict_error {
130 Some(e) => e.exit(),
131 None => matches,
132 }
133 } else {
134 let mut full = argv;
135 full.extend(extra);
136 match cmd.try_get_matches_from(full) {
137 Ok(m) => m,
138 Err(e) => match strict_error {
143 Some(orig) => orig.exit(),
144 None => fail(&sources, &e.to_string()),
145 },
146 }
147 }
148 }
149 Err(e) => {
150 eprintln!("error: {e}");
151 std::process::exit(2);
152 }
153 }
154 };
155
156 match T::from_arg_matches(&final_matches) {
157 Ok(t) => t,
158 Err(e) => e.exit(),
159 }
160}
161
162fn fail(sources: &str, msg: &str) -> ! {
163 eprintln!(
164 "error: applying [defaults] from {sources}: {}",
165 msg.trim_end()
166 );
167 eprintln!("(pass --no-defaults to skip them for this run)");
168 std::process::exit(2);
169}
170
171fn augment_command(cmd: Command) -> Command {
173 cmd.arg(
174 Arg::new(NO_DEFAULTS)
175 .long(NO_DEFAULTS)
176 .global(true)
177 .action(ArgAction::SetTrue)
178 .help("Ignore the config file's [defaults] table"),
179 )
180}
181
182pub type DefaultsTable = (toml::Table, String);
188
189fn merge_over(base: &mut toml::Table, over: toml::Table) {
192 for (key, value) in over {
193 match (base.get_mut(&key), value) {
194 (Some(toml::Value::Table(b)), toml::Value::Table(o)) => merge_over(b, o),
195 (_, value) => {
196 base.insert(key, value);
197 }
198 }
199 }
200}
201fn load_defaults(tool: &str) -> Result<Option<DefaultsTable>, String> {
202 let Some(cfg) = sandogasa_config::ConfigFile::try_for_tool(tool) else {
203 return Ok(None);
204 };
205 let sources = cfg.describe_sources();
206 let Some(table) = cfg.read_merged()? else {
207 return Ok(None);
208 };
209 match table.get("defaults") {
210 None => Ok(None),
211 Some(toml::Value::Table(t)) => Ok(Some((t.clone(), sources))),
212 Some(_) => Err(format!("{sources}: [defaults] must be a table")),
213 }
214}
215
216fn plan_injections(
220 cmd: &Command,
221 matches: &ArgMatches,
222 defaults: &toml::Table,
223) -> Result<Vec<OsString>, String> {
224 let mut extra = Vec::new();
225
226 for (key, value) in defaults {
231 if let toml::Value::Table(sub_table) = value {
232 let Some(sub_cmd) = cmd.find_subcommand(key) else {
235 return Err(format!("[defaults.{key}]: no such subcommand"));
236 };
237 let Some((invoked, sub_matches)) = matches.subcommand() else {
239 continue;
240 };
241 if invoked != key {
242 continue;
243 }
244 for (sub_key, sub_value) in sub_table {
245 plan_one(
246 sub_cmd,
247 sub_matches,
248 Some((cmd, matches)),
249 &format!("{key}."),
250 sub_key,
251 sub_value,
252 &mut extra,
253 )?;
254 }
255 } else if find_arg(cmd, key).is_some() {
256 plan_one(cmd, matches, None, "", key, value, &mut extra)?;
257 } else if let Some((invoked, sub_matches)) = matches.subcommand().filter(|(name, _)| {
258 cmd.find_subcommand(name)
259 .and_then(|s| find_arg(s, key))
260 .is_some()
261 }) {
262 let sub_cmd = cmd.find_subcommand(invoked).expect("filtered above");
263 plan_one(sub_cmd, sub_matches, None, "", key, value, &mut extra)?;
264 } else if !cmd.get_subcommands().any(|s| find_arg(s, key).is_some()) {
265 return Err(format!("[defaults.{key}]: no such flag --{key}"));
269 }
270 }
271 Ok(extra)
272}
273
274fn plan_one(
278 cmd: &Command,
279 matches: &ArgMatches,
280 parent: Option<(&Command, &ArgMatches)>,
281 scope: &str,
282 key: &str,
283 value: &toml::Value,
284 extra: &mut Vec<OsString>,
285) -> Result<(), String> {
286 let found = find_arg(cmd, key).map(|a| (a, cmd, matches)).or_else(|| {
289 parent.and_then(|(p_cmd, p_matches)| {
290 find_arg(p_cmd, key)
291 .filter(|a| a.is_global_set())
292 .map(|a| (a, p_cmd, p_matches))
293 })
294 });
295 let Some((arg, arg_cmd, arg_matches)) = found else {
296 return Err(format!("[defaults.{scope}{key}]: no such flag --{key}"));
297 };
298 if arg.get_id().as_str() == NO_DEFAULTS {
299 return Err(format!(
300 "[defaults.{scope}{key}]: --{key} cannot be a default"
301 ));
302 }
303 if NEVER_DEFAULTED.contains(&key.replace('_', "-").as_str()) {
304 return Err(format!(
305 "[defaults.{scope}{key}]: --{key} authorizes a write without \
306 asking, so it cannot be a default; pass it on the command \
307 line for the run you mean it for"
308 ));
309 }
310
311 if given(arg_matches, arg.get_id().as_str()) {
313 return Ok(());
314 }
315 let conflicts_with_given = arg_cmd
320 .get_arg_conflicts_with(arg)
321 .iter()
322 .any(|c| given(arg_matches, c.get_id().as_str()))
323 || arg_cmd.get_arguments().any(|g| {
324 given(arg_matches, g.get_id().as_str())
325 && arg_cmd
326 .get_arg_conflicts_with(g)
327 .iter()
328 .any(|c| c.get_id() == arg.get_id())
329 });
330 if conflicts_with_given {
331 return Ok(());
332 }
333
334 let long = format!("--{key}");
335 let is_switch = matches!(
336 arg.get_action(),
337 ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count
338 );
339 match value {
340 toml::Value::Boolean(true) if is_switch => extra.push(long.into()),
341 toml::Value::Boolean(false) if is_switch => {}
343 toml::Value::String(s) if !is_switch => {
344 extra.push(long.into());
345 extra.push(s.into());
346 }
347 toml::Value::Integer(n) if !is_switch => {
348 extra.push(long.into());
349 extra.push(n.to_string().into());
350 }
351 toml::Value::Float(n) if !is_switch => {
352 extra.push(long.into());
353 extra.push(n.to_string().into());
354 }
355 toml::Value::Array(items) if !is_switch => {
356 for item in items {
357 let s = match item {
358 toml::Value::String(s) => s.clone(),
359 toml::Value::Integer(n) => n.to_string(),
360 toml::Value::Float(n) => n.to_string(),
361 other => {
362 return Err(format!(
363 "[defaults.{scope}{key}]: unsupported array element {other}"
364 ));
365 }
366 };
367 extra.push(long.clone().into());
368 extra.push(s.into());
369 }
370 }
371 other => {
372 let kind = if is_switch {
373 "a boolean flag (use true)"
374 } else {
375 "a value flag (use a string, number, or array)"
376 };
377 return Err(format!(
378 "[defaults.{scope}{key}]: --{key} is {kind}, got {other}"
379 ));
380 }
381 }
382 Ok(())
383}
384
385fn find_arg<'c>(cmd: &'c Command, long: &str) -> Option<&'c Arg> {
387 cmd.get_arguments().find(|a| a.get_long() == Some(long))
388}
389
390fn given(matches: &ArgMatches, id: &str) -> bool {
393 matches!(
394 matches.value_source(id),
395 Some(ValueSource::CommandLine) | Some(ValueSource::EnvVariable)
396 )
397}
398
399#[cfg(test)]
400mod tests {
401 use clap::{CommandFactory, FromArgMatches};
402
403 use super::*;
404
405 #[test]
406 fn merge_over_replaces_leaves_and_merges_tables() {
407 let mut base: toml::Table = toml::from_str(
408 "explain = true\nuser = \"a\"\n[keep]\ngraph = \"old\"\nverbose = true\n",
409 )
410 .unwrap();
411 let over: toml::Table =
412 toml::from_str("user = \"b\"\n[keep]\ngraph = \"new\"\n[kondo]\nuser = \"b\"\n")
413 .unwrap();
414 merge_over(&mut base, over);
415 assert_eq!(base["explain"], toml::Value::Boolean(true));
416 assert_eq!(base["user"].as_str(), Some("b"));
417 assert_eq!(base["keep"]["graph"].as_str(), Some("new"));
418 assert_eq!(base["keep"]["verbose"], toml::Value::Boolean(true));
419 assert_eq!(base["kondo"]["user"].as_str(), Some("b"));
420 }
421
422 #[derive(Parser, Debug)]
423 #[command(name = "demo")]
424 struct DemoCli {
425 #[arg(short, long, global = true)]
427 verbose: bool,
428
429 #[command(subcommand)]
430 command: DemoCommand,
431 }
432
433 #[derive(clap::Subcommand, Debug)]
434 enum DemoCommand {
435 Update {
436 #[arg(long)]
437 explain: bool,
438 #[arg(short, long)]
441 yes: bool,
442 #[arg(short, long, conflicts_with = "explain")]
443 quiet: bool,
444 #[arg(long)]
445 branch: Vec<String>,
446 #[arg(long, default_value_t = 3)]
447 retries: u32,
448 },
449 Show,
450 }
451
452 fn plan(argv: &[&str], defaults: &str) -> Result<Vec<String>, String> {
453 let cmd = augment_command(DemoCli::command());
454 let matches = cmd.clone().try_get_matches_from(argv).unwrap();
455 let table: toml::Table = defaults.parse().unwrap();
456 plan_injections(&cmd, &matches, &table)
457 .map(|v| v.into_iter().map(|s| s.into_string().unwrap()).collect())
458 }
459
460 #[test]
461 fn refuses_to_default_a_flag_that_authorizes_a_write() {
462 let err = plan(&["demo", "update"], "[update]\nyes = true\n").unwrap_err();
466 assert!(err.contains("--yes"), "{err}");
467 assert!(err.contains("authorizes a write"), "{err}");
468 assert!(err.contains("command line"), "{err}");
469 }
470
471 #[test]
472 fn refuses_a_write_flag_written_with_an_underscore() {
473 let err = plan(&["demo", "update"], "[update]\nyes = false\n").unwrap_err();
475 assert!(err.contains("authorizes a write"), "{err}");
476 }
477
478 #[test]
479 fn injects_bool_flag_for_invoked_subcommand() {
480 let extra = plan(&["demo", "update"], "[update]\nexplain = true").unwrap();
481 assert_eq!(extra, vec!["--explain"]);
482 }
483
484 #[test]
485 fn other_subcommands_defaults_do_not_apply() {
486 let extra = plan(&["demo", "show"], "[update]\nexplain = true").unwrap();
487 assert!(extra.is_empty());
488 }
489
490 #[test]
491 fn command_line_wins_over_default() {
492 let extra = plan(
494 &["demo", "update", "--retries", "5"],
495 "[update]\nretries = 9",
496 )
497 .unwrap();
498 assert!(extra.is_empty());
499 }
500
501 #[test]
502 fn conflicting_explicit_flag_suppresses_default() {
503 let extra = plan(&["demo", "update", "--quiet"], "[update]\nexplain = true").unwrap();
506 assert!(extra.is_empty());
507 }
508
509 #[test]
510 fn global_flag_default_applies_from_top_table() {
511 let extra = plan(&["demo", "update"], "verbose = true").unwrap();
512 assert_eq!(extra, vec!["--verbose"]);
513 }
514
515 #[test]
516 fn top_level_key_reaches_subcommand_flag() {
517 let extra = plan(&["demo", "update"], "explain = true").unwrap();
521 assert_eq!(extra, vec!["--explain"]);
522 let extra = plan(&["demo", "show"], "explain = true").unwrap();
524 assert!(extra.is_empty());
525 let extra = plan(&["demo", "update", "--quiet"], "explain = true").unwrap();
527 assert!(extra.is_empty());
528 }
529
530 #[test]
531 fn top_level_typo_still_errors() {
532 let err = plan(&["demo", "show"], "explian = true").unwrap_err();
533 assert!(err.contains("no such flag --explian"), "{err}");
534 }
535
536 #[test]
537 fn subcommand_table_can_set_global_flag() {
538 let extra = plan(&["demo", "update"], "[update]\nverbose = true").unwrap();
539 assert_eq!(extra, vec!["--verbose"]);
540 }
541
542 #[test]
543 fn arrays_repeat_value_flags() {
544 let extra = plan(
545 &["demo", "update"],
546 "[update]\nbranch = [\"epel9\", \"epel10\"]",
547 )
548 .unwrap();
549 assert_eq!(extra, vec!["--branch", "epel9", "--branch", "epel10"]);
550 }
551
552 #[test]
553 fn numbers_become_values() {
554 let extra = plan(&["demo", "update"], "[update]\nretries = 9").unwrap();
555 assert_eq!(extra, vec!["--retries", "9"]);
556 }
557
558 #[test]
559 fn false_is_a_no_op_for_switches() {
560 let extra = plan(&["demo", "update"], "[update]\nexplain = false").unwrap();
561 assert!(extra.is_empty());
562 }
563
564 #[test]
565 fn unknown_flag_is_an_error() {
566 let err = plan(&["demo", "update"], "[update]\nexplian = true").unwrap_err();
567 assert!(err.contains("no such flag --explian"), "{err}");
568 }
569
570 #[test]
571 fn unknown_subcommand_table_is_an_error() {
572 let err = plan(&["demo", "show"], "[updaet]\nexplain = true").unwrap_err();
573 assert!(err.contains("no such subcommand"), "{err}");
574 }
575
576 #[test]
577 fn wrong_value_shape_is_an_error() {
578 let err = plan(&["demo", "update"], "[update]\nexplain = \"yes\"").unwrap_err();
579 assert!(err.contains("boolean flag"), "{err}");
580 let err = plan(&["demo", "update"], "[update]\nretries = true").unwrap_err();
581 assert!(err.contains("value flag"), "{err}");
582 }
583
584 #[test]
585 fn no_defaults_flag_cannot_be_defaulted() {
586 let err = plan(&["demo", "update"], "no-defaults = true").unwrap_err();
587 assert!(err.contains("cannot be a default"), "{err}");
588 }
589
590 #[test]
591 fn end_to_end_reparse_applies_defaults() {
592 let cmd = augment_command(DemoCli::command());
595 let argv = vec!["demo", "update"];
596 let matches = cmd.clone().try_get_matches_from(&argv).unwrap();
597 let table: toml::Table = "[update]\nexplain = true\nretries = 9".parse().unwrap();
598 let extra = plan_injections(&cmd, &matches, &table).unwrap();
599 let full: Vec<OsString> = argv.iter().map(OsString::from).chain(extra).collect();
600 let final_matches = cmd.clone().try_get_matches_from(full).unwrap();
601 let cli = DemoCli::from_arg_matches(&final_matches).unwrap();
602 match cli.command {
603 DemoCommand::Update {
604 explain, retries, ..
605 } => {
606 assert!(explain);
607 assert_eq!(retries, 9);
608 }
609 other => panic!("unexpected {other:?}"),
610 }
611 }
612}