Skip to main content

sparrow/tools/
builder_tools.rs

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
9// ─── Test Runner ───────────────────────────────────────────────────────────────
10
11pub 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
92// ─── Apply Patch ───────────────────────────────────────────────────────────────
93
94pub 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        // Read all current contents first (for rollback)
130        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            // Backup
140            let original = std::fs::read_to_string(&full_path)?;
141            backups.push((file.to_string(), original.clone()));
142
143            // Apply
144            let count = original.matches(old).count();
145            if count == 0 {
146                // Rollback all previous patches
147                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
169// ─── Git PR Create ─────────────────────────────────────────────────────────────
170
171pub 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        // Try gh CLI first
201        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
221// ─── Fetch Docs ────────────────────────────────────────────────────────────────
222
223pub 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        // Fetch docs page
258        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                // Extract first 2000 chars of meaningful content
268                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
286// ─── Lightweight local code intelligence ───────────────────────────────────────
287
288pub 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
647// ─── REPL ──────────────────────────────────────────────────────────────────────
648
649pub 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}