1use super::RipgrepMatch;
2use chrono::{DateTime, Utc};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone)]
7pub struct SessionGroup {
8 pub session_id: String,
9 pub file_path: String,
10 pub matches: Vec<RipgrepMatch>,
11 pub automation: Option<String>,
12}
13
14impl SessionGroup {
15 pub fn latest_timestamp(&self) -> Option<DateTime<Utc>> {
17 self.matches
18 .iter()
19 .filter_map(|m| m.message.as_ref())
20 .map(|msg| msg.timestamp)
21 .max()
22 }
23
24 pub fn first_match(&self) -> Option<&RipgrepMatch> {
26 self.matches.first()
27 }
28}
29
30pub fn group_by_session(results: Vec<RipgrepMatch>) -> Vec<SessionGroup> {
32 if results.is_empty() {
33 return vec![];
34 }
35
36 let mut group_map: HashMap<String, SessionGroup> = HashMap::new();
38
39 for m in results {
40 let Some(ref msg) = m.message else {
42 continue;
43 };
44
45 let session_id = msg.session_id.clone();
46
47 if let Some(group) = group_map.get_mut(&session_id) {
48 group.matches.push(m);
49 } else {
50 group_map.insert(
51 session_id.clone(),
52 SessionGroup {
53 session_id,
54 file_path: m.file_path.clone(),
55 matches: vec![m],
56 automation: None,
57 },
58 );
59 }
60 }
61
62 let mut groups: Vec<SessionGroup> = group_map.into_values().collect();
66
67 for group in &mut groups {
68 group.matches.sort_by(|a, b| {
69 let ta = a.message.as_ref().map(|m| m.timestamp);
70 let tb = b.message.as_ref().map(|m| m.timestamp);
71 tb.cmp(&ta) });
73 }
74
75 groups.sort_by(|a, b| {
77 let ta = a.latest_timestamp();
78 let tb = b.latest_timestamp();
79 tb.cmp(&ta) });
81
82 groups
83}
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use crate::search::{Message, SessionSource};
88 use chrono::TimeZone;
89 use std::fs;
90 use std::io::Write;
91 use tempfile::TempDir;
92
93 fn make_match(session_id: &str, timestamp_mins: i64) -> RipgrepMatch {
94 RipgrepMatch {
95 file_path: format!("/path/to/{}.jsonl", session_id),
96 message: Some(Message {
97 session_id: session_id.to_string(),
98 role: "user".to_string(),
99 content: "test content".to_string(),
100 timestamp: Utc
101 .with_ymd_and_hms(2025, 1, 9, 10, timestamp_mins as u32, 0)
102 .unwrap(),
103 branch: None,
104 line_number: 1,
105 uuid: None,
106 parent_uuid: None,
107 }),
108 source: SessionSource::ClaudeCodeCLI,
109 }
110 }
111
112 #[test]
113 fn test_group_by_session_empty() {
114 let results: Vec<RipgrepMatch> = vec![];
115 let groups = group_by_session(results);
116
117 assert!(groups.is_empty(), "Should return empty for empty input");
118 }
119
120 #[test]
121 fn test_group_by_session_single_session() {
122 let results = vec![
123 make_match("session-1", 0),
124 make_match("session-1", 1),
125 make_match("session-1", 2),
126 ];
127
128 let groups = group_by_session(results);
129
130 assert_eq!(groups.len(), 1, "Should have 1 group");
131 assert_eq!(groups[0].session_id, "session-1");
132 assert_eq!(groups[0].matches.len(), 3, "Should have 3 matches");
133 }
134
135 #[test]
136 fn test_group_by_session_multiple_sessions() {
137 let results = vec![
138 make_match("session-1", 0),
139 make_match("session-2", 1),
140 make_match("session-1", 2),
141 make_match("session-3", 3),
142 make_match("session-2", 4),
143 ];
144
145 let groups = group_by_session(results);
146
147 assert_eq!(groups.len(), 3, "Should have 3 groups");
148
149 let session_counts: HashMap<_, _> = groups
151 .iter()
152 .map(|g| (g.session_id.clone(), g.matches.len()))
153 .collect();
154
155 assert_eq!(session_counts.get("session-1"), Some(&2));
156 assert_eq!(session_counts.get("session-2"), Some(&2));
157 assert_eq!(session_counts.get("session-3"), Some(&1));
158 }
159
160 #[test]
161 fn test_group_by_session_sorted_by_newest() {
162 let results = vec![
163 make_match("old-session", 0), make_match("new-session", 59), make_match("mid-session", 30), ];
167
168 let groups = group_by_session(results);
169
170 assert_eq!(groups.len(), 3);
171 assert_eq!(
172 groups[0].session_id, "new-session",
173 "Newest should be first"
174 );
175 assert_eq!(
176 groups[1].session_id, "mid-session",
177 "Middle should be second"
178 );
179 assert_eq!(groups[2].session_id, "old-session", "Oldest should be last");
180 }
181
182 #[test]
183 fn test_group_by_session_matches_sorted_within_group() {
184 let results = vec![
185 make_match("session-1", 0),
186 make_match("session-1", 30),
187 make_match("session-1", 15),
188 ];
189
190 let groups = group_by_session(results);
191
192 assert_eq!(groups.len(), 1);
193 let matches = &groups[0].matches;
194 assert_eq!(matches.len(), 3);
195
196 let t0 = matches[0].message.as_ref().unwrap().timestamp;
198 let t1 = matches[1].message.as_ref().unwrap().timestamp;
199 let t2 = matches[2].message.as_ref().unwrap().timestamp;
200
201 assert!(t0 >= t1, "First should be newest");
202 assert!(t1 >= t2, "Second should be before third");
203 }
204
205 #[test]
206 fn test_latest_timestamp() {
207 let group = SessionGroup {
208 session_id: "test".to_string(),
209 file_path: "/path/to/test.jsonl".to_string(),
210 matches: vec![
211 make_match("test", 0),
212 make_match("test", 30), make_match("test", 15),
214 ],
215 automation: None,
216 };
217
218 let latest = group.latest_timestamp();
219
220 assert!(latest.is_some());
221 let expected = Utc.with_ymd_and_hms(2025, 1, 9, 10, 30, 0).unwrap();
222 assert_eq!(latest.unwrap(), expected);
223 }
224
225 #[test]
226 fn test_first_match() {
227 let group = SessionGroup {
228 session_id: "test".to_string(),
229 file_path: "/path/to/test.jsonl".to_string(),
230 matches: vec![make_match("test", 0), make_match("test", 1)],
231 automation: None,
232 };
233
234 let first = group.first_match();
235
236 assert!(first.is_some());
237 assert_eq!(
238 first.unwrap().message.as_ref().unwrap().timestamp,
239 Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap()
240 );
241 }
242
243 #[test]
244 fn test_first_match_empty() {
245 let group = SessionGroup {
246 session_id: "test".to_string(),
247 file_path: "/path/to/test.jsonl".to_string(),
248 matches: vec![],
249 automation: None,
250 };
251
252 let first = group.first_match();
253
254 assert!(first.is_none());
255 }
256
257 fn make_match_with_content(
258 session_id: &str,
259 role: &str,
260 content: &str,
261 timestamp_mins: i64,
262 ) -> RipgrepMatch {
263 RipgrepMatch {
264 file_path: format!("/path/to/{}.jsonl", session_id),
265 message: Some(Message {
266 session_id: session_id.to_string(),
267 role: role.to_string(),
268 content: content.to_string(),
269 timestamp: Utc
270 .with_ymd_and_hms(2025, 1, 9, 10, timestamp_mins as u32, 0)
271 .unwrap(),
272 branch: None,
273 line_number: 1,
274 uuid: None,
275 parent_uuid: None,
276 }),
277 source: SessionSource::ClaudeCodeCLI,
278 }
279 }
280
281 #[test]
282 fn test_group_leaves_automation_unset_without_session_scan() {
283 let results = vec![
284 make_match_with_content(
285 "rx-session",
286 "user",
287 "Do task. Output <<<RALPHEX:ALL_TASKS_DONE>>>",
288 0,
289 ),
290 make_match_with_content("rx-session", "assistant", "Working on it.", 1),
291 ];
292
293 let groups = group_by_session(results);
294 assert_eq!(groups.len(), 1);
295 assert_eq!(groups[0].automation, None);
296 }
297
298 #[test]
299 fn test_group_manual_session_no_automation() {
300 let results = vec![
301 make_match_with_content("manual-session", "user", "How do I sort a list?", 0),
302 make_match_with_content("manual-session", "assistant", "Use sorted()", 1),
303 ];
304
305 let groups = group_by_session(results);
306 assert_eq!(groups.len(), 1);
307 assert_eq!(groups[0].automation, None);
308 }
309
310 #[test]
311 fn test_group_marker_in_assistant_not_detected() {
312 let results = vec![
313 make_match_with_content("chat-session", "user", "Tell me about ralphex", 0),
314 make_match_with_content(
315 "chat-session",
316 "assistant",
317 "Ralphex uses <<<RALPHEX:ALL_TASKS_DONE>>> signals",
318 1,
319 ),
320 ];
321
322 let groups = group_by_session(results);
323 assert_eq!(groups.len(), 1);
324 assert_eq!(groups[0].automation, None);
325 }
326
327 #[test]
328 fn test_group_does_not_scan_session_file_when_hits_miss_marker() {
329 let dir = TempDir::new().unwrap();
330 let path = dir.path().join("session.jsonl");
331 let mut f = fs::File::create(&path).unwrap();
332
333 writeln!(
334 f,
335 r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Bootstrap <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"auto-session","timestamp":"2025-01-09T10:00:00Z"}}"#
336 )
337 .unwrap();
338 writeln!(
339 f,
340 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Later answer"}}]}},"sessionId":"auto-session","timestamp":"2025-01-09T10:01:00Z"}}"#
341 )
342 .unwrap();
343
344 let results = vec![RipgrepMatch {
345 file_path: path.to_string_lossy().to_string(),
346 message: Some(Message {
347 session_id: "auto-session".to_string(),
348 role: "assistant".to_string(),
349 content: "Later answer".to_string(),
350 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 1, 0).unwrap(),
351 branch: None,
352 line_number: 2,
353 uuid: None,
354 parent_uuid: None,
355 }),
356 source: SessionSource::ClaudeCodeCLI,
357 }];
358
359 let groups = group_by_session(results);
360 assert_eq!(groups.len(), 1);
361 assert_eq!(groups[0].automation, None);
362 }
363
364 #[test]
365 fn test_group_does_not_scan_parent_session_when_hit_is_auxiliary_file() {
366 let dir = TempDir::new().unwrap();
367 let parent_path = dir.path().join("auto-session.jsonl");
368 let aux_path = dir.path().join("agent-abc123.jsonl");
369
370 fs::write(
371 &parent_path,
372 concat!(
373 r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Bootstrap <<<RALPHEX:ALL_TASKS_DONE>>>"}]},"sessionId":"auto-session","timestamp":"2025-01-09T10:00:00Z"}"#,
374 "\n",
375 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Parent answer"}]},"sessionId":"auto-session","timestamp":"2025-01-09T10:01:00Z"}"#,
376 "\n"
377 ),
378 )
379 .unwrap();
380 fs::write(
381 &aux_path,
382 concat!(
383 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Auxiliary answer"}]},"sessionId":"auto-session","timestamp":"2025-01-09T10:02:00Z"}"#,
384 "\n"
385 ),
386 )
387 .unwrap();
388
389 let results = vec![RipgrepMatch {
390 file_path: aux_path.to_string_lossy().to_string(),
391 message: Some(Message {
392 session_id: "auto-session".to_string(),
393 role: "assistant".to_string(),
394 content: "Auxiliary answer".to_string(),
395 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 2, 0).unwrap(),
396 branch: None,
397 line_number: 1,
398 uuid: None,
399 parent_uuid: None,
400 }),
401 source: SessionSource::ClaudeCodeCLI,
402 }];
403
404 let groups = group_by_session(results);
405 assert_eq!(groups.len(), 1);
406 assert_eq!(groups[0].automation, None);
407 }
408
409 #[test]
410 fn test_group_skips_none_messages() {
411 let results = vec![
412 RipgrepMatch {
413 file_path: "/path/to/session.jsonl".to_string(),
414 message: None, source: SessionSource::ClaudeCodeCLI,
416 },
417 make_match("session-1", 0),
418 ];
419
420 let groups = group_by_session(results);
421
422 assert_eq!(groups.len(), 1);
423 assert_eq!(groups[0].matches.len(), 1, "Should skip None messages");
424 }
425}