1use std::fs;
93use std::io::{BufRead, BufReader};
94use std::path::{Path, PathBuf};
95use std::time::SystemTime;
96
97use serde::Serialize;
98use serde_json::Value;
99
100use crate::error::{Error, Result};
101
102#[derive(Debug, Clone)]
106pub struct HistoryRoot {
107 path: PathBuf,
108}
109
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub enum ListSort {
114 #[default]
120 NameAsc,
121 RecencyDesc,
128}
129
130#[derive(Debug, Clone)]
138pub struct ListOptions {
139 pub limit: Option<usize>,
141 pub offset: usize,
144 pub include_empty: bool,
154 pub sort: ListSort,
156}
157
158impl Default for ListOptions {
159 fn default() -> Self {
160 Self {
161 limit: None,
162 offset: 0,
163 include_empty: true,
164 sort: ListSort::default(),
165 }
166 }
167}
168
169impl HistoryRoot {
170 pub fn home() -> Result<Self> {
173 let home = home_dir().ok_or_else(|| Error::History {
174 message: "could not determine user home directory".to_string(),
175 })?;
176 Ok(Self {
177 path: home.join(".claude").join("projects"),
178 })
179 }
180
181 pub fn at(path: impl Into<PathBuf>) -> Self {
184 Self { path: path.into() }
185 }
186
187 pub fn path(&self) -> &Path {
189 &self.path
190 }
191
192 pub fn list_projects(&self) -> Result<Vec<ProjectSummary>> {
202 self.list_projects_with(&ListOptions::default())
203 }
204
205 pub fn list_projects_with(&self, opts: &ListOptions) -> Result<Vec<ProjectSummary>> {
219 let entries = match fs::read_dir(&self.path) {
220 Ok(it) => it,
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
222 Err(e) => return Err(e.into()),
223 };
224
225 let mut out = Vec::new();
226 for entry in entries.flatten() {
227 let ft = match entry.file_type() {
228 Ok(ft) => ft,
229 Err(_) => continue,
230 };
231 if !ft.is_dir() {
232 continue;
233 }
234 let slug = entry.file_name().to_string_lossy().into_owned();
235 let summary = summarize_project(&entry.path(), slug);
236 if !opts.include_empty && summary.session_count == 0 {
237 continue;
238 }
239 out.push(summary);
240 }
241 match opts.sort {
242 ListSort::NameAsc => out.sort_by(|a, b| a.slug.cmp(&b.slug)),
243 ListSort::RecencyDesc => out.sort_by(|a, b| {
244 match (a.last_modified, b.last_modified) {
246 (Some(am), Some(bm)) => bm.cmp(&am),
247 (Some(_), None) => std::cmp::Ordering::Less,
248 (None, Some(_)) => std::cmp::Ordering::Greater,
249 (None, None) => a.slug.cmp(&b.slug),
250 }
251 }),
252 }
253 apply_offset_limit(&mut out, opts);
254 Ok(out)
255 }
256
257 pub fn list_sessions(&self, slug: Option<&str>) -> Result<Vec<SessionSummary>> {
263 self.list_sessions_with(slug, &ListOptions::default())
264 }
265
266 pub fn list_sessions_with(
274 &self,
275 slug: Option<&str>,
276 opts: &ListOptions,
277 ) -> Result<Vec<SessionSummary>> {
278 let enumerate_opts = ListOptions {
281 include_empty: true,
282 ..ListOptions::default()
283 };
284 let project_dirs = match slug {
285 Some(s) => vec![self.path.join(s)],
286 None => self
287 .list_projects_with(&enumerate_opts)?
288 .into_iter()
289 .map(|p| self.path.join(&p.slug))
290 .collect(),
291 };
292
293 let mut out = Vec::new();
294 for dir in project_dirs {
295 let project_slug = dir
296 .file_name()
297 .map(|n| n.to_string_lossy().into_owned())
298 .unwrap_or_default();
299 let entries = match fs::read_dir(&dir) {
300 Ok(it) => it,
301 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
302 Err(e) => return Err(e.into()),
303 };
304 for entry in entries.flatten() {
305 let path = entry.path();
306 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
307 continue;
308 }
309 let Some(session_id) = path
310 .file_stem()
311 .and_then(|s| s.to_str())
312 .map(str::to_string)
313 else {
314 continue;
315 };
316 if let Some(summary) = summarize_session(&path, session_id, project_slug.clone()) {
317 if !opts.include_empty && summary.message_count == 0 {
318 continue;
319 }
320 out.push(summary);
321 }
322 }
323 }
324 match opts.sort {
325 ListSort::NameAsc => out.sort_by(|a, b| a.session_id.cmp(&b.session_id)),
326 ListSort::RecencyDesc => out.sort_by(|a, b| {
327 match (a.last_timestamp.as_deref(), b.last_timestamp.as_deref()) {
330 (Some(at), Some(bt)) => bt.cmp(at),
331 (Some(_), None) => std::cmp::Ordering::Less,
332 (None, Some(_)) => std::cmp::Ordering::Greater,
333 (None, None) => a.session_id.cmp(&b.session_id),
334 }
335 }),
336 }
337 apply_offset_limit(&mut out, opts);
338 Ok(out)
339 }
340
341 #[must_use]
357 pub fn project_slug(path: impl AsRef<Path>) -> String {
358 let path = path.as_ref();
359 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
360 encode_path_slug(&canonical.to_string_lossy())
361 }
362
363 pub fn sessions_for_path(&self, cwd: impl AsRef<Path>) -> Result<Vec<SessionSummary>> {
372 self.sessions_for_path_with(cwd, &ListOptions::default())
373 }
374
375 pub fn sessions_for_path_with(
377 &self,
378 cwd: impl AsRef<Path>,
379 opts: &ListOptions,
380 ) -> Result<Vec<SessionSummary>> {
381 let slug = Self::project_slug(cwd);
382 self.list_sessions_with(Some(&slug), opts)
383 }
384
385 pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
391 let (path, project_slug) =
392 self.find_session(session_id)?
393 .ok_or_else(|| Error::History {
394 message: format!(
395 "no session with id `{session_id}` under {}",
396 self.path.display()
397 ),
398 })?;
399 parse_session(&path, session_id.to_string(), project_slug)
400 }
401
402 pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
408 for project in self.list_projects()? {
409 let candidate = self
410 .path
411 .join(&project.slug)
412 .join(format!("{session_id}.jsonl"));
413 if candidate.is_file() {
414 return Ok(Some((candidate, project.slug)));
415 }
416 }
417 Ok(None)
418 }
419
420 pub fn list_subagents(&self, session_id: &str) -> Result<Vec<SubagentSummary>> {
427 let Some(dir) = self.session_dir(session_id)? else {
428 return Ok(Vec::new());
429 };
430 let mut out = Vec::new();
431 for path in list_files_with_extension(&dir.join("subagents"), "jsonl") {
432 let stem = match path.file_stem().and_then(|s| s.to_str()) {
434 Some(s) => s,
435 None => continue,
436 };
437 let agent_id = stem.strip_prefix("agent-").unwrap_or(stem).to_string();
438 let meta = read_subagent_meta(&path);
439 out.push(SubagentSummary {
440 agent_id,
441 path,
442 meta,
443 });
444 }
445 out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
446 Ok(out)
447 }
448
449 pub fn read_subagent(&self, session_id: &str, agent_id: &str) -> Result<Vec<HistoryEntry>> {
458 let found = self
459 .list_subagents(session_id)?
460 .into_iter()
461 .find(|s| s.agent_id == agent_id)
462 .ok_or_else(|| Error::History {
463 message: format!("no subagent with id `{agent_id}` for session `{session_id}`"),
464 })?;
465 parse_jsonl_entries(&found.path)
466 }
467
468 pub fn list_tool_results(&self, session_id: &str) -> Result<Vec<ToolResultSummary>> {
476 let Some(dir) = self.session_dir(session_id)? else {
477 return Ok(Vec::new());
478 };
479 let mut out = Vec::new();
480 for path in list_files_with_extension(&dir.join("tool-results"), "txt") {
481 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
482 continue;
483 };
484 let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
485 out.push(ToolResultSummary {
486 tool_use_id: stem.to_string(),
487 path,
488 size_bytes,
489 });
490 }
491 out.sort_by(|a, b| a.tool_use_id.cmp(&b.tool_use_id));
492 Ok(out)
493 }
494
495 pub fn read_tool_result(&self, session_id: &str, tool_use_id: &str) -> Result<String> {
500 let found = self
501 .list_tool_results(session_id)?
502 .into_iter()
503 .find(|t| t.tool_use_id == tool_use_id)
504 .ok_or_else(|| Error::History {
505 message: format!(
506 "no tool result with id `{tool_use_id}` for session `{session_id}`"
507 ),
508 })?;
509 Ok(fs::read_to_string(&found.path)?)
510 }
511
512 pub fn list_workflows(&self, session_id: &str) -> Result<Vec<WorkflowSummary>> {
521 let Some(dir) = self.session_dir(session_id)? else {
522 return Ok(Vec::new());
523 };
524 let workflows_dir = dir.join("workflows");
525 let scripts: Vec<PathBuf> = list_files_with_extension(&workflows_dir.join("scripts"), "js");
526 let mut out = Vec::new();
527 for path in list_files_with_extension(&workflows_dir, "json") {
528 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
529 continue;
530 };
531 let workflow_id = stem.to_string();
532 let script_suffix = format!("-{workflow_id}.js");
533 let script_path = scripts
534 .iter()
535 .find(|p| {
536 p.file_name()
537 .and_then(|n| n.to_str())
538 .is_some_and(|n| n.ends_with(&script_suffix))
539 })
540 .cloned();
541 let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
542 out.push(WorkflowSummary {
543 workflow_id,
544 path,
545 script_path,
546 size_bytes,
547 });
548 }
549 out.sort_by(|a, b| a.workflow_id.cmp(&b.workflow_id));
550 Ok(out)
551 }
552
553 pub fn read_workflow(&self, session_id: &str, workflow_id: &str) -> Result<Value> {
560 let found = self
561 .list_workflows(session_id)?
562 .into_iter()
563 .find(|w| w.workflow_id == workflow_id)
564 .ok_or_else(|| Error::History {
565 message: format!("no workflow with id `{workflow_id}` for session `{session_id}`"),
566 })?;
567 let content = fs::read_to_string(&found.path)?;
568 serde_json::from_str(&content).map_err(|e| Error::History {
569 message: format!(
570 "workflow journal `{}` is not valid JSON: {e}",
571 found.path.display()
572 ),
573 })
574 }
575
576 pub fn prompt_history(&self) -> Result<Vec<PromptHistoryEntry>> {
588 self.prompt_history_with(&ListOptions::default())
589 }
590
591 pub fn prompt_history_with(&self, opts: &ListOptions) -> Result<Vec<PromptHistoryEntry>> {
595 let Some(parent) = self.path.parent() else {
596 return Ok(Vec::new());
597 };
598 let file_path = parent.join("history.jsonl");
599 if !file_path.is_file() {
600 return Ok(Vec::new());
601 }
602 let file = fs::File::open(&file_path)?;
603 let reader = BufReader::new(file);
604 let mut entries = Vec::new();
605 for (lineno, line) in reader.lines().enumerate() {
606 let line = match line {
607 Ok(l) => l,
608 Err(e) => {
609 tracing::warn!(
610 path = %file_path.display(),
611 line = lineno + 1,
612 error = %e,
613 "history: skipping unreadable prompt-history line",
614 );
615 continue;
616 }
617 };
618 let trimmed = line.trim();
619 if trimmed.is_empty() {
620 continue;
621 }
622 match parse_prompt_history_line(trimmed) {
623 Ok(entry) => entries.push(entry),
624 Err(e) => {
625 tracing::warn!(
626 path = %file_path.display(),
627 line = lineno + 1,
628 error = %e,
629 "history: skipping malformed prompt-history line",
630 );
631 }
632 }
633 }
634 apply_offset_limit(&mut entries, opts);
635 Ok(entries)
636 }
637
638 fn session_dir(&self, session_id: &str) -> Result<Option<PathBuf>> {
644 let (jsonl_path, _slug) = self
645 .find_session(session_id)?
646 .ok_or_else(|| Error::History {
647 message: format!(
648 "no session with id `{session_id}` under {}",
649 self.path.display()
650 ),
651 })?;
652 let dir = match jsonl_path.parent() {
653 Some(parent) => parent.join(session_id),
654 None => return Ok(None),
655 };
656 Ok(dir.is_dir().then_some(dir))
657 }
658}
659
660#[derive(Debug, Clone, Serialize)]
662pub struct ProjectSummary {
663 pub slug: String,
665 pub decoded_path: PathBuf,
668 pub is_decode_verified: bool,
684 pub session_count: usize,
686 pub last_modified: Option<SystemTime>,
689}
690
691#[derive(Debug, Clone, Serialize)]
693pub struct SessionSummary {
694 pub session_id: String,
696 pub project_slug: String,
698 pub message_count: usize,
701 pub first_timestamp: Option<String>,
704 pub last_timestamp: Option<String>,
706 pub title: Option<String>,
709 pub first_user_preview: Option<String>,
715 pub total_cost_usd: Option<f64>,
720 pub total_tokens: Option<u64>,
724 pub size_bytes: u64,
726}
727
728#[derive(Debug, Clone, Serialize)]
730pub struct SessionLog {
731 pub session_id: String,
733 pub project_slug: String,
735 pub entries: Vec<HistoryEntry>,
737}
738
739#[derive(Debug, Clone, Serialize)]
742pub struct SubagentSummary {
743 pub agent_id: String,
746 pub path: PathBuf,
748 pub meta: Option<SubagentMeta>,
750}
751
752#[derive(Debug, Clone, Serialize)]
754pub struct SubagentMeta {
755 pub agent_type: Option<String>,
757 pub description: Option<String>,
759 pub tool_use_id: Option<String>,
761 pub spawn_depth: Option<u64>,
763 #[serde(flatten)]
765 pub rest: serde_json::Map<String, Value>,
766}
767
768#[derive(Debug, Clone, Serialize)]
770pub struct ToolResultSummary {
771 pub tool_use_id: String,
774 pub path: PathBuf,
776 pub size_bytes: u64,
778}
779
780#[derive(Debug, Clone, Serialize)]
782pub struct WorkflowSummary {
783 pub workflow_id: String,
786 pub path: PathBuf,
788 pub script_path: Option<PathBuf>,
791 pub size_bytes: u64,
793}
794
795#[derive(Debug, Clone, Serialize)]
799pub struct PromptHistoryEntry {
800 pub display: Option<String>,
802 pub timestamp_ms: Option<u64>,
806 pub project: Option<String>,
808 pub session_id: Option<String>,
810 #[serde(flatten)]
813 pub rest: serde_json::Map<String, Value>,
814}
815
816#[derive(Debug, Clone, Serialize)]
823#[serde(tag = "kind", rename_all = "snake_case")]
824pub enum HistoryEntry {
825 User {
827 uuid: Option<String>,
829 timestamp: Option<String>,
831 cwd: Option<String>,
833 git_branch: Option<String>,
835 message: Value,
837 #[serde(flatten)]
842 rest: serde_json::Map<String, Value>,
843 },
844 Assistant {
846 uuid: Option<String>,
848 timestamp: Option<String>,
850 message: Value,
852 #[serde(flatten)]
857 rest: serde_json::Map<String, Value>,
858 },
859 Other {
861 type_tag: String,
863 raw: Value,
865 },
866}
867
868impl HistoryEntry {
869 pub fn field(&self, key: &str) -> Option<&Value> {
883 match self {
884 Self::User { rest, .. } | Self::Assistant { rest, .. } => rest.get(key),
885 Self::Other { raw, .. } => raw.get(key),
886 }
887 }
888
889 pub fn prompt_source(&self) -> Option<&str> {
894 self.field("promptSource").and_then(Value::as_str)
895 }
896
897 pub fn entrypoint(&self) -> Option<&str> {
900 self.field("entrypoint").and_then(Value::as_str)
901 }
902
903 pub fn is_meta(&self) -> Option<bool> {
906 self.field("isMeta").and_then(Value::as_bool)
907 }
908
909 pub fn is_sidechain(&self) -> Option<bool> {
912 self.field("isSidechain").and_then(Value::as_bool)
913 }
914
915 pub fn session_id(&self) -> Option<&str> {
917 self.field("sessionId").and_then(Value::as_str)
918 }
919
920 pub fn parent_uuid(&self) -> Option<&str> {
923 self.field("parentUuid").and_then(Value::as_str)
924 }
925}
926
927fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
932 if opts.offset >= items.len() {
933 items.clear();
934 return;
935 }
936 if opts.offset > 0 {
937 items.drain(..opts.offset);
938 }
939 if let Some(lim) = opts.limit
940 && items.len() > lim
941 {
942 items.truncate(lim);
943 }
944}
945
946fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
947 let mut session_count = 0usize;
948 let mut last_modified: Option<SystemTime> = None;
949 if let Ok(entries) = fs::read_dir(dir) {
950 for entry in entries.flatten() {
951 let path = entry.path();
952 if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
953 session_count += 1;
954 if let Ok(meta) = entry.metadata()
955 && let Ok(mtime) = meta.modified()
956 {
957 last_modified = Some(match last_modified {
958 Some(prev) if prev > mtime => prev,
959 _ => mtime,
960 });
961 }
962 }
963 }
964 }
965 let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
966 ProjectSummary {
967 decoded_path,
968 is_decode_verified,
969 slug,
970 session_count,
971 last_modified,
972 }
973}
974
975fn summarize_session(
976 path: &Path,
977 session_id: String,
978 project_slug: String,
979) -> Option<SessionSummary> {
980 let meta = fs::metadata(path).ok()?;
981 let size_bytes = meta.len();
982
983 let file = fs::File::open(path).ok()?;
984 let reader = BufReader::new(file);
985
986 let mut message_count = 0usize;
987 let mut first_timestamp = None;
988 let mut last_timestamp = None;
989 let mut title = None;
990 let mut first_user_preview: Option<String> = None;
991 let mut total_cost_usd: Option<f64> = None;
992 let mut total_tokens: Option<u64> = None;
993
994 for line in reader.lines().map_while(std::io::Result::ok) {
995 let trimmed = line.trim();
996 if trimmed.is_empty() {
997 continue;
998 }
999 let v: Value = match serde_json::from_str(trimmed) {
1000 Ok(v) => v,
1001 Err(_) => continue,
1002 };
1003 let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
1004 match ty {
1005 "user" => {
1006 message_count += 1;
1007 if first_user_preview.is_none()
1008 && let Some(p) = extract_user_text_preview(&v, 160)
1009 {
1010 first_user_preview = Some(p);
1011 }
1012 }
1013 "assistant" => {
1014 message_count += 1;
1015 if let Some(c) = v
1016 .get("message")
1017 .and_then(|m| m.get("usage"))
1018 .and_then(|u| u.get("total_cost_usd"))
1019 .and_then(Value::as_f64)
1020 {
1021 *total_cost_usd.get_or_insert(0.0) += c;
1022 }
1023 if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
1024 let mut t = 0u64;
1026 for k in [
1027 "input_tokens",
1028 "output_tokens",
1029 "cache_creation_input_tokens",
1030 "cache_read_input_tokens",
1031 ] {
1032 if let Some(n) = usage.get(k).and_then(Value::as_u64) {
1033 t += n;
1034 }
1035 }
1036 if t > 0 {
1037 *total_tokens.get_or_insert(0) += t;
1038 }
1039 }
1040 }
1041 "ai-title" => {
1042 let candidate = v
1046 .get("aiTitle")
1047 .and_then(Value::as_str)
1048 .or_else(|| v.get("title").and_then(Value::as_str));
1049 if let Some(t) = candidate
1050 && !t.is_empty()
1051 {
1052 title = Some(t.to_string());
1053 }
1054 }
1055 _ => {}
1056 }
1057 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
1058 if first_timestamp.is_none() {
1059 first_timestamp = Some(ts.to_string());
1060 }
1061 last_timestamp = Some(ts.to_string());
1062 }
1063 }
1064
1065 Some(SessionSummary {
1066 session_id,
1067 project_slug,
1068 message_count,
1069 first_timestamp,
1070 last_timestamp,
1071 title,
1072 first_user_preview,
1073 total_cost_usd,
1074 total_tokens,
1075 size_bytes,
1076 })
1077}
1078
1079fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
1085 let content = entry.get("message")?.get("content")?;
1086 let raw = if let Some(s) = content.as_str() {
1087 s.to_string()
1088 } else {
1089 let arr = content.as_array()?;
1090 let mut buf = String::new();
1091 for block in arr {
1092 let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
1093 if ty == "text"
1094 && let Some(t) = block.get("text").and_then(Value::as_str)
1095 {
1096 if !buf.is_empty() {
1097 buf.push(' ');
1098 }
1099 buf.push_str(t);
1100 }
1101 }
1102 buf
1103 };
1104 let one_line = raw
1105 .split('\n')
1106 .map(str::trim)
1107 .filter(|l| !l.is_empty())
1108 .collect::<Vec<_>>()
1109 .join(" ");
1110 if one_line.is_empty() {
1111 return None;
1112 }
1113 let truncated: String = one_line.chars().take(max_chars).collect();
1114 if truncated.len() < one_line.len() {
1115 Some(format!("{truncated}..."))
1116 } else {
1117 Some(truncated)
1118 }
1119}
1120
1121fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
1122 let entries = parse_jsonl_entries(path)?;
1123 Ok(SessionLog {
1124 session_id,
1125 project_slug,
1126 entries,
1127 })
1128}
1129
1130fn parse_jsonl_entries(path: &Path) -> Result<Vec<HistoryEntry>> {
1133 let file = fs::File::open(path)?;
1134 let reader = BufReader::new(file);
1135
1136 let mut entries = Vec::new();
1137 for (lineno, line) in reader.lines().enumerate() {
1138 let line = match line {
1139 Ok(l) => l,
1140 Err(e) => {
1141 tracing::warn!(
1142 path = %path.display(),
1143 line = lineno + 1,
1144 error = %e,
1145 "history: skipping unreadable line",
1146 );
1147 continue;
1148 }
1149 };
1150 let trimmed = line.trim();
1151 if trimmed.is_empty() {
1152 continue;
1153 }
1154 match parse_entry(trimmed) {
1155 Ok(entry) => entries.push(entry),
1156 Err(e) => {
1157 tracing::warn!(
1158 path = %path.display(),
1159 line = lineno + 1,
1160 error = %e,
1161 "history: skipping malformed line",
1162 );
1163 }
1164 }
1165 }
1166 Ok(entries)
1167}
1168
1169fn list_files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
1172 let mut out = Vec::new();
1173 if let Ok(entries) = fs::read_dir(dir) {
1174 for entry in entries.flatten() {
1175 let path = entry.path();
1176 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext) {
1177 out.push(path);
1178 }
1179 }
1180 }
1181 out
1182}
1183
1184fn parse_prompt_history_line(
1185 line: &str,
1186) -> std::result::Result<PromptHistoryEntry, serde_json::Error> {
1187 let value: Value = serde_json::from_str(line)?;
1188 let mut rest = into_map(value);
1189 let timestamp_ms = match rest.remove("timestamp") {
1190 Some(v) => {
1191 let n = v.as_u64();
1192 if n.is_none() {
1193 rest.insert("timestamp".to_string(), v);
1194 }
1195 n
1196 }
1197 None => None,
1198 };
1199 Ok(PromptHistoryEntry {
1200 display: take_string(&mut rest, "display"),
1201 timestamp_ms,
1202 project: take_string(&mut rest, "project"),
1203 session_id: take_string(&mut rest, "sessionId"),
1204 rest,
1205 })
1206}
1207
1208fn read_subagent_meta(transcript_path: &Path) -> Option<SubagentMeta> {
1212 let meta_path = transcript_path.with_extension("meta.json");
1213 let content = fs::read_to_string(&meta_path).ok()?;
1214 let value: Value = serde_json::from_str(&content).ok()?;
1215 let mut rest = into_map(value);
1216 let spawn_depth = match rest.remove("spawnDepth") {
1217 Some(v) => {
1218 let n = v.as_u64();
1219 if n.is_none() {
1220 rest.insert("spawnDepth".to_string(), v);
1221 }
1222 n
1223 }
1224 None => None,
1225 };
1226 Some(SubagentMeta {
1227 agent_type: take_string(&mut rest, "agentType"),
1228 description: take_string(&mut rest, "description"),
1229 tool_use_id: take_string(&mut rest, "toolUseId"),
1230 spawn_depth,
1231 rest,
1232 })
1233}
1234
1235fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
1236 let value: Value = serde_json::from_str(line)?;
1237 let ty = value
1238 .get("type")
1239 .and_then(Value::as_str)
1240 .unwrap_or("")
1241 .to_string();
1242 match ty.as_str() {
1243 "user" => {
1244 let mut rest = into_map(value);
1245 rest.remove("type");
1246 Ok(HistoryEntry::User {
1247 uuid: take_string(&mut rest, "uuid"),
1248 timestamp: take_string(&mut rest, "timestamp"),
1249 cwd: take_string(&mut rest, "cwd"),
1250 git_branch: take_string(&mut rest, "gitBranch"),
1251 message: rest.remove("message").unwrap_or(Value::Null),
1252 rest,
1253 })
1254 }
1255 "assistant" => {
1256 let mut rest = into_map(value);
1257 rest.remove("type");
1258 Ok(HistoryEntry::Assistant {
1259 uuid: take_string(&mut rest, "uuid"),
1260 timestamp: take_string(&mut rest, "timestamp"),
1261 message: rest.remove("message").unwrap_or(Value::Null),
1262 rest,
1263 })
1264 }
1265 other => Ok(HistoryEntry::Other {
1266 type_tag: other.to_string(),
1267 raw: value,
1268 }),
1269 }
1270}
1271
1272fn into_map(value: Value) -> serde_json::Map<String, Value> {
1276 match value {
1277 Value::Object(map) => map,
1278 _ => serde_json::Map::new(),
1279 }
1280}
1281
1282fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
1286 match map.remove(key) {
1287 Some(Value::String(s)) => Some(s),
1288 Some(other) => {
1289 map.insert(key.to_string(), other);
1290 None
1291 }
1292 None => None,
1293 }
1294}
1295
1296fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
1315 let body = slug.strip_prefix('-').unwrap_or(slug);
1316 let mut segments = body.split('-');
1317 let mut built_path = PathBuf::from("/");
1318 let mut is_decode_verified = true;
1319
1320 let mut current_component = segments.next().unwrap_or("").to_string();
1323
1324 for next_segment in segments {
1325 let hyphen_component = format!("{current_component}-{next_segment}");
1326 let slash_exists = built_path.join(¤t_component).exists();
1327 let hyphen_exists = built_path.join(&hyphen_component).exists();
1328
1329 if hyphen_exists {
1334 current_component = hyphen_component;
1335 } else {
1336 if !slash_exists {
1337 is_decode_verified = false;
1338 }
1339 built_path.push(¤t_component);
1340 current_component = next_segment.to_string();
1341 }
1342 }
1343
1344 built_path.push(¤t_component);
1345 (built_path, is_decode_verified)
1346}
1347
1348fn encode_path_slug(path: &str) -> String {
1358 path.chars()
1359 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1360 .collect()
1361}
1362
1363fn home_dir() -> Option<PathBuf> {
1364 if let Ok(h) = std::env::var("HOME")
1367 && !h.is_empty()
1368 {
1369 return Some(PathBuf::from(h));
1370 }
1371 if let Ok(h) = std::env::var("USERPROFILE")
1372 && !h.is_empty()
1373 {
1374 return Some(PathBuf::from(h));
1375 }
1376 None
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381 use super::*;
1382 use std::io::Write;
1383
1384 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
1385 let path = dir.join(format!("{session_id}.jsonl"));
1386 let mut f = fs::File::create(&path).expect("create jsonl");
1387 for line in lines {
1388 writeln!(f, "{line}").unwrap();
1389 }
1390 path
1391 }
1392
1393 fn set_mtime(path: &Path, secs_since_epoch: u64) {
1398 let f = fs::OpenOptions::new()
1399 .write(true)
1400 .open(path)
1401 .expect("reopen for mtime");
1402 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
1403 f.set_modified(when).expect("set mtime");
1404 }
1405
1406 fn fixture_root() -> tempfile::TempDir {
1407 let tmp = tempfile::tempdir().expect("tempdir");
1408 let a = tmp.path().join("-Users-josh-Code-projA");
1410 fs::create_dir_all(&a).unwrap();
1411 write_session(
1412 &a,
1413 "session-aaa",
1414 &[
1415 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
1416 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
1417 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
1418 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
1419 ],
1420 );
1421 write_session(
1422 &a,
1423 "session-bbb",
1424 &[
1425 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
1426 ],
1427 );
1428 let sub = a.join("session-aaa");
1431 fs::create_dir_all(sub.join("subagents")).unwrap();
1432 write_session(
1433 &sub.join("subagents"),
1434 "agent-abc123",
1435 &[
1436 r#"{"type":"user","uuid":"su1","agentId":"abc123","isSidechain":true,"message":{"role":"user","content":"subtask"}}"#,
1437 r#"{"type":"assistant","uuid":"sa1","agentId":"abc123","isSidechain":true,"message":{"role":"assistant","content":"done"}}"#,
1438 ],
1439 );
1440 fs::write(
1441 sub.join("subagents").join("agent-abc123.meta.json"),
1442 r#"{"agentType":"general-purpose","description":"audit the crate","toolUseId":"toolu_spawn1","spawnDepth":1,"futureField":"kept"}"#,
1443 )
1444 .unwrap();
1445 fs::create_dir_all(sub.join("tool-results")).unwrap();
1446 fs::write(
1447 sub.join("tool-results").join("toolu_r1.txt"),
1448 "spilled tool output",
1449 )
1450 .unwrap();
1451 fs::create_dir_all(sub.join("workflows").join("scripts")).unwrap();
1452 fs::write(
1453 sub.join("workflows").join("wf_run1.json"),
1454 r#"{"runId":"wf_run1","script":"export const meta = {}"}"#,
1455 )
1456 .unwrap();
1457 fs::write(
1458 sub.join("workflows")
1459 .join("scripts")
1460 .join("my-task-wf_run1.js"),
1461 "export const meta = {}",
1462 )
1463 .unwrap();
1464 let b = tmp.path().join("-private-tmp-projB");
1466 fs::create_dir_all(&b).unwrap();
1467 write_session(
1468 &b,
1469 "session-ccc",
1470 &[
1471 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1472 r#"NOT VALID JSON"#,
1473 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
1474 ],
1475 );
1476 tmp
1477 }
1478
1479 #[test]
1480 fn list_projects_returns_directories_sorted_by_slug() {
1481 let tmp = fixture_root();
1482 let root = HistoryRoot::at(tmp.path());
1483 let projects = root.list_projects().expect("list projects");
1484 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1485 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
1486 }
1487
1488 #[test]
1489 fn list_projects_counts_sessions() {
1490 let tmp = fixture_root();
1491 let root = HistoryRoot::at(tmp.path());
1492 let projects = root.list_projects().expect("list");
1493 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
1494 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
1495 assert_eq!(a.session_count, 2);
1496 assert_eq!(b.session_count, 1);
1497 }
1498
1499 #[test]
1500 fn list_projects_decodes_slug_to_filesystem_path() {
1501 let tmp = fixture_root();
1502 let root = HistoryRoot::at(tmp.path());
1503 let projects = root.list_projects().expect("list");
1504 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
1505 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
1506 }
1507
1508 #[test]
1509 fn list_projects_returns_empty_when_root_missing() {
1510 let tmp = tempfile::tempdir().unwrap();
1511 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
1512 let projects = root.list_projects().expect("ok");
1513 assert!(projects.is_empty());
1514 }
1515
1516 #[test]
1517 fn list_sessions_filtered_by_slug() {
1518 let tmp = fixture_root();
1519 let root = HistoryRoot::at(tmp.path());
1520 let sessions = root
1521 .list_sessions(Some("-Users-josh-Code-projA"))
1522 .expect("list");
1523 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1524 assert_eq!(ids, ["session-aaa", "session-bbb"]);
1525 assert!(
1526 sessions
1527 .iter()
1528 .all(|s| s.project_slug == "-Users-josh-Code-projA")
1529 );
1530 }
1531
1532 #[test]
1533 fn list_sessions_unfiltered_returns_union() {
1534 let tmp = fixture_root();
1535 let root = HistoryRoot::at(tmp.path());
1536 let sessions = root.list_sessions(None).expect("list");
1537 assert_eq!(sessions.len(), 3);
1538 }
1539
1540 #[test]
1541 fn session_summary_counts_only_user_and_assistant() {
1542 let tmp = fixture_root();
1543 let root = HistoryRoot::at(tmp.path());
1544 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
1545 let aaa = sessions
1546 .iter()
1547 .find(|s| s.session_id == "session-aaa")
1548 .unwrap();
1549 assert_eq!(aaa.message_count, 2);
1551 assert_eq!(aaa.title.as_deref(), Some("hello world"));
1552 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1553 }
1554
1555 #[test]
1556 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1557 let tmp = fixture_root();
1558 let root = HistoryRoot::at(tmp.path());
1559 let log = root.read_session("session-ccc").expect("read");
1560 assert_eq!(log.session_id, "session-ccc");
1561 assert_eq!(log.project_slug, "-private-tmp-projB");
1562 assert_eq!(log.entries.len(), 2);
1564 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1565 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1566 }
1567
1568 #[test]
1569 fn read_session_user_entry_carries_metadata() {
1570 let tmp = fixture_root();
1571 let root = HistoryRoot::at(tmp.path());
1572 let log = root.read_session("session-aaa").expect("read");
1573 match &log.entries[0] {
1574 HistoryEntry::User {
1575 uuid,
1576 timestamp,
1577 cwd,
1578 git_branch,
1579 ..
1580 } => {
1581 assert_eq!(uuid.as_deref(), Some("u1"));
1582 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1583 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1584 assert_eq!(git_branch.as_deref(), Some("main"));
1585 }
1586 other => panic!("expected User entry, got {other:?}"),
1587 }
1588 }
1589
1590 #[test]
1591 fn parse_entry_populates_rest_with_unmodeled_fields() {
1592 let line = r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/w","gitBranch":"main","message":{"role":"user","content":"hi"},"promptSource":"typed","entrypoint":"cli","isSidechain":false,"sessionId":"s1","parentUuid":null,"permissionMode":"default","version":"2.1.0"}"#;
1593 let entry = parse_entry(line).expect("parse");
1594 match &entry {
1595 HistoryEntry::User { rest, .. } => {
1596 assert_eq!(rest["promptSource"], "typed");
1597 assert_eq!(rest["permissionMode"], "default");
1598 assert_eq!(rest["version"], "2.1.0");
1599 for consumed in ["type", "uuid", "timestamp", "cwd", "gitBranch", "message"] {
1601 assert!(!rest.contains_key(consumed), "{consumed} leaked into rest");
1602 }
1603 }
1604 other => panic!("expected User entry, got {other:?}"),
1605 }
1606 }
1607
1608 #[test]
1609 fn parse_entry_keeps_mistyped_field_in_rest() {
1610 let line = r#"{"type":"assistant","uuid":42,"message":{"role":"assistant","content":"y"}}"#;
1613 let entry = parse_entry(line).expect("parse");
1614 match &entry {
1615 HistoryEntry::Assistant { uuid, rest, .. } => {
1616 assert_eq!(uuid.as_deref(), None);
1617 assert_eq!(rest["uuid"], 42);
1618 }
1619 other => panic!("expected Assistant entry, got {other:?}"),
1620 }
1621 }
1622
1623 #[test]
1624 fn typed_accessors_resolve_from_rest() {
1625 let line = r#"{"type":"user","uuid":"u1","message":{},"promptSource":"sdk","entrypoint":"sdk-cli","isSidechain":true,"isMeta":true,"sessionId":"s1","parentUuid":"p1"}"#;
1626 let entry = parse_entry(line).expect("parse");
1627 assert_eq!(entry.prompt_source(), Some("sdk"));
1628 assert_eq!(entry.entrypoint(), Some("sdk-cli"));
1629 assert_eq!(entry.is_sidechain(), Some(true));
1630 assert_eq!(entry.is_meta(), Some(true));
1631 assert_eq!(entry.session_id(), Some("s1"));
1632 assert_eq!(entry.parent_uuid(), Some("p1"));
1633 assert_eq!(
1634 entry.field("promptSource").and_then(Value::as_str),
1635 Some("sdk")
1636 );
1637 }
1638
1639 #[test]
1640 fn typed_accessors_return_none_when_fields_absent() {
1641 let line = r#"{"type":"assistant","uuid":"a1","message":{},"parentUuid":null}"#;
1642 let entry = parse_entry(line).expect("parse");
1643 assert_eq!(entry.prompt_source(), None);
1644 assert_eq!(entry.entrypoint(), None);
1645 assert_eq!(entry.is_meta(), None);
1646 assert_eq!(entry.is_sidechain(), None);
1647 assert_eq!(entry.session_id(), None);
1648 assert_eq!(entry.parent_uuid(), None);
1650 assert_eq!(entry.field("noSuchField"), None);
1651 }
1652
1653 #[test]
1654 fn field_and_accessors_resolve_on_other_variant() {
1655 let line = r#"{"type":"queue-operation","operation":"enqueue","sessionId":"s9"}"#;
1656 let entry = parse_entry(line).expect("parse");
1657 assert_eq!(
1658 entry.field("operation").and_then(Value::as_str),
1659 Some("enqueue")
1660 );
1661 assert_eq!(entry.session_id(), Some("s9"));
1662 assert_eq!(entry.prompt_source(), None);
1663 }
1664
1665 #[test]
1666 fn serialized_entry_reemits_rest_fields_at_top_level() {
1667 let line = r#"{"type":"user","uuid":"u1","message":{},"promptSource":"typed"}"#;
1668 let entry = parse_entry(line).expect("parse");
1669 let v = serde_json::to_value(&entry).expect("serialize");
1670 assert_eq!(v["kind"], "user");
1671 assert_eq!(v["promptSource"], "typed");
1672 }
1673
1674 fn prompt_history_fixture(lines: &[&str]) -> (tempfile::TempDir, HistoryRoot) {
1677 let tmp = tempfile::tempdir().expect("tempdir");
1678 let projects = tmp.path().join("projects");
1679 fs::create_dir_all(&projects).unwrap();
1680 let mut f = fs::File::create(tmp.path().join("history.jsonl")).unwrap();
1681 for line in lines {
1682 writeln!(f, "{line}").unwrap();
1683 }
1684 let root = HistoryRoot::at(projects);
1685 (tmp, root)
1686 }
1687
1688 #[test]
1689 fn prompt_history_parses_fields_and_keeps_rest() {
1690 let (_tmp, root) = prompt_history_fixture(&[
1691 r#"{"display":"fix the tests","pastedContents":{"1":"snippet"},"timestamp":1781804723955,"project":"/Users/me/Code/projA","sessionId":"s1","futureField":true}"#,
1692 ]);
1693 let entries = root.prompt_history().expect("read");
1694 assert_eq!(entries.len(), 1);
1695 let e = &entries[0];
1696 assert_eq!(e.display.as_deref(), Some("fix the tests"));
1697 assert_eq!(e.timestamp_ms, Some(1781804723955));
1698 assert_eq!(e.project.as_deref(), Some("/Users/me/Code/projA"));
1699 assert_eq!(e.session_id.as_deref(), Some("s1"));
1700 assert_eq!(e.rest["pastedContents"]["1"], "snippet");
1701 assert_eq!(e.rest["futureField"], true);
1702 for consumed in ["display", "timestamp", "project", "sessionId"] {
1703 assert!(
1704 !e.rest.contains_key(consumed),
1705 "{consumed} leaked into rest"
1706 );
1707 }
1708 }
1709
1710 #[test]
1711 fn prompt_history_keeps_mistyped_timestamp_in_rest() {
1712 let (_tmp, root) =
1713 prompt_history_fixture(&[r#"{"display":"x","timestamp":"not-a-number"}"#]);
1714 let entries = root.prompt_history().expect("read");
1715 assert_eq!(entries[0].timestamp_ms, None);
1716 assert_eq!(entries[0].rest["timestamp"], "not-a-number");
1717 }
1718
1719 #[test]
1720 fn prompt_history_skips_malformed_lines_and_paginates() {
1721 let (_tmp, root) = prompt_history_fixture(&[
1722 r#"{"display":"one","timestamp":1}"#,
1723 r#"NOT JSON"#,
1724 r#"{"display":"two","timestamp":2}"#,
1725 r#"{"display":"three","timestamp":3}"#,
1726 ]);
1727 let all = root.prompt_history().expect("read");
1728 assert_eq!(all.len(), 3);
1729 assert_eq!(all[0].display.as_deref(), Some("one"));
1731 let page = root
1732 .prompt_history_with(&ListOptions {
1733 offset: 1,
1734 limit: Some(1),
1735 ..ListOptions::default()
1736 })
1737 .expect("read");
1738 assert_eq!(page.len(), 1);
1739 assert_eq!(page[0].display.as_deref(), Some("two"));
1740 }
1741
1742 #[test]
1743 fn prompt_history_missing_file_is_empty() {
1744 let tmp = tempfile::tempdir().unwrap();
1745 let projects = tmp.path().join("projects");
1746 fs::create_dir_all(&projects).unwrap();
1747 let root = HistoryRoot::at(projects);
1749 assert!(root.prompt_history().expect("ok").is_empty());
1750 }
1751
1752 #[test]
1753 fn list_subagents_parses_ids_and_meta() {
1754 let tmp = fixture_root();
1755 let root = HistoryRoot::at(tmp.path());
1756 let subs = root.list_subagents("session-aaa").expect("list");
1757 assert_eq!(subs.len(), 1);
1758 let sub = &subs[0];
1759 assert_eq!(sub.agent_id, "abc123");
1760 let meta = sub.meta.as_ref().expect("meta parsed");
1761 assert_eq!(meta.agent_type.as_deref(), Some("general-purpose"));
1762 assert_eq!(meta.description.as_deref(), Some("audit the crate"));
1763 assert_eq!(meta.tool_use_id.as_deref(), Some("toolu_spawn1"));
1764 assert_eq!(meta.spawn_depth, Some(1));
1765 assert_eq!(meta.rest["futureField"], "kept");
1766 }
1767
1768 #[test]
1769 fn read_subagent_entries_carry_sidechain_attribution() {
1770 let tmp = fixture_root();
1771 let root = HistoryRoot::at(tmp.path());
1772 let entries = root.read_subagent("session-aaa", "abc123").expect("read");
1773 assert_eq!(entries.len(), 2);
1774 assert!(matches!(entries[0], HistoryEntry::User { .. }));
1775 assert_eq!(entries[0].is_sidechain(), Some(true));
1776 assert_eq!(
1777 entries[0].field("agentId").and_then(Value::as_str),
1778 Some("abc123")
1779 );
1780 }
1781
1782 #[test]
1783 fn read_subagent_unknown_agent_errors() {
1784 let tmp = fixture_root();
1785 let root = HistoryRoot::at(tmp.path());
1786 let err = root.read_subagent("session-aaa", "nope").unwrap_err();
1787 assert!(format!("{err}").contains("no subagent with id"));
1788 }
1789
1790 #[test]
1791 fn session_without_subdirectory_lists_empty() {
1792 let tmp = fixture_root();
1793 let root = HistoryRoot::at(tmp.path());
1794 assert!(root.list_subagents("session-bbb").expect("ok").is_empty());
1795 assert!(
1796 root.list_tool_results("session-bbb")
1797 .expect("ok")
1798 .is_empty()
1799 );
1800 assert!(root.list_workflows("session-bbb").expect("ok").is_empty());
1801 }
1802
1803 #[test]
1804 fn subdirectory_lookups_error_on_unknown_session() {
1805 let tmp = fixture_root();
1806 let root = HistoryRoot::at(tmp.path());
1807 let err = root.list_subagents("not-a-session").unwrap_err();
1808 assert!(matches!(err, Error::History { .. }));
1809 assert!(format!("{err}").contains("no session with id"));
1810 }
1811
1812 #[test]
1813 fn tool_result_list_and_read_round_trip() {
1814 let tmp = fixture_root();
1815 let root = HistoryRoot::at(tmp.path());
1816 let results = root.list_tool_results("session-aaa").expect("list");
1817 assert_eq!(results.len(), 1);
1818 assert_eq!(results[0].tool_use_id, "toolu_r1");
1819 assert_eq!(results[0].size_bytes, "spilled tool output".len() as u64);
1820 let content = root
1821 .read_tool_result("session-aaa", "toolu_r1")
1822 .expect("read");
1823 assert_eq!(content, "spilled tool output");
1824 let err = root
1825 .read_tool_result("session-aaa", "toolu_nope")
1826 .unwrap_err();
1827 assert!(format!("{err}").contains("no tool result with id"));
1828 }
1829
1830 #[test]
1831 fn workflow_list_links_script_and_read_parses_json() {
1832 let tmp = fixture_root();
1833 let root = HistoryRoot::at(tmp.path());
1834 let flows = root.list_workflows("session-aaa").expect("list");
1835 assert_eq!(flows.len(), 1);
1836 assert_eq!(flows[0].workflow_id, "wf_run1");
1837 let script = flows[0].script_path.as_ref().expect("script linked");
1838 assert!(script.ends_with("my-task-wf_run1.js"));
1839 let journal = root.read_workflow("session-aaa", "wf_run1").expect("read");
1840 assert_eq!(journal["runId"], "wf_run1");
1841 let err = root.read_workflow("session-aaa", "wf_nope").unwrap_err();
1842 assert!(format!("{err}").contains("no workflow with id"));
1843 }
1844
1845 #[test]
1846 fn read_session_other_entry_preserves_type_tag_and_raw() {
1847 let tmp = fixture_root();
1848 let root = HistoryRoot::at(tmp.path());
1849 let log = root.read_session("session-aaa").expect("read");
1850 let queue_op = log
1852 .entries
1853 .iter()
1854 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1855 .expect("queue-operation entry");
1856 if let HistoryEntry::Other { raw, .. } = queue_op {
1857 assert_eq!(raw["operation"], "enqueue");
1858 }
1859 }
1860
1861 #[test]
1862 fn read_session_unknown_id_errors() {
1863 let tmp = fixture_root();
1864 let root = HistoryRoot::at(tmp.path());
1865 let err = root.read_session("not-a-real-session").unwrap_err();
1866 assert!(matches!(err, Error::History { .. }));
1867 assert!(format!("{err}").contains("no session with id"));
1868 }
1869
1870 #[test]
1871 fn find_session_returns_none_for_unknown_id() {
1872 let tmp = fixture_root();
1873 let root = HistoryRoot::at(tmp.path());
1874 let found = root.find_session("nope").expect("ok");
1875 assert!(found.is_none());
1876 }
1877
1878 #[test]
1879 fn find_session_locates_real_session() {
1880 let tmp = fixture_root();
1881 let root = HistoryRoot::at(tmp.path());
1882 let (path, slug) = root
1883 .find_session("session-ccc")
1884 .expect("ok")
1885 .expect("found");
1886 assert!(path.ends_with("session-ccc.jsonl"));
1887 assert_eq!(slug, "-private-tmp-projB");
1888 }
1889
1890 #[test]
1891 fn decode_slug_anchored_no_hyphens_in_components() {
1892 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1897 assert_eq!(path, PathBuf::from("/a/b/c/d"));
1898 }
1899
1900 #[test]
1901 fn decode_slug_anchored_single_hyphenated_segment() {
1902 let tmp = tempfile::tempdir().unwrap();
1904 let dir = tmp.path().join("foo-bar");
1905 fs::create_dir_all(&dir).unwrap();
1906 let tmp_str = tmp.path().to_string_lossy();
1907 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1908 let slug = format!("-{tmp_encoded}-foo-bar");
1909 let expected = tmp.path().join("foo-bar");
1910 let (decoded, is_verified) = decode_slug_anchored(&slug);
1911 assert_eq!(decoded, expected);
1912 assert!(is_verified);
1913 }
1914
1915 #[test]
1916 fn decode_slug_anchored_multiple_hyphenated_segments() {
1917 let tmp = tempfile::tempdir().unwrap();
1919 let dir = tmp.path().join("foo-bar").join("baz-qux");
1920 fs::create_dir_all(&dir).unwrap();
1921 let tmp_str = tmp.path().to_string_lossy();
1922 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1923 let slug = format!("-{tmp_encoded}-foo-bar-baz-qux");
1924 let expected = tmp.path().join("foo-bar").join("baz-qux");
1925 let (decoded, is_verified) = decode_slug_anchored(&slug);
1926 assert_eq!(decoded, expected);
1927 assert!(is_verified);
1928 }
1929
1930 #[test]
1931 fn decode_slug_anchored_fallback_when_nothing_exists() {
1932 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1934 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1935 assert!(!verified);
1936 }
1937
1938 #[test]
1939 fn decode_slug_anchored_real_world_issue_example() {
1940 let tmp = tempfile::tempdir().unwrap();
1945 let dir = tmp.path().join("rust").join("claude-wrapper");
1946 fs::create_dir_all(&dir).unwrap();
1947 let tmp_str = tmp.path().to_string_lossy();
1948 let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1949 let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1950 let expected = tmp.path().join("rust").join("claude-wrapper");
1951 let (decoded, is_verified) = decode_slug_anchored(&slug);
1952 assert_eq!(decoded, expected);
1953 assert!(is_verified);
1954 }
1955
1956 fn paginated_fixture() -> tempfile::TempDir {
1961 let tmp = tempfile::tempdir().unwrap();
1962 for stem in ["-zzz-empty1", "-aaa-empty2"] {
1964 fs::create_dir_all(tmp.path().join(stem)).unwrap();
1965 }
1966 for (stem, ts, mtime) in [
1967 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1968 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1969 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1970 ] {
1971 let dir = tmp.path().join(stem);
1972 fs::create_dir_all(&dir).unwrap();
1973 let session_path = write_session(
1974 &dir,
1975 "s1",
1976 &[&format!(
1977 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1978 )],
1979 );
1980 set_mtime(&session_path, mtime);
1981 }
1982 tmp
1983 }
1984
1985 #[test]
1986 fn list_projects_with_include_empty_false_filters_them_out() {
1987 let tmp = paginated_fixture();
1988 let root = HistoryRoot::at(tmp.path());
1989 let projects = root
1990 .list_projects_with(&ListOptions {
1991 include_empty: false,
1992 ..Default::default()
1993 })
1994 .expect("list");
1995 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1996 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1998 }
1999
2000 #[test]
2001 fn list_projects_with_default_includes_empty_for_bc() {
2002 let tmp = paginated_fixture();
2005 let root = HistoryRoot::at(tmp.path());
2006 let projects = root
2007 .list_projects_with(&ListOptions::default())
2008 .expect("list");
2009 assert_eq!(projects.len(), 5);
2010 }
2011
2012 #[test]
2013 fn list_projects_zero_arg_preserves_legacy_inclusion() {
2014 let tmp = paginated_fixture();
2017 let root = HistoryRoot::at(tmp.path());
2018 let projects = root.list_projects().expect("list");
2019 assert_eq!(projects.len(), 5);
2020 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2021 assert_eq!(
2022 slugs,
2023 [
2024 "-aaa-empty2",
2025 "-bbb-proj",
2026 "-ccc-proj",
2027 "-ddd-proj",
2028 "-zzz-empty1",
2029 ]
2030 );
2031 }
2032
2033 #[test]
2034 fn list_projects_with_limit_caps_results() {
2035 let tmp = paginated_fixture();
2036 let root = HistoryRoot::at(tmp.path());
2037 let projects = root
2038 .list_projects_with(&ListOptions {
2039 limit: Some(2),
2040 include_empty: true,
2041 ..Default::default()
2042 })
2043 .expect("list");
2044 assert_eq!(projects.len(), 2);
2045 }
2046
2047 #[test]
2048 fn list_projects_with_offset_skips() {
2049 let tmp = paginated_fixture();
2050 let root = HistoryRoot::at(tmp.path());
2051 let projects = root
2052 .list_projects_with(&ListOptions {
2053 offset: 3,
2054 include_empty: true,
2055 ..Default::default()
2056 })
2057 .expect("list");
2058 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2061 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
2062 }
2063
2064 #[test]
2065 fn list_projects_with_offset_past_end_returns_empty() {
2066 let tmp = paginated_fixture();
2067 let root = HistoryRoot::at(tmp.path());
2068 let projects = root
2069 .list_projects_with(&ListOptions {
2070 offset: 99,
2071 include_empty: true,
2072 ..Default::default()
2073 })
2074 .expect("list");
2075 assert!(projects.is_empty());
2076 }
2077
2078 #[test]
2079 fn list_projects_with_recency_desc_sort() {
2080 let tmp = paginated_fixture();
2081 let root = HistoryRoot::at(tmp.path());
2082 let projects = root
2086 .list_projects_with(&ListOptions {
2087 sort: ListSort::RecencyDesc,
2088 include_empty: false,
2089 ..Default::default()
2090 })
2091 .expect("list");
2092 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2093 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
2094 }
2095
2096 #[test]
2097 fn list_sessions_with_include_empty_false_filters_zero_message() {
2098 let tmp = tempfile::tempdir().unwrap();
2099 let dir = tmp.path().join("-proj");
2100 fs::create_dir_all(&dir).unwrap();
2101 write_session(
2103 &dir,
2104 "real",
2105 &[
2106 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2107 ],
2108 );
2109 write_session(
2111 &dir,
2112 "orphan",
2113 &[
2114 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
2115 ],
2116 );
2117 let root = HistoryRoot::at(tmp.path());
2118 let sessions = root
2119 .list_sessions_with(
2120 Some("-proj"),
2121 &ListOptions {
2122 include_empty: false,
2123 ..Default::default()
2124 },
2125 )
2126 .expect("list");
2127 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2128 assert_eq!(ids, ["real"]);
2129 }
2130
2131 #[test]
2132 fn list_sessions_with_default_returns_orphans_for_bc() {
2133 let tmp = tempfile::tempdir().unwrap();
2134 let dir = tmp.path().join("-proj");
2135 fs::create_dir_all(&dir).unwrap();
2136 write_session(
2137 &dir,
2138 "orphan",
2139 &[
2140 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
2141 ],
2142 );
2143 let root = HistoryRoot::at(tmp.path());
2144 let sessions = root
2145 .list_sessions_with(Some("-proj"), &ListOptions::default())
2146 .expect("list");
2147 assert_eq!(sessions.len(), 1);
2148 assert_eq!(sessions[0].message_count, 0);
2149 }
2150
2151 #[test]
2152 fn list_sessions_with_recency_desc_sort() {
2153 let tmp = tempfile::tempdir().unwrap();
2154 let dir = tmp.path().join("-proj");
2155 fs::create_dir_all(&dir).unwrap();
2156 let old_p = write_session(
2157 &dir,
2158 "old",
2159 &[
2160 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2161 ],
2162 );
2163 let new_p = write_session(
2164 &dir,
2165 "new",
2166 &[
2167 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2168 ],
2169 );
2170 let mid_p = write_session(
2171 &dir,
2172 "mid",
2173 &[
2174 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2175 ],
2176 );
2177 set_mtime(&old_p, 1_700_000_000);
2178 set_mtime(&mid_p, 1_700_001_000);
2179 set_mtime(&new_p, 1_700_002_000);
2180 let root = HistoryRoot::at(tmp.path());
2181 let sessions = root
2182 .list_sessions_with(
2183 Some("-proj"),
2184 &ListOptions {
2185 sort: ListSort::RecencyDesc,
2186 ..Default::default()
2187 },
2188 )
2189 .expect("list");
2190 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2191 assert_eq!(ids, ["new", "mid", "old"]);
2192 }
2193
2194 #[test]
2195 fn list_sessions_with_limit_and_offset_combine() {
2196 let tmp = tempfile::tempdir().unwrap();
2197 let dir = tmp.path().join("-proj");
2198 fs::create_dir_all(&dir).unwrap();
2199 for i in 0..5 {
2200 write_session(
2201 &dir,
2202 &format!("s{i}"),
2203 &[&format!(
2204 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
2205 )],
2206 );
2207 }
2208 let root = HistoryRoot::at(tmp.path());
2209 let sessions = root
2210 .list_sessions_with(
2211 Some("-proj"),
2212 &ListOptions {
2213 offset: 1,
2214 limit: Some(2),
2215 ..Default::default()
2216 },
2217 )
2218 .expect("list");
2219 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2220 assert_eq!(ids, ["s1", "s2"]);
2222 }
2223
2224 #[test]
2227 fn session_summary_parses_ai_title_camelcase() {
2228 let tmp = tempfile::tempdir().unwrap();
2231 let dir = tmp.path().join("-proj");
2232 fs::create_dir_all(&dir).unwrap();
2233 write_session(
2234 &dir,
2235 "real-shape",
2236 &[
2237 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2238 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
2239 ],
2240 );
2241 let root = HistoryRoot::at(tmp.path());
2242 let sessions = root.list_sessions(Some("-proj")).expect("list");
2243 let s = sessions
2244 .iter()
2245 .find(|s| s.session_id == "real-shape")
2246 .unwrap();
2247 assert_eq!(s.title.as_deref(), Some("My Session"));
2248 }
2249
2250 #[test]
2251 fn session_summary_legacy_title_field_still_works() {
2252 let tmp = tempfile::tempdir().unwrap();
2254 let dir = tmp.path().join("-proj");
2255 fs::create_dir_all(&dir).unwrap();
2256 write_session(
2257 &dir,
2258 "legacy",
2259 &[
2260 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2261 r#"{"type":"ai-title","title":"Legacy Form"}"#,
2262 ],
2263 );
2264 let root = HistoryRoot::at(tmp.path());
2265 let sessions = root.list_sessions(Some("-proj")).expect("list");
2266 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
2267 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
2268 }
2269
2270 #[test]
2273 fn encode_path_slug_encodes_slash_and_dot() {
2274 assert_eq!(
2275 encode_path_slug("/Users/josh/Code/projA"),
2276 "-Users-josh-Code-projA"
2277 );
2278 assert_eq!(
2280 encode_path_slug("/private/var/folders/T/tmp.AbC"),
2281 "-private-var-folders-T-tmp-AbC"
2282 );
2283 assert_eq!(
2287 encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
2288 "-Users-me-genagent-claude-wrapper-ex"
2289 );
2290 assert_eq!(
2291 encode_path_slug("/Users/me/My Project (v2)"),
2292 "-Users-me-My-Project--v2-"
2293 );
2294 }
2295
2296 #[test]
2297 fn project_slug_canonicalizes_and_encodes_dot() {
2298 let work = tempfile::tempdir().unwrap();
2299 let cwd = work.path().join("my.proj");
2300 fs::create_dir_all(&cwd).unwrap();
2301
2302 let slug = HistoryRoot::project_slug(&cwd);
2303 assert!(
2304 slug.contains("my-proj"),
2305 "dotted segment must encode '.' -> '-', got {slug}"
2306 );
2307 assert!(
2308 !slug.contains('.'),
2309 "no '.' may survive in the slug: {slug}"
2310 );
2311 assert!(
2312 !slug.contains('/'),
2313 "no '/' may survive in the slug: {slug}"
2314 );
2315 }
2316
2317 #[test]
2318 fn project_slug_canonicalizes_and_encodes_underscore() {
2319 let work = tempfile::tempdir().unwrap();
2322 let cwd = work.path().join("claude_wrapper_ex");
2323 fs::create_dir_all(&cwd).unwrap();
2324
2325 let slug = HistoryRoot::project_slug(&cwd);
2326 assert!(
2327 slug.contains("claude-wrapper-ex"),
2328 "underscored segment must encode '_' -> '-', got {slug}"
2329 );
2330 assert!(
2331 !slug.contains('_'),
2332 "no '_' may survive in the slug: {slug}"
2333 );
2334 }
2335
2336 #[test]
2337 fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
2338 let projects = tempfile::tempdir().unwrap();
2343 let work = tempfile::tempdir().unwrap();
2344 let cwd = work.path().join("tmp.XYZ");
2345 fs::create_dir_all(&cwd).unwrap();
2346
2347 let canonical = fs::canonicalize(&cwd).unwrap();
2351 let expected_slug = encode_path_slug(&canonical.to_string_lossy());
2352 let proj_dir = projects.path().join(&expected_slug);
2353 fs::create_dir_all(&proj_dir).unwrap();
2354 write_session(
2355 &proj_dir,
2356 "sess-dot",
2357 &[
2358 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
2359 ],
2360 );
2361
2362 let root = HistoryRoot::at(projects.path());
2363 let sessions = root.sessions_for_path(&cwd).expect("enumerate");
2364 assert_eq!(
2365 sessions.len(),
2366 1,
2367 "should find the session for the dotted/symlinked cwd"
2368 );
2369 assert_eq!(sessions[0].session_id, "sess-dot");
2370 }
2371}