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 try_load_policy(path).ok()
109}
110
111pub fn try_load_policy(path: &Path) -> anyhow::Result<Policy> {
114 let content = fs::read_to_string(path)
115 .map_err(|err| anyhow::anyhow!("failed to read policy {}: {err}", path.display()))?;
116 let policy: Policy = toml_edit::de::from_str(&content)
117 .map_err(|err| anyhow::anyhow!("failed to parse policy {}: {err}", path.display()))?;
118 Ok(policy)
119}
120
121pub fn apply_policy(
126 suggestions: Vec<crate::suggestions::Suggestion>,
127 policy: &Policy,
128) -> Vec<crate::suggestions::Suggestion> {
129 let mut filtered: Vec<_> = suggestions
130 .into_iter()
131 .filter(|s| {
132 if suggestion_crates(&s.current)
134 .any(|name| policy.ignore_packages.iter().any(|ignored| ignored == name))
135 {
136 return false;
137 }
138
139 for pkg_name in s.current.split('+').map(|n| n.trim()) {
141 if let Some(pkg_policy) = policy.packages.get(pkg_name) {
142 if pkg_policy.suppress {
143 return false;
144 }
145 }
146 }
147
148 true
149 })
150 .collect();
151
152 if policy.settings.max_suggestions > 0 {
154 filtered.truncate(policy.settings.max_suggestions);
155 }
156
157 filtered
158}
159
160fn suggestion_crates(current: &str) -> impl Iterator<Item = &str> {
161 current.split('+').map(str::trim)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_load_policy_from_string() {
170 let toml_content = r#"
171ignore_packages = ["ignored_dep"]
172
173[[rules]]
174pattern = "old_crate"
175replacement = "new_crate"
176reason = "old_crate is unmaintained"
177kind = "modern_alternative"
178
179[packages.foo]
180suppress = true
181"#;
182 let policy: Policy = toml_edit::de::from_str(toml_content).unwrap();
183 assert_eq!(policy.rules.len(), 1);
184 assert_eq!(policy.rules[0].pattern, "old_crate");
185 assert!(policy.ignore_packages.contains(&"ignored_dep".to_string()));
186 assert!(policy.packages.get("foo").unwrap().suppress);
187 }
188
189 #[test]
190 fn test_apply_policy_suppress() {
191 let policy = Policy {
192 packages: HashMap::from_iter([(
193 "lazy_static".to_string(),
194 PackagePolicy {
195 suppress: true,
196 pin_version: None,
197 keep_reason: None,
198 },
199 )]),
200 ..Default::default()
201 };
202
203 let suggestions = vec![crate::suggestions::Suggestion {
204 kind: crate::suggestions::SuggestionKind::StdReplacement,
205 current: "lazy_static".into(),
206 recommended: "std::sync::LazyLock".into(),
207 reason: "built-in since 1.80".into(),
208 source: "test".into(),
209 impact: crate::suggestions::Impact::High,
210 confidence: crate::suggestions::Confidence::High,
211 migration_risk: crate::suggestions::MigrationRisk::Low,
212 autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
213 evidence_source: crate::suggestions::EvidenceSource::Heuristic,
214 package: None,
215 }];
216
217 let filtered = apply_policy(suggestions, &policy);
218 assert!(
219 filtered.is_empty(),
220 "suppressed suggestion should be removed"
221 );
222 }
223
224 #[test]
225 fn test_apply_policy_max_suggestions() {
226 let policy = Policy {
227 settings: PolicySettings {
228 max_suggestions: 2,
229 ..Default::default()
230 },
231 ..Default::default()
232 };
233
234 let suggestions: Vec<_> = (0..5)
235 .map(|i| crate::suggestions::Suggestion {
236 kind: crate::suggestions::SuggestionKind::ModernAlternative,
237 current: format!("dep_{}", i),
238 recommended: format!("new_dep_{}", i),
239 reason: "test".into(),
240 source: "test".into(),
241 impact: crate::suggestions::Impact::Low,
242 confidence: crate::suggestions::Confidence::Medium,
243 migration_risk: crate::suggestions::MigrationRisk::Medium,
244 autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
245 evidence_source: crate::suggestions::EvidenceSource::Heuristic,
246 package: None,
247 })
248 .collect();
249
250 let filtered = apply_policy(suggestions, &policy);
251 assert_eq!(filtered.len(), 2, "should cap at max_suggestions");
252 }
253
254 #[test]
255 fn test_ignore_packages_matches_exact_crate_tokens() {
256 let policy = Policy {
257 ignore_packages: vec!["rand".to_string()],
258 ..Default::default()
259 };
260
261 let suggestions = vec![
262 crate::suggestions::Suggestion {
263 kind: crate::suggestions::SuggestionKind::ModernAlternative,
264 current: "rand".into(),
265 recommended: "getrandom".into(),
266 reason: "test".into(),
267 source: "test".into(),
268 impact: crate::suggestions::Impact::Low,
269 confidence: crate::suggestions::Confidence::Medium,
270 migration_risk: crate::suggestions::MigrationRisk::Medium,
271 autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
272 evidence_source: crate::suggestions::EvidenceSource::Heuristic,
273 package: None,
274 },
275 crate::suggestions::Suggestion {
276 kind: crate::suggestions::SuggestionKind::ModernAlternative,
277 current: "fastrand".into(),
278 recommended: "rand".into(),
279 reason: "test".into(),
280 source: "test".into(),
281 impact: crate::suggestions::Impact::Low,
282 confidence: crate::suggestions::Confidence::Medium,
283 migration_risk: crate::suggestions::MigrationRisk::Medium,
284 autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
285 evidence_source: crate::suggestions::EvidenceSource::Heuristic,
286 package: None,
287 },
288 ];
289
290 let filtered = apply_policy(suggestions, &policy);
291 assert_eq!(filtered.len(), 1);
292 assert_eq!(filtered[0].current, "fastrand");
293 }
294}