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 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
153impl 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 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 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}