//! Locating and archiving on-disk claude sessions by title. use std::io::BufRead as _; use std::path::PathBuf; use crate::{Error, Result}; /// On-disk claude session store scoped to one working directory. /// /// Claude Code keeps sessions under /// `/projects//.jsonl`, one project dir per cwd. /// This type locates and archives those files by their display title. It is /// generic Claude Code layout knowledge — no application specifics. #[derive(Debug, Clone)] pub struct SessionStore { claude_home: PathBuf, cwd: PathBuf, } impl SessionStore { /// Build a store for `cwd`, with claude's home dir (normally `~/.claude`) /// at `claude_home`. pub fn new(claude_home: impl Into, cwd: impl Into) -> Self { Self { claude_home: claude_home.into(), cwd: cwd.into(), } } /// `/projects/` for this cwd. Claude slugises the /// absolute cwd by replacing every `/` and `.` with `-` (verified against /// claude 2.1.197 — e.g. `/agents/iris/state` → `-agents-iris-state`). #[must_use] pub fn project_dir(&self) -> PathBuf { let slug: String = self .cwd .to_string_lossy() .chars() .map(|c| if c == '/' || c == '.' { '-' } else { c }) .collect(); self.claude_home.join("projects").join(slug) } /// Find the `.jsonl` in the project dir whose `customTitle` equals /// `title` (the value `--name` sets, stored in a `custom-title` event). /// /// Reads each session file line by line and stops at the first match, so a /// huge transcript isn't slurped into memory. Returns `None` if no session /// carries the title or the project dir is absent. Non-`.jsonl` files /// (including anything already archived to `*.jsonl.archived`) are skipped. #[must_use] pub fn find_by_title(&self, title: &str) -> Option { for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } let Ok(file) = std::fs::File::open(&path) else { continue; }; if std::io::BufReader::new(file) .lines() .map_while(std::result::Result::ok) .any(|line| line_sets_title(&line, title)) { return Some(path); } } None } /// Archive the session titled `title` by renaming its backing file /// `.jsonl` → `.jsonl.archived`. That drops it out of claude's /// `*.jsonl` resolution glob (so a later `--resume ` misses) while /// preserving the full transcript on disk. Only the file with a matching /// `customTitle` is touched. /// /// Returns the archived file's new path, or `None` if no session carried /// the title. /// /// # Errors /// /// [`Error::Io`] if the rename fails. pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> { let Some(path) = self.find_by_title(title) else { return Ok(None); }; let mut target = path.clone().into_os_string(); target.push(".archived"); let target = PathBuf::from(target); std::fs::rename(&path, &target).map_err(Error::Io)?; Ok(Some(target)) } } /// True if `line` is the session's `custom-title` event whose **top-level** /// `customTitle` field equals `title`. /// /// Parsing the line (rather than substring-matching the whole transcript) is /// what makes this robust: a message that merely *quotes* the marker in its /// content has no top-level `customTitle` key, so it can't cause a false match /// on the wrong session file; the compact-vs-spaced JSON form is irrelevant; /// and titles containing `"` / `\` are handled by the parser. Verified shape /// (claude 2.1.x): `{"type":"custom-title","customTitle":"<title>",…}`. /// /// The `contains` pre-check keeps the common case cheap — only the rare line /// mentioning `customTitle` is parsed as JSON, not every transcript line. fn line_sets_title(line: &str, title: &str) -> bool { if !line.contains("customTitle") { return false; } let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else { return false; }; value.get("customTitle").and_then(|t| t.as_str()) == Some(title) } #[cfg(test)] mod tests { use super::*; fn write(dir: &std::path::Path, name: &str, lines: &[&str]) { std::fs::write(dir.join(name), lines.join("\n")).unwrap(); } #[test] fn find_by_title_matches_only_the_custom_title_event() { let home = tempfile::tempdir().unwrap(); let cwd = std::path::Path::new("/agents/iris/state"); let store = SessionStore::new(home.path(), cwd); let proj = store.project_dir(); std::fs::create_dir_all(&proj).unwrap(); // The real titled session. write( &proj, "real.jsonl", &[ r#"{"type":"summary","summary":"x"}"#, r#"{"type":"custom-title","customTitle":"iris","sessionId":"real"}"#, ], ); // A DIFFERENT session whose transcript merely quotes the marker string // in message content — must NOT match. write( &proj, "other.jsonl", &[ r#"{"type":"custom-title","customTitle":"someone-else","sessionId":"other"}"#, r#"{"type":"assistant","message":{"content":[{"type":"text","text":"the file had \"customTitle\":\"iris\" in it"}]}}"#, ], ); assert_eq!(store.find_by_title("iris"), Some(proj.join("real.jsonl"))); assert_eq!(store.find_by_title("nobody"), None); } #[test] fn find_by_title_tolerates_spaced_json_and_escapes() { let home = tempfile::tempdir().unwrap(); let store = SessionStore::new(home.path(), std::path::Path::new("/x")); let proj = store.project_dir(); std::fs::create_dir_all(&proj).unwrap(); // Spaced JSON form + a title needing JSON escaping. write( &proj, "s.jsonl", &[r#"{ "type": "custom-title", "customTitle": "a\"b" }"#], ); assert_eq!(store.find_by_title("a\"b"), Some(proj.join("s.jsonl"))); } }