Skip to main content

ccs/search/
group.rs

1use super::RipgrepMatch;
2use chrono::{DateTime, Utc};
3use std::collections::HashMap;
4
5/// A group of matches from the same session
6#[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    /// Returns the most recent timestamp in the group
16    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    /// Returns the first match in the group
25    pub fn first_match(&self) -> Option<&RipgrepMatch> {
26        self.matches.first()
27    }
28}
29
30/// Group matches by session ID, sorted by newest first
31pub fn group_by_session(results: Vec<RipgrepMatch>) -> Vec<SessionGroup> {
32    if results.is_empty() {
33        return vec![];
34    }
35
36    // Group by session ID
37    let mut group_map: HashMap<String, SessionGroup> = HashMap::new();
38
39    for m in results {
40        // Skip matches without messages
41        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    // Convert to vec and sort matches within each group (newest first). Automation is
63    // resolved later from the full session file because partial search hits are not enough
64    // to classify session origin reliably.
65    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) // Newest first
72        });
73    }
74
75    // Sort groups by newest message timestamp
76    groups.sort_by(|a, b| {
77        let ta = a.latest_timestamp();
78        let tb = b.latest_timestamp();
79        tb.cmp(&ta) // Newest first
80    });
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        // Find each session and check match count
150        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),  // oldest
164            make_match("new-session", 59), // newest
165            make_match("mid-session", 30), // middle
166        ];
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        // Matches within group should be sorted by timestamp (newest first)
197        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), // latest
213                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, // Should be skipped
415                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}