Skip to main content

dejavu/config/
mod.rs

1//! Effective configuration (spec §18): defaults ← global `config.toml` ←
2//! project `.dejavu.toml`. Each layer supplies only the keys it overrides.
3
4mod defaults;
5
6use crate::error::ConfigError;
7use crate::paths::{config_file_path, CacheLayout};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// Canonical list of interceptable command names (spec §10). This is the single
12/// source of truth; `exec::shim` generates a shim for each enabled entry.
13pub const SHIM_NAMES: &[&str] = &[
14    "npm", "pnpm", "yarn", "bun", "git", "rg", "grep", "find", "ls", "tree", "tsc", "eslint",
15    "vitest", "jest", "pytest", "cargo", "go", "docker",
16];
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
19#[serde(default)]
20pub struct Config {
21    pub enabled: bool,
22    pub store_raw_outputs: bool,
23    pub redact_secrets: bool,
24    pub max_raw_output_bytes: u64,
25    pub min_raw_tokens_to_reduce: u64,
26    pub max_emitted_lines_first_seen: usize,
27    pub max_emitted_lines_large_delta: usize,
28    pub max_emitted_lines_small_delta: usize,
29    pub small_delta_max_changed_lines: usize,
30    pub small_delta_max_changed_ratio: f64,
31    pub estimate_tokens_method: String,
32    pub retention_days: u32,
33    pub intercept: InterceptConfig,
34}
35
36#[derive(Debug, Clone, Deserialize, Serialize)]
37#[serde(default)]
38pub struct InterceptConfig {
39    pub npm: bool,
40    pub pnpm: bool,
41    pub yarn: bool,
42    pub bun: bool,
43    pub tsc: bool,
44    pub eslint: bool,
45    pub vitest: bool,
46    pub jest: bool,
47    pub pytest: bool,
48    pub cargo: bool,
49    pub go: bool,
50    pub git: bool,
51    pub rg: bool,
52    pub grep: bool,
53    pub find: bool,
54    pub ls: bool,
55    pub tree: bool,
56    pub docker: bool,
57    /// User-added command names to intercept, e.g. `extra = ["make", "terraform"]`.
58    /// Each gets a shim and is reduced generically (validation family: dedup,
59    /// deltas, bounded summaries, parser sniffing) with the usual guards
60    /// (watch-mode passthrough, min-token floor, agent gating).
61    pub extra: Vec<String>,
62}
63
64impl InterceptConfig {
65    /// Whether a given shim name is enabled for interception.
66    pub fn is_enabled(&self, shim: &str) -> bool {
67        match shim {
68            "npm" => self.npm,
69            "pnpm" => self.pnpm,
70            "yarn" => self.yarn,
71            "bun" => self.bun,
72            "tsc" => self.tsc,
73            "eslint" => self.eslint,
74            "vitest" => self.vitest,
75            "jest" => self.jest,
76            "pytest" => self.pytest,
77            "cargo" => self.cargo,
78            "go" => self.go,
79            "git" => self.git,
80            "rg" => self.rg,
81            "grep" => self.grep,
82            "find" => self.find,
83            "ls" => self.ls,
84            "tree" => self.tree,
85            "docker" => self.docker,
86            _ => self.is_extra(shim),
87        }
88    }
89
90    /// Whether a name comes from the user's `extra` list (and is not a builtin
91    /// — builtins keep their specialized classification).
92    pub fn is_extra(&self, shim: &str) -> bool {
93        !SHIM_NAMES.contains(&shim) && self.sane_extra().any(|e| e == shim)
94    }
95
96    /// The enabled shim names (builtins in stable order, then extras).
97    pub fn enabled_shims(&self) -> Vec<String> {
98        let mut out: Vec<String> = SHIM_NAMES
99            .iter()
100            .filter(|name| self.is_enabled(name))
101            .map(|s| s.to_string())
102            .collect();
103        for extra in self.sane_extra() {
104            if !SHIM_NAMES.contains(&extra) && !out.iter().any(|o| o == extra) {
105                out.push(extra.to_string());
106            }
107        }
108        out
109    }
110
111    /// `extra` entries that are safe to use as shim file names.
112    fn sane_extra(&self) -> impl Iterator<Item = &str> {
113        self.extra.iter().map(String::as_str).filter(|name| {
114            !name.is_empty()
115                && *name != "dejavu"
116                && !name.contains('/')
117                && !name.contains(char::is_whitespace)
118        })
119    }
120}
121
122/// Deep-merge `over` into `base` (tables merge key-by-key; scalars replace).
123fn merge_toml(base: &mut toml::Value, over: toml::Value) {
124    match over {
125        toml::Value::Table(over_table) => {
126            if let toml::Value::Table(base_table) = base {
127                for (key, value) in over_table {
128                    match base_table.get_mut(&key) {
129                        Some(existing) => merge_toml(existing, value),
130                        None => {
131                            base_table.insert(key, value);
132                        }
133                    }
134                }
135            } else {
136                *base = toml::Value::Table(over_table);
137            }
138        }
139        other => *base = other,
140    }
141}
142
143impl Config {
144    /// Load and merge the effective config for a repo.
145    pub fn load(repo_root: &Path) -> Result<Config, ConfigError> {
146        let mut merged =
147            toml::Value::try_from(Config::default()).expect("default config always serializes");
148
149        if let Ok(path) = config_file_path() {
150            if path.exists() {
151                let text = std::fs::read_to_string(&path)?;
152                let value: toml::Value =
153                    toml::from_str(&text).map_err(|source| ConfigError::Toml {
154                        path: path.clone(),
155                        source,
156                    })?;
157                merge_toml(&mut merged, value);
158            }
159        }
160
161        let project = repo_root.join(".dejavu.toml");
162        if project.exists() {
163            let text = std::fs::read_to_string(&project)?;
164            let value: toml::Value = toml::from_str(&text).map_err(|source| ConfigError::Toml {
165                path: project.clone(),
166                source,
167            })?;
168            merge_toml(&mut merged, value);
169        }
170
171        merged.try_into().map_err(|source| ConfigError::Toml {
172            path: repo_root.to_path_buf(),
173            source,
174        })
175    }
176
177    /// Serialize the effective config to `config.effective.json` for `doctor`.
178    pub fn write_effective(&self, layout: &CacheLayout) -> Result<(), ConfigError> {
179        let json = serde_json::to_string_pretty(self)?;
180        std::fs::write(layout.effective_config(), json)?;
181        Ok(())
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn defaults_match_spec_section_18() {
191        let c = Config::default();
192        assert!(c.enabled);
193        assert!(c.store_raw_outputs);
194        assert!(c.redact_secrets);
195        assert_eq!(c.max_raw_output_bytes, 5_242_880);
196        assert_eq!(c.min_raw_tokens_to_reduce, 800);
197        assert_eq!(c.small_delta_max_changed_lines, 80);
198        assert!((c.small_delta_max_changed_ratio - 0.20).abs() < 1e-9);
199        assert_eq!(c.retention_days, 14);
200        assert!(c.intercept.is_enabled("git"));
201        assert!(c.intercept.is_enabled("docker"));
202        assert!(!c.intercept.is_enabled("unknown-tool"));
203    }
204
205    #[test]
206    fn merge_overrides_only_given_keys() {
207        let mut base = toml::Value::try_from(Config::default()).unwrap();
208        let over: toml::Value =
209            toml::from_str("min_raw_tokens_to_reduce = 100\n[intercept]\ndocker = false\n")
210                .unwrap();
211        merge_toml(&mut base, over);
212        let c: Config = base.try_into().unwrap();
213
214        assert_eq!(c.min_raw_tokens_to_reduce, 100); // overridden
215        assert!(!c.intercept.docker); // overridden
216        assert!(c.intercept.git); // untouched by the overlay
217        assert!(c.enabled); // untouched
218        assert_eq!(c.retention_days, 14); // untouched
219    }
220
221    #[test]
222    fn enabled_shims_reflects_intercept() {
223        let mut c = Config::default();
224        c.intercept.docker = false;
225        c.intercept.go = false;
226        let shims = c.intercept.enabled_shims();
227        assert!(shims.iter().any(|s| s == "git"));
228        assert!(!shims.iter().any(|s| s == "docker"));
229        assert!(!shims.iter().any(|s| s == "go"));
230        assert_eq!(shims.len(), SHIM_NAMES.len() - 2);
231    }
232
233    #[test]
234    fn extra_commands_are_intercepted_and_sanitized() {
235        let mut c = Config::default();
236        c.intercept.extra = vec![
237            "mytool".to_string(),
238            "make".to_string(),
239            "vitest".to_string(),    // builtin now: not an extra, no dup shim
240            "git".to_string(),       // builtin: not an extra, no duplicate shim
241            "dejavu".to_string(),    // reserved: dropped
242            "a/b".to_string(),       // path separator: dropped
243            "has space".to_string(), // whitespace: dropped
244            String::new(),           // empty: dropped
245        ];
246
247        assert!(c.intercept.is_extra("mytool"));
248        assert!(c.intercept.is_extra("make"));
249        assert!(!c.intercept.is_extra("vitest")); // builtin keeps its classifier
250        assert!(!c.intercept.is_extra("git")); // builtin keeps its classifier
251        assert!(!c.intercept.is_extra("dejavu"));
252        assert!(!c.intercept.is_extra("a/b"));
253
254        assert!(c.intercept.is_enabled("mytool"));
255        assert!(c.intercept.is_enabled("vitest"));
256
257        let shims = c.intercept.enabled_shims();
258        assert!(shims.iter().any(|s| s == "mytool"));
259        assert!(shims.iter().any(|s| s == "make"));
260        for builtin in ["vitest", "git"] {
261            assert_eq!(
262                shims.iter().filter(|s| s.as_str() == builtin).count(),
263                1,
264                "builtin listed once even when repeated in extra"
265            );
266        }
267        assert_eq!(shims.len(), SHIM_NAMES.len() + 2);
268    }
269
270    #[test]
271    fn extra_parses_from_toml() {
272        let c: Config =
273            toml::from_str("[intercept]\nextra = [\"mytool\", \"terraform\"]\ngit = false\n")
274                .unwrap();
275        assert!(c.intercept.is_extra("mytool"));
276        assert!(c.intercept.is_extra("terraform"));
277        assert!(!c.intercept.git);
278    }
279}