1use 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
17fn 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
32pub 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 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 prune_temp_scripts(&test_dir, 20);
105
106 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
129fn 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)); for (_, p) in files.into_iter().skip(keep) {
148 let _ = std::fs::remove_file(p);
149 }
150}
151
152pub 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 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 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 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
280pub 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
339pub 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
404fn 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 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 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}