agentguard_policy/
diff.rs

1//! Line-level diff between two [`PolicyBundle`]s.
2
3use crate::PolicyBundle;
4
5/// Compute a unified text diff between the two bundles' policy sources.
6///
7/// The result is a list of `(policy_id, line_diff)` pairs. Each `line_diff`
8/// is the unified diff text (empty string if the policies are identical).
9pub fn diff_bundles(old: &PolicyBundle, new: &PolicyBundle) -> Vec<(String, String)> {
10    use std::collections::HashMap;
11    let old_map: HashMap<&str, &str> = old
12        .policies
13        .iter()
14        .map(|p| (p.id.as_str(), p.source.as_str()))
15        .collect();
16    let new_map: HashMap<&str, &str> = new
17        .policies
18        .iter()
19        .map(|p| (p.id.as_str(), p.source.as_str()))
20        .collect();
21
22    let mut out: Vec<(String, String)> = Vec::new();
23
24    // Removed policies.
25    for id in old_map.keys() {
26        if !new_map.contains_key(*id) {
27            out.push(((*id).to_string(), line_diff(Some(""), Some(""))));
28        }
29    }
30    // Added or changed policies.
31    for (id, new_src) in &new_map {
32        let old_src = old_map.get(id).copied();
33        if old_src != Some(*new_src) {
34            out.push(((*id).to_string(), line_diff(old_src, Some(new_src))));
35        }
36    }
37    out
38}
39
40/// Unified text diff between two optional strings. Empty string if identical.
41pub fn line_diff(old: Option<&str>, new: Option<&str>) -> String {
42    use similar::{ChangeTag, TextDiff};
43    let old = old.unwrap_or("");
44    let new = new.unwrap_or("");
45    let diff = TextDiff::from_lines(old, new);
46    let mut out = String::new();
47    for change in diff.iter_all_changes() {
48        let prefix = match change.tag() {
49            ChangeTag::Equal => " ",
50            ChangeTag::Insert => "+",
51            ChangeTag::Delete => "-",
52        };
53        out.push_str(prefix);
54        out.push_str(change.value());
55        if !change.value().ends_with('\n') {
56            out.push('\n');
57        }
58    }
59    out
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::version::PolicyVersion;
66    use crate::{NamedPolicy, PolicyBundle};
67
68    fn bundle(id: &str, source: &str) -> PolicyBundle {
69        PolicyBundle {
70            version: PolicyVersion::new(1),
71            tenant_id: "t".into(),
72            schema_hash: [0u8; 32],
73            policies_hash: [0u8; 32],
74            created_at: 0,
75            created_by: "test".into(),
76            signature: None,
77            schema_source: "entity User;".into(),
78            policies: vec![NamedPolicy {
79                id: id.into(),
80                source: source.into(),
81            }],
82        }
83    }
84
85    #[test]
86    fn unchanged_policies_produce_no_diff() {
87        let b = bundle("p0", "permit (principal, action, resource);");
88        let diff = diff_bundles(&b, &b);
89        assert!(diff.is_empty(), "expected no diff, got {:?}", diff);
90    }
91
92    #[test]
93    fn changed_policy_is_detected() {
94        let a = bundle("p0", "permit (principal, action, resource);");
95        let b = bundle("p0", "forbid (principal, action, resource);");
96        let diff = diff_bundles(&a, &b);
97        assert_eq!(diff.len(), 1);
98        let (id, text) = &diff[0];
99        assert_eq!(id, "p0");
100        assert!(text.contains("-permit"));
101        assert!(text.contains("+forbid"));
102    }
103
104    #[test]
105    fn added_policy_is_detected() {
106        let a = bundle("p0", "permit (principal, action, resource);");
107        let mut b = bundle("p0", "permit (principal, action, resource);");
108        b.policies.push(NamedPolicy {
109            id: "p1".into(),
110            source: "permit (...)".into(),
111        });
112        let diff = diff_bundles(&a, &b);
113        assert!(diff.iter().any(|(id, _)| id == "p1"));
114    }
115}