1use covguard_output_features::{OutputFeatureConfig, OutputFeatureFlags};
10use covguard_policy::profile_defaults as policy_profile_defaults;
11pub use covguard_policy::{FailOn, MissingBehavior, Profile, Scope};
12use serde::Deserialize;
13use std::path::Path;
14use thiserror::Error;
15
16#[derive(Debug, Error)]
22pub enum ConfigError {
23 #[error("Failed to read config file: {0}")]
25 IoError(#[from] std::io::Error),
26
27 #[error("Failed to parse config file: {0}")]
29 ParseError(#[from] toml::de::Error),
30
31 #[error("Invalid config value: {0}")]
33 InvalidValue(String),
34}
35
36#[derive(Debug, Clone, Default, Deserialize)]
42pub struct PathConfig {
43 #[serde(default)]
45 pub exclude: Vec<String>,
46 #[serde(default)]
49 pub include: Vec<String>,
50}
51
52#[derive(Debug, Clone, Default, Deserialize)]
54pub struct IgnoreConfig {
55 #[serde(default = "default_true")]
57 pub directives: bool,
58}
59
60#[derive(Debug, Clone, Default, Deserialize)]
62pub struct NormalizeConfig {
63 #[serde(default)]
65 pub path_strip: Vec<String>,
66}
67
68fn default_true() -> bool {
69 true
70}
71
72#[derive(Debug, Clone, Default, Deserialize)]
74pub struct Config {
75 #[serde(default)]
77 pub profile: Option<Profile>,
78
79 #[serde(default)]
81 pub scope: Option<Scope>,
82
83 #[serde(default)]
85 pub fail_on: Option<FailOn>,
86
87 #[serde(default)]
89 pub min_diff_coverage_pct: Option<f64>,
90
91 #[serde(default)]
93 pub max_uncovered_lines: Option<u32>,
94
95 #[serde(default)]
97 pub missing_coverage: Option<MissingBehavior>,
98
99 #[serde(default)]
101 pub missing_file: Option<MissingBehavior>,
102
103 #[serde(default)]
105 pub paths: PathConfig,
106
107 #[serde(default, rename = "ignore")]
109 pub ignore_config: IgnoreConfig,
110
111 #[serde(default)]
113 pub normalize: NormalizeConfig,
114
115 #[serde(default)]
117 pub output: OutputFeatureConfig,
118}
119
120#[derive(Debug, Clone)]
131pub struct EffectiveConfig {
132 pub scope: Scope,
133 pub fail_on: FailOn,
134 pub threshold_pct: f64,
135 pub max_uncovered_lines: Option<u32>,
136 pub missing_coverage: MissingBehavior,
137 pub missing_file: MissingBehavior,
138 pub exclude_patterns: Vec<String>,
139 pub include_patterns: Vec<String>,
140 pub ignore_directives: bool,
141 pub path_strip: Vec<String>,
142 pub output: OutputFeatureFlags,
143}
144
145impl Default for EffectiveConfig {
146 fn default() -> Self {
147 Self {
148 scope: Scope::Added,
149 fail_on: FailOn::Error,
150 threshold_pct: 80.0,
151 max_uncovered_lines: None,
152 missing_coverage: MissingBehavior::Warn,
153 missing_file: MissingBehavior::Warn,
154 exclude_patterns: vec![],
155 include_patterns: vec![],
156 ignore_directives: true,
157 path_strip: vec![],
158 output: OutputFeatureFlags::default(),
159 }
160 }
161}
162
163pub fn profile_defaults(profile: Profile) -> EffectiveConfig {
169 let defaults = policy_profile_defaults(profile);
170
171 EffectiveConfig {
172 scope: defaults.scope,
173 fail_on: defaults.fail_on,
174 threshold_pct: defaults.threshold_pct,
175 max_uncovered_lines: defaults.max_uncovered_lines,
176 missing_coverage: defaults.missing_coverage,
177 missing_file: defaults.missing_file,
178 exclude_patterns: vec![],
179 include_patterns: vec![],
180 ignore_directives: defaults.ignore_directives,
181 path_strip: vec![],
182 output: OutputFeatureFlags::default(),
183 }
184}
185
186pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
192 let content = std::fs::read_to_string(path)?;
193 let config: Config = toml::from_str(&content)?;
194 validate_config(&config)?;
195 Ok(config)
196}
197
198pub fn parse_config(content: &str) -> Result<Config, ConfigError> {
200 let config: Config = toml::from_str(content)?;
201 validate_config(&config)?;
202 Ok(config)
203}
204
205fn validate_config(config: &Config) -> Result<(), ConfigError> {
207 if let Some(pct) = config.min_diff_coverage_pct
208 && !(0.0..=100.0).contains(&pct)
209 {
210 return Err(ConfigError::InvalidValue(format!(
211 "min_diff_coverage_pct must be between 0 and 100, got {}",
212 pct
213 )));
214 }
215 Ok(())
216}
217
218pub fn discover_config() -> Option<(std::path::PathBuf, Config)> {
222 let mut current = std::env::current_dir().ok()?;
223
224 loop {
225 let config_path = current.join("covguard.toml");
226 if config_path.exists()
227 && let Ok(config) = load_config(&config_path)
228 {
229 return Some((config_path, config));
230 }
231
232 if !current.pop() {
233 break;
234 }
235 }
236
237 None
238}
239
240#[derive(Debug, Clone, Default)]
246pub struct CliOverrides {
247 pub scope: Option<Scope>,
248 pub fail_on: Option<FailOn>,
249 pub threshold_pct: Option<f64>,
250 pub max_uncovered_lines: Option<u32>,
251 pub ignore_directives: Option<bool>,
252 pub path_strip: Option<Vec<String>>,
253 pub output: Option<OutputFeatureConfig>,
254}
255
256pub fn resolve_config(config: Option<&Config>, cli: &CliOverrides) -> EffectiveConfig {
260 let profile = config.and_then(|c| c.profile).unwrap_or(Profile::Team);
262 let mut effective = profile_defaults(profile);
263
264 if let Some(config) = config {
266 if let Some(scope) = config.scope {
267 effective.scope = scope;
268 }
269 if let Some(fail_on) = config.fail_on {
270 effective.fail_on = fail_on;
271 }
272 if let Some(pct) = config.min_diff_coverage_pct {
273 effective.threshold_pct = pct;
274 }
275 if let Some(max) = config.max_uncovered_lines {
276 effective.max_uncovered_lines = Some(max);
277 }
278 if let Some(behavior) = config.missing_coverage {
279 effective.missing_coverage = behavior;
280 }
281 if let Some(behavior) = config.missing_file {
282 effective.missing_file = behavior;
283 }
284 effective.exclude_patterns = config.paths.exclude.clone();
285 effective.include_patterns = config.paths.include.clone();
286 effective.ignore_directives = config.ignore_config.directives;
287 effective.path_strip = config.normalize.path_strip.clone();
288 effective.output = config.output.materialize(effective.output);
289 }
290
291 if let Some(scope) = cli.scope {
293 effective.scope = scope;
294 }
295 if let Some(fail_on) = cli.fail_on {
296 effective.fail_on = fail_on;
297 }
298 if let Some(pct) = cli.threshold_pct {
299 effective.threshold_pct = pct;
300 }
301 if let Some(max) = cli.max_uncovered_lines {
302 effective.max_uncovered_lines = Some(max);
303 }
304 if let Some(ignore) = cli.ignore_directives {
305 effective.ignore_directives = ignore;
306 }
307 if let Some(path_strip) = &cli.path_strip {
308 effective.path_strip = path_strip.clone();
309 }
310 if let Some(output) = cli.output {
311 effective.output = output.materialize(effective.output);
312 }
313
314 effective
315}
316
317pub fn matches_any_pattern(path: &str, patterns: &[String]) -> bool {
323 for pattern in patterns {
324 if let Ok(glob_pattern) = glob::Pattern::new(pattern)
325 && glob_pattern.matches(path)
326 {
327 return true;
328 }
329 }
330 false
331}
332
333pub fn should_include_path(
337 path: &str,
338 include_patterns: &[String],
339 exclude_patterns: &[String],
340) -> bool {
341 if matches_any_pattern(path, exclude_patterns) {
343 return false;
344 }
345
346 if !include_patterns.is_empty() && !matches_any_pattern(path, include_patterns) {
348 return false;
349 }
350
351 true
352}
353
354#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn test_default_true_returns_true() {
364 assert!(default_true());
365 }
366 #[test]
367 fn test_effective_config_default_values() {
368 let defaults = EffectiveConfig::default();
369 let output = OutputFeatureFlags::default();
370 assert_eq!(defaults.scope, Scope::Added);
371 assert_eq!(defaults.fail_on, FailOn::Error);
372 assert_eq!(defaults.threshold_pct, 80.0);
373 assert_eq!(defaults.max_uncovered_lines, None);
374 assert_eq!(defaults.missing_coverage, MissingBehavior::Warn);
375 assert_eq!(defaults.missing_file, MissingBehavior::Warn);
376 assert!(defaults.exclude_patterns.is_empty());
377 assert!(defaults.include_patterns.is_empty());
378 assert!(defaults.ignore_directives);
379 assert!(defaults.path_strip.is_empty());
380 assert_eq!(
381 defaults.output.max_markdown_lines,
382 output.max_markdown_lines
383 );
384 assert_eq!(defaults.output.max_annotations, output.max_annotations);
385 assert_eq!(defaults.output.max_sarif_results, output.max_sarif_results);
386 }
387 #[test]
388 fn test_parse_minimal_config() {
389 let config = parse_config("").unwrap();
390 assert!(config.profile.is_none());
391 assert!(config.scope.is_none());
392 }
393 #[test]
394 fn test_parse_full_config() {
395 let toml = r#"
396profile = "strict"
397scope = "touched"
398fail_on = "warn"
399min_diff_coverage_pct = 90
400max_uncovered_lines = 5
401missing_coverage = "fail"
402missing_file = "skip"
403
404[paths]
405exclude = ["target/**", "vendor/**"]
406include = ["src/**"]
407
408[ignore]
409directives = false
410
411[normalize]
412path_strip = ["/home/runner/"]
413
414[output]
415max_markdown_lines = 12
416max_annotations = 21
417max_sarif_results = 7
418"#;
419 let config = parse_config(toml).unwrap();
420
421 assert_eq!(config.profile, Some(Profile::Strict));
422 assert_eq!(config.scope, Some(Scope::Touched));
423 assert_eq!(config.fail_on, Some(FailOn::Warn));
424 assert_eq!(config.min_diff_coverage_pct, Some(90.0));
425 assert_eq!(config.max_uncovered_lines, Some(5));
426 assert_eq!(config.missing_coverage, Some(MissingBehavior::Fail));
427 assert_eq!(config.missing_file, Some(MissingBehavior::Skip));
428 assert_eq!(config.paths.exclude, vec!["target/**", "vendor/**"]);
429 assert_eq!(config.paths.include, vec!["src/**"]);
430 assert!(!config.ignore_config.directives);
431 assert_eq!(config.normalize.path_strip, vec!["/home/runner/"]);
432 assert_eq!(config.output.max_markdown_lines, Some(12));
433 assert_eq!(config.output.max_annotations, Some(21));
434 assert_eq!(config.output.max_sarif_results, Some(7));
435 }
436 #[test]
437 fn test_invalid_threshold() {
438 let toml = "min_diff_coverage_pct = 150";
439 let result = parse_config(toml);
440 assert!(result.is_err());
441 }
442 #[test]
443 fn test_profile_defaults_oss() {
444 let defaults = profile_defaults(Profile::Oss);
445 assert_eq!(defaults.fail_on, FailOn::Never);
446 assert_eq!(defaults.threshold_pct, 70.0);
447 assert_eq!(defaults.missing_coverage, MissingBehavior::Skip);
448 assert_eq!(defaults.missing_file, MissingBehavior::Skip);
449 assert_eq!(defaults.scope, Scope::Added);
450 }
451 #[test]
452 fn test_profile_defaults_moderate() {
453 let defaults = profile_defaults(Profile::Moderate);
454 assert_eq!(defaults.fail_on, FailOn::Error);
455 assert_eq!(defaults.threshold_pct, 75.0);
456 assert_eq!(defaults.missing_coverage, MissingBehavior::Warn);
457 assert_eq!(defaults.missing_file, MissingBehavior::Skip);
458 assert_eq!(defaults.scope, Scope::Added);
459 }
460 #[test]
461 fn test_profile_defaults_team() {
462 let defaults = profile_defaults(Profile::Team);
463 assert_eq!(defaults.fail_on, FailOn::Error);
464 assert_eq!(defaults.threshold_pct, 80.0);
465 assert_eq!(defaults.missing_coverage, MissingBehavior::Warn);
466 assert_eq!(defaults.missing_file, MissingBehavior::Warn);
467 assert_eq!(defaults.scope, Scope::Added);
468 }
469 #[test]
470 fn test_profile_defaults_strict() {
471 let defaults = profile_defaults(Profile::Strict);
472 assert_eq!(defaults.fail_on, FailOn::Error);
473 assert_eq!(defaults.threshold_pct, 90.0);
474 assert_eq!(defaults.max_uncovered_lines, Some(5));
475 assert_eq!(defaults.missing_coverage, MissingBehavior::Fail);
476 assert_eq!(defaults.missing_file, MissingBehavior::Fail);
477 assert_eq!(defaults.scope, Scope::Touched);
478 }
479 #[test]
480 fn test_profile_defaults_lenient() {
481 let defaults = profile_defaults(Profile::Lenient);
482 assert_eq!(defaults.fail_on, FailOn::Never);
483 assert_eq!(defaults.threshold_pct, 0.0);
484 assert_eq!(defaults.max_uncovered_lines, None);
485 assert_eq!(defaults.missing_coverage, MissingBehavior::Warn);
486 assert_eq!(defaults.missing_file, MissingBehavior::Warn);
487 assert_eq!(defaults.scope, Scope::Added);
488 }
489 #[test]
490 fn test_resolve_config_cli_overrides() {
491 let config = parse_config("min_diff_coverage_pct = 70").unwrap();
492 let cli = CliOverrides {
493 threshold_pct: Some(85.0),
494 ..Default::default()
495 };
496 let effective = resolve_config(Some(&config), &cli);
497
498 assert_eq!(effective.threshold_pct, 85.0);
499 }
500
501 #[test]
502 fn test_resolve_config_output_materializes_defaults() {
503 let config = parse_config(
504 r#"
505[output]
506max_markdown_lines = 12
507max_annotations = 34
508"#,
509 )
510 .unwrap();
511 let cli = CliOverrides::default();
512 let effective = resolve_config(Some(&config), &cli);
513
514 assert_eq!(effective.output.max_markdown_lines, 12);
515 assert_eq!(effective.output.max_annotations, 34);
516 assert_eq!(
517 effective.output.max_sarif_results,
518 OutputFeatureFlags::default().max_sarif_results
519 );
520 }
521
522 #[test]
523 fn test_resolve_config_output_cli_precedence() {
524 let config = parse_config(
525 r#"
526[output]
527max_markdown_lines = 12
528max_annotations = 34
529max_sarif_results = 56
530"#,
531 )
532 .unwrap();
533 let cli = CliOverrides {
534 output: Some(OutputFeatureConfig {
535 max_markdown_lines: Some(5),
536 max_annotations: None,
537 max_sarif_results: None,
538 }),
539 ..Default::default()
540 };
541 let effective = resolve_config(Some(&config), &cli);
542
543 assert_eq!(effective.output.max_markdown_lines, 5);
544 assert_eq!(effective.output.max_annotations, 34);
545 assert_eq!(effective.output.max_sarif_results, 56);
546 }
547 #[test]
548 fn test_resolve_config_applies_config_fields() {
549 let toml = r#"
550profile = "moderate"
551scope = "touched"
552fail_on = "warn"
553min_diff_coverage_pct = 66
554max_uncovered_lines = 12
555missing_coverage = "skip"
556missing_file = "fail"
557
558[paths]
559exclude = ["target/**"]
560include = ["src/**"]
561
562[ignore]
563directives = false
564
565[normalize]
566path_strip = ["/workspace/"]
567"#;
568 let config = parse_config(toml).unwrap();
569 let cli = CliOverrides::default();
570 let effective = resolve_config(Some(&config), &cli);
571 assert_eq!(effective.scope, Scope::Touched);
572 assert_eq!(effective.fail_on, FailOn::Warn);
573 assert_eq!(effective.threshold_pct, 66.0);
574 assert_eq!(effective.max_uncovered_lines, Some(12));
575 assert_eq!(effective.missing_coverage, MissingBehavior::Skip);
576 assert_eq!(effective.missing_file, MissingBehavior::Fail);
577 assert_eq!(effective.exclude_patterns, vec!["target/**"]);
578 assert_eq!(effective.include_patterns, vec!["src/**"]);
579 assert!(!effective.ignore_directives);
580 assert_eq!(effective.path_strip, vec!["/workspace/"]);
581 }
582 #[test]
583 fn test_resolve_config_cli_overrides_all_fields() {
584 let config = parse_config("min_diff_coverage_pct = 70").unwrap();
585 let cli = CliOverrides {
586 scope: Some(Scope::Touched),
587 fail_on: Some(FailOn::Warn),
588 threshold_pct: Some(85.0),
589 max_uncovered_lines: Some(9),
590 ignore_directives: Some(false),
591 path_strip: Some(vec!["/tmp/".to_string()]),
592 output: None,
593 };
594 let effective = resolve_config(Some(&config), &cli);
595 assert_eq!(effective.scope, Scope::Touched);
596 assert_eq!(effective.fail_on, FailOn::Warn);
597 assert_eq!(effective.threshold_pct, 85.0);
598 assert_eq!(effective.max_uncovered_lines, Some(9));
599 assert!(!effective.ignore_directives);
600 assert_eq!(effective.path_strip, vec!["/tmp/"]);
601 }
602 #[test]
603 fn test_resolve_config_no_config() {
604 let cli = CliOverrides::default();
605 let effective = resolve_config(None, &cli);
606
607 assert_eq!(effective.threshold_pct, 80.0);
609 assert_eq!(effective.fail_on, FailOn::Error);
610 }
611 #[test]
612 fn test_matches_any_pattern() {
613 assert!(matches_any_pattern(
614 "target/debug/foo",
615 &["target/**".to_string()]
616 ));
617 assert!(matches_any_pattern(
618 "vendor/lib.rs",
619 &["vendor/**".to_string()]
620 ));
621 assert!(!matches_any_pattern(
622 "src/lib.rs",
623 &["target/**".to_string()]
624 ));
625 }
626 #[test]
627 fn test_matches_any_pattern_invalid_glob_returns_false() {
628 assert!(!matches_any_pattern(
629 "src/lib.rs",
630 &["[invalid".to_string()]
631 ));
632 }
633 #[test]
634 fn test_should_include_path() {
635 let exclude = vec!["target/**".to_string(), "vendor/**".to_string()];
636 let include = vec![];
637
638 assert!(should_include_path("src/lib.rs", &include, &exclude));
639 assert!(!should_include_path("target/debug/foo", &include, &exclude));
640 assert!(!should_include_path("vendor/lib.rs", &include, &exclude));
641 }
642 #[test]
643 fn test_should_include_path_with_allowlist() {
644 let exclude = vec![];
645 let include = vec!["src/**".to_string()];
646
647 assert!(should_include_path("src/lib.rs", &include, &exclude));
648 assert!(!should_include_path("tests/test.rs", &include, &exclude));
649 }
650 #[test]
651 fn test_load_and_discover_config() {
652 use std::fs;
653
654 let unique = std::time::SystemTime::now()
655 .duration_since(std::time::UNIX_EPOCH)
656 .expect("time")
657 .as_nanos();
658 let root = std::env::temp_dir().join(format!("covguard-config-{unique}"));
659 let nested = root.join("child");
660 fs::create_dir_all(&nested).expect("create temp dirs");
661
662 let config_path = root.join("covguard.toml");
663 fs::write(&config_path, "min_diff_coverage_pct = 72").expect("write config");
664
665 let loaded = load_config(&config_path).expect("load config");
666 assert_eq!(loaded.min_diff_coverage_pct, Some(72.0));
667
668 let original_dir = std::env::current_dir().expect("current dir");
669 std::env::set_current_dir(&nested).expect("set current dir");
670 let discovered = discover_config().expect("discover config");
671 std::env::set_current_dir(original_dir).expect("restore current dir");
672
673 let expected_path = std::fs::canonicalize(&config_path).expect("canonicalize expected");
674 let actual_path = std::fs::canonicalize(&discovered.0).expect("canonicalize discovered");
675
676 assert_eq!(actual_path, expected_path);
677 assert_eq!(discovered.1.min_diff_coverage_pct, Some(72.0));
678
679 let _ = fs::remove_dir_all(&root);
680 }
681}