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    match s.to_ascii_lowercase().as_str() {
121        "auto" => Some(ColorArg::Auto),
122        "always" | "yes" | "force" | "true" | "on" => Some(ColorArg::Always),
123        "never" | "no" | "none" | "false" | "off" => Some(ColorArg::Never),
124        _ => None,
125    }
126}
127
128fn parse_icons_arg(s: &str) -> Option<IconsArg> {
129    IconsWhen::parse(s).map(IconsArg::from)
130}
131
132/// Accept bool or string for `icons` in TOML (`true`/`false`/`"auto"`/`"always"`/`"never"`).
133fn deserialize_opt_icons<'de, D>(deserializer: D) -> Result<Option<IconsArg>, D::Error>
134where
135    D: serde::Deserializer<'de>,
136{
137    use serde::de::{self, Visitor};
138    use std::fmt;
139
140    struct IconsVisitor;
141
142    impl<'de> Visitor<'de> for IconsVisitor {
143        type Value = Option<IconsArg>;
144
145        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
146            f.write_str("a bool or icons when string (auto/always/never)")
147        }
148
149        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
150            Ok(None)
151        }
152
153        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
154            Ok(None)
155        }
156
157        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
158            Ok(Some(if v { IconsArg::Always } else { IconsArg::Never }))
159        }
160
161        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
162            parse_icons_arg(v)
163                .map(Some)
164                .ok_or_else(|| de::Error::custom(format!("invalid icons value: {v}")))
165        }
166
167        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
168            self.visit_str(&v)
169        }
170    }
171
172    deserializer.deserialize_any(IconsVisitor)
173}
174
175/// Whether `flag` (e.g. `--icons` or `--git`) appears in process args.
176pub fn cli_has_long(flag: &str) -> bool {
177    let eq = format!("{flag}=");
178    std::env::args().any(|a| a == flag || a.starts_with(&eq))
179}
180
181/// Merge file defaults into CLI args.
182///
183/// Precedence: built-in defaults < config < CLI flags (when set / non-default).
184/// For false-default bools, config `true` enables.
185/// For `git` (CLI default true), config applies unless `--git` was on the command line.
186/// For `color` / `icons`, config applies when no explicit `--color` / `--icons` on the CLI.
187pub fn merge_config_into_args(args: &mut Args, file: &FileConfig) {
188    let d = file.resolved_defaults();
189
190    if let Some(true) = d.all {
191        args.all = true;
192    }
193    if let Some(true) = d.almost_all {
194        args.almost_all = true;
195    }
196    if let Some(true) = d.long {
197        args.long = true;
198    }
199    if let Some(true) = d.human_readable {
200        args.human_readable = true;
201    }
202    if let Some(true) = d.classify {
203        args.classify = true;
204    }
205
206    if let Some(v) = d.icons {
207        if !cli_has_long("--icons") {
208            args.icons = v;
209        }
210    }
211    if let Some(v) = d.dirs_first {
212        if v {
213            args.dirs_first = true;
214        } else if !cli_has_long("--dirs-first") {
215            args.dirs_first = false;
216        }
217    }
218
219    if let Some(v) = d.git {
220        if !cli_has_long("--git") {
221            args.git = v;
222        }
223    }
224
225    if let Some(ref c) = d.color {
226        if !cli_has_long("--color") {
227            if let Some(parsed) = parse_color_arg(c) {
228                args.color = parsed;
229            }
230        }
231    }
232}
233
234/// Apply env overrides (`F00_GNU`).
235pub fn apply_env_overrides(args: &mut Args) {
236    if args.gnu {
237        return;
238    }
239    if let Ok(v) = std::env::var("F00_GNU") {
240        let v = v.to_ascii_lowercase();
241        if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
242            args.gnu = true;
243        }
244    }
245}
246
247/// Detect whether the binary was invoked as `ls` / `ls.exe`.
248pub fn invoked_as_ls() -> bool {
249    invoked_as_ls_from(std::env::args_os().next())
250}
251
252/// Testable argv0 check.
253pub fn invoked_as_ls_from(argv0: Option<std::ffi::OsString>) -> bool {
254    let Some(argv0) = argv0 else {
255        return false;
256    };
257    // Prefer Path (correct separators for the host OS).
258    if let Some(stem) = Path::new(&argv0).file_stem().and_then(|s| s.to_str()) {
259        if stem.eq_ignore_ascii_case("ls") {
260            return true;
261        }
262    }
263    // Also handle Windows-style paths when parsing on Unix (and vice versa).
264    if let Some(s) = argv0.to_str() {
265        let name = s.rsplit(['/', '\\']).next().unwrap_or(s);
266        let stem = name
267            .strip_suffix(".exe")
268            .or_else(|| name.strip_suffix(".EXE"))
269            .unwrap_or(name);
270        return stem.eq_ignore_ascii_case("ls");
271    }
272    false
273}
274
275/// Resolve effective args after clap parse.
276///
277/// Order: argv0 soft defaults → config file → env (`F00_GNU`).
278/// CLI flags already present in `args` win over config where merge logic allows.
279pub fn resolve_args(args: &mut Args, file: Option<&FileConfig>, as_ls: bool) {
280    if as_ls {
281        // Icons / dirs_first off unless the user passed the flags (config may re-enable).
282        let mut icons = IconsWhen::from(args.icons);
283        f00_compat::prefer_ls_defaults(
284            &mut icons,
285            &mut args.dirs_first,
286            cli_has_long("--icons"),
287            cli_has_long("--dirs-first"),
288        );
289        args.icons = IconsArg::from(icons);
290    }
291    if let Some(file) = file {
292        merge_config_into_args(args, file);
293    }
294    apply_env_overrides(args);
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::cli::{ColorArg, IconsArg};
301
302    fn empty_args() -> Args {
303        let mut a = Args::test_default();
304        a.git = true;
305        a.color = ColorArg::Auto;
306        a
307    }
308
309    #[test]
310    fn parse_defaults_section() {
311        let cfg = parse_config_str(
312            r#"
313            [defaults]
314            all = true
315            icons = true
316            color = "never"
317            dirs_first = true
318            git = false
319            "#,
320        )
321        .unwrap();
322        let d = cfg.resolved_defaults();
323        assert_eq!(d.all, Some(true));
324        assert_eq!(d.icons, Some(IconsArg::Always));
325        assert_eq!(d.color.as_deref(), Some("never"));
326        assert_eq!(d.dirs_first, Some(true));
327        assert_eq!(d.git, Some(false));
328    }
329
330    #[test]
331    fn parse_icons_when_string() {
332        let cfg = parse_config_str(r#"icons = "auto""#).unwrap();
333        assert_eq!(cfg.resolved_defaults().icons, Some(IconsArg::Auto));
334        let cfg = parse_config_str(r#"icons = "never""#).unwrap();
335        assert_eq!(cfg.resolved_defaults().icons, Some(IconsArg::Never));
336    }
337
338    #[test]
339    fn parse_root_level_and_human_alias() {
340        let cfg = parse_config_str(
341            r#"
342            long = true
343            human = true
344            classify = true
345            "#,
346        )
347        .unwrap();
348        let d = cfg.resolved_defaults();
349        assert_eq!(d.long, Some(true));
350        assert_eq!(d.human_readable, Some(true));
351        assert_eq!(d.classify, Some(true));
352    }
353
354    #[test]
355    fn merge_enables_flags_from_config() {
356        let cfg = parse_config_str(
357            r#"
358            [defaults]
359            all = true
360            long = true
361            icons = true
362            human_readable = true
363            "#,
364        )
365        .unwrap();
366        let mut args = empty_args();
367        merge_config_into_args(&mut args, &cfg);
368        assert!(args.all);
369        assert!(args.long);
370        assert_eq!(args.icons, IconsArg::Always);
371        assert!(args.human_readable);
372    }
373
374    #[test]
375    fn merge_color_from_config() {
376        let cfg = parse_config_str(r#"color = "never""#).unwrap();
377        let mut args = empty_args();
378        merge_config_into_args(&mut args, &cfg);
379        assert!(matches!(args.color, ColorArg::Never));
380    }
381
382    #[test]
383    fn merge_git_false_from_config() {
384        let cfg = parse_config_str(r#"git = false"#).unwrap();
385        let mut args = empty_args();
386        assert!(args.git);
387        merge_config_into_args(&mut args, &cfg);
388        // Without --git on the real CLI of this test process, config applies.
389        if !cli_has_long("--git") {
390            assert!(!args.git);
391        }
392    }
393
394    #[test]
395    fn argv0_ls_detection() {
396        assert!(invoked_as_ls_from(Some("ls".into())));
397        assert!(invoked_as_ls_from(Some("/usr/bin/ls".into())));
398        assert!(invoked_as_ls_from(Some(r"C:\bin\ls.exe".into())));
399        assert!(invoked_as_ls_from(Some("LS".into())));
400        assert!(!invoked_as_ls_from(Some("f00".into())));
401        assert!(!invoked_as_ls_from(Some("/usr/local/bin/f00".into())));
402        assert!(!invoked_as_ls_from(None));
403    }
404
405    #[test]
406    fn platform_path_ends_with_config_toml() {
407        if let Some(p) = platform_config_path() {
408            assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("config.toml"));
409            assert!(p
410                .components()
411                .any(|c| c.as_os_str() == "f00" || c.as_os_str() == ".config"));
412        }
413    }
414
415    #[test]
416    fn resolve_ls_then_config_can_enable_icons() {
417        let cfg = parse_config_str(r#"icons = true"#).unwrap();
418        let mut args = empty_args();
419        resolve_args(&mut args, Some(&cfg), true);
420        assert_eq!(
421            args.icons,
422            IconsArg::Always,
423            "config should re-enable icons under argv0 ls"
424        );
425    }
426}