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() {
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
132fn 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
175pub 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
181pub 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
234pub 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
247pub fn invoked_as_ls() -> bool {
249 invoked_as_ls_from(std::env::args_os().next())
250}
251
252pub fn invoked_as_ls_from(argv0: Option<std::ffi::OsString>) -> bool {
254 let Some(argv0) = argv0 else {
255 return false;
256 };
257 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 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
275pub fn resolve_args(args: &mut Args, file: Option<&FileConfig>, as_ls: bool) {
280 if as_ls {
281 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 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}