1use 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
11pub const DEFAULT_MAX_PARENT_DEPTH: usize = 16;
13
14#[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 pub first_user_message: Option<String>,
25}
26
27#[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 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 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 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 pub fn session_id(&self) -> &str {
86 &self.header.id
87 }
88
89 pub fn cwd(&self) -> &str {
91 &self.header.cwd
92 }
93
94 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 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 pub fn main_thread(&self) -> Vec<&Entry> {
119 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 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
159pub 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 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 match serde_json::from_str::<Entry>(trimmed) {
205 Ok(entry) => entries.push(entry),
206 Err(parse_err) => {
207 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
245pub 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
280fn 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
311pub fn peek_header(path: &Path) -> Result<SessionHeader> {
314 read_header_only(path)
315}
316
317pub 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
336pub 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
363pub 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(20));
698 write_jsonl(&newer, &[&header_line("n")]);
699
700 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 &msg_line("c1", Some("b"), "2026-04-16T00:00:03Z", "3a"),
791 &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#[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 }
853}