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    "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 pytest: bool,
46    pub cargo: bool,
47    pub go: bool,
48    pub git: bool,
49    pub rg: bool,
50    pub grep: bool,
51    pub find: bool,
52    pub ls: bool,
53    pub tree: bool,
54    pub docker: bool,
55}
56
57impl InterceptConfig {
58    /// Whether a given shim name is enabled for interception.
59    pub fn is_enabled(&self, shim: &str) -> bool {
60        match shim {
61            "npm" => self.npm,
62            "pnpm" => self.pnpm,
63            "yarn" => self.yarn,
64            "bun" => self.bun,
65            "tsc" => self.tsc,
66            "eslint" => self.eslint,
67            "pytest" => self.pytest,
68            "cargo" => self.cargo,
69            "go" => self.go,
70            "git" => self.git,
71            "rg" => self.rg,
72            "grep" => self.grep,
73            "find" => self.find,
74            "ls" => self.ls,
75            "tree" => self.tree,
76            "docker" => self.docker,
77            _ => false,
78        }
79    }
80
81    /// The enabled shim names, in a stable order.
82    pub fn enabled_shims(&self) -> Vec<&'static str> {
83        SHIM_NAMES
84            .iter()
85            .copied()
86            .filter(|name| self.is_enabled(name))
87            .collect()
88    }
89}
90
91/// Deep-merge `over` into `base` (tables merge key-by-key; scalars replace).
92fn merge_toml(base: &mut toml::Value, over: toml::Value) {
93    match over {
94        toml::Value::Table(over_table) => {
95            if let toml::Value::Table(base_table) = base {
96                for (key, value) in over_table {
97                    match base_table.get_mut(&key) {
98                        Some(existing) => merge_toml(existing, value),
99                        None => {
100                            base_table.insert(key, value);
101                        }
102                    }
103                }
104            } else {
105                *base = toml::Value::Table(over_table);
106            }
107        }
108        other => *base = other,
109    }
110}
111
112impl Config {
113    /// Load and merge the effective config for a repo.
114    pub fn load(repo_root: &Path) -> Result<Config, ConfigError> {
115        let mut merged =
116            toml::Value::try_from(Config::default()).expect("default config always serializes");
117
118        if let Ok(path) = config_file_path() {
119            if path.exists() {
120                let text = std::fs::read_to_string(&path)?;
121                let value: toml::Value =
122                    toml::from_str(&text).map_err(|source| ConfigError::Toml {
123                        path: path.clone(),
124                        source,
125                    })?;
126                merge_toml(&mut merged, value);
127            }
128        }
129
130        let project = repo_root.join(".dejavu.toml");
131        if project.exists() {
132            let text = std::fs::read_to_string(&project)?;
133            let value: toml::Value = toml::from_str(&text).map_err(|source| ConfigError::Toml {
134                path: project.clone(),
135                source,
136            })?;
137            merge_toml(&mut merged, value);
138        }
139
140        merged.try_into().map_err(|source| ConfigError::Toml {
141            path: repo_root.to_path_buf(),
142            source,
143        })
144    }
145
146    /// Serialize the effective config to `config.effective.json` for `doctor`.
147    pub fn write_effective(&self, layout: &CacheLayout) -> Result<(), ConfigError> {
148        let json = serde_json::to_string_pretty(self)?;
149        std::fs::write(layout.effective_config(), json)?;
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn defaults_match_spec_section_18() {
160        let c = Config::default();
161        assert!(c.enabled);
162        assert!(c.store_raw_outputs);
163        assert!(c.redact_secrets);
164        assert_eq!(c.max_raw_output_bytes, 5_242_880);
165        assert_eq!(c.min_raw_tokens_to_reduce, 800);
166        assert_eq!(c.small_delta_max_changed_lines, 80);
167        assert!((c.small_delta_max_changed_ratio - 0.20).abs() < 1e-9);
168        assert_eq!(c.retention_days, 14);
169        assert!(c.intercept.is_enabled("git"));
170        assert!(c.intercept.is_enabled("docker"));
171        assert!(!c.intercept.is_enabled("unknown-tool"));
172    }
173
174    #[test]
175    fn merge_overrides_only_given_keys() {
176        let mut base = toml::Value::try_from(Config::default()).unwrap();
177        let over: toml::Value =
178            toml::from_str("min_raw_tokens_to_reduce = 100\n[intercept]\ndocker = false\n")
179                .unwrap();
180        merge_toml(&mut base, over);
181        let c: Config = base.try_into().unwrap();
182
183        assert_eq!(c.min_raw_tokens_to_reduce, 100); // overridden
184        assert!(!c.intercept.docker); // overridden
185        assert!(c.intercept.git); // untouched by the overlay
186        assert!(c.enabled); // untouched
187        assert_eq!(c.retention_days, 14); // untouched
188    }
189
190    #[test]
191    fn enabled_shims_reflects_intercept() {
192        let mut c = Config::default();
193        c.intercept.docker = false;
194        c.intercept.go = false;
195        let shims = c.intercept.enabled_shims();
196        assert!(shims.contains(&"git"));
197        assert!(!shims.contains(&"docker"));
198        assert!(!shims.contains(&"go"));
199        assert_eq!(shims.len(), SHIM_NAMES.len() - 2);
200    }
201}