Skip to main content

cc_token_usage/data/
scanner.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::io::{BufRead, BufReader};
4use std::path::Path;
5
6use super::models::SessionFile;
7
8/// Check if a string looks like a UUID (8-4-4-4-12 hex pattern).
9fn 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
21/// Scan `~/.claude/projects/` for all session JSONL files and return metadata.
22///
23/// Finds three kinds of files:
24/// 1. Main sessions: `<project>/<uuid>.jsonl`
25/// 2. Legacy agents: `<project>/agent-<id>.jsonl`
26/// 3. New-style agents: `<project>/<uuid>/subagents/agent-<id>.jsonl`
27pub 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
32/// Scan a projects directory for all session JSONL files.
33///
34/// This is the core scanner that works on any directory containing project
35/// subdirectories with JSONL session files. `scan_claude_home` delegates to
36/// this after appending `projects/`.
37///
38/// Directory structure expected:
39/// ```text
40/// projects_dir/
41///   <project>/
42///     <uuid>.jsonl              — main session
43///     agent-<id>.jsonl          — legacy agent
44///     <uuid>/subagents/agent-<id>.jsonl — new-style agent
45/// ```
46pub 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    // Iterate over project directories
54    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        // Iterate over entries inside each project directory
69        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                // Skip non-jsonl files
79                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                    // Type 1: Main session — <project>/<uuid>.jsonl
87                    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                    // Type 2: Legacy agent — <project>/agent-<id>.jsonl
96                    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                // Skip well-known non-session directories
106                if file_name == "memory" || file_name == "tool-results" {
107                    continue;
108                }
109
110                // Check for new-style agents under <uuid>/subagents/
111                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                                // Type 3: New-style agent
134                                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
152/// For legacy agent files that have no parent_session_id yet, read the first
153/// JSON line and extract the `sessionId` field to use as parent_session_id.
154pub 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        // Read first line and extract sessionId
161        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/// Load agent metadata from .meta.json files for a given session.
182/// Returns a map of agent_id (without "agent-" prefix) -> (agentType, description).
183pub 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    // Search all project dirs for <session_id>/subagents/agent-*.meta.json
189    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    /// Helper to create a minimal Claude home with projects structure.
221    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        // Type 1: main session
235        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        // Type 2: legacy agent
243        fs::write(
244            project_dir.join("agent-abc1234.jsonl"),
245            r#"{"type":"user","sessionId":"parent-session-id-here"}"#,
246        )
247        .unwrap();
248
249        // Type 3: new-style agent
250        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()); // not resolved yet
272
273        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        // .meta.json — should be ignored
318        fs::write(project_dir.join("something.meta.json"), "{}").unwrap();
319
320        // tool-results directory — should be ignored
321        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        // memory directory — should be ignored
326        let memory = project_dir.join("memory");
327        fs::create_dir_all(&memory).unwrap();
328        fs::write(memory.join("notes.jsonl"), "{}").unwrap();
329
330        // A random .txt file — should be ignored
331        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}