1use anyhow::{Context, Result, anyhow};
4use serde_json::{Value, json};
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8use tokio::process::Command;
9use tokio::time::timeout;
10
11use brainwires_core::{Tool, ToolInputSchema, ToolResult};
12
13const BUILD_TIMEOUT: Duration = Duration::from_secs(600);
14const SYNTAX_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
15
16fn validate_file_path(file_path: &str) -> Result<PathBuf> {
17 if file_path.is_empty() {
18 return Err(anyhow!("File path cannot be empty"));
19 }
20 if file_path.contains('\0') {
21 return Err(anyhow!("File path contains null byte"));
22 }
23 let path = PathBuf::from(file_path);
24 let canonical = path
25 .canonicalize()
26 .with_context(|| format!("Failed to resolve path: {}", file_path))?;
27 if !canonical.exists() {
28 return Err(anyhow!("File does not exist: {}", file_path));
29 }
30 if !canonical.is_file() {
31 return Err(anyhow!("Path is not a file: {}", file_path));
32 }
33 Ok(canonical)
34}
35
36fn validate_directory_path(dir_path: &str) -> Result<PathBuf> {
37 if dir_path.is_empty() {
38 return Err(anyhow!("Directory path cannot be empty"));
39 }
40 if dir_path.contains('\0') {
41 return Err(anyhow!("Directory path contains null byte"));
42 }
43 let path = PathBuf::from(dir_path);
44 let canonical = path
45 .canonicalize()
46 .with_context(|| format!("Failed to resolve directory: {}", dir_path))?;
47 if !canonical.exists() {
48 return Err(anyhow!("Directory does not exist: {}", dir_path));
49 }
50 if !canonical.is_dir() {
51 return Err(anyhow!("Path is not a directory: {}", dir_path));
52 }
53 Ok(canonical)
54}
55
56#[tracing::instrument(name = "tool.validate.duplicates")]
58pub async fn check_duplicates(file_path: &str) -> Result<ToolResult> {
59 let validated_path = validate_file_path(file_path)?;
60 let content = tokio::fs::read_to_string(&validated_path)
61 .await
62 .with_context(|| format!("Failed to read file: {}", file_path))?;
63 let lines: Vec<&str> = content.lines().collect();
64 let mut exports = HashMap::new();
65 let mut duplicates = Vec::new();
66 for (line_num, line) in lines.iter().enumerate() {
67 if is_export_line(line)
68 && let Some(name) = extract_export_name(line)
69 {
70 if let Some(first) = exports.get(&name) {
71 duplicates.push(json!({"name": name, "first_line": first, "duplicate_line": line_num + 1, "code": line.trim()}));
72 } else {
73 exports.insert(name, line_num + 1);
74 }
75 }
76 }
77 let result = json!({"file": validated_path.display().to_string(), "has_duplicates": !duplicates.is_empty(), "duplicate_count": duplicates.len(), "duplicates": duplicates, "total_exports": exports.len()});
78 Ok(ToolResult {
79 tool_use_id: String::new(),
80 content: serde_json::to_string_pretty(&result)?,
81 is_error: false,
82 })
83}
84
85fn is_export_line(line: &str) -> bool {
86 let t = line.trim();
87 t.starts_with("export const ")
88 || t.starts_with("export let ")
89 || t.starts_with("export var ")
90 || t.starts_with("export function ")
91 || t.starts_with("export async function ")
92 || t.starts_with("export class ")
93 || t.starts_with("export interface ")
94 || t.starts_with("export type ")
95 || t.starts_with("export enum ")
96 || t.starts_with("export namespace ")
97 || t.starts_with("export default class ")
98 || t.starts_with("export default function ")
99 || t.starts_with("export default async function ")
100}
101
102fn extract_export_name(line: &str) -> Option<String> {
103 let t = line.trim();
104 for prefix in &["export const ", "export let ", "export var "] {
105 if let Some(after) = t.strip_prefix(prefix) {
106 return after
107 .split_whitespace()
108 .next()
109 .map(|s| s.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '$'))
110 .map(String::from);
111 }
112 }
113 if let Some(after) = t.strip_prefix("export async function ") {
114 return after.split('(').next().map(|s| s.trim().to_string());
115 }
116 if let Some(after) = t.strip_prefix("export function ") {
117 return after.split('(').next().map(|s| s.trim().to_string());
118 }
119 if let Some(after) = t.strip_prefix("export default async function ") {
120 let name = after.split('(').next().map(|s| s.trim().to_string())?;
121 return Some(if name.is_empty() {
122 "default".to_string()
123 } else {
124 name
125 });
126 }
127 if let Some(after) = t.strip_prefix("export default function ") {
128 let name = after.split('(').next().map(|s| s.trim().to_string())?;
129 return Some(if name.is_empty() {
130 "default".to_string()
131 } else {
132 name
133 });
134 }
135 if let Some(after) = t.strip_prefix("export default class ") {
136 let name = after.split_whitespace().next().map(|s| s.to_string())?;
137 return Some(if name.is_empty() || name == "{" {
138 "default".to_string()
139 } else {
140 name
141 });
142 }
143 if let Some(after) = t.strip_prefix("export class ") {
144 return after.split_whitespace().next().map(|s| s.to_string());
145 }
146 if let Some(after) = t.strip_prefix("export interface ") {
147 return after.split_whitespace().next().map(|s| s.to_string());
148 }
149 if let Some(after) = t.strip_prefix("export type ") {
150 return after
151 .split(|c: char| c.is_whitespace() || c == '=' || c == '<')
152 .next()
153 .map(|s| s.trim().to_string());
154 }
155 if let Some(after) = t.strip_prefix("export enum ") {
156 return after.split_whitespace().next().map(|s| s.to_string());
157 }
158 if let Some(after) = t.strip_prefix("export namespace ") {
159 return after.split_whitespace().next().map(|s| s.to_string());
160 }
161 None
162}
163
164#[tracing::instrument(name = "tool.validate.build")]
166pub async fn verify_build(working_directory: &str, build_type: &str) -> Result<ToolResult> {
167 let validated_dir = validate_directory_path(working_directory)?;
168 let (command, args) = match build_type {
169 "npm" => ("npm", vec!["run", "build"]),
170 "yarn" => ("yarn", vec!["build"]),
171 "pnpm" => ("pnpm", vec!["build"]),
172 "bun" => ("bun", vec!["run", "build"]),
173 "cargo" => ("cargo", vec!["build"]),
174 "typescript" => ("npx", vec!["tsc", "--noEmit"]),
175 "go" => ("go", vec!["build", "./..."]),
176 "python" => ("python", vec!["-m", "py_compile"]),
177 "gradle" => ("gradle", vec!["build"]),
178 "maven" => ("mvn", vec!["compile"]),
179 "make" => ("make", vec![]),
180 _ => {
181 return Ok(ToolResult {
182 tool_use_id: String::new(),
183 content: format!("Unknown build type: {}", build_type),
184 is_error: true,
185 });
186 }
187 };
188
189 let output_result = timeout(
190 BUILD_TIMEOUT,
191 Command::new(command)
192 .args(&args)
193 .current_dir(&validated_dir)
194 .output(),
195 )
196 .await;
197 let output = match output_result {
198 Ok(Ok(output)) => output,
199 Ok(Err(e)) => {
200 return Ok(ToolResult {
201 tool_use_id: String::new(),
202 content: json!({"success": false, "error": format!("Failed to execute: {}", e)})
203 .to_string(),
204 is_error: true,
205 });
206 }
207 Err(_) => {
208 return Ok(ToolResult {
209 tool_use_id: String::new(),
210 content: json!({"success": false, "error": "Build timed out", "timed_out": true})
211 .to_string(),
212 is_error: true,
213 });
214 }
215 };
216
217 let stdout = String::from_utf8_lossy(&output.stdout);
218 let stderr = String::from_utf8_lossy(&output.stderr);
219 let success = output.status.success();
220 let errors = parse_build_errors(&stderr, &stdout, build_type);
221 let result = json!({"success": success, "exit_code": output.status.code(), "error_count": errors.len(), "errors": errors, "working_directory": validated_dir.display().to_string()});
222 Ok(ToolResult {
223 tool_use_id: String::new(),
224 content: serde_json::to_string_pretty(&result)?,
225 is_error: !success,
226 })
227}
228
229fn parse_build_errors(stderr: &str, stdout: &str, build_type: &str) -> Vec<Value> {
230 let mut errors = Vec::new();
231 let mut seen = HashSet::new();
232 let combined = format!("{}\n{}", stderr, stdout);
233 for line in combined.lines() {
234 let trimmed = line.trim();
235 if trimmed.is_empty() {
236 continue;
237 }
238 let lower = trimmed.to_lowercase();
239 if lower.starts_with("warning:")
240 || lower.starts_with("note:")
241 || lower.starts_with("help:")
242 || lower.starts_with("-->")
243 {
244 continue;
245 }
246 let error = match build_type {
247 "typescript" | "npm" | "yarn" | "pnpm" | "bun" => {
248 parse_typescript_error(line).or_else(|| parse_javascript_error(line))
249 }
250 "cargo" => parse_rust_error(line),
251 "go" => parse_go_error(line),
252 "python" => parse_python_error(line),
253 "gradle" | "maven" => parse_java_error(line),
254 _ => None,
255 };
256 if let Some(mut error) = error {
257 let key = error["message"].as_str().unwrap_or("").to_string();
258 if !seen.contains(&key) {
259 seen.insert(key);
260 error["build_type"] = json!(build_type);
261 errors.push(error);
262 }
263 continue;
264 }
265 if lower.contains("error") && !lower.contains("0 error") {
266 let key = trimmed.to_string();
267 if !seen.contains(&key) {
268 seen.insert(key);
269 errors
270 .push(json!({"message": trimmed, "type": "generic", "build_type": build_type}));
271 }
272 }
273 }
274 errors.truncate(25);
275 errors
276}
277
278fn parse_typescript_error(line: &str) -> Option<Value> {
279 let parts: Vec<&str> = line.splitn(2, " - error ").collect();
280 if parts.len() == 2 {
281 Some(json!({"location": parts[0].trim(), "message": parts[1].trim(), "type": "typescript"}))
282 } else {
283 None
284 }
285}
286
287fn parse_rust_error(line: &str) -> Option<Value> {
288 if line.contains("error[E") || line.trim().starts_with("error:") {
289 Some(json!({"message": line.trim(), "type": "rust", "severity": "error"}))
290 } else {
291 None
292 }
293}
294
295fn parse_javascript_error(line: &str) -> Option<Value> {
296 let t = line.trim();
297 if t.contains("Error:")
298 && (t.contains("SyntaxError")
299 || t.contains("ReferenceError")
300 || t.contains("TypeError")
301 || t.contains("RangeError"))
302 {
303 Some(json!({"message": t, "type": "javascript", "severity": "error"}))
304 } else {
305 None
306 }
307}
308
309fn parse_go_error(line: &str) -> Option<Value> {
310 let t = line.trim();
311 if t.contains(".go:") && t.contains(": ") {
312 let parts: Vec<&str> = t.splitn(2, ": ").collect();
313 if parts.len() == 2 {
314 return Some(
315 json!({"location": parts[0].trim(), "message": parts[1].trim(), "type": "go"}),
316 );
317 }
318 }
319 if t.starts_with("can't load package:") || t.starts_with("package") {
320 return Some(json!({"message": t, "type": "go", "severity": "error"}));
321 }
322 None
323}
324
325fn parse_python_error(line: &str) -> Option<Value> {
326 let t = line.trim();
327 if t.starts_with("File \"") && t.contains("line ") {
328 return Some(json!({"location": t, "type": "python"}));
329 }
330 if (t.ends_with("Error:") || t.contains("Error: "))
331 && (t.contains("SyntaxError")
332 || t.contains("IndentationError")
333 || t.contains("NameError")
334 || t.contains("ImportError")
335 || t.contains("ModuleNotFoundError"))
336 {
337 return Some(json!({"message": t, "type": "python", "severity": "error"}));
338 }
339 None
340}
341
342fn parse_java_error(line: &str) -> Option<Value> {
343 let t = line.trim();
344 if t.contains(".java:") && t.contains("error:") {
345 let parts: Vec<&str> = t.splitn(2, "error:").collect();
346 if parts.len() == 2 {
347 return Some(
348 json!({"location": parts[0].trim(), "message": parts[1].trim(), "type": "java"}),
349 );
350 }
351 }
352 if t.starts_with("[ERROR]") {
353 return Some(json!({"message": t.trim_start_matches("[ERROR]").trim(), "type": "java"}));
354 }
355 if t.contains("COMPILATION ERROR") || t.contains("BUILD FAILURE") {
356 return Some(json!({"message": t, "type": "java"}));
357 }
358 None
359}
360
361pub async fn check_syntax(file_path: &str) -> Result<ToolResult> {
363 let validated_path = validate_file_path(file_path)?;
364 let extension = validated_path
365 .extension()
366 .and_then(|s| s.to_str())
367 .unwrap_or("");
368
369 if matches!(extension, "ts" | "tsx") {
370 let content = tokio::fs::read_to_string(&validated_path)
371 .await
372 .with_context(|| format!("Failed to read: {}", file_path))?;
373 let mut errors = Vec::new();
374 if content.contains("export export") {
375 errors.push(json!({"message": "Duplicate 'export' keyword", "type": "syntax_error"}));
376 }
377 if content.contains("import import") {
378 errors.push(json!({"message": "Duplicate 'import' keyword", "type": "syntax_error"}));
379 }
380 let open = content.matches('{').count();
381 let close = content.matches('}').count();
382 if open != close {
383 errors.push(json!({"message": format!("Unmatched braces: {} open, {} close", open, close), "type": "syntax_error"}));
384 }
385 if !errors.is_empty() {
386 return Ok(ToolResult { tool_use_id: String::new(), content: json!({"file": validated_path.display().to_string(), "valid_syntax": false, "errors": errors}).to_string(), is_error: true });
387 }
388 return Ok(ToolResult { tool_use_id: String::new(), content: json!({"file": validated_path.display().to_string(), "valid_syntax": true, "skipped": true}).to_string(), is_error: false });
389 }
390
391 let file_path_str = validated_path.display().to_string();
392 let (command, args) = match extension {
393 "js" | "jsx" => (
394 "npx",
395 vec![
396 "eslint",
397 "--no-eslintrc",
398 "--parser",
399 "@babel/eslint-parser",
400 &file_path_str,
401 ],
402 ),
403 "rs" => (
404 "rustc",
405 vec![
406 "--crate-type",
407 "lib",
408 "--error-format",
409 "json",
410 &file_path_str,
411 ],
412 ),
413 "py" => ("python", vec!["-m", "py_compile", &file_path_str]),
414 "go" => ("gofmt", vec!["-e", &file_path_str]),
415 _ => {
416 return Ok(ToolResult {
417 tool_use_id: String::new(),
418 content: format!("Unsupported file type: {}", extension),
419 is_error: true,
420 });
421 }
422 };
423
424 let working_dir = validated_path.parent().unwrap_or_else(|| Path::new("."));
425 let output_result = timeout(
426 SYNTAX_CHECK_TIMEOUT,
427 Command::new(command)
428 .args(&args)
429 .current_dir(working_dir)
430 .output(),
431 )
432 .await;
433 let output = match output_result {
434 Ok(Ok(output)) => output,
435 Ok(Err(e)) => {
436 return Ok(ToolResult {
437 tool_use_id: String::new(),
438 content:
439 json!({"file": file_path, "valid_syntax": false, "error": format!("{}", e)})
440 .to_string(),
441 is_error: true,
442 });
443 }
444 Err(_) => {
445 return Ok(ToolResult {
446 tool_use_id: String::new(),
447 content: json!({"file": file_path, "valid_syntax": false, "timed_out": true})
448 .to_string(),
449 is_error: true,
450 });
451 }
452 };
453
454 let success = output.status.success();
455 let result = json!({"file": validated_path.display().to_string(), "valid_syntax": success});
456 Ok(ToolResult {
457 tool_use_id: String::new(),
458 content: serde_json::to_string_pretty(&result)?,
459 is_error: !success,
460 })
461}
462
463pub struct ValidationTool;
465
466impl ValidationTool {
467 pub fn get_tools() -> Vec<Tool> {
469 get_validation_tools()
470 }
471
472 pub async fn execute(
474 tool_use_id: &str,
475 tool_name: &str,
476 input: &Value,
477 _context: &brainwires_core::ToolContext,
478 ) -> ToolResult {
479 let result = match tool_name {
480 "check_duplicates" => {
481 let file_path = input["file_path"].as_str().unwrap_or("");
482 check_duplicates(file_path).await
483 }
484 "verify_build" => {
485 let dir = input["working_directory"].as_str().unwrap_or(".");
486 let build_type = input["build_type"].as_str().unwrap_or("cargo");
487 verify_build(dir, build_type).await
488 }
489 "check_syntax" => {
490 let file_path = input["file_path"].as_str().unwrap_or("");
491 check_syntax(file_path).await
492 }
493 _ => Err(anyhow!("Unknown validation tool: {}", tool_name)),
494 };
495
496 match result {
497 Ok(tool_result) => tool_result,
498 Err(e) => {
499 ToolResult::error(tool_use_id.to_string(), format!("Validation failed: {}", e))
500 }
501 }
502 }
503}
504
505pub fn get_validation_tools() -> Vec<Tool> {
507 vec![
508 Tool {
509 name: "check_duplicates".to_string(),
510 description: "Check a file for duplicate exports, constants, or function definitions.".to_string(),
511 input_schema: ToolInputSchema::object({ let mut p = HashMap::new(); p.insert("file_path".to_string(), json!({"type": "string", "description": "Path to file"})); p }, vec!["file_path".to_string()]),
512 requires_approval: false, ..Default::default()
513 },
514 Tool {
515 name: "verify_build".to_string(),
516 description: "Run a build command and verify it succeeds. Supports: npm, yarn, pnpm, bun, cargo, typescript, go, python, gradle, maven, make.".to_string(),
517 input_schema: ToolInputSchema::object({ let mut p = HashMap::new(); p.insert("working_directory".to_string(), json!({"type": "string"})); p.insert("build_type".to_string(), json!({"type": "string", "enum": ["npm", "yarn", "pnpm", "bun", "cargo", "typescript", "go", "python", "gradle", "maven", "make"]})); p }, vec!["working_directory".to_string(), "build_type".to_string()]),
518 requires_approval: false, ..Default::default()
519 },
520 Tool {
521 name: "check_syntax".to_string(),
522 description: "Check syntax of a single file without running a full build.".to_string(),
523 input_schema: ToolInputSchema::object({ let mut p = HashMap::new(); p.insert("file_path".to_string(), json!({"type": "string"})); p }, vec!["file_path".to_string()]),
524 requires_approval: false, ..Default::default()
525 },
526 ]
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use std::io::Write;
533 use tempfile::NamedTempFile;
534
535 #[test]
536 fn test_extract_export_name() {
537 assert_eq!(
538 extract_export_name("export const FOO = 'bar'"),
539 Some("FOO".to_string())
540 );
541 assert_eq!(
542 extract_export_name("export function myFunc() {"),
543 Some("myFunc".to_string())
544 );
545 assert_eq!(
546 extract_export_name("export interface MyInterface {"),
547 Some("MyInterface".to_string())
548 );
549 assert_eq!(
550 extract_export_name("export type MyType = string"),
551 Some("MyType".to_string())
552 );
553 }
554
555 #[test]
556 fn test_is_export_line() {
557 assert!(is_export_line("export const FOO = 'bar'"));
558 assert!(is_export_line("export interface MyInterface {"));
559 assert!(!is_export_line("const FOO = 'bar'"));
560 }
561
562 #[test]
563 fn test_parse_rust_error() {
564 let result = parse_rust_error("error[E0425]: cannot find value `foo`");
565 assert!(result.is_some());
566 }
567
568 #[tokio::test]
569 async fn test_check_duplicates() {
570 let mut temp = NamedTempFile::new().unwrap();
571 writeln!(temp, "export const FOO = 'bar'").unwrap();
572 writeln!(temp, "export const BAZ = 'qux'").unwrap();
573 writeln!(temp, "export const FOO = 'dup'").unwrap();
574 temp.flush().unwrap();
575 let result = check_duplicates(temp.path().to_str().unwrap())
576 .await
577 .unwrap();
578 let parsed: Value = serde_json::from_str(&result.content).unwrap();
579 assert_eq!(parsed["has_duplicates"], true);
580 }
581}