use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Policy {
#[serde(default)]
pub rules: Vec<PolicyRule>,
#[serde(default)]
pub ignore_packages: Vec<String>,
#[serde(default)]
pub fail_on: Option<Vec<String>>,
#[serde(default)]
pub packages: HashMap<String, PackagePolicy>,
#[serde(default)]
pub settings: PolicySettings,
#[serde(default)]
pub code_audit: CodeAuditPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
pub pattern: String,
pub replacement: String,
pub reason: String,
#[serde(default = "default_rule_kind")]
pub kind: String,
pub condition: Option<String>,
}
fn default_rule_kind() -> String {
"modern_alternative".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PackagePolicy {
#[serde(default)]
pub suppress: bool,
pub pin_version: Option<String>,
pub keep_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PolicySettings {
#[serde(default)]
pub offline: bool,
#[serde(default)]
pub all_targets: bool,
#[serde(default)]
pub max_suggestions: usize,
#[serde(default)]
pub min_confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodeAuditPolicy {
#[serde(default)]
pub ignore_paths: Vec<String>,
#[serde(default)]
pub ignore_kinds: Vec<String>,
#[serde(default)]
pub include_tests: bool,
}
pub fn load_policy(path: &Path) -> Option<Policy> {
try_load_policy(path).ok()
}
pub fn try_load_policy(path: &Path) -> anyhow::Result<Policy> {
let content = fs::read_to_string(path)
.map_err(|err| anyhow::anyhow!("failed to read policy {}: {err}", path.display()))?;
let policy: Policy = toml_edit::de::from_str(&content)
.map_err(|err| anyhow::anyhow!("failed to parse policy {}: {err}", path.display()))?;
Ok(policy)
}
pub fn apply_policy(
suggestions: Vec<crate::suggestions::Suggestion>,
policy: &Policy,
) -> Vec<crate::suggestions::Suggestion> {
let mut filtered: Vec<_> = suggestions
.into_iter()
.filter(|s| {
if suggestion_crates(&s.current)
.any(|name| policy.ignore_packages.iter().any(|ignored| ignored == name))
{
return false;
}
for pkg_name in s.current.split('+').map(|n| n.trim()) {
if let Some(pkg_policy) = policy.packages.get(pkg_name) {
if pkg_policy.suppress {
return false;
}
}
}
true
})
.collect();
if policy.settings.max_suggestions > 0 {
filtered.truncate(policy.settings.max_suggestions);
}
filtered
}
fn suggestion_crates(current: &str) -> impl Iterator<Item = &str> {
current.split('+').map(str::trim)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_policy_from_string() {
let toml_content = r#"
ignore_packages = ["ignored_dep"]
[[rules]]
pattern = "old_crate"
replacement = "new_crate"
reason = "old_crate is unmaintained"
kind = "modern_alternative"
[packages.foo]
suppress = true
"#;
let policy: Policy = toml_edit::de::from_str(toml_content).unwrap();
assert_eq!(policy.rules.len(), 1);
assert_eq!(policy.rules[0].pattern, "old_crate");
assert!(policy.ignore_packages.contains(&"ignored_dep".to_string()));
assert!(policy.packages.get("foo").unwrap().suppress);
}
#[test]
fn test_apply_policy_suppress() {
let policy = Policy {
packages: HashMap::from_iter([(
"lazy_static".to_string(),
PackagePolicy {
suppress: true,
pin_version: None,
keep_reason: None,
},
)]),
..Default::default()
};
let suggestions = vec![crate::suggestions::Suggestion {
kind: crate::suggestions::SuggestionKind::StdReplacement,
current: "lazy_static".into(),
recommended: "std::sync::LazyLock".into(),
reason: "built-in since 1.80".into(),
source: "test".into(),
impact: crate::suggestions::Impact::High,
confidence: crate::suggestions::Confidence::High,
migration_risk: crate::suggestions::MigrationRisk::Low,
autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
evidence_source: crate::suggestions::EvidenceSource::Heuristic,
package: None,
}];
let filtered = apply_policy(suggestions, &policy);
assert!(
filtered.is_empty(),
"suppressed suggestion should be removed"
);
}
#[test]
fn test_apply_policy_max_suggestions() {
let policy = Policy {
settings: PolicySettings {
max_suggestions: 2,
..Default::default()
},
..Default::default()
};
let suggestions: Vec<_> = (0..5)
.map(|i| crate::suggestions::Suggestion {
kind: crate::suggestions::SuggestionKind::ModernAlternative,
current: format!("dep_{}", i),
recommended: format!("new_dep_{}", i),
reason: "test".into(),
source: "test".into(),
impact: crate::suggestions::Impact::Low,
confidence: crate::suggestions::Confidence::Medium,
migration_risk: crate::suggestions::MigrationRisk::Medium,
autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
evidence_source: crate::suggestions::EvidenceSource::Heuristic,
package: None,
})
.collect();
let filtered = apply_policy(suggestions, &policy);
assert_eq!(filtered.len(), 2, "should cap at max_suggestions");
}
#[test]
fn test_ignore_packages_matches_exact_crate_tokens() {
let policy = Policy {
ignore_packages: vec!["rand".to_string()],
..Default::default()
};
let suggestions = vec![
crate::suggestions::Suggestion {
kind: crate::suggestions::SuggestionKind::ModernAlternative,
current: "rand".into(),
recommended: "getrandom".into(),
reason: "test".into(),
source: "test".into(),
impact: crate::suggestions::Impact::Low,
confidence: crate::suggestions::Confidence::Medium,
migration_risk: crate::suggestions::MigrationRisk::Medium,
autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
evidence_source: crate::suggestions::EvidenceSource::Heuristic,
package: None,
},
crate::suggestions::Suggestion {
kind: crate::suggestions::SuggestionKind::ModernAlternative,
current: "fastrand".into(),
recommended: "rand".into(),
reason: "test".into(),
source: "test".into(),
impact: crate::suggestions::Impact::Low,
confidence: crate::suggestions::Confidence::Medium,
migration_risk: crate::suggestions::MigrationRisk::Medium,
autofix_safety: crate::suggestions::AutofixSafety::ManualOnly,
evidence_source: crate::suggestions::EvidenceSource::Heuristic,
package: None,
},
];
let filtered = apply_policy(suggestions, &policy);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].current, "fastrand");
}
}