172 lines
6.4 KiB
Rust
172 lines
6.4 KiB
Rust
//! 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
|
|
/// `<claude_home>/projects/<slug(cwd)>/<uuid>.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<PathBuf>, cwd: impl Into<PathBuf>) -> Self {
|
|
Self {
|
|
claude_home: claude_home.into(),
|
|
cwd: cwd.into(),
|
|
}
|
|
}
|
|
|
|
/// `<claude_home>/projects/<slug>` 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 `<uuid>.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<PathBuf> {
|
|
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
|
|
/// `<uuid>.jsonl` → `<uuid>.jsonl.archived`. That drops it out of claude's
|
|
/// `*.jsonl` resolution glob (so a later `--resume <title>` 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")));
|
|
}
|
|
}
|