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,
459 pub project_slug: String,
461 pub entries: Vec<HistoryEntry>,
463}
464
465#[derive(Debug, Clone, Serialize)]
472#[serde(tag = "kind", rename_all = "snake_case")]
473pub enum HistoryEntry {
474 User {
476 uuid: Option<String>,
478 timestamp: Option<String>,
480 cwd: Option<String>,
482 git_branch: Option<String>,
484 message: Value,
486 #[serde(flatten)]
488 rest: serde_json::Map<String, Value>,
489 },
490 Assistant {
492 uuid: Option<String>,
494 timestamp: Option<String>,
496 message: Value,
498 #[serde(flatten)]
500 rest: serde_json::Map<String, Value>,
501 },
502 Other {
504 type_tag: String,
506 raw: Value,
508 },
509}
510
511fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
516 if opts.offset >= items.len() {
517 items.clear();
518 return;
519 }
520 if opts.offset > 0 {
521 items.drain(..opts.offset);
522 }
523 if let Some(lim) = opts.limit
524 && items.len() > lim
525 {
526 items.truncate(lim);
527 }
528}
529
530fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
531 let mut session_count = 0usize;
532 let mut last_modified: Option<SystemTime> = None;
533 if let Ok(entries) = fs::read_dir(dir) {
534 for entry in entries.flatten() {
535 let path = entry.path();
536 if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
537 session_count += 1;
538 if let Ok(meta) = entry.metadata()
539 && let Ok(mtime) = meta.modified()
540 {
541 last_modified = Some(match last_modified {
542 Some(prev) if prev > mtime => prev,
543 _ => mtime,
544 });
545 }
546 }
547 }
548 }
549 let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
550 ProjectSummary {
551 decoded_path,
552 is_decode_verified,
553 slug,
554 session_count,
555 last_modified,
556 }
557}
558
559fn summarize_session(
560 path: &Path,
561 session_id: String,
562 project_slug: String,
563) -> Option<SessionSummary> {
564 let meta = fs::metadata(path).ok()?;
565 let size_bytes = meta.len();
566
567 let file = fs::File::open(path).ok()?;
568 let reader = BufReader::new(file);
569
570 let mut message_count = 0usize;
571 let mut first_timestamp = None;
572 let mut last_timestamp = None;
573 let mut title = None;
574 let mut first_user_preview: Option<String> = None;
575 let mut total_cost_usd: Option<f64> = None;
576 let mut total_tokens: Option<u64> = None;
577
578 for line in reader.lines().map_while(std::io::Result::ok) {
579 let trimmed = line.trim();
580 if trimmed.is_empty() {
581 continue;
582 }
583 let v: Value = match serde_json::from_str(trimmed) {
584 Ok(v) => v,
585 Err(_) => continue,
586 };
587 let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
588 match ty {
589 "user" => {
590 message_count += 1;
591 if first_user_preview.is_none()
592 && let Some(p) = extract_user_text_preview(&v, 160)
593 {
594 first_user_preview = Some(p);
595 }
596 }
597 "assistant" => {
598 message_count += 1;
599 if let Some(c) = v
600 .get("message")
601 .and_then(|m| m.get("usage"))
602 .and_then(|u| u.get("total_cost_usd"))
603 .and_then(Value::as_f64)
604 {
605 *total_cost_usd.get_or_insert(0.0) += c;
606 }
607 if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
608 let mut t = 0u64;
610 for k in [
611 "input_tokens",
612 "output_tokens",
613 "cache_creation_input_tokens",
614 "cache_read_input_tokens",
615 ] {
616 if let Some(n) = usage.get(k).and_then(Value::as_u64) {
617 t += n;
618 }
619 }
620 if t > 0 {
621 *total_tokens.get_or_insert(0) += t;
622 }
623 }
624 }
625 "ai-title" => {
626 let candidate = v
630 .get("aiTitle")
631 .and_then(Value::as_str)
632 .or_else(|| v.get("title").and_then(Value::as_str));
633 if let Some(t) = candidate
634 && !t.is_empty()
635 {
636 title = Some(t.to_string());
637 }
638 }
639 _ => {}
640 }
641 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
642 if first_timestamp.is_none() {
643 first_timestamp = Some(ts.to_string());
644 }
645 last_timestamp = Some(ts.to_string());
646 }
647 }
648
649 Some(SessionSummary {
650 session_id,
651 project_slug,
652 message_count,
653 first_timestamp,
654 last_timestamp,
655 title,
656 first_user_preview,
657 total_cost_usd,
658 total_tokens,
659 size_bytes,
660 })
661}
662
663fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
669 let content = entry.get("message")?.get("content")?;
670 let raw = if let Some(s) = content.as_str() {
671 s.to_string()
672 } else if let Some(arr) = content.as_array() {
673 let mut buf = String::new();
674 for block in arr {
675 let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
676 if ty == "text"
677 && let Some(t) = block.get("text").and_then(Value::as_str)
678 {
679 if !buf.is_empty() {
680 buf.push(' ');
681 }
682 buf.push_str(t);
683 }
684 }
685 buf
686 } else {
687 return None;
688 };
689 let one_line = raw
690 .split('\n')
691 .map(str::trim)
692 .filter(|l| !l.is_empty())
693 .collect::<Vec<_>>()
694 .join(" ");
695 if one_line.is_empty() {
696 return None;
697 }
698 let truncated: String = one_line.chars().take(max_chars).collect();
699 if truncated.len() < one_line.len() {
700 Some(format!("{truncated}..."))
701 } else {
702 Some(truncated)
703 }
704}
705
706fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
707 let file = fs::File::open(path)?;
708 let reader = BufReader::new(file);
709
710 let mut entries = Vec::new();
711 for (lineno, line) in reader.lines().enumerate() {
712 let line = match line {
713 Ok(l) => l,
714 Err(e) => {
715 tracing::warn!(
716 path = %path.display(),
717 line = lineno + 1,
718 error = %e,
719 "history: skipping unreadable line",
720 );
721 continue;
722 }
723 };
724 let trimmed = line.trim();
725 if trimmed.is_empty() {
726 continue;
727 }
728 match parse_entry(trimmed) {
729 Ok(entry) => entries.push(entry),
730 Err(e) => {
731 tracing::warn!(
732 path = %path.display(),
733 line = lineno + 1,
734 error = %e,
735 "history: skipping malformed line",
736 );
737 }
738 }
739 }
740 Ok(SessionLog {
741 session_id,
742 project_slug,
743 entries,
744 })
745}
746
747fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
748 let mut value: Value = serde_json::from_str(line)?;
749 let ty = value
750 .get("type")
751 .and_then(Value::as_str)
752 .unwrap_or("")
753 .to_string();
754 match ty.as_str() {
755 "user" => Ok(HistoryEntry::User {
756 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
757 timestamp: value
758 .get("timestamp")
759 .and_then(Value::as_str)
760 .map(String::from),
761 cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
762 git_branch: value
763 .get("gitBranch")
764 .and_then(Value::as_str)
765 .map(String::from),
766 message: value.get("message").cloned().unwrap_or(Value::Null),
767 rest: take_object(&mut value),
768 }),
769 "assistant" => Ok(HistoryEntry::Assistant {
770 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
771 timestamp: value
772 .get("timestamp")
773 .and_then(Value::as_str)
774 .map(String::from),
775 message: value.get("message").cloned().unwrap_or(Value::Null),
776 rest: take_object(&mut value),
777 }),
778 other => Ok(HistoryEntry::Other {
779 type_tag: other.to_string(),
780 raw: value,
781 }),
782 }
783}
784
785fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
786 serde_json::Map::new()
791}
792
793fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
812 let body = slug.strip_prefix('-').unwrap_or(slug);
813 let mut segments = body.split('-');
814 let mut built_path = PathBuf::from("/");
815 let mut is_decode_verified = true;
816
817 let mut current_component = segments.next().unwrap_or("").to_string();
820
821 for next_segment in segments {
822 let hyphen_component = format!("{current_component}-{next_segment}");
823 let slash_exists = built_path.join(¤t_component).exists();
824 let hyphen_exists = built_path.join(&hyphen_component).exists();
825
826 if hyphen_exists {
831 current_component = hyphen_component;
832 } else {
833 if !slash_exists {
834 is_decode_verified = false;
835 }
836 built_path.push(¤t_component);
837 current_component = next_segment.to_string();
838 }
839 }
840
841 built_path.push(¤t_component);
842 (built_path, is_decode_verified)
843}
844
845fn encode_path_slug(path: &str) -> String {
855 path.chars()
856 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
857 .collect()
858}
859
860fn home_dir() -> Option<PathBuf> {
861 if let Ok(h) = std::env::var("HOME")
864 && !h.is_empty()
865 {
866 return Some(PathBuf::from(h));
867 }
868 if let Ok(h) = std::env::var("USERPROFILE")
869 && !h.is_empty()
870 {
871 return Some(PathBuf::from(h));
872 }
873 None
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use std::io::Write;
880
881 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
882 let path = dir.join(format!("{session_id}.jsonl"));
883 let mut f = fs::File::create(&path).expect("create jsonl");
884 for line in lines {
885 writeln!(f, "{line}").unwrap();
886 }
887 path
888 }
889
890 fn set_mtime(path: &Path, secs_since_epoch: u64) {
895 let f = fs::OpenOptions::new()
896 .write(true)
897 .open(path)
898 .expect("reopen for mtime");
899 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
900 f.set_modified(when).expect("set mtime");
901 }
902
903 fn fixture_root() -> tempfile::TempDir {
904 let tmp = tempfile::tempdir().expect("tempdir");
905 let a = tmp.path().join("-Users-josh-Code-projA");
907 fs::create_dir_all(&a).unwrap();
908 write_session(
909 &a,
910 "session-aaa",
911 &[
912 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
913 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
914 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
915 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
916 ],
917 );
918 write_session(
919 &a,
920 "session-bbb",
921 &[
922 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
923 ],
924 );
925 let b = tmp.path().join("-private-tmp-projB");
927 fs::create_dir_all(&b).unwrap();
928 write_session(
929 &b,
930 "session-ccc",
931 &[
932 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
933 r#"NOT VALID JSON"#,
934 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
935 ],
936 );
937 tmp
938 }
939
940 #[test]
941 fn list_projects_returns_directories_sorted_by_slug() {
942 let tmp = fixture_root();
943 let root = HistoryRoot::at(tmp.path());
944 let projects = root.list_projects().expect("list projects");
945 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
946 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
947 }
948
949 #[test]
950 fn list_projects_counts_sessions() {
951 let tmp = fixture_root();
952 let root = HistoryRoot::at(tmp.path());
953 let projects = root.list_projects().expect("list");
954 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
955 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
956 assert_eq!(a.session_count, 2);
957 assert_eq!(b.session_count, 1);
958 }
959
960 #[test]
961 fn list_projects_decodes_slug_to_filesystem_path() {
962 let tmp = fixture_root();
963 let root = HistoryRoot::at(tmp.path());
964 let projects = root.list_projects().expect("list");
965 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
966 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
967 }
968
969 #[test]
970 fn list_projects_returns_empty_when_root_missing() {
971 let tmp = tempfile::tempdir().unwrap();
972 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
973 let projects = root.list_projects().expect("ok");
974 assert!(projects.is_empty());
975 }
976
977 #[test]
978 fn list_sessions_filtered_by_slug() {
979 let tmp = fixture_root();
980 let root = HistoryRoot::at(tmp.path());
981 let sessions = root
982 .list_sessions(Some("-Users-josh-Code-projA"))
983 .expect("list");
984 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
985 assert_eq!(ids, ["session-aaa", "session-bbb"]);
986 assert!(
987 sessions
988 .iter()
989 .all(|s| s.project_slug == "-Users-josh-Code-projA")
990 );
991 }
992
993 #[test]
994 fn list_sessions_unfiltered_returns_union() {
995 let tmp = fixture_root();
996 let root = HistoryRoot::at(tmp.path());
997 let sessions = root.list_sessions(None).expect("list");
998 assert_eq!(sessions.len(), 3);
999 }
1000
1001 #[test]
1002 fn session_summary_counts_only_user_and_assistant() {
1003 let tmp = fixture_root();
1004 let root = HistoryRoot::at(tmp.path());
1005 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
1006 let aaa = sessions
1007 .iter()
1008 .find(|s| s.session_id == "session-aaa")
1009 .unwrap();
1010 assert_eq!(aaa.message_count, 2);
1012 assert_eq!(aaa.title.as_deref(), Some("hello world"));
1013 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1014 }
1015
1016 #[test]
1017 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1018 let tmp = fixture_root();
1019 let root = HistoryRoot::at(tmp.path());
1020 let log = root.read_session("session-ccc").expect("read");
1021 assert_eq!(log.session_id, "session-ccc");
1022 assert_eq!(log.project_slug, "-private-tmp-projB");
1023 assert_eq!(log.entries.len(), 2);
1025 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1026 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1027 }
1028
1029 #[test]
1030 fn read_session_user_entry_carries_metadata() {
1031 let tmp = fixture_root();
1032 let root = HistoryRoot::at(tmp.path());
1033 let log = root.read_session("session-aaa").expect("read");
1034 match &log.entries[0] {
1035 HistoryEntry::User {
1036 uuid,
1037 timestamp,
1038 cwd,
1039 git_branch,
1040 ..
1041 } => {
1042 assert_eq!(uuid.as_deref(), Some("u1"));
1043 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1044 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1045 assert_eq!(git_branch.as_deref(), Some("main"));
1046 }
1047 other => panic!("expected User entry, got {other:?}"),
1048 }
1049 }
1050
1051 #[test]
1052 fn read_session_other_entry_preserves_type_tag_and_raw() {
1053 let tmp = fixture_root();
1054 let root = HistoryRoot::at(tmp.path());
1055 let log = root.read_session("session-aaa").expect("read");
1056 let queue_op = log
1058 .entries
1059 .iter()
1060 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1061 .expect("queue-operation entry");
1062 if let HistoryEntry::Other { raw, .. } = queue_op {
1063 assert_eq!(raw["operation"], "enqueue");
1064 }
1065 }
1066
1067 #[test]
1068 fn read_session_unknown_id_errors() {
1069 let tmp = fixture_root();
1070 let root = HistoryRoot::at(tmp.path());
1071 let err = root.read_session("not-a-real-session").unwrap_err();
1072 assert!(matches!(err, Error::History { .. }));
1073 assert!(format!("{err}").contains("no session with id"));
1074 }
1075
1076 #[test]
1077 fn find_session_returns_none_for_unknown_id() {
1078 let tmp = fixture_root();
1079 let root = HistoryRoot::at(tmp.path());
1080 let found = root.find_session("nope").expect("ok");
1081 assert!(found.is_none());
1082 }
1083
1084 #[test]
1085 fn find_session_locates_real_session() {
1086 let tmp = fixture_root();
1087 let root = HistoryRoot::at(tmp.path());
1088 let (path, slug) = root
1089 .find_session("session-ccc")
1090 .expect("ok")
1091 .expect("found");
1092 assert!(path.ends_with("session-ccc.jsonl"));
1093 assert_eq!(slug, "-private-tmp-projB");
1094 }
1095
1096 #[test]
1097 fn decode_slug_anchored_no_hyphens_in_components() {
1098 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1103 assert_eq!(path, PathBuf::from("/a/b/c/d"));
1104 }
1105
1106 #[test]
1107 fn decode_slug_anchored_single_hyphenated_segment() {
1108 let tmp = tempfile::tempdir().unwrap();
1110 let dir = tmp.path().join("foo-bar");
1111 fs::create_dir_all(&dir).unwrap();
1112 let tmp_str = tmp.path().to_string_lossy();
1113 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1114 let slug = format!("-{tmp_encoded}-foo-bar");
1115 let expected = tmp.path().join("foo-bar");
1116 let (decoded, is_verified) = decode_slug_anchored(&slug);
1117 assert_eq!(decoded, expected);
1118 assert!(is_verified);
1119 }
1120
1121 #[test]
1122 fn decode_slug_anchored_multiple_hyphenated_segments() {
1123 let tmp = tempfile::tempdir().unwrap();
1125 let dir = tmp.path().join("foo-bar").join("baz-qux");
1126 fs::create_dir_all(&dir).unwrap();
1127 let tmp_str = tmp.path().to_string_lossy();
1128 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1129 let slug = format!("-{tmp_encoded}-foo-bar-baz-qux");
1130 let expected = tmp.path().join("foo-bar").join("baz-qux");
1131 let (decoded, is_verified) = decode_slug_anchored(&slug);
1132 assert_eq!(decoded, expected);
1133 assert!(is_verified);
1134 }
1135
1136 #[test]
1137 fn decode_slug_anchored_fallback_when_nothing_exists() {
1138 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1140 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1141 assert!(!verified);
1142 }
1143
1144 #[test]
1145 fn decode_slug_anchored_real_world_issue_example() {
1146 let tmp = tempfile::tempdir().unwrap();
1151 let dir = tmp.path().join("rust").join("claude-wrapper");
1152 fs::create_dir_all(&dir).unwrap();
1153 let tmp_str = tmp.path().to_string_lossy();
1154 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1155 let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1156 let expected = tmp.path().join("rust").join("claude-wrapper");
1157 let (decoded, is_verified) = decode_slug_anchored(&slug);
1158 assert_eq!(decoded, expected);
1159 assert!(is_verified);
1160 }
1161
1162 fn paginated_fixture() -> tempfile::TempDir {
1167 let tmp = tempfile::tempdir().unwrap();
1168 for stem in ["-zzz-empty1", "-aaa-empty2"] {
1170 fs::create_dir_all(tmp.path().join(stem)).unwrap();
1171 }
1172 for (stem, ts, mtime) in [
1173 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1174 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1175 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1176 ] {
1177 let dir = tmp.path().join(stem);
1178 fs::create_dir_all(&dir).unwrap();
1179 let session_path = write_session(
1180 &dir,
1181 "s1",
1182 &[&format!(
1183 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1184 )],
1185 );
1186 set_mtime(&session_path, mtime);
1187 }
1188 tmp
1189 }
1190
1191 #[test]
1192 fn list_projects_with_include_empty_false_filters_them_out() {
1193 let tmp = paginated_fixture();
1194 let root = HistoryRoot::at(tmp.path());
1195 let projects = root
1196 .list_projects_with(&ListOptions {
1197 include_empty: false,
1198 ..Default::default()
1199 })
1200 .expect("list");
1201 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1202 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1204 }
1205
1206 #[test]
1207 fn list_projects_with_default_includes_empty_for_bc() {
1208 let tmp = paginated_fixture();
1211 let root = HistoryRoot::at(tmp.path());
1212 let projects = root
1213 .list_projects_with(&ListOptions::default())
1214 .expect("list");
1215 assert_eq!(projects.len(), 5);
1216 }
1217
1218 #[test]
1219 fn list_projects_zero_arg_preserves_legacy_inclusion() {
1220 let tmp = paginated_fixture();
1223 let root = HistoryRoot::at(tmp.path());
1224 let projects = root.list_projects().expect("list");
1225 assert_eq!(projects.len(), 5);
1226 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1227 assert_eq!(
1228 slugs,
1229 [
1230 "-aaa-empty2",
1231 "-bbb-proj",
1232 "-ccc-proj",
1233 "-ddd-proj",
1234 "-zzz-empty1",
1235 ]
1236 );
1237 }
1238
1239 #[test]
1240 fn list_projects_with_limit_caps_results() {
1241 let tmp = paginated_fixture();
1242 let root = HistoryRoot::at(tmp.path());
1243 let projects = root
1244 .list_projects_with(&ListOptions {
1245 limit: Some(2),
1246 include_empty: true,
1247 ..Default::default()
1248 })
1249 .expect("list");
1250 assert_eq!(projects.len(), 2);
1251 }
1252
1253 #[test]
1254 fn list_projects_with_offset_skips() {
1255 let tmp = paginated_fixture();
1256 let root = HistoryRoot::at(tmp.path());
1257 let projects = root
1258 .list_projects_with(&ListOptions {
1259 offset: 3,
1260 include_empty: true,
1261 ..Default::default()
1262 })
1263 .expect("list");
1264 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1267 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1268 }
1269
1270 #[test]
1271 fn list_projects_with_offset_past_end_returns_empty() {
1272 let tmp = paginated_fixture();
1273 let root = HistoryRoot::at(tmp.path());
1274 let projects = root
1275 .list_projects_with(&ListOptions {
1276 offset: 99,
1277 include_empty: true,
1278 ..Default::default()
1279 })
1280 .expect("list");
1281 assert!(projects.is_empty());
1282 }
1283
1284 #[test]
1285 fn list_projects_with_recency_desc_sort() {
1286 let tmp = paginated_fixture();
1287 let root = HistoryRoot::at(tmp.path());
1288 let projects = root
1292 .list_projects_with(&ListOptions {
1293 sort: ListSort::RecencyDesc,
1294 include_empty: false,
1295 ..Default::default()
1296 })
1297 .expect("list");
1298 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1299 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1300 }
1301
1302 #[test]
1303 fn list_sessions_with_include_empty_false_filters_zero_message() {
1304 let tmp = tempfile::tempdir().unwrap();
1305 let dir = tmp.path().join("-proj");
1306 fs::create_dir_all(&dir).unwrap();
1307 write_session(
1309 &dir,
1310 "real",
1311 &[
1312 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1313 ],
1314 );
1315 write_session(
1317 &dir,
1318 "orphan",
1319 &[
1320 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1321 ],
1322 );
1323 let root = HistoryRoot::at(tmp.path());
1324 let sessions = root
1325 .list_sessions_with(
1326 Some("-proj"),
1327 &ListOptions {
1328 include_empty: false,
1329 ..Default::default()
1330 },
1331 )
1332 .expect("list");
1333 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1334 assert_eq!(ids, ["real"]);
1335 }
1336
1337 #[test]
1338 fn list_sessions_with_default_returns_orphans_for_bc() {
1339 let tmp = tempfile::tempdir().unwrap();
1340 let dir = tmp.path().join("-proj");
1341 fs::create_dir_all(&dir).unwrap();
1342 write_session(
1343 &dir,
1344 "orphan",
1345 &[
1346 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1347 ],
1348 );
1349 let root = HistoryRoot::at(tmp.path());
1350 let sessions = root
1351 .list_sessions_with(Some("-proj"), &ListOptions::default())
1352 .expect("list");
1353 assert_eq!(sessions.len(), 1);
1354 assert_eq!(sessions[0].message_count, 0);
1355 }
1356
1357 #[test]
1358 fn list_sessions_with_recency_desc_sort() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 let dir = tmp.path().join("-proj");
1361 fs::create_dir_all(&dir).unwrap();
1362 let old_p = write_session(
1363 &dir,
1364 "old",
1365 &[
1366 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1367 ],
1368 );
1369 let new_p = write_session(
1370 &dir,
1371 "new",
1372 &[
1373 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1374 ],
1375 );
1376 let mid_p = write_session(
1377 &dir,
1378 "mid",
1379 &[
1380 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1381 ],
1382 );
1383 set_mtime(&old_p, 1_700_000_000);
1384 set_mtime(&mid_p, 1_700_001_000);
1385 set_mtime(&new_p, 1_700_002_000);
1386 let root = HistoryRoot::at(tmp.path());
1387 let sessions = root
1388 .list_sessions_with(
1389 Some("-proj"),
1390 &ListOptions {
1391 sort: ListSort::RecencyDesc,
1392 ..Default::default()
1393 },
1394 )
1395 .expect("list");
1396 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1397 assert_eq!(ids, ["new", "mid", "old"]);
1398 }
1399
1400 #[test]
1401 fn list_sessions_with_limit_and_offset_combine() {
1402 let tmp = tempfile::tempdir().unwrap();
1403 let dir = tmp.path().join("-proj");
1404 fs::create_dir_all(&dir).unwrap();
1405 for i in 0..5 {
1406 write_session(
1407 &dir,
1408 &format!("s{i}"),
1409 &[&format!(
1410 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1411 )],
1412 );
1413 }
1414 let root = HistoryRoot::at(tmp.path());
1415 let sessions = root
1416 .list_sessions_with(
1417 Some("-proj"),
1418 &ListOptions {
1419 offset: 1,
1420 limit: Some(2),
1421 ..Default::default()
1422 },
1423 )
1424 .expect("list");
1425 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1426 assert_eq!(ids, ["s1", "s2"]);
1428 }
1429
1430 #[test]
1433 fn session_summary_parses_ai_title_camelcase() {
1434 let tmp = tempfile::tempdir().unwrap();
1437 let dir = tmp.path().join("-proj");
1438 fs::create_dir_all(&dir).unwrap();
1439 write_session(
1440 &dir,
1441 "real-shape",
1442 &[
1443 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1444 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1445 ],
1446 );
1447 let root = HistoryRoot::at(tmp.path());
1448 let sessions = root.list_sessions(Some("-proj")).expect("list");
1449 let s = sessions
1450 .iter()
1451 .find(|s| s.session_id == "real-shape")
1452 .unwrap();
1453 assert_eq!(s.title.as_deref(), Some("My Session"));
1454 }
1455
1456 #[test]
1457 fn session_summary_legacy_title_field_still_works() {
1458 let tmp = tempfile::tempdir().unwrap();
1460 let dir = tmp.path().join("-proj");
1461 fs::create_dir_all(&dir).unwrap();
1462 write_session(
1463 &dir,
1464 "legacy",
1465 &[
1466 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1467 r#"{"type":"ai-title","title":"Legacy Form"}"#,
1468 ],
1469 );
1470 let root = HistoryRoot::at(tmp.path());
1471 let sessions = root.list_sessions(Some("-proj")).expect("list");
1472 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1473 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1474 }
1475
1476 #[test]
1479 fn encode_path_slug_encodes_slash_and_dot() {
1480 assert_eq!(
1481 encode_path_slug("/Users/josh/Code/projA"),
1482 "-Users-josh-Code-projA"
1483 );
1484 assert_eq!(
1486 encode_path_slug("/private/var/folders/T/tmp.AbC"),
1487 "-private-var-folders-T-tmp-AbC"
1488 );
1489 assert_eq!(
1493 encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
1494 "-Users-me-genagent-claude-wrapper-ex"
1495 );
1496 assert_eq!(
1497 encode_path_slug("/Users/me/My Project (v2)"),
1498 "-Users-me-My-Project--v2-"
1499 );
1500 }
1501
1502 #[test]
1503 fn project_slug_canonicalizes_and_encodes_dot() {
1504 let work = tempfile::tempdir().unwrap();
1505 let cwd = work.path().join("my.proj");
1506 fs::create_dir_all(&cwd).unwrap();
1507
1508 let slug = HistoryRoot::project_slug(&cwd);
1509 assert!(
1510 slug.contains("my-proj"),
1511 "dotted segment must encode '.' -> '-', got {slug}"
1512 );
1513 assert!(
1514 !slug.contains('.'),
1515 "no '.' may survive in the slug: {slug}"
1516 );
1517 assert!(
1518 !slug.contains('/'),
1519 "no '/' may survive in the slug: {slug}"
1520 );
1521 }
1522
1523 #[test]
1524 fn project_slug_canonicalizes_and_encodes_underscore() {
1525 let work = tempfile::tempdir().unwrap();
1528 let cwd = work.path().join("claude_wrapper_ex");
1529 fs::create_dir_all(&cwd).unwrap();
1530
1531 let slug = HistoryRoot::project_slug(&cwd);
1532 assert!(
1533 slug.contains("claude-wrapper-ex"),
1534 "underscored segment must encode '_' -> '-', got {slug}"
1535 );
1536 assert!(
1537 !slug.contains('_'),
1538 "no '_' may survive in the slug: {slug}"
1539 );
1540 }
1541
1542 #[test]
1543 fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
1544 let projects = tempfile::tempdir().unwrap();
1549 let work = tempfile::tempdir().unwrap();
1550 let cwd = work.path().join("tmp.XYZ");
1551 fs::create_dir_all(&cwd).unwrap();
1552
1553 let canonical = fs::canonicalize(&cwd).unwrap();
1557 let expected_slug = encode_path_slug(&canonical.to_string_lossy());
1558 let proj_dir = projects.path().join(&expected_slug);
1559 fs::create_dir_all(&proj_dir).unwrap();
1560 write_session(
1561 &proj_dir,
1562 "sess-dot",
1563 &[
1564 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
1565 ],
1566 );
1567
1568 let root = HistoryRoot::at(projects.path());
1569 let sessions = root.sessions_for_path(&cwd).expect("enumerate");
1570 assert_eq!(
1571 sessions.len(),
1572 1,
1573 "should find the session for the dotted/symlinked cwd"
1574 );
1575 assert_eq!(sessions[0].session_id, "sess-dot");
1576 }
1577}