Skip to main content

sandogasa_cli/
defaults.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3//! Flag defaults from the tool's config file.
4//!
5//! Every sandogasa tool lets users pin flag defaults in a
6//! `[defaults]` table of its config — `/etc/<tool>/config.toml`
7//! overridden per key by `~/.config/<tool>/config.toml` (see the
8//! root DEVELOPMENT.md for the pattern):
9//!
10//! ```toml
11//! [defaults]          # tool-wide
12//! explain = true
13//!
14//! [defaults.update]   # for one subcommand only
15//! quiet = true
16//! ```
17//!
18//! Keys are the flag's **long name** (as typed on the command
19//! line, dashes included). A top-level key covers global and
20//! top-level flags, and also applies to any invoked subcommand
21//! that has a flag of that name (a subcommand without it just
22//! ignores the default) — so one `explain = true` line covers
23//! every dbranch subcommand with `--explain`. Values: `true`
24//! turns a boolean flag on (`false` is a no-op — flags can't be
25//! un-set, so it just means "no default"); strings and numbers
26//! become `--key value`; arrays repeat the flag per element.
27//!
28//! Precedence and safety rules, in order:
29//! - anything given on the command line (or via a flag's env var)
30//!   wins — a config default never overrides it;
31//! - a default is silently skipped when it *conflicts* (per
32//!   clap's `conflicts_with`) with an explicitly-given flag, so
33//!   e.g. `--quiet` on the command line suppresses a configured
34//!   `explain = true` rather than erroring;
35//! - `--no-defaults` (added to every tool by this module) skips
36//!   the whole table for one run;
37//! - unknown keys are hard errors — a typo'd flag name must not
38//!   be silently ignored.
39
40use std::ffi::OsString;
41
42use clap::parser::ValueSource;
43use clap::{Arg, ArgAction, ArgMatches, Command, Parser};
44
45/// The injected escape-hatch flag's id and long name.
46const NO_DEFAULTS: &str = "no-defaults";
47
48/// Flags that must never come from a config file, because each one
49/// authorizes a write that a person would otherwise be asked about.
50///
51/// A config file is precisely where such a setting gets forgotten, and
52/// then every run acts without asking — writing to Bugzilla, Bodhi,
53/// dist-git or the ledger. A paired `--no-yes` escape would not fix
54/// that: it only helps someone who remembers to type it, and the
55/// person at risk is the one who has forgotten the setting exists.
56/// These are passed on the command line, for the run they are meant
57/// for.
58const NEVER_DEFAULTED: &[&str] = &["apply", "claim", "give-karma", "prune", "submit", "yes"];
59
60/// Parse the command line like `T::parse()`, applying `[defaults]`
61/// from the tool's config layers (`/etc/<tool>/config.toml`, then
62/// `~/.config/<tool>/config.toml` overriding it per key) for flags
63/// not given on the command line. Call with the tool's crate name:
64/// `parse_with_defaults::<Cli>(env!("CARGO_PKG_NAME"))`.
65pub fn parse_with_defaults<T: Parser>(tool: &str) -> T {
66    parse_with_defaults_and::<T>(tool, |_| Ok(None))
67}
68
69/// [`parse_with_defaults`] with a second source of defaults the tool
70/// computes from the first parse — a workspace file named on the
71/// command line, say. `extra` sees the matches of that first parse
72/// and returns a table shaped like `[defaults]` plus a description of
73/// where it came from; its keys win over the config file's, and the
74/// command line wins over both. `--no-defaults` skips both.
75pub 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    // A required flag may be one the defaults supply, so a strict
83    // parse that fails on a missing argument is not the last word:
84    // plan the injections on a lenient parse and try again with them.
85    // Help and version exit here as they always did.
86    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                        // The command line was already wrong on its own:
139                        // its own complaint is the useful one. Only a
140                        // parse that passed without the defaults and
141                        // fails with them is the defaults' fault.
142                        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
171/// Add the `--no-defaults` escape hatch to a tool's command.
172fn 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
182/// Load the `[defaults]` table from the tool's config layers
183/// (`/etc/<tool>/config.toml` overridden per key by
184/// `~/.config/<tool>/config.toml`). `Ok(None)` when there is no
185/// config dir, no file, or no table. The second tuple field
186/// describes the sources for error messages.
187pub type DefaultsTable = (toml::Table, String);
188
189/// Lay `over` on top of `base`, key by key; nested tables merge
190/// recursively, anything else is replaced.
191fn 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
216/// Compute the extra argv tokens the `[defaults]` table asks for,
217/// given what the first parse saw. Pure over (command, matches,
218/// table) so it's unit-testable.
219fn 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    // Non-table entries apply to the top-level command (and
227    // global args) — or, for flags that live on subcommands
228    // (e.g. dbranch's --explain, present on several), to the
229    // invoked subcommand when it has a flag of that name.
230    for (key, value) in defaults {
231        if let toml::Value::Table(sub_table) = value {
232            // A nested table is a subcommand's defaults; validate
233            // the name eagerly so typos don't rot silently.
234            let Some(sub_cmd) = cmd.find_subcommand(key) else {
235                return Err(format!("[defaults.{key}]: no such subcommand"));
236            };
237            // Only the invoked subcommand's table applies.
238            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            // Neither a top-level flag nor any subcommand's:
266            // a typo. (Known on *some* subcommand but not the
267            // invoked one is a silent no-op instead.)
268            return Err(format!("[defaults.{key}]: no such flag --{key}"));
269        }
270    }
271    Ok(extra)
272}
273
274/// Plan the tokens for one `key = value` entry in scope `cmd` /
275/// `matches`. `parent` is the enclosing command for subcommand
276/// scopes, so entries can also name global args defined there.
277fn 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    // Resolve the arg by its long name, falling back to the
287    // parent's global args for subcommand scopes.
288    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    // The command line (or an explicit env var) always wins.
312    if given(arg_matches, arg.get_id().as_str()) {
313        return Ok(());
314    }
315    // Skip a default that conflicts with something explicitly
316    // given, instead of letting the re-parse error out. clap only
317    // reports conflicts *declared by* the queried arg, so check
318    // both directions: ours, and every given arg's declarations.
319    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        // `false` on a switch is "no default", not an un-set.
342        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
385/// Find an argument by its long name.
386fn find_arg<'c>(cmd: &'c Command, long: &str) -> Option<&'c Arg> {
387    cmd.get_arguments().find(|a| a.get_long() == Some(long))
388}
389
390/// Whether the user explicitly supplied this arg (command line or
391/// its env var) — as opposed to a clap default.
392fn 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        /// Global verbosity.
426        #[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            /// Stands in for the write-authorizing flags every tool
439            /// has, which must not be settable from a config file.
440            #[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        // A config file is where such a setting gets forgotten, after
463        // which every run acts without asking. Better to reject it
464        // than to accept it and act on it.
465        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        // The rejection keys off the flag, not its spelling in TOML.
474        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        // Value flag: explicit CLI value suppresses the default.
493        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        // --quiet conflicts with explain; a configured explain
504        // default must yield, not error.
505        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        // A flag that lives on subcommands (not global) can be
518        // defaulted tool-wide from the top-level table; it applies
519        // to any invoked subcommand that has it.
520        let extra = plan(&["demo", "update"], "explain = true").unwrap();
521        assert_eq!(extra, vec!["--explain"]);
522        // ...and is a silent no-op for subcommands without it.
523        let extra = plan(&["demo", "show"], "explain = true").unwrap();
524        assert!(extra.is_empty());
525        // CLI-wins and conflict rules still hold in that scope.
526        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        // Simulate the full flow: plan against the first parse,
593        // then re-parse with the extra tokens appended.
594        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}