1use crate::types::GapSeverity;
2use crate::types::Language;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const CONFIG_FILENAME: &str = ".testgap.toml";
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct TestGapConfig {
10 #[serde(default = "default_exclude")]
11 pub exclude: Vec<String>,
12
13 #[serde(default)]
14 pub languages: Option<Vec<Language>>,
15
16 #[serde(default = "default_min_severity")]
17 pub min_severity: GapSeverity,
18
19 #[serde(default = "default_format")]
20 pub format: OutputFormat,
21
22 #[serde(default)]
23 pub ai: AiConfig,
24
25 #[serde(default)]
26 pub test_patterns: TestPatternConfig,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum OutputFormat {
32 Human,
33 Json,
34 Markdown,
35 Sarif,
36 Github,
37}
38
39impl std::fmt::Display for OutputFormat {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 OutputFormat::Human => f.write_str("human"),
43 OutputFormat::Json => f.write_str("json"),
44 OutputFormat::Markdown => f.write_str("markdown"),
45 OutputFormat::Sarif => f.write_str("sarif"),
46 OutputFormat::Github => f.write_str("github"),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AiConfig {
53 #[serde(default = "default_true")]
54 pub enabled: bool,
55
56 #[serde(default = "default_model")]
57 pub model: String,
58
59 #[serde(default = "default_batch_size")]
60 pub batch_size: usize,
61
62 #[serde(default = "default_max_function_body_tokens")]
63 pub max_function_body_tokens: usize,
64
65 #[serde(default = "default_ai_min_severity")]
66 pub ai_min_severity: GapSeverity,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TestPatternConfig {
71 #[serde(default = "default_test_dirs")]
72 pub test_dirs: Vec<String>,
73
74 #[serde(default = "default_test_suffixes")]
75 pub test_file_suffixes: Vec<String>,
76
77 #[serde(default = "default_test_prefixes")]
78 pub test_file_prefixes: Vec<String>,
79}
80
81fn default_exclude() -> Vec<String> {
82 vec![
83 "**/target/**".into(),
84 "**/node_modules/**".into(),
85 "**/.git/**".into(),
86 "**/dist/**".into(),
87 "**/build/**".into(),
88 "**/vendor/**".into(),
89 "**/__pycache__/**".into(),
90 "**/.venv/**".into(),
91 ]
92}
93
94fn default_min_severity() -> GapSeverity {
95 GapSeverity::Info
96}
97
98fn default_format() -> OutputFormat {
99 OutputFormat::Human
100}
101
102fn default_true() -> bool {
103 true
104}
105
106fn default_model() -> String {
107 "claude-sonnet-4-20250514".into()
108}
109
110fn default_batch_size() -> usize {
111 5
112}
113
114fn default_max_function_body_tokens() -> usize {
115 2000
116}
117
118fn default_ai_min_severity() -> GapSeverity {
119 GapSeverity::Critical
120}
121
122fn default_test_dirs() -> Vec<String> {
123 vec![
124 "tests".into(),
125 "test".into(),
126 "__tests__".into(),
127 "spec".into(),
128 ]
129}
130
131fn default_test_suffixes() -> Vec<String> {
132 vec![
133 "_test".into(),
134 ".test".into(),
135 ".spec".into(),
136 "_spec".into(),
137 ]
138}
139
140fn default_test_prefixes() -> Vec<String> {
141 vec!["test_".into()]
142}
143
144impl Default for TestGapConfig {
145 fn default() -> Self {
146 Self {
147 exclude: default_exclude(),
148 languages: None,
149 min_severity: default_min_severity(),
150 format: default_format(),
151 ai: AiConfig::default(),
152 test_patterns: TestPatternConfig::default(),
153 }
154 }
155}
156
157impl Default for AiConfig {
158 fn default() -> Self {
159 Self {
160 enabled: true,
161 model: default_model(),
162 batch_size: default_batch_size(),
163 max_function_body_tokens: default_max_function_body_tokens(),
164 ai_min_severity: default_ai_min_severity(),
165 }
166 }
167}
168
169impl Default for TestPatternConfig {
170 fn default() -> Self {
171 Self {
172 test_dirs: default_test_dirs(),
173 test_file_suffixes: default_test_suffixes(),
174 test_file_prefixes: default_test_prefixes(),
175 }
176 }
177}
178
179impl TestGapConfig {
180 pub fn load(start: &Path) -> Self {
182 if let Some(path) = find_config_file(start) {
183 match std::fs::read_to_string(&path) {
184 Ok(contents) => match toml::from_str(&contents) {
185 Ok(config) => {
186 tracing::info!("Loaded config from {}", path.display());
187 return config;
188 }
189 Err(e) => {
190 eprintln!("Warning: failed to parse {}: {e}", path.display());
191 }
192 },
193 Err(e) => {
194 tracing::warn!("Failed to read {}: {e}", path.display());
195 }
196 }
197 }
198 tracing::info!("No .testgap.toml found, using defaults");
199 Self::default()
200 }
201
202 pub fn merge_cli_overrides(
204 &mut self,
205 format: Option<OutputFormat>,
206 languages: Option<Vec<Language>>,
207 min_severity: Option<GapSeverity>,
208 no_ai: bool,
209 ai_severity: Option<GapSeverity>,
210 ) {
211 if let Some(f) = format {
212 self.format = f;
213 }
214 if let Some(l) = languages {
215 self.languages = Some(l);
216 }
217 if let Some(s) = min_severity {
218 self.min_severity = s;
219 }
220 if no_ai {
221 self.ai.enabled = false;
222 }
223 if let Some(s) = ai_severity {
224 self.ai.ai_min_severity = s;
225 }
226 }
227}
228
229fn find_config_file(start: &Path) -> Option<PathBuf> {
230 let mut dir = if start.is_file() {
231 start.parent()?.to_path_buf()
232 } else {
233 start.to_path_buf()
234 };
235
236 loop {
237 let candidate = dir.join(CONFIG_FILENAME);
238 if candidate.is_file() {
239 return Some(candidate);
240 }
241 if !dir.pop() {
242 return None;
243 }
244 }
245}
246
247pub fn generate_default_config() -> String {
248 r#"# testgap configuration
249# Place this file at the root of your project as .testgap.toml
250
251# Glob patterns to exclude from analysis
252exclude = [
253 "**/target/**",
254 "**/node_modules/**",
255 "**/.git/**",
256 "**/dist/**",
257 "**/build/**",
258 "**/vendor/**",
259 "**/__pycache__/**",
260 "**/.venv/**",
261]
262
263# Restrict analysis to specific languages (comment out to auto-detect)
264# languages = ["rust", "typescript", "python"]
265
266# Minimum severity to report: "info", "warning", or "critical"
267min_severity = "info"
268
269# Output format: "human", "json", "markdown", "sarif", or "github"
270format = "human"
271
272[ai]
273enabled = true
274model = "claude-sonnet-4-20250514"
275batch_size = 5
276max_function_body_tokens = 2000
277
278[test_patterns]
279test_dirs = ["tests", "test", "__tests__", "spec"]
280test_file_suffixes = ["_test", ".test", ".spec", "_spec"]
281test_file_prefixes = ["test_"]
282"#
283 .to_string()
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
293 fn default_config_exclude_patterns() {
294 let cfg = TestGapConfig::default();
295 let patterns: Vec<&str> = cfg.exclude.iter().map(|s| s.as_str()).collect();
296 assert!(patterns.contains(&"**/target/**"), "should contain target");
297 assert!(
298 patterns.contains(&"**/node_modules/**"),
299 "should contain node_modules"
300 );
301 }
302
303 #[test]
304 fn default_config_format_is_human() {
305 let cfg = TestGapConfig::default();
306 assert_eq!(cfg.format, OutputFormat::Human);
307 }
308
309 #[test]
310 fn default_config_ai_enabled() {
311 let cfg = TestGapConfig::default();
312 assert!(cfg.ai.enabled);
313 }
314
315 #[test]
316 fn default_config_ai_model() {
317 let cfg = TestGapConfig::default();
318 assert_eq!(cfg.ai.model, "claude-sonnet-4-20250514");
319 }
320
321 #[test]
322 fn default_config_ai_batch_size() {
323 let cfg = TestGapConfig::default();
324 assert_eq!(cfg.ai.batch_size, 5);
325 }
326
327 #[test]
330 fn load_from_valid_toml_file() {
331 let dir = tempfile::tempdir().unwrap();
332 let config_path = dir.path().join(CONFIG_FILENAME);
333 std::fs::write(&config_path, r#"min_severity = "warning""#).unwrap();
334
335 let cfg = TestGapConfig::load(dir.path());
336 assert_eq!(cfg.min_severity, GapSeverity::Warning);
337 }
338
339 #[test]
342 fn load_from_nonexistent_dir_uses_defaults() {
343 let cfg = TestGapConfig::load(Path::new("/tmp/nonexistent_testgap_dir_12345"));
344 assert_eq!(cfg.format, OutputFormat::Human);
346 assert!(cfg.ai.enabled);
347 assert_eq!(cfg.min_severity, GapSeverity::Info);
348 }
349
350 #[test]
353 fn merge_cli_overrides_sets_format() {
354 let mut cfg = TestGapConfig::default();
355 cfg.merge_cli_overrides(Some(OutputFormat::Json), None, None, false, None);
356 assert_eq!(cfg.format, OutputFormat::Json);
357 }
358
359 #[test]
360 fn merge_cli_overrides_no_ai_disables_ai() {
361 let mut cfg = TestGapConfig::default();
362 assert!(cfg.ai.enabled);
363 cfg.merge_cli_overrides(None, None, None, true, None);
364 assert!(!cfg.ai.enabled);
365 }
366
367 #[test]
368 fn merge_cli_overrides_sets_languages() {
369 let mut cfg = TestGapConfig::default();
370 cfg.merge_cli_overrides(
371 None,
372 Some(vec![Language::Rust, Language::Python]),
373 None,
374 false,
375 None,
376 );
377 assert_eq!(cfg.languages, Some(vec![Language::Rust, Language::Python]));
378 }
379
380 #[test]
381 fn merge_cli_overrides_sets_min_severity() {
382 let mut cfg = TestGapConfig::default();
383 cfg.merge_cli_overrides(None, None, Some(GapSeverity::Critical), false, None);
384 assert_eq!(cfg.min_severity, GapSeverity::Critical);
385 }
386
387 #[test]
390 fn load_walks_up_to_find_config() {
391 let root = tempfile::tempdir().unwrap();
392 let a = root.path().join("a");
393 let b = a.join("b");
394 let c = b.join("c");
395 std::fs::create_dir_all(&c).unwrap();
396
397 let config_path = a.join(CONFIG_FILENAME);
399 std::fs::write(&config_path, r#"min_severity = "critical""#).unwrap();
400
401 let cfg = TestGapConfig::load(&c);
403 assert_eq!(cfg.min_severity, GapSeverity::Critical);
404 }
405
406 #[test]
409 fn generate_default_config_round_trips() {
410 let toml_text = generate_default_config();
411 let parsed: TestGapConfig =
412 toml::from_str(&toml_text).expect("generated config should parse");
413
414 assert_eq!(parsed.format, OutputFormat::Human);
416 assert!(parsed.ai.enabled);
417 assert_eq!(parsed.ai.model, "claude-sonnet-4-20250514");
418 assert_eq!(parsed.ai.batch_size, 5);
419 assert_eq!(parsed.min_severity, GapSeverity::Info);
420 assert!(parsed.exclude.contains(&"**/target/**".to_string()));
421 }
422}