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
181pub fn load_agent_meta(session_id: &str, claude_home: &Path) -> std::collections::HashMap<String, (String, String)> {
184 let mut result = std::collections::HashMap::new();
185 let projects_dir = claude_home.join("projects");
186 if !projects_dir.exists() { return result; }
187
188 if let Ok(entries) = fs::read_dir(&projects_dir) {
190 for entry in entries.flatten() {
191 let subagents_dir = entry.path().join(session_id).join("subagents");
192 if !subagents_dir.exists() { continue; }
193
194 if let Ok(sub_entries) = fs::read_dir(&subagents_dir) {
195 for sub_entry in sub_entries.flatten() {
196 let name = sub_entry.file_name().to_string_lossy().to_string();
197 if !name.ends_with(".meta.json") { continue; }
198 let agent_id = name.trim_start_matches("agent-").trim_end_matches(".meta.json");
199
200 if let Ok(content) = fs::read_to_string(sub_entry.path()) {
201 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
202 let agent_type = val.get("agentType").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
203 let description = val.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
204 result.insert(agent_id.to_string(), (agent_type, description));
205 }
206 }
207 }
208 }
209 }
210 }
211 result
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::fs;
218 use tempfile::TempDir;
219
220 fn setup_claude_home() -> TempDir {
222 let tmp = TempDir::new().unwrap();
223 let projects = tmp.path().join("projects");
224 fs::create_dir_all(&projects).unwrap();
225 tmp
226 }
227
228 #[test]
229 fn scan_finds_all_session_types() {
230 let tmp = setup_claude_home();
231 let project_dir = tmp.path().join("projects").join("-Users-testuser-myproject");
232 fs::create_dir_all(&project_dir).unwrap();
233
234 let main_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
236 fs::write(
237 project_dir.join(format!("{}.jsonl", main_uuid)),
238 r#"{"type":"user","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}"#,
239 )
240 .unwrap();
241
242 fs::write(
244 project_dir.join("agent-abc1234.jsonl"),
245 r#"{"type":"user","sessionId":"parent-session-id-here"}"#,
246 )
247 .unwrap();
248
249 let subagents_dir = project_dir.join(main_uuid).join("subagents");
251 fs::create_dir_all(&subagents_dir).unwrap();
252 fs::write(
253 subagents_dir.join("agent-long-id-abcdef1234567890.jsonl"),
254 r#"{"type":"user","sessionId":"sub-session"}"#,
255 )
256 .unwrap();
257
258 let files = scan_claude_home(tmp.path()).unwrap();
259
260 assert_eq!(files.len(), 3, "should find 3 session files, found: {files:?}");
261
262 let main = files.iter().find(|f| f.session_id == main_uuid).unwrap();
263 assert!(!main.is_agent);
264 assert!(main.parent_session_id.is_none());
265
266 let legacy = files
267 .iter()
268 .find(|f| f.session_id == "agent-abc1234")
269 .unwrap();
270 assert!(legacy.is_agent);
271 assert!(legacy.parent_session_id.is_none()); let new_agent = files
274 .iter()
275 .find(|f| f.session_id == "agent-long-id-abcdef1234567890")
276 .unwrap();
277 assert!(new_agent.is_agent);
278 assert_eq!(
279 new_agent.parent_session_id.as_deref(),
280 Some(main_uuid),
281 "new-style agent should have parent_session_id from directory name"
282 );
283 }
284
285 #[test]
286 fn agent_has_parent_session_id() {
287 let tmp = setup_claude_home();
288 let project_dir = tmp.path().join("projects").join("-Users-testuser-myproject");
289 let parent_uuid = "11111111-2222-3333-4444-555555555555";
290 let subagents_dir = project_dir.join(parent_uuid).join("subagents");
291 fs::create_dir_all(&subagents_dir).unwrap();
292
293 fs::write(
294 subagents_dir.join("agent-newstyle-001.jsonl"),
295 r#"{"type":"user","sessionId":"agent-newstyle-001"}"#,
296 )
297 .unwrap();
298
299 let files = scan_claude_home(tmp.path()).unwrap();
300
301 assert_eq!(files.len(), 1);
302 let agent = &files[0];
303 assert!(agent.is_agent);
304 assert_eq!(
305 agent.parent_session_id.as_deref(),
306 Some(parent_uuid),
307 "new-style agent parent_session_id must match the UUID directory"
308 );
309 }
310
311 #[test]
312 fn ignores_non_jsonl_files() {
313 let tmp = setup_claude_home();
314 let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
315 fs::create_dir_all(&project_dir).unwrap();
316
317 fs::write(project_dir.join("something.meta.json"), "{}").unwrap();
319
320 let tool_results = project_dir.join("tool-results");
322 fs::create_dir_all(&tool_results).unwrap();
323 fs::write(tool_results.join("result.jsonl"), "{}").unwrap();
324
325 let memory = project_dir.join("memory");
327 fs::create_dir_all(&memory).unwrap();
328 fs::write(memory.join("notes.jsonl"), "{}").unwrap();
329
330 fs::write(project_dir.join("notes.txt"), "hello").unwrap();
332
333 let files = scan_claude_home(tmp.path()).unwrap();
334 assert!(
335 files.is_empty(),
336 "should not find any session files, but found: {files:?}"
337 );
338 }
339
340 #[test]
341 fn resolve_legacy_agent_parent() {
342 let tmp = setup_claude_home();
343 let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
344 fs::create_dir_all(&project_dir).unwrap();
345
346 let agent_file = project_dir.join("agent-xyz7890.jsonl");
347 fs::write(
348 &agent_file,
349 r#"{"type":"user","sessionId":"parent-sess-id","uuid":"u1"}
350{"type":"assistant","sessionId":"parent-sess-id","uuid":"u2"}"#,
351 )
352 .unwrap();
353
354 let mut files = scan_claude_home(tmp.path()).unwrap();
355 assert_eq!(files.len(), 1);
356 assert!(files[0].parent_session_id.is_none());
357
358 resolve_agent_parents(&mut files).unwrap();
359 assert_eq!(
360 files[0].parent_session_id.as_deref(),
361 Some("parent-sess-id"),
362 "legacy agent parent_session_id should come from first line's sessionId"
363 );
364 }
365}