claude_scriptorium/
discovery.rs1use 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#[derive(Debug)]
14pub struct Quire {
15 pub dir: PathBuf,
16 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 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
40pub 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
60pub 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
69pub 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
100fn 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 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}