1mod defaults;
5
6use crate::error::ConfigError;
7use crate::paths::{config_file_path, CacheLayout};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11pub 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 pub extra: Vec<String>,
62}
63
64impl InterceptConfig {
65 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 pub fn is_extra(&self, shim: &str) -> bool {
93 !SHIM_NAMES.contains(&shim) && self.sane_extra().any(|e| e == shim)
94 }
95
96 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 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
122fn 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 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 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); assert!(!c.intercept.docker); assert!(c.intercept.git); assert!(c.enabled); assert_eq!(c.retention_days, 14); }
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(), "git".to_string(), "dejavu".to_string(), "a/b".to_string(), "has space".to_string(), String::new(), ];
246
247 assert!(c.intercept.is_extra("mytool"));
248 assert!(c.intercept.is_extra("make"));
249 assert!(!c.intercept.is_extra("vitest")); assert!(!c.intercept.is_extra("git")); 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}