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