claude-scriptorium 0.1.3

Render Claude Code sessions as self-contained HTML
Documentation
//! Locating session files under Claude Code's project store.

use std::{
    cmp::Reverse,
    fs,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result, anyhow, bail};

/// Every session Claude Code has recorded for one project directory.
#[derive(Debug)]
pub struct Quire {
    pub dir: PathBuf,
    /// Session files, most recently modified first.
    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()))
    }

    /// When the most recently touched session in this quire was modified,
    /// used to order projects by activity. `sessions` is already sorted
    /// most-recent first, so the head carries the answer.
    fn latest_modified(&self) -> SystemTime {
        self.sessions
            .first()
            .and_then(|session| session.metadata().ok())
            .and_then(|metadata| metadata.modified().ok())
            .unwrap_or(UNIX_EPOCH)
    }
}

/// Every project's quire, most recently active first, for browsing across
/// projects. Directories that hold no session files are left out: an empty
/// quire is nothing to pick.
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)
}

/// The root Claude Code writes project transcripts under.
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"))
}

/// Claude Code names each project directory after its path, with everything
/// outside `[A-Za-z0-9_]` flattened to a dash.
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,
    })
}

/// Session files in one project directory, most recently modified first.
///
/// `agent-*.jsonl` holds a subagent's own transcript, which is reachable from
/// the parent session it was spawned by.
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"
        );
    }

    /// Builds a throwaway projects root and removes it when dropped, so the
    /// filesystem-facing enumeration can be exercised without a temp-dir crate.
    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")]);
    }
}