Skip to main content

lowfat_core/
config.rs

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/// Resolved lowfat configuration from env + .lowfat file.
9#[derive(Debug)]
10pub struct RunfConfig {
11    pub level: Level,
12    pub disabled: HashSet<String>,
13    /// Some = whitelist mode (only these filters active)
14    pub allowed: Option<HashSet<String>>,
15    pub data_dir: PathBuf,
16    pub plugin_dir: PathBuf,
17    pub home_dir: PathBuf,
18    /// Per-command conditional pipelines from .lowfat config.
19    /// Supports: pipeline.git = ..., pipeline.git.error = ..., pipeline.git.large = ...
20    pub pipelines: HashMap<String, ConditionalPipelines>,
21}
22
23impl RunfConfig {
24    /// Resolve configuration from environment and .lowfat config walking.
25    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        // Level: LOWFAT_LEVEL env > .lowfat config > default
48        let mut level = Level::Full;
49        let mut disabled = HashSet::new();
50        let mut allowed: Option<HashSet<String>> = None;
51        // Collect raw pipeline lines for post-processing into ConditionalPipelines
52        // Key: (command, condition) e.g., ("git", "") or ("git", "error")
53        let mut pipeline_lines: HashMap<String, Vec<(String, String)>> = HashMap::new();
54        let mut pipelines = HashMap::new();
55
56        // Parse .lowfat config (walk up from cwd)
57        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                        // pipeline.git = strip-ansi | git-compact | truncate
78                        // pipeline.git.error = strip-ansi | head
79                        // pipeline.git.large = git-compact | token-budget
80                        if let Some((key, spec)) = rest.split_once('=') {
81                            let key = key.trim();
82                            let spec = spec.trim().to_string();
83                            // Split "git.error" → cmd="git", condition="error"
84                            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        // Build ConditionalPipelines from collected lines
99        for (cmd, lines) in pipeline_lines {
100            pipelines.insert(cmd, parse_conditional_pipeline(&lines));
101        }
102
103        // LOWFAT_DISABLE env overrides
104        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        // LOWFAT_LEVEL env takes highest priority
111        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    /// Get the conditional pipelines for a command, if configured.
129    pub fn pipeline_for(&self, cmd: &str) -> Option<&ConditionalPipelines> {
130        self.pipelines.get(cmd)
131    }
132
133    /// Check if a filter name is enabled under current config.
134    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
145/// Walk up from cwd to find nearest `.lowfat` config file.
146pub 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
165/// Resolve the plugin / config home directory.
166///
167/// Precedence (highest to lowest):
168///   1. `$LOWFAT_HOME` — explicit override always wins
169///   2. `$XDG_CONFIG_HOME/lowfat` if `XDG_CONFIG_HOME` is set
170///   3. `~/.config/lowfat` if that directory already exists (XDG default)
171///   4. `~/.lowfat` — fallback when none of the above apply
172///
173/// When both an XDG path and `~/.lowfat` exist, prints a one-shot warning
174/// to stderr and prefers XDG. Pure function — takes env vars + a
175/// `path_exists` closure so tests don't touch the real fs.
176pub 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
218/// Find the .lowfat config path (exposed for display purposes).
219pub 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}