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) {
796 let body = slug.strip_prefix('-').unwrap_or(slug);
797 let mut segments = body.split('-');
798 let mut built_path = PathBuf::from("/");
799 let mut is_decode_verified = true;
800
801 let mut current_component = segments.next().unwrap_or("").to_string();
804
805 for next_segment in segments {
806 let hyphen_component = format!("{current_component}-{next_segment}");
807 let slash_exists = built_path.join(¤t_component).exists();
808 let hyphen_exists = built_path.join(&hyphen_component).exists();
809
810 if hyphen_exists {
815 current_component = hyphen_component;
816 } else {
817 if !slash_exists {
818 is_decode_verified = false;
819 }
820 built_path.push(¤t_component);
821 current_component = next_segment.to_string();
822 }
823 }
824
825 built_path.push(¤t_component);
826 (built_path, is_decode_verified)
827}
828
829fn encode_path_slug(path: &str) -> String {
839 path.chars()
840 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
841 .collect()
842}
843
844fn home_dir() -> Option<PathBuf> {
845 if let Ok(h) = std::env::var("HOME")
848 && !h.is_empty()
849 {
850 return Some(PathBuf::from(h));
851 }
852 if let Ok(h) = std::env::var("USERPROFILE")
853 && !h.is_empty()
854 {
855 return Some(PathBuf::from(h));
856 }
857 None
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use std::io::Write;
864
865 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
866 let path = dir.join(format!("{session_id}.jsonl"));
867 let mut f = fs::File::create(&path).expect("create jsonl");
868 for line in lines {
869 writeln!(f, "{line}").unwrap();
870 }
871 path
872 }
873
874 fn set_mtime(path: &Path, secs_since_epoch: u64) {
879 let f = fs::OpenOptions::new()
880 .write(true)
881 .open(path)
882 .expect("reopen for mtime");
883 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
884 f.set_modified(when).expect("set mtime");
885 }
886
887 fn fixture_root() -> tempfile::TempDir {
888 let tmp = tempfile::tempdir().expect("tempdir");
889 let a = tmp.path().join("-Users-josh-Code-projA");
891 fs::create_dir_all(&a).unwrap();
892 write_session(
893 &a,
894 "session-aaa",
895 &[
896 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
897 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
898 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
899 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
900 ],
901 );
902 write_session(
903 &a,
904 "session-bbb",
905 &[
906 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
907 ],
908 );
909 let b = tmp.path().join("-private-tmp-projB");
911 fs::create_dir_all(&b).unwrap();
912 write_session(
913 &b,
914 "session-ccc",
915 &[
916 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
917 r#"NOT VALID JSON"#,
918 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
919 ],
920 );
921 tmp
922 }
923
924 #[test]
925 fn list_projects_returns_directories_sorted_by_slug() {
926 let tmp = fixture_root();
927 let root = HistoryRoot::at(tmp.path());
928 let projects = root.list_projects().expect("list projects");
929 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
930 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
931 }
932
933 #[test]
934 fn list_projects_counts_sessions() {
935 let tmp = fixture_root();
936 let root = HistoryRoot::at(tmp.path());
937 let projects = root.list_projects().expect("list");
938 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
939 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
940 assert_eq!(a.session_count, 2);
941 assert_eq!(b.session_count, 1);
942 }
943
944 #[test]
945 fn list_projects_decodes_slug_to_filesystem_path() {
946 let tmp = fixture_root();
947 let root = HistoryRoot::at(tmp.path());
948 let projects = root.list_projects().expect("list");
949 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
950 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
951 }
952
953 #[test]
954 fn list_projects_returns_empty_when_root_missing() {
955 let tmp = tempfile::tempdir().unwrap();
956 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
957 let projects = root.list_projects().expect("ok");
958 assert!(projects.is_empty());
959 }
960
961 #[test]
962 fn list_sessions_filtered_by_slug() {
963 let tmp = fixture_root();
964 let root = HistoryRoot::at(tmp.path());
965 let sessions = root
966 .list_sessions(Some("-Users-josh-Code-projA"))
967 .expect("list");
968 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
969 assert_eq!(ids, ["session-aaa", "session-bbb"]);
970 assert!(
971 sessions
972 .iter()
973 .all(|s| s.project_slug == "-Users-josh-Code-projA")
974 );
975 }
976
977 #[test]
978 fn list_sessions_unfiltered_returns_union() {
979 let tmp = fixture_root();
980 let root = HistoryRoot::at(tmp.path());
981 let sessions = root.list_sessions(None).expect("list");
982 assert_eq!(sessions.len(), 3);
983 }
984
985 #[test]
986 fn session_summary_counts_only_user_and_assistant() {
987 let tmp = fixture_root();
988 let root = HistoryRoot::at(tmp.path());
989 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
990 let aaa = sessions
991 .iter()
992 .find(|s| s.session_id == "session-aaa")
993 .unwrap();
994 assert_eq!(aaa.message_count, 2);
996 assert_eq!(aaa.title.as_deref(), Some("hello world"));
997 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
998 }
999
1000 #[test]
1001 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1002 let tmp = fixture_root();
1003 let root = HistoryRoot::at(tmp.path());
1004 let log = root.read_session("session-ccc").expect("read");
1005 assert_eq!(log.session_id, "session-ccc");
1006 assert_eq!(log.project_slug, "-private-tmp-projB");
1007 assert_eq!(log.entries.len(), 2);
1009 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1010 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1011 }
1012
1013 #[test]
1014 fn read_session_user_entry_carries_metadata() {
1015 let tmp = fixture_root();
1016 let root = HistoryRoot::at(tmp.path());
1017 let log = root.read_session("session-aaa").expect("read");
1018 match &log.entries[0] {
1019 HistoryEntry::User {
1020 uuid,
1021 timestamp,
1022 cwd,
1023 git_branch,
1024 ..
1025 } => {
1026 assert_eq!(uuid.as_deref(), Some("u1"));
1027 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1028 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1029 assert_eq!(git_branch.as_deref(), Some("main"));
1030 }
1031 other => panic!("expected User entry, got {other:?}"),
1032 }
1033 }
1034
1035 #[test]
1036 fn read_session_other_entry_preserves_type_tag_and_raw() {
1037 let tmp = fixture_root();
1038 let root = HistoryRoot::at(tmp.path());
1039 let log = root.read_session("session-aaa").expect("read");
1040 let queue_op = log
1042 .entries
1043 .iter()
1044 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1045 .expect("queue-operation entry");
1046 if let HistoryEntry::Other { raw, .. } = queue_op {
1047 assert_eq!(raw["operation"], "enqueue");
1048 }
1049 }
1050
1051 #[test]
1052 fn read_session_unknown_id_errors() {
1053 let tmp = fixture_root();
1054 let root = HistoryRoot::at(tmp.path());
1055 let err = root.read_session("not-a-real-session").unwrap_err();
1056 assert!(matches!(err, Error::History { .. }));
1057 assert!(format!("{err}").contains("no session with id"));
1058 }
1059
1060 #[test]
1061 fn find_session_returns_none_for_unknown_id() {
1062 let tmp = fixture_root();
1063 let root = HistoryRoot::at(tmp.path());
1064 let found = root.find_session("nope").expect("ok");
1065 assert!(found.is_none());
1066 }
1067
1068 #[test]
1069 fn find_session_locates_real_session() {
1070 let tmp = fixture_root();
1071 let root = HistoryRoot::at(tmp.path());
1072 let (path, slug) = root
1073 .find_session("session-ccc")
1074 .expect("ok")
1075 .expect("found");
1076 assert!(path.ends_with("session-ccc.jsonl"));
1077 assert_eq!(slug, "-private-tmp-projB");
1078 }
1079
1080 #[test]
1081 fn decode_slug_anchored_no_hyphens_in_components() {
1082 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1087 assert_eq!(path, PathBuf::from("/a/b/c/d"));
1088 }
1089
1090 #[test]
1091 fn decode_slug_anchored_single_hyphenated_segment() {
1092 let tmp = tempfile::tempdir().unwrap();
1094 let dir = tmp.path().join("foo-bar");
1095 fs::create_dir_all(&dir).unwrap();
1096 let tmp_str = tmp.path().to_string_lossy();
1097 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1098 let slug = format!("-{tmp_encoded}-foo-bar");
1099 let expected = tmp.path().join("foo-bar");
1100 let (decoded, is_verified) = decode_slug_anchored(&slug);
1101 assert_eq!(decoded, expected);
1102 assert!(is_verified);
1103 }
1104
1105 #[test]
1106 fn decode_slug_anchored_multiple_hyphenated_segments() {
1107 let tmp = tempfile::tempdir().unwrap();
1109 let dir = tmp.path().join("foo-bar").join("baz-qux");
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-baz-qux");
1114 let expected = tmp.path().join("foo-bar").join("baz-qux");
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_fallback_when_nothing_exists() {
1122 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1124 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1125 assert!(!verified);
1126 }
1127
1128 #[test]
1129 fn decode_slug_anchored_real_world_issue_example() {
1130 let tmp = tempfile::tempdir().unwrap();
1135 let dir = tmp.path().join("rust").join("claude-wrapper");
1136 fs::create_dir_all(&dir).unwrap();
1137 let tmp_str = tmp.path().to_string_lossy();
1138 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1139 let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1140 let expected = tmp.path().join("rust").join("claude-wrapper");
1141 let (decoded, is_verified) = decode_slug_anchored(&slug);
1142 assert_eq!(decoded, expected);
1143 assert!(is_verified);
1144 }
1145
1146 fn paginated_fixture() -> tempfile::TempDir {
1151 let tmp = tempfile::tempdir().unwrap();
1152 for stem in ["-zzz-empty1", "-aaa-empty2"] {
1154 fs::create_dir_all(tmp.path().join(stem)).unwrap();
1155 }
1156 for (stem, ts, mtime) in [
1157 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1158 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1159 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1160 ] {
1161 let dir = tmp.path().join(stem);
1162 fs::create_dir_all(&dir).unwrap();
1163 let session_path = write_session(
1164 &dir,
1165 "s1",
1166 &[&format!(
1167 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1168 )],
1169 );
1170 set_mtime(&session_path, mtime);
1171 }
1172 tmp
1173 }
1174
1175 #[test]
1176 fn list_projects_with_include_empty_false_filters_them_out() {
1177 let tmp = paginated_fixture();
1178 let root = HistoryRoot::at(tmp.path());
1179 let projects = root
1180 .list_projects_with(&ListOptions {
1181 include_empty: false,
1182 ..Default::default()
1183 })
1184 .expect("list");
1185 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1186 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1188 }
1189
1190 #[test]
1191 fn list_projects_with_default_includes_empty_for_bc() {
1192 let tmp = paginated_fixture();
1195 let root = HistoryRoot::at(tmp.path());
1196 let projects = root
1197 .list_projects_with(&ListOptions::default())
1198 .expect("list");
1199 assert_eq!(projects.len(), 5);
1200 }
1201
1202 #[test]
1203 fn list_projects_zero_arg_preserves_legacy_inclusion() {
1204 let tmp = paginated_fixture();
1207 let root = HistoryRoot::at(tmp.path());
1208 let projects = root.list_projects().expect("list");
1209 assert_eq!(projects.len(), 5);
1210 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1211 assert_eq!(
1212 slugs,
1213 [
1214 "-aaa-empty2",
1215 "-bbb-proj",
1216 "-ccc-proj",
1217 "-ddd-proj",
1218 "-zzz-empty1",
1219 ]
1220 );
1221 }
1222
1223 #[test]
1224 fn list_projects_with_limit_caps_results() {
1225 let tmp = paginated_fixture();
1226 let root = HistoryRoot::at(tmp.path());
1227 let projects = root
1228 .list_projects_with(&ListOptions {
1229 limit: Some(2),
1230 include_empty: true,
1231 ..Default::default()
1232 })
1233 .expect("list");
1234 assert_eq!(projects.len(), 2);
1235 }
1236
1237 #[test]
1238 fn list_projects_with_offset_skips() {
1239 let tmp = paginated_fixture();
1240 let root = HistoryRoot::at(tmp.path());
1241 let projects = root
1242 .list_projects_with(&ListOptions {
1243 offset: 3,
1244 include_empty: true,
1245 ..Default::default()
1246 })
1247 .expect("list");
1248 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1251 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1252 }
1253
1254 #[test]
1255 fn list_projects_with_offset_past_end_returns_empty() {
1256 let tmp = paginated_fixture();
1257 let root = HistoryRoot::at(tmp.path());
1258 let projects = root
1259 .list_projects_with(&ListOptions {
1260 offset: 99,
1261 include_empty: true,
1262 ..Default::default()
1263 })
1264 .expect("list");
1265 assert!(projects.is_empty());
1266 }
1267
1268 #[test]
1269 fn list_projects_with_recency_desc_sort() {
1270 let tmp = paginated_fixture();
1271 let root = HistoryRoot::at(tmp.path());
1272 let projects = root
1276 .list_projects_with(&ListOptions {
1277 sort: ListSort::RecencyDesc,
1278 include_empty: false,
1279 ..Default::default()
1280 })
1281 .expect("list");
1282 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1283 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1284 }
1285
1286 #[test]
1287 fn list_sessions_with_include_empty_false_filters_zero_message() {
1288 let tmp = tempfile::tempdir().unwrap();
1289 let dir = tmp.path().join("-proj");
1290 fs::create_dir_all(&dir).unwrap();
1291 write_session(
1293 &dir,
1294 "real",
1295 &[
1296 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1297 ],
1298 );
1299 write_session(
1301 &dir,
1302 "orphan",
1303 &[
1304 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1305 ],
1306 );
1307 let root = HistoryRoot::at(tmp.path());
1308 let sessions = root
1309 .list_sessions_with(
1310 Some("-proj"),
1311 &ListOptions {
1312 include_empty: false,
1313 ..Default::default()
1314 },
1315 )
1316 .expect("list");
1317 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1318 assert_eq!(ids, ["real"]);
1319 }
1320
1321 #[test]
1322 fn list_sessions_with_default_returns_orphans_for_bc() {
1323 let tmp = tempfile::tempdir().unwrap();
1324 let dir = tmp.path().join("-proj");
1325 fs::create_dir_all(&dir).unwrap();
1326 write_session(
1327 &dir,
1328 "orphan",
1329 &[
1330 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1331 ],
1332 );
1333 let root = HistoryRoot::at(tmp.path());
1334 let sessions = root
1335 .list_sessions_with(Some("-proj"), &ListOptions::default())
1336 .expect("list");
1337 assert_eq!(sessions.len(), 1);
1338 assert_eq!(sessions[0].message_count, 0);
1339 }
1340
1341 #[test]
1342 fn list_sessions_with_recency_desc_sort() {
1343 let tmp = tempfile::tempdir().unwrap();
1344 let dir = tmp.path().join("-proj");
1345 fs::create_dir_all(&dir).unwrap();
1346 let old_p = write_session(
1347 &dir,
1348 "old",
1349 &[
1350 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1351 ],
1352 );
1353 let new_p = write_session(
1354 &dir,
1355 "new",
1356 &[
1357 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1358 ],
1359 );
1360 let mid_p = write_session(
1361 &dir,
1362 "mid",
1363 &[
1364 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1365 ],
1366 );
1367 set_mtime(&old_p, 1_700_000_000);
1368 set_mtime(&mid_p, 1_700_001_000);
1369 set_mtime(&new_p, 1_700_002_000);
1370 let root = HistoryRoot::at(tmp.path());
1371 let sessions = root
1372 .list_sessions_with(
1373 Some("-proj"),
1374 &ListOptions {
1375 sort: ListSort::RecencyDesc,
1376 ..Default::default()
1377 },
1378 )
1379 .expect("list");
1380 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1381 assert_eq!(ids, ["new", "mid", "old"]);
1382 }
1383
1384 #[test]
1385 fn list_sessions_with_limit_and_offset_combine() {
1386 let tmp = tempfile::tempdir().unwrap();
1387 let dir = tmp.path().join("-proj");
1388 fs::create_dir_all(&dir).unwrap();
1389 for i in 0..5 {
1390 write_session(
1391 &dir,
1392 &format!("s{i}"),
1393 &[&format!(
1394 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1395 )],
1396 );
1397 }
1398 let root = HistoryRoot::at(tmp.path());
1399 let sessions = root
1400 .list_sessions_with(
1401 Some("-proj"),
1402 &ListOptions {
1403 offset: 1,
1404 limit: Some(2),
1405 ..Default::default()
1406 },
1407 )
1408 .expect("list");
1409 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1410 assert_eq!(ids, ["s1", "s2"]);
1412 }
1413
1414 #[test]
1417 fn session_summary_parses_ai_title_camelcase() {
1418 let tmp = tempfile::tempdir().unwrap();
1421 let dir = tmp.path().join("-proj");
1422 fs::create_dir_all(&dir).unwrap();
1423 write_session(
1424 &dir,
1425 "real-shape",
1426 &[
1427 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1428 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1429 ],
1430 );
1431 let root = HistoryRoot::at(tmp.path());
1432 let sessions = root.list_sessions(Some("-proj")).expect("list");
1433 let s = sessions
1434 .iter()
1435 .find(|s| s.session_id == "real-shape")
1436 .unwrap();
1437 assert_eq!(s.title.as_deref(), Some("My Session"));
1438 }
1439
1440 #[test]
1441 fn session_summary_legacy_title_field_still_works() {
1442 let tmp = tempfile::tempdir().unwrap();
1444 let dir = tmp.path().join("-proj");
1445 fs::create_dir_all(&dir).unwrap();
1446 write_session(
1447 &dir,
1448 "legacy",
1449 &[
1450 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1451 r#"{"type":"ai-title","title":"Legacy Form"}"#,
1452 ],
1453 );
1454 let root = HistoryRoot::at(tmp.path());
1455 let sessions = root.list_sessions(Some("-proj")).expect("list");
1456 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1457 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1458 }
1459
1460 #[test]
1463 fn encode_path_slug_encodes_slash_and_dot() {
1464 assert_eq!(
1465 encode_path_slug("/Users/josh/Code/projA"),
1466 "-Users-josh-Code-projA"
1467 );
1468 assert_eq!(
1470 encode_path_slug("/private/var/folders/T/tmp.AbC"),
1471 "-private-var-folders-T-tmp-AbC"
1472 );
1473 assert_eq!(
1477 encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
1478 "-Users-me-genagent-claude-wrapper-ex"
1479 );
1480 assert_eq!(
1481 encode_path_slug("/Users/me/My Project (v2)"),
1482 "-Users-me-My-Project--v2-"
1483 );
1484 }
1485
1486 #[test]
1487 fn project_slug_canonicalizes_and_encodes_dot() {
1488 let work = tempfile::tempdir().unwrap();
1489 let cwd = work.path().join("my.proj");
1490 fs::create_dir_all(&cwd).unwrap();
1491
1492 let slug = HistoryRoot::project_slug(&cwd);
1493 assert!(
1494 slug.contains("my-proj"),
1495 "dotted segment must encode '.' -> '-', got {slug}"
1496 );
1497 assert!(
1498 !slug.contains('.'),
1499 "no '.' may survive in the slug: {slug}"
1500 );
1501 assert!(
1502 !slug.contains('/'),
1503 "no '/' may survive in the slug: {slug}"
1504 );
1505 }
1506
1507 #[test]
1508 fn project_slug_canonicalizes_and_encodes_underscore() {
1509 let work = tempfile::tempdir().unwrap();
1512 let cwd = work.path().join("claude_wrapper_ex");
1513 fs::create_dir_all(&cwd).unwrap();
1514
1515 let slug = HistoryRoot::project_slug(&cwd);
1516 assert!(
1517 slug.contains("claude-wrapper-ex"),
1518 "underscored segment must encode '_' -> '-', got {slug}"
1519 );
1520 assert!(
1521 !slug.contains('_'),
1522 "no '_' may survive in the slug: {slug}"
1523 );
1524 }
1525
1526 #[test]
1527 fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
1528 let projects = tempfile::tempdir().unwrap();
1533 let work = tempfile::tempdir().unwrap();
1534 let cwd = work.path().join("tmp.XYZ");
1535 fs::create_dir_all(&cwd).unwrap();
1536
1537 let canonical = fs::canonicalize(&cwd).unwrap();
1541 let expected_slug = encode_path_slug(&canonical.to_string_lossy());
1542 let proj_dir = projects.path().join(&expected_slug);
1543 fs::create_dir_all(&proj_dir).unwrap();
1544 write_session(
1545 &proj_dir,
1546 "sess-dot",
1547 &[
1548 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
1549 ],
1550 );
1551
1552 let root = HistoryRoot::at(projects.path());
1553 let sessions = root.sessions_for_path(&cwd).expect("enumerate");
1554 assert_eq!(
1555 sessions.len(),
1556 1,
1557 "should find the session for the dotted/symlinked cwd"
1558 );
1559 assert_eq!(sessions[0].session_id, "sess-dot");
1560 }
1561}