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 #[serde(default)]
106 pub include_tests: bool,
107}
108
109pub fn load_policy(path: &Path) -> Option<Policy> {
112 try_load_policy(path).ok()
113}
114
115pub 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
125pub 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 if suggestion_crates(&s.current)
138 .any(|name| policy.ignore_packages.iter().any(|ignored| ignored == name))
139 {
140 return false;
141 }
142
143 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 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}