1use serde::Serialize;
7use similar::{ChangeTag, TextDiff};
8
9use crate::cli::Output;
10use crate::error::Result;
11use crate::models::Session;
12
13#[derive(Debug, Clone, Serialize)]
15pub struct DiffSummary {
16 pub equal: usize,
18 pub added: usize,
20 pub removed: usize,
22}
23
24pub 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 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 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 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 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 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 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 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); assert_eq!(summary.removed, 1); assert_eq!(summary.added, 2); }
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}