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    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        // Includes --help / --version, which exit 0 from here.
72        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
118/// Add the `--no-defaults` escape hatch to a tool's command.
119fn 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
129/// Load the `[defaults]` table from the tool's config layers
130/// (`/etc/<tool>/config.toml` overridden per key by
131/// `~/.config/<tool>/config.toml`). `Ok(None)` when there is no
132/// config dir, no file, or no table. The second tuple field
133/// describes the sources for error messages.
134type 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
150/// Compute the extra argv tokens the `[defaults]` table asks for,
151/// given what the first parse saw. Pure over (command, matches,
152/// table) so it's unit-testable.
153fn 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    // Non-table entries apply to the top-level command (and
161    // global args) — or, for flags that live on subcommands
162    // (e.g. dbranch's --explain, present on several), to the
163    // invoked subcommand when it has a flag of that name.
164    for (key, value) in defaults {
165        if let toml::Value::Table(sub_table) = value {
166            // A nested table is a subcommand's defaults; validate
167            // the name eagerly so typos don't rot silently.
168            let Some(sub_cmd) = cmd.find_subcommand(key) else {
169                return Err(format!("[defaults.{key}]: no such subcommand"));
170            };
171            // Only the invoked subcommand's table applies.
172            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            // Neither a top-level flag nor any subcommand's:
200            // a typo. (Known on *some* subcommand but not the
201            // invoked one is a silent no-op instead.)
202            return Err(format!("[defaults.{key}]: no such flag --{key}"));
203        }
204    }
205    Ok(extra)
206}
207
208/// Plan the tokens for one `key = value` entry in scope `cmd` /
209/// `matches`. `parent` is the enclosing command for subcommand
210/// scopes, so entries can also name global args defined there.
211fn 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    // Resolve the arg by its long name, falling back to the
221    // parent's global args for subcommand scopes.
222    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    // The command line (or an explicit env var) always wins.
246    if given(arg_matches, arg.get_id().as_str()) {
247        return Ok(());
248    }
249    // Skip a default that conflicts with something explicitly
250    // given, instead of letting the re-parse error out. clap only
251    // reports conflicts *declared by* the queried arg, so check
252    // both directions: ours, and every given arg's declarations.
253    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        // `false` on a switch is "no default", not an un-set.
276        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
319/// Find an argument by its long name.
320fn find_arg<'c>(cmd: &'c Command, long: &str) -> Option<&'c Arg> {
321    cmd.get_arguments().find(|a| a.get_long() == Some(long))
322}
323
324/// Whether the user explicitly supplied this arg (command line or
325/// its env var) — as opposed to a clap default.
326fn 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        /// Global verbosity.
343        #[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            /// Stands in for the write-authorizing flags every tool
356            /// has, which must not be settable from a config file.
357            #[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        // A config file is where such a setting gets forgotten, after
380        // which every run acts without asking. Better to reject it
381        // than to accept it and act on it.
382        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        // The rejection keys off the flag, not its spelling in TOML.
391        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        // Value flag: explicit CLI value suppresses the default.
410        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        // --quiet conflicts with explain; a configured explain
421        // default must yield, not error.
422        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        // A flag that lives on subcommands (not global) can be
435        // defaulted tool-wide from the top-level table; it applies
436        // to any invoked subcommand that has it.
437        let extra = plan(&["demo", "update"], "explain = true").unwrap();
438        assert_eq!(extra, vec!["--explain"]);
439        // ...and is a silent no-op for subcommands without it.
440        let extra = plan(&["demo", "show"], "explain = true").unwrap();
441        assert!(extra.is_empty());
442        // CLI-wins and conflict rules still hold in that scope.
443        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        // Simulate the full flow: plan against the first parse,
510        // then re-parse with the extra tokens appended.
511        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}