1use serde::Serialize;
7use std::collections::{HashMap, HashSet};
8
9use crate::core::session::SessionState;
10
11#[derive(Debug, Clone, Serialize)]
12pub struct SessionDiff {
13 pub session_a: String,
14 pub session_b: String,
15 pub files: FilesDiff,
16 pub findings: CountDiff,
17 pub decisions: CountDiff,
18 pub stats: StatsDiff,
19 pub modes: ModesDiff,
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct FilesDiff {
24 pub added: Vec<String>,
25 pub removed: Vec<String>,
26 pub changed_mode: Vec<FileModeChange>,
27 pub common_count: usize,
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct FileModeChange {
32 pub path: String,
33 pub mode_a: String,
34 pub mode_b: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct CountDiff {
39 pub count_a: usize,
40 pub count_b: usize,
41 pub added: Vec<String>,
42 pub removed: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct StatsDiff {
47 pub tool_calls_a: u32,
48 pub tool_calls_b: u32,
49 pub tokens_saved_a: u64,
50 pub tokens_saved_b: u64,
51 pub files_read_a: u32,
52 pub files_read_b: u32,
53 pub commands_a: u32,
54 pub commands_b: u32,
55}
56
57#[derive(Debug, Clone, Serialize)]
58pub struct ModesDiff {
59 pub modes_a: HashMap<String, usize>,
60 pub modes_b: HashMap<String, usize>,
61}
62
63pub fn diff_sessions(a: &SessionState, b: &SessionState) -> SessionDiff {
64 SessionDiff {
65 session_a: a.id.clone(),
66 session_b: b.id.clone(),
67 files: diff_files(a, b),
68 findings: diff_findings(a, b),
69 decisions: diff_decisions(a, b),
70 stats: diff_stats(a, b),
71 modes: diff_modes(a, b),
72 }
73}
74
75fn diff_files(a: &SessionState, b: &SessionState) -> FilesDiff {
76 let paths_a: HashSet<&str> = a.files_touched.iter().map(|f| f.path.as_str()).collect();
77 let paths_b: HashSet<&str> = b.files_touched.iter().map(|f| f.path.as_str()).collect();
78
79 let added: Vec<String> = paths_b
80 .difference(&paths_a)
81 .map(ToString::to_string)
82 .collect();
83 let removed: Vec<String> = paths_a
84 .difference(&paths_b)
85 .map(ToString::to_string)
86 .collect();
87
88 let common: HashSet<&&str> = paths_a.intersection(&paths_b).collect();
89 let common_count = common.len();
90
91 let mode_map_a: HashMap<&str, &str> = a
92 .files_touched
93 .iter()
94 .map(|f| (f.path.as_str(), f.last_mode.as_str()))
95 .collect();
96 let mode_map_b: HashMap<&str, &str> = b
97 .files_touched
98 .iter()
99 .map(|f| (f.path.as_str(), f.last_mode.as_str()))
100 .collect();
101
102 let mut changed_mode = Vec::new();
103 for path in &common {
104 if let (Some(&ma), Some(&mb)) = (mode_map_a.get(**path), mode_map_b.get(**path)) {
105 if ma != mb {
106 changed_mode.push(FileModeChange {
107 path: path.to_string(),
108 mode_a: ma.to_string(),
109 mode_b: mb.to_string(),
110 });
111 }
112 }
113 }
114
115 FilesDiff {
116 added,
117 removed,
118 changed_mode,
119 common_count,
120 }
121}
122
123fn diff_findings(a: &SessionState, b: &SessionState) -> CountDiff {
124 let summaries_a: HashSet<&str> = a.findings.iter().map(|f| f.summary.as_str()).collect();
125 let summaries_b: HashSet<&str> = b.findings.iter().map(|f| f.summary.as_str()).collect();
126
127 CountDiff {
128 count_a: a.findings.len(),
129 count_b: b.findings.len(),
130 added: summaries_b
131 .difference(&summaries_a)
132 .map(ToString::to_string)
133 .collect(),
134 removed: summaries_a
135 .difference(&summaries_b)
136 .map(ToString::to_string)
137 .collect(),
138 }
139}
140
141fn diff_decisions(a: &SessionState, b: &SessionState) -> CountDiff {
142 let summaries_a: HashSet<&str> = a.decisions.iter().map(|d| d.summary.as_str()).collect();
143 let summaries_b: HashSet<&str> = b.decisions.iter().map(|d| d.summary.as_str()).collect();
144
145 CountDiff {
146 count_a: a.decisions.len(),
147 count_b: b.decisions.len(),
148 added: summaries_b
149 .difference(&summaries_a)
150 .map(ToString::to_string)
151 .collect(),
152 removed: summaries_a
153 .difference(&summaries_b)
154 .map(ToString::to_string)
155 .collect(),
156 }
157}
158
159fn diff_stats(a: &SessionState, b: &SessionState) -> StatsDiff {
160 StatsDiff {
161 tool_calls_a: a.stats.total_tool_calls,
162 tool_calls_b: b.stats.total_tool_calls,
163 tokens_saved_a: a.stats.total_tokens_saved,
164 tokens_saved_b: b.stats.total_tokens_saved,
165 files_read_a: a.stats.files_read,
166 files_read_b: b.stats.files_read,
167 commands_a: a.stats.commands_run,
168 commands_b: b.stats.commands_run,
169 }
170}
171
172fn diff_modes(a: &SessionState, b: &SessionState) -> ModesDiff {
173 let mut modes_a: HashMap<String, usize> = HashMap::new();
174 for f in &a.files_touched {
175 *modes_a.entry(f.last_mode.clone()).or_insert(0) += 1;
176 }
177 let mut modes_b: HashMap<String, usize> = HashMap::new();
178 for f in &b.files_touched {
179 *modes_b.entry(f.last_mode.clone()).or_insert(0) += 1;
180 }
181 ModesDiff { modes_a, modes_b }
182}
183
184impl SessionDiff {
185 pub fn format_summary(&self) -> String {
186 let mut lines = Vec::new();
187 lines.push(format!(
188 "Session Diff: {} vs {}",
189 &self.session_a[..8.min(self.session_a.len())],
190 &self.session_b[..8.min(self.session_b.len())]
191 ));
192
193 lines.push(format!(
194 "Files: {} common, +{} added, -{} removed, ~{} mode-changed",
195 self.files.common_count,
196 self.files.added.len(),
197 self.files.removed.len(),
198 self.files.changed_mode.len()
199 ));
200
201 if !self.files.added.is_empty() {
202 for f in &self.files.added {
203 lines.push(format!(" + {f}"));
204 }
205 }
206 if !self.files.removed.is_empty() {
207 for f in &self.files.removed {
208 lines.push(format!(" - {f}"));
209 }
210 }
211 for mc in &self.files.changed_mode {
212 lines.push(format!(" ~ {} ({} -> {})", mc.path, mc.mode_a, mc.mode_b));
213 }
214
215 lines.push(format!(
216 "Findings: {} vs {} (+{} / -{})",
217 self.findings.count_a,
218 self.findings.count_b,
219 self.findings.added.len(),
220 self.findings.removed.len()
221 ));
222
223 lines.push(format!(
224 "Decisions: {} vs {} (+{} / -{})",
225 self.decisions.count_a,
226 self.decisions.count_b,
227 self.decisions.added.len(),
228 self.decisions.removed.len()
229 ));
230
231 lines.push(format!(
232 "Stats: calls {}/{}, saved {}/{}, files {}/{}, cmds {}/{}",
233 self.stats.tool_calls_a,
234 self.stats.tool_calls_b,
235 self.stats.tokens_saved_a,
236 self.stats.tokens_saved_b,
237 self.stats.files_read_a,
238 self.stats.files_read_b,
239 self.stats.commands_a,
240 self.stats.commands_b,
241 ));
242
243 lines.join("\n")
244 }
245
246 pub fn format_json(&self) -> String {
247 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into())
248 }
249}
250
251#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn make_session(id: &str) -> SessionState {
260 let mut s = SessionState::new();
261 s.id = id.to_string();
262 s
263 }
264
265 #[test]
266 fn empty_sessions_produce_empty_diff() {
267 let a = make_session("aaa");
268 let b = make_session("bbb");
269 let d = diff_sessions(&a, &b);
270 assert!(d.files.added.is_empty());
271 assert!(d.files.removed.is_empty());
272 assert_eq!(d.files.common_count, 0);
273 }
274
275 #[test]
276 fn added_files_detected() {
277 let a = make_session("a");
278 let mut b = make_session("b");
279 b.touch_file("src/new.rs", None, "full", 100);
280 let d = diff_sessions(&a, &b);
281 assert_eq!(d.files.added, vec!["src/new.rs"]);
282 }
283
284 #[test]
285 fn removed_files_detected() {
286 let mut a = make_session("a");
287 a.touch_file("src/old.rs", None, "full", 100);
288 let b = make_session("b");
289 let d = diff_sessions(&a, &b);
290 assert_eq!(d.files.removed, vec!["src/old.rs"]);
291 }
292
293 #[test]
294 fn mode_changes_detected() {
295 let mut a = make_session("a");
296 let mut b = make_session("b");
297 a.touch_file("src/lib.rs", None, "full", 500);
298 b.touch_file("src/lib.rs", None, "signatures", 100);
299 let d = diff_sessions(&a, &b);
300 assert_eq!(d.files.changed_mode.len(), 1);
301 assert_eq!(d.files.changed_mode[0].mode_a, "full");
302 assert_eq!(d.files.changed_mode[0].mode_b, "signatures");
303 }
304
305 #[test]
306 fn findings_diff() {
307 let mut a = make_session("a");
308 let mut b = make_session("b");
309 a.add_finding(None, None, "old finding");
310 b.add_finding(None, None, "new finding");
311 let d = diff_sessions(&a, &b);
312 assert_eq!(d.findings.added, vec!["new finding"]);
313 assert_eq!(d.findings.removed, vec!["old finding"]);
314 }
315
316 #[test]
317 fn format_summary_includes_key_info() {
318 let mut a = make_session("session_aaa");
319 let mut b = make_session("session_bbb");
320 a.touch_file("src/main.rs", None, "full", 500);
321 b.touch_file("src/main.rs", None, "map", 100);
322 b.touch_file("src/new.rs", None, "full", 200);
323 let d = diff_sessions(&a, &b);
324 let summary = d.format_summary();
325 assert!(summary.contains("session_"));
326 assert!(summary.contains("+ src/new.rs"));
327 assert!(summary.contains("full -> map"));
328 }
329}