Skip to main content

rec/session/
diff.rs

1//! Command-level diff between two sessions.
2//!
3//! Produces unified diff output comparing commands from two sessions,
4//! with color support and JSON output mode.
5
6use serde::Serialize;
7use similar::{ChangeTag, TextDiff};
8
9use crate::cli::Output;
10use crate::error::Result;
11use crate::models::Session;
12
13/// Summary of changes between two sessions.
14#[derive(Debug, Clone, Serialize)]
15pub struct DiffSummary {
16    /// Number of commands present in both sessions
17    pub equal: usize,
18    /// Number of commands added in session 2
19    pub added: usize,
20    /// Number of commands removed from session 1
21    pub removed: usize,
22}
23
24/// Produce a unified diff of commands between two sessions.
25///
26/// Compares the command text from `session1` and `session2` using
27/// line-based diff via the `similar` crate.
28///
29/// # Output modes
30/// - Human: unified diff with optional color (red for deletions,
31///   green for insertions, cyan for hunk headers)
32/// - JSON: structured change list with summary
33///
34/// # Edge cases
35/// - Both sessions empty: prints "Both sessions have no commands"
36/// - Identical sessions: prints "Sessions are identical"
37///
38/// # Errors
39/// Returns an error only on I/O failures (unlikely for stdout).
40pub fn diff_sessions(
41    session1: &Session,
42    session2: &Session,
43    json: bool,
44    output: &Output,
45) -> Result<()> {
46    let old_commands: Vec<&str> = session1
47        .commands
48        .iter()
49        .map(|c| c.command.as_str())
50        .collect();
51    let new_commands: Vec<&str> = session2
52        .commands
53        .iter()
54        .map(|c| c.command.as_str())
55        .collect();
56
57    let old_text = old_commands.join("\n");
58    let new_text = new_commands.join("\n");
59
60    // Handle both-empty edge case
61    if old_commands.is_empty() && new_commands.is_empty() {
62        if json {
63            let output_json = serde_json::json!({
64                "session1": { "name": session1.name(), "commands": 0 },
65                "session2": { "name": session2.name(), "commands": 0 },
66                "changes": [],
67                "summary": { "equal": 0, "added": 0, "removed": 0 }
68            });
69            println!(
70                "{}",
71                serde_json::to_string_pretty(&output_json).unwrap_or_else(|_| "{}".to_string())
72            );
73        } else {
74            println!("Both sessions have no commands");
75        }
76        return Ok(());
77    }
78
79    // Add trailing newlines for proper diff behavior when non-empty
80    let old_diffable = if old_text.is_empty() {
81        old_text.clone()
82    } else {
83        format!("{old_text}\n")
84    };
85    let new_diffable = if new_text.is_empty() {
86        new_text.clone()
87    } else {
88        format!("{new_text}\n")
89    };
90
91    let diff = TextDiff::from_lines(&old_diffable, &new_diffable);
92
93    // Count changes
94    let mut summary = DiffSummary {
95        equal: 0,
96        added: 0,
97        removed: 0,
98    };
99    for change in diff.iter_all_changes() {
100        match change.tag() {
101            ChangeTag::Equal => summary.equal += 1,
102            ChangeTag::Insert => summary.added += 1,
103            ChangeTag::Delete => summary.removed += 1,
104        }
105    }
106
107    if json {
108        let mut changes: Vec<serde_json::Value> = Vec::new();
109        for change in diff.iter_all_changes() {
110            let change_type = match change.tag() {
111                ChangeTag::Equal => "equal",
112                ChangeTag::Insert => "insert",
113                ChangeTag::Delete => "delete",
114            };
115            let text = change.value().trim_end_matches('\n');
116            if !text.is_empty() || !matches!(change.tag(), ChangeTag::Equal) {
117                changes.push(serde_json::json!({
118                    "type": change_type,
119                    "command": text,
120                }));
121            }
122        }
123
124        let output_json = serde_json::json!({
125            "session1": { "name": session1.name(), "commands": old_commands.len() },
126            "session2": { "name": session2.name(), "commands": new_commands.len() },
127            "changes": changes,
128            "summary": {
129                "equal": summary.equal,
130                "added": summary.added,
131                "removed": summary.removed,
132            }
133        });
134        println!(
135            "{}",
136            serde_json::to_string_pretty(&output_json).unwrap_or_else(|_| "{}".to_string())
137        );
138        return Ok(());
139    }
140
141    // Check identical
142    if summary.added == 0 && summary.removed == 0 {
143        println!("Sessions are identical");
144        println!("{} command(s) in common, 0 added, 0 removed", summary.equal);
145        return Ok(());
146    }
147
148    // Human-readable unified diff output
149    let unified = diff
150        .unified_diff()
151        .header(
152            &format!("--- {}", session1.name()),
153            &format!("+++ {}", session2.name()),
154        )
155        .context_radius(3)
156        .to_string();
157
158    if output.colors {
159        // Post-process lines for color
160        for line in unified.lines() {
161            if line.starts_with("---") || line.starts_with("+++") {
162                println!("\x1b[1m{line}\x1b[0m");
163            } else if line.starts_with("@@") {
164                println!("\x1b[36m{line}\x1b[0m");
165            } else if line.starts_with('-') {
166                println!("\x1b[31m{line}\x1b[0m");
167            } else if line.starts_with('+') {
168                println!("\x1b[32m{line}\x1b[0m");
169            } else {
170                println!("{line}");
171            }
172        }
173    } else {
174        print!("{unified}");
175    }
176
177    println!(
178        "{} command(s) in common, {} added, {} removed",
179        summary.equal, summary.added, summary.removed
180    );
181
182    Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::models::{Command, Session, SessionStatus};
189    use std::path::PathBuf;
190
191    fn create_session(name: &str, commands: &[&str]) -> Session {
192        let mut session = Session::new(name);
193        for (i, cmd_text) in commands.iter().enumerate() {
194            session.commands.push(Command::new(
195                i as u32,
196                cmd_text.to_string(),
197                PathBuf::from("/tmp"),
198            ));
199        }
200        session.complete(SessionStatus::Completed);
201        session
202    }
203
204    #[test]
205    fn test_diff_identical_sessions() {
206        let s1 = create_session("session-a", &["echo hello", "ls -la", "pwd"]);
207        let s2 = create_session("session-b", &["echo hello", "ls -la", "pwd"]);
208
209        let output = Output {
210            colors: false,
211            symbols: crate::models::SymbolMode::Ascii,
212            verbosity: crate::models::Verbosity::Normal,
213            json: false,
214        };
215
216        let result = diff_sessions(&s1, &s2, false, &output);
217        assert!(result.is_ok());
218    }
219
220    #[test]
221    fn test_diff_completely_different() {
222        let s1 = create_session("session-a", &["echo hello", "ls -la"]);
223        let s2 = create_session("session-b", &["cargo build", "cargo test"]);
224
225        let output = Output {
226            colors: false,
227            symbols: crate::models::SymbolMode::Ascii,
228            verbosity: crate::models::Verbosity::Normal,
229            json: false,
230        };
231
232        let result = diff_sessions(&s1, &s2, false, &output);
233        assert!(result.is_ok());
234    }
235
236    #[test]
237    fn test_diff_mixed_changes() {
238        let s1 = create_session("session-a", &["echo hello", "ls -la", "pwd"]);
239        let s2 = create_session("session-b", &["echo hello", "ls -la", "whoami"]);
240
241        let output = Output {
242            colors: false,
243            symbols: crate::models::SymbolMode::Ascii,
244            verbosity: crate::models::Verbosity::Normal,
245            json: false,
246        };
247
248        let result = diff_sessions(&s1, &s2, false, &output);
249        assert!(result.is_ok());
250    }
251
252    #[test]
253    fn test_diff_empty_sessions() {
254        let s1 = create_session("session-a", &[]);
255        let s2 = create_session("session-b", &[]);
256
257        let output = Output {
258            colors: false,
259            symbols: crate::models::SymbolMode::Ascii,
260            verbosity: crate::models::Verbosity::Normal,
261            json: false,
262        };
263
264        let result = diff_sessions(&s1, &s2, false, &output);
265        assert!(result.is_ok());
266    }
267
268    #[test]
269    fn test_diff_one_empty() {
270        let s1 = create_session("session-a", &[]);
271        let s2 = create_session("session-b", &["echo hello", "ls"]);
272
273        let output = Output {
274            colors: false,
275            symbols: crate::models::SymbolMode::Ascii,
276            verbosity: crate::models::Verbosity::Normal,
277            json: false,
278        };
279
280        let result = diff_sessions(&s1, &s2, false, &output);
281        assert!(result.is_ok());
282    }
283
284    #[test]
285    fn test_diff_summary_counts() {
286        let _s1 = create_session("session-a", &["echo hello", "ls -la", "pwd"]);
287        let _s2 = create_session("session-b", &["echo hello", "ls -la", "whoami", "date"]);
288
289        // We test DiffSummary logic directly
290        let old_text = "echo hello\nls -la\npwd\n";
291        let new_text = "echo hello\nls -la\nwhoami\ndate\n";
292        let diff = TextDiff::from_lines(old_text, new_text);
293
294        let mut summary = DiffSummary {
295            equal: 0,
296            added: 0,
297            removed: 0,
298        };
299        for change in diff.iter_all_changes() {
300            match change.tag() {
301                ChangeTag::Equal => summary.equal += 1,
302                ChangeTag::Insert => summary.added += 1,
303                ChangeTag::Delete => summary.removed += 1,
304            }
305        }
306
307        assert_eq!(summary.equal, 2); // "echo hello" and "ls -la"
308        assert_eq!(summary.removed, 1); // "pwd"
309        assert_eq!(summary.added, 2); // "whoami" and "date"
310    }
311
312    #[test]
313    fn test_diff_json_output() {
314        let s1 = create_session("session-a", &["echo hello", "ls"]);
315        let s2 = create_session("session-b", &["echo hello", "pwd"]);
316
317        let output = Output {
318            colors: false,
319            symbols: crate::models::SymbolMode::Ascii,
320            verbosity: crate::models::Verbosity::Normal,
321            json: true,
322        };
323
324        let result = diff_sessions(&s1, &s2, true, &output);
325        assert!(result.is_ok());
326    }
327
328    #[test]
329    fn test_diff_colored_output() {
330        let s1 = create_session("session-a", &["echo hello", "ls -la"]);
331        let s2 = create_session("session-b", &["echo hello", "pwd"]);
332
333        let output = Output {
334            colors: true,
335            symbols: crate::models::SymbolMode::Unicode,
336            verbosity: crate::models::Verbosity::Normal,
337            json: false,
338        };
339
340        let result = diff_sessions(&s1, &s2, false, &output);
341        assert!(result.is_ok());
342    }
343
344    #[test]
345    fn test_diff_one_empty_reverse() {
346        let s1 = create_session("session-a", &["echo hello", "ls"]);
347        let s2 = create_session("session-b", &[]);
348
349        let output = Output {
350            colors: false,
351            symbols: crate::models::SymbolMode::Ascii,
352            verbosity: crate::models::Verbosity::Normal,
353            json: false,
354        };
355
356        let result = diff_sessions(&s1, &s2, false, &output);
357        assert!(result.is_ok());
358    }
359}