1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct Policy {
11 #[serde(default)]
13 pub rules: Vec<PolicyRule>,
14
15 #[serde(default)]
17 pub ignore_packages: Vec<String>,
18
19 #[serde(default)]
21 pub fail_on: Option<Vec<String>>,
22
23 #[serde(default)]
25 pub packages: HashMap<String, PackagePolicy>,
26
27 #[serde(default)]
29 pub settings: PolicySettings,
30
31 #[serde(default)]
33 pub code_audit: CodeAuditPolicy,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PolicyRule {
39 pub pattern: String,
41
42 pub replacement: String,
44
45 pub reason: String,
47
48 #[serde(default = "default_rule_kind")]
50 pub kind: String,
51
52 pub condition: Option<String>,
54}
55
56fn default_rule_kind() -> String {
57 "modern_alternative".to_string()
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct PackagePolicy {
63 #[serde(default)]
65 pub suppress: bool,
66
67 pub pin_version: Option<String>,
69
70 pub keep_reason: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76pub struct PolicySettings {
77 #[serde(default)]
79 pub offline: bool,
80
81 #[serde(default)]
83 pub all_targets: bool,
84
85 #[serde(default)]
87 pub max_suggestions: usize,
88
89 #[serde(default)]
91 pub min_confidence: f64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct CodeAuditPolicy {
96 #[serde(default)]
98 pub ignore_paths: Vec<String>,
99
100 #[serde(default)]
102 pub ignore_kinds: Vec<String>,
103}
104
105pub fn load_policy(path: &Path) -> Option<Policy> {
108 let content = fs::read_to_string(path).ok()?;
109 let policy: Policy = toml_edit::de::from_str(&content).ok()?;
110 Some(policy)
111}
112
113pub fn apply_policy(
118 suggestions: Vec<crate::suggestions::Suggestion>,
119 policy: &Policy,
120) -> Vec<crate::suggestions::Suggestion> {
121 let mut filtered: Vec<_> = suggestions
122 .into_iter()
123 .filter(|s| {
124 if policy.ignore_packages.iter().any(|p| s.current.contains(p)) {
126 return false;
127 }
128
129 for pkg_name in s.current.split('+').map(|n| n.trim()) {
131 if let Some(pkg_policy) = policy.packages.get(pkg_name) {
132 if pkg_policy.suppress {
133 return false;
134 }
135 }
136 }
137
138 true
139 })
140 .collect();
141
142 if policy.settings.max_suggestions > 0 {
144 filtered.truncate(policy.settings.max_suggestions);
145 }
146
147 filtered
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn test_load_policy_from_string() {
156 let toml_content = r#"
157ignore_packages = ["ignored_dep"]
158
159[[rules]]
160pattern = "old_crate"
161replacement = "new_crate"
162reason = "old_crate is unmaintained"
163kind = "modern_alternative"
164
165[packages.foo]
166suppress = true
167"#;
168 let policy: Policy = toml_edit::de::from_str(toml_content).unwrap();
169 assert_eq!(policy.rules.len(), 1);
170 assert_eq!(policy.rules[0].pattern, "old_crate");
171 assert!(policy.ignore_packages.contains(&"ignored_dep".to_string()));
172 assert!(policy.packages.get("foo").unwrap().suppress);
173 }
174
175 #[test]
176 fn test_apply_policy_suppress() {
177 let policy = Policy {
178 packages: HashMap::from_iter([(
179 "lazy_static".to_string(),
180 PackagePolicy {
181 suppress: true,
182 pin_version: None,
183 keep_reason: None,
184 },
185 )]),
186 ..Default::default()
187 };
188
189 let suggestions = vec![crate::suggestions::Suggestion {
190 kind: crate::suggestions::SuggestionKind::StdReplacement,
191 current: "lazy_static".into(),
192 recommended: "std::sync::LazyLock".into(),
193 reason: "built-in since 1.80".into(),
194 source: "test".into(),
195 impact: crate::suggestions::Impact::High,
196 confidence: crate::suggestions::Confidence::High,
197 migration_risk: crate::suggestions::MigrationRisk::Low,
198 autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
199 evidence_source: crate::suggestions::EvidenceSource::Heuristic,
200 }];
201
202 let filtered = apply_policy(suggestions, &policy);
203 assert!(
204 filtered.is_empty(),
205 "suppressed suggestion should be removed"
206 );
207 }
208
209 #[test]
210 fn test_apply_policy_max_suggestions() {
211 let policy = Policy {
212 settings: PolicySettings {
213 max_suggestions: 2,
214 ..Default::default()
215 },
216 ..Default::default()
217 };
218
219 let suggestions: Vec<_> = (0..5)
220 .map(|i| crate::suggestions::Suggestion {
221 kind: crate::suggestions::SuggestionKind::ModernAlternative,
222 current: format!("dep_{}", i),
223 recommended: format!("new_dep_{}", i),
224 reason: "test".into(),
225 source: "test".into(),
226 impact: crate::suggestions::Impact::Low,
227 confidence: crate::suggestions::Confidence::Medium,
228 migration_risk: crate::suggestions::MigrationRisk::Medium,
229 autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
230 evidence_source: crate::suggestions::EvidenceSource::Heuristic,
231 })
232 .collect();
233
234 let filtered = apply_policy(suggestions, &policy);
235 assert_eq!(filtered.len(), 2, "should cap at max_suggestions");
236 }
237}