Skip to main content

testgap_core/
config.rs

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    /// Search upward from `start` for `.testgap.toml`, falling back to defaults.
181    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    /// Merge CLI overrides into this config.
203    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    // ── default config values ───────────────────────────────────────
291
292    #[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    // ── load from valid TOML file ───────────────────────────────────
328
329    #[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    // ── load from non-existent dir falls back to defaults ───────────
340
341    #[test]
342    fn load_from_nonexistent_dir_uses_defaults() {
343        let cfg = TestGapConfig::load(Path::new("/tmp/nonexistent_testgap_dir_12345"));
344        // Should be the same as defaults
345        assert_eq!(cfg.format, OutputFormat::Human);
346        assert!(cfg.ai.enabled);
347        assert_eq!(cfg.min_severity, GapSeverity::Info);
348    }
349
350    // ── merge_cli_overrides ─────────────────────────────────────────
351
352    #[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    // ── walk-up config file discovery ───────────────────────────────
388
389    #[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        // Place config in "a/"
398        let config_path = a.join(CONFIG_FILENAME);
399        std::fs::write(&config_path, r#"min_severity = "critical""#).unwrap();
400
401        // Load from "a/b/c/" — should walk up and find it in "a/"
402        let cfg = TestGapConfig::load(&c);
403        assert_eq!(cfg.min_severity, GapSeverity::Critical);
404    }
405
406    // ── generate_default_config round-trips ─────────────────────────
407
408    #[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        // Verify a few known values survive the round-trip
415        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}