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/// Load workflow-agent metadata from `.meta.json` files under
41/// `<session_id>/subagents/workflows/wf_*/`.
42///
43/// Returns the same `(agentType, description)` tuple format as
44/// [`load_agent_meta`], keyed by agent id with the `agent-` prefix stripped.
45/// This complements [`load_agent_meta`], which only reads the first-level
46/// `subagents/agent-*.meta.json` sidecars and therefore misses workflow agents.
47pub fn load_workflow_agent_meta(
48    session_id: &str,
49    claude_home: &Path,
50) -> std::collections::HashMap<String, (String, String)> {
51    cc_session_jsonl::scanner::load_workflow_agent_meta(session_id, claude_home)
52        .into_iter()
53        .map(|(k, meta)| {
54            let agent_type = meta.agent_type.unwrap_or_else(|| "unknown".to_string());
55            let description = meta.description.unwrap_or_default();
56            (k, (agent_type, description))
57        })
58        .collect()
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use std::fs;
65    use tempfile::TempDir;
66
67    /// Helper to create a minimal Claude home with projects structure.
68    fn setup_claude_home() -> TempDir {
69        let tmp = TempDir::new().unwrap();
70        let projects = tmp.path().join("projects");
71        fs::create_dir_all(&projects).unwrap();
72        tmp
73    }
74
75    #[test]
76    fn scan_finds_all_session_types() {
77        let tmp = setup_claude_home();
78        let project_dir = tmp
79            .path()
80            .join("projects")
81            .join("-Users-testuser-myproject");
82        fs::create_dir_all(&project_dir).unwrap();
83
84        // Type 1: main session
85        let main_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
86        fs::write(
87            project_dir.join(format!("{}.jsonl", main_uuid)),
88            r#"{"type":"user","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}"#,
89        )
90        .unwrap();
91
92        // Type 2: legacy agent
93        fs::write(
94            project_dir.join("agent-abc1234.jsonl"),
95            r#"{"type":"user","sessionId":"parent-session-id-here"}"#,
96        )
97        .unwrap();
98
99        // Type 3: new-style agent
100        let subagents_dir = project_dir.join(main_uuid).join("subagents");
101        fs::create_dir_all(&subagents_dir).unwrap();
102        fs::write(
103            subagents_dir.join("agent-long-id-abcdef1234567890.jsonl"),
104            r#"{"type":"user","sessionId":"sub-session"}"#,
105        )
106        .unwrap();
107
108        let files = scan_claude_home(tmp.path()).unwrap();
109
110        assert_eq!(
111            files.len(),
112            3,
113            "should find 3 session files, found: {files:?}"
114        );
115
116        let main = files.iter().find(|f| f.session_id == main_uuid).unwrap();
117        assert!(!main.is_agent);
118        assert!(main.parent_session_id.is_none());
119
120        let legacy = files
121            .iter()
122            .find(|f| f.session_id == "agent-abc1234")
123            .unwrap();
124        assert!(legacy.is_agent);
125        assert!(legacy.parent_session_id.is_none()); // not resolved yet
126
127        let new_agent = files
128            .iter()
129            .find(|f| f.session_id == "agent-long-id-abcdef1234567890")
130            .unwrap();
131        assert!(new_agent.is_agent);
132        assert_eq!(
133            new_agent.parent_session_id.as_deref(),
134            Some(main_uuid),
135            "new-style agent should have parent_session_id from directory name"
136        );
137    }
138
139    #[test]
140    fn agent_has_parent_session_id() {
141        let tmp = setup_claude_home();
142        let project_dir = tmp
143            .path()
144            .join("projects")
145            .join("-Users-testuser-myproject");
146        let parent_uuid = "11111111-2222-3333-4444-555555555555";
147        let subagents_dir = project_dir.join(parent_uuid).join("subagents");
148        fs::create_dir_all(&subagents_dir).unwrap();
149
150        fs::write(
151            subagents_dir.join("agent-newstyle-001.jsonl"),
152            r#"{"type":"user","sessionId":"agent-newstyle-001"}"#,
153        )
154        .unwrap();
155
156        let files = scan_claude_home(tmp.path()).unwrap();
157
158        assert_eq!(files.len(), 1);
159        let agent = &files[0];
160        assert!(agent.is_agent);
161        assert_eq!(
162            agent.parent_session_id.as_deref(),
163            Some(parent_uuid),
164            "new-style agent parent_session_id must match the UUID directory"
165        );
166    }
167
168    #[test]
169    fn ignores_non_jsonl_files() {
170        let tmp = setup_claude_home();
171        let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
172        fs::create_dir_all(&project_dir).unwrap();
173
174        // .meta.json — should be ignored
175        fs::write(project_dir.join("something.meta.json"), "{}").unwrap();
176
177        // tool-results directory — should be ignored
178        let tool_results = project_dir.join("tool-results");
179        fs::create_dir_all(&tool_results).unwrap();
180        fs::write(tool_results.join("result.jsonl"), "{}").unwrap();
181
182        // memory directory — should be ignored
183        let memory = project_dir.join("memory");
184        fs::create_dir_all(&memory).unwrap();
185        fs::write(memory.join("notes.jsonl"), "{}").unwrap();
186
187        // A random .txt file — should be ignored
188        fs::write(project_dir.join("notes.txt"), "hello").unwrap();
189
190        let files = scan_claude_home(tmp.path()).unwrap();
191        assert!(
192            files.is_empty(),
193            "should not find any session files, but found: {files:?}"
194        );
195    }
196
197    #[test]
198    fn resolve_legacy_agent_parent() {
199        let tmp = setup_claude_home();
200        let project_dir = tmp.path().join("projects").join("-Users-testuser-proj");
201        fs::create_dir_all(&project_dir).unwrap();
202
203        let agent_file = project_dir.join("agent-xyz7890.jsonl");
204        fs::write(
205            &agent_file,
206            r#"{"type":"user","sessionId":"parent-sess-id","uuid":"u1"}
207{"type":"assistant","sessionId":"parent-sess-id","uuid":"u2"}"#,
208        )
209        .unwrap();
210
211        let mut files = scan_claude_home(tmp.path()).unwrap();
212        assert_eq!(files.len(), 1);
213        assert!(files[0].parent_session_id.is_none());
214
215        resolve_agent_parents(&mut files).unwrap();
216        assert_eq!(
217            files[0].parent_session_id.as_deref(),
218            Some("parent-sess-id"),
219            "legacy agent parent_session_id should come from first line's sessionId"
220        );
221    }
222}