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 {
673 let arr = content.as_array()?;
674 let mut buf = String::new();
675 for block in arr {
676 let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
677 if ty == "text"
678 && let Some(t) = block.get("text").and_then(Value::as_str)
679 {
680 if !buf.is_empty() {
681 buf.push(' ');
682 }
683 buf.push_str(t);
684 }
685 }
686 buf
687 };
688 let one_line = raw
689 .split('\n')
690 .map(str::trim)
691 .filter(|l| !l.is_empty())
692 .collect::<Vec<_>>()
693 .join(" ");
694 if one_line.is_empty() {
695 return None;
696 }
697 let truncated: String = one_line.chars().take(max_chars).collect();
698 if truncated.len() < one_line.len() {
699 Some(format!("{truncated}..."))
700 } else {
701 Some(truncated)
702 }
703}
704
705fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
706 let file = fs::File::open(path)?;
707 let reader = BufReader::new(file);
708
709 let mut entries = Vec::new();
710 for (lineno, line) in reader.lines().enumerate() {
711 let line = match line {
712 Ok(l) => l,
713 Err(e) => {
714 tracing::warn!(
715 path = %path.display(),
716 line = lineno + 1,
717 error = %e,
718 "history: skipping unreadable line",
719 );
720 continue;
721 }
722 };
723 let trimmed = line.trim();
724 if trimmed.is_empty() {
725 continue;
726 }
727 match parse_entry(trimmed) {
728 Ok(entry) => entries.push(entry),
729 Err(e) => {
730 tracing::warn!(
731 path = %path.display(),
732 line = lineno + 1,
733 error = %e,
734 "history: skipping malformed line",
735 );
736 }
737 }
738 }
739 Ok(SessionLog {
740 session_id,
741 project_slug,
742 entries,
743 })
744}
745
746fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
747 let mut value: Value = serde_json::from_str(line)?;
748 let ty = value
749 .get("type")
750 .and_then(Value::as_str)
751 .unwrap_or("")
752 .to_string();
753 match ty.as_str() {
754 "user" => Ok(HistoryEntry::User {
755 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
756 timestamp: value
757 .get("timestamp")
758 .and_then(Value::as_str)
759 .map(String::from),
760 cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
761 git_branch: value
762 .get("gitBranch")
763 .and_then(Value::as_str)
764 .map(String::from),
765 message: value.get("message").cloned().unwrap_or(Value::Null),
766 rest: take_object(&mut value),
767 }),
768 "assistant" => Ok(HistoryEntry::Assistant {
769 uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
770 timestamp: value
771 .get("timestamp")
772 .and_then(Value::as_str)
773 .map(String::from),
774 message: value.get("message").cloned().unwrap_or(Value::Null),
775 rest: take_object(&mut value),
776 }),
777 other => Ok(HistoryEntry::Other {
778 type_tag: other.to_string(),
779 raw: value,
780 }),
781 }
782}
783
784fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
785 serde_json::Map::new()
790}
791
792fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
811 let body = slug.strip_prefix('-').unwrap_or(slug);
812 let mut segments = body.split('-');
813 let mut built_path = PathBuf::from("/");
814 let mut is_decode_verified = true;
815
816 let mut current_component = segments.next().unwrap_or("").to_string();
819
820 for next_segment in segments {
821 let hyphen_component = format!("{current_component}-{next_segment}");
822 let slash_exists = built_path.join(¤t_component).exists();
823 let hyphen_exists = built_path.join(&hyphen_component).exists();
824
825 if hyphen_exists {
830 current_component = hyphen_component;
831 } else {
832 if !slash_exists {
833 is_decode_verified = false;
834 }
835 built_path.push(¤t_component);
836 current_component = next_segment.to_string();
837 }
838 }
839
840 built_path.push(¤t_component);
841 (built_path, is_decode_verified)
842}
843
844fn encode_path_slug(path: &str) -> String {
854 path.chars()
855 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
856 .collect()
857}
858
859fn home_dir() -> Option<PathBuf> {
860 if let Ok(h) = std::env::var("HOME")
863 && !h.is_empty()
864 {
865 return Some(PathBuf::from(h));
866 }
867 if let Ok(h) = std::env::var("USERPROFILE")
868 && !h.is_empty()
869 {
870 return Some(PathBuf::from(h));
871 }
872 None
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use std::io::Write;
879
880 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
881 let path = dir.join(format!("{session_id}.jsonl"));
882 let mut f = fs::File::create(&path).expect("create jsonl");
883 for line in lines {
884 writeln!(f, "{line}").unwrap();
885 }
886 path
887 }
888
889 fn set_mtime(path: &Path, secs_since_epoch: u64) {
894 let f = fs::OpenOptions::new()
895 .write(true)
896 .open(path)
897 .expect("reopen for mtime");
898 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
899 f.set_modified(when).expect("set mtime");
900 }
901
902 fn fixture_root() -> tempfile::TempDir {
903 let tmp = tempfile::tempdir().expect("tempdir");
904 let a = tmp.path().join("-Users-josh-Code-projA");
906 fs::create_dir_all(&a).unwrap();
907 write_session(
908 &a,
909 "session-aaa",
910 &[
911 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
912 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
913 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
914 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
915 ],
916 );
917 write_session(
918 &a,
919 "session-bbb",
920 &[
921 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
922 ],
923 );
924 let b = tmp.path().join("-private-tmp-projB");
926 fs::create_dir_all(&b).unwrap();
927 write_session(
928 &b,
929 "session-ccc",
930 &[
931 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
932 r#"NOT VALID JSON"#,
933 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
934 ],
935 );
936 tmp
937 }
938
939 #[test]
940 fn list_projects_returns_directories_sorted_by_slug() {
941 let tmp = fixture_root();
942 let root = HistoryRoot::at(tmp.path());
943 let projects = root.list_projects().expect("list projects");
944 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
945 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
946 }
947
948 #[test]
949 fn list_projects_counts_sessions() {
950 let tmp = fixture_root();
951 let root = HistoryRoot::at(tmp.path());
952 let projects = root.list_projects().expect("list");
953 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
954 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
955 assert_eq!(a.session_count, 2);
956 assert_eq!(b.session_count, 1);
957 }
958
959 #[test]
960 fn list_projects_decodes_slug_to_filesystem_path() {
961 let tmp = fixture_root();
962 let root = HistoryRoot::at(tmp.path());
963 let projects = root.list_projects().expect("list");
964 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
965 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
966 }
967
968 #[test]
969 fn list_projects_returns_empty_when_root_missing() {
970 let tmp = tempfile::tempdir().unwrap();
971 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
972 let projects = root.list_projects().expect("ok");
973 assert!(projects.is_empty());
974 }
975
976 #[test]
977 fn list_sessions_filtered_by_slug() {
978 let tmp = fixture_root();
979 let root = HistoryRoot::at(tmp.path());
980 let sessions = root
981 .list_sessions(Some("-Users-josh-Code-projA"))
982 .expect("list");
983 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
984 assert_eq!(ids, ["session-aaa", "session-bbb"]);
985 assert!(
986 sessions
987 .iter()
988 .all(|s| s.project_slug == "-Users-josh-Code-projA")
989 );
990 }
991
992 #[test]
993 fn list_sessions_unfiltered_returns_union() {
994 let tmp = fixture_root();
995 let root = HistoryRoot::at(tmp.path());
996 let sessions = root.list_sessions(None).expect("list");
997 assert_eq!(sessions.len(), 3);
998 }
999
1000 #[test]
1001 fn session_summary_counts_only_user_and_assistant() {
1002 let tmp = fixture_root();
1003 let root = HistoryRoot::at(tmp.path());
1004 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
1005 let aaa = sessions
1006 .iter()
1007 .find(|s| s.session_id == "session-aaa")
1008 .unwrap();
1009 assert_eq!(aaa.message_count, 2);
1011 assert_eq!(aaa.title.as_deref(), Some("hello world"));
1012 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1013 }
1014
1015 #[test]
1016 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1017 let tmp = fixture_root();
1018 let root = HistoryRoot::at(tmp.path());
1019 let log = root.read_session("session-ccc").expect("read");
1020 assert_eq!(log.session_id, "session-ccc");
1021 assert_eq!(log.project_slug, "-private-tmp-projB");
1022 assert_eq!(log.entries.len(), 2);
1024 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1025 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1026 }
1027
1028 #[test]
1029 fn read_session_user_entry_carries_metadata() {
1030 let tmp = fixture_root();
1031 let root = HistoryRoot::at(tmp.path());
1032 let log = root.read_session("session-aaa").expect("read");
1033 match &log.entries[0] {
1034 HistoryEntry::User {
1035 uuid,
1036 timestamp,
1037 cwd,
1038 git_branch,
1039 ..
1040 } => {
1041 assert_eq!(uuid.as_deref(), Some("u1"));
1042 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1043 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1044 assert_eq!(git_branch.as_deref(), Some("main"));
1045 }
1046 other => panic!("expected User entry, got {other:?}"),
1047 }
1048 }
1049
1050 #[test]
1051 fn read_session_other_entry_preserves_type_tag_and_raw() {
1052 let tmp = fixture_root();
1053 let root = HistoryRoot::at(tmp.path());
1054 let log = root.read_session("session-aaa").expect("read");
1055 let queue_op = log
1057 .entries
1058 .iter()
1059 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1060 .expect("queue-operation entry");
1061 if let HistoryEntry::Other { raw, .. } = queue_op {
1062 assert_eq!(raw["operation"], "enqueue");
1063 }
1064 }
1065
1066 #[test]
1067 fn read_session_unknown_id_errors() {
1068 let tmp = fixture_root();
1069 let root = HistoryRoot::at(tmp.path());
1070 let err = root.read_session("not-a-real-session").unwrap_err();
1071 assert!(matches!(err, Error::History { .. }));
1072 assert!(format!("{err}").contains("no session with id"));
1073 }
1074
1075 #[test]
1076 fn find_session_returns_none_for_unknown_id() {
1077 let tmp = fixture_root();
1078 let root = HistoryRoot::at(tmp.path());
1079 let found = root.find_session("nope").expect("ok");
1080 assert!(found.is_none());
1081 }
1082
1083 #[test]
1084 fn find_session_locates_real_session() {
1085 let tmp = fixture_root();
1086 let root = HistoryRoot::at(tmp.path());
1087 let (path, slug) = root
1088 .find_session("session-ccc")
1089 .expect("ok")
1090 .expect("found");
1091 assert!(path.ends_with("session-ccc.jsonl"));
1092 assert_eq!(slug, "-private-tmp-projB");
1093 }
1094
1095 #[test]
1096 fn decode_slug_anchored_no_hyphens_in_components() {
1097 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1102 assert_eq!(path, PathBuf::from("/a/b/c/d"));
1103 }
1104
1105 #[test]
1106 fn decode_slug_anchored_single_hyphenated_segment() {
1107 let tmp = tempfile::tempdir().unwrap();
1109 let dir = tmp.path().join("foo-bar");
1110 fs::create_dir_all(&dir).unwrap();
1111 let tmp_str = tmp.path().to_string_lossy();
1112 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1113 let slug = format!("-{tmp_encoded}-foo-bar");
1114 let expected = tmp.path().join("foo-bar");
1115 let (decoded, is_verified) = decode_slug_anchored(&slug);
1116 assert_eq!(decoded, expected);
1117 assert!(is_verified);
1118 }
1119
1120 #[test]
1121 fn decode_slug_anchored_multiple_hyphenated_segments() {
1122 let tmp = tempfile::tempdir().unwrap();
1124 let dir = tmp.path().join("foo-bar").join("baz-qux");
1125 fs::create_dir_all(&dir).unwrap();
1126 let tmp_str = tmp.path().to_string_lossy();
1127 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1128 let slug = format!("-{tmp_encoded}-foo-bar-baz-qux");
1129 let expected = tmp.path().join("foo-bar").join("baz-qux");
1130 let (decoded, is_verified) = decode_slug_anchored(&slug);
1131 assert_eq!(decoded, expected);
1132 assert!(is_verified);
1133 }
1134
1135 #[test]
1136 fn decode_slug_anchored_fallback_when_nothing_exists() {
1137 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1139 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1140 assert!(!verified);
1141 }
1142
1143 #[test]
1144 fn decode_slug_anchored_real_world_issue_example() {
1145 let tmp = tempfile::tempdir().unwrap();
1150 let dir = tmp.path().join("rust").join("claude-wrapper");
1151 fs::create_dir_all(&dir).unwrap();
1152 let tmp_str = tmp.path().to_string_lossy();
1153 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1154 let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1155 let expected = tmp.path().join("rust").join("claude-wrapper");
1156 let (decoded, is_verified) = decode_slug_anchored(&slug);
1157 assert_eq!(decoded, expected);
1158 assert!(is_verified);
1159 }
1160
1161 fn paginated_fixture() -> tempfile::TempDir {
1166 let tmp = tempfile::tempdir().unwrap();
1167 for stem in ["-zzz-empty1", "-aaa-empty2"] {
1169 fs::create_dir_all(tmp.path().join(stem)).unwrap();
1170 }
1171 for (stem, ts, mtime) in [
1172 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1173 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1174 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1175 ] {
1176 let dir = tmp.path().join(stem);
1177 fs::create_dir_all(&dir).unwrap();
1178 let session_path = write_session(
1179 &dir,
1180 "s1",
1181 &[&format!(
1182 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1183 )],
1184 );
1185 set_mtime(&session_path, mtime);
1186 }
1187 tmp
1188 }
1189
1190 #[test]
1191 fn list_projects_with_include_empty_false_filters_them_out() {
1192 let tmp = paginated_fixture();
1193 let root = HistoryRoot::at(tmp.path());
1194 let projects = root
1195 .list_projects_with(&ListOptions {
1196 include_empty: false,
1197 ..Default::default()
1198 })
1199 .expect("list");
1200 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1201 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1203 }
1204
1205 #[test]
1206 fn list_projects_with_default_includes_empty_for_bc() {
1207 let tmp = paginated_fixture();
1210 let root = HistoryRoot::at(tmp.path());
1211 let projects = root
1212 .list_projects_with(&ListOptions::default())
1213 .expect("list");
1214 assert_eq!(projects.len(), 5);
1215 }
1216
1217 #[test]
1218 fn list_projects_zero_arg_preserves_legacy_inclusion() {
1219 let tmp = paginated_fixture();
1222 let root = HistoryRoot::at(tmp.path());
1223 let projects = root.list_projects().expect("list");
1224 assert_eq!(projects.len(), 5);
1225 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1226 assert_eq!(
1227 slugs,
1228 [
1229 "-aaa-empty2",
1230 "-bbb-proj",
1231 "-ccc-proj",
1232 "-ddd-proj",
1233 "-zzz-empty1",
1234 ]
1235 );
1236 }
1237
1238 #[test]
1239 fn list_projects_with_limit_caps_results() {
1240 let tmp = paginated_fixture();
1241 let root = HistoryRoot::at(tmp.path());
1242 let projects = root
1243 .list_projects_with(&ListOptions {
1244 limit: Some(2),
1245 include_empty: true,
1246 ..Default::default()
1247 })
1248 .expect("list");
1249 assert_eq!(projects.len(), 2);
1250 }
1251
1252 #[test]
1253 fn list_projects_with_offset_skips() {
1254 let tmp = paginated_fixture();
1255 let root = HistoryRoot::at(tmp.path());
1256 let projects = root
1257 .list_projects_with(&ListOptions {
1258 offset: 3,
1259 include_empty: true,
1260 ..Default::default()
1261 })
1262 .expect("list");
1263 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1266 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1267 }
1268
1269 #[test]
1270 fn list_projects_with_offset_past_end_returns_empty() {
1271 let tmp = paginated_fixture();
1272 let root = HistoryRoot::at(tmp.path());
1273 let projects = root
1274 .list_projects_with(&ListOptions {
1275 offset: 99,
1276 include_empty: true,
1277 ..Default::default()
1278 })
1279 .expect("list");
1280 assert!(projects.is_empty());
1281 }
1282
1283 #[test]
1284 fn list_projects_with_recency_desc_sort() {
1285 let tmp = paginated_fixture();
1286 let root = HistoryRoot::at(tmp.path());
1287 let projects = root
1291 .list_projects_with(&ListOptions {
1292 sort: ListSort::RecencyDesc,
1293 include_empty: false,
1294 ..Default::default()
1295 })
1296 .expect("list");
1297 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1298 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1299 }
1300
1301 #[test]
1302 fn list_sessions_with_include_empty_false_filters_zero_message() {
1303 let tmp = tempfile::tempdir().unwrap();
1304 let dir = tmp.path().join("-proj");
1305 fs::create_dir_all(&dir).unwrap();
1306 write_session(
1308 &dir,
1309 "real",
1310 &[
1311 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1312 ],
1313 );
1314 write_session(
1316 &dir,
1317 "orphan",
1318 &[
1319 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1320 ],
1321 );
1322 let root = HistoryRoot::at(tmp.path());
1323 let sessions = root
1324 .list_sessions_with(
1325 Some("-proj"),
1326 &ListOptions {
1327 include_empty: false,
1328 ..Default::default()
1329 },
1330 )
1331 .expect("list");
1332 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1333 assert_eq!(ids, ["real"]);
1334 }
1335
1336 #[test]
1337 fn list_sessions_with_default_returns_orphans_for_bc() {
1338 let tmp = tempfile::tempdir().unwrap();
1339 let dir = tmp.path().join("-proj");
1340 fs::create_dir_all(&dir).unwrap();
1341 write_session(
1342 &dir,
1343 "orphan",
1344 &[
1345 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1346 ],
1347 );
1348 let root = HistoryRoot::at(tmp.path());
1349 let sessions = root
1350 .list_sessions_with(Some("-proj"), &ListOptions::default())
1351 .expect("list");
1352 assert_eq!(sessions.len(), 1);
1353 assert_eq!(sessions[0].message_count, 0);
1354 }
1355
1356 #[test]
1357 fn list_sessions_with_recency_desc_sort() {
1358 let tmp = tempfile::tempdir().unwrap();
1359 let dir = tmp.path().join("-proj");
1360 fs::create_dir_all(&dir).unwrap();
1361 let old_p = write_session(
1362 &dir,
1363 "old",
1364 &[
1365 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1366 ],
1367 );
1368 let new_p = write_session(
1369 &dir,
1370 "new",
1371 &[
1372 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1373 ],
1374 );
1375 let mid_p = write_session(
1376 &dir,
1377 "mid",
1378 &[
1379 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1380 ],
1381 );
1382 set_mtime(&old_p, 1_700_000_000);
1383 set_mtime(&mid_p, 1_700_001_000);
1384 set_mtime(&new_p, 1_700_002_000);
1385 let root = HistoryRoot::at(tmp.path());
1386 let sessions = root
1387 .list_sessions_with(
1388 Some("-proj"),
1389 &ListOptions {
1390 sort: ListSort::RecencyDesc,
1391 ..Default::default()
1392 },
1393 )
1394 .expect("list");
1395 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1396 assert_eq!(ids, ["new", "mid", "old"]);
1397 }
1398
1399 #[test]
1400 fn list_sessions_with_limit_and_offset_combine() {
1401 let tmp = tempfile::tempdir().unwrap();
1402 let dir = tmp.path().join("-proj");
1403 fs::create_dir_all(&dir).unwrap();
1404 for i in 0..5 {
1405 write_session(
1406 &dir,
1407 &format!("s{i}"),
1408 &[&format!(
1409 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1410 )],
1411 );
1412 }
1413 let root = HistoryRoot::at(tmp.path());
1414 let sessions = root
1415 .list_sessions_with(
1416 Some("-proj"),
1417 &ListOptions {
1418 offset: 1,
1419 limit: Some(2),
1420 ..Default::default()
1421 },
1422 )
1423 .expect("list");
1424 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1425 assert_eq!(ids, ["s1", "s2"]);
1427 }
1428
1429 #[test]
1432 fn session_summary_parses_ai_title_camelcase() {
1433 let tmp = tempfile::tempdir().unwrap();
1436 let dir = tmp.path().join("-proj");
1437 fs::create_dir_all(&dir).unwrap();
1438 write_session(
1439 &dir,
1440 "real-shape",
1441 &[
1442 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1443 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1444 ],
1445 );
1446 let root = HistoryRoot::at(tmp.path());
1447 let sessions = root.list_sessions(Some("-proj")).expect("list");
1448 let s = sessions
1449 .iter()
1450 .find(|s| s.session_id == "real-shape")
1451 .unwrap();
1452 assert_eq!(s.title.as_deref(), Some("My Session"));
1453 }
1454
1455 #[test]
1456 fn session_summary_legacy_title_field_still_works() {
1457 let tmp = tempfile::tempdir().unwrap();
1459 let dir = tmp.path().join("-proj");
1460 fs::create_dir_all(&dir).unwrap();
1461 write_session(
1462 &dir,
1463 "legacy",
1464 &[
1465 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1466 r#"{"type":"ai-title","title":"Legacy Form"}"#,
1467 ],
1468 );
1469 let root = HistoryRoot::at(tmp.path());
1470 let sessions = root.list_sessions(Some("-proj")).expect("list");
1471 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1472 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1473 }
1474
1475 #[test]
1478 fn encode_path_slug_encodes_slash_and_dot() {
1479 assert_eq!(
1480 encode_path_slug("/Users/josh/Code/projA"),
1481 "-Users-josh-Code-projA"
1482 );
1483 assert_eq!(
1485 encode_path_slug("/private/var/folders/T/tmp.AbC"),
1486 "-private-var-folders-T-tmp-AbC"
1487 );
1488 assert_eq!(
1492 encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
1493 "-Users-me-genagent-claude-wrapper-ex"
1494 );
1495 assert_eq!(
1496 encode_path_slug("/Users/me/My Project (v2)"),
1497 "-Users-me-My-Project--v2-"
1498 );
1499 }
1500
1501 #[test]
1502 fn project_slug_canonicalizes_and_encodes_dot() {
1503 let work = tempfile::tempdir().unwrap();
1504 let cwd = work.path().join("my.proj");
1505 fs::create_dir_all(&cwd).unwrap();
1506
1507 let slug = HistoryRoot::project_slug(&cwd);
1508 assert!(
1509 slug.contains("my-proj"),
1510 "dotted segment must encode '.' -> '-', got {slug}"
1511 );
1512 assert!(
1513 !slug.contains('.'),
1514 "no '.' may survive in the slug: {slug}"
1515 );
1516 assert!(
1517 !slug.contains('/'),
1518 "no '/' may survive in the slug: {slug}"
1519 );
1520 }
1521
1522 #[test]
1523 fn project_slug_canonicalizes_and_encodes_underscore() {
1524 let work = tempfile::tempdir().unwrap();
1527 let cwd = work.path().join("claude_wrapper_ex");
1528 fs::create_dir_all(&cwd).unwrap();
1529
1530 let slug = HistoryRoot::project_slug(&cwd);
1531 assert!(
1532 slug.contains("claude-wrapper-ex"),
1533 "underscored segment must encode '_' -> '-', got {slug}"
1534 );
1535 assert!(
1536 !slug.contains('_'),
1537 "no '_' may survive in the slug: {slug}"
1538 );
1539 }
1540
1541 #[test]
1542 fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
1543 let projects = tempfile::tempdir().unwrap();
1548 let work = tempfile::tempdir().unwrap();
1549 let cwd = work.path().join("tmp.XYZ");
1550 fs::create_dir_all(&cwd).unwrap();
1551
1552 let canonical = fs::canonicalize(&cwd).unwrap();
1556 let expected_slug = encode_path_slug(&canonical.to_string_lossy());
1557 let proj_dir = projects.path().join(&expected_slug);
1558 fs::create_dir_all(&proj_dir).unwrap();
1559 write_session(
1560 &proj_dir,
1561 "sess-dot",
1562 &[
1563 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
1564 ],
1565 );
1566
1567 let root = HistoryRoot::at(projects.path());
1568 let sessions = root.sessions_for_path(&cwd).expect("enumerate");
1569 assert_eq!(
1570 sessions.len(),
1571 1,
1572 "should find the session for the dotted/symlinked cwd"
1573 );
1574 assert_eq!(sessions[0].session_id, "sess-dot");
1575 }
1576}