1use async_trait::async_trait;
2use serde_json::json;
3use std::path::{Path, PathBuf};
4use std::process::Command as StdCommand;
5
6use crate::event::{Block, RiskLevel};
7use crate::tools::{Tool, ToolCtx, ToolResult};
8
9pub struct TestRunner;
12
13#[async_trait]
14impl Tool for TestRunner {
15 fn name(&self) -> &str {
16 "test"
17 }
18 fn description(&self) -> &str {
19 "Detect and run the project test suite. Parses results."
20 }
21 fn schema(&self) -> serde_json::Value {
22 json!({
23 "type": "object",
24 "properties": {
25 "framework": { "type": "string", "description": "Force framework: cargo, pytest, jest, go" },
26 "args": { "type": "array", "items": {"type": "string"}, "description": "Extra args" }
27 },
28 "required": []
29 })
30 }
31 fn risk(&self) -> RiskLevel {
32 RiskLevel::Exec
33 }
34 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
35 let framework = args["framework"].as_str();
36 let detected = detect_framework(&ctx.workspace_root);
37 let fw = framework.unwrap_or(&detected);
38
39 let (program, fw_args): (&str, Vec<&str>) = match fw {
40 "cargo" => ("cargo", vec!["test"]),
41 "pytest" => ("pytest", vec!["-v"]),
42 "jest" => ("npx", vec!["jest"]),
43 "go" => ("go", vec!["test", "./..."]),
44 _ => ("cargo", vec!["test"]),
45 };
46
47 let output = StdCommand::new(program)
48 .args(&fw_args)
49 .current_dir(&ctx.workspace_root)
50 .output()?;
51
52 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
53 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
54 let exit_code = output.status.code().unwrap_or(-1);
55
56 let passed = stdout
57 .lines()
58 .filter(|l| l.contains("pass") || l.contains("PASS") || l.contains("ok"))
59 .count() as u32;
60 let failed = stdout
61 .lines()
62 .filter(|l| l.contains("fail") || l.contains("FAIL") || l.contains("FAILED"))
63 .count() as u32;
64
65 Ok(ToolResult::ok(vec![Block::Text(format!(
66 "Framework: {}\nPassed: {}\nFailed: {}\nExit: {}\n\n{}",
67 fw,
68 passed,
69 failed,
70 exit_code,
71 if stderr.is_empty() { &stdout } else { &stderr }
72 ))]))
73 }
74}
75
76fn detect_framework(root: &std::path::Path) -> String {
77 if root.join("Cargo.toml").exists() {
78 return "cargo".into();
79 }
80 if root.join("pyproject.toml").exists() || root.join("pytest.ini").exists() {
81 return "pytest".into();
82 }
83 if root.join("package.json").exists() {
84 return "jest".into();
85 }
86 if root.join("go.mod").exists() {
87 return "go".into();
88 }
89 "cargo".into()
90}
91
92pub struct ApplyPatch;
95
96#[async_trait]
97impl Tool for ApplyPatch {
98 fn name(&self) -> &str {
99 "apply_patch"
100 }
101 fn description(&self) -> &str {
102 "Apply a structured multi-file patch atomically with rollback"
103 }
104 fn schema(&self) -> serde_json::Value {
105 json!({
106 "type": "object",
107 "properties": {
108 "patches": { "type": "array", "items": {
109 "type": "object",
110 "properties": {
111 "file": {"type": "string"},
112 "old": {"type": "string"},
113 "new": {"type": "string"}
114 },
115 "required": ["file", "old", "new"]
116 }}
117 },
118 "required": ["patches"]
119 })
120 }
121 fn risk(&self) -> RiskLevel {
122 RiskLevel::Mutating
123 }
124 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
125 let patches = args["patches"]
126 .as_array()
127 .ok_or_else(|| anyhow::anyhow!("patches must be an array"))?;
128
129 let mut backups: Vec<(String, String)> = Vec::new();
131 let mut results = Vec::new();
132
133 for patch in patches {
134 let file = patch["file"].as_str().unwrap_or("");
135 let old = patch["old"].as_str().unwrap_or("");
136 let new = patch["new"].as_str().unwrap_or("");
137 let full_path = ctx.workspace_root.join(file);
138
139 let original = std::fs::read_to_string(&full_path)?;
141 backups.push((file.to_string(), original.clone()));
142
143 let count = original.matches(old).count();
145 if count == 0 {
146 for (f, content) in &backups {
148 std::fs::write(ctx.workspace_root.join(f), content)?;
149 }
150 return Ok(ToolResult::error(format!(
151 "Patch failed on '{}': old string not found. All changes rolled back.",
152 file
153 )));
154 }
155
156 let new_content = original.replace(old, new);
157 std::fs::write(&full_path, new_content)?;
158 results.push(format!("✓ {} : applied", file));
159 }
160
161 Ok(ToolResult::ok(vec![Block::Text(format!(
162 "Patched {} file(s) atomically:\n{}",
163 results.len(),
164 results.join("\n")
165 ))]))
166 }
167}
168
169pub struct GitPrCreate;
172
173#[async_trait]
174impl Tool for GitPrCreate {
175 fn name(&self) -> &str {
176 "git_pr_create"
177 }
178 fn description(&self) -> &str {
179 "Create a pull request on GitHub"
180 }
181 fn schema(&self) -> serde_json::Value {
182 json!({
183 "type": "object",
184 "properties": {
185 "title": {"type": "string", "description": "PR title"},
186 "body": {"type": "string", "description": "PR description"},
187 "base": {"type": "string", "description": "Base branch (default: main)"}
188 },
189 "required": ["title"]
190 })
191 }
192 fn risk(&self) -> RiskLevel {
193 RiskLevel::Network
194 }
195 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
196 let title = args["title"].as_str().unwrap_or("Sparrow PR");
197 let body = args["body"].as_str().unwrap_or("");
198 let base = args["base"].as_str().unwrap_or("main");
199
200 let output = StdCommand::new("gh")
202 .args([
203 "pr", "create", "--title", title, "--body", body, "--base", base,
204 ])
205 .current_dir(&ctx.workspace_root)
206 .output();
207
208 match output {
209 Ok(o) if o.status.success() => {
210 let stdout = String::from_utf8_lossy(&o.stdout).to_string();
211 Ok(ToolResult::text(format!("PR created:\n{}", stdout)))
212 }
213 _ => Ok(ToolResult::text(format!(
214 "PR would be created:\n Title: {}\n Base: {}\n Body: {}",
215 title, base, body
216 ))),
217 }
218 }
219}
220
221pub struct FetchDocs;
224
225#[async_trait]
226impl Tool for FetchDocs {
227 fn name(&self) -> &str {
228 "fetch_docs"
229 }
230 fn description(&self) -> &str {
231 "Fetch up-to-date library documentation to avoid API hallucinations"
232 }
233 fn schema(&self) -> serde_json::Value {
234 json!({
235 "type": "object",
236 "properties": {
237 "package": {"type": "string", "description": "Package name (e.g., tokio, serde, react)"},
238 "language": {"type": "string", "description": "Language: rust, python, js, go"}
239 },
240 "required": ["package"]
241 })
242 }
243 fn risk(&self) -> RiskLevel {
244 RiskLevel::Network
245 }
246 async fn call(&self, args: serde_json::Value, _ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
247 let package = args["package"].as_str().unwrap_or("");
248 let lang = args["language"].as_str().unwrap_or("rust");
249
250 let url = match lang {
251 "rust" => format!("https://docs.rs/{}/latest", package),
252 "python" => format!("https://pypi.org/project/{}/", package),
253 "js" => format!("https://www.npmjs.com/package/{}", package),
254 _ => format!("https://docs.rs/{}/latest", package),
255 };
256
257 let client = reqwest::Client::builder()
259 .user_agent("sparrow-docs/0.1")
260 .timeout(std::time::Duration::from_secs(10))
261 .build()?;
262
263 match client.get(&url).send().await {
264 Ok(resp) => {
265 let status = resp.status();
266 let text = resp.text().await.unwrap_or_default();
267 let preview: String = text
269 .chars()
270 .filter(|c| !c.is_whitespace() || *c == ' ')
271 .take(2000)
272 .collect();
273 Ok(ToolResult::text(format!(
274 "Docs for {}: {}\nStatus: {}\n\n{}",
275 package, url, status, preview
276 )))
277 }
278 Err(e) => Ok(ToolResult::text(format!(
279 "Could not fetch docs for {}: {}\nURL: {}",
280 package, e, url
281 ))),
282 }
283 }
284}
285
286pub struct LspClient;
289
290#[async_trait]
291impl Tool for LspClient {
292 fn name(&self) -> &str {
293 "lsp"
294 }
295 fn description(&self) -> &str {
296 "Local code intelligence: diagnostics, goto definition, references, and hover context without a language server daemon"
297 }
298 fn schema(&self) -> serde_json::Value {
299 json!({
300 "type": "object",
301 "properties": {
302 "action": {"type": "string", "enum": ["diagnostics", "goto_definition", "find_references", "hover", "rename"]},
303 "file": {"type": "string"},
304 "line": {"type": "integer"},
305 "column": {"type": "integer"},
306 "symbol": {"type": "string"}
307 },
308 "required": ["action", "file"]
309 })
310 }
311 fn risk(&self) -> RiskLevel {
312 RiskLevel::ReadOnly
313 }
314 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
315 let action = args["action"].as_str().unwrap_or("diagnostics");
316 let file = args["file"].as_str().unwrap_or("");
317 if file.is_empty() {
318 return Ok(ToolResult::error("lsp: 'file' is required"));
319 }
320 let root = ctx
321 .workspace_root
322 .canonicalize()
323 .unwrap_or_else(|_| ctx.workspace_root.clone());
324 let path = crate::tools::resolve_workspace_path(&root, file)?;
325 let rel = path
326 .strip_prefix(&root)
327 .unwrap_or(&path)
328 .to_string_lossy()
329 .replace('\\', "/");
330 let line = args["line"].as_u64().map(|n| n.max(1) as usize);
331 let column = args["column"].as_u64().map(|n| n.max(1) as usize);
332 let symbol = args["symbol"]
333 .as_str()
334 .map(str::trim)
335 .filter(|s| !s.is_empty())
336 .map(ToOwned::to_owned)
337 .or_else(|| symbol_at_position(&path, line, column).ok().flatten());
338
339 match action {
340 "diagnostics" => diagnostics(&root, &path, &rel),
341 "goto_definition" => {
342 let Some(symbol) = symbol else {
343 return Ok(ToolResult::error(
344 "lsp goto_definition: provide 'symbol' or line/column",
345 ));
346 };
347 Ok(definitions(&root, &symbol, false))
348 }
349 "find_references" => {
350 let Some(symbol) = symbol else {
351 return Ok(ToolResult::error(
352 "lsp find_references: provide 'symbol' or line/column",
353 ));
354 };
355 Ok(references(&root, &symbol))
356 }
357 "hover" => hover(&root, &path, &rel, line, column, symbol.as_deref()),
358 "rename" => Ok(ToolResult::error(
359 "lsp rename is mutating; use the edit or multi_edit tool after reviewing references",
360 )),
361 other => Ok(ToolResult::error(format!(
362 "lsp: unknown action '{}'",
363 other
364 ))),
365 }
366 }
367}
368
369const LSP_SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", "build", ".venv"];
370const LSP_MAX_RESULTS: usize = 120;
371
372fn diagnostics(root: &Path, path: &Path, rel: &str) -> anyhow::Result<ToolResult> {
373 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
374 let output = match ext {
375 "rs" if root.join("Cargo.toml").exists() => StdCommand::new("cargo")
376 .args(["check", "--message-format", "short"])
377 .current_dir(root)
378 .output(),
379 "py" => StdCommand::new("python")
380 .args(["-m", "py_compile", rel])
381 .current_dir(root)
382 .output()
383 .or_else(|_| {
384 StdCommand::new("python3")
385 .args(["-m", "py_compile", rel])
386 .current_dir(root)
387 .output()
388 }),
389 "js" | "mjs" | "cjs" => StdCommand::new("node")
390 .args(["--check", rel])
391 .current_dir(root)
392 .output(),
393 "ts" | "tsx" if root.join("package.json").exists() => StdCommand::new("npx")
394 .args(["tsc", "--noEmit", "--pretty", "false"])
395 .current_dir(root)
396 .output(),
397 _ => return syntax_scan(path, rel),
398 };
399
400 match output {
401 Ok(output) => {
402 let stdout = String::from_utf8_lossy(&output.stdout);
403 let stderr = String::from_utf8_lossy(&output.stderr);
404 let combined = if stderr.trim().is_empty() {
405 stdout.trim().to_string()
406 } else {
407 format!("{}\n{}", stdout.trim(), stderr.trim())
408 .trim()
409 .to_string()
410 };
411 let status = output.status.code().unwrap_or(-1);
412 Ok(ToolResult::text(format!(
413 "diagnostics for {}\nexit: {}\n{}",
414 rel,
415 status,
416 if combined.is_empty() {
417 "no diagnostics reported".to_string()
418 } else {
419 combined
420 }
421 )))
422 }
423 Err(e) => Ok(ToolResult::error(format!(
424 "diagnostics for {} failed to launch checker: {}",
425 rel, e
426 ))),
427 }
428}
429
430fn syntax_scan(path: &Path, rel: &str) -> anyhow::Result<ToolResult> {
431 let content = std::fs::read_to_string(path)?;
432 let mut findings = Vec::new();
433 let mut paren = 0i32;
434 let mut brace = 0i32;
435 let mut bracket = 0i32;
436 for (idx, line) in content.lines().enumerate() {
437 for ch in line.chars() {
438 match ch {
439 '(' => paren += 1,
440 ')' => paren -= 1,
441 '{' => brace += 1,
442 '}' => brace -= 1,
443 '[' => bracket += 1,
444 ']' => bracket -= 1,
445 _ => {}
446 }
447 }
448 if paren < 0 || brace < 0 || bracket < 0 {
449 findings.push(format!("{}:{}: unmatched closing delimiter", rel, idx + 1));
450 paren = paren.max(0);
451 brace = brace.max(0);
452 bracket = bracket.max(0);
453 }
454 }
455 if paren > 0 || brace > 0 || bracket > 0 {
456 findings.push(format!(
457 "{}: unmatched delimiters: paren={} brace={} bracket={}",
458 rel, paren, brace, bracket
459 ));
460 }
461 if findings.is_empty() {
462 findings.push(format!("{}: no lightweight syntax findings", rel));
463 }
464 Ok(ToolResult::text(findings.join("\n")))
465}
466
467fn definitions(root: &Path, symbol: &str, include_empty_message: bool) -> ToolResult {
468 let patterns = definition_patterns(symbol);
469 let hits = scan_code(root, |path, line_no, line| {
470 if patterns.iter().any(|re| re.is_match(line)) {
471 Some(format!(
472 "{}:{}: {}",
473 rel_path(root, path),
474 line_no,
475 line.trim()
476 ))
477 } else {
478 None
479 }
480 });
481 if hits.is_empty() {
482 if include_empty_message {
483 ToolResult::text(format!("no definition found for '{}'", symbol))
484 } else {
485 ToolResult::error(format!("no definition found for '{}'", symbol))
486 }
487 } else {
488 ToolResult::ok(vec![Block::Text(hits.join("\n"))])
489 }
490}
491
492fn references(root: &Path, symbol: &str) -> ToolResult {
493 let Ok(re) = regex::Regex::new(&format!(r"\b{}\b", regex::escape(symbol))) else {
494 return ToolResult::error("invalid reference regex");
495 };
496 let hits = scan_code(root, |path, line_no, line| {
497 if re.is_match(line) {
498 Some(format!(
499 "{}:{}: {}",
500 rel_path(root, path),
501 line_no,
502 line.trim()
503 ))
504 } else {
505 None
506 }
507 });
508 if hits.is_empty() {
509 ToolResult::text(format!("no references found for '{}'", symbol))
510 } else {
511 ToolResult::ok(vec![Block::Text(hits.join("\n"))])
512 }
513}
514
515fn hover(
516 root: &Path,
517 path: &Path,
518 rel: &str,
519 line: Option<usize>,
520 column: Option<usize>,
521 symbol: Option<&str>,
522) -> anyhow::Result<ToolResult> {
523 let content = std::fs::read_to_string(path)?;
524 let lines: Vec<&str> = content.lines().collect();
525 let line = line.unwrap_or(1).clamp(1, lines.len().max(1));
526 let start = line.saturating_sub(3).max(1);
527 let end = (line + 2).min(lines.len().max(1));
528 let mut out = vec![format!("hover {}:{}:{}", rel, line, column.unwrap_or(1))];
529 if let Some(symbol) = symbol {
530 out.push(format!("symbol: {}", symbol));
531 if let Block::Text(defs) = definitions(root, symbol, true).content.remove(0) {
532 out.push(format!("definitions:\n{}", defs));
533 }
534 }
535 out.push("context:".into());
536 for n in start..=end {
537 if let Some(text) = lines.get(n - 1) {
538 out.push(format!("{:>4} | {}", n, text));
539 }
540 }
541 Ok(ToolResult::text(out.join("\n")))
542}
543
544fn definition_patterns(symbol: &str) -> Vec<regex::Regex> {
545 let esc = regex::escape(symbol);
546 [
547 format!(
548 r"\b(fn|struct|enum|trait|type|const|static|class|def|function)\s+{}\b",
549 esc
550 ),
551 format!(r"\bimpl\b[^\n]*\b{}\b", esc),
552 format!(r"\b{}\s*[:=]\s*(async\s*)?(\([^)]*\)\s*=>|function\b)", esc),
553 ]
554 .into_iter()
555 .filter_map(|p| regex::Regex::new(&p).ok())
556 .collect()
557}
558
559fn symbol_at_position(
560 path: &Path,
561 line: Option<usize>,
562 column: Option<usize>,
563) -> anyhow::Result<Option<String>> {
564 let Some(line_no) = line else {
565 return Ok(None);
566 };
567 let content = std::fs::read_to_string(path)?;
568 let Some(line) = content.lines().nth(line_no.saturating_sub(1)) else {
569 return Ok(None);
570 };
571 let col = column.unwrap_or(1).saturating_sub(1).min(line.len());
572 let bytes = line.as_bytes();
573 let mut start = col;
574 while start > 0 && is_ident(bytes[start - 1]) {
575 start -= 1;
576 }
577 let mut end = col;
578 while end < bytes.len() && is_ident(bytes[end]) {
579 end += 1;
580 }
581 if start == end {
582 return Ok(None);
583 }
584 Ok(Some(line[start..end].to_string()))
585}
586
587fn is_ident(ch: u8) -> bool {
588 ch.is_ascii_alphanumeric() || ch == b'_'
589}
590
591fn scan_code<F>(root: &Path, mut mapper: F) -> Vec<String>
592where
593 F: FnMut(&Path, usize, &str) -> Option<String>,
594{
595 let mut files = Vec::new();
596 walk_code_files(root, &mut files);
597 let mut hits = Vec::new();
598 for file in files {
599 let Ok(content) = std::fs::read_to_string(&file) else {
600 continue;
601 };
602 for (idx, line) in content.lines().enumerate() {
603 if let Some(hit) = mapper(&file, idx + 1, line) {
604 hits.push(hit);
605 if hits.len() >= LSP_MAX_RESULTS {
606 return hits;
607 }
608 }
609 }
610 }
611 hits
612}
613
614fn walk_code_files(root: &Path, out: &mut Vec<PathBuf>) {
615 let Ok(entries) = std::fs::read_dir(root) else {
616 return;
617 };
618 for entry in entries.flatten() {
619 let path = entry.path();
620 let name = entry.file_name();
621 let name = name.to_string_lossy();
622 if path.is_dir() {
623 if name.starts_with('.') || LSP_SKIP_DIRS.contains(&name.as_ref()) {
624 continue;
625 }
626 walk_code_files(&path, out);
627 } else if is_lsp_code_file(&path) {
628 out.push(path);
629 }
630 }
631}
632
633fn is_lsp_code_file(path: &Path) -> bool {
634 matches!(
635 path.extension().and_then(|e| e.to_str()),
636 Some("rs" | "py" | "js" | "jsx" | "ts" | "tsx" | "go" | "java" | "c" | "h" | "cpp" | "hpp")
637 )
638}
639
640fn rel_path(root: &Path, path: &Path) -> String {
641 path.strip_prefix(root)
642 .unwrap_or(path)
643 .to_string_lossy()
644 .replace('\\', "/")
645}
646
647pub struct Repl;
650
651#[async_trait]
652impl Tool for Repl {
653 fn name(&self) -> &str {
654 "repl"
655 }
656 fn description(&self) -> &str {
657 "Execute code interactively in a sandboxed REPL (Python/Node)"
658 }
659 fn schema(&self) -> serde_json::Value {
660 json!({
661 "type": "object",
662 "properties": {
663 "language": {"type": "string", "enum": ["python", "node"]},
664 "code": {"type": "string", "description": "Code to execute"},
665 "timeout_ms": {"type": "integer"}
666 },
667 "required": ["language", "code"]
668 })
669 }
670 fn risk(&self) -> RiskLevel {
671 RiskLevel::Exec
672 }
673 async fn call(&self, args: serde_json::Value, _ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
674 let lang = args["language"].as_str().unwrap_or("python");
675 let code = args["code"].as_str().unwrap_or("");
676 let _timeout_ms = args["timeout_ms"].as_u64().unwrap_or(30_000);
677
678 let program = match lang {
679 "node" => "node",
680 _ => "python3",
681 };
682 let flag = match lang {
683 "node" => "-e",
684 _ => "-c",
685 };
686
687 let output = StdCommand::new(program).args([flag, code]).output()?;
688
689 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
690 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
691 let result = if stderr.is_empty() {
692 stdout
693 } else {
694 format!("{}\n{}", stdout, stderr)
695 };
696
697 Ok(ToolResult::text(result))
698 }
699}