Skip to main content

lean_ctx/core/
session_diff.rs

1//! Session diffing — structured comparison of two session states.
2//!
3//! Produces a diff showing added/removed/changed files, findings,
4//! decisions, and tool-call pattern differences between sessions.
5
6use 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// ---------------------------------------------------------------------------
252// Tests
253// ---------------------------------------------------------------------------
254
255#[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}