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