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 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 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 prune_temp_scripts(&test_dir, 20);
108
109 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
132fn 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)); for (_, p) in files.into_iter().skip(keep) {
151 let _ = std::fs::remove_file(p);
152 }
153}
154
155pub 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 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 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 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
283pub 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
342pub 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
407fn 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 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 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}