//! 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 { let marker = format!("\"customTitle\":\"{title}\""); 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.contains(&marker)) { 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)) } }