Skip to main content

cargo_bless/
policy.rs

1//! Policy layer — parses bless.toml for custom rules, overrides, and enforcement settings.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7
8/// Top-level policy configuration loaded from bless.toml.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct Policy {
11    /// Custom suggestion rules to add or override defaults.
12    #[serde(default)]
13    pub rules: Vec<PolicyRule>,
14
15    /// Packages to exclude from analysis entirely.
16    #[serde(default)]
17    pub ignore_packages: Vec<String>,
18
19    /// Override the default fail-on severity thresholds.
20    #[serde(default)]
21    pub fail_on: Option<Vec<String>>,
22
23    /// Per-package overrides (e.g., pin a version, suppress specific rules).
24    #[serde(default)]
25    pub packages: HashMap<String, PackagePolicy>,
26
27    /// Global settings.
28    #[serde(default)]
29    pub settings: PolicySettings,
30
31    /// Bullshit detector code-audit suppressions.
32    #[serde(default)]
33    pub code_audit: CodeAuditPolicy,
34}
35
36/// A custom rule from bless.toml.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PolicyRule {
39    /// Crate name or combo pattern (e.g., "reqwest+serde_json").
40    pub pattern: String,
41
42    /// Recommended replacement.
43    pub replacement: String,
44
45    /// Reason for the suggestion.
46    pub reason: String,
47
48    /// Kind of suggestion. Defaults to "modern_alternative" if omitted.
49    #[serde(default = "default_rule_kind")]
50    pub kind: String,
51
52    /// Optional condition (e.g., "version < 0.12").
53    pub condition: Option<String>,
54}
55
56fn default_rule_kind() -> String {
57    "modern_alternative".to_string()
58}
59
60/// Per-package policy overrides.
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct PackagePolicy {
63    /// Suppress all suggestions for this package.
64    #[serde(default)]
65    pub suppress: bool,
66
67    /// Pin to a specific version (prevents upgrade suggestions).
68    pub pin_version: Option<String>,
69
70    /// Custom reason for keeping the current dependency.
71    pub keep_reason: Option<String>,
72}
73
74/// Global policy settings.
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76pub struct PolicySettings {
77    /// Whether to run in offline mode by default.
78    #[serde(default)]
79    pub offline: bool,
80
81    /// Whether to include dev-dependencies in analysis by default.
82    #[serde(default)]
83    pub all_targets: bool,
84
85    /// Maximum number of suggestions to show per run (0 = unlimited).
86    #[serde(default)]
87    pub max_suggestions: usize,
88
89    /// Reserved confidence threshold for future machine-assisted suggestions.
90    #[serde(default)]
91    pub min_confidence: f64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct CodeAuditPolicy {
96    /// Suppress findings in paths containing any of these strings.
97    #[serde(default)]
98    pub ignore_paths: Vec<String>,
99
100    /// Suppress findings with these kind names, e.g. "UnwrapAbuse".
101    #[serde(default)]
102    pub ignore_kinds: Vec<String>,
103
104    /// Also scan tests/, examples/, and benches/ (default: false — src/ only).
105    #[serde(default)]
106    pub include_tests: bool,
107}
108
109/// Load policy from a bless.toml file at the given path.
110/// Returns None if the file does not exist or cannot be parsed.
111pub fn load_policy(path: &Path) -> Option<Policy> {
112    try_load_policy(path).ok()
113}
114
115/// Load policy from a bless.toml file at the given path.
116/// Returns an error if the file is missing, unreadable, or invalid.
117pub fn try_load_policy(path: &Path) -> anyhow::Result<Policy> {
118    let content = fs::read_to_string(path)
119        .map_err(|err| anyhow::anyhow!("failed to read policy {}: {err}", path.display()))?;
120    let policy: Policy = toml_edit::de::from_str(&content)
121        .map_err(|err| anyhow::anyhow!("failed to parse policy {}: {err}", path.display()))?;
122    Ok(policy)
123}
124
125/// Filter suggestions based on policy rules.
126/// - Removes suggestions for ignored packages.
127/// - Applies per-package suppress/pin overrides.
128/// - Caps total suggestions if max_suggestions is set.
129pub fn apply_policy(
130    suggestions: Vec<crate::suggestions::Suggestion>,
131    policy: &Policy,
132) -> Vec<crate::suggestions::Suggestion> {
133    let mut filtered: Vec<_> = suggestions
134        .into_iter()
135        .filter(|s| {
136            // Check ignore_packages
137            if suggestion_crates(&s.current)
138                .any(|name| policy.ignore_packages.iter().any(|ignored| ignored == name))
139            {
140                return false;
141            }
142
143            // Check per-package suppress
144            for pkg_name in s.current.split('+').map(|n| n.trim()) {
145                if let Some(pkg_policy) = policy.packages.get(pkg_name) {
146                    if pkg_policy.suppress {
147                        return false;
148                    }
149                }
150            }
151
152            true
153        })
154        .collect();
155
156    // Apply max_suggestions cap
157    if policy.settings.max_suggestions > 0 {
158        filtered.truncate(policy.settings.max_suggestions);
159    }
160
161    filtered
162}
163
164fn suggestion_crates(current: &str) -> impl Iterator<Item = &str> {
165    current.split('+').map(str::trim)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_load_policy_from_string() {
174        let toml_content = r#"
175ignore_packages = ["ignored_dep"]
176
177[[rules]]
178pattern = "old_crate"
179replacement = "new_crate"
180reason = "old_crate is unmaintained"
181kind = "modern_alternative"
182
183[packages.foo]
184suppress = true
185"#;
186        let policy: Policy = toml_edit::de::from_str(toml_content).unwrap();
187        assert_eq!(policy.rules.len(), 1);
188        assert_eq!(policy.rules[0].pattern, "old_crate");
189        assert!(policy.ignore_packages.contains(&"ignored_dep".to_string()));
190        assert!(policy.packages.get("foo").unwrap().suppress);
191    }
192
193    #[test]
194    fn test_apply_policy_suppress() {
195        let policy = Policy {
196            packages: HashMap::from_iter([(
197                "lazy_static".to_string(),
198                PackagePolicy {
199                    suppress: true,
200                    pin_version: None,
201                    keep_reason: None,
202                },
203            )]),
204            ..Default::default()
205        };
206
207        let suggestions = vec![crate::suggestions::Suggestion {
208            kind: crate::suggestions::SuggestionKind::StdReplacement,
209            current: "lazy_static".into(),
210            recommended: "std::sync::LazyLock".into(),
211            reason: "built-in since 1.80".into(),
212            source: "test".into(),
213            impact: crate::suggestions::Impact::High,
214            confidence: crate::suggestions::Confidence::High,
215            migration_risk: crate::suggestions::MigrationRisk::Low,
216            autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
217            evidence_source: crate::suggestions::EvidenceSource::Heuristic,
218            package: None,
219        }];
220
221        let filtered = apply_policy(suggestions, &policy);
222        assert!(
223            filtered.is_empty(),
224            "suppressed suggestion should be removed"
225        );
226    }
227
228    #[test]
229    fn test_apply_policy_max_suggestions() {
230        let policy = Policy {
231            settings: PolicySettings {
232                max_suggestions: 2,
233                ..Default::default()
234            },
235            ..Default::default()
236        };
237
238        let suggestions: Vec<_> = (0..5)
239            .map(|i| crate::suggestions::Suggestion {
240                kind: crate::suggestions::SuggestionKind::ModernAlternative,
241                current: format!("dep_{}", i),
242                recommended: format!("new_dep_{}", i),
243                reason: "test".into(),
244                source: "test".into(),
245                impact: crate::suggestions::Impact::Low,
246                confidence: crate::suggestions::Confidence::Medium,
247                migration_risk: crate::suggestions::MigrationRisk::Medium,
248                autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
249                evidence_source: crate::suggestions::EvidenceSource::Heuristic,
250                package: None,
251            })
252            .collect();
253
254        let filtered = apply_policy(suggestions, &policy);
255        assert_eq!(filtered.len(), 2, "should cap at max_suggestions");
256    }
257
258    #[test]
259    fn test_ignore_packages_matches_exact_crate_tokens() {
260        let policy = Policy {
261            ignore_packages: vec!["rand".to_string()],
262            ..Default::default()
263        };
264
265        let suggestions = vec![
266            crate::suggestions::Suggestion {
267                kind: crate::suggestions::SuggestionKind::ModernAlternative,
268                current: "rand".into(),
269                recommended: "getrandom".into(),
270                reason: "test".into(),
271                source: "test".into(),
272                impact: crate::suggestions::Impact::Low,
273                confidence: crate::suggestions::Confidence::Medium,
274                migration_risk: crate::suggestions::MigrationRisk::Medium,
275                autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
276                evidence_source: crate::suggestions::EvidenceSource::Heuristic,
277                package: None,
278            },
279            crate::suggestions::Suggestion {
280                kind: crate::suggestions::SuggestionKind::ModernAlternative,
281                current: "fastrand".into(),
282                recommended: "rand".into(),
283                reason: "test".into(),
284                source: "test".into(),
285                impact: crate::suggestions::Impact::Low,
286                confidence: crate::suggestions::Confidence::Medium,
287                migration_risk: crate::suggestions::MigrationRisk::Medium,
288                autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
289                evidence_source: crate::suggestions::EvidenceSource::Heuristic,
290                package: None,
291            },
292        ];
293
294        let filtered = apply_policy(suggestions, &policy);
295        assert_eq!(filtered.len(), 1);
296        assert_eq!(filtered[0].current, "fastrand");
297    }
298}