Skip to main content

limit_cli/tools/
analysis.rs

1use async_trait::async_trait;
2use limit_agent::error::AgentError;
3use limit_agent::Tool;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::path::Path;
8use std::process::Command;
9
10const GREP_MAX_RESULTS: usize = 1000;
11const GREP_CONTEXT_LINES: usize = 3;
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct Position {
15    pub line: u32,
16    pub character: u32,
17}
18pub struct GrepTool;
19
20impl GrepTool {
21    pub fn new() -> Self {
22        GrepTool
23    }
24}
25
26impl Default for GrepTool {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32#[async_trait]
33impl Tool for GrepTool {
34    fn name(&self) -> &str {
35        "grep"
36    }
37
38    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
39        let pattern: String = serde_json::from_value(args["pattern"].clone())
40            .map_err(|e| AgentError::ToolError(format!("Invalid pattern argument: {}", e)))?;
41
42        if pattern.trim().is_empty() {
43            return Err(AgentError::ToolError(
44                "pattern argument cannot be empty".to_string(),
45            ));
46        }
47
48        // Validate regex pattern
49        Regex::new(&pattern)
50            .map_err(|e| AgentError::ToolError(format!("Invalid regex pattern: {}", e)))?;
51
52        let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
53
54        // Validate path exists
55        if !Path::new(path).exists() {
56            return Err(AgentError::ToolError(format!("Path not found: {}", path)));
57        }
58
59        // Use grep command-line tool
60        let mut cmd = Command::new("grep");
61        cmd.arg("-r")
62            .arg("-n")
63            .arg("-I") // Ignore binary files
64            .arg("--color=never")
65            .args(["-C", &GREP_CONTEXT_LINES.to_string()])
66            .arg(&pattern)
67            .arg(path);
68
69        let output = cmd
70            .output()
71            .map_err(|e| AgentError::ToolError(format!("Failed to execute grep: {}", e)))?;
72
73        if !output.status.success() {
74            // grep returns non-zero if no matches found, but that's not an error
75            let stderr = String::from_utf8_lossy(&output.stderr);
76            if !stderr.is_empty() && !stderr.contains("No such file") {
77                return Err(AgentError::ToolError(format!("grep failed: {}", stderr)));
78            }
79        }
80
81        let stdout = String::from_utf8_lossy(&output.stdout);
82        let lines: Vec<&str> = stdout.lines().collect();
83
84        // Limit results
85        let limited_lines = if lines.len() > GREP_MAX_RESULTS {
86            lines[..GREP_MAX_RESULTS].to_vec()
87        } else {
88            lines
89        };
90
91        // Parse grep output
92        let mut matches = Vec::new();
93        for line in limited_lines {
94            // Parse grep output format: "filename:line_number:content"
95            if let Some((rest, content)) = line.split_once(':') {
96                if let Some((file_path, line_number)) = rest.split_once(':') {
97                    if let Ok(line_num) = line_number.parse::<usize>() {
98                        matches.push(serde_json::json!({
99                            "file": file_path,
100                            "line": line_num,
101                            "content": content
102                        }));
103                    }
104                }
105            }
106        }
107
108        Ok(serde_json::json!({
109            "matches": matches,
110            "count": matches.len(),
111            "pattern": pattern
112        }))
113    }
114}
115
116pub struct AstGrepTool;
117
118impl AstGrepTool {
119    pub fn new() -> Self {
120        AstGrepTool
121    }
122
123    fn get_language_support(lang: &str) -> Result<&'static str, AgentError> {
124        match lang.to_lowercase().as_str() {
125            "rust" | "rs" => Ok("rust"),
126            "typescript" | "ts" | "tsx" => Ok("typescript"),
127            "python" | "py" => Ok("python"),
128            _ => Err(AgentError::ToolError(format!(
129                "Unsupported language: {}. Supported: rust, typescript, python",
130                lang
131            ))),
132        }
133    }
134}
135
136impl Default for AstGrepTool {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142#[async_trait]
143impl Tool for AstGrepTool {
144    fn name(&self) -> &str {
145        "ast_grep"
146    }
147
148    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
149        let pattern: String = serde_json::from_value(args["pattern"].clone())
150            .map_err(|e| AgentError::ToolError(format!("Invalid pattern argument: {}", e)))?;
151
152        if pattern.trim().is_empty() {
153            return Err(AgentError::ToolError(
154                "pattern argument cannot be empty".to_string(),
155            ));
156        }
157
158        let language: String = serde_json::from_value(args["language"].clone())
159            .map_err(|e| AgentError::ToolError(format!("Invalid language argument: {}", e)))?;
160
161        let lang = Self::get_language_support(&language)?;
162
163        let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
164
165        // Validate path exists
166        if !Path::new(path).exists() {
167            return Err(AgentError::ToolError(format!("Path not found: {}", path)));
168        }
169
170        // Check if ast-grep CLI is available
171        let check_result = Command::new("ast-grep").arg("--version").output();
172
173        match check_result {
174            Ok(output) if output.status.success() => {}
175            _ => {
176                return Err(AgentError::ToolError(
177                    "ast-grep not found in PATH. Please install ast-grep CLI tool.".to_string(),
178                ));
179            }
180        }
181
182        // Use ast-grep CLI tool
183        let mut cmd = Command::new("ast-grep");
184        cmd.arg("run")
185            .arg("--json")
186            .args(["--lang", lang])
187            .arg(&pattern)
188            .arg(path);
189
190        let output = cmd
191            .output()
192            .map_err(|e| AgentError::ToolError(format!("Failed to execute ast-grep: {}", e)))?;
193
194        if !output.status.success() {
195            let stderr = String::from_utf8_lossy(&output.stderr);
196            return Err(AgentError::ToolError(format!(
197                "ast-grep failed: {}",
198                stderr
199            )));
200        }
201
202        let stdout = String::from_utf8_lossy(&output.stdout);
203
204        // Parse JSON output from ast-grep
205        if stdout.trim().is_empty() {
206            return Ok(serde_json::json!({
207                "matches": [],
208                "count": 0,
209                "pattern": pattern,
210                "language": language
211            }));
212        }
213
214        // ast-grep returns JSON objects per line
215        let mut matches = Vec::new();
216        for line in stdout.lines() {
217            if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
218                matches.push(json_value);
219            }
220        }
221
222        Ok(serde_json::json!({
223            "matches": matches,
224            "count": matches.len(),
225            "pattern": pattern,
226            "language": language
227        }))
228    }
229}
230
231pub struct LspTool;
232
233impl LspTool {
234    pub fn new() -> Self {
235        LspTool
236    }
237
238    fn get_lsp_server(file_path: &Path) -> Result<String, AgentError> {
239        let extension = file_path
240            .extension()
241            .and_then(|ext| ext.to_str())
242            .unwrap_or("");
243
244        match extension {
245            "rs" => Ok("rust-analyzer".to_string()),
246            "ts" | "tsx" | "js" | "jsx" => Ok("typescript-language-server".to_string()),
247            "py" => Ok("pylsp".to_string()),
248            _ => Err(AgentError::ToolError(format!(
249                "Unsupported file extension: {}. Supported: rs, ts, tsx, js, jsx, py",
250                extension
251            ))),
252        }
253    }
254
255    fn check_lsp_server_available(server_name: &str) -> Result<(), AgentError> {
256        let result = Command::new(server_name).arg("--version").output();
257
258        match result {
259            Ok(output) if output.status.success() => Ok(()),
260            Ok(_) => Err(AgentError::ToolError(format!(
261                "LSP server {} failed to execute",
262                server_name
263            ))),
264            Err(_) => Err(AgentError::ToolError(format!(
265                "LSP server {} not found in PATH. Please install it to use LSP features.",
266                server_name
267            ))),
268        }
269    }
270}
271
272impl Default for LspTool {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278#[async_trait]
279impl Tool for LspTool {
280    fn name(&self) -> &str {
281        "lsp"
282    }
283
284    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
285        let command: String = serde_json::from_value(args["command"].clone())
286            .map_err(|e| AgentError::ToolError(format!("Invalid command argument: {}", e)))?;
287
288        // Validate command first
289        match command.as_str() {
290            "goto_definition" | "find_references" => {}
291            _ => {
292                return Err(AgentError::ToolError(format!(
293                    "Unsupported LSP command: {}. Supported: goto_definition, find_references",
294                    command
295                )));
296            }
297        }
298
299        let file_path: String = serde_json::from_value(args["file_path"].clone())
300            .map_err(|e| AgentError::ToolError(format!("Invalid file_path argument: {}", e)))?;
301
302        if !Path::new(&file_path).exists() {
303            return Err(AgentError::ToolError(format!(
304                "File not found: {}",
305                file_path
306            )));
307        }
308
309        let position: Position = serde_json::from_value(args["position"].clone())
310            .map_err(|e| AgentError::ToolError(format!("Invalid position argument: {}", e)))?;
311        let lsp_server = Self::get_lsp_server(Path::new(&file_path))?;
312        Self::check_lsp_server_available(&lsp_server)?;
313
314        // For now, return a mock response
315        // Full LSP protocol implementation would require a proper LSP client
316        //
317        // For rust-analyzer: use rust-analyzer proc-macro
318        // For typescript: use tsserver
319        // For python: use pylsp
320        match command.as_str() {
321            "goto_definition" => Ok(serde_json::json!({
322                "command": command,
323                "file_path": file_path,
324                "position": position,
325                "result": "LSP goto_definition requires full LSP client implementation",
326                "note": "This is a placeholder. Implement full LSP client for production use."
327            })),
328            "find_references" => Ok(serde_json::json!({
329                "command": command,
330                "file_path": file_path,
331                "position": position,
332                "result": "LSP find_references requires full LSP client implementation",
333                "note": "This is a placeholder. Implement full LSP client for production use."
334            })),
335            _ => unreachable!(),
336        }
337    }
338}
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::io::Write;
343    use tempfile::NamedTempFile;
344
345    #[tokio::test]
346    async fn test_grep_tool_name() {
347        let tool = GrepTool::new();
348        assert_eq!(tool.name(), "grep");
349    }
350
351    #[tokio::test]
352    async fn test_grep_tool_default() {
353        let tool = GrepTool;
354        assert_eq!(tool.name(), "grep");
355    }
356
357    #[tokio::test]
358    async fn test_grep_tool_empty_pattern() {
359        let tool = GrepTool::new();
360        let args = serde_json::json!({
361            "pattern": ""
362        });
363
364        let result = tool.execute(args).await;
365        assert!(result.is_err());
366        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
367    }
368
369    #[tokio::test]
370    async fn test_grep_tool_invalid_regex() {
371        let tool = GrepTool::new();
372        let args = serde_json::json!({
373            "pattern": "[invalid(regex"
374        });
375
376        let result = tool.execute(args).await;
377        assert!(result.is_err());
378        assert!(result.unwrap_err().to_string().contains("Invalid regex"));
379    }
380
381    #[tokio::test]
382    async fn test_grep_tool_path_not_found() {
383        let tool = GrepTool::new();
384        let args = serde_json::json!({
385            "pattern": "test",
386            "path": "/nonexistent/path"
387        });
388
389        let result = tool.execute(args).await;
390        assert!(result.is_err());
391        assert!(result.unwrap_err().to_string().contains("not found"));
392    }
393
394    #[tokio::test]
395    async fn test_ast_grep_tool_name() {
396        let tool = AstGrepTool::new();
397        assert_eq!(tool.name(), "ast_grep");
398    }
399
400    #[tokio::test]
401    async fn test_ast_grep_tool_default() {
402        let tool = AstGrepTool;
403        assert_eq!(tool.name(), "ast_grep");
404    }
405
406    #[tokio::test]
407    async fn test_ast_grep_tool_empty_pattern() {
408        let tool = AstGrepTool::new();
409        let args = serde_json::json!({
410            "pattern": "",
411            "language": "rust"
412        });
413
414        let result = tool.execute(args).await;
415        assert!(result.is_err());
416        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
417    }
418
419    #[tokio::test]
420    async fn test_ast_grep_tool_unsupported_lang() {
421        let tool = AstGrepTool::new();
422        let args = serde_json::json!({
423            "pattern": "test",
424            "language": "java"
425        });
426
427        let result = tool.execute(args).await;
428        assert!(result.is_err());
429        assert!(result
430            .unwrap_err()
431            .to_string()
432            .contains("Unsupported language"));
433    }
434
435    #[tokio::test]
436    async fn test_ast_grep_tool_path_not_found() {
437        let tool = AstGrepTool::new();
438        let args = serde_json::json!({
439            "pattern": "test",
440            "language": "rust",
441            "path": "/nonexistent/path"
442        });
443
444        let result = tool.execute(args).await;
445        assert!(result.is_err());
446        assert!(result.unwrap_err().to_string().contains("not found"));
447    }
448
449    #[tokio::test]
450    async fn test_ast_grep_tool_rust() {
451        let tool = AstGrepTool::new();
452
453        // Create a temp file with Rust code
454        let mut temp_file = NamedTempFile::new().unwrap();
455        writeln!(temp_file, "fn hello() {{}}").unwrap();
456        writeln!(temp_file, "fn world() {{}}").unwrap();
457
458        let args = serde_json::json!({
459            "pattern": "fn $NAME() {}",
460            "language": "rust",
461            "path": temp_file.path().parent().unwrap().to_str().unwrap()
462        });
463
464        // This will fail if ast-grep is not installed, but we test the parsing logic
465        let result = tool.execute(args).await;
466
467        // If ast-grep is not available, we should get a specific error
468        // If it is available, we should get a valid result
469        match result {
470            Ok(_) => {
471                // ast-grep is available and executed successfully
472            }
473            Err(e) => {
474                // Either ast-grep is not available or there was another error
475                let error_msg = e.to_string();
476                assert!(
477                    error_msg.contains("ast-grep not found") || error_msg.contains("failed"),
478                    "Unexpected error: {}",
479                    error_msg
480                );
481            }
482        }
483    }
484
485    #[tokio::test]
486    async fn test_ast_grep_tool_typescript() {
487        let tool = AstGrepTool::new();
488
489        let args = serde_json::json!({
490            "pattern": "console.log($MSG)",
491            "language": "typescript"
492        });
493
494        // This will fail if ast-grep is not in a TS project, but we test the parsing
495        let result = tool.execute(args).await;
496
497        // If ast-grep is not available, we should get a specific error
498        match result {
499            Ok(_) => {}
500            Err(e) => {
501                let error_msg = e.to_string();
502                assert!(
503                    error_msg.contains("ast-grep not found") || error_msg.contains("failed"),
504                    "Unexpected error: {}",
505                    error_msg
506                );
507            }
508        }
509    }
510
511    #[tokio::test]
512    async fn test_ast_grep_tool_python() {
513        let tool = AstGrepTool::new();
514
515        let args = serde_json::json!({
516            "pattern": "def $FUNC():",
517            "language": "python"
518        });
519
520        // This will fail if ast-grep is not in a Python project, but we test the parsing
521        let result = tool.execute(args).await;
522
523        // If ast-grep is not available, we should get a specific error
524        match result {
525            Ok(_) => {}
526            Err(e) => {
527                let error_msg = e.to_string();
528                assert!(
529                    error_msg.contains("ast-grep not found") || error_msg.contains("failed"),
530                    "Unexpected error: {}",
531                    error_msg
532                );
533            }
534        }
535    }
536
537    #[tokio::test]
538    async fn test_lsp_tool_name() {
539        let tool = LspTool::new();
540        assert_eq!(tool.name(), "lsp");
541    }
542
543    #[tokio::test]
544    async fn test_lsp_tool_default() {
545        let tool = LspTool;
546        assert_eq!(tool.name(), "lsp");
547    }
548
549    #[tokio::test]
550    async fn test_lsp_tool_missing_command() {
551        let tool = LspTool::new();
552        let args = serde_json::json!({
553            "command": "invalid_command"
554        });
555
556        let result = tool.execute(args).await;
557        assert!(result.is_err());
558        assert!(result
559            .unwrap_err()
560            .to_string()
561            .contains("Unsupported LSP command"));
562    }
563
564    #[tokio::test]
565    async fn test_lsp_tool_file_not_found() {
566        let tool = LspTool::new();
567        let args = serde_json::json!({
568            "command": "goto_definition",
569            "file_path": "/nonexistent/file.rs",
570            "position": {"line": 1, "character": 0}
571        });
572
573        let result = tool.execute(args).await;
574        assert!(result.is_err());
575        assert!(result.unwrap_err().to_string().contains("not found"));
576    }
577
578    #[tokio::test]
579    async fn test_lsp_tool_unsupported_extension() {
580        let tool = LspTool::new();
581
582        let mut temp_file = NamedTempFile::new().unwrap();
583        writeln!(temp_file, "test").unwrap();
584
585        let args = serde_json::json!({
586            "command": "goto_definition",
587            "file_path": temp_file.path(),
588            "position": {"line": 1, "character": 0}
589        });
590
591        let result = tool.execute(args).await;
592        assert!(result.is_err());
593        assert!(result
594            .unwrap_err()
595            .to_string()
596            .contains("Unsupported file extension"));
597    }
598
599    #[tokio::test]
600    async fn test_lsp_tool_missing_server() {
601        let tool = LspTool::new();
602
603        // Create a Rust file
604        let temp_dir = tempfile::tempdir().unwrap();
605        let rust_file = temp_dir.path().join("test.rs");
606        std::fs::write(&rust_file, "fn main() {}").unwrap();
607
608        let args = serde_json::json!({
609            "command": "goto_definition",
610            "file_path": rust_file,
611            "position": {"line": 0, "character": 0}
612        });
613        // This will likely fail because rust-analyzer is not installed in test environment
614        // But we test the parsing logic
615        let result = tool.execute(args).await;
616
617        // Expect either success (if rust-analyzer is installed) or error about missing server
618        match result {
619            Ok(value) => {
620                // LSP server is available
621                assert!(value["command"] == "goto_definition");
622            }
623            Err(e) => {
624                let error_msg = e.to_string();
625                assert!(
626                    error_msg.contains("not found in PATH")
627                        || error_msg.contains("failed to execute"),
628                    "Unexpected error: {}",
629                    error_msg
630                );
631            }
632        }
633    }
634
635    #[tokio::test]
636    async fn test_all_tools_implement_default() {
637        let _grep = GrepTool;
638        let _ast_grep = AstGrepTool;
639        let _lsp = LspTool;
640    }
641
642    #[tokio::test]
643    async fn test_position_deserialize() {
644        let json = serde_json::json!({"line": 10, "character": 5});
645        let pos: Position = serde_json::from_value(json).unwrap();
646        assert_eq!(pos.line, 10);
647        assert_eq!(pos.character, 5);
648    }
649}