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 RunfConfig {
118 level,
119 disabled,
120 allowed,
121 data_dir,
122 plugin_dir,
123 home_dir,
124 pipelines,
125 }
126 }
127
128 pub fn pipeline_for(&self, cmd: &str) -> Option<&ConditionalPipelines> {
130 self.pipelines.get(cmd)
131 }
132
133 pub fn is_enabled(&self, name: &str) -> bool {
135 if self.disabled.contains(name) {
136 return false;
137 }
138 if let Some(ref allowed) = self.allowed {
139 return allowed.contains(name);
140 }
141 true
142 }
143}
144
145pub fn find_config() -> Option<PathBuf> {
147 let mut dir = env::current_dir().ok()?;
148 loop {
149 let candidate = dir.join(".lowfat");
150 if candidate.is_file() {
151 return Some(candidate);
152 }
153 if !dir.pop() {
154 return None;
155 }
156 }
157}
158
159fn dirs_home() -> PathBuf {
160 env::var("HOME")
161 .map(PathBuf::from)
162 .unwrap_or_else(|_| PathBuf::from("/tmp"))
163}
164
165pub fn resolve_home_dir(
177 lowfat_home: Option<&str>,
178 xdg_config_home: Option<&str>,
179 home: &std::path::Path,
180 path_exists: &dyn Fn(&std::path::Path) -> bool,
181) -> PathBuf {
182 if let Some(h) = lowfat_home {
183 return PathBuf::from(h);
184 }
185
186 let dot_lowfat = home.join(".lowfat");
187
188 if let Some(xdg) = xdg_config_home {
189 let xdg_path = PathBuf::from(xdg).join("lowfat");
190 warn_if_both_exist(&xdg_path, &dot_lowfat, path_exists);
191 return xdg_path;
192 }
193
194 let xdg_default = home.join(".config").join("lowfat");
195 if path_exists(&xdg_default) {
196 warn_if_both_exist(&xdg_default, &dot_lowfat, path_exists);
197 return xdg_default;
198 }
199
200 dot_lowfat
201}
202
203fn warn_if_both_exist(
204 chosen: &std::path::Path,
205 other: &std::path::Path,
206 path_exists: &dyn Fn(&std::path::Path) -> bool,
207) {
208 if chosen != other && path_exists(chosen) && path_exists(other) {
209 eprintln!(
210 "[lowfat] warning: both {} and {} exist; using {}. Remove one to silence this.",
211 chosen.display(),
212 other.display(),
213 chosen.display(),
214 );
215 }
216}
217
218pub fn find_config_display() -> Option<PathBuf> {
220 find_config()
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn is_enabled_default() {
229 let config = RunfConfig {
230 level: Level::Full,
231 disabled: HashSet::new(),
232 allowed: None,
233 data_dir: PathBuf::new(),
234 plugin_dir: PathBuf::new(),
235 home_dir: PathBuf::new(),
236 pipelines: HashMap::new(),
237 };
238 assert!(config.is_enabled("git"));
239 assert!(config.is_enabled("docker"));
240 }
241
242 #[test]
243 fn is_enabled_disabled() {
244 let mut disabled = HashSet::new();
245 disabled.insert("npm".to_string());
246 let config = RunfConfig {
247 level: Level::Full,
248 disabled,
249 allowed: None,
250 data_dir: PathBuf::new(),
251 plugin_dir: PathBuf::new(),
252 home_dir: PathBuf::new(),
253 pipelines: HashMap::new(),
254 };
255 assert!(!config.is_enabled("npm"));
256 assert!(config.is_enabled("git"));
257 }
258
259 #[test]
260 fn home_explicit_lowfat_home_wins() {
261 let r = resolve_home_dir(
262 Some("/custom/lf"),
263 Some("/wrong/.config"),
264 std::path::Path::new("/home/user"),
265 &|_| true,
266 );
267 assert_eq!(r, PathBuf::from("/custom/lf"));
268 }
269
270 #[test]
271 fn home_xdg_env_used_when_set_even_if_path_missing() {
272 let r = resolve_home_dir(
273 None,
274 Some("/explicit/.config"),
275 std::path::Path::new("/home/user"),
276 &|_| false,
277 );
278 assert_eq!(r, PathBuf::from("/explicit/.config/lowfat"));
279 }
280
281 #[test]
282 fn home_xdg_default_used_when_path_exists() {
283 let home = PathBuf::from("/home/user");
284 let xdg = home.join(".config/lowfat");
285 let r = resolve_home_dir(None, None, &home, &|p| p == xdg.as_path());
286 assert_eq!(r, xdg);
287 }
288
289 #[test]
290 fn home_dot_lowfat_used_when_neither_xdg_set_nor_exists() {
291 let r = resolve_home_dir(
292 None,
293 None,
294 std::path::Path::new("/home/user"),
295 &|_| false,
296 );
297 assert_eq!(r, PathBuf::from("/home/user/.lowfat"));
298 }
299
300 #[test]
301 fn home_dot_lowfat_used_when_only_it_exists() {
302 let home = PathBuf::from("/home/user");
303 let dot_lowfat = home.join(".lowfat");
304 let r = resolve_home_dir(None, None, &home, &|p| p == dot_lowfat.as_path());
305 assert_eq!(r, dot_lowfat);
306 }
307
308 #[test]
309 fn home_xdg_wins_when_both_exist() {
310 let home = PathBuf::from("/home/user");
311 let r = resolve_home_dir(None, None, &home, &|_| true);
312 assert_eq!(r, home.join(".config/lowfat"));
313 }
314
315 #[test]
316 fn is_enabled_whitelist() {
317 let mut allowed = HashSet::new();
318 allowed.insert("git".to_string());
319 allowed.insert("docker".to_string());
320 let config = RunfConfig {
321 level: Level::Full,
322 disabled: HashSet::new(),
323 allowed: Some(allowed),
324 data_dir: PathBuf::new(),
325 plugin_dir: PathBuf::new(),
326 home_dir: PathBuf::new(),
327 pipelines: HashMap::new(),
328 };
329 assert!(config.is_enabled("git"));
330 assert!(!config.is_enabled("npm"));
331 }
332}