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 #[must_use]
322 pub fn project_slug(path: impl AsRef<Path>) -> String {
323 let path = path.as_ref();
324 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
325 encode_path_slug(&canonical.to_string_lossy())
326 }
327
328 pub fn sessions_for_path(&self, cwd: impl AsRef<Path>) -> Result<Vec<SessionSummary>> {
337 self.sessions_for_path_with(cwd, &ListOptions::default())
338 }
339
340 pub fn sessions_for_path_with(
342 &self,
343 cwd: impl AsRef<Path>,
344 opts: &ListOptions,
345 ) -> Result<Vec<SessionSummary>> {
346 let slug = Self::project_slug(cwd);
347 self.list_sessions_with(Some(&slug), opts)
348 }
349
350 pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
356 let (path, project_slug) =
357 self.find_session(session_id)?
358 .ok_or_else(|| Error::History {
359 message: format!(
360 "no session with id `{session_id}` under {}",
361 self.path.display()
362 ),
363 })?;
364 parse_session(&path, session_id.to_string(), project_slug)
365 }
366
367 pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
373 for project in self.list_projects()? {
374 let candidate = self
375 .path
376 .join(&project.slug)
377 .join(format!("{session_id}.jsonl"));
378 if candidate.is_file() {
379 return Ok(Some((candidate, project.slug)));
380 }
381 }
382 Ok(None)
383 }
384}
385
386#[derive(Debug, Clone, Serialize)]
388pub struct ProjectSummary {
389 pub slug: String,
391 pub decoded_path: PathBuf,
394 pub is_decode_verified: bool,
410 pub session_count: usize,
412 pub last_modified: Option<SystemTime>,
415}
416
417#[derive(Debug, Clone, Serialize)]
419pub struct SessionSummary {
420 pub session_id: String,
422 pub project_slug: String,
424 pub message_count: usize,
427 pub first_timestamp: Option<String>,
430 pub last_timestamp: Option<String>,
432 pub title: Option<String>,
435 pub first_user_preview: Option<String>,
441 pub total_cost_usd: Option<f64>,
446 pub total_tokens: Option<u64>,
450 pub size_bytes: u64,
452}
453
454#[derive(Debug, Clone, Serialize)]
456pub struct SessionLog {
457 pub session_id: String,
458 pub project_slug: String,
459 pub entries: Vec<HistoryEntry>,
460}
461
462#[derive(Debug, Clone, Serialize)]
469#[serde(tag = "kind", rename_all = "snake_case")]
470pub enum HistoryEntry {
471 User {
472 uuid: Option<String>,
473 timestamp: Option<String>,
474 cwd: Option<String>,
475 git_branch: Option<String>,
476 message: Value,
477 #[serde(flatten)]
478 rest: serde_json::Map<String, Value>,
479 },
480 Assistant {
481 uuid: Option<String>,
482 timestamp: Option<String>,
483 message: Value,
484 #[serde(flatten)]
485 rest: serde_json::Map<String, Value>,
486 },
487 Other {
488 type_tag: String,
490 raw: Value,
492 },
493}
494
495fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
500 if opts.offset >= items.len() {
501 items.clear();
502 return;
503 }
504 if opts.offset > 0 {
505 items.drain(..opts.offset);
506 }
507 if let Some(lim) = opts.limit
508 && items.len() > lim
509 {
510 items.truncate(lim);
511 }
512}
513
514fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
515 let mut session_count = 0usize;
516 let mut last_modified: Option<SystemTime> = None;
517 if let Ok(entries) = fs::read_dir(dir) {
518 for entry in entries.flatten() {
519 let path = entry.path();
520 if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
521 session_count += 1;
522 if let Ok(meta) = entry.metadata()
523 && let Ok(mtime) = meta.modified()
524 {
525 last_modified = Some(match last_modified {
526 Some(prev) if prev > mtime => prev,
527 _ => mtime,
528 });
529 }
530 }
531 }
532 }
533 let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
534 ProjectSummary {
535 decoded_path,
536 is_decode_verified,
537 slug,
538 session_count,
539 last_modified,
540 }
541}
542
543fn summarize_session(
544 path: &Path,
545 session_id: String,
546 project_slug: String,
547) -> Option<SessionSummary> {
548 let meta = fs::metadata(path).ok()?;
549 let size_bytes = meta.len();
550
551 let file = fs::File::open(path).ok()?;
552 let reader = BufReader::new(file);
553
554 let mut message_count = 0usize;
555 let mut first_timestamp = None;
556 let mut last_timestamp = None;
557 let mut title = None;
558 let mut first_user_preview: Option<String> = None;
559 let mut total_cost_usd: Option<f64> = None;
560 let mut total_tokens: Option<u64> = None;
561
562 for line in reader.lines().map_while(std::io::Result::ok) {
563 let trimmed = line.trim();
564 if trimmed.is_empty() {
565 continue;
566 }
567 let v: Value = match serde_json::from_str(trimmed) {
568 Ok(v) => v,
569 Err(_) => continue,
570 };
571 let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
572 match ty {
573 "user" => {
574 message_count += 1;
575 if first_user_preview.is_none()
576 && let Some(p) = extract_user_text_preview(&v, 160)
577 {
578 first_user_preview = Some(p);
579 }
580 }
581 "assistant" => {
582 message_count += 1;
583 if let Some(c) = v
584 .get("message")
585 .and_then(|m| m.get("usage"))
586 .and_then(|u| u.get("total_cost_usd"))
587 .and_then(Value::as_f64)
588 {
589 *total_cost_usd.get_or_insert(0.0) += c;
590 }
591 if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
592 let mut t = 0u64;
594 for k in [
595 "input_tokens",
596 "output_tokens",
597 "cache_creation_input_tokens",
598 "cache_read_input_tokens",
599 ] {
600 if let Some(n) = usage.get(k).and_then(Value::as_u64) {
601 t += n;
602 }
603 }
604 if t > 0 {
605 *total_tokens.get_or_insert(0) += t;
606 }
607 }
608 }
609 "ai-title" => {
610 let candidate = v
614 .get("aiTitle")
615 .and_then(Value::as_str)
616 .or_else(|| v.get("title").and_then(Value::as_str));
617 if let Some(t) = candidate
618 && !t.is_empty()
619 {
620 title = Some(t.to_string());
621 }
622 }
623 _ => {}
624 }
625 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
626 if first_timestamp.is_none() {
627 first_timestamp = Some(ts.to_string());
628 }
629 last_timestamp = Some(ts.to_string());
630 }
631 }
632
633 Some(SessionSummary {
634 session_id,
635 project_slug,
636 message_count,
637 first_timestamp,
638 last_timestamp,
639 title,
640 first_user_preview,
641 total_cost_usd,
642 total_tokens,
643 size_bytes,
644 })
645}
646
647fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
653 let content = entry.get("message")?.get("content")?;
654 let raw = if let Some(s) = content.as_str() {
655 s.to_string()
656 } else if let Some(arr) = content.as_array() {
657 let mut buf = String::new();
658 for block in arr {
659 let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
660 if ty == "text"
661 && let Some(t) = block.get("text").and_then(Value::as_str)
662 {
663 if !buf.is_empty() {
664 buf.push(' ');
665 }
666 buf.push_str(t);
667 }
668 }
669 buf
670 } else {
671 return None;
672 };
673 let one_line = raw
674 .split('\n')
675 .map(str::trim)
676 .filter(|l| !l.is_empty())
677 .collect::<Vec<_>>()
678 .join(" ");
679 if one_line.is_empty() {
680 return None;
681 }
682 let truncated: String = one_line.chars().take(max_chars).collect();
683 if truncated.len() < one_line.len() {
684 Some(format!("{truncated}..."))
685 } else {
686 Some(truncated)
687 }
688}
689
690fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
691 let file = fs::File::open(path)?;
692 let reader = BufReader::new(file);
693
694 let mut entries = Vec::new();
695 for (lineno, line) in reader.lines().enumerate() {
696 let line = match line {
697 Ok(l) => l,
698 Err(e) => {
699 tracing::warn!(
700 path = %path.display(),
701 line = lineno + 1,
702 error = %e,
703 "history: skipping unreadable line",
704 );
705 continue;
706 }
707 };
708 let trimmed = line.trim();
709 if trimmed.is_empty() {
710 continue;
711 }
712 match parse_entry(trimmed) {
713 Ok(entry) => entries.push(entry),
714 Err(e) => {
715 tracing::warn!(
716 path = %path.display(),
717 line = lineno + 1,
718 error = %e,
719 "history: skipping malformed line",
720 );
721 }
722 }
723 }
724 Ok(SessionLog {
725 session_id,
726 project_slug,
727 entries,
728 })
729}
730
731fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
732 let mut value: Value = serde_json::from_str(line)?;
733 let ty = value
734 .get("type")
735 .and_then(Value::as_str)
736 .unwrap_or("")
737 .to_string();
738 match ty.as_str() {
739 "user" => Ok(HistoryEntry::User {
740 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
741 timestamp: value
742 .get("timestamp")
743 .and_then(Value::as_str)
744 .map(String::from),
745 cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
746 git_branch: value
747 .get("gitBranch")
748 .and_then(Value::as_str)
749 .map(String::from),
750 message: value.get("message").cloned().unwrap_or(Value::Null),
751 rest: take_object(&mut value),
752 }),
753 "assistant" => Ok(HistoryEntry::Assistant {
754 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
755 timestamp: value
756 .get("timestamp")
757 .and_then(Value::as_str)
758 .map(String::from),
759 message: value.get("message").cloned().unwrap_or(Value::Null),
760 rest: take_object(&mut value),
761 }),
762 other => Ok(HistoryEntry::Other {
763 type_tag: other.to_string(),
764 raw: value,
765 }),
766 }
767}
768
769fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
770 serde_json::Map::new()
775}
776
777fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
795 let body = slug.strip_prefix('-').unwrap_or(slug);
796 let mut segments = body.split('-');
797 let mut built_path = PathBuf::from("/");
798 let mut is_decode_verified = true;
799
800 let mut current_component = segments.next().unwrap_or("").to_string();
803
804 for next_segment in segments {
805 let hyphen_component = format!("{current_component}-{next_segment}");
806 let slash_exists = built_path.join(¤t_component).exists();
807 let hyphen_exists = built_path.join(&hyphen_component).exists();
808
809 if hyphen_exists {
814 current_component = hyphen_component;
815 } else {
816 if !slash_exists {
817 is_decode_verified = false;
818 }
819 built_path.push(¤t_component);
820 current_component = next_segment.to_string();
821 }
822 }
823
824 built_path.push(¤t_component);
825 (built_path, is_decode_verified)
826}
827
828fn encode_path_slug(path: &str) -> String {
834 path.chars()
835 .map(|c| if c == '/' || c == '.' { '-' } else { c })
836 .collect()
837}
838
839fn home_dir() -> Option<PathBuf> {
840 if let Ok(h) = std::env::var("HOME")
843 && !h.is_empty()
844 {
845 return Some(PathBuf::from(h));
846 }
847 if let Ok(h) = std::env::var("USERPROFILE")
848 && !h.is_empty()
849 {
850 return Some(PathBuf::from(h));
851 }
852 None
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use std::io::Write;
859
860 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
861 let path = dir.join(format!("{session_id}.jsonl"));
862 let mut f = fs::File::create(&path).expect("create jsonl");
863 for line in lines {
864 writeln!(f, "{line}").unwrap();
865 }
866 path
867 }
868
869 fn set_mtime(path: &Path, secs_since_epoch: u64) {
874 let f = fs::OpenOptions::new()
875 .write(true)
876 .open(path)
877 .expect("reopen for mtime");
878 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
879 f.set_modified(when).expect("set mtime");
880 }
881
882 fn fixture_root() -> tempfile::TempDir {
883 let tmp = tempfile::tempdir().expect("tempdir");
884 let a = tmp.path().join("-Users-josh-Code-projA");
886 fs::create_dir_all(&a).unwrap();
887 write_session(
888 &a,
889 "session-aaa",
890 &[
891 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
892 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
893 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
894 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
895 ],
896 );
897 write_session(
898 &a,
899 "session-bbb",
900 &[
901 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
902 ],
903 );
904 let b = tmp.path().join("-private-tmp-projB");
906 fs::create_dir_all(&b).unwrap();
907 write_session(
908 &b,
909 "session-ccc",
910 &[
911 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
912 r#"NOT VALID JSON"#,
913 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
914 ],
915 );
916 tmp
917 }
918
919 #[test]
920 fn list_projects_returns_directories_sorted_by_slug() {
921 let tmp = fixture_root();
922 let root = HistoryRoot::at(tmp.path());
923 let projects = root.list_projects().expect("list projects");
924 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
925 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
926 }
927
928 #[test]
929 fn list_projects_counts_sessions() {
930 let tmp = fixture_root();
931 let root = HistoryRoot::at(tmp.path());
932 let projects = root.list_projects().expect("list");
933 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
934 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
935 assert_eq!(a.session_count, 2);
936 assert_eq!(b.session_count, 1);
937 }
938
939 #[test]
940 fn list_projects_decodes_slug_to_filesystem_path() {
941 let tmp = fixture_root();
942 let root = HistoryRoot::at(tmp.path());
943 let projects = root.list_projects().expect("list");
944 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
945 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
946 }
947
948 #[test]
949 fn list_projects_returns_empty_when_root_missing() {
950 let tmp = tempfile::tempdir().unwrap();
951 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
952 let projects = root.list_projects().expect("ok");
953 assert!(projects.is_empty());
954 }
955
956 #[test]
957 fn list_sessions_filtered_by_slug() {
958 let tmp = fixture_root();
959 let root = HistoryRoot::at(tmp.path());
960 let sessions = root
961 .list_sessions(Some("-Users-josh-Code-projA"))
962 .expect("list");
963 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
964 assert_eq!(ids, ["session-aaa", "session-bbb"]);
965 assert!(
966 sessions
967 .iter()
968 .all(|s| s.project_slug == "-Users-josh-Code-projA")
969 );
970 }
971
972 #[test]
973 fn list_sessions_unfiltered_returns_union() {
974 let tmp = fixture_root();
975 let root = HistoryRoot::at(tmp.path());
976 let sessions = root.list_sessions(None).expect("list");
977 assert_eq!(sessions.len(), 3);
978 }
979
980 #[test]
981 fn session_summary_counts_only_user_and_assistant() {
982 let tmp = fixture_root();
983 let root = HistoryRoot::at(tmp.path());
984 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
985 let aaa = sessions
986 .iter()
987 .find(|s| s.session_id == "session-aaa")
988 .unwrap();
989 assert_eq!(aaa.message_count, 2);
991 assert_eq!(aaa.title.as_deref(), Some("hello world"));
992 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
993 }
994
995 #[test]
996 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
997 let tmp = fixture_root();
998 let root = HistoryRoot::at(tmp.path());
999 let log = root.read_session("session-ccc").expect("read");
1000 assert_eq!(log.session_id, "session-ccc");
1001 assert_eq!(log.project_slug, "-private-tmp-projB");
1002 assert_eq!(log.entries.len(), 2);
1004 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1005 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1006 }
1007
1008 #[test]
1009 fn read_session_user_entry_carries_metadata() {
1010 let tmp = fixture_root();
1011 let root = HistoryRoot::at(tmp.path());
1012 let log = root.read_session("session-aaa").expect("read");
1013 match &log.entries[0] {
1014 HistoryEntry::User {
1015 uuid,
1016 timestamp,
1017 cwd,
1018 git_branch,
1019 ..
1020 } => {
1021 assert_eq!(uuid.as_deref(), Some("u1"));
1022 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1023 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1024 assert_eq!(git_branch.as_deref(), Some("main"));
1025 }
1026 other => panic!("expected User entry, got {other:?}"),
1027 }
1028 }
1029
1030 #[test]
1031 fn read_session_other_entry_preserves_type_tag_and_raw() {
1032 let tmp = fixture_root();
1033 let root = HistoryRoot::at(tmp.path());
1034 let log = root.read_session("session-aaa").expect("read");
1035 let queue_op = log
1037 .entries
1038 .iter()
1039 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1040 .expect("queue-operation entry");
1041 if let HistoryEntry::Other { raw, .. } = queue_op {
1042 assert_eq!(raw["operation"], "enqueue");
1043 }
1044 }
1045
1046 #[test]
1047 fn read_session_unknown_id_errors() {
1048 let tmp = fixture_root();
1049 let root = HistoryRoot::at(tmp.path());
1050 let err = root.read_session("not-a-real-session").unwrap_err();
1051 assert!(matches!(err, Error::History { .. }));
1052 assert!(format!("{err}").contains("no session with id"));
1053 }
1054
1055 #[test]
1056 fn find_session_returns_none_for_unknown_id() {
1057 let tmp = fixture_root();
1058 let root = HistoryRoot::at(tmp.path());
1059 let found = root.find_session("nope").expect("ok");
1060 assert!(found.is_none());
1061 }
1062
1063 #[test]
1064 fn find_session_locates_real_session() {
1065 let tmp = fixture_root();
1066 let root = HistoryRoot::at(tmp.path());
1067 let (path, slug) = root
1068 .find_session("session-ccc")
1069 .expect("ok")
1070 .expect("found");
1071 assert!(path.ends_with("session-ccc.jsonl"));
1072 assert_eq!(slug, "-private-tmp-projB");
1073 }
1074
1075 #[test]
1076 fn decode_slug_anchored_no_hyphens_in_components() {
1077 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1082 assert_eq!(path, PathBuf::from("/a/b/c/d"));
1083 }
1084
1085 #[test]
1086 fn decode_slug_anchored_single_hyphenated_segment() {
1087 let tmp = tempfile::tempdir().unwrap();
1089 let dir = tmp.path().join("foo-bar");
1090 fs::create_dir_all(&dir).unwrap();
1091 let tmp_str = tmp.path().to_string_lossy();
1092 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1093 let slug = format!("-{tmp_encoded}-foo-bar");
1094 let expected = tmp.path().join("foo-bar");
1095 let (decoded, is_verified) = decode_slug_anchored(&slug);
1096 assert_eq!(decoded, expected);
1097 assert!(is_verified);
1098 }
1099
1100 #[test]
1101 fn decode_slug_anchored_multiple_hyphenated_segments() {
1102 let tmp = tempfile::tempdir().unwrap();
1104 let dir = tmp.path().join("foo-bar").join("baz-qux");
1105 fs::create_dir_all(&dir).unwrap();
1106 let tmp_str = tmp.path().to_string_lossy();
1107 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1108 let slug = format!("-{tmp_encoded}-foo-bar-baz-qux");
1109 let expected = tmp.path().join("foo-bar").join("baz-qux");
1110 let (decoded, is_verified) = decode_slug_anchored(&slug);
1111 assert_eq!(decoded, expected);
1112 assert!(is_verified);
1113 }
1114
1115 #[test]
1116 fn decode_slug_anchored_fallback_when_nothing_exists() {
1117 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1119 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1120 assert!(!verified);
1121 }
1122
1123 #[test]
1124 fn decode_slug_anchored_real_world_issue_example() {
1125 let tmp = tempfile::tempdir().unwrap();
1130 let dir = tmp.path().join("rust").join("claude-wrapper");
1131 fs::create_dir_all(&dir).unwrap();
1132 let tmp_str = tmp.path().to_string_lossy();
1133 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1134 let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1135 let expected = tmp.path().join("rust").join("claude-wrapper");
1136 let (decoded, is_verified) = decode_slug_anchored(&slug);
1137 assert_eq!(decoded, expected);
1138 assert!(is_verified);
1139 }
1140
1141 fn paginated_fixture() -> tempfile::TempDir {
1146 let tmp = tempfile::tempdir().unwrap();
1147 for stem in ["-zzz-empty1", "-aaa-empty2"] {
1149 fs::create_dir_all(tmp.path().join(stem)).unwrap();
1150 }
1151 for (stem, ts, mtime) in [
1152 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1153 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1154 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1155 ] {
1156 let dir = tmp.path().join(stem);
1157 fs::create_dir_all(&dir).unwrap();
1158 let session_path = write_session(
1159 &dir,
1160 "s1",
1161 &[&format!(
1162 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1163 )],
1164 );
1165 set_mtime(&session_path, mtime);
1166 }
1167 tmp
1168 }
1169
1170 #[test]
1171 fn list_projects_with_include_empty_false_filters_them_out() {
1172 let tmp = paginated_fixture();
1173 let root = HistoryRoot::at(tmp.path());
1174 let projects = root
1175 .list_projects_with(&ListOptions {
1176 include_empty: false,
1177 ..Default::default()
1178 })
1179 .expect("list");
1180 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1181 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1183 }
1184
1185 #[test]
1186 fn list_projects_with_default_includes_empty_for_bc() {
1187 let tmp = paginated_fixture();
1190 let root = HistoryRoot::at(tmp.path());
1191 let projects = root
1192 .list_projects_with(&ListOptions::default())
1193 .expect("list");
1194 assert_eq!(projects.len(), 5);
1195 }
1196
1197 #[test]
1198 fn list_projects_zero_arg_preserves_legacy_inclusion() {
1199 let tmp = paginated_fixture();
1202 let root = HistoryRoot::at(tmp.path());
1203 let projects = root.list_projects().expect("list");
1204 assert_eq!(projects.len(), 5);
1205 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1206 assert_eq!(
1207 slugs,
1208 [
1209 "-aaa-empty2",
1210 "-bbb-proj",
1211 "-ccc-proj",
1212 "-ddd-proj",
1213 "-zzz-empty1",
1214 ]
1215 );
1216 }
1217
1218 #[test]
1219 fn list_projects_with_limit_caps_results() {
1220 let tmp = paginated_fixture();
1221 let root = HistoryRoot::at(tmp.path());
1222 let projects = root
1223 .list_projects_with(&ListOptions {
1224 limit: Some(2),
1225 include_empty: true,
1226 ..Default::default()
1227 })
1228 .expect("list");
1229 assert_eq!(projects.len(), 2);
1230 }
1231
1232 #[test]
1233 fn list_projects_with_offset_skips() {
1234 let tmp = paginated_fixture();
1235 let root = HistoryRoot::at(tmp.path());
1236 let projects = root
1237 .list_projects_with(&ListOptions {
1238 offset: 3,
1239 include_empty: true,
1240 ..Default::default()
1241 })
1242 .expect("list");
1243 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1246 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1247 }
1248
1249 #[test]
1250 fn list_projects_with_offset_past_end_returns_empty() {
1251 let tmp = paginated_fixture();
1252 let root = HistoryRoot::at(tmp.path());
1253 let projects = root
1254 .list_projects_with(&ListOptions {
1255 offset: 99,
1256 include_empty: true,
1257 ..Default::default()
1258 })
1259 .expect("list");
1260 assert!(projects.is_empty());
1261 }
1262
1263 #[test]
1264 fn list_projects_with_recency_desc_sort() {
1265 let tmp = paginated_fixture();
1266 let root = HistoryRoot::at(tmp.path());
1267 let projects = root
1271 .list_projects_with(&ListOptions {
1272 sort: ListSort::RecencyDesc,
1273 include_empty: false,
1274 ..Default::default()
1275 })
1276 .expect("list");
1277 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1278 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1279 }
1280
1281 #[test]
1282 fn list_sessions_with_include_empty_false_filters_zero_message() {
1283 let tmp = tempfile::tempdir().unwrap();
1284 let dir = tmp.path().join("-proj");
1285 fs::create_dir_all(&dir).unwrap();
1286 write_session(
1288 &dir,
1289 "real",
1290 &[
1291 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1292 ],
1293 );
1294 write_session(
1296 &dir,
1297 "orphan",
1298 &[
1299 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1300 ],
1301 );
1302 let root = HistoryRoot::at(tmp.path());
1303 let sessions = root
1304 .list_sessions_with(
1305 Some("-proj"),
1306 &ListOptions {
1307 include_empty: false,
1308 ..Default::default()
1309 },
1310 )
1311 .expect("list");
1312 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1313 assert_eq!(ids, ["real"]);
1314 }
1315
1316 #[test]
1317 fn list_sessions_with_default_returns_orphans_for_bc() {
1318 let tmp = tempfile::tempdir().unwrap();
1319 let dir = tmp.path().join("-proj");
1320 fs::create_dir_all(&dir).unwrap();
1321 write_session(
1322 &dir,
1323 "orphan",
1324 &[
1325 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1326 ],
1327 );
1328 let root = HistoryRoot::at(tmp.path());
1329 let sessions = root
1330 .list_sessions_with(Some("-proj"), &ListOptions::default())
1331 .expect("list");
1332 assert_eq!(sessions.len(), 1);
1333 assert_eq!(sessions[0].message_count, 0);
1334 }
1335
1336 #[test]
1337 fn list_sessions_with_recency_desc_sort() {
1338 let tmp = tempfile::tempdir().unwrap();
1339 let dir = tmp.path().join("-proj");
1340 fs::create_dir_all(&dir).unwrap();
1341 let old_p = write_session(
1342 &dir,
1343 "old",
1344 &[
1345 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1346 ],
1347 );
1348 let new_p = write_session(
1349 &dir,
1350 "new",
1351 &[
1352 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1353 ],
1354 );
1355 let mid_p = write_session(
1356 &dir,
1357 "mid",
1358 &[
1359 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1360 ],
1361 );
1362 set_mtime(&old_p, 1_700_000_000);
1363 set_mtime(&mid_p, 1_700_001_000);
1364 set_mtime(&new_p, 1_700_002_000);
1365 let root = HistoryRoot::at(tmp.path());
1366 let sessions = root
1367 .list_sessions_with(
1368 Some("-proj"),
1369 &ListOptions {
1370 sort: ListSort::RecencyDesc,
1371 ..Default::default()
1372 },
1373 )
1374 .expect("list");
1375 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1376 assert_eq!(ids, ["new", "mid", "old"]);
1377 }
1378
1379 #[test]
1380 fn list_sessions_with_limit_and_offset_combine() {
1381 let tmp = tempfile::tempdir().unwrap();
1382 let dir = tmp.path().join("-proj");
1383 fs::create_dir_all(&dir).unwrap();
1384 for i in 0..5 {
1385 write_session(
1386 &dir,
1387 &format!("s{i}"),
1388 &[&format!(
1389 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1390 )],
1391 );
1392 }
1393 let root = HistoryRoot::at(tmp.path());
1394 let sessions = root
1395 .list_sessions_with(
1396 Some("-proj"),
1397 &ListOptions {
1398 offset: 1,
1399 limit: Some(2),
1400 ..Default::default()
1401 },
1402 )
1403 .expect("list");
1404 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1405 assert_eq!(ids, ["s1", "s2"]);
1407 }
1408
1409 #[test]
1412 fn session_summary_parses_ai_title_camelcase() {
1413 let tmp = tempfile::tempdir().unwrap();
1416 let dir = tmp.path().join("-proj");
1417 fs::create_dir_all(&dir).unwrap();
1418 write_session(
1419 &dir,
1420 "real-shape",
1421 &[
1422 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1423 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1424 ],
1425 );
1426 let root = HistoryRoot::at(tmp.path());
1427 let sessions = root.list_sessions(Some("-proj")).expect("list");
1428 let s = sessions
1429 .iter()
1430 .find(|s| s.session_id == "real-shape")
1431 .unwrap();
1432 assert_eq!(s.title.as_deref(), Some("My Session"));
1433 }
1434
1435 #[test]
1436 fn session_summary_legacy_title_field_still_works() {
1437 let tmp = tempfile::tempdir().unwrap();
1439 let dir = tmp.path().join("-proj");
1440 fs::create_dir_all(&dir).unwrap();
1441 write_session(
1442 &dir,
1443 "legacy",
1444 &[
1445 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1446 r#"{"type":"ai-title","title":"Legacy Form"}"#,
1447 ],
1448 );
1449 let root = HistoryRoot::at(tmp.path());
1450 let sessions = root.list_sessions(Some("-proj")).expect("list");
1451 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1452 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1453 }
1454
1455 #[test]
1458 fn encode_path_slug_encodes_slash_and_dot() {
1459 assert_eq!(
1460 encode_path_slug("/Users/josh/Code/projA"),
1461 "-Users-josh-Code-projA"
1462 );
1463 assert_eq!(
1465 encode_path_slug("/private/var/folders/T/tmp.AbC"),
1466 "-private-var-folders-T-tmp-AbC"
1467 );
1468 }
1469
1470 #[test]
1471 fn project_slug_canonicalizes_and_encodes_dot() {
1472 let work = tempfile::tempdir().unwrap();
1473 let cwd = work.path().join("my.proj");
1474 fs::create_dir_all(&cwd).unwrap();
1475
1476 let slug = HistoryRoot::project_slug(&cwd);
1477 assert!(
1478 slug.contains("my-proj"),
1479 "dotted segment must encode '.' -> '-', got {slug}"
1480 );
1481 assert!(
1482 !slug.contains('.'),
1483 "no '.' may survive in the slug: {slug}"
1484 );
1485 assert!(
1486 !slug.contains('/'),
1487 "no '/' may survive in the slug: {slug}"
1488 );
1489 }
1490
1491 #[test]
1492 fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
1493 let projects = tempfile::tempdir().unwrap();
1498 let work = tempfile::tempdir().unwrap();
1499 let cwd = work.path().join("tmp.XYZ");
1500 fs::create_dir_all(&cwd).unwrap();
1501
1502 let canonical = fs::canonicalize(&cwd).unwrap();
1506 let expected_slug = encode_path_slug(&canonical.to_string_lossy());
1507 let proj_dir = projects.path().join(&expected_slug);
1508 fs::create_dir_all(&proj_dir).unwrap();
1509 write_session(
1510 &proj_dir,
1511 "sess-dot",
1512 &[
1513 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
1514 ],
1515 );
1516
1517 let root = HistoryRoot::at(projects.path());
1518 let sessions = root.sessions_for_path(&cwd).expect("enumerate");
1519 assert_eq!(
1520 sessions.len(),
1521 1,
1522 "should find the session for the dotted/symlinked cwd"
1523 );
1524 assert_eq!(sessions[0].session_id, "sess-dot");
1525 }
1526}