Skip to main content

covy_core/
config.rs

1use serde::Deserialize;
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use crate::error::CovyError;
6
7#[derive(Debug, Clone, Deserialize)]
8#[serde(default)]
9pub struct CovyConfig {
10    pub project: ProjectConfig,
11    pub ingest: IngestConfig,
12    pub diff: DiffConfig,
13    pub gate: GateConfig,
14    pub report: ReportConfig,
15    pub cache: CacheConfig,
16    pub impact: ImpactConfig,
17    pub shard: ShardConfig,
18    pub merge: MergeConfig,
19    pub paths: PathsConfig,
20    #[serde(alias = "path_mapping")]
21    pub path_mapping: PathMappingConfig,
22}
23
24#[derive(Debug, Clone, Deserialize)]
25#[serde(default)]
26pub struct ProjectConfig {
27    pub name: String,
28    pub source_root: String,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(default)]
33pub struct IngestConfig {
34    pub report_paths: Vec<String>,
35    pub strip_prefixes: Vec<String>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39#[serde(default)]
40pub struct DiffConfig {
41    pub base: String,
42    pub head: String,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46#[serde(default)]
47pub struct GateConfig {
48    pub fail_under_total: Option<f64>,
49    pub fail_under_changed: Option<f64>,
50    pub fail_under_new: Option<f64>,
51    #[serde(default)]
52    pub issues: IssueGateConfig,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56#[serde(default)]
57pub struct IssueGateConfig {
58    pub max_new_errors: Option<u32>,
59    pub max_new_warnings: Option<u32>,
60    pub max_new_issues: Option<u32>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64#[serde(default)]
65pub struct ReportConfig {
66    pub format: String,
67    pub show_missing: bool,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71#[serde(default)]
72pub struct CacheConfig {
73    pub enabled: bool,
74    pub dir: String,
75    pub max_age_days: u32,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79#[serde(default)]
80pub struct ImpactConfig {
81    pub testmap_path: String,
82    pub max_tests: usize,
83    pub target_coverage: f64,
84    pub stale_after_days: u32,
85    pub allow_stale: bool,
86    pub test_id_strategy: String,
87    // Legacy fields kept for backward compatibility.
88    pub fresh_hours: u32,
89    pub full_suite_threshold: f64,
90    pub fallback_mode: String,
91    pub smoke: ImpactSmokeConfig,
92}
93
94#[derive(Debug, Clone, Deserialize)]
95#[serde(default)]
96pub struct ImpactSmokeConfig {
97    pub always: Vec<String>,
98    pub stale_extra: Vec<String>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102#[serde(default)]
103pub struct ShardConfig {
104    pub timings_path: String,
105    pub algorithm: String,
106    pub unknown_test_seconds: f64,
107    pub tiers: ShardTiersConfig,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111#[serde(default)]
112pub struct ShardTiersConfig {
113    pub pr: ShardTierConfig,
114    pub nightly: ShardTierConfig,
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(default)]
119pub struct ShardTierConfig {
120    pub exclude_tags: Vec<String>,
121}
122
123#[derive(Debug, Clone, Deserialize)]
124#[serde(default)]
125pub struct MergeConfig {
126    pub strict: bool,
127    pub output_coverage: String,
128    pub output_issues: String,
129}
130
131#[derive(Debug, Clone, Deserialize)]
132#[serde(default)]
133pub struct PathMappingConfig {
134    pub rules: BTreeMap<String, String>,
135}
136
137#[derive(Debug, Clone, Deserialize)]
138#[serde(default)]
139pub struct PathsConfig {
140    pub strip_prefix: Vec<String>,
141    pub replace_prefix: Vec<ReplacePrefixRule>,
142    pub ignore_globs: Vec<String>,
143    pub case_sensitive: bool,
144}
145
146#[derive(Debug, Clone, Deserialize)]
147#[serde(default)]
148pub struct ReplacePrefixRule {
149    pub from: String,
150    pub to: String,
151}
152
153// Defaults
154
155impl Default for CovyConfig {
156    fn default() -> Self {
157        Self {
158            project: ProjectConfig::default(),
159            ingest: IngestConfig::default(),
160            diff: DiffConfig::default(),
161            gate: GateConfig::default(),
162            report: ReportConfig::default(),
163            cache: CacheConfig::default(),
164            impact: ImpactConfig::default(),
165            shard: ShardConfig::default(),
166            merge: MergeConfig::default(),
167            paths: PathsConfig::default(),
168            path_mapping: PathMappingConfig::default(),
169        }
170    }
171}
172
173impl Default for ProjectConfig {
174    fn default() -> Self {
175        Self {
176            name: String::new(),
177            source_root: ".".to_string(),
178        }
179    }
180}
181
182impl Default for IngestConfig {
183    fn default() -> Self {
184        Self {
185            report_paths: Vec::new(),
186            strip_prefixes: Vec::new(),
187        }
188    }
189}
190
191impl Default for DiffConfig {
192    fn default() -> Self {
193        Self {
194            base: "main".to_string(),
195            head: "HEAD".to_string(),
196        }
197    }
198}
199
200impl Default for GateConfig {
201    fn default() -> Self {
202        Self {
203            fail_under_total: None,
204            fail_under_changed: None,
205            fail_under_new: None,
206            issues: IssueGateConfig::default(),
207        }
208    }
209}
210
211impl Default for IssueGateConfig {
212    fn default() -> Self {
213        Self {
214            max_new_errors: None,
215            max_new_warnings: None,
216            max_new_issues: None,
217        }
218    }
219}
220
221impl Default for ReportConfig {
222    fn default() -> Self {
223        Self {
224            format: "terminal".to_string(),
225            show_missing: false,
226        }
227    }
228}
229
230impl Default for CacheConfig {
231    fn default() -> Self {
232        Self {
233            enabled: true,
234            dir: ".covy/cache".to_string(),
235            max_age_days: 30,
236        }
237    }
238}
239
240impl Default for ImpactConfig {
241    fn default() -> Self {
242        Self {
243            testmap_path: ".covy/state/testmap.bin".to_string(),
244            max_tests: 25,
245            target_coverage: 0.90,
246            stale_after_days: 14,
247            allow_stale: true,
248            test_id_strategy: "junit".to_string(),
249            fresh_hours: 24,
250            full_suite_threshold: 0.40,
251            fallback_mode: "fail-open".to_string(),
252            smoke: ImpactSmokeConfig::default(),
253        }
254    }
255}
256
257impl Default for ImpactSmokeConfig {
258    fn default() -> Self {
259        Self {
260            always: Vec::new(),
261            stale_extra: Vec::new(),
262        }
263    }
264}
265
266impl Default for ShardConfig {
267    fn default() -> Self {
268        Self {
269            timings_path: ".covy/state/testtimings.bin".to_string(),
270            algorithm: "lpt".to_string(),
271            unknown_test_seconds: 8.0,
272            tiers: ShardTiersConfig::default(),
273        }
274    }
275}
276
277impl Default for ShardTiersConfig {
278    fn default() -> Self {
279        Self {
280            pr: ShardTierConfig {
281                exclude_tags: vec!["slow".to_string()],
282            },
283            nightly: ShardTierConfig::default(),
284        }
285    }
286}
287
288impl Default for ShardTierConfig {
289    fn default() -> Self {
290        Self {
291            exclude_tags: Vec::new(),
292        }
293    }
294}
295
296impl Default for MergeConfig {
297    fn default() -> Self {
298        Self {
299            strict: true,
300            output_coverage: ".covy/state/latest.bin".to_string(),
301            output_issues: ".covy/state/issues.bin".to_string(),
302        }
303    }
304}
305
306impl Default for PathMappingConfig {
307    fn default() -> Self {
308        Self {
309            rules: BTreeMap::new(),
310        }
311    }
312}
313
314impl Default for PathsConfig {
315    fn default() -> Self {
316        Self {
317            strip_prefix: Vec::new(),
318            replace_prefix: Vec::new(),
319            ignore_globs: vec![
320                "**/target/**".to_string(),
321                "**/node_modules/**".to_string(),
322                "**/bazel-out/**".to_string(),
323            ],
324            case_sensitive: !cfg!(windows),
325        }
326    }
327}
328
329impl Default for ReplacePrefixRule {
330    fn default() -> Self {
331        Self {
332            from: String::new(),
333            to: String::new(),
334        }
335    }
336}
337
338impl CovyConfig {
339    /// Load config from a TOML file. Returns default config if file doesn't exist.
340    pub fn load(path: &Path) -> Result<Self, CovyError> {
341        if !path.exists() {
342            return Ok(Self::default());
343        }
344        let content = std::fs::read_to_string(path)?;
345        let config: CovyConfig = toml::from_str(&content)?;
346        Ok(config)
347    }
348
349    /// Search for covy.toml in the current directory and parents.
350    pub fn find_and_load() -> Result<Self, CovyError> {
351        let mut dir = std::env::current_dir()?;
352        loop {
353            let candidate = dir.join("covy.toml");
354            if candidate.exists() {
355                return Self::load(&candidate);
356            }
357            if !dir.pop() {
358                break;
359            }
360        }
361        Ok(Self::default())
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_deserialize_gate_issues_defaults() {
371        let raw = r#"
372            [gate]
373            fail_under_total = 80.0
374        "#;
375        let config: CovyConfig = toml::from_str(raw).unwrap();
376        assert_eq!(config.gate.fail_under_total, Some(80.0));
377        assert_eq!(config.gate.issues.max_new_errors, None);
378        assert_eq!(config.gate.issues.max_new_warnings, None);
379        assert_eq!(config.gate.issues.max_new_issues, None);
380    }
381
382    #[test]
383    fn test_deserialize_gate_issues_configured() {
384        let raw = r#"
385            [gate]
386            fail_under_changed = 90.0
387
388            [gate.issues]
389            max_new_errors = 0
390            max_new_warnings = 5
391            max_new_issues = 8
392        "#;
393        let config: CovyConfig = toml::from_str(raw).unwrap();
394        assert_eq!(config.gate.fail_under_changed, Some(90.0));
395        assert_eq!(config.gate.issues.max_new_errors, Some(0));
396        assert_eq!(config.gate.issues.max_new_warnings, Some(5));
397        assert_eq!(config.gate.issues.max_new_issues, Some(8));
398    }
399
400    #[test]
401    fn test_deserialize_impact_shard_merge_defaults() {
402        let raw = r#"
403            [project]
404            name = "demo"
405        "#;
406        let config: CovyConfig = toml::from_str(raw).unwrap();
407        assert_eq!(config.impact.testmap_path, ".covy/state/testmap.bin");
408        assert_eq!(config.impact.max_tests, 25);
409        assert!((config.impact.target_coverage - 0.90).abs() < f64::EPSILON);
410        assert_eq!(config.impact.stale_after_days, 14);
411        assert!(config.impact.allow_stale);
412        assert_eq!(config.impact.test_id_strategy, "junit");
413        assert_eq!(config.impact.fresh_hours, 24);
414        assert!((config.impact.full_suite_threshold - 0.40).abs() < f64::EPSILON);
415        assert_eq!(config.impact.fallback_mode, "fail-open");
416        assert_eq!(config.shard.algorithm, "lpt");
417        assert!((config.shard.unknown_test_seconds - 8.0).abs() < f64::EPSILON);
418        assert_eq!(config.shard.tiers.pr.exclude_tags, vec!["slow".to_string()]);
419        assert!(config.shard.tiers.nightly.exclude_tags.is_empty());
420        assert!(config.merge.strict);
421        assert_eq!(config.merge.output_coverage, ".covy/state/latest.bin");
422    }
423
424    #[test]
425    fn test_deserialize_new_paths_and_impact_v2_fields() {
426        let raw = r#"
427            [impact]
428            testmap_path = ".covy/state/t.bin"
429            max_tests = 12
430            target_coverage = 0.95
431            stale_after_days = 7
432            allow_stale = false
433            test_id_strategy = "pytest"
434
435            [paths]
436            strip_prefix = ["/home/runner/work/repo/repo", "/__w/repo/repo"]
437            ignore_globs = ["**/bazel-out/**"]
438            case_sensitive = false
439
440            [[paths.replace_prefix]]
441            from = "/workspace"
442            to = "."
443        "#;
444        let config: CovyConfig = toml::from_str(raw).unwrap();
445        assert_eq!(config.impact.testmap_path, ".covy/state/t.bin");
446        assert_eq!(config.impact.max_tests, 12);
447        assert!((config.impact.target_coverage - 0.95).abs() < f64::EPSILON);
448        assert_eq!(config.impact.stale_after_days, 7);
449        assert!(!config.impact.allow_stale);
450        assert_eq!(config.impact.test_id_strategy, "pytest");
451        assert_eq!(config.paths.strip_prefix.len(), 2);
452        assert_eq!(config.paths.replace_prefix.len(), 1);
453        assert_eq!(config.paths.replace_prefix[0].from, "/workspace");
454        assert_eq!(config.paths.replace_prefix[0].to, ".");
455        assert_eq!(
456            config.paths.ignore_globs,
457            vec!["**/bazel-out/**".to_string()]
458        );
459        assert!(!config.paths.case_sensitive);
460    }
461
462    #[test]
463    fn test_deserialize_legacy_path_mapping_still_supported() {
464        let raw = r#"
465            [path_mapping.rules]
466            "/build/classes/" = "src/main/java/"
467        "#;
468        let config: CovyConfig = toml::from_str(raw).unwrap();
469        assert_eq!(
470            config.path_mapping.rules.get("/build/classes/"),
471            Some(&"src/main/java/".to_string())
472        );
473    }
474}