use std::{
cmp::Reverse,
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, anyhow, bail};
#[derive(Debug)]
pub struct Quire {
pub dir: PathBuf,
pub sessions: Vec<PathBuf>,
}
impl Quire {
pub fn latest(&self) -> Result<&Path> {
self.sessions
.first()
.map(PathBuf::as_path)
.ok_or_else(|| anyhow!("no sessions recorded in {}", self.dir.display()))
}
fn latest_modified(&self) -> SystemTime {
self.sessions
.first()
.and_then(|session| session.metadata().ok())
.and_then(|metadata| metadata.modified().ok())
.unwrap_or(UNIX_EPOCH)
}
}
pub fn all_quires(root: &Path) -> Result<Vec<Quire>> {
let mut quires = Vec::new();
for entry in fs::read_dir(root).with_context(|| format!("listing {}", root.display()))? {
let dir = entry?.path();
if !dir.is_dir() {
continue;
}
let sessions = sessions_in(&dir)?;
if sessions.is_empty() {
continue;
}
quires.push(Quire { dir, sessions });
}
quires.sort_by_key(|quire| Reverse(quire.latest_modified()));
Ok(quires)
}
pub fn projects_root() -> Result<PathBuf> {
if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") {
return Ok(PathBuf::from(configured).join("projects"));
}
let home = std::env::home_dir().context("locating home directory")?;
Ok(home.join(".claude").join("projects"))
}
pub fn encode_project_path(project: &Path) -> String {
project
.to_string_lossy()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'-'
}
})
.collect()
}
pub fn quire_for(root: &Path, project: &Path) -> Result<Quire> {
let dir = root.join(encode_project_path(project));
if !dir.is_dir() {
bail!(
"no recorded sessions for {} (looked in {})",
project.display(),
dir.display()
);
}
Ok(Quire {
sessions: sessions_in(&dir)?,
dir,
})
}
fn sessions_in(dir: &Path) -> Result<Vec<PathBuf>> {
let mut found: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("listing {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "jsonl") {
continue;
}
let name = entry.file_name();
if name.to_string_lossy().starts_with("agent-") {
continue;
}
found.push((entry.metadata()?.modified()?, path));
}
found.sort_by_key(|(modified, _)| Reverse(*modified));
Ok(found.into_iter().map(|(_, path)| path).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separators_and_dots_flatten_to_dashes() {
assert_eq!(
encode_project_path(Path::new("/home/scribe/projects/dot.ted")),
"-home-scribe-projects-dot-ted"
);
}
#[test]
fn underscores_survive_encoding() {
assert_eq!(
encode_project_path(Path::new("/srv/quire_two")),
"-srv-quire_two"
);
}
struct TempRoot {
path: PathBuf,
}
impl TempRoot {
fn new(tag: &str) -> Self {
let path =
std::env::temp_dir().join(format!("scriptorium-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).unwrap();
Self { path }
}
fn project(&self, name: &str, files: &[&str]) {
let dir = self.path.join(name);
fs::create_dir_all(&dir).unwrap();
for file in files {
fs::write(dir.join(file), "{}\n").unwrap();
}
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn all_quires_lists_projects_holding_sessions() {
let root = TempRoot::new("with-sessions");
root.project("-srv-alpha", &["one.jsonl", "two.jsonl"]);
let quires = all_quires(&root.path).unwrap();
let dirs: Vec<_> = quires.iter().map(|quire| quire.dir.clone()).collect();
assert_eq!(dirs, [root.path.join("-srv-alpha")]);
assert_eq!(quires[0].sessions.len(), 2);
}
#[test]
fn all_quires_skips_empty_and_subagent_only_projects() {
let root = TempRoot::new("empty-and-subagent");
root.project("-srv-empty", &[]);
root.project("-srv-subagent", &["agent-7f.jsonl"]);
root.project("-srv-real", &["session.jsonl"]);
let quires = all_quires(&root.path).unwrap();
let dirs: Vec<_> = quires.iter().map(|quire| quire.dir.clone()).collect();
assert_eq!(dirs, [root.path.join("-srv-real")]);
}
}