1use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16use crate::cli::{Args, ColorArg, IconsArg};
17use f00_core::IconsWhen;
18
19#[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
39#[serde(default)]
40pub struct FileConfig {
41 #[serde(default)]
42 pub defaults: ConfigDefaults,
43 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 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
74pub fn parse_config_str(s: &str) -> Result<FileConfig, toml::de::Error> {
76 toml::from_str(s)
77}
78
79pub 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
87pub fn platform_config_path() -> Option<PathBuf> {
89 directories::ProjectDirs::from("", "", "f00").map(|d| d.config_dir().join("config.toml"))
90}
91
92pub 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
106pub 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() {
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
133fn 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
176pub 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
182pub 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
235pub 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
258pub 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
276pub fn invoked_as_ls() -> bool {
278 invoked_as_ls_from(std::env::args_os().next())
279}
280
281pub fn invoked_as_ls_from(argv0: Option<std::ffi::OsString>) -> bool {
283 let Some(argv0) = argv0 else {
284 return false;
285 };
286 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 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
304pub 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
318pub 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 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 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 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 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}