Skip to main content

terraphim_session_analyzer/
tool_analyzer.rs

1//! Tool analysis and command parsing logic
2
3use std::collections::HashMap;
4
5use crate::models::{ToolInvocation, ToolStatistics};
6
7/// Shell built-ins and keywords to exclude from tool detection
8#[allow(dead_code)] // Will be used in Phase 2
9const EXCLUDED_SHELL_BUILTINS: &[&str] = &[
10    "cd", "ls", "pwd", "echo", "cat", "mkdir", "rm", "cp", "mv", "export", "source", "if", "then",
11    "else", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "local",
12    "set", "unset", "shift", "test", "[", "[[", "alias", "unalias", "bg", "fg", "jobs", "wait",
13    "kill", "exit", "break", "continue", "read", "printf", "pushd", "popd", "dirs", "true",
14    "false", ":", ".",
15];
16
17/// Parse command into components (command, args, flags)
18///
19/// # Arguments
20/// * `command` - The full command string
21/// * `tool_start` - Offset where the tool name starts
22///
23/// # Returns
24/// Tuple of (full_command, args, flags) or None if parsing fails
25pub fn parse_command_context(
26    command: &str,
27    tool_start: usize,
28) -> Option<(String, Vec<String>, HashMap<String, String>)> {
29    // Split on shell operators (&&, ||, ;, |)
30    let cmd_parts = split_command_pipeline(command);
31
32    // Find segment containing the tool
33    let relevant_part = cmd_parts
34        .iter()
35        .find(|part| {
36            // Check if this part contains the tool at the right position
37            if let Some(offset) = command.find(*part) {
38                tool_start >= offset && tool_start < offset + part.len()
39            } else {
40                false
41            }
42        })?
43        .trim();
44
45    // Simple tokenization (space-separated)
46    let tokens: Vec<String> = shell_words::split(relevant_part).ok()?;
47
48    if tokens.is_empty() {
49        return None;
50    }
51
52    let mut args = Vec::new();
53    let mut flags = HashMap::new();
54
55    let mut i = 1; // Skip command itself
56    while i < tokens.len() {
57        let token = &tokens[i];
58
59        if token.starts_with("--") {
60            // Long flag: --env production
61            let flag_name = token.trim_start_matches("--");
62            let flag_value = tokens.get(i + 1).cloned().unwrap_or_default();
63            flags.insert(flag_name.to_string(), flag_value);
64            i += 2;
65        } else if token.starts_with('-') && token.len() > 1 {
66            // Short flag: -f value
67            let flag_name = token.trim_start_matches('-');
68            let flag_value = tokens.get(i + 1).cloned().unwrap_or_default();
69            flags.insert(flag_name.to_string(), flag_value);
70            i += 2;
71        } else {
72            // Positional argument
73            args.push(token.clone());
74            i += 1;
75        }
76    }
77
78    Some((relevant_part.to_string(), args, flags))
79}
80
81/// Split command on shell operators while respecting quotes
82pub fn split_command_pipeline(command: &str) -> Vec<String> {
83    let mut parts = Vec::new();
84    let mut current = String::new();
85    let mut in_quotes = false;
86    let mut quote_char = ' ';
87
88    let chars: Vec<char> = command.chars().collect();
89    let mut i = 0;
90
91    while i < chars.len() {
92        let ch = chars[i];
93
94        match ch {
95            '"' | '\'' if !in_quotes => {
96                in_quotes = true;
97                quote_char = ch;
98                current.push(ch);
99            }
100            '"' | '\'' if in_quotes && ch == quote_char => {
101                in_quotes = false;
102                current.push(ch);
103            }
104            '&' | '|' | ';' if !in_quotes => {
105                // Handle && and ||
106                if (ch == '&' || ch == '|') && i + 1 < chars.len() && chars[i + 1] == ch {
107                    if !current.trim().is_empty() {
108                        parts.push(current.trim().to_string());
109                        current.clear();
110                    }
111                    i += 2;
112                    continue;
113                }
114                if !current.trim().is_empty() {
115                    parts.push(current.trim().to_string());
116                    current.clear();
117                }
118            }
119            _ => current.push(ch),
120        }
121        i += 1;
122    }
123
124    if !current.trim().is_empty() {
125        parts.push(current.trim().to_string());
126    }
127
128    parts
129}
130
131/// Check if a command is an actual tool invocation (not a shell built-in)
132#[must_use]
133#[allow(dead_code)] // Used in parser for filtering shell builtins
134pub fn is_actual_tool(tool_name: &str) -> bool {
135    // Extract just the command name without path
136    let base_name = tool_name.rsplit('/').next().unwrap_or(tool_name).trim();
137
138    // Check if it's an excluded built-in
139    !EXCLUDED_SHELL_BUILTINS.contains(&base_name)
140}
141
142/// Calculate tool statistics from invocations
143/// Replaced by Analyzer::calculate_tool_statistics - kept for compatibility
144#[must_use]
145#[allow(dead_code)]
146pub fn calculate_tool_statistics(
147    invocations: &[ToolInvocation],
148) -> HashMap<String, ToolStatistics> {
149    let mut stats: HashMap<String, ToolStatistics> = HashMap::new();
150
151    for inv in invocations {
152        let stat = stats
153            .entry(inv.tool_name.clone())
154            .or_insert_with(|| ToolStatistics {
155                tool_name: inv.tool_name.clone(),
156                category: inv.tool_category.clone(),
157                total_invocations: 0,
158                agents_using: Vec::new(),
159                success_count: 0,
160                failure_count: 0,
161                first_seen: inv.timestamp,
162                last_seen: inv.timestamp,
163                command_patterns: Vec::new(),
164                sessions: Vec::new(),
165            });
166
167        stat.total_invocations += 1;
168
169        // Track agents
170        if let Some(ref agent) = inv.agent_context {
171            if !stat.agents_using.contains(agent) {
172                stat.agents_using.push(agent.clone());
173            }
174        }
175
176        // Track sessions
177        if !stat.sessions.contains(&inv.session_id) {
178            stat.sessions.push(inv.session_id.clone());
179        }
180
181        // Update timestamps
182        if inv.timestamp < stat.first_seen {
183            stat.first_seen = inv.timestamp;
184        }
185        if inv.timestamp > stat.last_seen {
186            stat.last_seen = inv.timestamp;
187        }
188
189        // Track success/failure
190        match inv.exit_code {
191            Some(0) => stat.success_count += 1,
192            Some(_) => stat.failure_count += 1,
193            None => {}
194        }
195
196        // Track command patterns (store unique base commands)
197        let base_cmd = format!("{} {}", inv.tool_name, inv.arguments.join(" "));
198        if !stat.command_patterns.contains(&base_cmd) && stat.command_patterns.len() < 10 {
199            stat.command_patterns.push(base_cmd);
200        }
201    }
202
203    stats
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_parse_command_context() {
212        let cmd = "npx wrangler deploy --env production";
213        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
214
215        assert!(full.contains("wrangler"));
216        assert!(args.contains(&"deploy".to_string()));
217        assert_eq!(flags.get("env"), Some(&"production".to_string()));
218    }
219
220    #[test]
221    fn test_split_command_pipeline() {
222        let cmd = "npm install && npm build";
223        let parts = split_command_pipeline(cmd);
224
225        assert_eq!(parts.len(), 2);
226        assert_eq!(parts[0], "npm install");
227        assert_eq!(parts[1], "npm build");
228    }
229
230    #[test]
231    fn test_split_with_quotes() {
232        let cmd = r#"echo "hello && world" && npm install"#;
233        let parts = split_command_pipeline(cmd);
234
235        assert_eq!(parts.len(), 2);
236        assert!(parts[0].contains("hello && world"));
237    }
238
239    #[test]
240    fn test_split_with_pipe() {
241        let cmd = "cat file.txt | grep pattern";
242        let parts = split_command_pipeline(cmd);
243
244        assert_eq!(parts.len(), 2);
245        assert_eq!(parts[0], "cat file.txt");
246        assert_eq!(parts[1], "grep pattern");
247    }
248
249    // Comprehensive wrangler command parsing tests
250    #[test]
251    fn test_parse_wrangler_deploy_with_env() {
252        let cmd = "npx wrangler deploy --env production";
253        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
254
255        assert!(full.contains("wrangler"));
256        assert_eq!(args, vec!["wrangler", "deploy"]);
257        assert_eq!(flags.get("env"), Some(&"production".to_string()));
258    }
259
260    #[test]
261    fn test_parse_wrangler_complex_flags() {
262        let cmd = "npx wrangler deploy --env prod --minify --compatibility-date 2024-01-01";
263        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
264
265        assert!(full.contains("wrangler"));
266        assert_eq!(args, vec!["wrangler", "deploy", "2024-01-01"]);
267        assert_eq!(flags.get("env"), Some(&"prod".to_string()));
268        assert_eq!(
269            flags.get("minify"),
270            Some(&"--compatibility-date".to_string())
271        );
272    }
273
274    #[test]
275    fn test_parse_wrangler_bunx() {
276        let cmd = "bunx wrangler login";
277        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
278
279        assert!(full.contains("wrangler"));
280        assert_eq!(args, vec!["wrangler", "login"]);
281        assert!(flags.is_empty());
282    }
283
284    #[test]
285    fn test_parse_wrangler_pnpm() {
286        let cmd = "pnpm wrangler deploy --env staging";
287        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
288
289        assert!(full.contains("wrangler"));
290        assert_eq!(args, vec!["wrangler", "deploy"]);
291        assert_eq!(flags.get("env"), Some(&"staging".to_string()));
292    }
293
294    #[test]
295    fn test_parse_wrangler_yarn() {
296        let cmd = "yarn wrangler publish";
297        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
298
299        assert!(full.contains("wrangler"));
300        assert_eq!(args, vec!["wrangler", "publish"]);
301        assert!(flags.is_empty());
302    }
303
304    #[test]
305    fn test_parse_wrangler_dev() {
306        let cmd = "npx wrangler dev --port 8787";
307        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
308
309        assert!(full.contains("wrangler"));
310        assert_eq!(args, vec!["wrangler", "dev"]);
311        assert_eq!(flags.get("port"), Some(&"8787".to_string()));
312    }
313
314    #[test]
315    fn test_parse_wrangler_tail() {
316        let cmd = "bunx wrangler tail my-worker";
317        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
318
319        assert!(full.contains("wrangler"));
320        assert_eq!(args, vec!["wrangler", "tail", "my-worker"]);
321        assert!(flags.is_empty());
322    }
323
324    #[test]
325    fn test_parse_wrangler_kv_commands() {
326        let cmd = "npx wrangler kv:namespace create NAMESPACE --preview";
327        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
328
329        assert!(full.contains("wrangler"));
330        assert_eq!(
331            args,
332            vec!["wrangler", "kv:namespace", "create", "NAMESPACE"]
333        );
334        assert!(flags.contains_key("preview"));
335    }
336
337    #[test]
338    fn test_parse_wrangler_pages_deploy() {
339        let cmd = "npx wrangler pages deploy ./dist --project-name my-project --branch main";
340        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
341
342        assert!(full.contains("wrangler"));
343        assert_eq!(args, vec!["wrangler", "pages", "deploy", "./dist"]);
344        assert_eq!(flags.get("project-name"), Some(&"my-project".to_string()));
345        assert_eq!(flags.get("branch"), Some(&"main".to_string()));
346    }
347
348    #[test]
349    fn test_parse_wrangler_secret_put() {
350        let cmd = "npx wrangler secret put API_KEY";
351        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
352
353        assert!(full.contains("wrangler"));
354        assert_eq!(args, vec!["wrangler", "secret", "put", "API_KEY"]);
355        assert!(flags.is_empty());
356    }
357
358    #[test]
359    fn test_parse_wrangler_in_pipeline() {
360        let cmd = "npm install && npx wrangler deploy --env production && npm test";
361        let (full, args, flags) = parse_command_context(cmd, 15).unwrap(); // Start at "npx"
362
363        assert!(full.contains("wrangler"));
364        assert_eq!(args, vec!["wrangler", "deploy"]);
365        assert_eq!(flags.get("env"), Some(&"production".to_string()));
366    }
367
368    #[test]
369    fn test_parse_wrangler_with_output_redirect() {
370        let cmd = "npx wrangler deploy --env prod";
371        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
372
373        assert!(full.contains("wrangler"));
374        assert!(args.contains(&"wrangler".to_string()));
375        assert!(args.contains(&"deploy".to_string()));
376        assert_eq!(flags.get("env"), Some(&"prod".to_string()));
377    }
378
379    #[test]
380    fn test_parse_wrangler_init() {
381        let cmd = "bunx wrangler init my-worker --type rust";
382        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
383
384        assert!(full.contains("wrangler"));
385        assert_eq!(args, vec!["wrangler", "init", "my-worker"]);
386        assert_eq!(flags.get("type"), Some(&"rust".to_string()));
387    }
388
389    #[test]
390    fn test_parse_wrangler_whoami() {
391        let cmd = "npx wrangler whoami";
392        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
393
394        assert!(full.contains("wrangler"));
395        assert_eq!(args, vec!["wrangler", "whoami"]);
396        assert!(flags.is_empty());
397    }
398
399    #[test]
400    fn test_parse_wrangler_case_insensitive() {
401        let cmd = "NPX WRANGLER DEPLOY --ENV PRODUCTION";
402        let (full, args, flags) = parse_command_context(cmd, 0).unwrap();
403
404        assert!(full.to_lowercase().contains("wrangler"));
405        assert_eq!(args.len(), 2); // wrangler, deploy
406        assert!(!flags.is_empty());
407    }
408}