Skip to main content

aacode_rs/tools/
code_tools.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Code tools — execute_python, run_tests, debug_code, analyze_code.
5//!
6//! Ported from Python `tools/code_tools.py` + `tools/custom_tools.py`.
7
8use super::registry::Tool;
9use super::schema::{ParamType, ToolParameter, ToolSchema};
10use crate::error::{AacodeError, Result};
11use crate::tools::ShellBackend;
12use serde_json::{json, Value};
13use std::path::PathBuf;
14use std::sync::atomic::AtomicBool;
15use std::sync::Arc;
16
17// ──────────────────────── execute_python ────────────────────────────────
18
19/// Build the script preamble that best-effort changes into `project_path`.
20///
21/// The fastshell Python sandbox re-roots absolute paths, so a hard
22/// `os.chdir("<host path>")` fails with `FileNotFoundError`. We only chdir when
23/// the path is genuinely a directory and swallow any residual error — the
24/// backend already runs the script with the project dir as cwd, so a skipped
25/// chdir is still correct (no false failure, no wrong cwd).
26fn python_wrapper(project_path: &str, code: &str) -> String {
27    format!(
28        "import sys, os\ntry:\n    _p = {project_path:?}\n    if os.path.isdir(_p):\n        os.chdir(_p)\nexcept Exception:\n    pass\n{code}"
29    )
30}
31
32/// Executes Python code by writing to a temp file and running it through
33/// the shell backend. On desktop this uses the native OS shell (python3);
34/// on mobile it routes through fastshell's embedded CPython engine.
35pub struct ExecutePythonTool {
36    pub project_path: PathBuf,
37    pub backend: Arc<dyn ShellBackend>,
38    pub default_timeout_secs: u64,
39}
40
41#[async_trait::async_trait]
42impl Tool for ExecutePythonTool {
43    fn lane(&self) -> Option<&'static str> {
44        Some("python")
45    }
46    fn schema(&self) -> ToolSchema {
47        ToolSchema::new(
48            "execute_python",
49            "Execute Python code directly. Writes code to a temp file and runs via python3. Returns stdout, stderr, and returncode. For quick inline tests, syntax checks, and one-shot calculations. Large scripts should be written via run_shell heredoc and then executed with run_shell python3.",
50            vec![
51                ToolParameter::new("code", ParamType::String, true, "Python code to execute inline", &["script", "source"]),
52                ToolParameter::new("timeout", ParamType::Integer, false, "Timeout in seconds", &["time_limit", "max_time"]),
53                ToolParameter::new("stdin_input", ParamType::String, false, "Stdin content", &["input", "stdin"]),
54            ],
55        )
56    }
57
58    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
59        let code = args
60            .get("code")
61            .and_then(|v| v.as_str())
62            .unwrap_or("")
63            .to_string();
64        if code.is_empty() {
65            return Ok(json!({"success": false, "error": "empty code"}).to_string());
66        }
67        let stdin_input = args
68            .get("stdin_input")
69            .and_then(|v| v.as_str())
70            .filter(|s| !s.is_empty())
71            .map(|s| s.to_string());
72        let backend = self.backend.clone();
73        let project_path = self.project_path.clone();
74        let default_timeout_secs = self.default_timeout_secs;
75
76        tokio::task::spawn_blocking(move || -> Result<String> {
77            let test_dir = project_path.join(".aacode").join("tests");
78            let _ = std::fs::create_dir_all(&test_dir);
79            let temp_path = test_dir.join(format!("temp_{}.py", uuid::Uuid::new_v4().simple()));
80
81            let wrapped = python_wrapper(&project_path.to_string_lossy(), &code);
82            if let Err(e) = std::fs::write(&temp_path, &wrapped) {
83                return Ok(
84                    json!({"success": false, "error": format!("write temp file: {e}")}).to_string(),
85                );
86            }
87
88            // The backend's cwd is `project_path` and the fastshell sandbox
89            // re-roots absolute paths — pass a RELATIVE path or the script is
90            // looked up under `<sandbox>/<abs>` → FileNotFoundError.
91            let rel = temp_path
92                .strip_prefix(&project_path)
93                .unwrap_or(&temp_path)
94                .to_string_lossy()
95                .to_string();
96
97            let cmd = format!("python3 {rel:?}");
98            let result = backend.run(
99                &cmd,
100                stdin_input.as_deref(),
101                default_timeout_secs,
102                0,
103                &project_path,
104            );
105
106            // Bound leftover temp scripts (the model may re-read a few).
107            prune_temp_scripts(&test_dir, 20);
108
109            // Same binary guard as run_shell: a script that prints bytes must
110            // not paste them into the context.
111            let stdout = if crate::tools::shell::is_binary_text(&result.stdout) {
112                let store = crate::artifacts::ArtifactStore::new(&project_path);
113                crate::tools::shell::binary_output_json(&store, "python_stdout", &result.stdout)
114            } else {
115                json!(result.stdout)
116            };
117
118            Ok(json!({
119                "success": result.exit_code == 0,
120                "returncode": result.exit_code,
121                "file": rel,
122                "stdout": stdout,
123                "stderr": result.stderr,
124            })
125            .to_string())
126        })
127        .await
128        .map_err(|e| AacodeError::Other(format!("execute_python: {e}")))?
129    }
130}
131
132/// Keep at most `keep` most-recent `temp_*.py` scripts in `dir` (the model
133/// re-reads recent ones; older ones are dead weight).
134fn prune_temp_scripts(dir: &std::path::Path, keep: usize) {
135    let Ok(rd) = std::fs::read_dir(dir) else {
136        return;
137    };
138    let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
139        .flatten()
140        .filter(|e| {
141            let n = e.file_name().to_string_lossy().to_string();
142            n.starts_with("temp_") && n.ends_with(".py")
143        })
144        .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
145        .collect();
146    if files.len() <= keep {
147        return;
148    }
149    files.sort_by(|a, b| b.0.cmp(&a.0)); // newest first
150    for (_, p) in files.into_iter().skip(keep) {
151        let _ = std::fs::remove_file(p);
152    }
153}
154
155// ──────────────────────── analyze_code ──────────────────────────────────
156
157/// Analyzes a source file and returns its structure (functions, classes,
158/// imports, line count). Language-agnostic via regex patterns.
159pub struct AnalyzeCodeTool {
160    pub project_path: PathBuf,
161}
162
163#[async_trait::async_trait]
164impl Tool for AnalyzeCodeTool {
165    fn schema(&self) -> ToolSchema {
166        ToolSchema::new(
167            "analyze_code",
168            "Analyze a source code file. Returns detected language, function/class names, imports, and line count.",
169            vec![
170                ToolParameter::new("file_path", ParamType::String, true, "Path to the source file", &["file", "path"]),
171            ],
172        )
173    }
174
175    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
176        let file_path = args
177            .get("file_path")
178            .and_then(|v| v.as_str())
179            .unwrap_or("")
180            .to_string();
181        if file_path.is_empty() {
182            return Ok(json!({"success": false, "error": "empty file_path"}).to_string());
183        }
184        let project_path = self.project_path.clone();
185        let fp = file_path.clone();
186        let content = tokio::task::spawn_blocking(move || {
187            let full_path = if std::path::Path::new(&fp).is_absolute() {
188                std::path::PathBuf::from(&fp)
189            } else {
190                project_path.join(&fp)
191            };
192            std::fs::read_to_string(&full_path)
193        })
194        .await
195        .map_err(|e| AacodeError::Other(format!("analyze_code: {e}")))?;
196        let content = match content {
197            Ok(c) => c,
198            Err(_) => {
199                return Ok(json!({"success": false, "error": "read: file not found"}).to_string())
200            }
201        };
202        let lang = lang_from_ext(&file_path);
203        let lines: Vec<&str> = content.lines().collect();
204
205        // Detect functions and classes (simple regex patterns per language).
206        let patterns: &[(&str, &str)] = match lang {
207            "python" => &[("def ", "function"), ("class ", "class")],
208            "rust" => &[
209                ("fn ", "function"),
210                ("struct ", "struct"),
211                ("impl ", "impl"),
212                ("enum ", "enum"),
213            ],
214            "javascript" | "typescript" => &[
215                ("function ", "function"),
216                ("class ", "class"),
217                ("const ", "const"),
218                ("export ", "export"),
219            ],
220            _ => &[
221                ("def ", "function"),
222                ("fn ", "function"),
223                ("function ", "function"),
224                ("class ", "class"),
225            ],
226        };
227
228        let mut functions = Vec::new();
229        let mut classes = Vec::new();
230        let mut imports = Vec::new();
231
232        for (i, line) in lines.iter().enumerate() {
233            let trimmed = line.trim();
234            for (prefix, kind) in patterns {
235                if let Some(rest) = trimmed.strip_prefix(prefix) {
236                    let rest = rest.trim();
237                    if !rest.is_empty() && !rest.starts_with('(') {
238                        // Take first token (function/class name)
239                        let name: String = rest
240                            .split(|c: char| !c.is_alphanumeric() && c != '_')
241                            .next()
242                            .unwrap_or(rest)
243                            .to_string();
244                        if *kind == "function" || *kind == "fn" {
245                            functions.push(format!("L{}: {name}", i + 1));
246                        } else if *kind == "class" || *kind == "struct" || *kind == "enum" {
247                            classes.push(format!("L{}: {name}", i + 1));
248                        }
249                    } else if let Some(paren_open) = rest.find('(') {
250                        let name = rest[..paren_open].trim().to_string();
251                        if !name.is_empty() {
252                            functions.push(format!("L{}: {name}", i + 1));
253                        }
254                    }
255                    break;
256                }
257            }
258            // Detect imports (language-agnostic).
259            let ln = i + 1;
260            if trimmed.starts_with("import ") || trimmed.starts_with("from ") {
261                imports.push(format!("L{ln}: {trimmed}"));
262            } else if trimmed.starts_with("use ") {
263                imports.push(format!("L{ln}: {trimmed}"));
264            } else if trimmed.starts_with("require(") || trimmed.starts_with("import ") {
265                imports.push(format!("L{ln}: {trimmed}"));
266            }
267        }
268
269        Ok(json!({
270            "success": true,
271            "file": file_path,
272            "language": lang,
273            "line_count": lines.len(),
274            "functions": functions,
275            "classes": classes,
276            "imports": imports,
277            "top_comment": lines.first().map(|s| s.to_string()).filter(|s| s.starts_with('#') || s.starts_with("//")),
278        })
279        .to_string())
280    }
281}
282
283// ──────────────────────── run_tests ─────────────────────────────────────
284
285pub struct RunTestsTool {
286    pub project_path: PathBuf,
287}
288
289#[async_trait::async_trait]
290impl Tool for RunTestsTool {
291    fn schema(&self) -> ToolSchema {
292        ToolSchema::new(
293            "run_tests",
294            "Detect the test framework and suggest the command to run. Does NOT execute tests — use run_shell with the returned suggested_command to actually run them. Use after writing code to verify correctness.",
295            vec![
296                ToolParameter::new("test_path", ParamType::String, false, "Optional specific test file or directory", &["path", "dir", "file"]),
297                ToolParameter::new("timeout", ParamType::Integer, false, "Timeout seconds", &["time_limit"]),
298            ],
299        )
300    }
301
302    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
303        let test_path = args
304            .get("test_path")
305            .and_then(|v| v.as_str())
306            .unwrap_or("")
307            .to_string();
308        let project_path = self.project_path.clone();
309        let lang = tokio::task::spawn_blocking(move || detect_lang(&project_path))
310            .await
311            .map_err(|e| AacodeError::Other(format!("run_tests: {e}")))?;
312        let cmd = match lang {
313            "python" => {
314                if test_path.is_empty() {
315                    "python3 -m pytest".to_string()
316                } else {
317                    format!("python3 -m pytest {test_path}")
318                }
319            }
320            "rust" => {
321                if test_path.is_empty() {
322                    "cargo test".to_string()
323                } else {
324                    format!("cargo test --test {}", test_path.trim_end_matches(".rs"))
325                }
326            }
327            "javascript" | "typescript" => {
328                if test_path.is_empty() {
329                    "npm test".to_string()
330                } else {
331                    format!("npx jest {test_path}")
332                }
333            }
334            _ => {
335                return Ok(json!({"success": false, "error": "No supported test framework detected. Use run_shell directly.", "detected_lang": lang}).to_string());
336            }
337        };
338        Ok(json!({"success": true, "detected_lang": lang, "suggested_command": cmd, "hint": &format!("run_shell(command=\"{cmd}\")")}).to_string())
339    }
340}
341
342// ──────────────────────── debug_code ────────────────────────────────────
343
344pub struct DebugCodeTool {
345    pub project_path: PathBuf,
346}
347
348#[async_trait::async_trait]
349impl Tool for DebugCodeTool {
350    fn schema(&self) -> ToolSchema {
351        ToolSchema::new(
352            "debug_code",
353            "Suggest debugging commands for a file or error message, based on detected language (e.g. pdb/print for Python, dbg!/--nocapture for Rust, node --inspect for JS).",
354            vec![
355                ToolParameter::new("file_path", ParamType::String, false, "File to debug", &["file", "path"]),
356                ToolParameter::new("error_message", ParamType::String, false, "Error message to analyze", &["error", "message"]),
357            ],
358        )
359    }
360
361    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
362        let file = args
363            .get("file_path")
364            .and_then(|v| v.as_str())
365            .unwrap_or("")
366            .to_string();
367        let err_msg = args
368            .get("error_message")
369            .and_then(|v| v.as_str())
370            .unwrap_or("")
371            .to_string();
372        let project_path = self.project_path.clone();
373        let lang = if !file.is_empty() {
374            lang_from_ext(&file)
375        } else {
376            tokio::task::spawn_blocking(move || detect_lang(&project_path))
377                .await
378                .map_err(|e| AacodeError::Other(format!("debug_code: {e}")))?
379        };
380        let suggestions: &[&str] = match lang {
381            "rust" => &[
382                "cargo check",
383                "cargo test -- --nocapture",
384                "dbg!(&variable)",
385            ],
386            "python" => &[
387                "python3 -m pdb <file>",
388                "print(f'{var=}')",
389                "python3 -c 'import ast; ast.parse(open(\"FILE\").read())'",
390            ],
391            "javascript" | "typescript" => {
392                &["node --inspect-brk", "console.log(JSON.stringify(obj))"]
393            }
394            _ => &["Use run_shell to inspect errors."],
395        };
396        let mut out = String::from("Debug suggestions:\n");
397        for s in suggestions {
398            out.push_str(&format!("  - {s}\n"));
399        }
400        if !err_msg.is_empty() {
401            out.push_str(&format!("\nError: {err_msg}\n"));
402        }
403        Ok(json!({"success": true, "detected_lang": lang, "suggestions": suggestions}).to_string())
404    }
405}
406
407// ──────────────────────── helpers ───────────────────────────────────────
408
409fn detect_lang(project: &std::path::Path) -> &'static str {
410    for (lang, markers) in &[
411        ("rust", &["Cargo.toml"][..]),
412        (
413            "python",
414            &[
415                "pyproject.toml",
416                "requirements.txt",
417                "setup.py",
418                "setup.cfg",
419            ],
420        ),
421        (
422            "javascript",
423            &["package.json", "yarn.lock", "pnpm-lock.yaml"],
424        ),
425        ("typescript", &["tsconfig.json"]),
426    ] {
427        for m in *markers {
428            if project.join(m).exists() {
429                return lang;
430            }
431        }
432    }
433    let mut counts = std::collections::HashMap::new();
434    if let Ok(rd) = std::fs::read_dir(project) {
435        for e in rd.flatten() {
436            if let Some(ext) = e.path().extension() {
437                let k = match ext.to_str().unwrap_or("") {
438                    "py" => "python",
439                    "rs" => "rust",
440                    "js" => "javascript",
441                    "ts" => "typescript",
442                    _ => continue,
443                };
444                *counts.entry(k).or_insert(0usize) += 1;
445            }
446        }
447    }
448    counts
449        .into_iter()
450        .max_by_key(|(_, n)| *n)
451        .map(|(k, _)| k)
452        .unwrap_or("unknown")
453}
454
455fn lang_from_ext(file: &str) -> &'static str {
456    if file.ends_with(".py") {
457        "python"
458    } else if file.ends_with(".rs") {
459        "rust"
460    } else if file.ends_with(".ts") {
461        "typescript"
462    } else if file.ends_with(".js") {
463        "javascript"
464    } else {
465        "unknown"
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn python_wrapper_guards_chdir() {
475        let w = python_wrapper("/data/user/0/app/projects/x", "print(1)");
476        // No bare `os.chdir(...)`: it must be guarded by isdir + try/except so a
477        // re-rooted sandbox path can't raise FileNotFoundError.
478        assert!(!w.contains("os\nos.chdir"), "unguarded chdir: {w}");
479        assert!(w.contains("if os.path.isdir(_p):"), "{w}");
480        assert!(w.contains("except Exception:"), "{w}");
481        assert!(w.ends_with("print(1)"), "code must be last: {w}");
482    }
483
484    fn tmp_py() -> PathBuf {
485        let d = std::env::temp_dir().join(format!("ct_{}", uuid::Uuid::new_v4().simple()));
486        std::fs::create_dir_all(&d).unwrap();
487        std::fs::write(d.join("requirements.txt"), "").unwrap();
488        d
489    }
490
491    #[tokio::test]
492    async fn execute_python_prints() {
493        let d = tmp_py();
494        let backend: Arc<dyn ShellBackend> = Arc::new(crate::tools::backend::NativeShell::new());
495        let t = ExecutePythonTool {
496            project_path: d,
497            backend,
498            default_timeout_secs: 10,
499        };
500        let cancel = AtomicBool::new(false);
501        let out = t
502            .call(&json!({"code": "print(1+2)"}), &cancel)
503            .await
504            .unwrap();
505        let v: Value = serde_json::from_str(&out).unwrap();
506        if v["success"].as_bool().unwrap() {
507            assert!(v["stdout"].as_str().unwrap().contains("3"));
508        }
509    }
510
511    #[tokio::test]
512    async fn execute_python_empty_code() {
513        let backend: Arc<dyn ShellBackend> = Arc::new(crate::tools::backend::NativeShell::new());
514        let t = ExecutePythonTool {
515            project_path: std::env::temp_dir(),
516            backend,
517            default_timeout_secs: 10,
518        };
519        let cancel = AtomicBool::new(false);
520        let out = t.call(&json!({"code": ""}), &cancel).await.unwrap();
521        assert_eq!(
522            serde_json::from_str::<Value>(&out).unwrap()["success"],
523            false
524        );
525    }
526
527    #[tokio::test]
528    async fn analyze_code_python() {
529        let d = tmp_py();
530        std::fs::write(
531            d.join("app.py"),
532            "import os\n\nclass Hello:\n    def greet(self):\n        print('hi')\n",
533        )
534        .unwrap();
535        let t = AnalyzeCodeTool { project_path: d };
536        let cancel = AtomicBool::new(false);
537        let out = t
538            .call(&json!({"file_path": "app.py"}), &cancel)
539            .await
540            .unwrap();
541        let v: Value = serde_json::from_str(&out).unwrap();
542        assert_eq!(v["success"], true);
543        assert_eq!(v["language"], "python");
544        assert!(v["functions"]
545            .as_array()
546            .unwrap()
547            .iter()
548            .any(|f| f.as_str().unwrap().contains("greet")));
549        assert!(v["imports"]
550            .as_array()
551            .unwrap()
552            .iter()
553            .any(|i| i.as_str().unwrap().contains("os")));
554    }
555
556    #[tokio::test]
557    async fn analyze_code_rust() {
558        let d = tmp_py();
559        std::fs::write(
560            d.join("main.rs"),
561            "use std::io;\n\nfn main() {\n    println!(\"hi\");\n}\n",
562        )
563        .unwrap();
564        let t = AnalyzeCodeTool { project_path: d };
565        let cancel = AtomicBool::new(false);
566        let out = t
567            .call(&json!({"file_path": "main.rs"}), &cancel)
568            .await
569            .unwrap();
570        let v: Value = serde_json::from_str(&out).unwrap();
571        assert_eq!(v["language"], "rust");
572        assert!(v["functions"]
573            .as_array()
574            .unwrap()
575            .iter()
576            .any(|f| f.as_str().unwrap().contains("main")));
577    }
578
579    #[tokio::test]
580    async fn analyze_code_missing_file() {
581        let d = tmp_py();
582        let t = AnalyzeCodeTool { project_path: d };
583        let cancel = AtomicBool::new(false);
584        let out = t
585            .call(&json!({"file_path": "nope.py"}), &cancel)
586            .await
587            .unwrap();
588        assert_eq!(
589            serde_json::from_str::<Value>(&out).unwrap()["success"],
590            false
591        );
592    }
593
594    #[tokio::test]
595    async fn debug_code_for_unknown_lang() {
596        let d = std::env::temp_dir().join(format!("cd_{}", uuid::Uuid::new_v4().simple()));
597        std::fs::create_dir_all(&d).unwrap();
598        let t = DebugCodeTool { project_path: d };
599        let cancel = AtomicBool::new(false);
600        let out = t
601            .call(
602                &json!({"file_path": "app.py", "error_message": "SyntaxError"}),
603                &cancel,
604            )
605            .await
606            .unwrap();
607        assert!(out.contains("\"success\":true"));
608    }
609
610    #[test]
611    fn prune_temp_scripts_keeps_newest() {
612        let d = std::env::temp_dir().join(format!("ct_prune_{}", uuid::Uuid::new_v4().simple()));
613        std::fs::create_dir_all(&d).unwrap();
614        for i in 0..25 {
615            let p = d.join(format!("temp_{i}.py"));
616            std::fs::write(&p, "x").unwrap();
617            // Stagger mtimes so ordering is deterministic.
618            std::thread::sleep(std::time::Duration::from_millis(2));
619        }
620        std::fs::write(d.join("keep_me.txt"), "x").unwrap();
621        prune_temp_scripts(&d, 20);
622        let n = std::fs::read_dir(&d)
623            .unwrap()
624            .flatten()
625            .filter(|e| e.file_name().to_string_lossy().starts_with("temp_"))
626            .count();
627        assert_eq!(n, 20, "keeps 20 newest temp_*.py");
628        assert!(d.join("keep_me.txt").exists(), "unrelated files untouched");
629        let _ = std::fs::remove_dir_all(&d);
630    }
631}