1use crate::level::Level;
2use crate::pipeline::{ConditionalPipelines, parse_conditional_pipeline};
3use std::collections::{HashMap, HashSet};
4use std::env;
5use std::fs;
6use std::path::PathBuf;
7
8#[derive(Debug)]
10pub struct RunfConfig {
11 pub level: Level,
12 pub disabled: HashSet<String>,
13 pub allowed: Option<HashSet<String>>,
15 pub data_dir: PathBuf,
16 pub plugin_dir: PathBuf,
17 pub home_dir: PathBuf,
18 pub pipelines: HashMap<String, ConditionalPipelines>,
21}
22
23impl RunfConfig {
24 pub fn resolve() -> Self {
26 let lowfat_home = env::var("LOWFAT_HOME").ok();
27 let xdg_config_home = env::var("XDG_CONFIG_HOME").ok();
28 let home = dirs_home();
29 let home_dir = resolve_home_dir(
30 lowfat_home.as_deref(),
31 xdg_config_home.as_deref(),
32 &home,
33 &|p| p.exists(),
34 );
35
36 let data_dir = env::var("LOWFAT_DATA")
37 .map(PathBuf::from)
38 .unwrap_or_else(|_| {
39 env::var("XDG_DATA_HOME")
40 .map(PathBuf::from)
41 .unwrap_or_else(|_| dirs_home().join(".local/share"))
42 .join("lowfat")
43 });
44
45 let plugin_dir = home_dir.join("plugins");
46
47 let mut level = Level::Full;
49 let mut disabled = HashSet::new();
50 let mut allowed: Option<HashSet<String>> = None;
51 let mut pipeline_lines: HashMap<String, Vec<(String, String)>> = HashMap::new();
54 let mut pipelines = HashMap::new();
55
56 if let Some(config_path) = find_config() {
58 if let Ok(content) = fs::read_to_string(&config_path) {
59 for line in content.lines() {
60 let line = line.trim();
61 if line.is_empty() || line.starts_with('#') {
62 continue;
63 }
64 if let Some(val) = line.strip_prefix("level=") {
65 if let Ok(l) = val.parse() {
66 level = l;
67 }
68 } else if let Some(val) = line.strip_prefix("filters=") {
69 allowed = Some(
70 val.split(',').map(|s| s.trim().to_string()).collect(),
71 );
72 } else if let Some(val) = line.strip_prefix("disable=") {
73 for name in val.split(',') {
74 disabled.insert(name.trim().to_string());
75 }
76 } else if let Some(rest) = line.strip_prefix("pipeline.") {
77 if let Some((key, spec)) = rest.split_once('=') {
81 let key = key.trim();
82 let spec = spec.trim().to_string();
83 let (cmd, condition) = match key.split_once('.') {
85 Some((c, cond)) => (c.to_string(), cond.to_string()),
86 None => (key.to_string(), String::new()),
87 };
88 pipeline_lines
89 .entry(cmd)
90 .or_default()
91 .push((condition, spec));
92 }
93 }
94 }
95 }
96 }
97
98 for (cmd, lines) in pipeline_lines {
100 pipelines.insert(cmd, parse_conditional_pipeline(&lines));
101 }
102
103 if let Ok(val) = env::var("LOWFAT_DISABLE") {
105 for name in val.split(',') {
106 disabled.insert(name.trim().to_string());
107 }
108 }
109
110 if let Ok(val) = env::var("LOWFAT_LEVEL") {
112 if let Ok(l) = val.parse() {
113 level = l;
114 }
115 }
116
117 let (global_redact, project_redact) =
120 crate::redact::paths(&home_dir, find_config().as_deref());
121 crate::redact::init(Some(&global_redact), project_redact.as_deref());
122
123 RunfConfig {
124 level,
125 disabled,
126 allowed,
127 data_dir,
128 plugin_dir,
129 home_dir,
130 pipelines,
131 }
132 }
133
134 pub fn pipeline_for(&self, cmd: &str) -> Option<&ConditionalPipelines> {
136 self.pipelines.get(cmd)
137 }
138
139 pub fn is_enabled(&self, name: &str) -> bool {
141 if self.disabled.contains(name) {
142 return false;
143 }
144 if let Some(ref allowed) = self.allowed {
145 return allowed.contains(name);
146 }
147 true
148 }
149}
150
151pub fn find_config() -> Option<PathBuf> {
153 let mut dir = env::current_dir().ok()?;
154 loop {
155 let candidate = dir.join(".lowfat");
156 if candidate.is_file() {
157 return Some(candidate);
158 }
159 if !dir.pop() {
160 return None;
161 }
162 }
163}
164
165fn dirs_home() -> PathBuf {
166 env::var("HOME")
167 .map(PathBuf::from)
168 .unwrap_or_else(|_| PathBuf::from("/tmp"))
169}
170
171pub fn resolve_home_dir(
183 lowfat_home: Option<&str>,
184 xdg_config_home: Option<&str>,
185 home: &std::path::Path,
186 path_exists: &dyn Fn(&std::path::Path) -> bool,
187) -> PathBuf {
188 if let Some(h) = lowfat_home {
189 return PathBuf::from(h);
190 }
191
192 let dot_lowfat = home.join(".lowfat");
193
194 if let Some(xdg) = xdg_config_home {
195 let xdg_path = PathBuf::from(xdg).join("lowfat");
196 warn_if_both_exist(&xdg_path, &dot_lowfat, path_exists);
197 return xdg_path;
198 }
199
200 let xdg_default = home.join(".config").join("lowfat");
201 if path_exists(&xdg_default) {
202 warn_if_both_exist(&xdg_default, &dot_lowfat, path_exists);
203 return xdg_default;
204 }
205
206 dot_lowfat
207}
208
209fn warn_if_both_exist(
210 chosen: &std::path::Path,
211 other: &std::path::Path,
212 path_exists: &dyn Fn(&std::path::Path) -> bool,
213) {
214 if chosen != other && path_exists(chosen) && path_exists(other) {
215 eprintln!(
216 "[lowfat] warning: both {} and {} exist; using {}. Remove one to silence this.",
217 chosen.display(),
218 other.display(),
219 chosen.display(),
220 );
221 }
222}
223
224pub fn find_config_display() -> Option<PathBuf> {
226 find_config()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn is_enabled_default() {
235 let config = RunfConfig {
236 level: Level::Full,
237 disabled: HashSet::new(),
238 allowed: None,
239 data_dir: PathBuf::new(),
240 plugin_dir: PathBuf::new(),
241 home_dir: PathBuf::new(),
242 pipelines: HashMap::new(),
243 };
244 assert!(config.is_enabled("git"));
245 assert!(config.is_enabled("docker"));
246 }
247
248 #[test]
249 fn is_enabled_disabled() {
250 let mut disabled = HashSet::new();
251 disabled.insert("npm".to_string());
252 let config = RunfConfig {
253 level: Level::Full,
254 disabled,
255 allowed: None,
256 data_dir: PathBuf::new(),
257 plugin_dir: PathBuf::new(),
258 home_dir: PathBuf::new(),
259 pipelines: HashMap::new(),
260 };
261 assert!(!config.is_enabled("npm"));
262 assert!(config.is_enabled("git"));
263 }
264
265 #[test]
266 fn home_explicit_lowfat_home_wins() {
267 let r = resolve_home_dir(
268 Some("/custom/lf"),
269 Some("/wrong/.config"),
270 std::path::Path::new("/home/user"),
271 &|_| true,
272 );
273 assert_eq!(r, PathBuf::from("/custom/lf"));
274 }
275
276 #[test]
277 fn home_xdg_env_used_when_set_even_if_path_missing() {
278 let r = resolve_home_dir(
279 None,
280 Some("/explicit/.config"),
281 std::path::Path::new("/home/user"),
282 &|_| false,
283 );
284 assert_eq!(r, PathBuf::from("/explicit/.config/lowfat"));
285 }
286
287 #[test]
288 fn home_xdg_default_used_when_path_exists() {
289 let home = PathBuf::from("/home/user");
290 let xdg = home.join(".config/lowfat");
291 let r = resolve_home_dir(None, None, &home, &|p| p == xdg.as_path());
292 assert_eq!(r, xdg);
293 }
294
295 #[test]
296 fn home_dot_lowfat_used_when_neither_xdg_set_nor_exists() {
297 let r = resolve_home_dir(
298 None,
299 None,
300 std::path::Path::new("/home/user"),
301 &|_| false,
302 );
303 assert_eq!(r, PathBuf::from("/home/user/.lowfat"));
304 }
305
306 #[test]
307 fn home_dot_lowfat_used_when_only_it_exists() {
308 let home = PathBuf::from("/home/user");
309 let dot_lowfat = home.join(".lowfat");
310 let r = resolve_home_dir(None, None, &home, &|p| p == dot_lowfat.as_path());
311 assert_eq!(r, dot_lowfat);
312 }
313
314 #[test]
315 fn home_xdg_wins_when_both_exist() {
316 let home = PathBuf::from("/home/user");
317 let r = resolve_home_dir(None, None, &home, &|_| true);
318 assert_eq!(r, home.join(".config/lowfat"));
319 }
320
321 #[test]
322 fn is_enabled_whitelist() {
323 let mut allowed = HashSet::new();
324 allowed.insert("git".to_string());
325 allowed.insert("docker".to_string());
326 let config = RunfConfig {
327 level: Level::Full,
328 disabled: HashSet::new(),
329 allowed: Some(allowed),
330 data_dir: PathBuf::new(),
331 plugin_dir: PathBuf::new(),
332 home_dir: PathBuf::new(),
333 pipelines: HashMap::new(),
334 };
335 assert!(config.is_enabled("git"));
336 assert!(!config.is_enabled("npm"));
337 }
338}