1use crate::scan::ScanReport;
11use crate::scan::inventory::PathEntry;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct DiffReport {
19 pub added: Vec<PathEntry>,
21 pub modified: Vec<Change>,
23 pub removed: Vec<PathEntry>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct Change {
30 pub path: PathBuf,
32 pub category: String,
37 pub prev_sha256: String,
39 pub curr_sha256: String,
41 pub prev_size: u64,
43 pub curr_size: u64,
45}
46
47pub fn compute(prev: &ScanReport, curr: &ScanReport) -> DiffReport {
53 let prev_map: HashMap<&PathBuf, &PathEntry> = prev.paths.iter().map(|e| (&e.path, e)).collect();
54 let curr_map: HashMap<&PathBuf, &PathEntry> = curr.paths.iter().map(|e| (&e.path, e)).collect();
55
56 let mut added = Vec::new();
57 let mut modified = Vec::new();
58 let mut removed = Vec::new();
59
60 for (path, curr_entry) in &curr_map {
61 match prev_map.get(path) {
62 None => added.push((*curr_entry).clone()),
63 Some(prev_entry) if prev_entry.sha256 != curr_entry.sha256 => {
64 modified.push(Change {
65 path: (*path).clone(),
66 category: curr_entry.category.clone(),
72 prev_sha256: prev_entry.sha256.clone(),
73 curr_sha256: curr_entry.sha256.clone(),
74 prev_size: prev_entry.size,
75 curr_size: curr_entry.size,
76 });
77 }
78 _ => {}
79 }
80 }
81
82 for (path, prev_entry) in &prev_map {
83 if !curr_map.contains_key(path) {
84 removed.push((*prev_entry).clone());
85 }
86 }
87
88 added.sort_by(|a, b| a.path.cmp(&b.path));
89 modified.sort_by(|a, b| a.path.cmp(&b.path));
90 removed.sort_by(|a, b| a.path.cmp(&b.path));
91
92 DiffReport {
93 added,
94 modified,
95 removed,
96 }
97}
98
99impl DiffReport {
100 pub fn is_empty(&self) -> bool {
102 self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 fn entry(p: &str, sha: &str, size: u64) -> PathEntry {
111 PathEntry {
112 path: p.into(),
113 category: "x".into(),
114 sha256: sha.repeat(8),
115 size,
116 }
117 }
118
119 fn report(entries: Vec<PathEntry>) -> ScanReport {
120 ScanReport {
121 scanned_at: chrono::Utc::now(),
122 paths: entries,
123 }
124 }
125
126 #[test]
127 fn empty_diff() {
128 let r = report(vec![entry("/a", "11111111", 1)]);
129 let d = compute(&r, &r);
130 assert!(d.is_empty());
131 }
132
133 fn entry_with_category(p: &str, sha: &str, size: u64, category: &str) -> PathEntry {
134 PathEntry {
135 path: p.into(),
136 category: category.into(),
137 sha256: sha.repeat(8),
138 size,
139 }
140 }
141
142 #[test]
143 fn modified_change_carries_category_through() {
144 let prev = report(vec![entry_with_category(
145 "/home/u/.claude.json",
146 "11111111",
147 10,
148 "local_config",
149 )]);
150 let curr = report(vec![entry_with_category(
151 "/home/u/.claude.json",
152 "22222222",
153 12,
154 "local_config",
155 )]);
156 let d = compute(&prev, &curr);
157 assert_eq!(d.modified.len(), 1);
158 assert_eq!(d.modified[0].category, "local_config");
159 }
160
161 #[test]
162 fn detects_added_modified_removed() {
163 let prev = report(vec![entry("/a", "11111111", 1), entry("/b", "22222222", 2)]);
164 let curr = report(vec![
165 entry("/a", "33333333", 1), entry("/c", "44444444", 3), ]);
169 let d = compute(&prev, &curr);
170 assert_eq!(d.added.len(), 1);
171 assert_eq!(d.added[0].path, PathBuf::from("/c"));
172 assert_eq!(d.modified.len(), 1);
173 assert_eq!(d.modified[0].path, PathBuf::from("/a"));
174 assert_eq!(d.removed.len(), 1);
175 assert_eq!(d.removed[0].path, PathBuf::from("/b"));
176 }
177}