Skip to main content

covguard_policy/
lib.rs

1//! Shared policy model for coverage policy evaluation and profile-based presets.
2
3use serde::{Deserialize, Serialize};
4use std::str::FromStr;
5
6// ============================================================================
7// Policy enums
8// ============================================================================
9
10/// Scope of lines to evaluate.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Scope {
14    /// Only evaluate added lines.
15    #[default]
16    Added,
17    /// Evaluate all touched (added + modified) lines.
18    Touched,
19}
20
21impl Scope {
22    /// Render the scope as the canonical protocol string.
23    pub const fn as_str(&self) -> &'static str {
24        match self {
25            Self::Added => "added",
26            Self::Touched => "touched",
27        }
28    }
29}
30
31/// Determines when the evaluation should fail.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum FailOn {
35    /// Fail if there are any error-level findings.
36    #[default]
37    Error,
38    /// Fail if there are any warn-level or error-level findings.
39    Warn,
40    /// Never fail (always pass unless there's a runtime error).
41    Never,
42}
43
44/// How to handle missing coverage data.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum MissingBehavior {
48    /// Skip missing coverage from the percentage and do not emit missing-file findings.
49    Skip,
50    /// Warn on missing coverage (default behavior).
51    #[default]
52    Warn,
53    /// Fail on missing coverage.
54    Fail,
55}
56
57/// Built-in policy profiles.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum Profile {
61    /// Open-source-friendly: warn on missing data, never fail.
62    Oss,
63    /// Transitional profile for teams still stabilizing strictness.
64    Moderate,
65    /// Team standard profile.
66    #[default]
67    Team,
68    /// Strict profile for higher-quality checks.
69    Strict,
70    /// Lenient profile for exploratory or onboarding runs.
71    Lenient,
72}
73
74impl Profile {
75    /// Canonical string representation of the profile.
76    pub const fn as_str(&self) -> &'static str {
77        match self {
78            Self::Oss => "oss",
79            Self::Moderate => "moderate",
80            Self::Team => "team",
81            Self::Strict => "strict",
82            Self::Lenient => "lenient",
83        }
84    }
85
86    /// Resolve full policy settings for a profile.
87    pub const fn flags(self) -> ProfileFlags {
88        match self {
89            Self::Oss => ProfileFlags {
90                scope: Scope::Added,
91                fail_on: FailOn::Never,
92                threshold_pct: 70.0,
93                max_uncovered_lines: None,
94                missing_coverage: MissingBehavior::Skip,
95                missing_file: MissingBehavior::Skip,
96                ignore_directives: true,
97            },
98            Self::Moderate => ProfileFlags {
99                scope: Scope::Added,
100                fail_on: FailOn::Error,
101                threshold_pct: 75.0,
102                max_uncovered_lines: None,
103                missing_coverage: MissingBehavior::Warn,
104                missing_file: MissingBehavior::Skip,
105                ignore_directives: true,
106            },
107            Self::Team => ProfileFlags {
108                scope: Scope::Added,
109                fail_on: FailOn::Error,
110                threshold_pct: 80.0,
111                max_uncovered_lines: None,
112                missing_coverage: MissingBehavior::Warn,
113                missing_file: MissingBehavior::Warn,
114                ignore_directives: true,
115            },
116            Self::Strict => ProfileFlags {
117                scope: Scope::Touched,
118                fail_on: FailOn::Error,
119                threshold_pct: 90.0,
120                max_uncovered_lines: Some(5),
121                missing_coverage: MissingBehavior::Fail,
122                missing_file: MissingBehavior::Fail,
123                ignore_directives: true,
124            },
125            Self::Lenient => ProfileFlags {
126                scope: Scope::Added,
127                fail_on: FailOn::Never,
128                threshold_pct: 0.0,
129                max_uncovered_lines: None,
130                missing_coverage: MissingBehavior::Warn,
131                missing_file: MissingBehavior::Warn,
132                ignore_directives: true,
133            },
134        }
135    }
136}
137
138/// Parse a profile by case-insensitive label.
139pub fn profile_from_name(name: &str) -> Option<Profile> {
140    match name.to_ascii_lowercase().as_str() {
141        "oss" => Some(Profile::Oss),
142        "moderate" => Some(Profile::Moderate),
143        "team" => Some(Profile::Team),
144        "strict" => Some(Profile::Strict),
145        "lenient" => Some(Profile::Lenient),
146        _ => None,
147    }
148}
149
150impl FromStr for Profile {
151    type Err = ();
152
153    fn from_str(name: &str) -> Result<Self, Self::Err> {
154        profile_from_name(name).ok_or(())
155    }
156}
157
158/// Concrete policy settings for a profile.
159#[derive(Debug, Clone, Copy, PartialEq)]
160pub struct ProfileFlags {
161    /// Which lines to evaluate.
162    pub scope: Scope,
163    /// Failure semantics for error-level and warn-level findings.
164    pub fail_on: FailOn,
165    /// Minimum diff coverage threshold.
166    pub threshold_pct: f64,
167    /// Maximum allowed uncovered lines before surfacing as error-level findings.
168    pub max_uncovered_lines: Option<u32>,
169    /// Missing coverage behavior for individual lines.
170    pub missing_coverage: MissingBehavior,
171    /// Missing coverage behavior for entire files.
172    pub missing_file: MissingBehavior,
173    /// Whether ignore directives can disable coverage checks for specific lines.
174    pub ignore_directives: bool,
175}
176
177/// Resolve profile default flags.
178pub const fn profile_defaults(profile: Profile) -> ProfileFlags {
179    profile.flags()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_scope_str() {
188        assert_eq!(Scope::Added.as_str(), "added");
189        assert_eq!(Scope::Touched.as_str(), "touched");
190    }
191
192    #[test]
193    fn test_profile_flags_have_expected_thresholds() {
194        assert_eq!(profile_defaults(Profile::Oss).threshold_pct, 70.0);
195        assert_eq!(profile_defaults(Profile::Moderate).threshold_pct, 75.0);
196        assert_eq!(profile_defaults(Profile::Team).threshold_pct, 80.0);
197        assert_eq!(profile_defaults(Profile::Strict).threshold_pct, 90.0);
198        assert_eq!(profile_defaults(Profile::Lenient).threshold_pct, 0.0);
199    }
200
201    #[test]
202    fn test_profile_from_name_parsing() {
203        assert_eq!(Profile::from_str("OSS").ok(), Some(Profile::Oss));
204        assert_eq!(Profile::from_str("lenient").ok(), Some(Profile::Lenient));
205        assert_eq!(profile_from_name("unknown"), None);
206    }
207
208    #[test]
209    fn test_profile_default_behavior() {
210        assert_eq!(profile_defaults(Profile::Lenient).fail_on, FailOn::Never);
211        assert_eq!(
212            profile_defaults(Profile::Strict).max_uncovered_lines,
213            Some(5)
214        );
215        assert_eq!(profile_defaults(Profile::Strict).scope, Scope::Touched);
216    }
217}