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/// Parse the command line like `T::parse()`, applying `[defaults]`
49/// from the tool's config layers (`/etc/<tool>/config.toml`, then
50/// `~/.config/<tool>/config.toml` overriding it per key) for flags
51/// not given on the command line. Call with the tool's crate name:
52/// `parse_with_defaults::<Cli>(env!("CARGO_PKG_NAME"))`.
53pub 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        // Includes --help / --version, which exit 0 from here.
60        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
106/// Add the `--no-defaults` escape hatch to a tool's command.
107fn 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
117/// Load the `[defaults]` table from the tool's config layers
118/// (`/etc/<tool>/config.toml` overridden per key by
119/// `~/.config/<tool>/config.toml`). `Ok(None)` when there is no
120/// config dir, no file, or no table. The second tuple field
121/// describes the sources for error messages.
122type 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
138/// Compute the extra argv tokens the `[defaults]` table asks for,
139/// given what the first parse saw. Pure over (command, matches,
140/// table) so it's unit-testable.
141fn 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    // Non-table entries apply to the top-level command (and
149    // global args) — or, for flags that live on subcommands
150    // (e.g. dbranch's --explain, present on several), to the
151    // invoked subcommand when it has a flag of that name.
152    for (key, value) in defaults {
153        if let toml::Value::Table(sub_table) = value {
154            // A nested table is a subcommand's defaults; validate
155            // the name eagerly so typos don't rot silently.
156            let Some(sub_cmd) = cmd.find_subcommand(key) else {
157                return Err(format!("[defaults.{key}]: no such subcommand"));
158            };
159            // Only the invoked subcommand's table applies.
160            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            // Neither a top-level flag nor any subcommand's:
188            // a typo. (Known on *some* subcommand but not the
189            // invoked one is a silent no-op instead.)
190            return Err(format!("[defaults.{key}]: no such flag --{key}"));
191        }
192    }
193    Ok(extra)
194}
195
196/// Plan the tokens for one `key = value` entry in scope `cmd` /
197/// `matches`. `parent` is the enclosing command for subcommand
198/// scopes, so entries can also name global args defined there.
199fn 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    // Resolve the arg by its long name, falling back to the
209    // parent's global args for subcommand scopes.
210    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    // The command line (or an explicit env var) always wins.
227    if given(arg_matches, arg.get_id().as_str()) {
228        return Ok(());
229    }
230    // Skip a default that conflicts with something explicitly
231    // given, instead of letting the re-parse error out. clap only
232    // reports conflicts *declared by* the queried arg, so check
233    // both directions: ours, and every given arg's declarations.
234    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        // `false` on a switch is "no default", not an un-set.
257        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
300/// Find an argument by its long name.
301fn find_arg<'c>(cmd: &'c Command, long: &str) -> Option<&'c Arg> {
302    cmd.get_arguments().find(|a| a.get_long() == Some(long))
303}
304
305/// Whether the user explicitly supplied this arg (command line or
306/// its env var) — as opposed to a clap default.
307fn 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        /// Global verbosity.
324        #[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        // Value flag: explicit CLI value suppresses the default.
369        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        // --quiet conflicts with explain; a configured explain
380        // default must yield, not error.
381        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        // A flag that lives on subcommands (not global) can be
394        // defaulted tool-wide from the top-level table; it applies
395        // to any invoked subcommand that has it.
396        let extra = plan(&["demo", "update"], "explain = true").unwrap();
397        assert_eq!(extra, vec!["--explain"]);
398        // ...and is a silent no-op for subcommands without it.
399        let extra = plan(&["demo", "show"], "explain = true").unwrap();
400        assert!(extra.is_empty());
401        // CLI-wins and conflict rules still hold in that scope.
402        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        // Simulate the full flow: plan against the first parse,
469        // then re-parse with the extra tokens appended.
470        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}