Skip to main content

greplm_core/
config.rs

1//! Index configuration persisted at `.greplm/config.toml`.
2
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6use crate::error::{Error, Result};
7
8/// Which ingest read backend to use.
9///
10/// Only the portable backend exists today; the enum is kept so `config.toml`
11/// stays forward-compatible if a batched-submission backend is added.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "kebab-case")]
14pub enum Backend {
15    /// Portable rayon-based read pool (all platforms).
16    #[default]
17    Auto,
18    /// Force the portable backend.
19    Rayon,
20    /// Accepted only for compatibility with configs written before 0.5.0, when
21    /// this value selected a stub that did buffered reads anyway. Resolves to
22    /// [`Backend::Rayon`] with a warning, so an existing `config.toml` keeps
23    /// working instead of failing every command over a value that never did
24    /// anything.
25    #[serde(rename = "io-uring")]
26    IoUringRemoved,
27}
28
29/// Persistent project configuration.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Config {
33    /// Glob patterns to include (empty = all text files).
34    pub include: Vec<String>,
35    /// Extra glob patterns to exclude (on top of .gitignore).
36    pub exclude: Vec<String>,
37    /// Skip files larger than this many bytes. `0` disables the cap entirely
38    /// (grep parity: grep has no size limit).
39    pub max_file_size: u64,
40    /// Honor `.gitignore` / `.ignore` files during the walk.
41    pub respect_gitignore: bool,
42    /// Index hidden files and directories.
43    pub index_hidden: bool,
44    /// Index files containing NUL bytes (binary). Off by default; grep scans
45    /// such files (`grep -a`), so enabling this restores grep parity.
46    pub index_binary: bool,
47    /// Index empty (zero-byte) files. Off by default since they never match.
48    pub index_empty: bool,
49    /// Ingest read backend.
50    pub backend: Backend,
51    /// Merge segments automatically once this many accumulate.
52    pub merge_threshold: usize,
53}
54
55impl Default for Config {
56    fn default() -> Self {
57        Self {
58            include: Vec::new(),
59            exclude: vec![
60                "**/.git/**".to_string(),
61                "**/node_modules/**".to_string(),
62                "**/target/**".to_string(),
63                "**/.greplm/**".to_string(),
64            ],
65            max_file_size: 4 * 1024 * 1024,
66            respect_gitignore: true,
67            index_hidden: false,
68            index_binary: false,
69            index_empty: false,
70            backend: Backend::Auto,
71            merge_threshold: 16,
72        }
73    }
74}
75
76impl Config {
77    pub fn load(path: &Path) -> Result<Config> {
78        let mut config = match std::fs::read_to_string(path) {
79            Ok(s) => toml::from_str(&s)?,
80            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
81            Err(e) => return Err(Error::io(path, e)),
82        };
83        config.apply_env_overrides();
84        Ok(config)
85    }
86
87    /// Overlay `GREPLM_*` environment variables on top of the loaded config.
88    /// Env wins over the file so a one-off `GREPLM_INDEX_BINARY=1 greplm index`
89    /// works without editing `config.toml`. Unset or unparseable vars are
90    /// ignored (the file/default value stands).
91    pub fn apply_env_overrides(&mut self) {
92        if let Some(v) = env_u64("GREPLM_MAX_FILE_SIZE") {
93            self.max_file_size = v;
94        }
95        if let Some(v) = env_bool("GREPLM_RESPECT_GITIGNORE") {
96            self.respect_gitignore = v;
97        }
98        if let Some(v) = env_bool("GREPLM_INDEX_HIDDEN") {
99            self.index_hidden = v;
100        }
101        if let Some(v) = env_bool("GREPLM_INDEX_BINARY") {
102            self.index_binary = v;
103        }
104        if let Some(v) = env_bool("GREPLM_INDEX_EMPTY") {
105            self.index_empty = v;
106        }
107    }
108
109    pub fn save(&self, path: &Path) -> Result<()> {
110        let s = toml::to_string_pretty(self)?;
111        std::fs::write(path, s).map_err(|e| Error::io(path, e))
112    }
113}
114
115/// Parse a boolean environment variable. Accepts `1/true/yes/on` and
116/// `0/false/no/off` (case-insensitive); anything else (or unset) yields `None`.
117fn env_bool(key: &str) -> Option<bool> {
118    let raw = std::env::var(key).ok()?;
119    match raw.trim().to_ascii_lowercase().as_str() {
120        "1" | "true" | "yes" | "on" => Some(true),
121        "0" | "false" | "no" | "off" => Some(false),
122        _ => None,
123    }
124}
125
126/// Parse an unsigned-integer environment variable; unset/unparseable yields
127/// `None`.
128fn env_u64(key: &str) -> Option<u64> {
129    std::env::var(key).ok()?.trim().parse().ok()
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn defaults_are_grep_conservative() {
138        let c = Config::default();
139        assert!(!c.index_binary);
140        assert!(!c.index_empty);
141        assert_eq!(c.max_file_size, 4 * 1024 * 1024);
142    }
143
144    #[test]
145    fn env_bool_parses_common_forms() {
146        for (raw, want) in [
147            ("1", true),
148            ("TRUE", true),
149            ("Yes", true),
150            ("on", true),
151            ("0", false),
152            ("false", false),
153            ("off", false),
154        ] {
155            // SAFETY: single-threaded test; key is unique per case.
156            let key = format!("GREPLM_TEST_BOOL_{raw}");
157            std::env::set_var(&key, raw);
158            assert_eq!(env_bool(&key), Some(want), "raw={raw}");
159            std::env::remove_var(&key);
160        }
161        assert_eq!(env_bool("GREPLM_TEST_BOOL_UNSET_XYZ"), None);
162    }
163}