cc_token_usage/data/
scanner.rs1use anyhow::{Context, Result};
2use std::fs;
3use std::io::{BufRead, BufReader};
4use std::path::Path;
5
6use super::models::SessionFile;
7
8fn is_uuid(s: &str) -> bool {
10 let parts: Vec<&str> = s.split('-').collect();
11 if parts.len() != 5 {
12 return false;
13 }
14 let expected_lens = [8, 4, 4, 4, 12];
15 parts
16 .iter()
17 .zip(expected_lens.iter())
18 .all(|(part, &len)| part.len() == len && part.chars().all(|c| c.is_ascii_hexdigit()))
19}
20
21pub fn scan_claude_home(claude_home: &Path) -> Result<Vec<SessionFile>> {
28 let projects_dir = claude_home.join("projects");
29 scan_projects_dir(&projects_dir)
30}
31
32pub fn scan_projects_dir(projects_dir: &Path) -> Result<Vec<SessionFile>> {
47 if !projects_dir.is_dir() {
48 return Ok(Vec::new());
49 }
50
51 let mut results = Vec::new();
52
53 let project_entries = fs::read_dir(projects_dir)
55 .with_context(|| format!("failed to read projects dir: {}", projects_dir.display()))?;
56
57 for project_entry in project_entries {
58 let project_entry = project_entry?;
59 let project_path = project_entry.path();
60 if !project_path.is_dir() {
61 continue;
62 }
63 let project_name = project_entry
64 .file_name()
65 .to_string_lossy()
66 .into_owned();
67
68 let entries = fs::read_dir(&project_path)
70 .with_context(|| format!("failed to read project dir: {}", project_path.display()))?;
71
72 for entry in entries {
73 let entry = entry?;
74 let entry_path = entry.path();
75 let file_name = entry.file_name().to_string_lossy().into_owned();
76
77 if entry_path.is_file() {
78 if !file_name.ends_with(".jsonl") {
80 continue;
81 }
82
83 let stem = file_name.trim_end_matches(".jsonl");
84
85 if is_uuid(stem) {
86 results.push(SessionFile {
88 session_id: stem.to_string(),
89 project: Some(project_name.clone()),
90 file_path: entry_path,
91 is_agent: false,
92 parent_session_id: None,
93 });
94 } else if stem.starts_with("agent-") {
95 results.push(SessionFile {
97 session_id: stem.to_string(),
98 project: Some(project_name.clone()),
99 file_path: entry_path,
100 is_agent: true,
101 parent_session_id: None,
102 });
103 }
104 } else if entry_path.is_dir() {
105 if file_name == "memory" || file_name == "tool-results" {
107 continue;
108 }
109
110 if is_uuid(&file_name) {
112 let parent_uuid = file_name.clone();
113 let subagents_dir = entry_path.join("subagents");
114 if subagents_dir.is_dir() {
115 let sub_entries = fs::read_dir(&subagents_dir).with_context(|| {
116 format!(
117 "failed to read subagents dir: {}",
118 subagents_dir.display()
119 )
120 })?;
121
122 for sub_entry in sub_entries {
123 let sub_entry = sub_entry?;
124 let sub_path = sub_entry.path();
125 let sub_name = sub_entry.file_name().to_string_lossy().into_owned();
126
127 if !sub_path.is_file() || !sub_name.ends_with(".jsonl") {
128 continue;
129 }
130
131 let sub_stem = sub_name.trim_end_matches(".jsonl");
132 if sub_stem.starts_with("agent-") {
133 results.push(SessionFile {
135 session_id: sub_stem.to_string(),
136 project: Some(project_name.clone()),
137 file_path: sub_path,
138 is_agent: true,
139 parent_session_id: Some(parent_uuid.clone()),
140 });
141 }
142 }
143 }
144 }
145 }
146 }
147 }
148
149 Ok(results)
150}
151
152pub fn resolve_agent_parents(files: &mut [SessionFile]) -> Result<()> {
155 for file in files.iter_mut() {
156 if !file.is_agent || file.parent_session_id.is_some() {
157 continue;
158 }
159
160 let f = fs::File::open(&file.file_path).with_context(|| {
162 format!(
163 "failed to open agent file for parent resolution: {}",
164 file.file_path.display()
165 )
166 })?;
167 let reader = BufReader::new(f);
168
169 if let Some(Ok(first_line)) = reader.lines().next() {
170 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&first_line) {
171 if let Some(sid) = val.get("sessionId").and_then(|v| v.as_str()) {
172 file.parent_session_id = Some(sid.to_string());
173 }
174 }
175 }
176 }
177
178 Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use std::fs;
185 use tempfile::TempDir;
186
187 fn setup_claude_home() -> TempDir {
189 let tmp = TempDir::new().unwrap();
190 let projects = tmp.path().join("projects");
191 fs::create_dir_all(&projects).unwrap();
192 tmp
193 }
194
195 #[test]
196 fn scan_finds_all_session_types() {
197 let tmp = setup_claude_home();
198 let project_dir = tmp.path().join("projects").join("-Users-testuser-myproject");
199 fs::create_dir_all(&project_dir).unwrap();
200
201 let main_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
203 fs::write(
204 project_dir.join(format!("{}.jsonl", main_uuid)),
205 r#"{"type":"user","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}"#,
206 )
207 .unwrap();
208
209 fs::write(
211 project_dir.join("agent-abc1234.jsonl"),
212 r#"{"type":"user","sessionId":"parent-session-id-here"}"#,
213 )
214 .unwrap();
215
216 let subagents_dir = project_dir.join(main_uuid).join("subagents");
218 fs::create_dir_all(&subagents_dir).unwrap();
219 fs::write(
220 subagents_dir.join("agent-long-id-abcdef1234567890.jsonl"),
221 r#"{"type":"user","sessionId":"sub-session"}"#,
222 )
223 .unwrap();
224
225 let files = scan_claude_home(tmp.path()).unwrap();
226
227 assert_eq!(files.len(), 3, "should find 3 session files, found: {files:?}");
228
229 let main = files.iter().find(|f| f.session_id == main_uuid).unwrap();
230 assert!(!main.is_agent);
231 assert!(main.parent_session_id.is_none());
232
233 let legacy = files
234 .iter()
235 .find(|f| f.session_id == "agent-abc1234")
236 .unwrap();
237 assert!(legacy.is_agent);
238 assert!(legacy.parent_session_id.is_none()); let new_agent = files
241 .iter()
242 .find(|f| f.session_id == "agent-long-id-abcdef1234567890")
243 .unwrap();
244 assert!(new_agent.is_agent);
245 assert_eq!(
246 new_agent.parent_session_id.as_deref(),
247 Some(main_uuid),
248 "new-style agent should have parent_session_id from directory name"
249 );
250 }
251
252 #[test]
253 fn agent_has_parent_session_id() {
254 let tmp = setup_claude_home();
255 let project_dir = tmp.path().join("projects").join("-Users-testuser-myproject");
256 let parent_uuid = "11111111-2222-3333-4444-555555555555";
257 let subagents_dir = project_dir.join(parent_uuid).join("subagents");
258 fs::create_dir_all(&subagents_dir).unwrap();
259
260 fs::write(
261 subagents_dir.join("agent-newstyle-001.jsonl"),
262 r#"{"type":"user","sessionId":"agent-newstyle-001"}"#,
263 )
264 .unwrap();
265
266 let files = scan_claude_home(tmp.path()).unwrap();
267
268 assert_eq!(files.len(), 1);
269 let agent = &files[0];
270 assert!(agent.is_agent);
271 assert_eq!(
272 agent.parent_session_id.as_deref(),
273 Some(parent_uuid),
274 "new-style agent parent_session_id must match the UUID directory"
275 );
276 }
277
278 #[test]
279 fn ignores_non_jsonl_files() {
280 let tmp = setup_claude_home();
281 let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
282 fs::create_dir_all(&project_dir).unwrap();
283
284 fs::write(project_dir.join("something.meta.json"), "{}").unwrap();
286
287 let tool_results = project_dir.join("tool-results");
289 fs::create_dir_all(&tool_results).unwrap();
290 fs::write(tool_results.join("result.jsonl"), "{}").unwrap();
291
292 let memory = project_dir.join("memory");
294 fs::create_dir_all(&memory).unwrap();
295 fs::write(memory.join("notes.jsonl"), "{}").unwrap();
296
297 fs::write(project_dir.join("notes.txt"), "hello").unwrap();
299
300 let files = scan_claude_home(tmp.path()).unwrap();
301 assert!(
302 files.is_empty(),
303 "should not find any session files, but found: {files:?}"
304 );
305 }
306
307 #[test]
308 fn resolve_legacy_agent_parent() {
309 let tmp = setup_claude_home();
310 let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
311 fs::create_dir_all(&project_dir).unwrap();
312
313 let agent_file = project_dir.join("agent-xyz7890.jsonl");
314 fs::write(
315 &agent_file,
316 r#"{"type":"user","sessionId":"parent-sess-id","uuid":"u1"}
317{"type":"assistant","sessionId":"parent-sess-id","uuid":"u2"}"#,
318 )
319 .unwrap();
320
321 let mut files = scan_claude_home(tmp.path()).unwrap();
322 assert_eq!(files.len(), 1);
323 assert!(files[0].parent_session_id.is_none());
324
325 resolve_agent_parents(&mut files).unwrap();
326 assert_eq!(
327 files[0].parent_session_id.as_deref(),
328 Some("parent-sess-id"),
329 "legacy agent parent_session_id should come from first line's sessionId"
330 );
331 }
332}