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 let argv: Vec<OsString> = std::env::args_os().collect();
67 let cmd = augment_command(T::command());
68
69 let matches = match cmd.clone().try_get_matches_from(&argv) {
70 Ok(m) => m,
71 Err(e) => e.exit(),
73 };
74
75 let final_matches = if matches.get_flag(NO_DEFAULTS) {
76 matches
77 } else {
78 match load_defaults(tool) {
79 Ok(None) => matches,
80 Ok(Some((table, sources))) => {
81 let extra = match plan_injections(&cmd, &matches, &table) {
82 Ok(extra) => extra,
83 Err(e) => fail(&sources, &e),
84 };
85 if extra.is_empty() {
86 matches
87 } else {
88 let mut full = argv;
89 full.extend(extra);
90 match cmd.try_get_matches_from(full) {
91 Ok(m) => m,
92 Err(e) => fail(&sources, &e.to_string()),
93 }
94 }
95 }
96 Err(e) => {
97 eprintln!("error: {e}");
98 std::process::exit(2);
99 }
100 }
101 };
102
103 match T::from_arg_matches(&final_matches) {
104 Ok(t) => t,
105 Err(e) => e.exit(),
106 }
107}
108
109fn fail(sources: &str, msg: &str) -> ! {
110 eprintln!(
111 "error: applying [defaults] from {sources}: {}",
112 msg.trim_end()
113 );
114 eprintln!("(pass --no-defaults to skip them for this run)");
115 std::process::exit(2);
116}
117
118fn augment_command(cmd: Command) -> Command {
120 cmd.arg(
121 Arg::new(NO_DEFAULTS)
122 .long(NO_DEFAULTS)
123 .global(true)
124 .action(ArgAction::SetTrue)
125 .help("Ignore the config file's [defaults] table"),
126 )
127}
128
129type DefaultsTable = (toml::Table, String);
135fn load_defaults(tool: &str) -> Result<Option<DefaultsTable>, String> {
136 let Some(cfg) = sandogasa_config::ConfigFile::try_for_tool(tool) else {
137 return Ok(None);
138 };
139 let sources = cfg.describe_sources();
140 let Some(table) = cfg.read_merged()? else {
141 return Ok(None);
142 };
143 match table.get("defaults") {
144 None => Ok(None),
145 Some(toml::Value::Table(t)) => Ok(Some((t.clone(), sources))),
146 Some(_) => Err(format!("{sources}: [defaults] must be a table")),
147 }
148}
149
150fn plan_injections(
154 cmd: &Command,
155 matches: &ArgMatches,
156 defaults: &toml::Table,
157) -> Result<Vec<OsString>, String> {
158 let mut extra = Vec::new();
159
160 for (key, value) in defaults {
165 if let toml::Value::Table(sub_table) = value {
166 let Some(sub_cmd) = cmd.find_subcommand(key) else {
169 return Err(format!("[defaults.{key}]: no such subcommand"));
170 };
171 let Some((invoked, sub_matches)) = matches.subcommand() else {
173 continue;
174 };
175 if invoked != key {
176 continue;
177 }
178 for (sub_key, sub_value) in sub_table {
179 plan_one(
180 sub_cmd,
181 sub_matches,
182 Some((cmd, matches)),
183 &format!("{key}."),
184 sub_key,
185 sub_value,
186 &mut extra,
187 )?;
188 }
189 } else if find_arg(cmd, key).is_some() {
190 plan_one(cmd, matches, None, "", key, value, &mut extra)?;
191 } else if let Some((invoked, sub_matches)) = matches.subcommand().filter(|(name, _)| {
192 cmd.find_subcommand(name)
193 .and_then(|s| find_arg(s, key))
194 .is_some()
195 }) {
196 let sub_cmd = cmd.find_subcommand(invoked).expect("filtered above");
197 plan_one(sub_cmd, sub_matches, None, "", key, value, &mut extra)?;
198 } else if !cmd.get_subcommands().any(|s| find_arg(s, key).is_some()) {
199 return Err(format!("[defaults.{key}]: no such flag --{key}"));
203 }
204 }
205 Ok(extra)
206}
207
208fn plan_one(
212 cmd: &Command,
213 matches: &ArgMatches,
214 parent: Option<(&Command, &ArgMatches)>,
215 scope: &str,
216 key: &str,
217 value: &toml::Value,
218 extra: &mut Vec<OsString>,
219) -> Result<(), String> {
220 let found = find_arg(cmd, key).map(|a| (a, cmd, matches)).or_else(|| {
223 parent.and_then(|(p_cmd, p_matches)| {
224 find_arg(p_cmd, key)
225 .filter(|a| a.is_global_set())
226 .map(|a| (a, p_cmd, p_matches))
227 })
228 });
229 let Some((arg, arg_cmd, arg_matches)) = found else {
230 return Err(format!("[defaults.{scope}{key}]: no such flag --{key}"));
231 };
232 if arg.get_id().as_str() == NO_DEFAULTS {
233 return Err(format!(
234 "[defaults.{scope}{key}]: --{key} cannot be a default"
235 ));
236 }
237 if NEVER_DEFAULTED.contains(&key.replace('_', "-").as_str()) {
238 return Err(format!(
239 "[defaults.{scope}{key}]: --{key} authorizes a write without \
240 asking, so it cannot be a default; pass it on the command \
241 line for the run you mean it for"
242 ));
243 }
244
245 if given(arg_matches, arg.get_id().as_str()) {
247 return Ok(());
248 }
249 let conflicts_with_given = arg_cmd
254 .get_arg_conflicts_with(arg)
255 .iter()
256 .any(|c| given(arg_matches, c.get_id().as_str()))
257 || arg_cmd.get_arguments().any(|g| {
258 given(arg_matches, g.get_id().as_str())
259 && arg_cmd
260 .get_arg_conflicts_with(g)
261 .iter()
262 .any(|c| c.get_id() == arg.get_id())
263 });
264 if conflicts_with_given {
265 return Ok(());
266 }
267
268 let long = format!("--{key}");
269 let is_switch = matches!(
270 arg.get_action(),
271 ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count
272 );
273 match value {
274 toml::Value::Boolean(true) if is_switch => extra.push(long.into()),
275 toml::Value::Boolean(false) if is_switch => {}
277 toml::Value::String(s) if !is_switch => {
278 extra.push(long.into());
279 extra.push(s.into());
280 }
281 toml::Value::Integer(n) if !is_switch => {
282 extra.push(long.into());
283 extra.push(n.to_string().into());
284 }
285 toml::Value::Float(n) if !is_switch => {
286 extra.push(long.into());
287 extra.push(n.to_string().into());
288 }
289 toml::Value::Array(items) if !is_switch => {
290 for item in items {
291 let s = match item {
292 toml::Value::String(s) => s.clone(),
293 toml::Value::Integer(n) => n.to_string(),
294 toml::Value::Float(n) => n.to_string(),
295 other => {
296 return Err(format!(
297 "[defaults.{scope}{key}]: unsupported array element {other}"
298 ));
299 }
300 };
301 extra.push(long.clone().into());
302 extra.push(s.into());
303 }
304 }
305 other => {
306 let kind = if is_switch {
307 "a boolean flag (use true)"
308 } else {
309 "a value flag (use a string, number, or array)"
310 };
311 return Err(format!(
312 "[defaults.{scope}{key}]: --{key} is {kind}, got {other}"
313 ));
314 }
315 }
316 Ok(())
317}
318
319fn find_arg<'c>(cmd: &'c Command, long: &str) -> Option<&'c Arg> {
321 cmd.get_arguments().find(|a| a.get_long() == Some(long))
322}
323
324fn given(matches: &ArgMatches, id: &str) -> bool {
327 matches!(
328 matches.value_source(id),
329 Some(ValueSource::CommandLine) | Some(ValueSource::EnvVariable)
330 )
331}
332
333#[cfg(test)]
334mod tests {
335 use clap::{CommandFactory, FromArgMatches};
336
337 use super::*;
338
339 #[derive(Parser, Debug)]
340 #[command(name = "demo")]
341 struct DemoCli {
342 #[arg(short, long, global = true)]
344 verbose: bool,
345
346 #[command(subcommand)]
347 command: DemoCommand,
348 }
349
350 #[derive(clap::Subcommand, Debug)]
351 enum DemoCommand {
352 Update {
353 #[arg(long)]
354 explain: bool,
355 #[arg(short, long)]
358 yes: bool,
359 #[arg(short, long, conflicts_with = "explain")]
360 quiet: bool,
361 #[arg(long)]
362 branch: Vec<String>,
363 #[arg(long, default_value_t = 3)]
364 retries: u32,
365 },
366 Show,
367 }
368
369 fn plan(argv: &[&str], defaults: &str) -> Result<Vec<String>, String> {
370 let cmd = augment_command(DemoCli::command());
371 let matches = cmd.clone().try_get_matches_from(argv).unwrap();
372 let table: toml::Table = defaults.parse().unwrap();
373 plan_injections(&cmd, &matches, &table)
374 .map(|v| v.into_iter().map(|s| s.into_string().unwrap()).collect())
375 }
376
377 #[test]
378 fn refuses_to_default_a_flag_that_authorizes_a_write() {
379 let err = plan(&["demo", "update"], "[update]\nyes = true\n").unwrap_err();
383 assert!(err.contains("--yes"), "{err}");
384 assert!(err.contains("authorizes a write"), "{err}");
385 assert!(err.contains("command line"), "{err}");
386 }
387
388 #[test]
389 fn refuses_a_write_flag_written_with_an_underscore() {
390 let err = plan(&["demo", "update"], "[update]\nyes = false\n").unwrap_err();
392 assert!(err.contains("authorizes a write"), "{err}");
393 }
394
395 #[test]
396 fn injects_bool_flag_for_invoked_subcommand() {
397 let extra = plan(&["demo", "update"], "[update]\nexplain = true").unwrap();
398 assert_eq!(extra, vec!["--explain"]);
399 }
400
401 #[test]
402 fn other_subcommands_defaults_do_not_apply() {
403 let extra = plan(&["demo", "show"], "[update]\nexplain = true").unwrap();
404 assert!(extra.is_empty());
405 }
406
407 #[test]
408 fn command_line_wins_over_default() {
409 let extra = plan(
411 &["demo", "update", "--retries", "5"],
412 "[update]\nretries = 9",
413 )
414 .unwrap();
415 assert!(extra.is_empty());
416 }
417
418 #[test]
419 fn conflicting_explicit_flag_suppresses_default() {
420 let extra = plan(&["demo", "update", "--quiet"], "[update]\nexplain = true").unwrap();
423 assert!(extra.is_empty());
424 }
425
426 #[test]
427 fn global_flag_default_applies_from_top_table() {
428 let extra = plan(&["demo", "update"], "verbose = true").unwrap();
429 assert_eq!(extra, vec!["--verbose"]);
430 }
431
432 #[test]
433 fn top_level_key_reaches_subcommand_flag() {
434 let extra = plan(&["demo", "update"], "explain = true").unwrap();
438 assert_eq!(extra, vec!["--explain"]);
439 let extra = plan(&["demo", "show"], "explain = true").unwrap();
441 assert!(extra.is_empty());
442 let extra = plan(&["demo", "update", "--quiet"], "explain = true").unwrap();
444 assert!(extra.is_empty());
445 }
446
447 #[test]
448 fn top_level_typo_still_errors() {
449 let err = plan(&["demo", "show"], "explian = true").unwrap_err();
450 assert!(err.contains("no such flag --explian"), "{err}");
451 }
452
453 #[test]
454 fn subcommand_table_can_set_global_flag() {
455 let extra = plan(&["demo", "update"], "[update]\nverbose = true").unwrap();
456 assert_eq!(extra, vec!["--verbose"]);
457 }
458
459 #[test]
460 fn arrays_repeat_value_flags() {
461 let extra = plan(
462 &["demo", "update"],
463 "[update]\nbranch = [\"epel9\", \"epel10\"]",
464 )
465 .unwrap();
466 assert_eq!(extra, vec!["--branch", "epel9", "--branch", "epel10"]);
467 }
468
469 #[test]
470 fn numbers_become_values() {
471 let extra = plan(&["demo", "update"], "[update]\nretries = 9").unwrap();
472 assert_eq!(extra, vec!["--retries", "9"]);
473 }
474
475 #[test]
476 fn false_is_a_no_op_for_switches() {
477 let extra = plan(&["demo", "update"], "[update]\nexplain = false").unwrap();
478 assert!(extra.is_empty());
479 }
480
481 #[test]
482 fn unknown_flag_is_an_error() {
483 let err = plan(&["demo", "update"], "[update]\nexplian = true").unwrap_err();
484 assert!(err.contains("no such flag --explian"), "{err}");
485 }
486
487 #[test]
488 fn unknown_subcommand_table_is_an_error() {
489 let err = plan(&["demo", "show"], "[updaet]\nexplain = true").unwrap_err();
490 assert!(err.contains("no such subcommand"), "{err}");
491 }
492
493 #[test]
494 fn wrong_value_shape_is_an_error() {
495 let err = plan(&["demo", "update"], "[update]\nexplain = \"yes\"").unwrap_err();
496 assert!(err.contains("boolean flag"), "{err}");
497 let err = plan(&["demo", "update"], "[update]\nretries = true").unwrap_err();
498 assert!(err.contains("value flag"), "{err}");
499 }
500
501 #[test]
502 fn no_defaults_flag_cannot_be_defaulted() {
503 let err = plan(&["demo", "update"], "no-defaults = true").unwrap_err();
504 assert!(err.contains("cannot be a default"), "{err}");
505 }
506
507 #[test]
508 fn end_to_end_reparse_applies_defaults() {
509 let cmd = augment_command(DemoCli::command());
512 let argv = vec!["demo", "update"];
513 let matches = cmd.clone().try_get_matches_from(&argv).unwrap();
514 let table: toml::Table = "[update]\nexplain = true\nretries = 9".parse().unwrap();
515 let extra = plan_injections(&cmd, &matches, &table).unwrap();
516 let full: Vec<OsString> = argv.iter().map(OsString::from).chain(extra).collect();
517 let final_matches = cmd.clone().try_get_matches_from(full).unwrap();
518 let cli = DemoCli::from_arg_matches(&final_matches).unwrap();
519 match cli.command {
520 DemoCommand::Update {
521 explain, retries, ..
522 } => {
523 assert!(explain);
524 assert_eq!(retries, 9);
525 }
526 other => panic!("unexpected {other:?}"),
527 }
528 }
529}