Skip to main content

f00_cli/
config.rs

1//! User TOML configuration for f00.
2//!
3//! Search order (first found wins):
4//! 1. `--config PATH` if provided
5//! 2. `$F00_CONFIG` if set
6//! 3. Platform config dir:
7//!    - Unix: `$XDG_CONFIG_HOME/f00/config.toml` or `~/.config/f00/config.toml`
8//!      (macOS via `directories`: `~/Library/Application Support/f00/config.toml`)
9//!    - Windows: `%APPDATA%\f00\config.toml`
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16use crate::cli::{Args, ColorArg, IconsArg};
17use f00_core::IconsWhen;
18
19/// Optional defaults from a TOML config file.
20#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
21#[serde(default)]
22pub struct ConfigDefaults {
23    pub all: Option<bool>,
24    pub almost_all: Option<bool>,
25    pub long: Option<bool>,
26    #[serde(alias = "human")]
27    pub human_readable: Option<bool>,
28    pub color: Option<String>,
29    /// Icons when: bool (`true`/`false`) or string (`auto`/`always`/`never`).
30    #[serde(default, deserialize_with = "deserialize_opt_icons")]
31    pub icons: Option<IconsArg>,
32    pub dirs_first: Option<bool>,
33    pub git: Option<bool>,
34    pub classify: Option<bool>,
35}
36
37/// Root of `config.toml`. Fields may live under `[defaults]` or at the root.
38#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
39#[serde(default)]
40pub struct FileConfig {
41    #[serde(default)]
42    pub defaults: ConfigDefaults,
43    // Root-level keys (take precedence over nested `[defaults]` when present).
44    pub all: Option<bool>,
45    pub almost_all: Option<bool>,
46    pub long: Option<bool>,
47    #[serde(alias = "human")]
48    pub human_readable: Option<bool>,
49    pub color: Option<String>,
50    #[serde(default, deserialize_with = "deserialize_opt_icons")]
51    pub icons: Option<IconsArg>,
52    pub dirs_first: Option<bool>,
53    pub git: Option<bool>,
54    pub classify: Option<bool>,
55}
56
57impl FileConfig {
58    /// Flatten root-level keys on top of `[defaults]`.
59    pub fn resolved_defaults(&self) -> ConfigDefaults {
60        ConfigDefaults {
61            all: self.all.or(self.defaults.all),
62            almost_all: self.almost_all.or(self.defaults.almost_all),
63            long: self.long.or(self.defaults.long),
64            human_readable: self.human_readable.or(self.defaults.human_readable),
65            color: self.color.clone().or_else(|| self.defaults.color.clone()),
66            icons: self.icons.or(self.defaults.icons),
67            dirs_first: self.dirs_first.or(self.defaults.dirs_first),
68            git: self.git.or(self.defaults.git),
69            classify: self.classify.or(self.defaults.classify),
70        }
71    }
72}
73
74/// Parse TOML text into a [`FileConfig`].
75pub fn parse_config_str(s: &str) -> Result<FileConfig, toml::de::Error> {
76    toml::from_str(s)
77}
78
79/// Load config from an explicit path.
80pub fn load_config_from_path(path: &Path) -> anyhow::Result<FileConfig> {
81    let text = fs::read_to_string(path)
82        .map_err(|e| anyhow::anyhow!("failed to read config {}: {e}", path.display()))?;
83    parse_config_str(&text)
84        .map_err(|e| anyhow::anyhow!("failed to parse config {}: {e}", path.display()))
85}
86
87/// Resolve the platform user config path (`…/f00/config.toml`), if available.
88pub fn platform_config_path() -> Option<PathBuf> {
89    directories::ProjectDirs::from("", "", "f00").map(|d| d.config_dir().join("config.toml"))
90}
91
92/// Paths to try when no `--config` override is given.
93pub fn config_search_paths() -> Vec<PathBuf> {
94    let mut paths = Vec::new();
95    if let Ok(p) = std::env::var("F00_CONFIG") {
96        if !p.is_empty() {
97            paths.push(PathBuf::from(p));
98        }
99    }
100    if let Some(p) = platform_config_path() {
101        paths.push(p);
102    }
103    paths
104}
105
106/// Load config: explicit path, else first existing search path, else `None`.
107pub fn load_user_config(explicit: Option<&Path>) -> anyhow::Result<Option<FileConfig>> {
108    if let Some(path) = explicit {
109        return Ok(Some(load_config_from_path(path)?));
110    }
111    for path in config_search_paths() {
112        if path.is_file() {
113            return Ok(Some(load_config_from_path(&path)?));
114        }
115    }
116    Ok(None)
117}
118
119fn parse_color_arg(s: &str) -> Option<ColorArg> {
120    // Keep in sync with `ColorArg` clap aliases (GNU coreutils ls --color=WHEN).
121    match s.to_ascii_lowercase().as_str() {
122        "auto" | "tty" | "if-tty" => Some(ColorArg::Auto),
123        "always" | "yes" | "force" | "true" | "on" => Some(ColorArg::Always),
124        "never" | "no" | "none" | "false" | "off" => Some(ColorArg::Never),
125        _ => None,
126    }
127}
128
129fn parse_icons_arg(s: &str) -> Option<IconsArg> {
130    IconsWhen::parse(s).map(IconsArg::from)
131}
132
133/// Accept bool or string for `icons` in TOML (`true`/`false`/`"auto"`/`"always"`/`"never"`).
134fn deserialize_opt_icons<'de, D>(deserializer: D) -> Result<Option<IconsArg>, D::Error>
135where
136    D: serde::Deserializer<'de>,
137{
138    use serde::de::{self, Visitor};
139    use std::fmt;
140
141    struct IconsVisitor;
142
143    impl<'de> Visitor<'de> for IconsVisitor {
144        type Value = Option<IconsArg>;
145
146        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
147            f.write_str("a bool or icons when string (auto/always/never)")
148        }
149
150        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
151            Ok(None)
152        }
153
154        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
155            Ok(None)
156        }
157
158        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
159            Ok(Some(if v { IconsArg::Always } else { IconsArg::Never }))
160        }
161
162        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
163            parse_icons_arg(v)
164                .map(Some)
165                .ok_or_else(|| de::Error::custom(format!("invalid icons value: {v}")))
166        }
167
168        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
169            self.visit_str(&v)
170        }
171    }
172
173    deserializer.deserialize_any(IconsVisitor)
174}
175
176/// Whether `flag` (e.g. `--icons` or `--git`) appears in process args.
177pub fn cli_has_long(flag: &str) -> bool {
178    let eq = format!("{flag}=");
179    std::env::args().any(|a| a == flag || a.starts_with(&eq))
180}
181
182/// Merge file defaults into CLI args.
183///
184/// Precedence: built-in defaults < config < CLI flags (when set / non-default).
185/// For false-default bools, config `true` enables.
186/// For `git` (CLI default true), config applies unless `--git` was on the command line.
187/// For `color` / `icons`, config applies when no explicit `--color` / `--icons` on the CLI.
188pub fn merge_config_into_args(args: &mut Args, file: &FileConfig) {
189    let d = file.resolved_defaults();
190
191    if let Some(true) = d.all {
192        args.all = true;
193    }
194    if let Some(true) = d.almost_all {
195        args.almost_all = true;
196    }
197    if let Some(true) = d.long {
198        args.long = true;
199    }
200    if let Some(true) = d.human_readable {
201        args.human_readable = true;
202    }
203    if let Some(true) = d.classify {
204        args.classify = Some(ColorArg::Always);
205    }
206
207    if let Some(v) = d.icons {
208        if !cli_has_long("--icons") {
209            args.icons = v;
210        }
211    }
212    if let Some(v) = d.dirs_first {
213        if v {
214            args.dirs_first = true;
215        } else if !cli_has_long("--dirs-first") {
216            args.dirs_first = false;
217        }
218    }
219
220    if let Some(v) = d.git {
221        if !cli_has_long("--git") {
222            args.git = v;
223        }
224    }
225
226    if let Some(ref c) = d.color {
227        if !cli_has_long("--color") {
228            if let Some(parsed) = parse_color_arg(c) {
229                args.color = parsed;
230            }
231        }
232    }
233}
234
235/// Apply env overrides (`F00_GNU`, `F00_NO_GNU`).
236///
237/// `F00_NO_GNU` wins over `F00_GNU` when both are set (matches `--no-gnu` over `--gnu`).
238pub fn apply_env_overrides(args: &mut Args) {
239    if let Ok(v) = std::env::var("F00_NO_GNU") {
240        let v = v.to_ascii_lowercase();
241        if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
242            args.no_gnu = true;
243            args.gnu = false;
244            return;
245        }
246    }
247    if args.gnu || args.no_gnu {
248        return;
249    }
250    if let Ok(v) = std::env::var("F00_GNU") {
251        let v = v.to_ascii_lowercase();
252        if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
253            args.gnu = true;
254        }
255    }
256}
257
258/// Auto-enable script-safe / GNU mode when stdout is not a TTY.
259///
260/// Daily-driver product chrome (icons, git, modern sort) is for interactive
261/// terminals. Pipes, capture, and CI get coreutils-shaped output unless the
262/// user opts out with `--no-gnu` / `F00_NO_GNU=1`.
263pub fn apply_auto_gnu(args: &mut Args, is_stdout_tty: bool) {
264    if args.no_gnu {
265        args.gnu = false;
266        return;
267    }
268    if args.gnu {
269        return;
270    }
271    if !is_stdout_tty {
272        args.gnu = true;
273    }
274}
275
276/// Detect whether the binary was invoked as `ls` / `ls.exe`.
277pub fn invoked_as_ls() -> bool {
278    invoked_as_ls_from(std::env::args_os().next())
279}
280
281/// Testable argv0 check.
282pub fn invoked_as_ls_from(argv0: Option<std::ffi::OsString>) -> bool {
283    let Some(argv0) = argv0 else {
284        return false;
285    };
286    // Prefer Path (correct separators for the host OS).
287    if let Some(stem) = Path::new(&argv0).file_stem().and_then(|s| s.to_str()) {
288        if stem.eq_ignore_ascii_case("ls") {
289            return true;
290        }
291    }
292    // Also handle Windows-style paths when parsing on Unix (and vice versa).
293    if let Some(s) = argv0.to_str() {
294        let name = s.rsplit(['/', '\\']).next().unwrap_or(s);
295        let stem = name
296            .strip_suffix(".exe")
297            .or_else(|| name.strip_suffix(".EXE"))
298            .unwrap_or(name);
299        return stem.eq_ignore_ascii_case("ls");
300    }
301    false
302}
303
304/// Resolve effective args after clap parse.
305///
306/// Order: argv0 soft defaults → config file → env (`F00_GNU` / `F00_NO_GNU`) →
307/// auto GNU when stdout is not a TTY (unless `--no-gnu`).
308/// CLI flags already present in `args` win over config where merge logic allows.
309pub fn resolve_args(args: &mut Args, file: Option<&FileConfig>, as_ls: bool) {
310    resolve_args_with_tty(
311        args,
312        file,
313        as_ls,
314        std::io::IsTerminal::is_terminal(&std::io::stdout()),
315    );
316}
317
318/// Like [`resolve_args`] with an explicit TTY flag (for tests).
319pub fn resolve_args_with_tty(
320    args: &mut Args,
321    file: Option<&FileConfig>,
322    as_ls: bool,
323    is_stdout_tty: bool,
324) {
325    if as_ls {
326        // Soft drop-in: keep full f00 chrome (icons/git) on a TTY. Only quiet
327        // dirs-first unless the user asked for it. Non-TTY auto-enables GNU.
328        f00_compat::prefer_ls_defaults(
329            &mut args.dirs_first,
330            cli_has_long("--dirs-first") || cli_has_long("--group-directories-first"),
331        );
332    }
333    if let Some(file) = file {
334        merge_config_into_args(args, file);
335    }
336    apply_env_overrides(args);
337    apply_auto_gnu(args, is_stdout_tty);
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::cli::{ColorArg, IconsArg};
344
345    fn empty_args() -> Args {
346        let mut a = Args::test_default();
347        a.git = true;
348        a.color = ColorArg::Auto;
349        a
350    }
351
352    #[test]
353    fn parse_defaults_section() {
354        let cfg = parse_config_str(
355            r#"
356            [defaults]
357            all = true
358            icons = true
359            color = "never"
360            dirs_first = true
361            git = false
362            "#,
363        )
364        .unwrap();
365        let d = cfg.resolved_defaults();
366        assert_eq!(d.all, Some(true));
367        assert_eq!(d.icons, Some(IconsArg::Always));
368        assert_eq!(d.color.as_deref(), Some("never"));
369        assert_eq!(d.dirs_first, Some(true));
370        assert_eq!(d.git, Some(false));
371    }
372
373    #[test]
374    fn parse_icons_when_string() {
375        let cfg = parse_config_str(r#"icons = "auto""#).unwrap();
376        assert_eq!(cfg.resolved_defaults().icons, Some(IconsArg::Auto));
377        let cfg = parse_config_str(r#"icons = "never""#).unwrap();
378        assert_eq!(cfg.resolved_defaults().icons, Some(IconsArg::Never));
379    }
380
381    #[test]
382    fn parse_root_level_and_human_alias() {
383        let cfg = parse_config_str(
384            r#"
385            long = true
386            human = true
387            classify = true
388            "#,
389        )
390        .unwrap();
391        let d = cfg.resolved_defaults();
392        assert_eq!(d.long, Some(true));
393        assert_eq!(d.human_readable, Some(true));
394        assert_eq!(d.classify, Some(true));
395    }
396
397    #[test]
398    fn merge_enables_flags_from_config() {
399        let cfg = parse_config_str(
400            r#"
401            [defaults]
402            all = true
403            long = true
404            icons = true
405            human_readable = true
406            "#,
407        )
408        .unwrap();
409        let mut args = empty_args();
410        merge_config_into_args(&mut args, &cfg);
411        assert!(args.all);
412        assert!(args.long);
413        assert_eq!(args.icons, IconsArg::Always);
414        assert!(args.human_readable);
415    }
416
417    #[test]
418    fn merge_color_from_config() {
419        let cfg = parse_config_str(r#"color = "never""#).unwrap();
420        let mut args = empty_args();
421        merge_config_into_args(&mut args, &cfg);
422        assert!(matches!(args.color, ColorArg::Never));
423    }
424
425    #[test]
426    fn parse_color_arg_gnu_synonyms() {
427        for s in ["auto", "tty", "if-tty", "TTY", "If-Tty"] {
428            assert!(
429                matches!(parse_color_arg(s), Some(ColorArg::Auto)),
430                "auto synonym: {s}"
431            );
432        }
433        for s in ["always", "yes", "force", "true", "on"] {
434            assert!(
435                matches!(parse_color_arg(s), Some(ColorArg::Always)),
436                "always synonym: {s}"
437            );
438        }
439        for s in ["never", "no", "none", "false", "off"] {
440            assert!(
441                matches!(parse_color_arg(s), Some(ColorArg::Never)),
442                "never synonym: {s}"
443            );
444        }
445        assert!(parse_color_arg("rainbow").is_none());
446    }
447
448    #[test]
449    fn merge_color_tty_from_config() {
450        let cfg = parse_config_str(r#"color = "tty""#).unwrap();
451        let mut args = empty_args();
452        merge_config_into_args(&mut args, &cfg);
453        assert!(matches!(args.color, ColorArg::Auto));
454    }
455
456    #[test]
457    fn merge_git_false_from_config() {
458        let cfg = parse_config_str(r#"git = false"#).unwrap();
459        let mut args = empty_args();
460        assert!(args.git);
461        merge_config_into_args(&mut args, &cfg);
462        // Without --git on the real CLI of this test process, config applies.
463        if !cli_has_long("--git") {
464            assert!(!args.git);
465        }
466    }
467
468    #[test]
469    fn argv0_ls_detection() {
470        assert!(invoked_as_ls_from(Some("ls".into())));
471        assert!(invoked_as_ls_from(Some("/usr/bin/ls".into())));
472        assert!(invoked_as_ls_from(Some(r"C:\bin\ls.exe".into())));
473        assert!(invoked_as_ls_from(Some("LS".into())));
474        assert!(!invoked_as_ls_from(Some("f00".into())));
475        assert!(!invoked_as_ls_from(Some("/usr/local/bin/f00".into())));
476        assert!(!invoked_as_ls_from(None));
477    }
478
479    #[test]
480    fn platform_path_ends_with_config_toml() {
481        if let Some(p) = platform_config_path() {
482            assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("config.toml"));
483            assert!(p
484                .components()
485                .any(|c| c.as_os_str() == "f00" || c.as_os_str() == ".config"));
486        }
487    }
488
489    #[test]
490    fn resolve_ls_soft_mode_keeps_default_icons() {
491        let mut args = empty_args();
492        assert_eq!(args.icons, IconsArg::Auto);
493        // Soft ls chrome is for interactive TTYs; pin TTY so CI pipes do not force GNU.
494        resolve_args_with_tty(&mut args, None, true, true);
495        assert_eq!(
496            args.icons,
497            IconsArg::Auto,
498            "argv0 ls must keep icons=auto (full chrome; only --gnu strips icons)"
499        );
500        assert!(
501            !args.dirs_first,
502            "soft ls still defaults dirs-first off like GNU"
503        );
504        assert!(!args.gnu, "TTY soft ls must not auto-enable --gnu");
505    }
506
507    #[test]
508    fn resolve_ls_config_can_set_icons_always() {
509        let cfg = parse_config_str(r#"icons = true"#).unwrap();
510        let mut args = empty_args();
511        resolve_args_with_tty(&mut args, Some(&cfg), true, true);
512        assert_eq!(args.icons, IconsArg::Always);
513    }
514
515    #[test]
516    fn auto_gnu_when_not_tty() {
517        let mut args = empty_args();
518        apply_auto_gnu(&mut args, false);
519        assert!(args.gnu);
520    }
521
522    #[test]
523    fn no_auto_gnu_on_tty() {
524        let mut args = empty_args();
525        apply_auto_gnu(&mut args, true);
526        assert!(!args.gnu);
527    }
528
529    #[test]
530    fn no_gnu_opts_out_of_auto() {
531        let mut args = empty_args();
532        args.no_gnu = true;
533        apply_auto_gnu(&mut args, false);
534        assert!(!args.gnu);
535    }
536
537    #[test]
538    fn explicit_gnu_stays_on_tty() {
539        let mut args = empty_args();
540        args.gnu = true;
541        apply_auto_gnu(&mut args, true);
542        assert!(args.gnu);
543    }
544
545    #[test]
546    fn no_gnu_clears_explicit_gnu_flag_if_set() {
547        // clap conflicts_with prevents both on CLI; function still clears for env safety.
548        let mut args = empty_args();
549        args.gnu = true;
550        args.no_gnu = true;
551        apply_auto_gnu(&mut args, false);
552        assert!(!args.gnu);
553    }
554}