ccboard_core/parsers/
invocations.rs1use crate::error::CoreError;
4use crate::models::{InvocationStats, SessionLine};
5use regex::Regex;
6use std::path::Path;
7use std::sync::OnceLock;
8use tokio::fs::File;
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tracing::{debug, trace};
11
12fn command_regex() -> &'static Regex {
14 static COMMAND_RE: OnceLock<Regex> = OnceLock::new();
15 COMMAND_RE.get_or_init(|| Regex::new(r"^/([a-z][a-z0-9-]*)").unwrap())
16}
17
18#[derive(Debug)]
20pub struct InvocationParser {
21 max_lines: usize,
23}
24
25impl Default for InvocationParser {
26 fn default() -> Self {
27 Self { max_lines: 50_000 }
28 }
29}
30
31impl InvocationParser {
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub async fn scan_session(&self, path: &Path) -> Result<InvocationStats, CoreError> {
38 let mut stats = InvocationStats::new();
39 stats.sessions_analyzed = 1;
40
41 let file = File::open(path).await.map_err(|e| {
42 if e.kind() == std::io::ErrorKind::NotFound {
43 CoreError::FileNotFound {
44 path: path.to_path_buf(),
45 }
46 } else {
47 CoreError::FileRead {
48 path: path.to_path_buf(),
49 source: e,
50 }
51 }
52 })?;
53
54 let reader = BufReader::new(file);
55 let mut lines = reader.lines();
56 let mut line_number = 0;
57
58 while let Some(line_result) = lines.next_line().await.map_err(|e| CoreError::FileRead {
59 path: path.to_path_buf(),
60 source: e,
61 })? {
62 line_number += 1;
63
64 if line_number > self.max_lines {
66 debug!(
67 path = %path.display(),
68 lines = line_number,
69 "Invocation scan hit line limit"
70 );
71 break;
72 }
73
74 let session_line: SessionLine = match serde_json::from_str(&line_result) {
76 Ok(l) => l,
77 Err(_) => continue,
78 };
79
80 if let Some(ref message) = session_line.message {
82 if let Some(ref content) = message.content {
83 if let Some(content_array) = content.as_array() {
85 for item in content_array {
86 if let Some(obj) = item.as_object() {
87 if obj.get("name").and_then(|v| v.as_str()) == Some("Task") {
89 if let Some(input) =
90 obj.get("input").and_then(|v| v.as_object())
91 {
92 if let Some(agent_type) =
93 input.get("subagent_type").and_then(|v| v.as_str())
94 {
95 *stats
96 .agents
97 .entry(agent_type.to_string())
98 .or_insert(0) += 1;
99 trace!(agent = agent_type, "Detected agent invocation");
100 }
101 }
102 }
103 if obj.get("name").and_then(|v| v.as_str()) == Some("Skill") {
105 if let Some(input) =
106 obj.get("input").and_then(|v| v.as_object())
107 {
108 if let Some(skill_name) =
109 input.get("skill").and_then(|v| v.as_str())
110 {
111 *stats
112 .skills
113 .entry(skill_name.to_string())
114 .or_insert(0) += 1;
115 trace!(skill = skill_name, "Detected skill invocation");
116 }
117 }
118 }
119 }
120 }
121 }
122 }
123 }
124
125 if session_line.line_type == "user" {
127 if let Some(ref message) = session_line.message {
128 if let Some(ref content) = message.content {
129 let text = match content {
131 serde_json::Value::String(s) => s.as_str(),
132 serde_json::Value::Array(blocks) => {
133 blocks
135 .first()
136 .and_then(|block| block.get("text"))
137 .and_then(|t| t.as_str())
138 .unwrap_or("")
139 }
140 _ => "",
141 };
142
143 if let Some(caps) = command_regex().captures(text) {
144 let command = format!("/{}", &caps[1]);
145 *stats.commands.entry(command.clone()).or_insert(0) += 1;
146 trace!(command, "Detected command invocation");
147 }
148 }
149 }
150 }
151 }
152
153 debug!(
154 path = %path.display(),
155 agents = stats.agents.len(),
156 commands = stats.commands.len(),
157 skills = stats.skills.len(),
158 "Invocation scan complete"
159 );
160
161 Ok(stats)
162 }
163
164 fn extract_invocations(&self, content: &str, stats: &mut InvocationStats) {
166 if let Some(caps) = command_regex().captures(content) {
168 let command = format!("/{}", &caps[1]);
169 *stats.commands.entry(command).or_insert(0) += 1;
170 }
171 }
172
173 pub async fn scan_sessions(&self, paths: &[impl AsRef<Path>]) -> InvocationStats {
175 let mut aggregated = InvocationStats::new();
176
177 for path in paths {
178 match self.scan_session(path.as_ref()).await {
179 Ok(stats) => aggregated.merge(&stats),
180 Err(e) => {
181 trace!(
182 path = %path.as_ref().display(),
183 error = %e,
184 "Failed to scan session for invocations"
185 );
186 }
187 }
188 }
189
190 debug!(
191 sessions = aggregated.sessions_analyzed,
192 total = aggregated.total_invocations(),
193 "Aggregated invocation stats"
194 );
195
196 aggregated
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::io::Write;
204 use tempfile::NamedTempFile;
205
206 #[tokio::test]
207 async fn test_detect_agent_invocation() {
208 let mut file = NamedTempFile::new().unwrap();
209 writeln!(
210 file,
211 r#"{{"type": "assistant", "message": {{"content": [{{"type":"tool_use","name":"Task","input":{{"subagent_type":"technical-writer","description":"Create docs"}}}}]}}}}"#
212 )
213 .unwrap();
214
215 let parser = InvocationParser::new();
216 let stats = parser.scan_session(file.path()).await.unwrap();
217
218 assert_eq!(stats.agents.get("technical-writer"), Some(&1));
219 assert_eq!(stats.sessions_analyzed, 1);
220 }
221
222 #[tokio::test]
223 async fn test_detect_skill_invocation() {
224 let mut file = NamedTempFile::new().unwrap();
225 writeln!(
226 file,
227 r#"{{"type": "assistant", "message": {{"content": [{{"type":"tool_use","name":"Skill","input":{{"skill":"pdf-generator"}}}}]}}}}"#
228 )
229 .unwrap();
230
231 let parser = InvocationParser::new();
232 let stats = parser.scan_session(file.path()).await.unwrap();
233
234 assert_eq!(stats.skills.get("pdf-generator"), Some(&1));
235 }
236
237 #[tokio::test]
238 async fn test_detect_command_invocation() {
239 let mut file = NamedTempFile::new().unwrap();
240 writeln!(
241 file,
242 r#"{{"type": "user", "message": {{"content": "/commit -m \"Fix bug\""}}}}"#
243 )
244 .unwrap();
245 writeln!(
246 file,
247 r#"{{"type": "user", "message": {{"content": "/help"}}}}"#
248 )
249 .unwrap();
250
251 let parser = InvocationParser::new();
252 let stats = parser.scan_session(file.path()).await.unwrap();
253
254 assert_eq!(stats.commands.get("/commit"), Some(&1));
255 assert_eq!(stats.commands.get("/help"), Some(&1));
256 }
257
258 #[test]
259 fn test_command_regex() {
260 let re = command_regex();
261 assert!(re.is_match("/commit"));
262 assert!(re.is_match("/help"));
263 assert!(re.is_match("/review-pr"));
264 assert!(!re.is_match("not a command"));
265 assert!(!re.is_match("/ space"));
266 }
267
268 #[tokio::test]
269 async fn test_aggregation() {
270 let mut file1 = NamedTempFile::new().unwrap();
271 writeln!(
272 file1,
273 r#"{{"type": "user", "message": {{"content": "/commit"}}}}"#
274 )
275 .unwrap();
276
277 let mut file2 = NamedTempFile::new().unwrap();
278 writeln!(
279 file2,
280 r#"{{"type": "user", "message": {{"content": "/commit"}}}}"#
281 )
282 .unwrap();
283
284 let parser = InvocationParser::new();
285 let paths = vec![file1.path(), file2.path()];
286 let stats = parser.scan_sessions(&paths).await;
287
288 assert_eq!(stats.commands.get("/commit"), Some(&2));
289 assert_eq!(stats.sessions_analyzed, 2);
290 }
291}