Skip to main content

claude_scriptorium/
discovery.rs

1//! Locating session files under Claude Code's project store.
2
3use std::{
4    cmp::Reverse,
5    fs,
6    path::{Path, PathBuf},
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10use anyhow::{Context, Result, anyhow, bail};
11
12/// Every session Claude Code has recorded for one project directory.
13#[derive(Debug)]
14pub struct Quire {
15    pub dir: PathBuf,
16    /// Session files, most recently modified first.
17    pub sessions: Vec<PathBuf>,
18}
19
20impl Quire {
21    pub fn latest(&self) -> Result<&Path> {
22        self.sessions
23            .first()
24            .map(PathBuf::as_path)
25            .ok_or_else(|| anyhow!("no sessions recorded in {}", self.dir.display()))
26    }
27
28    /// When the most recently touched session in this quire was modified,
29    /// used to order projects by activity. `sessions` is already sorted
30    /// most-recent first, so the head carries the answer.
31    fn latest_modified(&self) -> SystemTime {
32        self.sessions
33            .first()
34            .and_then(|session| session.metadata().ok())
35            .and_then(|metadata| metadata.modified().ok())
36            .unwrap_or(UNIX_EPOCH)
37    }
38}
39
40/// Every project's quire, most recently active first, for browsing across
41/// projects. Directories that hold no session files are left out: an empty
42/// quire is nothing to pick.
43pub fn all_quires(root: &Path) -> Result<Vec<Quire>> {
44    let mut quires = Vec::new();
45    for entry in fs::read_dir(root).with_context(|| format!("listing {}", root.display()))? {
46        let dir = entry?.path();
47        if !dir.is_dir() {
48            continue;
49        }
50        let sessions = sessions_in(&dir)?;
51        if sessions.is_empty() {
52            continue;
53        }
54        quires.push(Quire { dir, sessions });
55    }
56    quires.sort_by_key(|quire| Reverse(quire.latest_modified()));
57    Ok(quires)
58}
59
60/// The root Claude Code writes project transcripts under.
61pub fn projects_root() -> Result<PathBuf> {
62    if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") {
63        return Ok(PathBuf::from(configured).join("projects"));
64    }
65    let home = std::env::home_dir().context("locating home directory")?;
66    Ok(home.join(".claude").join("projects"))
67}
68
69/// Claude Code names each project directory after its path, with everything
70/// outside `[A-Za-z0-9_]` flattened to a dash.
71pub fn encode_project_path(project: &Path) -> String {
72    project
73        .to_string_lossy()
74        .chars()
75        .map(|c| {
76            if c.is_ascii_alphanumeric() || c == '_' {
77                c
78            } else {
79                '-'
80            }
81        })
82        .collect()
83}
84
85pub fn quire_for(root: &Path, project: &Path) -> Result<Quire> {
86    let dir = root.join(encode_project_path(project));
87    if !dir.is_dir() {
88        bail!(
89            "no recorded sessions for {} (looked in {})",
90            project.display(),
91            dir.display()
92        );
93    }
94    Ok(Quire {
95        sessions: sessions_in(&dir)?,
96        dir,
97    })
98}
99
100/// Session files in one project directory, most recently modified first.
101///
102/// `agent-*.jsonl` holds a subagent's own transcript, which is reachable from
103/// the parent session it was spawned by.
104fn sessions_in(dir: &Path) -> Result<Vec<PathBuf>> {
105    let mut found: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
106    for entry in fs::read_dir(dir).with_context(|| format!("listing {}", dir.display()))? {
107        let entry = entry?;
108        let path = entry.path();
109        if path.extension().is_none_or(|ext| ext != "jsonl") {
110            continue;
111        }
112        let name = entry.file_name();
113        if name.to_string_lossy().starts_with("agent-") {
114            continue;
115        }
116        found.push((entry.metadata()?.modified()?, path));
117    }
118    found.sort_by_key(|(modified, _)| Reverse(*modified));
119    Ok(found.into_iter().map(|(_, path)| path).collect())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn separators_and_dots_flatten_to_dashes() {
128        assert_eq!(
129            encode_project_path(Path::new("/home/scribe/projects/dot.ted")),
130            "-home-scribe-projects-dot-ted"
131        );
132    }
133
134    #[test]
135    fn underscores_survive_encoding() {
136        assert_eq!(
137            encode_project_path(Path::new("/srv/quire_two")),
138            "-srv-quire_two"
139        );
140    }
141
142    /// Builds a throwaway projects root and removes it when dropped, so the
143    /// filesystem-facing enumeration can be exercised without a temp-dir crate.
144    struct TempRoot {
145        path: PathBuf,
146    }
147
148    impl TempRoot {
149        fn new(tag: &str) -> Self {
150            let path =
151                std::env::temp_dir().join(format!("scriptorium-{tag}-{}", std::process::id()));
152            let _ = fs::remove_dir_all(&path);
153            fs::create_dir_all(&path).unwrap();
154            Self { path }
155        }
156
157        fn project(&self, name: &str, files: &[&str]) {
158            let dir = self.path.join(name);
159            fs::create_dir_all(&dir).unwrap();
160            for file in files {
161                fs::write(dir.join(file), "{}\n").unwrap();
162            }
163        }
164    }
165
166    impl Drop for TempRoot {
167        fn drop(&mut self) {
168            let _ = fs::remove_dir_all(&self.path);
169        }
170    }
171
172    #[test]
173    fn all_quires_lists_projects_holding_sessions() {
174        let root = TempRoot::new("with-sessions");
175        root.project("-srv-alpha", &["one.jsonl", "two.jsonl"]);
176
177        let quires = all_quires(&root.path).unwrap();
178
179        let dirs: Vec<_> = quires.iter().map(|quire| quire.dir.clone()).collect();
180        assert_eq!(dirs, [root.path.join("-srv-alpha")]);
181        assert_eq!(quires[0].sessions.len(), 2);
182    }
183
184    #[test]
185    fn all_quires_skips_empty_and_subagent_only_projects() {
186        let root = TempRoot::new("empty-and-subagent");
187        root.project("-srv-empty", &[]);
188        root.project("-srv-subagent", &["agent-7f.jsonl"]);
189        root.project("-srv-real", &["session.jsonl"]);
190
191        let quires = all_quires(&root.path).unwrap();
192
193        let dirs: Vec<_> = quires.iter().map(|quire| quire.dir.clone()).collect();
194        assert_eq!(dirs, [root.path.join("-srv-real")]);
195    }
196}