Skip to main content

covguard_config/
lib.rs

1//! Configuration parsing and management for covguard.
2//!
3//! This crate provides:
4//! - Configuration types (`Config`, `Profile`, etc.)
5//! - TOML parsing
6//! - Profile system (oss, team, strict)
7//! - Precedence handling (CLI > config file > defaults)
8
9use 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// ============================================================================
17// Errors
18// ============================================================================
19
20/// Errors that can occur during configuration loading.
21#[derive(Debug, Error)]
22pub enum ConfigError {
23    /// Failed to read the configuration file.
24    #[error("Failed to read config file: {0}")]
25    IoError(#[from] std::io::Error),
26
27    /// Failed to parse the configuration file.
28    #[error("Failed to parse config file: {0}")]
29    ParseError(#[from] toml::de::Error),
30
31    /// Invalid configuration value.
32    #[error("Invalid config value: {0}")]
33    InvalidValue(String),
34}
35
36// ============================================================================
37// Configuration Types
38// ============================================================================
39
40/// Path filtering configuration.
41#[derive(Debug, Clone, Default, Deserialize)]
42pub struct PathConfig {
43    /// Glob patterns for files/directories to exclude.
44    #[serde(default)]
45    pub exclude: Vec<String>,
46    /// Glob patterns for files/directories to include (allowlist).
47    /// If empty, all files are included.
48    #[serde(default)]
49    pub include: Vec<String>,
50}
51
52/// Ignore directive configuration.
53#[derive(Debug, Clone, Default, Deserialize)]
54pub struct IgnoreConfig {
55    /// Enable `covguard: ignore` directives in source comments.
56    #[serde(default = "default_true")]
57    pub directives: bool,
58}
59
60/// Path normalization configuration.
61#[derive(Debug, Clone, Default, Deserialize)]
62pub struct NormalizeConfig {
63    /// Prefixes to strip from LCOV SF paths.
64    #[serde(default)]
65    pub path_strip: Vec<String>,
66}
67
68fn default_true() -> bool {
69    true
70}
71
72/// Full configuration for covguard.
73#[derive(Debug, Clone, Default, Deserialize)]
74pub struct Config {
75    /// Configuration profile to use.
76    #[serde(default)]
77    pub profile: Option<Profile>,
78
79    /// Scope of lines to evaluate.
80    #[serde(default)]
81    pub scope: Option<Scope>,
82
83    /// Determines when the evaluation should fail.
84    #[serde(default)]
85    pub fail_on: Option<FailOn>,
86
87    /// Minimum diff coverage percentage (0-100).
88    #[serde(default)]
89    pub min_diff_coverage_pct: Option<f64>,
90
91    /// Maximum allowed uncovered lines.
92    #[serde(default)]
93    pub max_uncovered_lines: Option<u32>,
94
95    /// How to handle missing coverage data for lines.
96    #[serde(default)]
97    pub missing_coverage: Option<MissingBehavior>,
98
99    /// How to handle files with no coverage data.
100    #[serde(default)]
101    pub missing_file: Option<MissingBehavior>,
102
103    /// Path filtering configuration.
104    #[serde(default)]
105    pub paths: PathConfig,
106
107    /// Ignore directive configuration.
108    #[serde(default, rename = "ignore")]
109    pub ignore_config: IgnoreConfig,
110
111    /// Path normalization configuration.
112    #[serde(default)]
113    pub normalize: NormalizeConfig,
114
115    /// Rendering budgets for markdown, annotations, and SARIF output.
116    #[serde(default)]
117    pub output: OutputFeatureConfig,
118}
119
120// ============================================================================
121// Effective Configuration
122// ============================================================================
123
124/// Effective configuration with all values resolved.
125///
126/// This represents the final configuration after applying:
127/// 1. Profile defaults
128/// 2. Config file values
129/// 3. CLI overrides
130#[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
163// ============================================================================
164// Profile Defaults
165// ============================================================================
166
167/// Get default configuration for a profile.
168pub 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
186// ============================================================================
187// Configuration Loading
188// ============================================================================
189
190/// Load configuration from a TOML file.
191pub 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
198/// Load configuration from a TOML string.
199pub 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
205/// Validate configuration values.
206fn 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
218/// Try to find and load configuration from the standard location.
219///
220/// Searches for `covguard.toml` in the current directory and parent directories.
221pub 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// ============================================================================
241// Precedence Resolution
242// ============================================================================
243
244/// CLI override options.
245#[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
256/// Resolve effective configuration from profile, config file, and CLI overrides.
257///
258/// Precedence: CLI > config file > profile defaults > global defaults
259pub fn resolve_config(config: Option<&Config>, cli: &CliOverrides) -> EffectiveConfig {
260    // Start with profile defaults or global defaults
261    let profile = config.and_then(|c| c.profile).unwrap_or(Profile::Team);
262    let mut effective = profile_defaults(profile);
263
264    // Apply config file values
265    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    // Apply CLI overrides
292    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
317// ============================================================================
318// Path Filtering
319// ============================================================================
320
321/// Check if a path matches any of the given glob patterns.
322pub 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
333/// Filter a path based on include/exclude patterns.
334///
335/// Returns `true` if the path should be included in evaluation.
336pub fn should_include_path(
337    path: &str,
338    include_patterns: &[String],
339    exclude_patterns: &[String],
340) -> bool {
341    // If exclude patterns match, exclude the path
342    if matches_any_pattern(path, exclude_patterns) {
343        return false;
344    }
345
346    // If include patterns are specified and path doesn't match, exclude it
347    if !include_patterns.is_empty() && !matches_any_pattern(path, include_patterns) {
348        return false;
349    }
350
351    true
352}
353
354// ============================================================================
355// Tests
356// ============================================================================
357
358#[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        // Should use Team profile defaults
608        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}