envelope_cli/audit/
diff.rs

1//! Diff generation for audit logging
2//!
3//! Generates human-readable diffs between before and after values
4//! for audit log entries.
5
6use serde_json::Value;
7
8/// Generate a human-readable diff between two JSON values
9///
10/// Returns a string describing the changes in a user-friendly format.
11/// Only includes top-level field changes for readability.
12pub fn generate_diff(before: &Value, after: &Value) -> Option<String> {
13    match (before, after) {
14        (Value::Object(before_obj), Value::Object(after_obj)) => {
15            let mut changes = Vec::new();
16
17            // Check for modified and removed fields
18            for (key, before_val) in before_obj {
19                if let Some(after_val) = after_obj.get(key) {
20                    if before_val != after_val {
21                        changes.push(format!(
22                            "{}: {} -> {}",
23                            key,
24                            format_value(before_val),
25                            format_value(after_val)
26                        ));
27                    }
28                } else {
29                    changes.push(format!(
30                        "{}: {} -> (removed)",
31                        key,
32                        format_value(before_val)
33                    ));
34                }
35            }
36
37            // Check for added fields
38            for (key, after_val) in after_obj {
39                if !before_obj.contains_key(key) {
40                    changes.push(format!("{}: (added) -> {}", key, format_value(after_val)));
41                }
42            }
43
44            if changes.is_empty() {
45                None
46            } else {
47                Some(changes.join(", "))
48            }
49        }
50        _ => {
51            // For non-object values, just show the change
52            if before != after {
53                Some(format!(
54                    "{} -> {}",
55                    format_value(before),
56                    format_value(after)
57                ))
58            } else {
59                None
60            }
61        }
62    }
63}
64
65/// Format a JSON value for human-readable display
66fn format_value(value: &Value) -> String {
67    match value {
68        Value::Null => "null".to_string(),
69        Value::Bool(b) => b.to_string(),
70        Value::Number(n) => n.to_string(),
71        Value::String(s) => {
72            // Truncate long strings
73            if s.len() > 50 {
74                format!("\"{}...\"", &s[..47])
75            } else {
76                format!("\"{}\"", s)
77            }
78        }
79        Value::Array(arr) => format!("[{} items]", arr.len()),
80        Value::Object(obj) => format!("{{{} fields}}", obj.len()),
81    }
82}
83
84/// Generate a detailed diff that includes nested changes
85///
86/// More verbose than `generate_diff`, useful for detailed auditing.
87pub fn generate_detailed_diff(before: &Value, after: &Value, prefix: &str) -> Vec<String> {
88    let mut changes = Vec::new();
89
90    match (before, after) {
91        (Value::Object(before_obj), Value::Object(after_obj)) => {
92            // Check for modified and removed fields
93            for (key, before_val) in before_obj {
94                let field_prefix = if prefix.is_empty() {
95                    key.clone()
96                } else {
97                    format!("{}.{}", prefix, key)
98                };
99
100                if let Some(after_val) = after_obj.get(key) {
101                    if before_val != after_val {
102                        // Recurse for nested objects
103                        if before_val.is_object() && after_val.is_object() {
104                            changes.extend(generate_detailed_diff(
105                                before_val,
106                                after_val,
107                                &field_prefix,
108                            ));
109                        } else {
110                            changes.push(format!(
111                                "{}: {} -> {}",
112                                field_prefix,
113                                format_value(before_val),
114                                format_value(after_val)
115                            ));
116                        }
117                    }
118                } else {
119                    changes.push(format!(
120                        "{}: {} -> (removed)",
121                        field_prefix,
122                        format_value(before_val)
123                    ));
124                }
125            }
126
127            // Check for added fields
128            for (key, after_val) in after_obj {
129                if !before_obj.contains_key(key) {
130                    let field_prefix = if prefix.is_empty() {
131                        key.clone()
132                    } else {
133                        format!("{}.{}", prefix, key)
134                    };
135                    changes.push(format!(
136                        "{}: (added) -> {}",
137                        field_prefix,
138                        format_value(after_val)
139                    ));
140                }
141            }
142        }
143        (Value::Array(before_arr), Value::Array(after_arr)) => {
144            if before_arr.len() != after_arr.len() {
145                changes.push(format!(
146                    "{}: [{} items] -> [{} items]",
147                    prefix,
148                    before_arr.len(),
149                    after_arr.len()
150                ));
151            } else {
152                for (i, (b, a)) in before_arr.iter().zip(after_arr.iter()).enumerate() {
153                    if b != a {
154                        let item_prefix = format!("{}[{}]", prefix, i);
155                        changes.extend(generate_detailed_diff(b, a, &item_prefix));
156                    }
157                }
158            }
159        }
160        _ => {
161            if before != after {
162                changes.push(format!(
163                    "{}: {} -> {}",
164                    prefix,
165                    format_value(before),
166                    format_value(after)
167                ));
168            }
169        }
170    }
171
172    changes
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use serde_json::json;
179
180    #[test]
181    fn test_simple_field_change() {
182        let before = json!({"name": "Checking", "balance": 1000});
183        let after = json!({"name": "Checking", "balance": 1500});
184
185        let diff = generate_diff(&before, &after).unwrap();
186        assert!(diff.contains("balance: 1000 -> 1500"));
187        assert!(!diff.contains("name")); // unchanged field
188    }
189
190    #[test]
191    fn test_string_field_change() {
192        let before = json!({"name": "Old Name"});
193        let after = json!({"name": "New Name"});
194
195        let diff = generate_diff(&before, &after).unwrap();
196        assert!(diff.contains("name: \"Old Name\" -> \"New Name\""));
197    }
198
199    #[test]
200    fn test_field_added() {
201        let before = json!({"name": "Test"});
202        let after = json!({"name": "Test", "balance": 100});
203
204        let diff = generate_diff(&before, &after).unwrap();
205        assert!(diff.contains("balance: (added) -> 100"));
206    }
207
208    #[test]
209    fn test_field_removed() {
210        let before = json!({"name": "Test", "old_field": "value"});
211        let after = json!({"name": "Test"});
212
213        let diff = generate_diff(&before, &after).unwrap();
214        assert!(diff.contains("old_field: \"value\" -> (removed)"));
215    }
216
217    #[test]
218    fn test_no_changes() {
219        let before = json!({"name": "Test", "value": 100});
220        let after = json!({"name": "Test", "value": 100});
221
222        let diff = generate_diff(&before, &after);
223        assert!(diff.is_none());
224    }
225
226    #[test]
227    fn test_multiple_changes() {
228        let before = json!({"a": 1, "b": 2, "c": 3});
229        let after = json!({"a": 10, "b": 2, "c": 30});
230
231        let diff = generate_diff(&before, &after).unwrap();
232        assert!(diff.contains("a: 1 -> 10"));
233        assert!(diff.contains("c: 3 -> 30"));
234        assert!(!diff.contains("b:")); // unchanged
235    }
236
237    #[test]
238    fn test_bool_change() {
239        let before = json!({"active": true});
240        let after = json!({"active": false});
241
242        let diff = generate_diff(&before, &after).unwrap();
243        assert!(diff.contains("active: true -> false"));
244    }
245
246    #[test]
247    fn test_null_handling() {
248        let before = json!({"value": null});
249        let after = json!({"value": 100});
250
251        let diff = generate_diff(&before, &after).unwrap();
252        assert!(diff.contains("value: null -> 100"));
253    }
254
255    #[test]
256    fn test_array_change_summary() {
257        let before = json!({"items": [1, 2, 3]});
258        let after = json!({"items": [1, 2, 3, 4, 5]});
259
260        let diff = generate_diff(&before, &after).unwrap();
261        assert!(diff.contains("items: [3 items] -> [5 items]"));
262    }
263
264    #[test]
265    fn test_detailed_diff_nested() {
266        let before = json!({"account": {"name": "Old", "balance": 100}});
267        let after = json!({"account": {"name": "New", "balance": 100}});
268
269        let changes = generate_detailed_diff(&before, &after, "");
270        assert!(changes.iter().any(|c| c.contains("account.name")));
271    }
272
273    #[test]
274    fn test_long_string_truncation() {
275        let long_string = "a".repeat(100);
276        let before = json!({"memo": long_string});
277        let after = json!({"memo": "short"});
278
279        let diff = generate_diff(&before, &after).unwrap();
280        assert!(diff.contains("...\""));
281    }
282
283    #[test]
284    fn test_format_value() {
285        assert_eq!(format_value(&json!(null)), "null");
286        assert_eq!(format_value(&json!(true)), "true");
287        assert_eq!(format_value(&json!(42)), "42");
288        assert_eq!(format_value(&json!("test")), "\"test\"");
289        assert_eq!(format_value(&json!([1, 2, 3])), "[3 items]");
290        assert_eq!(format_value(&json!({"a": 1, "b": 2})), "{2 fields}");
291    }
292}