Skip to main content

toolpath_pi/
reader.rs

1//! JSONL parsing + session assembly for Pi session files.
2
3use crate::error::{PiError, Result};
4use crate::paths::PathResolver;
5use crate::types::{AgentMessage, Entry, EntryBase, SessionHeader};
6use std::collections::{HashMap, HashSet};
7use std::fs::File;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10
11/// Default maximum depth when following `parentSession` chains.
12pub const DEFAULT_MAX_PARENT_DEPTH: usize = 16;
13
14/// Lightweight summary of a session on disk.
15#[derive(Debug, Clone)]
16pub struct SessionMeta {
17    pub id: String,
18    pub timestamp: String,
19    pub file_path: PathBuf,
20    pub entry_count: usize,
21    /// First non-empty user-prompt text in the session. Useful as a
22    /// human-readable title for picker UIs (e.g. `path list pi --format tsv`
23    /// piped into fzf). `None` if the session has no parseable user message.
24    pub first_user_message: Option<String>,
25}
26
27/// In-memory representation of a Pi session file (plus optional parent).
28#[derive(Debug, Clone)]
29pub struct PiSession {
30    pub header: SessionHeader,
31    pub entries: Vec<Entry>,
32    pub file_path: PathBuf,
33    pub parent: Option<Box<PiSession>>,
34}
35
36impl Entry {
37    /// Get the entry's id, regardless of variant.
38    pub fn entry_id(&self) -> &str {
39        match self {
40            Entry::Session(h) => &h.id,
41            Entry::Message { base, .. }
42            | Entry::ModelChange { base, .. }
43            | Entry::ThinkingLevelChange { base, .. }
44            | Entry::Compaction { base, .. }
45            | Entry::BranchSummary { base, .. }
46            | Entry::Custom { base, .. }
47            | Entry::CustomMessage { base, .. }
48            | Entry::Label { base, .. } => &base.id,
49        }
50    }
51
52    /// Get the parent entry's id, if any. Session headers have no parent here.
53    pub fn parent_entry_id(&self) -> Option<&str> {
54        match self {
55            Entry::Session(_) => None,
56            Entry::Message { base, .. }
57            | Entry::ModelChange { base, .. }
58            | Entry::ThinkingLevelChange { base, .. }
59            | Entry::Compaction { base, .. }
60            | Entry::BranchSummary { base, .. }
61            | Entry::Custom { base, .. }
62            | Entry::CustomMessage { base, .. }
63            | Entry::Label { base, .. } => base.parent_id.as_deref(),
64        }
65    }
66
67    /// Get the entry's timestamp (ISO-8601 string).
68    pub fn entry_timestamp(&self) -> &str {
69        match self {
70            Entry::Session(h) => &h.timestamp,
71            Entry::Message { base, .. }
72            | Entry::ModelChange { base, .. }
73            | Entry::ThinkingLevelChange { base, .. }
74            | Entry::Compaction { base, .. }
75            | Entry::BranchSummary { base, .. }
76            | Entry::Custom { base, .. }
77            | Entry::CustomMessage { base, .. }
78            | Entry::Label { base, .. } => &base.timestamp,
79        }
80    }
81}
82
83impl PiSession {
84    /// This session's id (from the header).
85    pub fn session_id(&self) -> &str {
86        &self.header.id
87    }
88
89    /// The cwd under which this session was recorded.
90    pub fn cwd(&self) -> &str {
91        &self.header.cwd
92    }
93
94    /// Iterate over `Entry::Message` entries, yielding `(&base, &message)`.
95    pub fn message_entries(&self) -> impl Iterator<Item = (&EntryBase, &AgentMessage)> {
96        self.entries.iter().filter_map(|e| match e {
97            Entry::Message { base, message, .. } => Some((base, message)),
98            _ => None,
99        })
100    }
101
102    /// Collect every `Entry::Message` message in file order.
103    pub fn all_messages(&self) -> Vec<&AgentMessage> {
104        self.entries
105            .iter()
106            .filter_map(|e| match e {
107                Entry::Message { message, .. } => Some(message),
108                _ => None,
109            })
110            .collect()
111    }
112
113    /// Return entries on the "main thread" — the path from root to the
114    /// newest leaf (an entry with no children), walking parent links.
115    ///
116    /// Returns entries in chronological order (root-first). If there are no
117    /// non-session entries, returns an empty vec.
118    pub fn main_thread(&self) -> Vec<&Entry> {
119        // Index by id.
120        let mut by_id: HashMap<&str, &Entry> = HashMap::new();
121        let mut has_child: HashSet<&str> = HashSet::new();
122        for e in &self.entries {
123            by_id.insert(e.entry_id(), e);
124            if let Some(p) = e.parent_entry_id() {
125                has_child.insert(p);
126            }
127        }
128
129        // Consider only non-session entries as candidate leaves.
130        let leaf = self
131            .entries
132            .iter()
133            .filter(|e| !matches!(e, Entry::Session(_)))
134            .filter(|e| !has_child.contains(e.entry_id()))
135            .max_by(|a, b| a.entry_timestamp().cmp(b.entry_timestamp()));
136
137        let Some(leaf) = leaf else {
138            return Vec::new();
139        };
140
141        let mut chain: Vec<&Entry> = Vec::new();
142        let mut cur: Option<&Entry> = Some(leaf);
143        let mut visited: HashSet<&str> = HashSet::new();
144        while let Some(e) = cur {
145            if !visited.insert(e.entry_id()) {
146                break;
147            }
148            chain.push(e);
149            cur = match e.parent_entry_id() {
150                Some(pid) => by_id.get(pid).copied(),
151                None => None,
152            };
153        }
154        chain.reverse();
155        chain
156    }
157}
158
159/// Read and parse a session JSONL file. Does NOT follow `parentSession`.
160pub fn read_session_from_file(path: &Path) -> Result<PiSession> {
161    let file = File::open(path)
162        .map_err(|e| PiError::invalid_session_file(path.to_path_buf(), format!("open: {e}")))?;
163    let reader = BufReader::new(file);
164
165    let mut header: Option<SessionHeader> = None;
166    let mut entries: Vec<Entry> = Vec::new();
167    let mut line_no = 0usize;
168
169    for line in reader.lines() {
170        line_no += 1;
171        let line = line.map_err(|e| {
172            PiError::invalid_session_file(path.to_path_buf(), format!("read line {line_no}: {e}"))
173        })?;
174        let trimmed = line.trim();
175        if trimmed.is_empty() {
176            continue;
177        }
178
179        if header.is_none() {
180            // First non-empty line must be a session header.
181            let entry: Entry = serde_json::from_str(trimmed).map_err(|e| {
182                PiError::invalid_session_file(
183                    path.to_path_buf(),
184                    format!("line {line_no}: malformed header json: {e}"),
185                )
186            })?;
187            match entry {
188                Entry::Session(h) => {
189                    header = Some(h.clone());
190                    entries.push(Entry::Session(h));
191                }
192                _ => {
193                    return Err(PiError::malformed_header(format!(
194                        "{}: expected session header on first non-empty line (line {}), found different entry type",
195                        path.display(),
196                        line_no
197                    )));
198                }
199            }
200            continue;
201        }
202
203        // Try to parse as an Entry.
204        match serde_json::from_str::<Entry>(trimmed) {
205            Ok(entry) => entries.push(entry),
206            Err(parse_err) => {
207                // Is the line valid JSON at all? If so, it may be an unknown type — skip with warning.
208                match serde_json::from_str::<serde_json::Value>(trimmed) {
209                    Ok(val) => {
210                        let type_str = val
211                            .get("type")
212                            .and_then(|t| t.as_str())
213                            .unwrap_or("<unknown>");
214                        eprintln!(
215                            "warning: unknown Pi entry type '{}' at {}:{}",
216                            type_str,
217                            path.display(),
218                            line_no
219                        );
220                        continue;
221                    }
222                    Err(_) => {
223                        return Err(PiError::invalid_session_file(
224                            path.to_path_buf(),
225                            format!("line {line_no}: {parse_err}"),
226                        ));
227                    }
228                }
229            }
230        }
231    }
232
233    let header = header.ok_or_else(|| {
234        PiError::invalid_session_file(path.to_path_buf(), "empty session file".to_string())
235    })?;
236
237    Ok(PiSession {
238        header,
239        entries,
240        file_path: path.to_path_buf(),
241        parent: None,
242    })
243}
244
245/// Read a session and, if it has a `parentSession`, recursively attach the parent.
246///
247/// Missing parent files are logged to stderr but do not fail the read.
248/// When `max_depth` reaches 0, the walk stops.
249pub fn read_session_with_parent(path: &Path, max_depth: usize) -> Result<PiSession> {
250    let mut session = read_session_from_file(path)?;
251
252    if max_depth == 0 {
253        return Ok(session);
254    }
255
256    if let Some(parent_path_str) = session.header.parent_session.clone() {
257        let parent_path = PathBuf::from(&parent_path_str);
258        if !parent_path.exists() {
259            eprintln!(
260                "warning: parent session not found: {}",
261                parent_path.display()
262            );
263        } else {
264            match read_session_with_parent(&parent_path, max_depth - 1) {
265                Ok(parent) => session.parent = Some(Box::new(parent)),
266                Err(e) => {
267                    eprintln!(
268                        "warning: failed to read parent session {}: {}",
269                        parent_path.display(),
270                        e
271                    );
272                }
273            }
274        }
275    }
276
277    Ok(session)
278}
279
280/// Read only the header line of a session file.
281fn read_header_only(path: &Path) -> Result<SessionHeader> {
282    let file = File::open(path)
283        .map_err(|e| PiError::invalid_session_file(path.to_path_buf(), format!("open: {e}")))?;
284    let reader = BufReader::new(file);
285
286    for line in reader.lines() {
287        let line = line
288            .map_err(|e| PiError::invalid_session_file(path.to_path_buf(), format!("read: {e}")))?;
289        let trimmed = line.trim();
290        if trimmed.is_empty() {
291            continue;
292        }
293        let entry: Entry = serde_json::from_str(trimmed).map_err(|e| {
294            PiError::invalid_session_file(path.to_path_buf(), format!("malformed header json: {e}"))
295        })?;
296        return match entry {
297            Entry::Session(h) => Ok(h),
298            _ => Err(PiError::malformed_header(format!(
299                "{}: expected session header on first non-empty line",
300                path.display()
301            ))),
302        };
303    }
304
305    Err(PiError::invalid_session_file(
306        path.to_path_buf(),
307        "empty session file".to_string(),
308    ))
309}
310
311/// Public: read only the header of a session file. Used by `io.rs` to build
312/// [`SessionMeta`] without parsing every entry.
313pub fn peek_header(path: &Path) -> Result<SessionHeader> {
314    read_header_only(path)
315}
316
317/// Count the number of non-header entries in a session file (by counting
318/// non-empty lines and subtracting 1 for the header).
319///
320/// Returns 0 if the file is empty.
321pub fn count_entries(path: &Path) -> Result<usize> {
322    let file = File::open(path)
323        .map_err(|e| PiError::invalid_session_file(path.to_path_buf(), format!("open: {e}")))?;
324    let reader = BufReader::new(file);
325    let mut total = 0usize;
326    for line in reader.lines() {
327        let line = line
328            .map_err(|e| PiError::invalid_session_file(path.to_path_buf(), format!("read: {e}")))?;
329        if !line.trim().is_empty() {
330            total += 1;
331        }
332    }
333    Ok(total.saturating_sub(1))
334}
335
336/// List all `*.jsonl` session files in a project's directory, sorted by
337/// modification time descending (newest first).
338///
339/// Returns an empty vec if the project directory does not exist.
340pub fn list_session_files(resolver: &PathResolver, project: &str) -> Result<Vec<PathBuf>> {
341    let dir = resolver.project_dir(project);
342    if !dir.exists() {
343        return Ok(Vec::new());
344    }
345
346    let mut entries: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
347    for entry in std::fs::read_dir(&dir)? {
348        let entry = entry?;
349        let path = entry.path();
350        if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
351            continue;
352        }
353        let mtime = entry
354            .metadata()
355            .and_then(|m| m.modified())
356            .unwrap_or(std::time::UNIX_EPOCH);
357        entries.push((path, mtime));
358    }
359    entries.sort_by(|a, b| b.1.cmp(&a.1));
360    Ok(entries.into_iter().map(|(p, _)| p).collect())
361}
362
363/// Read a session by ID from within a project.
364///
365/// Matches by header `id` or by filename stem (`<date>_<uuid>.jsonl` where
366/// `<uuid>` equals `session_id`).
367pub fn read_session(resolver: &PathResolver, project: &str, session_id: &str) -> Result<PiSession> {
368    let project_dir = resolver.project_dir(project);
369    if !project_dir.exists() {
370        return Err(PiError::project_not_found(project));
371    }
372
373    let mut found: Option<PathBuf> = None;
374    for entry in std::fs::read_dir(&project_dir)? {
375        let entry = entry?;
376        let path = entry.path();
377        if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
378            continue;
379        }
380
381        // Filename-stem match: "<date>_<uuid>.jsonl" or "<uuid>.jsonl".
382        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
383            let uuid_part = stem.rsplit_once('_').map(|(_, u)| u).unwrap_or(stem);
384            if uuid_part == session_id {
385                found = Some(path.clone());
386                break;
387            }
388        }
389
390        // Header id match.
391        match read_header_only(&path) {
392            Ok(h) if h.id == session_id => {
393                found = Some(path.clone());
394                break;
395            }
396            _ => continue,
397        }
398    }
399
400    let Some(path) = found else {
401        return Err(PiError::session_not_found(session_id));
402    };
403
404    read_session_with_parent(&path, DEFAULT_MAX_PARENT_DEPTH)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use std::fs;
411    use std::io::Write as _;
412    use tempfile::TempDir;
413
414    fn write_jsonl(path: &Path, lines: &[&str]) {
415        let mut f = fs::File::create(path).unwrap();
416        for (i, l) in lines.iter().enumerate() {
417            if i > 0 {
418                f.write_all(b"\n").unwrap();
419            }
420            f.write_all(l.as_bytes()).unwrap();
421        }
422    }
423
424    fn header_line(id: &str) -> String {
425        format!(
426            r#"{{"type":"session","version":3,"id":"{id}","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp/proj"}}"#
427        )
428    }
429
430    fn header_with_parent(id: &str, parent_path: &str) -> String {
431        format!(
432            r#"{{"type":"session","version":3,"id":"{id}","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp/proj","parentSession":"{parent_path}"}}"#
433        )
434    }
435
436    fn msg_line(id: &str, parent: Option<&str>, ts: &str, text: &str) -> String {
437        let parent_s = match parent {
438            Some(p) => format!("\"{p}\""),
439            None => "null".to_string(),
440        };
441        format!(
442            r#"{{"type":"message","id":"{id}","parentId":{parent_s},"timestamp":"{ts}","message":{{"role":"user","content":"{text}","timestamp":1700000000000}}}}"#
443        )
444    }
445
446    #[test]
447    fn test_read_session_from_file_linear() {
448        let tmp = TempDir::new().unwrap();
449        let path = tmp.path().join("s.jsonl");
450        write_jsonl(
451            &path,
452            &[
453                &header_line("sess-1"),
454                &msg_line("a", None, "2026-04-16T00:00:01Z", "hi"),
455                &msg_line("b", Some("a"), "2026-04-16T00:00:02Z", "hey"),
456                &msg_line("c", Some("b"), "2026-04-16T00:00:03Z", "yo"),
457            ],
458        );
459        let s = read_session_from_file(&path).unwrap();
460        assert_eq!(s.header.id, "sess-1");
461        assert_eq!(s.entries.len(), 4);
462        assert!(s.parent.is_none());
463        assert_eq!(s.file_path, path);
464    }
465
466    #[test]
467    fn test_read_session_from_file_empty_file() {
468        let tmp = TempDir::new().unwrap();
469        let path = tmp.path().join("empty.jsonl");
470        fs::write(&path, "").unwrap();
471        let err = read_session_from_file(&path).unwrap_err();
472        assert!(matches!(err, PiError::InvalidSessionFile { .. }));
473    }
474
475    #[test]
476    fn test_read_session_from_file_missing_header() {
477        let tmp = TempDir::new().unwrap();
478        let path = tmp.path().join("bad.jsonl");
479        write_jsonl(&path, &[&msg_line("a", None, "t", "hi")]);
480        let err = read_session_from_file(&path).unwrap_err();
481        assert!(matches!(err, PiError::MalformedHeader(_)));
482    }
483
484    #[test]
485    fn test_read_session_from_file_malformed_json() {
486        let tmp = TempDir::new().unwrap();
487        let path = tmp.path().join("bad.jsonl");
488        write_jsonl(&path, &["not json"]);
489        let err = read_session_from_file(&path).unwrap_err();
490        match err {
491            PiError::InvalidSessionFile { reason, .. } => {
492                assert!(reason.to_lowercase().contains("malformed") || reason.contains("json"));
493            }
494            _ => panic!("expected InvalidSessionFile"),
495        }
496    }
497
498    #[test]
499    fn test_read_session_from_file_branched() {
500        let tmp = TempDir::new().unwrap();
501        let path = tmp.path().join("b.jsonl");
502        write_jsonl(
503            &path,
504            &[
505                &header_line("sess-b"),
506                &msg_line("root", None, "2026-04-16T00:00:01Z", "r"),
507                &msg_line("c1", Some("root"), "2026-04-16T00:00:02Z", "c1"),
508                &msg_line("c2", Some("root"), "2026-04-16T00:00:03Z", "c2"),
509            ],
510        );
511        let s = read_session_from_file(&path).unwrap();
512        assert_eq!(s.entries.len(), 4);
513        let ids: Vec<&str> = s.entries.iter().map(|e| e.entry_id()).collect();
514        assert!(ids.contains(&"c1"));
515        assert!(ids.contains(&"c2"));
516    }
517
518    #[test]
519    fn test_read_session_from_file_ignores_blank_lines() {
520        let tmp = TempDir::new().unwrap();
521        let path = tmp.path().join("blank.jsonl");
522        let content = format!(
523            "\n\n{}\n\n{}\n\n",
524            header_line("sess-1"),
525            msg_line("a", None, "t", "hi")
526        );
527        fs::write(&path, content).unwrap();
528        let s = read_session_from_file(&path).unwrap();
529        assert_eq!(s.entries.len(), 2);
530    }
531
532    #[test]
533    fn test_read_session_from_file_skips_unknown_entry_type() {
534        let tmp = TempDir::new().unwrap();
535        let path = tmp.path().join("u.jsonl");
536        write_jsonl(
537            &path,
538            &[
539                &header_line("sess-1"),
540                r#"{"type":"future_kind","id":"x","timestamp":"t"}"#,
541                &msg_line("a", None, "t", "hi"),
542            ],
543        );
544        let s = read_session_from_file(&path).unwrap();
545        // Header + one message; unknown entry skipped.
546        assert_eq!(s.entries.len(), 2);
547        let has_message = s.entries.iter().any(|e| matches!(e, Entry::Message { .. }));
548        assert!(has_message);
549    }
550
551    #[test]
552    fn test_read_session_with_parent_chains() {
553        let tmp = TempDir::new().unwrap();
554        let parent_path = tmp.path().join("parent.jsonl");
555        write_jsonl(
556            &parent_path,
557            &[&header_line("parent-sess"), &msg_line("p1", None, "t", "p")],
558        );
559        let child_path = tmp.path().join("child.jsonl");
560        write_jsonl(
561            &child_path,
562            &[
563                &header_with_parent("child-sess", parent_path.to_str().unwrap()),
564                &msg_line("c1", None, "t", "c"),
565            ],
566        );
567
568        let s = read_session_with_parent(&child_path, 16).unwrap();
569        assert_eq!(s.header.id, "child-sess");
570        let parent = s.parent.expect("parent attached");
571        assert_eq!(parent.header.id, "parent-sess");
572        assert_eq!(parent.file_path, parent_path);
573    }
574
575    #[test]
576    fn test_read_session_with_parent_missing_file() {
577        let tmp = TempDir::new().unwrap();
578        let child_path = tmp.path().join("child.jsonl");
579        write_jsonl(
580            &child_path,
581            &[
582                &header_with_parent("c", "/nonexistent/nope.jsonl"),
583                &msg_line("c1", None, "t", "x"),
584            ],
585        );
586        let s = read_session_with_parent(&child_path, 16).unwrap();
587        assert!(s.parent.is_none());
588    }
589
590    #[test]
591    fn test_read_session_with_parent_max_depth_zero() {
592        let tmp = TempDir::new().unwrap();
593        let parent_path = tmp.path().join("parent.jsonl");
594        write_jsonl(&parent_path, &[&header_line("p")]);
595        let child_path = tmp.path().join("child.jsonl");
596        write_jsonl(
597            &child_path,
598            &[&header_with_parent("c", parent_path.to_str().unwrap())],
599        );
600        let s = read_session_with_parent(&child_path, 0).unwrap();
601        assert!(s.parent.is_none());
602    }
603
604    fn resolver_with_project(tmp: &TempDir, cwd: &str) -> (PathResolver, PathBuf) {
605        let sessions = tmp.path().join("sessions");
606        fs::create_dir_all(&sessions).unwrap();
607        let resolver = PathResolver::new().with_sessions_dir(&sessions);
608        let proj_dir = resolver.project_dir(cwd);
609        fs::create_dir_all(&proj_dir).unwrap();
610        (resolver, proj_dir)
611    }
612
613    #[test]
614    fn test_read_session_by_id_found_via_header() {
615        let tmp = TempDir::new().unwrap();
616        let (resolver, proj_dir) = resolver_with_project(&tmp, "/p");
617        let path = proj_dir.join("anything.jsonl");
618        write_jsonl(
619            &path,
620            &[&header_line("sess-1"), &msg_line("a", None, "t", "hi")],
621        );
622        let s = read_session(&resolver, "/p", "sess-1").unwrap();
623        assert_eq!(s.header.id, "sess-1");
624    }
625
626    #[test]
627    fn test_read_session_by_id_found_via_filename() {
628        let tmp = TempDir::new().unwrap();
629        let (resolver, proj_dir) = resolver_with_project(&tmp, "/p");
630        let path = proj_dir.join("2026-04-16_sess-2.jsonl");
631        write_jsonl(&path, &[&header_line("sess-2")]);
632        let s = read_session(&resolver, "/p", "sess-2").unwrap();
633        assert_eq!(s.header.id, "sess-2");
634    }
635
636    #[test]
637    fn test_read_session_by_id_not_found() {
638        let tmp = TempDir::new().unwrap();
639        let (resolver, proj_dir) = resolver_with_project(&tmp, "/p");
640        let path = proj_dir.join("x.jsonl");
641        write_jsonl(&path, &[&header_line("other")]);
642        let err = read_session(&resolver, "/p", "missing").unwrap_err();
643        assert!(matches!(err, PiError::SessionNotFound(_)));
644    }
645
646    #[test]
647    fn test_read_session_project_not_found() {
648        let tmp = TempDir::new().unwrap();
649        let sessions = tmp.path().join("sessions");
650        fs::create_dir_all(&sessions).unwrap();
651        let resolver = PathResolver::new().with_sessions_dir(&sessions);
652        let err = read_session(&resolver, "/nonexistent-proj", "x").unwrap_err();
653        assert!(matches!(err, PiError::ProjectNotFound(_)));
654    }
655
656    #[test]
657    fn test_peek_header_minimal() {
658        let tmp = TempDir::new().unwrap();
659        let path = tmp.path().join("p.jsonl");
660        write_jsonl(
661            &path,
662            &[
663                &header_line("peek-me"),
664                &msg_line("a", None, "t", "hi"),
665                &msg_line("b", Some("a"), "t", "hey"),
666            ],
667        );
668        let h = peek_header(&path).unwrap();
669        assert_eq!(h.id, "peek-me");
670    }
671
672    #[test]
673    fn test_count_entries() {
674        let tmp = TempDir::new().unwrap();
675        let path = tmp.path().join("c.jsonl");
676        write_jsonl(
677            &path,
678            &[
679                &header_line("c"),
680                &msg_line("a", None, "t", "1"),
681                &msg_line("b", Some("a"), "t", "2"),
682                &msg_line("c", Some("b"), "t", "3"),
683            ],
684        );
685        assert_eq!(count_entries(&path).unwrap(), 3);
686    }
687
688    #[test]
689    fn test_list_session_files_sorted_newest_first() {
690        let tmp = TempDir::new().unwrap();
691        let (resolver, proj_dir) = resolver_with_project(&tmp, "/p");
692
693        let older = proj_dir.join("older.jsonl");
694        let newer = proj_dir.join("newer.jsonl");
695        write_jsonl(&older, &[&header_line("o")]);
696        // Ensure distinct mtime
697        std::thread::sleep(std::time::Duration::from_millis(20));
698        write_jsonl(&newer, &[&header_line("n")]);
699
700        // Explicitly bump newer's mtime so the ordering is deterministic.
701        let newer_time = std::time::SystemTime::now();
702        filetime::set_file_mtime_fallback(&newer, newer_time);
703
704        let files = list_session_files(&resolver, "/p").unwrap();
705        assert_eq!(files.len(), 2);
706        assert_eq!(files[0], newer);
707        assert_eq!(files[1], older);
708    }
709
710    #[test]
711    fn test_list_session_files_nonexistent_project() {
712        let tmp = TempDir::new().unwrap();
713        let resolver = PathResolver::new().with_sessions_dir(tmp.path());
714        let files = list_session_files(&resolver, "/missing").unwrap();
715        assert!(files.is_empty());
716    }
717
718    #[test]
719    fn test_entry_id_across_variants() {
720        let samples = [
721            (
722                r#"{"type":"session","version":3,"id":"s1","timestamp":"t","cwd":"/"}"#,
723                "s1",
724            ),
725            (
726                r#"{"type":"model_change","id":"m1","parentId":null,"timestamp":"t","provider":"a","modelId":"x"}"#,
727                "m1",
728            ),
729            (
730                r#"{"type":"thinking_level_change","id":"tl1","parentId":null,"timestamp":"t","thinkingLevel":"high"}"#,
731                "tl1",
732            ),
733            (
734                r#"{"type":"compaction","id":"cp1","parentId":null,"timestamp":"t","summary":"s","firstKeptEntryId":"x","tokensBefore":0}"#,
735                "cp1",
736            ),
737            (
738                r#"{"type":"branch_summary","id":"bs1","parentId":null,"timestamp":"t","fromId":"x","summary":"s"}"#,
739                "bs1",
740            ),
741            (
742                r#"{"type":"custom","id":"cu1","parentId":null,"timestamp":"t","customType":"t","data":{}}"#,
743                "cu1",
744            ),
745            (
746                r#"{"type":"custom_message","id":"cm1","parentId":null,"timestamp":"t","customType":"h","content":"x","display":true}"#,
747                "cm1",
748            ),
749            (
750                r#"{"type":"label","id":"lb1","parentId":null,"timestamp":"t"}"#,
751                "lb1",
752            ),
753        ];
754        for (raw, expected) in samples {
755            let e: Entry = serde_json::from_str(raw).unwrap();
756            assert_eq!(e.entry_id(), expected, "raw={raw}");
757        }
758    }
759
760    #[test]
761    fn test_session_main_thread_linear() {
762        let tmp = TempDir::new().unwrap();
763        let path = tmp.path().join("s.jsonl");
764        write_jsonl(
765            &path,
766            &[
767                &header_line("s1"),
768                &msg_line("a", None, "2026-04-16T00:00:01Z", "1"),
769                &msg_line("b", Some("a"), "2026-04-16T00:00:02Z", "2"),
770                &msg_line("c", Some("b"), "2026-04-16T00:00:03Z", "3"),
771            ],
772        );
773        let s = read_session_from_file(&path).unwrap();
774        let mt = s.main_thread();
775        let ids: Vec<&str> = mt.iter().map(|e| e.entry_id()).collect();
776        assert_eq!(ids, vec!["a", "b", "c"]);
777    }
778
779    #[test]
780    fn test_session_main_thread_with_branch() {
781        let tmp = TempDir::new().unwrap();
782        let path = tmp.path().join("s.jsonl");
783        write_jsonl(
784            &path,
785            &[
786                &header_line("s1"),
787                &msg_line("a", None, "2026-04-16T00:00:01Z", "1"),
788                &msg_line("b", Some("a"), "2026-04-16T00:00:02Z", "2"),
789                // c1 is an old dead-end
790                &msg_line("c1", Some("b"), "2026-04-16T00:00:03Z", "3a"),
791                // c2 is the newer leaf — main thread winner
792                &msg_line("c2", Some("b"), "2026-04-16T00:00:09Z", "3b"),
793            ],
794        );
795        let s = read_session_from_file(&path).unwrap();
796        let mt = s.main_thread();
797        let ids: Vec<&str> = mt.iter().map(|e| e.entry_id()).collect();
798        assert_eq!(ids, vec!["a", "b", "c2"]);
799    }
800
801    #[test]
802    fn test_session_all_messages_flattens_tree() {
803        let tmp = TempDir::new().unwrap();
804        let path = tmp.path().join("s.jsonl");
805        write_jsonl(
806            &path,
807            &[
808                &header_line("s1"),
809                &msg_line("a", None, "t", "1"),
810                &msg_line("b", Some("a"), "t", "2"),
811                &msg_line("c1", Some("b"), "t", "3"),
812                &msg_line("c2", Some("b"), "t", "4"),
813            ],
814        );
815        let s = read_session_from_file(&path).unwrap();
816        let msgs = s.all_messages();
817        assert_eq!(msgs.len(), 4);
818    }
819
820    #[test]
821    fn test_session_message_entries_iterator_yields_only_messages() {
822        let tmp = TempDir::new().unwrap();
823        let path = tmp.path().join("s.jsonl");
824        write_jsonl(
825            &path,
826            &[
827                &header_line("s1"),
828                r#"{"type":"model_change","id":"m1","parentId":null,"timestamp":"t","provider":"a","modelId":"x"}"#,
829                &msg_line("a", None, "t", "1"),
830                r#"{"type":"label","id":"lb1","parentId":null,"timestamp":"t"}"#,
831                &msg_line("b", Some("a"), "t", "2"),
832            ],
833        );
834        let s = read_session_from_file(&path).unwrap();
835        let count = s.message_entries().count();
836        assert_eq!(count, 2);
837        let ids: Vec<&str> = s.message_entries().map(|(b, _)| b.id.as_str()).collect();
838        assert_eq!(ids, vec!["a", "b"]);
839    }
840}
841
842// Small fallback helper for tests to set mtimes without adding a new dep.
843// We avoid adding the `filetime` crate; tests that rely on ordering use
844// `sleep` between writes to produce distinct mtimes. The `filetime` shim
845// below is a no-op used only by `test_list_session_files_sorted_newest_first`.
846#[cfg(test)]
847mod filetime {
848    use std::path::Path;
849    use std::time::SystemTime;
850    pub fn set_file_mtime_fallback(_path: &Path, _mtime: SystemTime) {
851        // no-op; we rely on the sleep between writes above.
852    }
853}