1use std::fs;
58use std::io::{BufRead, BufReader};
59use std::path::{Path, PathBuf};
60use std::time::SystemTime;
61
62use serde::Serialize;
63use serde_json::Value;
64
65use crate::error::{Error, Result};
66
67#[derive(Debug, Clone)]
71pub struct HistoryRoot {
72 path: PathBuf,
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub enum ListSort {
79 #[default]
85 NameAsc,
86 RecencyDesc,
93}
94
95#[derive(Debug, Clone)]
103pub struct ListOptions {
104 pub limit: Option<usize>,
106 pub offset: usize,
109 pub include_empty: bool,
119 pub sort: ListSort,
121}
122
123impl Default for ListOptions {
124 fn default() -> Self {
125 Self {
126 limit: None,
127 offset: 0,
128 include_empty: true,
129 sort: ListSort::default(),
130 }
131 }
132}
133
134impl HistoryRoot {
135 pub fn home() -> Result<Self> {
138 let home = home_dir().ok_or_else(|| Error::History {
139 message: "could not determine user home directory".to_string(),
140 })?;
141 Ok(Self {
142 path: home.join(".claude").join("projects"),
143 })
144 }
145
146 pub fn at(path: impl Into<PathBuf>) -> Self {
149 Self { path: path.into() }
150 }
151
152 pub fn path(&self) -> &Path {
154 &self.path
155 }
156
157 pub fn list_projects(&self) -> Result<Vec<ProjectSummary>> {
167 self.list_projects_with(&ListOptions::default())
168 }
169
170 pub fn list_projects_with(&self, opts: &ListOptions) -> Result<Vec<ProjectSummary>> {
184 let entries = match fs::read_dir(&self.path) {
185 Ok(it) => it,
186 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
187 Err(e) => return Err(e.into()),
188 };
189
190 let mut out = Vec::new();
191 for entry in entries.flatten() {
192 let ft = match entry.file_type() {
193 Ok(ft) => ft,
194 Err(_) => continue,
195 };
196 if !ft.is_dir() {
197 continue;
198 }
199 let slug = entry.file_name().to_string_lossy().into_owned();
200 let summary = summarize_project(&entry.path(), slug);
201 if !opts.include_empty && summary.session_count == 0 {
202 continue;
203 }
204 out.push(summary);
205 }
206 match opts.sort {
207 ListSort::NameAsc => out.sort_by(|a, b| a.slug.cmp(&b.slug)),
208 ListSort::RecencyDesc => out.sort_by(|a, b| {
209 match (a.last_modified, b.last_modified) {
211 (Some(am), Some(bm)) => bm.cmp(&am),
212 (Some(_), None) => std::cmp::Ordering::Less,
213 (None, Some(_)) => std::cmp::Ordering::Greater,
214 (None, None) => a.slug.cmp(&b.slug),
215 }
216 }),
217 }
218 apply_offset_limit(&mut out, opts);
219 Ok(out)
220 }
221
222 pub fn list_sessions(&self, slug: Option<&str>) -> Result<Vec<SessionSummary>> {
228 self.list_sessions_with(slug, &ListOptions::default())
229 }
230
231 pub fn list_sessions_with(
239 &self,
240 slug: Option<&str>,
241 opts: &ListOptions,
242 ) -> Result<Vec<SessionSummary>> {
243 let enumerate_opts = ListOptions {
246 include_empty: true,
247 ..ListOptions::default()
248 };
249 let project_dirs = match slug {
250 Some(s) => vec![self.path.join(s)],
251 None => self
252 .list_projects_with(&enumerate_opts)?
253 .into_iter()
254 .map(|p| self.path.join(&p.slug))
255 .collect(),
256 };
257
258 let mut out = Vec::new();
259 for dir in project_dirs {
260 let project_slug = dir
261 .file_name()
262 .map(|n| n.to_string_lossy().into_owned())
263 .unwrap_or_default();
264 let entries = match fs::read_dir(&dir) {
265 Ok(it) => it,
266 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
267 Err(e) => return Err(e.into()),
268 };
269 for entry in entries.flatten() {
270 let path = entry.path();
271 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
272 continue;
273 }
274 let Some(session_id) = path
275 .file_stem()
276 .and_then(|s| s.to_str())
277 .map(str::to_string)
278 else {
279 continue;
280 };
281 if let Some(summary) = summarize_session(&path, session_id, project_slug.clone()) {
282 if !opts.include_empty && summary.message_count == 0 {
283 continue;
284 }
285 out.push(summary);
286 }
287 }
288 }
289 match opts.sort {
290 ListSort::NameAsc => out.sort_by(|a, b| a.session_id.cmp(&b.session_id)),
291 ListSort::RecencyDesc => out.sort_by(|a, b| {
292 match (a.last_timestamp.as_deref(), b.last_timestamp.as_deref()) {
295 (Some(at), Some(bt)) => bt.cmp(at),
296 (Some(_), None) => std::cmp::Ordering::Less,
297 (None, Some(_)) => std::cmp::Ordering::Greater,
298 (None, None) => a.session_id.cmp(&b.session_id),
299 }
300 }),
301 }
302 apply_offset_limit(&mut out, opts);
303 Ok(out)
304 }
305
306 pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
312 let (path, project_slug) =
313 self.find_session(session_id)?
314 .ok_or_else(|| Error::History {
315 message: format!(
316 "no session with id `{session_id}` under {}",
317 self.path.display()
318 ),
319 })?;
320 parse_session(&path, session_id.to_string(), project_slug)
321 }
322
323 pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
329 for project in self.list_projects()? {
330 let candidate = self
331 .path
332 .join(&project.slug)
333 .join(format!("{session_id}.jsonl"));
334 if candidate.is_file() {
335 return Ok(Some((candidate, project.slug)));
336 }
337 }
338 Ok(None)
339 }
340}
341
342#[derive(Debug, Clone, Serialize)]
344pub struct ProjectSummary {
345 pub slug: String,
347 pub decoded_path: PathBuf,
350 pub is_decode_verified: bool,
366 pub session_count: usize,
368 pub last_modified: Option<SystemTime>,
371}
372
373#[derive(Debug, Clone, Serialize)]
375pub struct SessionSummary {
376 pub session_id: String,
378 pub project_slug: String,
380 pub message_count: usize,
383 pub first_timestamp: Option<String>,
386 pub last_timestamp: Option<String>,
388 pub title: Option<String>,
391 pub first_user_preview: Option<String>,
397 pub total_cost_usd: Option<f64>,
402 pub total_tokens: Option<u64>,
406 pub size_bytes: u64,
408}
409
410#[derive(Debug, Clone, Serialize)]
412pub struct SessionLog {
413 pub session_id: String,
414 pub project_slug: String,
415 pub entries: Vec<HistoryEntry>,
416}
417
418#[derive(Debug, Clone, Serialize)]
425#[serde(tag = "kind", rename_all = "snake_case")]
426pub enum HistoryEntry {
427 User {
428 uuid: Option<String>,
429 timestamp: Option<String>,
430 cwd: Option<String>,
431 git_branch: Option<String>,
432 message: Value,
433 #[serde(flatten)]
434 rest: serde_json::Map<String, Value>,
435 },
436 Assistant {
437 uuid: Option<String>,
438 timestamp: Option<String>,
439 message: Value,
440 #[serde(flatten)]
441 rest: serde_json::Map<String, Value>,
442 },
443 Other {
444 type_tag: String,
446 raw: Value,
448 },
449}
450
451fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
456 if opts.offset >= items.len() {
457 items.clear();
458 return;
459 }
460 if opts.offset > 0 {
461 items.drain(..opts.offset);
462 }
463 if let Some(lim) = opts.limit
464 && items.len() > lim
465 {
466 items.truncate(lim);
467 }
468}
469
470fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
471 let mut session_count = 0usize;
472 let mut last_modified: Option<SystemTime> = None;
473 if let Ok(entries) = fs::read_dir(dir) {
474 for entry in entries.flatten() {
475 let path = entry.path();
476 if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
477 session_count += 1;
478 if let Ok(meta) = entry.metadata()
479 && let Ok(mtime) = meta.modified()
480 {
481 last_modified = Some(match last_modified {
482 Some(prev) if prev > mtime => prev,
483 _ => mtime,
484 });
485 }
486 }
487 }
488 }
489 let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
490 ProjectSummary {
491 decoded_path,
492 is_decode_verified,
493 slug,
494 session_count,
495 last_modified,
496 }
497}
498
499fn summarize_session(
500 path: &Path,
501 session_id: String,
502 project_slug: String,
503) -> Option<SessionSummary> {
504 let meta = fs::metadata(path).ok()?;
505 let size_bytes = meta.len();
506
507 let file = fs::File::open(path).ok()?;
508 let reader = BufReader::new(file);
509
510 let mut message_count = 0usize;
511 let mut first_timestamp = None;
512 let mut last_timestamp = None;
513 let mut title = None;
514 let mut first_user_preview: Option<String> = None;
515 let mut total_cost_usd: Option<f64> = None;
516 let mut total_tokens: Option<u64> = None;
517
518 for line in reader.lines().map_while(std::io::Result::ok) {
519 let trimmed = line.trim();
520 if trimmed.is_empty() {
521 continue;
522 }
523 let v: Value = match serde_json::from_str(trimmed) {
524 Ok(v) => v,
525 Err(_) => continue,
526 };
527 let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
528 match ty {
529 "user" => {
530 message_count += 1;
531 if first_user_preview.is_none()
532 && let Some(p) = extract_user_text_preview(&v, 160)
533 {
534 first_user_preview = Some(p);
535 }
536 }
537 "assistant" => {
538 message_count += 1;
539 if let Some(c) = v
540 .get("message")
541 .and_then(|m| m.get("usage"))
542 .and_then(|u| u.get("total_cost_usd"))
543 .and_then(Value::as_f64)
544 {
545 *total_cost_usd.get_or_insert(0.0) += c;
546 }
547 if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
548 let mut t = 0u64;
550 for k in [
551 "input_tokens",
552 "output_tokens",
553 "cache_creation_input_tokens",
554 "cache_read_input_tokens",
555 ] {
556 if let Some(n) = usage.get(k).and_then(Value::as_u64) {
557 t += n;
558 }
559 }
560 if t > 0 {
561 *total_tokens.get_or_insert(0) += t;
562 }
563 }
564 }
565 "ai-title" => {
566 let candidate = v
570 .get("aiTitle")
571 .and_then(Value::as_str)
572 .or_else(|| v.get("title").and_then(Value::as_str));
573 if let Some(t) = candidate
574 && !t.is_empty()
575 {
576 title = Some(t.to_string());
577 }
578 }
579 _ => {}
580 }
581 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
582 if first_timestamp.is_none() {
583 first_timestamp = Some(ts.to_string());
584 }
585 last_timestamp = Some(ts.to_string());
586 }
587 }
588
589 Some(SessionSummary {
590 session_id,
591 project_slug,
592 message_count,
593 first_timestamp,
594 last_timestamp,
595 title,
596 first_user_preview,
597 total_cost_usd,
598 total_tokens,
599 size_bytes,
600 })
601}
602
603fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
609 let content = entry.get("message")?.get("content")?;
610 let raw = if let Some(s) = content.as_str() {
611 s.to_string()
612 } else if let Some(arr) = content.as_array() {
613 let mut buf = String::new();
614 for block in arr {
615 let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
616 if ty == "text"
617 && let Some(t) = block.get("text").and_then(Value::as_str)
618 {
619 if !buf.is_empty() {
620 buf.push(' ');
621 }
622 buf.push_str(t);
623 }
624 }
625 buf
626 } else {
627 return None;
628 };
629 let one_line = raw
630 .split('\n')
631 .map(str::trim)
632 .filter(|l| !l.is_empty())
633 .collect::<Vec<_>>()
634 .join(" ");
635 if one_line.is_empty() {
636 return None;
637 }
638 let truncated: String = one_line.chars().take(max_chars).collect();
639 if truncated.len() < one_line.len() {
640 Some(format!("{truncated}..."))
641 } else {
642 Some(truncated)
643 }
644}
645
646fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
647 let file = fs::File::open(path)?;
648 let reader = BufReader::new(file);
649
650 let mut entries = Vec::new();
651 for (lineno, line) in reader.lines().enumerate() {
652 let line = match line {
653 Ok(l) => l,
654 Err(e) => {
655 tracing::warn!(
656 path = %path.display(),
657 line = lineno + 1,
658 error = %e,
659 "history: skipping unreadable line",
660 );
661 continue;
662 }
663 };
664 let trimmed = line.trim();
665 if trimmed.is_empty() {
666 continue;
667 }
668 match parse_entry(trimmed) {
669 Ok(entry) => entries.push(entry),
670 Err(e) => {
671 tracing::warn!(
672 path = %path.display(),
673 line = lineno + 1,
674 error = %e,
675 "history: skipping malformed line",
676 );
677 }
678 }
679 }
680 Ok(SessionLog {
681 session_id,
682 project_slug,
683 entries,
684 })
685}
686
687fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
688 let mut value: Value = serde_json::from_str(line)?;
689 let ty = value
690 .get("type")
691 .and_then(Value::as_str)
692 .unwrap_or("")
693 .to_string();
694 match ty.as_str() {
695 "user" => Ok(HistoryEntry::User {
696 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
697 timestamp: value
698 .get("timestamp")
699 .and_then(Value::as_str)
700 .map(String::from),
701 cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
702 git_branch: value
703 .get("gitBranch")
704 .and_then(Value::as_str)
705 .map(String::from),
706 message: value.get("message").cloned().unwrap_or(Value::Null),
707 rest: take_object(&mut value),
708 }),
709 "assistant" => Ok(HistoryEntry::Assistant {
710 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
711 timestamp: value
712 .get("timestamp")
713 .and_then(Value::as_str)
714 .map(String::from),
715 message: value.get("message").cloned().unwrap_or(Value::Null),
716 rest: take_object(&mut value),
717 }),
718 other => Ok(HistoryEntry::Other {
719 type_tag: other.to_string(),
720 raw: value,
721 }),
722 }
723}
724
725fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
726 serde_json::Map::new()
731}
732
733fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
751 let body = slug.strip_prefix('-').unwrap_or(slug);
752 let mut segments = body.split('-');
753 let mut built_path = PathBuf::from("/");
754 let mut is_decode_verified = true;
755
756 let mut current_component = segments.next().unwrap_or("").to_string();
759
760 for next_segment in segments {
761 let hyphen_component = format!("{current_component}-{next_segment}");
762 let slash_exists = built_path.join(¤t_component).exists();
763 let hyphen_exists = built_path.join(&hyphen_component).exists();
764
765 if hyphen_exists {
770 current_component = hyphen_component;
771 } else {
772 if !slash_exists {
773 is_decode_verified = false;
774 }
775 built_path.push(¤t_component);
776 current_component = next_segment.to_string();
777 }
778 }
779
780 built_path.push(¤t_component);
781 (built_path, is_decode_verified)
782}
783
784fn home_dir() -> Option<PathBuf> {
785 if let Ok(h) = std::env::var("HOME")
788 && !h.is_empty()
789 {
790 return Some(PathBuf::from(h));
791 }
792 if let Ok(h) = std::env::var("USERPROFILE")
793 && !h.is_empty()
794 {
795 return Some(PathBuf::from(h));
796 }
797 None
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use std::io::Write;
804
805 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
806 let path = dir.join(format!("{session_id}.jsonl"));
807 let mut f = fs::File::create(&path).expect("create jsonl");
808 for line in lines {
809 writeln!(f, "{line}").unwrap();
810 }
811 path
812 }
813
814 fn set_mtime(path: &Path, secs_since_epoch: u64) {
819 let f = fs::OpenOptions::new()
820 .write(true)
821 .open(path)
822 .expect("reopen for mtime");
823 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
824 f.set_modified(when).expect("set mtime");
825 }
826
827 fn fixture_root() -> tempfile::TempDir {
828 let tmp = tempfile::tempdir().expect("tempdir");
829 let a = tmp.path().join("-Users-josh-Code-projA");
831 fs::create_dir_all(&a).unwrap();
832 write_session(
833 &a,
834 "session-aaa",
835 &[
836 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
837 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
838 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
839 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
840 ],
841 );
842 write_session(
843 &a,
844 "session-bbb",
845 &[
846 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
847 ],
848 );
849 let b = tmp.path().join("-private-tmp-projB");
851 fs::create_dir_all(&b).unwrap();
852 write_session(
853 &b,
854 "session-ccc",
855 &[
856 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
857 r#"NOT VALID JSON"#,
858 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
859 ],
860 );
861 tmp
862 }
863
864 #[test]
865 fn list_projects_returns_directories_sorted_by_slug() {
866 let tmp = fixture_root();
867 let root = HistoryRoot::at(tmp.path());
868 let projects = root.list_projects().expect("list projects");
869 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
870 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
871 }
872
873 #[test]
874 fn list_projects_counts_sessions() {
875 let tmp = fixture_root();
876 let root = HistoryRoot::at(tmp.path());
877 let projects = root.list_projects().expect("list");
878 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
879 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
880 assert_eq!(a.session_count, 2);
881 assert_eq!(b.session_count, 1);
882 }
883
884 #[test]
885 fn list_projects_decodes_slug_to_filesystem_path() {
886 let tmp = fixture_root();
887 let root = HistoryRoot::at(tmp.path());
888 let projects = root.list_projects().expect("list");
889 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
890 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
891 }
892
893 #[test]
894 fn list_projects_returns_empty_when_root_missing() {
895 let tmp = tempfile::tempdir().unwrap();
896 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
897 let projects = root.list_projects().expect("ok");
898 assert!(projects.is_empty());
899 }
900
901 #[test]
902 fn list_sessions_filtered_by_slug() {
903 let tmp = fixture_root();
904 let root = HistoryRoot::at(tmp.path());
905 let sessions = root
906 .list_sessions(Some("-Users-josh-Code-projA"))
907 .expect("list");
908 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
909 assert_eq!(ids, ["session-aaa", "session-bbb"]);
910 assert!(
911 sessions
912 .iter()
913 .all(|s| s.project_slug == "-Users-josh-Code-projA")
914 );
915 }
916
917 #[test]
918 fn list_sessions_unfiltered_returns_union() {
919 let tmp = fixture_root();
920 let root = HistoryRoot::at(tmp.path());
921 let sessions = root.list_sessions(None).expect("list");
922 assert_eq!(sessions.len(), 3);
923 }
924
925 #[test]
926 fn session_summary_counts_only_user_and_assistant() {
927 let tmp = fixture_root();
928 let root = HistoryRoot::at(tmp.path());
929 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
930 let aaa = sessions
931 .iter()
932 .find(|s| s.session_id == "session-aaa")
933 .unwrap();
934 assert_eq!(aaa.message_count, 2);
936 assert_eq!(aaa.title.as_deref(), Some("hello world"));
937 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
938 }
939
940 #[test]
941 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
942 let tmp = fixture_root();
943 let root = HistoryRoot::at(tmp.path());
944 let log = root.read_session("session-ccc").expect("read");
945 assert_eq!(log.session_id, "session-ccc");
946 assert_eq!(log.project_slug, "-private-tmp-projB");
947 assert_eq!(log.entries.len(), 2);
949 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
950 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
951 }
952
953 #[test]
954 fn read_session_user_entry_carries_metadata() {
955 let tmp = fixture_root();
956 let root = HistoryRoot::at(tmp.path());
957 let log = root.read_session("session-aaa").expect("read");
958 match &log.entries[0] {
959 HistoryEntry::User {
960 uuid,
961 timestamp,
962 cwd,
963 git_branch,
964 ..
965 } => {
966 assert_eq!(uuid.as_deref(), Some("u1"));
967 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
968 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
969 assert_eq!(git_branch.as_deref(), Some("main"));
970 }
971 other => panic!("expected User entry, got {other:?}"),
972 }
973 }
974
975 #[test]
976 fn read_session_other_entry_preserves_type_tag_and_raw() {
977 let tmp = fixture_root();
978 let root = HistoryRoot::at(tmp.path());
979 let log = root.read_session("session-aaa").expect("read");
980 let queue_op = log
982 .entries
983 .iter()
984 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
985 .expect("queue-operation entry");
986 if let HistoryEntry::Other { raw, .. } = queue_op {
987 assert_eq!(raw["operation"], "enqueue");
988 }
989 }
990
991 #[test]
992 fn read_session_unknown_id_errors() {
993 let tmp = fixture_root();
994 let root = HistoryRoot::at(tmp.path());
995 let err = root.read_session("not-a-real-session").unwrap_err();
996 assert!(matches!(err, Error::History { .. }));
997 assert!(format!("{err}").contains("no session with id"));
998 }
999
1000 #[test]
1001 fn find_session_returns_none_for_unknown_id() {
1002 let tmp = fixture_root();
1003 let root = HistoryRoot::at(tmp.path());
1004 let found = root.find_session("nope").expect("ok");
1005 assert!(found.is_none());
1006 }
1007
1008 #[test]
1009 fn find_session_locates_real_session() {
1010 let tmp = fixture_root();
1011 let root = HistoryRoot::at(tmp.path());
1012 let (path, slug) = root
1013 .find_session("session-ccc")
1014 .expect("ok")
1015 .expect("found");
1016 assert!(path.ends_with("session-ccc.jsonl"));
1017 assert_eq!(slug, "-private-tmp-projB");
1018 }
1019
1020 #[test]
1021 fn decode_slug_anchored_no_hyphens_in_components() {
1022 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1027 assert_eq!(path, PathBuf::from("/a/b/c/d"));
1028 }
1029
1030 #[test]
1031 fn decode_slug_anchored_single_hyphenated_segment() {
1032 let tmp = tempfile::tempdir().unwrap();
1034 let dir = tmp.path().join("foo-bar");
1035 fs::create_dir_all(&dir).unwrap();
1036 let tmp_str = tmp.path().to_string_lossy();
1037 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1038 let slug = format!("-{tmp_encoded}-foo-bar");
1039 let expected = tmp.path().join("foo-bar");
1040 let (decoded, is_verified) = decode_slug_anchored(&slug);
1041 assert_eq!(decoded, expected);
1042 assert!(is_verified);
1043 }
1044
1045 #[test]
1046 fn decode_slug_anchored_multiple_hyphenated_segments() {
1047 let tmp = tempfile::tempdir().unwrap();
1049 let dir = tmp.path().join("foo-bar").join("baz-qux");
1050 fs::create_dir_all(&dir).unwrap();
1051 let tmp_str = tmp.path().to_string_lossy();
1052 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1053 let slug = format!("-{tmp_encoded}-foo-bar-baz-qux");
1054 let expected = tmp.path().join("foo-bar").join("baz-qux");
1055 let (decoded, is_verified) = decode_slug_anchored(&slug);
1056 assert_eq!(decoded, expected);
1057 assert!(is_verified);
1058 }
1059
1060 #[test]
1061 fn decode_slug_anchored_fallback_when_nothing_exists() {
1062 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1064 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1065 assert!(!verified);
1066 }
1067
1068 #[test]
1069 fn decode_slug_anchored_real_world_issue_example() {
1070 let tmp = tempfile::tempdir().unwrap();
1075 let dir = tmp.path().join("rust").join("claude-wrapper");
1076 fs::create_dir_all(&dir).unwrap();
1077 let tmp_str = tmp.path().to_string_lossy();
1078 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1079 let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1080 let expected = tmp.path().join("rust").join("claude-wrapper");
1081 let (decoded, is_verified) = decode_slug_anchored(&slug);
1082 assert_eq!(decoded, expected);
1083 assert!(is_verified);
1084 }
1085
1086 fn paginated_fixture() -> tempfile::TempDir {
1091 let tmp = tempfile::tempdir().unwrap();
1092 for stem in ["-zzz-empty1", "-aaa-empty2"] {
1094 fs::create_dir_all(tmp.path().join(stem)).unwrap();
1095 }
1096 for (stem, ts, mtime) in [
1097 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1098 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1099 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1100 ] {
1101 let dir = tmp.path().join(stem);
1102 fs::create_dir_all(&dir).unwrap();
1103 let session_path = write_session(
1104 &dir,
1105 "s1",
1106 &[&format!(
1107 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1108 )],
1109 );
1110 set_mtime(&session_path, mtime);
1111 }
1112 tmp
1113 }
1114
1115 #[test]
1116 fn list_projects_with_include_empty_false_filters_them_out() {
1117 let tmp = paginated_fixture();
1118 let root = HistoryRoot::at(tmp.path());
1119 let projects = root
1120 .list_projects_with(&ListOptions {
1121 include_empty: false,
1122 ..Default::default()
1123 })
1124 .expect("list");
1125 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1126 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1128 }
1129
1130 #[test]
1131 fn list_projects_with_default_includes_empty_for_bc() {
1132 let tmp = paginated_fixture();
1135 let root = HistoryRoot::at(tmp.path());
1136 let projects = root
1137 .list_projects_with(&ListOptions::default())
1138 .expect("list");
1139 assert_eq!(projects.len(), 5);
1140 }
1141
1142 #[test]
1143 fn list_projects_zero_arg_preserves_legacy_inclusion() {
1144 let tmp = paginated_fixture();
1147 let root = HistoryRoot::at(tmp.path());
1148 let projects = root.list_projects().expect("list");
1149 assert_eq!(projects.len(), 5);
1150 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1151 assert_eq!(
1152 slugs,
1153 [
1154 "-aaa-empty2",
1155 "-bbb-proj",
1156 "-ccc-proj",
1157 "-ddd-proj",
1158 "-zzz-empty1",
1159 ]
1160 );
1161 }
1162
1163 #[test]
1164 fn list_projects_with_limit_caps_results() {
1165 let tmp = paginated_fixture();
1166 let root = HistoryRoot::at(tmp.path());
1167 let projects = root
1168 .list_projects_with(&ListOptions {
1169 limit: Some(2),
1170 include_empty: true,
1171 ..Default::default()
1172 })
1173 .expect("list");
1174 assert_eq!(projects.len(), 2);
1175 }
1176
1177 #[test]
1178 fn list_projects_with_offset_skips() {
1179 let tmp = paginated_fixture();
1180 let root = HistoryRoot::at(tmp.path());
1181 let projects = root
1182 .list_projects_with(&ListOptions {
1183 offset: 3,
1184 include_empty: true,
1185 ..Default::default()
1186 })
1187 .expect("list");
1188 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1191 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1192 }
1193
1194 #[test]
1195 fn list_projects_with_offset_past_end_returns_empty() {
1196 let tmp = paginated_fixture();
1197 let root = HistoryRoot::at(tmp.path());
1198 let projects = root
1199 .list_projects_with(&ListOptions {
1200 offset: 99,
1201 include_empty: true,
1202 ..Default::default()
1203 })
1204 .expect("list");
1205 assert!(projects.is_empty());
1206 }
1207
1208 #[test]
1209 fn list_projects_with_recency_desc_sort() {
1210 let tmp = paginated_fixture();
1211 let root = HistoryRoot::at(tmp.path());
1212 let projects = root
1216 .list_projects_with(&ListOptions {
1217 sort: ListSort::RecencyDesc,
1218 include_empty: false,
1219 ..Default::default()
1220 })
1221 .expect("list");
1222 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1223 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1224 }
1225
1226 #[test]
1227 fn list_sessions_with_include_empty_false_filters_zero_message() {
1228 let tmp = tempfile::tempdir().unwrap();
1229 let dir = tmp.path().join("-proj");
1230 fs::create_dir_all(&dir).unwrap();
1231 write_session(
1233 &dir,
1234 "real",
1235 &[
1236 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1237 ],
1238 );
1239 write_session(
1241 &dir,
1242 "orphan",
1243 &[
1244 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1245 ],
1246 );
1247 let root = HistoryRoot::at(tmp.path());
1248 let sessions = root
1249 .list_sessions_with(
1250 Some("-proj"),
1251 &ListOptions {
1252 include_empty: false,
1253 ..Default::default()
1254 },
1255 )
1256 .expect("list");
1257 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1258 assert_eq!(ids, ["real"]);
1259 }
1260
1261 #[test]
1262 fn list_sessions_with_default_returns_orphans_for_bc() {
1263 let tmp = tempfile::tempdir().unwrap();
1264 let dir = tmp.path().join("-proj");
1265 fs::create_dir_all(&dir).unwrap();
1266 write_session(
1267 &dir,
1268 "orphan",
1269 &[
1270 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1271 ],
1272 );
1273 let root = HistoryRoot::at(tmp.path());
1274 let sessions = root
1275 .list_sessions_with(Some("-proj"), &ListOptions::default())
1276 .expect("list");
1277 assert_eq!(sessions.len(), 1);
1278 assert_eq!(sessions[0].message_count, 0);
1279 }
1280
1281 #[test]
1282 fn list_sessions_with_recency_desc_sort() {
1283 let tmp = tempfile::tempdir().unwrap();
1284 let dir = tmp.path().join("-proj");
1285 fs::create_dir_all(&dir).unwrap();
1286 let old_p = write_session(
1287 &dir,
1288 "old",
1289 &[
1290 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1291 ],
1292 );
1293 let new_p = write_session(
1294 &dir,
1295 "new",
1296 &[
1297 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1298 ],
1299 );
1300 let mid_p = write_session(
1301 &dir,
1302 "mid",
1303 &[
1304 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1305 ],
1306 );
1307 set_mtime(&old_p, 1_700_000_000);
1308 set_mtime(&mid_p, 1_700_001_000);
1309 set_mtime(&new_p, 1_700_002_000);
1310 let root = HistoryRoot::at(tmp.path());
1311 let sessions = root
1312 .list_sessions_with(
1313 Some("-proj"),
1314 &ListOptions {
1315 sort: ListSort::RecencyDesc,
1316 ..Default::default()
1317 },
1318 )
1319 .expect("list");
1320 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1321 assert_eq!(ids, ["new", "mid", "old"]);
1322 }
1323
1324 #[test]
1325 fn list_sessions_with_limit_and_offset_combine() {
1326 let tmp = tempfile::tempdir().unwrap();
1327 let dir = tmp.path().join("-proj");
1328 fs::create_dir_all(&dir).unwrap();
1329 for i in 0..5 {
1330 write_session(
1331 &dir,
1332 &format!("s{i}"),
1333 &[&format!(
1334 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1335 )],
1336 );
1337 }
1338 let root = HistoryRoot::at(tmp.path());
1339 let sessions = root
1340 .list_sessions_with(
1341 Some("-proj"),
1342 &ListOptions {
1343 offset: 1,
1344 limit: Some(2),
1345 ..Default::default()
1346 },
1347 )
1348 .expect("list");
1349 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1350 assert_eq!(ids, ["s1", "s2"]);
1352 }
1353
1354 #[test]
1357 fn session_summary_parses_ai_title_camelcase() {
1358 let tmp = tempfile::tempdir().unwrap();
1361 let dir = tmp.path().join("-proj");
1362 fs::create_dir_all(&dir).unwrap();
1363 write_session(
1364 &dir,
1365 "real-shape",
1366 &[
1367 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1368 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1369 ],
1370 );
1371 let root = HistoryRoot::at(tmp.path());
1372 let sessions = root.list_sessions(Some("-proj")).expect("list");
1373 let s = sessions
1374 .iter()
1375 .find(|s| s.session_id == "real-shape")
1376 .unwrap();
1377 assert_eq!(s.title.as_deref(), Some("My Session"));
1378 }
1379
1380 #[test]
1381 fn session_summary_legacy_title_field_still_works() {
1382 let tmp = tempfile::tempdir().unwrap();
1384 let dir = tmp.path().join("-proj");
1385 fs::create_dir_all(&dir).unwrap();
1386 write_session(
1387 &dir,
1388 "legacy",
1389 &[
1390 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1391 r#"{"type":"ai-title","title":"Legacy Form"}"#,
1392 ],
1393 );
1394 let root = HistoryRoot::at(tmp.path());
1395 let sessions = root.list_sessions(Some("-proj")).expect("list");
1396 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1397 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1398 }
1399}