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#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::fs;
185    use tempfile::TempDir;
186
187    /// Helper to create a minimal Claude home with projects structure.
188    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        // Type 1: main session
202        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        // Type 2: legacy agent
210        fs::write(
211            project_dir.join("agent-abc1234.jsonl"),
212            r#"{"type":"user","sessionId":"parent-session-id-here"}"#,
213        )
214        .unwrap();
215
216        // Type 3: new-style agent
217        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()); // not resolved yet
239
240        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        // .meta.json — should be ignored
285        fs::write(project_dir.join("something.meta.json"), "{}").unwrap();
286
287        // tool-results directory — should be ignored
288        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        // memory directory — should be ignored
293        let memory = project_dir.join("memory");
294        fs::create_dir_all(&memory).unwrap();
295        fs::write(memory.join("notes.jsonl"), "{}").unwrap();
296
297        // A random .txt file — should be ignored
298        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}