Skip to main content

cc_token_usage/data/
scanner.rs

1//! Session file discovery — thin wrapper around cc-session-jsonl's scanner.
2//!
3//! Re-exports the core scanner functions and types. The `load_agent_meta`
4//! function converts cc-session-jsonl's `AgentMeta` into the (String, String)
5//! tuple format expected by the existing analysis layer.
6
7use std::path::Path;
8
9pub use cc_session_jsonl::scanner::{
10    resolve_agent_parents, scan_sessions as scan_sessions_raw, SessionFile,
11};
12
13/// Scan `~/.claude/projects/` for all session JSONL files and return metadata.
14///
15/// This is a thin wrapper around `cc_session_jsonl::scan_sessions` that converts
16/// the `io::Result` into `anyhow::Result` for compatibility with the rest of the codebase.
17pub fn scan_claude_home(claude_home: &Path) -> anyhow::Result<Vec<SessionFile>> {
18    Ok(cc_session_jsonl::scanner::scan_sessions(claude_home)?)
19}
20
21/// Load agent metadata from .meta.json files for a given session.
22/// Returns a map of agent_id (e.g., "agent-abc123") -> (agentType, description).
23///
24/// This wraps cc-session-jsonl's `load_agent_meta` and converts `AgentMeta`
25/// into the tuple format used by the existing session analysis code.
26pub fn load_agent_meta(
27    session_id: &str,
28    claude_home: &Path,
29) -> std::collections::HashMap<String, (String, String)> {
30    cc_session_jsonl::scanner::load_agent_meta(session_id, claude_home)
31        .into_iter()
32        .map(|(k, meta)| {
33            let agent_type = meta.agent_type.unwrap_or_else(|| "unknown".to_string());
34            let description = meta.description.unwrap_or_default();
35            (k, (agent_type, description))
36        })
37        .collect()
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43    use std::fs;
44    use tempfile::TempDir;
45
46    /// Helper to create a minimal Claude home with projects structure.
47    fn setup_claude_home() -> TempDir {
48        let tmp = TempDir::new().unwrap();
49        let projects = tmp.path().join("projects");
50        fs::create_dir_all(&projects).unwrap();
51        tmp
52    }
53
54    #[test]
55    fn scan_finds_all_session_types() {
56        let tmp = setup_claude_home();
57        let project_dir = tmp
58            .path()
59            .join("projects")
60            .join("-Users-testuser-myproject");
61        fs::create_dir_all(&project_dir).unwrap();
62
63        // Type 1: main session
64        let main_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
65        fs::write(
66            project_dir.join(format!("{}.jsonl", main_uuid)),
67            r#"{"type":"user","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}"#,
68        )
69        .unwrap();
70
71        // Type 2: legacy agent
72        fs::write(
73            project_dir.join("agent-abc1234.jsonl"),
74            r#"{"type":"user","sessionId":"parent-session-id-here"}"#,
75        )
76        .unwrap();
77
78        // Type 3: new-style agent
79        let subagents_dir = project_dir.join(main_uuid).join("subagents");
80        fs::create_dir_all(&subagents_dir).unwrap();
81        fs::write(
82            subagents_dir.join("agent-long-id-abcdef1234567890.jsonl"),
83            r#"{"type":"user","sessionId":"sub-session"}"#,
84        )
85        .unwrap();
86
87        let files = scan_claude_home(tmp.path()).unwrap();
88
89        assert_eq!(
90            files.len(),
91            3,
92            "should find 3 session files, found: {files:?}"
93        );
94
95        let main = files.iter().find(|f| f.session_id == main_uuid).unwrap();
96        assert!(!main.is_agent);
97        assert!(main.parent_session_id.is_none());
98
99        let legacy = files
100            .iter()
101            .find(|f| f.session_id == "agent-abc1234")
102            .unwrap();
103        assert!(legacy.is_agent);
104        assert!(legacy.parent_session_id.is_none()); // not resolved yet
105
106        let new_agent = files
107            .iter()
108            .find(|f| f.session_id == "agent-long-id-abcdef1234567890")
109            .unwrap();
110        assert!(new_agent.is_agent);
111        assert_eq!(
112            new_agent.parent_session_id.as_deref(),
113            Some(main_uuid),
114            "new-style agent should have parent_session_id from directory name"
115        );
116    }
117
118    #[test]
119    fn agent_has_parent_session_id() {
120        let tmp = setup_claude_home();
121        let project_dir = tmp
122            .path()
123            .join("projects")
124            .join("-Users-testuser-myproject");
125        let parent_uuid = "11111111-2222-3333-4444-555555555555";
126        let subagents_dir = project_dir.join(parent_uuid).join("subagents");
127        fs::create_dir_all(&subagents_dir).unwrap();
128
129        fs::write(
130            subagents_dir.join("agent-newstyle-001.jsonl"),
131            r#"{"type":"user","sessionId":"agent-newstyle-001"}"#,
132        )
133        .unwrap();
134
135        let files = scan_claude_home(tmp.path()).unwrap();
136
137        assert_eq!(files.len(), 1);
138        let agent = &files[0];
139        assert!(agent.is_agent);
140        assert_eq!(
141            agent.parent_session_id.as_deref(),
142            Some(parent_uuid),
143            "new-style agent parent_session_id must match the UUID directory"
144        );
145    }
146
147    #[test]
148    fn ignores_non_jsonl_files() {
149        let tmp = setup_claude_home();
150        let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
151        fs::create_dir_all(&project_dir).unwrap();
152
153        // .meta.json — should be ignored
154        fs::write(project_dir.join("something.meta.json"), "{}").unwrap();
155
156        // tool-results directory — should be ignored
157        let tool_results = project_dir.join("tool-results");
158        fs::create_dir_all(&tool_results).unwrap();
159        fs::write(tool_results.join("result.jsonl"), "{}").unwrap();
160
161        // memory directory — should be ignored
162        let memory = project_dir.join("memory");
163        fs::create_dir_all(&memory).unwrap();
164        fs::write(memory.join("notes.jsonl"), "{}").unwrap();
165
166        // A random .txt file — should be ignored
167        fs::write(project_dir.join("notes.txt"), "hello").unwrap();
168
169        let files = scan_claude_home(tmp.path()).unwrap();
170        assert!(
171            files.is_empty(),
172            "should not find any session files, but found: {files:?}"
173        );
174    }
175
176    #[test]
177    fn resolve_legacy_agent_parent() {
178        let tmp = setup_claude_home();
179        let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
180        fs::create_dir_all(&project_dir).unwrap();
181
182        let agent_file = project_dir.join("agent-xyz7890.jsonl");
183        fs::write(
184            &agent_file,
185            r#"{"type":"user","sessionId":"parent-sess-id","uuid":"u1"}
186{"type":"assistant","sessionId":"parent-sess-id","uuid":"u2"}"#,
187        )
188        .unwrap();
189
190        let mut files = scan_claude_home(tmp.path()).unwrap();
191        assert_eq!(files.len(), 1);
192        assert!(files[0].parent_session_id.is_none());
193
194        resolve_agent_parents(&mut files).unwrap();
195        assert_eq!(
196            files[0].parent_session_id.as_deref(),
197            Some("parent-sess-id"),
198            "legacy agent parent_session_id should come from first line's sessionId"
199        );
200    }
201}