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