Skip to main content

brainwires_tool_system/
validation.rs

1//! Validation tools for agents to verify their work
2
3use 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/// Check for duplicate exports/constants in a file
57#[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/// Verify build by running the appropriate build command
165#[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
361/// Check syntax without full build
362pub 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
463/// Validation tool dispatcher
464pub struct ValidationTool;
465
466impl ValidationTool {
467    /// Return validation tool definitions.
468    pub fn get_tools() -> Vec<Tool> {
469        get_validation_tools()
470    }
471
472    /// Execute a validation tool by name
473    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
505/// Get validation tool definitions
506pub 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}