Skip to main content

ccboard_core/parsers/
invocations.rs

1//! Parser for extracting agent/command/skill invocations from session files
2
3use 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
12/// Regex for detecting command invocations (e.g., /commit, /help)
13fn 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/// Parser for invocation statistics
19#[derive(Debug)]
20pub struct InvocationParser {
21    /// Maximum lines to scan per session (circuit breaker)
22    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    /// Scan a single session file for invocations
37    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            // Circuit breaker
65            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            // Parse line (skip malformed)
75            let session_line: SessionLine = match serde_json::from_str(&line_result) {
76                Ok(l) => l,
77                Err(_) => continue,
78            };
79
80            // Detect agents and skills from assistant messages (tool_use content)
81            if let Some(ref message) = session_line.message {
82                if let Some(ref content) = message.content {
83                    // Content is now Value (can be String or Array)
84                    if let Some(content_array) = content.as_array() {
85                        for item in content_array {
86                            if let Some(obj) = item.as_object() {
87                                // Check for Task tool
88                                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                                // Check for Skill tool
104                                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            // Detect commands: user messages starting with /
126            if session_line.line_type == "user" {
127                if let Some(ref message) = session_line.message {
128                    if let Some(ref content) = message.content {
129                        // Extract text from Value (String or Array)
130                        let text = match content {
131                            serde_json::Value::String(s) => s.as_str(),
132                            serde_json::Value::Array(blocks) => {
133                                // For array, try to get first text block
134                                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    /// Extract invocations from a content string
165    #[allow(dead_code)]
166    fn extract_invocations(&self, content: &str, stats: &mut InvocationStats) {
167        // Detect commands in text
168        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    /// Scan multiple session files and aggregate stats
175    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}