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        // Redaction ruleset: built-in defaults < global redact.conf <
118        // project redact.conf (beside the discovered .lowfat).
119        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    /// Get the conditional pipelines for a command, if configured.
135    pub fn pipeline_for(&self, cmd: &str) -> Option<&ConditionalPipelines> {
136        self.pipelines.get(cmd)
137    }
138
139    /// Check if a filter name is enabled under current config.
140    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
151/// Walk up from cwd to find nearest `.lowfat` config file.
152pub 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
171/// Resolve the plugin / config home directory.
172///
173/// Precedence (highest to lowest):
174///   1. `$LOWFAT_HOME` — explicit override always wins
175///   2. `$XDG_CONFIG_HOME/lowfat` if `XDG_CONFIG_HOME` is set
176///   3. `~/.config/lowfat` if that directory already exists (XDG default)
177///   4. `~/.lowfat` — fallback when none of the above apply
178///
179/// When both an XDG path and `~/.lowfat` exist, prints a one-shot warning
180/// to stderr and prefers XDG. Pure function — takes env vars + a
181/// `path_exists` closure so tests don't touch the real fs.
182pub 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
224/// Find the .lowfat config path (exposed for display purposes).
225pub 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}