Skip to main content

ccs/
recent.rs

1use chrono::{DateTime, Utc};
2use rayon::prelude::*;
3use std::collections::{HashMap, HashSet};
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader, Read as _, Seek, SeekFrom};
6use std::path::{Path, PathBuf};
7
8use crate::search::extract_project_from_path;
9use crate::session::{self, SessionSource};
10
11const HEAD_SCAN_LINES: usize = 30;
12
13/// A recently accessed Claude session with summary metadata.
14#[derive(Debug, Clone)]
15pub struct RecentSession {
16    pub session_id: String,
17    pub file_path: String,
18    pub project: String,
19    pub source: SessionSource,
20    pub timestamp: DateTime<Utc>,
21    pub summary: String,
22    pub automation: Option<String>,
23}
24
25/// Truncate a string to `max_len` characters, appending "..." if truncated.
26fn truncate_summary(s: &str, max_len: usize) -> String {
27    let trimmed = s.trim();
28    if trimmed.chars().count() <= max_len {
29        trimmed.to_string()
30    } else {
31        let truncated: String = trimmed.chars().take(max_len.saturating_sub(3)).collect();
32        format!("{}...", truncated)
33    }
34}
35
36/// Extract text content from a message's content field.
37/// Handles both array format [{"type":"text","text":"..."}] and plain string.
38fn extract_text_content(content: &serde_json::Value) -> Option<String> {
39    if let Some(s) = content.as_str() {
40        let trimmed = s.trim();
41        if !trimmed.is_empty() {
42            return Some(trimmed.to_string());
43        }
44        return None;
45    }
46
47    if let Some(arr) = content.as_array() {
48        let mut parts: Vec<String> = Vec::new();
49        for item in arr {
50            let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
51            if item_type == "text" {
52                if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
53                    let trimmed = text.trim();
54                    if !trimmed.is_empty() {
55                        parts.push(trimmed.to_string());
56                    }
57                }
58            }
59        }
60        if !parts.is_empty() {
61            return Some(parts.join(" "));
62        }
63    }
64
65    None
66}
67
68#[derive(Default)]
69struct HeadScan {
70    lines_scanned: usize,
71    session_id: Option<String>,
72    first_user_message: Option<String>,
73    last_summary: Option<String>,
74    last_summary_sid: Option<String>,
75    automation: Option<String>,
76    saw_off_chain_summary: bool,
77}
78
79fn build_latest_chain(path: &Path) -> Option<HashSet<String>> {
80    let file = File::open(path).ok()?;
81    let reader = BufReader::new(file);
82    let mut parents: HashMap<String, Option<String>> = HashMap::new();
83    let mut last_uuid: Option<String> = None;
84
85    for line in reader.lines() {
86        let line = match line {
87            Ok(line) => line,
88            Err(_) => continue,
89        };
90
91        if !line.contains("\"uuid\"") {
92            continue;
93        }
94
95        let json: serde_json::Value = match serde_json::from_str(&line) {
96            Ok(v) => v,
97            Err(_) => continue,
98        };
99
100        if session::is_synthetic_linear_record(&json) {
101            continue;
102        }
103
104        let Some(uuid) = session::extract_uuid(&json) else {
105            continue;
106        };
107        parents.insert(uuid.clone(), session::extract_parent_uuid(&json));
108        last_uuid = Some(uuid);
109    }
110
111    let mut chain = HashSet::new();
112    let mut current = last_uuid?;
113    loop {
114        if !chain.insert(current.clone()) {
115            break;
116        }
117        let Some(parent_uuid) = parents.get(&current).cloned().flatten() else {
118            break;
119        };
120        current = parent_uuid;
121    }
122
123    Some(chain)
124}
125
126fn summary_is_on_latest_chain(
127    json: &serde_json::Value,
128    latest_chain: Option<&HashSet<String>>,
129) -> bool {
130    let Some(latest_chain) = latest_chain else {
131        return true;
132    };
133    let Some(leaf_uuid) = session::extract_leaf_uuid(json) else {
134        return true;
135    };
136    latest_chain.contains(&leaf_uuid)
137}
138
139fn scan_head_with_chain(
140    path: &Path,
141    max_lines: usize,
142    latest_chain: Option<&HashSet<String>>,
143) -> Option<HeadScan> {
144    let file = File::open(path).ok()?;
145    let reader = BufReader::new(file);
146    let mut scan = HeadScan::default();
147
148    for (i, line) in reader.lines().enumerate() {
149        if i >= max_lines {
150            break;
151        }
152        scan.lines_scanned = i + 1;
153
154        let line = match line {
155            Ok(l) => l,
156            Err(_) => continue,
157        };
158
159        let json: serde_json::Value = match serde_json::from_str(&line) {
160            Ok(v) => v,
161            Err(_) => continue,
162        };
163
164        if session::is_synthetic_linear_record(&json) {
165            continue;
166        }
167
168        if scan.session_id.is_none() {
169            scan.session_id = session::extract_session_id(&json);
170        }
171
172        if session::extract_record_type(&json) == Some("summary") {
173            if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
174                let trimmed = summary_text.trim();
175                if !trimmed.is_empty() {
176                    if summary_is_on_latest_chain(&json, latest_chain) {
177                        scan.last_summary = Some(truncate_summary(trimmed, 100));
178                        scan.last_summary_sid = session::extract_session_id(&json);
179                    } else {
180                        scan.saw_off_chain_summary = true;
181                    }
182                }
183            }
184        }
185
186        if session::extract_record_type(&json) == Some("user") {
187            let is_meta = json
188                .get("isMeta")
189                .and_then(|v| v.as_bool())
190                .unwrap_or(false);
191
192            if !is_meta {
193                if let Some(message) = json.get("message") {
194                    if let Some(content) = message.get("content") {
195                        if let Some(text) = extract_text_content(content) {
196                            if scan.automation.is_none() {
197                                scan.automation =
198                                    session::detect_automation(&text).map(|s| s.to_string());
199                            }
200                            if scan.first_user_message.is_none()
201                                && !text.starts_with("<system-reminder>")
202                            {
203                                scan.first_user_message = Some(truncate_summary(&text, 100));
204                            }
205                        }
206                    }
207                }
208            }
209        }
210
211        if scan.first_user_message.is_some()
212            && scan.session_id.is_some()
213            && scan.last_summary.is_some()
214        {
215            break;
216        }
217    }
218
219    Some(scan)
220}
221
222#[cfg(test)]
223fn scan_head(path: &Path, max_lines: usize) -> Option<HeadScan> {
224    scan_head_with_chain(path, max_lines, None)
225}
226
227#[derive(Default)]
228struct TailSummaryScan {
229    summary: Option<(Option<String>, String, Option<String>)>,
230    saw_off_chain_summary: bool,
231}
232
233/// Read the last `max_bytes` of a file and search for the last `type=summary` record.
234/// Compaction summaries are appended during context compaction, so they appear near
235/// the end of long session files. Returns (session_id, summary_text, automation) if found.
236///
237/// Reads into a byte buffer and skips to the first newline after the seek offset
238/// to avoid splitting multibyte UTF-8 characters or partial JSONL lines.
239fn find_summary_from_tail_with_chain(
240    path: &Path,
241    max_bytes: u64,
242    latest_chain: Option<&HashSet<String>>,
243) -> Option<TailSummaryScan> {
244    let mut file = File::open(path).ok()?;
245    let file_len = file.metadata().ok()?.len();
246    let start = file_len.saturating_sub(max_bytes);
247
248    // When seeking into the middle, start one byte earlier so we can check
249    // whether the seek position falls on a line boundary without reopening the file.
250    let read_start = if start > 0 { start - 1 } else { 0 };
251    if read_start > 0 {
252        file.seek(SeekFrom::Start(read_start)).ok()?;
253    }
254    let mut buf = Vec::new();
255    file.read_to_end(&mut buf).ok()?;
256
257    // If we seeked into the middle of the file, skip to the first complete line
258    // to avoid partial lines and mid-UTF-8 character issues.
259    // The buffer includes one extra byte before `start` (when start > 0) so we
260    // can check for a line boundary without a second file open.
261    let data = if start > 0 {
262        // buf[0] is the byte at read_start (= start - 1). The actual tail starts at buf[1..].
263        let at_line_boundary = buf[0] == b'\n';
264        let tail_buf = &buf[1..];
265        if at_line_boundary {
266            tail_buf
267        } else if tail_buf.first() == Some(&b'\n') {
268            &tail_buf[1..]
269        } else if let Some(pos) = tail_buf.iter().position(|&b| b == b'\n') {
270            &tail_buf[pos + 1..]
271        } else {
272            return None;
273        }
274    } else {
275        &buf
276    };
277    let tail = String::from_utf8_lossy(data);
278
279    // Find the last summary record in the tail, and track any sessionId from any record
280    // so we have a fallback if the summary record itself lacks a sessionId.
281    // Also detect automation from user-type records in the tail.
282    let mut last_summary: Option<(Option<String>, String)> = None;
283    let mut any_sid: Option<String> = None;
284    let mut tail_automation: Option<String> = None;
285    let mut saw_off_chain_summary = false;
286    for line in tail.lines() {
287        let json: serde_json::Value = match serde_json::from_str(line) {
288            Ok(v) => v,
289            Err(_) => continue,
290        };
291        if session::is_synthetic_linear_record(&json) {
292            continue;
293        }
294        if any_sid.is_none() {
295            any_sid = session::extract_session_id(&json);
296        }
297        if tail_automation.is_none() && session::extract_record_type(&json) == Some("user") {
298            let is_meta = json
299                .get("isMeta")
300                .and_then(|v| v.as_bool())
301                .unwrap_or(false);
302            if !is_meta {
303                if let Some(message) = json.get("message") {
304                    if let Some(content) = message.get("content") {
305                        if let Some(text) = extract_text_content(content) {
306                            tail_automation =
307                                session::detect_automation(&text).map(|s| s.to_string());
308                        }
309                    }
310                }
311            }
312        }
313        if session::extract_record_type(&json) == Some("summary") {
314            if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
315                let trimmed = summary_text.trim();
316                if !trimmed.is_empty() {
317                    if summary_is_on_latest_chain(&json, latest_chain) {
318                        let sid = session::extract_session_id(&json);
319                        last_summary = Some((sid, truncate_summary(trimmed, 100)));
320                    } else {
321                        saw_off_chain_summary = true;
322                    }
323                }
324            }
325        }
326    }
327
328    Some(TailSummaryScan {
329        summary: last_summary.map(|(sid, text)| (sid.or(any_sid), text, tail_automation)),
330        saw_off_chain_summary,
331    })
332}
333
334#[cfg(test)]
335fn find_summary_from_tail(
336    path: &Path,
337    max_bytes: u64,
338) -> Option<(Option<String>, String, Option<String>)> {
339    find_summary_from_tail_with_chain(path, max_bytes, None)?.summary
340}
341
342fn extract_latest_user_message_on_chain(
343    path: &Path,
344    latest_chain: &HashSet<String>,
345) -> Option<String> {
346    let file = File::open(path).ok()?;
347    let reader = BufReader::new(file);
348    let mut latest_user_message: Option<String> = None;
349
350    for line in reader.lines() {
351        let line = match line {
352            Ok(line) => line,
353            Err(_) => continue,
354        };
355
356        let json: serde_json::Value = match serde_json::from_str(&line) {
357            Ok(v) => v,
358            Err(_) => continue,
359        };
360
361        if session::is_synthetic_linear_record(&json)
362            || session::extract_record_type(&json) != Some("user")
363        {
364            continue;
365        }
366
367        let is_meta = json
368            .get("isMeta")
369            .and_then(|v| v.as_bool())
370            .unwrap_or(false);
371        if is_meta {
372            continue;
373        }
374
375        let Some(uuid) = session::extract_uuid(&json) else {
376            continue;
377        };
378        if !latest_chain.contains(&uuid) {
379            continue;
380        }
381
382        let Some(message) = json.get("message") else {
383            continue;
384        };
385        let Some(content) = message.get("content") else {
386            continue;
387        };
388        let Some(text) = extract_text_content(content) else {
389            continue;
390        };
391        if text.starts_with("<system-reminder>") {
392            continue;
393        }
394
395        latest_user_message = Some(truncate_summary(&text, 100));
396    }
397
398    latest_user_message
399}
400
401pub(crate) fn detect_session_automation(path: &Path) -> Option<String> {
402    let file = File::open(path).ok()?;
403    let reader = BufReader::new(file);
404
405    for line in reader.lines() {
406        let line = match line {
407            Ok(line) => line,
408            Err(_) => continue,
409        };
410
411        let json: serde_json::Value = match serde_json::from_str(&line) {
412            Ok(v) => v,
413            Err(_) => continue,
414        };
415
416        if session::is_synthetic_linear_record(&json)
417            || session::extract_record_type(&json) != Some("user")
418        {
419            continue;
420        }
421
422        let is_meta = json
423            .get("isMeta")
424            .and_then(|v| v.as_bool())
425            .unwrap_or(false);
426        if is_meta {
427            continue;
428        }
429
430        let Some(message) = json.get("message") else {
431            continue;
432        };
433        let Some(content) = message.get("content") else {
434            continue;
435        };
436        let Some(text) = extract_text_content(content) else {
437            continue;
438        };
439
440        if let Some(automation) = session::detect_automation(&text) {
441            return Some(automation.to_string());
442        }
443    }
444
445    None
446}
447
448/// Extract a `RecentSession` from a JSONL session file.
449///
450/// Priority for summary:
451/// 1. `type=summary` record -> use `.summary` field (scans file tail, head, then middle)
452/// 2. First `type=user` where `isMeta` is not true -> extract text content
453///
454/// Uses a three-pass approach:
455/// 1. Check the last 256KB for summary records (compaction summaries at file end)
456/// 2. Scan first 30 lines for session_id, first user message, and summary records
457/// 3. If no summary found yet, scan remaining lines for summary records only
458///
459/// Uses file mtime as timestamp for accurate recency sorting.
460pub fn extract_summary(path: &Path) -> Option<RecentSession> {
461    let path_str = path.to_str().unwrap_or("");
462    let source = SessionSource::from_path(path_str);
463    let project = extract_project_from_path(path_str);
464    const TAIL_BYTES: u64 = 256 * 1024;
465    let latest_chain = build_latest_chain(path);
466
467    // Use file mtime as timestamp — more accurate for recency than first JSONL record
468    let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
469    let mtime_timestamp: DateTime<Utc> = mtime.into();
470    let head_scan = scan_head_with_chain(path, HEAD_SCAN_LINES, latest_chain.as_ref())?;
471
472    // Calculate where the tail scan started (in bytes) so pass 3 knows when to stop
473    let file_len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
474    let tail_start = file_len.saturating_sub(TAIL_BYTES);
475    let tail_scan = find_summary_from_tail_with_chain(path, TAIL_BYTES, latest_chain.as_ref())
476        .unwrap_or_default();
477    let tail_summary = tail_scan.summary;
478    let tail_scanned_user_fields = tail_summary.is_some();
479
480    // Pass 2: reuse the head scan, then let the tail summary override it when present.
481    let mut session_id = head_scan.session_id.clone();
482    let mut first_user_message = head_scan.first_user_message.clone();
483    let mut last_summary = head_scan.last_summary.clone();
484    let mut last_summary_sid = head_scan.last_summary_sid.clone();
485    let mut automation = head_scan.automation.clone();
486    let mut saw_off_chain_summary =
487        head_scan.saw_off_chain_summary || tail_scan.saw_off_chain_summary;
488    let lines_scanned = head_scan.lines_scanned;
489
490    if let Some((tail_sid, summary_text, tail_automation)) = tail_summary {
491        session_id = tail_sid.clone().or(session_id);
492        last_summary = Some(summary_text);
493        last_summary_sid = tail_sid;
494        automation = tail_automation.or(automation);
495    }
496
497    // Pass 3: scan the middle region for any metadata the head/tail passes still
498    // could not find. This handles three cases:
499    // - Compaction wrote a summary early, then many messages pushed it out of both windows
500    // - The file starts with >30 non-message records (e.g., file-history-snapshot) so the
501    //   first user message was beyond the head scan
502    // - Automation markers or session IDs only appear in the unscanned middle of a large file
503    //
504    // Once a summary record is already known, there is no need to keep scanning for the
505    // first user message fallback.
506    let need_summary = last_summary.is_none();
507    let need_user_msg = last_summary.is_none() && first_user_message.is_none();
508    let need_sid = last_summary_sid.is_none() && session_id.is_none();
509    let need_automation = automation.is_none();
510    // For summaries, only scan the middle if tail_start > 0 (otherwise tail covered the whole
511    // file). For user messages, session_id, and automation, always scan beyond the head since
512    // the tail pass never looks for them.
513    let should_scan_middle =
514        (need_summary && tail_start > 0) || need_user_msg || need_sid || need_automation;
515    if should_scan_middle && lines_scanned >= HEAD_SCAN_LINES {
516        let file = File::open(path).ok()?;
517        let reader = BufReader::new(file);
518        let mut bytes_read: u64 = 0;
519
520        for (i, line) in reader.lines().enumerate() {
521            // Skip lines already covered by pass 2
522            if i < lines_scanned {
523                if let Ok(ref l) = line {
524                    bytes_read += l.len() as u64 + 1; // +1 for newline
525                }
526                continue;
527            }
528
529            // For summary scanning, stop before the tail region (already covered by pass 1).
530            // For user message scanning, continue through the tail since pass 1 never looks
531            // for user messages unless it already found a tail summary.
532            let in_tail_region = tail_start > 0 && bytes_read >= tail_start;
533            let still_need_user_msg = need_user_msg && first_user_message.is_none();
534            let still_need_sid = need_sid && session_id.is_none();
535            let still_need_auto = need_automation && automation.is_none();
536            let need_tail_user_scan = !tail_scanned_user_fields
537                && (still_need_user_msg || still_need_sid || still_need_auto);
538            if in_tail_region && !need_tail_user_scan {
539                break;
540            }
541
542            let line = match line {
543                Ok(l) => l,
544                Err(_) => continue,
545            };
546            bytes_read += line.len() as u64 + 1;
547
548            // Stop once we have everything we need from the middle scan
549            let have_summary = !need_summary || last_summary.is_some();
550            let have_user_msg = !need_user_msg || first_user_message.is_some();
551            let have_sid = !need_sid || session_id.is_some();
552            let have_auto = !need_automation || automation.is_some();
553            if have_summary && have_user_msg && have_sid && have_auto {
554                break;
555            }
556
557            let could_be_summary = need_summary && !in_tail_region && line.contains("\"summary\"");
558            let could_be_user =
559                (still_need_user_msg || still_need_auto) && line.contains("\"user\"");
560
561            let could_have_sid = still_need_sid
562                && (line.contains("\"sessionId\"") || line.contains("\"session_id\""));
563
564            if !could_be_summary && !could_be_user && !could_have_sid {
565                continue;
566            }
567
568            let json: serde_json::Value = match serde_json::from_str(&line) {
569                Ok(v) => v,
570                Err(_) => continue,
571            };
572            if session::is_synthetic_linear_record(&json) {
573                continue;
574            }
575
576            // Extract session_id from any parsed record if still missing
577            if session_id.is_none() {
578                session_id = session::extract_session_id(&json);
579            }
580
581            if could_be_summary && session::extract_record_type(&json) == Some("summary") {
582                if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
583                    let trimmed = summary_text.trim();
584                    if !trimmed.is_empty() {
585                        if summary_is_on_latest_chain(&json, latest_chain.as_ref()) {
586                            last_summary = Some(truncate_summary(trimmed, 100));
587                            last_summary_sid = session::extract_session_id(&json);
588                        } else {
589                            saw_off_chain_summary = true;
590                        }
591                    }
592                }
593            }
594
595            if could_be_user && session::extract_record_type(&json) == Some("user") {
596                let is_meta = json
597                    .get("isMeta")
598                    .and_then(|v| v.as_bool())
599                    .unwrap_or(false);
600                if !is_meta {
601                    if let Some(message) = json.get("message") {
602                        if let Some(content) = message.get("content") {
603                            if let Some(text) = extract_text_content(content) {
604                                if automation.is_none() {
605                                    automation =
606                                        session::detect_automation(&text).map(|s| s.to_string());
607                                }
608                                if first_user_message.is_none()
609                                    && !text.starts_with("<system-reminder>")
610                                {
611                                    first_user_message = Some(truncate_summary(&text, 100));
612                                }
613                            }
614                        }
615                    }
616                }
617            }
618        }
619    }
620
621    // After pass 3, check if we found a summary in the middle region
622    if let Some(summary_text) = last_summary {
623        let sid = last_summary_sid.or(session_id)?;
624        return Some(RecentSession {
625            session_id: sid,
626            file_path: path_str.to_string(),
627            project,
628            source,
629            timestamp: mtime_timestamp,
630            summary: summary_text,
631            automation,
632        });
633    }
634
635    let session_id = session_id?;
636    let summary = if saw_off_chain_summary {
637        latest_chain
638            .as_ref()
639            .and_then(|chain| extract_latest_user_message_on_chain(path, chain))
640            .or(first_user_message)
641            .unwrap_or_default()
642    } else {
643        first_user_message.unwrap_or_default()
644    };
645
646    if summary.is_empty() {
647        return None;
648    }
649
650    Some(RecentSession {
651        session_id,
652        file_path: path_str.to_string(),
653        project,
654        source,
655        timestamp: mtime_timestamp,
656        summary,
657        automation,
658    })
659}
660
661/// Read the first few lines to extract session_id.
662/// Scans up to 30 lines to handle files that start with non-session records
663/// (e.g., file-history-snapshot, summary) before the first record with a session_id.
664#[cfg(test)]
665fn extract_session_id_from_head(path: &Path) -> Option<String> {
666    scan_head(path, HEAD_SCAN_LINES).and_then(|scan| scan.session_id)
667}
668
669/// Walk directories and find `*.jsonl` files, skipping `agent-*` files.
670fn find_jsonl_files(search_paths: &[String]) -> Vec<PathBuf> {
671    let mut files: Vec<PathBuf> = Vec::new();
672    for base in search_paths {
673        let base_path = Path::new(base);
674        if !base_path.is_dir() {
675            continue;
676        }
677        collect_jsonl_recursive(base_path, &mut files);
678    }
679    files
680}
681
682fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
683    let entries = match fs::read_dir(dir) {
684        Ok(e) => e,
685        Err(_) => return,
686    };
687    for entry in entries.flatten() {
688        let path = entry.path();
689        if path.is_dir() {
690            // Skip symlinks to avoid infinite loops from cyclic directory structures
691            if path.is_symlink() {
692                continue;
693            }
694            collect_jsonl_recursive(&path, files);
695        } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
696            if name.ends_with(".jsonl") && !name.starts_with("agent-") {
697                files.push(path);
698            }
699        }
700    }
701}
702
703/// Collect recent sessions from search paths.
704///
705/// Walks directories, finds `*.jsonl` files (skipping `agent-*`),
706/// sorts by filesystem mtime descending, extracts summaries in parallel
707/// with rayon (filtering out non-session files), and returns the top
708/// `limit` results sorted by session timestamp descending.
709pub fn collect_recent_sessions(search_paths: &[String], limit: usize) -> Vec<RecentSession> {
710    let files = find_jsonl_files(search_paths);
711
712    // Collect mtime once per file, then sort — avoids O(n log n) repeated stat() calls
713    let mut files_with_mtime: Vec<(PathBuf, std::time::SystemTime)> = files
714        .into_iter()
715        .filter_map(|p| {
716            fs::metadata(&p)
717                .and_then(|m| m.modified())
718                .ok()
719                .map(|t| (p, t))
720        })
721        .collect();
722    files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1));
723
724    let files: Vec<PathBuf> = files_with_mtime.into_iter().map(|(p, _)| p).collect();
725
726    // Process files in batches to avoid reading the entire corpus when only `limit`
727    // sessions are needed. Non-session JSONL files (metadata, auxiliary) return None
728    // from extract_summary(), so we process extra files per batch to compensate.
729    // Start with 4x the limit and expand if needed.
730    let mut sessions: Vec<RecentSession> = Vec::new();
731    let batch_multiplier = 4;
732    let mut offset = 0;
733
734    loop {
735        let batch_size = if offset == 0 {
736            (limit * batch_multiplier).max(limit)
737        } else {
738            // Subsequent batches: process remaining files
739            files.len().saturating_sub(offset)
740        };
741        let end = (offset + batch_size).min(files.len());
742        if offset >= end {
743            break;
744        }
745
746        let batch = &files[offset..end];
747        let batch_sessions: Vec<RecentSession> = batch
748            .par_iter()
749            .filter_map(|path| extract_summary(path))
750            .collect();
751        sessions.extend(batch_sessions);
752        offset = end;
753
754        // If we have enough sessions, stop processing more files
755        if sessions.len() >= limit {
756            break;
757        }
758    }
759
760    // Sort by timestamp descending and apply limit
761    sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
762    sessions.truncate(limit);
763
764    sessions
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use chrono::TimeZone;
771    use std::io::Write;
772    use tempfile::NamedTempFile;
773
774    #[test]
775    fn test_recent_session_creation() {
776        let ts = Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap();
777        let session = RecentSession {
778            session_id: "sess-linear-001".to_string(),
779            file_path: "/Users/user/.claude/projects/-Users-user-myproject/abc.jsonl".to_string(),
780            project: "myproject".to_string(),
781            source: SessionSource::ClaudeCodeCLI,
782            timestamp: ts,
783            summary: "How do I sort a list in Python?".to_string(),
784            automation: None,
785        };
786        assert_eq!(session.session_id, "sess-linear-001");
787        assert_eq!(session.project, "myproject");
788        assert_eq!(session.source, SessionSource::ClaudeCodeCLI);
789        assert_eq!(session.timestamp, ts);
790        assert_eq!(session.summary, "How do I sort a list in Python?");
791    }
792
793    #[test]
794    fn test_recent_session_desktop_source() {
795        let ts = Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap();
796        let session = RecentSession {
797            session_id: "desktop-uuid".to_string(),
798            file_path: "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_session/audit.jsonl".to_string(),
799            project: "uuid1".to_string(),
800            source: SessionSource::ClaudeDesktop,
801            timestamp: ts,
802            summary: "Desktop session summary".to_string(),
803            automation: None,
804        };
805        assert_eq!(session.source, SessionSource::ClaudeDesktop);
806        assert_eq!(session.project, "uuid1");
807    }
808
809    #[test]
810    fn test_extract_summary_returns_first_user_message() {
811        let mut f = NamedTempFile::new().unwrap();
812        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How do I sort a list in Python?"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
813        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Use sorted()"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
814
815        let result = extract_summary(f.path()).unwrap();
816        assert_eq!(result.summary, "How do I sort a list in Python?");
817        assert_eq!(result.session_id, "sess-001");
818    }
819
820    #[test]
821    fn test_extract_summary_prefers_summary_record() {
822        let mut f = NamedTempFile::new().unwrap();
823        writeln!(f, r#"{{"type":"summary","summary":"This session discussed Python sorting","sessionId":"sess-001","timestamp":"2025-06-01T09:59:00Z"}}"#).unwrap();
824        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How do I sort a list?"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
825
826        let result = extract_summary(f.path()).unwrap();
827        assert_eq!(result.summary, "This session discussed Python sorting");
828    }
829
830    #[test]
831    fn test_extract_summary_returns_none_for_empty_file() {
832        let f = NamedTempFile::new().unwrap();
833        let result = extract_summary(f.path());
834        assert!(result.is_none());
835    }
836
837    #[test]
838    fn test_extract_summary_returns_none_for_assistant_only() {
839        let mut f = NamedTempFile::new().unwrap();
840        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hello"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
841
842        let result = extract_summary(f.path());
843        assert!(result.is_none());
844    }
845
846    #[test]
847    fn test_extract_summary_returns_none_for_synthetic_linear_bootstrap_only() {
848        let mut f = NamedTempFile::new().unwrap();
849        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Synthetic linear resume"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:00:00Z","ccsSyntheticLinear":true}}"#).unwrap();
850        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"reply"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:01:00Z","ccsSyntheticLinear":true}}"#).unwrap();
851
852        let result = extract_summary(f.path());
853        assert!(result.is_none());
854    }
855
856    #[test]
857    fn test_extract_summary_keeps_resumed_synthetic_branch_visible() {
858        let mut f = NamedTempFile::new().unwrap();
859        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Synthetic linear resume"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:00:00Z","ccsSyntheticLinear":true}}"#).unwrap();
860        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"bootstrap reply"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:01:00Z","ccsSyntheticLinear":true}}"#).unwrap();
861        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Continue working on this branch"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
862        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Continuing"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
863
864        let result = extract_summary(f.path()).unwrap();
865        assert_eq!(result.session_id, "sess-synth");
866        assert_eq!(result.summary, "Continue working on this branch");
867    }
868
869    #[test]
870    fn test_extract_summary_truncates_long_messages() {
871        let long_msg = "a".repeat(200);
872        let mut f = NamedTempFile::new().unwrap();
873        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#, long_msg).unwrap();
874
875        let result = extract_summary(f.path()).unwrap();
876        assert_eq!(result.summary.len(), 100); // 97 chars + "..."
877        assert!(result.summary.ends_with("..."));
878    }
879
880    #[test]
881    fn test_extract_summary_handles_desktop_format() {
882        let mut f = NamedTempFile::new().unwrap();
883        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"Explain Docker networking"}},"session_id":"desktop-001","_audit_timestamp":"2026-01-13T10:00:00.000Z"}}"#).unwrap();
884
885        let result = extract_summary(f.path()).unwrap();
886        assert_eq!(result.summary, "Explain Docker networking");
887        assert_eq!(result.session_id, "desktop-001");
888    }
889
890    #[test]
891    fn test_extract_summary_skips_meta_messages() {
892        let mut f = NamedTempFile::new().unwrap();
893        writeln!(f, r#"{{"type":"user","isMeta":true,"message":{{"role":"user","content":[{{"type":"text","text":"init message"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
894        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Real question here"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
895
896        let result = extract_summary(f.path()).unwrap();
897        assert_eq!(result.summary, "Real question here");
898    }
899
900    #[test]
901    fn test_extract_summary_skips_system_reminder_messages() {
902        let mut f = NamedTempFile::new().unwrap();
903        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"<system-reminder>Some system context</system-reminder>"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
904        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Real user question"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
905
906        let result = extract_summary(f.path()).unwrap();
907        assert_eq!(result.summary, "Real user question");
908    }
909
910    #[test]
911    fn test_extract_summary_none_when_only_system_reminders() {
912        let mut f = NamedTempFile::new().unwrap();
913        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"<system-reminder>System context only"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
914
915        let result = extract_summary(f.path());
916        assert!(result.is_none());
917    }
918
919    #[test]
920    fn test_extract_summary_prefers_summary_after_user_message() {
921        // Compaction layout: user message first, then summary record later.
922        // The summary record should be preferred over the first user message.
923        let mut f = NamedTempFile::new().unwrap();
924        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Start a long conversation"}}]}},"uuid":"c1","sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
925        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Sure, ready."}}]}},"uuid":"c2","parentUuid":"c1","sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
926        writeln!(f, r#"{{"type":"summary","summary":"The conversation covered initial setup and greetings.","uuid":"c3","parentUuid":"c2","sessionId":"sess-001","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
927        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Continue after compaction"}}]}},"uuid":"c4","parentUuid":"c3","sessionId":"sess-001","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
928
929        let result = extract_summary(f.path()).unwrap();
930        assert_eq!(
931            result.summary,
932            "The conversation covered initial setup and greetings."
933        );
934    }
935
936    #[test]
937    fn test_extract_summary_from_fixture_compaction() {
938        let path = Path::new("tests/fixtures/compaction_session.jsonl");
939        let result = extract_summary(path).unwrap();
940        assert_eq!(
941            result.summary,
942            "The conversation covered initial setup and greetings."
943        );
944        assert_eq!(result.session_id, "sess-compact-001");
945    }
946
947    #[test]
948    fn test_extract_summary_ignores_off_chain_summary_and_uses_live_branch_prompt() {
949        let mut f = NamedTempFile::new().unwrap();
950        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Original prompt"}}]}},"uuid":"u1","sessionId":"sess-branch","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
951        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Initial reply"}}]}},"uuid":"a1","parentUuid":"u1","sessionId":"sess-branch","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
952        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Abandoned branch prompt"}}]}},"uuid":"u2","parentUuid":"a1","sessionId":"sess-branch","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
953        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Abandoned branch reply"}}]}},"uuid":"a2","parentUuid":"u2","sessionId":"sess-branch","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
954        writeln!(f, r#"{{"type":"summary","summary":"Stale abandoned branch summary","leafUuid":"a2","uuid":"s1","parentUuid":"a2","sessionId":"sess-branch","timestamp":"2025-06-01T10:04:00Z"}}"#).unwrap();
955        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Live branch prompt"}}]}},"uuid":"u3","parentUuid":"a1","sessionId":"sess-branch","timestamp":"2025-06-01T10:05:00Z"}}"#).unwrap();
956        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Live branch reply"}}]}},"uuid":"a3","parentUuid":"u3","sessionId":"sess-branch","timestamp":"2025-06-01T10:06:00Z"}}"#).unwrap();
957
958        let result = extract_summary(f.path()).unwrap();
959        assert_eq!(result.session_id, "sess-branch");
960        assert_eq!(result.summary, "Live branch prompt");
961    }
962
963    #[test]
964    fn test_collect_recent_sessions_not_crowded_by_nonsession_files() {
965        // Auxiliary JSONL files (no user messages, no session_id) should not
966        // crowd out real sessions from the result when limit is applied.
967        let dir = tempfile::TempDir::new().unwrap();
968        let proj = dir.path().join("projects").join("-Users-user-proj");
969        std::fs::create_dir_all(&proj).unwrap();
970
971        // Create 3 real sessions first (older mtime)
972        for i in 0..3 {
973            write_test_session(
974                &proj,
975                &format!("real{}.jsonl", i),
976                &format!("sess-{}", i),
977                &format!("Real question {}", i),
978            );
979        }
980
981        // Brief pause so auxiliary files get newer mtime
982        std::thread::sleep(std::time::Duration::from_millis(50));
983
984        // Create 5 auxiliary JSONL files (no valid session data) — newer mtime
985        for i in 0..5 {
986            let aux_path = proj.join(format!("aux{}.jsonl", i));
987            let mut f = std::fs::File::create(&aux_path).unwrap();
988            writeln!(f, r#"{{"some_metadata":"value{}"}}"#, i).unwrap();
989        }
990
991        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
992        // With limit=3, if we only took top 3 by mtime (all aux files), we'd get 0 sessions.
993        // The over-fetch (limit*2=6) ensures we reach real sessions.
994        let result = collect_recent_sessions(&paths, 3);
995        assert_eq!(result.len(), 3);
996    }
997
998    #[test]
999    fn test_extract_summary_from_fixture_linear() {
1000        let path = Path::new("tests/fixtures/linear_session.jsonl");
1001        let result = extract_summary(path).unwrap();
1002        assert_eq!(result.summary, "How do I sort a list in Python?");
1003        assert_eq!(result.session_id, "sess-linear-001");
1004    }
1005
1006    #[test]
1007    fn test_extract_summary_from_fixture_desktop() {
1008        let path = Path::new("tests/fixtures/desktop_audit_session.jsonl");
1009        let result = extract_summary(path).unwrap();
1010        assert_eq!(result.summary, "Explain Docker networking");
1011    }
1012
1013    #[test]
1014    fn test_truncate_summary_short() {
1015        assert_eq!(truncate_summary("short text", 100), "short text");
1016    }
1017
1018    #[test]
1019    fn test_truncate_summary_exact() {
1020        let s = "a".repeat(100);
1021        assert_eq!(truncate_summary(&s, 100), s);
1022    }
1023
1024    #[test]
1025    fn test_truncate_summary_long() {
1026        let s = "a".repeat(150);
1027        let result = truncate_summary(&s, 100);
1028        assert_eq!(result.chars().count(), 100); // 97 chars + "..."
1029        assert!(result.ends_with("..."));
1030    }
1031
1032    // --- collect_recent_sessions tests ---
1033
1034    fn write_test_session(dir: &std::path::Path, filename: &str, session_id: &str, msg: &str) {
1035        let path = dir.join(filename);
1036        let mut f = std::fs::File::create(&path).unwrap();
1037        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"{}","timestamp":"2025-06-01T10:00:00Z"}}"#, msg, session_id).unwrap();
1038        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"reply"}}]}},"sessionId":"{}","timestamp":"2025-06-01T10:01:00Z"}}"#, session_id).unwrap();
1039    }
1040
1041    #[test]
1042    fn test_collect_recent_sessions_finds_across_dirs() {
1043        let dir = tempfile::TempDir::new().unwrap();
1044        let proj1 = dir.path().join("projects").join("-Users-user-proj1");
1045        let proj2 = dir.path().join("projects").join("-Users-user-proj2");
1046        std::fs::create_dir_all(&proj1).unwrap();
1047        std::fs::create_dir_all(&proj2).unwrap();
1048
1049        write_test_session(&proj1, "sess1.jsonl", "sess-1", "Question one");
1050        write_test_session(&proj2, "sess2.jsonl", "sess-2", "Question two");
1051
1052        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1053        let result = collect_recent_sessions(&paths, 50);
1054        assert_eq!(result.len(), 2);
1055
1056        let ids: Vec<&str> = result.iter().map(|s| s.session_id.as_str()).collect();
1057        assert!(ids.contains(&"sess-1"));
1058        assert!(ids.contains(&"sess-2"));
1059    }
1060
1061    #[test]
1062    fn test_collect_recent_sessions_skips_agent_files() {
1063        let dir = tempfile::TempDir::new().unwrap();
1064        let proj = dir.path().join("projects").join("-Users-user-proj");
1065        std::fs::create_dir_all(&proj).unwrap();
1066
1067        write_test_session(&proj, "sess1.jsonl", "sess-1", "Normal session");
1068        write_test_session(&proj, "agent-abc123.jsonl", "sess-1", "Agent session");
1069
1070        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1071        let result = collect_recent_sessions(&paths, 50);
1072        assert_eq!(result.len(), 1);
1073        assert_eq!(result[0].session_id, "sess-1");
1074    }
1075
1076    #[test]
1077    fn test_collect_recent_sessions_sorts_by_timestamp_desc() {
1078        let dir = tempfile::TempDir::new().unwrap();
1079        let proj = dir.path().join("projects").join("-Users-user-proj");
1080        std::fs::create_dir_all(&proj).unwrap();
1081
1082        // Create files with different JSONL timestamps
1083        let older_path = proj.join("older.jsonl");
1084        let mut f = std::fs::File::create(&older_path).unwrap();
1085        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Old question"}}]}},"sessionId":"sess-old","timestamp":"2025-01-01T10:00:00Z"}}"#).unwrap();
1086
1087        let newer_path = proj.join("newer.jsonl");
1088        let mut f = std::fs::File::create(&newer_path).unwrap();
1089        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"New question"}}]}},"sessionId":"sess-new","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1090
1091        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1092        let result = collect_recent_sessions(&paths, 50);
1093        assert_eq!(result.len(), 2);
1094        // Final sort is by session timestamp descending
1095        assert_eq!(result[0].session_id, "sess-new");
1096        assert_eq!(result[1].session_id, "sess-old");
1097    }
1098
1099    #[test]
1100    fn test_extract_summary_finds_late_summary_record() {
1101        // Simulates a long compacted session where the summary record appears
1102        // far from the beginning (beyond any fixed forward-scan limit).
1103        let mut f = NamedTempFile::new().unwrap();
1104        // First user message at line 1
1105        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1106        // 50 assistant/user exchanges (100 lines) pushing summary far away
1107        for i in 0..50 {
1108            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1109            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Follow-up {}"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:02:00Z"}}"#, i).unwrap();
1110        }
1111        // Summary record at line ~102 — well beyond any forward-scan limit
1112        writeln!(f, r#"{{"type":"summary","summary":"Session about Rust performance optimization","sessionId":"sess-long","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1113
1114        let result = extract_summary(f.path()).unwrap();
1115        assert_eq!(
1116            result.summary,
1117            "Session about Rust performance optimization"
1118        );
1119        assert_eq!(result.session_id, "sess-long");
1120    }
1121
1122    #[test]
1123    fn test_extract_summary_finds_summary_in_middle_of_large_file() {
1124        // Regression test: summary record sits between the head scan (30 lines)
1125        // and the tail scan (last 256KB). Without pass 3, this summary is invisible.
1126        let mut f = NamedTempFile::new().unwrap();
1127        // First user message at line 1
1128        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1129        // 40 lines of messages to push past the 30-line head scan
1130        for i in 0..40 {
1131            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1132        }
1133        // Summary record at line ~42 — beyond head scan
1134        writeln!(f, r#"{{"type":"summary","summary":"Mid-file compaction summary","sessionId":"sess-mid","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1135        // Add >256KB of content after the summary to push it out of the tail scan
1136        let padding_line = format!(
1137            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T12:00:00Z"}}"#,
1138            "x".repeat(500)
1139        );
1140        // Each line is ~600 bytes; need ~430 lines to exceed 256KB
1141        for _ in 0..500 {
1142            writeln!(f, "{}", padding_line).unwrap();
1143        }
1144
1145        let result = extract_summary(f.path()).unwrap();
1146        assert_eq!(result.summary, "Mid-file compaction summary");
1147        assert_eq!(result.session_id, "sess-mid");
1148    }
1149
1150    #[test]
1151    fn test_find_summary_from_tail_handles_multibyte_utf8_boundary() {
1152        // If the tail-scan byte offset lands in the middle of a multibyte UTF-8
1153        // character, the function should still find the summary (not silently fail).
1154        let mut f = NamedTempFile::new().unwrap();
1155        // Write a line with multibyte characters (Cyrillic = 2 bytes each, emoji = 4 bytes each)
1156        // This line is ~200 bytes total
1157        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Привет мир 🌍🌍🌍🌍🌍"}}]}},"sessionId":"sess-utf8","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1158        // Summary line is ~120 bytes
1159        writeln!(f, r#"{{"type":"summary","summary":"UTF-8 session summary","sessionId":"sess-utf8","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1160
1161        // Use a tail size that includes the summary line but starts mid-way through
1162        // the first line's multibyte characters. The file is ~320 bytes total;
1163        // reading the last 200 bytes should land inside the Cyrillic/emoji text.
1164        let result = find_summary_from_tail(f.path(), 200);
1165        assert!(result.is_some());
1166        let (sid, text, _auto) = result.unwrap();
1167        assert_eq!(text, "UTF-8 session summary");
1168        assert_eq!(sid, Some("sess-utf8".to_string()));
1169    }
1170
1171    #[test]
1172    fn test_extract_summary_finds_summary_in_head_when_tail_misses() {
1173        // Simulates a session where the summary record is near the beginning
1174        // (within the first 30 lines) and the tail window doesn't contain it.
1175        let mut f = NamedTempFile::new().unwrap();
1176        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-head","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1177        writeln!(f, r#"{{"type":"summary","summary":"Summary found in head scan","sessionId":"sess-head","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1178        // Add a few more user messages after the summary
1179        for i in 0..5 {
1180            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Follow-up {}"}}]}},"sessionId":"sess-head","timestamp":"2025-06-01T10:02:00Z"}}"#, i).unwrap();
1181        }
1182
1183        let result = extract_summary(f.path()).unwrap();
1184        // Should prefer the summary record over the first user message
1185        assert_eq!(result.summary, "Summary found in head scan");
1186        assert_eq!(result.session_id, "sess-head");
1187    }
1188
1189    #[test]
1190    fn test_extract_session_id_from_head_skips_non_session_records() {
1191        // Simulates a file where the first several lines are non-session records
1192        // (e.g., file-history-snapshot) without sessionId, followed by a user record
1193        // with a sessionId beyond the old 5-line limit.
1194        let mut f = NamedTempFile::new().unwrap();
1195        // 8 lines of metadata without sessionId
1196        for i in 0..8 {
1197            writeln!(
1198                f,
1199                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1200                i
1201            )
1202            .unwrap();
1203        }
1204        // User record with sessionId on line 9
1205        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Real question"}}]}},"sessionId":"sess-late-id","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1206
1207        let result = extract_session_id_from_head(f.path());
1208        assert_eq!(result, Some("sess-late-id".to_string()));
1209    }
1210
1211    #[test]
1212    fn test_extract_summary_finds_summary_after_user_in_large_file() {
1213        // Simulates a file larger than TAIL_BYTES where a summary record appears
1214        // AFTER the first user message in the head. The forward scan must not
1215        // break early before finding the summary.
1216        let mut f = NamedTempFile::new().unwrap();
1217        // User message on line 1
1218        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1219        // Some assistant/user exchanges
1220        for i in 0..5 {
1221            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1222        }
1223        // Summary record on line 7 — after the first user message
1224        writeln!(f, r#"{{"type":"summary","summary":"Summary after user message","sessionId":"sess-big","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
1225        // Pad the file to exceed TAIL_BYTES (256KB) so the tail scan doesn't cover the head
1226        let padding_line = format!(
1227            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:03:00Z"}}"#,
1228            "x".repeat(1024)
1229        );
1230        for _ in 0..300 {
1231            writeln!(f, "{}", padding_line).unwrap();
1232        }
1233
1234        let result = extract_summary(f.path()).unwrap();
1235        // Must find the summary record, not fall back to the first user message
1236        assert_eq!(result.summary, "Summary after user message");
1237        assert_eq!(result.session_id, "sess-big");
1238    }
1239
1240    #[test]
1241    fn test_extract_summary_user_message_beyond_head_scan() {
1242        // Regression test: session starts with >30 non-message records (e.g.,
1243        // file-history-snapshot) so the first user message is beyond the 30-line
1244        // head scan. Without scanning the middle for user messages, the session
1245        // would be silently dropped.
1246        let mut f = NamedTempFile::new().unwrap();
1247        // 35 lines of metadata records (with sessionId on the first one)
1248        writeln!(f, r#"{{"type":"file-history-snapshot","files":["a.rs"],"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1249        for i in 1..35 {
1250            writeln!(
1251                f,
1252                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1253                i
1254            )
1255            .unwrap();
1256        }
1257        // First user message at line 36 — beyond the 30-line head scan
1258        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Deep user question"}}]}},"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1259        // A few more messages
1260        for i in 0..3 {
1261            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:02:00Z"}}"#, i).unwrap();
1262        }
1263
1264        let result = extract_summary(f.path());
1265        assert!(
1266            result.is_some(),
1267            "Session with user message beyond 30-line head scan should not be dropped"
1268        );
1269        let session = result.unwrap();
1270        assert_eq!(session.summary, "Deep user question");
1271        assert_eq!(session.session_id, "sess-deep-user");
1272    }
1273
1274    #[test]
1275    fn test_extract_summary_session_id_only_beyond_head() {
1276        // Regression test: session_id is only present on records beyond the 30-line
1277        // head scan. Pass 3 must extract session_id from those records rather than
1278        // relying solely on the head scan.
1279        let mut f = NamedTempFile::new().unwrap();
1280        // 35 lines of metadata records WITHOUT sessionId
1281        for i in 0..35 {
1282            writeln!(
1283                f,
1284                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1285                i
1286            )
1287            .unwrap();
1288        }
1289        // First user message at line 36 — has sessionId, beyond the head scan
1290        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Late session ID question"}}]}},"sessionId":"sess-late-id","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1291
1292        let result = extract_summary(f.path());
1293        assert!(
1294            result.is_some(),
1295            "Session with session_id only beyond 30-line head scan should not be dropped"
1296        );
1297        let session = result.unwrap();
1298        assert_eq!(session.summary, "Late session ID question");
1299        assert_eq!(session.session_id, "sess-late-id");
1300    }
1301
1302    #[test]
1303    fn test_find_summary_from_tail_exact_line_boundary() {
1304        // Regression test: if the tail-scan offset lands exactly on the first byte
1305        // of a JSONL record (i.e., the preceding byte is '\n'), the function must
1306        // NOT skip that line. Without checking the preceding byte, the code would
1307        // see '{' as the first byte, skip to the next newline, and drop the record.
1308        use std::io::Write;
1309
1310        let mut f = NamedTempFile::new().unwrap();
1311        // Write a "prefix" line that we'll use to calculate exact offset
1312        let prefix_line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"padding"}]},"sessionId":"sess-boundary","timestamp":"2025-06-01T10:00:00Z"}"#;
1313        writeln!(f, "{}", prefix_line).unwrap();
1314        // The summary line starts right after the newline of the prefix line
1315        let summary_line = r#"{"type":"summary","summary":"Boundary summary found","sessionId":"sess-boundary","timestamp":"2025-06-01T11:00:00Z"}"#;
1316        writeln!(f, "{}", summary_line).unwrap();
1317
1318        let file_len = f.as_file().metadata().unwrap().len();
1319        // Set max_bytes so that start = file_len - max_bytes lands exactly on the
1320        // first byte of the summary line (right after the prefix line's newline).
1321        let summary_offset = prefix_line.len() as u64 + 1; // +1 for the newline
1322        let max_bytes = file_len - summary_offset;
1323
1324        let result = find_summary_from_tail(f.path(), max_bytes);
1325        assert!(
1326            result.is_some(),
1327            "Summary at exact tail boundary should not be skipped"
1328        );
1329        let (sid, text, _auto) = result.unwrap();
1330        assert_eq!(text, "Boundary summary found");
1331        assert_eq!(sid, Some("sess-boundary".to_string()));
1332    }
1333
1334    #[test]
1335    fn test_collect_recent_sessions_respects_limit() {
1336        let dir = tempfile::TempDir::new().unwrap();
1337        let proj = dir.path().join("projects").join("-Users-user-proj");
1338        std::fs::create_dir_all(&proj).unwrap();
1339
1340        for i in 0..5 {
1341            write_test_session(
1342                &proj,
1343                &format!("sess{}.jsonl", i),
1344                &format!("sess-{}", i),
1345                &format!("Question {}", i),
1346            );
1347        }
1348
1349        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1350        let result = collect_recent_sessions(&paths, 3);
1351        assert_eq!(result.len(), 3);
1352    }
1353
1354    #[test]
1355    fn test_extract_summary_detects_ralphex_automation() {
1356        let mut f = NamedTempFile::new().unwrap();
1357        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Read the plan file. When done output <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"sess-rx-001","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1358        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Working on it."}}]}},"sessionId":"sess-rx-001","timestamp":"2026-03-28T08:01:00Z"}}"#).unwrap();
1359
1360        let result = extract_summary(f.path()).unwrap();
1361        assert_eq!(result.automation, Some("ralphex".to_string()));
1362    }
1363
1364    #[test]
1365    fn test_extract_summary_manual_session_no_automation() {
1366        let mut f = NamedTempFile::new().unwrap();
1367        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How do I sort a list?"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1368
1369        let result = extract_summary(f.path()).unwrap();
1370        assert_eq!(result.automation, None);
1371    }
1372
1373    #[test]
1374    fn test_extract_summary_ralphex_fixture_file() {
1375        let path = std::path::Path::new("tests/fixtures/ralphex_session.jsonl");
1376        let result = extract_summary(path).unwrap();
1377        assert_eq!(result.session_id, "sess-ralphex-001");
1378        assert_eq!(result.automation, Some("ralphex".to_string()));
1379    }
1380
1381    #[test]
1382    fn test_extract_summary_linear_fixture_no_automation() {
1383        let path = std::path::Path::new("tests/fixtures/linear_session.jsonl");
1384        let result = extract_summary(path).unwrap();
1385        assert_eq!(result.session_id, "sess-linear-001");
1386        assert_eq!(result.automation, None);
1387    }
1388
1389    #[test]
1390    fn test_extract_summary_marker_in_assistant_not_detected() {
1391        let mut f = NamedTempFile::new().unwrap();
1392        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Tell me about ralphex"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1393        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Ralphex uses <<<RALPHEX:ALL_TASKS_DONE>>> signals."}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1394
1395        let result = extract_summary(f.path()).unwrap();
1396        assert_eq!(result.automation, None);
1397    }
1398
1399    #[test]
1400    fn test_extract_summary_tail_summary_uses_head_automation() {
1401        let mut f = NamedTempFile::new().unwrap();
1402        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Bootstrap run. <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1403
1404        let padding_line = format!(
1405            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:01:00Z"}}"#,
1406            "x".repeat(1024)
1407        );
1408        for _ in 0..300 {
1409            writeln!(f, "{}", padding_line).unwrap();
1410        }
1411
1412        writeln!(f, r#"{{"type":"summary","summary":"Tail summary with automation marker only in head","sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:02:00Z"}}"#).unwrap();
1413
1414        let result = extract_summary(f.path()).unwrap();
1415        assert_eq!(
1416            result.summary,
1417            "Tail summary with automation marker only in head"
1418        );
1419        assert_eq!(result.automation, Some("ralphex".to_string()));
1420    }
1421
1422    #[test]
1423    fn test_extract_summary_tail_summary_scans_middle_for_automation() {
1424        let mut f = NamedTempFile::new().unwrap();
1425
1426        for i in 0..35 {
1427            writeln!(
1428                f,
1429                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1430                i
1431            )
1432            .unwrap();
1433        }
1434
1435        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Follow the plan and emit <<<RALPHEX:ALL_TASKS_DONE>>> when complete"}}]}},"sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1436
1437        let padding_line = format!(
1438            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:01:00Z"}}"#,
1439            "x".repeat(1024)
1440        );
1441        for _ in 0..300 {
1442            writeln!(f, "{}", padding_line).unwrap();
1443        }
1444
1445        writeln!(f, r#"{{"type":"summary","summary":"Tail summary with automation marker only in middle","sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:02:00Z"}}"#).unwrap();
1446
1447        let result = extract_summary(f.path()).unwrap();
1448        assert_eq!(
1449            result.summary,
1450            "Tail summary with automation marker only in middle"
1451        );
1452        assert_eq!(result.automation, Some("ralphex".to_string()));
1453    }
1454}