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 #[allow(dead_code)]
166 fn extract_invocations(&self, content: &str, stats: &mut InvocationStats) {
167 if let Some(caps) = command_regex().captures(content) {
169 let command = format!("/{}", &caps[1]);
170 *stats.commands.entry(command).or_insert(0) += 1;
171 }
172 }
173
174 pub async fn scan_sessions(&self, paths: &[impl AsRef<Path>]) -> InvocationStats {
176 let mut aggregated = InvocationStats::new();
177
178 for path in paths {
179 match self.scan_session(path.as_ref()).await {
180 Ok(stats) => aggregated.merge(&stats),
181 Err(e) => {
182 trace!(
183 path = %path.as_ref().display(),
184 error = %e,
185 "Failed to scan session for invocations"
186 );
187 }
188 }
189 }
190
191 debug!(
192 sessions = aggregated.sessions_analyzed,
193 total = aggregated.total_invocations(),
194 "Aggregated invocation stats"
195 );
196
197 aggregated
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::io::Write;
205 use tempfile::NamedTempFile;
206
207 #[tokio::test]
208 async fn test_detect_agent_invocation() {
209 let mut file = NamedTempFile::new().unwrap();
210 writeln!(
211 file,
212 r#"{{"type": "assistant", "message": {{"content": [{{"type":"tool_use","name":"Task","input":{{"subagent_type":"technical-writer","description":"Create docs"}}}}]}}}}"#
213 )
214 .unwrap();
215
216 let parser = InvocationParser::new();
217 let stats = parser.scan_session(file.path()).await.unwrap();
218
219 assert_eq!(stats.agents.get("technical-writer"), Some(&1));
220 assert_eq!(stats.sessions_analyzed, 1);
221 }
222
223 #[tokio::test]
224 async fn test_detect_skill_invocation() {
225 let mut file = NamedTempFile::new().unwrap();
226 writeln!(
227 file,
228 r#"{{"type": "assistant", "message": {{"content": [{{"type":"tool_use","name":"Skill","input":{{"skill":"pdf-generator"}}}}]}}}}"#
229 )
230 .unwrap();
231
232 let parser = InvocationParser::new();
233 let stats = parser.scan_session(file.path()).await.unwrap();
234
235 assert_eq!(stats.skills.get("pdf-generator"), Some(&1));
236 }
237
238 #[tokio::test]
239 async fn test_detect_command_invocation() {
240 let mut file = NamedTempFile::new().unwrap();
241 writeln!(
242 file,
243 r#"{{"type": "user", "message": {{"content": "/commit -m \"Fix bug\""}}}}"#
244 )
245 .unwrap();
246 writeln!(
247 file,
248 r#"{{"type": "user", "message": {{"content": "/help"}}}}"#
249 )
250 .unwrap();
251
252 let parser = InvocationParser::new();
253 let stats = parser.scan_session(file.path()).await.unwrap();
254
255 assert_eq!(stats.commands.get("/commit"), Some(&1));
256 assert_eq!(stats.commands.get("/help"), Some(&1));
257 }
258
259 #[test]
260 fn test_command_regex() {
261 let re = command_regex();
262 assert!(re.is_match("/commit"));
263 assert!(re.is_match("/help"));
264 assert!(re.is_match("/review-pr"));
265 assert!(!re.is_match("not a command"));
266 assert!(!re.is_match("/ space"));
267 }
268
269 #[tokio::test]
270 async fn test_aggregation() {
271 let mut file1 = NamedTempFile::new().unwrap();
272 writeln!(
273 file1,
274 r#"{{"type": "user", "message": {{"content": "/commit"}}}}"#
275 )
276 .unwrap();
277
278 let mut file2 = NamedTempFile::new().unwrap();
279 writeln!(
280 file2,
281 r#"{{"type": "user", "message": {{"content": "/commit"}}}}"#
282 )
283 .unwrap();
284
285 let parser = InvocationParser::new();
286 let paths = vec![file1.path(), file2.path()];
287 let stats = parser.scan_sessions(&paths).await;
288
289 assert_eq!(stats.commands.get("/commit"), Some(&2));
290 assert_eq!(stats.sessions_analyzed, 2);
291 }
292}