use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::Path;
use tokio::process::Command;
use super::paths::resolve_in_workspace;
use super::Tool;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub passed: u32,
pub failed: u32,
pub ignored: u32,
pub errors: Vec<String>,
pub raw_output: String,
}
impl TestResult {
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if self.passed > 0 {
parts.push(format!("{} passed", self.passed));
}
if self.failed > 0 {
parts.push(format!("{} failed", self.failed));
}
if self.ignored > 0 {
parts.push(format!("{} ignored", self.ignored));
}
format!("Tests: {}", parts.join(", "))
}
}
pub struct RunTestsTool;
#[async_trait]
impl Tool for RunTestsTool {
fn name(&self) -> &str {
"run_tests"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Build
}
fn description(&self) -> &str {
"Run tests for the current project (cargo test or caatinga test)"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the project directory (default: current directory)"
},
"filter": {
"type": "string",
"description": "Optional test filter (test name pattern)"
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let project_path = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let filter = input.get("filter").and_then(|v| v.as_str());
let path = resolve_in_workspace(project_path)?;
let ran = match package_test_script(&path).await {
Some(script) => run_package_test(&path, filter, &script).await?,
None => run_cargo_test(&path, filter).await?,
};
let result = parse_test_output(&ran.output);
let mut formatted = format!("{}\n\n", result.summary());
if !result.errors.is_empty() {
formatted.push_str("Errors:\n");
for error in &result.errors {
formatted.push_str(&format!(" - {}\n", error));
}
}
if ran.success {
Ok(formatted)
} else {
Err(formatted)
}
}
}
struct Ran {
output: String,
success: bool,
}
async fn package_test_script(path: &Path) -> Option<String> {
if which::which("npm").is_err() {
return None;
}
let raw = tokio::fs::read_to_string(path.join("package.json"))
.await
.ok()?;
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
json.get("scripts")?
.get("test")?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
async fn run_package_test(path: &Path, filter: Option<&str>, script: &str) -> Result<Ran, String> {
let mut args = vec!["test".to_string(), "--silent".to_string()];
if let Some(f) = filter {
args.push("--".to_string());
args.push(f.to_string());
}
let output = Command::new("npm")
.args(&args)
.current_dir(path)
.output()
.await
.map_err(|e| format!("Failed to run `npm test` ({}): {}", script, e))?;
Ok(combine(output))
}
async fn run_cargo_test(path: &Path, filter: Option<&str>) -> Result<Ran, String> {
let mut args = vec!["test".to_string()];
if let Some(f) = filter {
args.push(f.to_string());
}
args.push("--".to_string());
args.push("--nocapture".to_string());
let output = Command::new("cargo")
.args(&args)
.current_dir(path)
.output()
.await
.map_err(|e| format!("Failed to run cargo test: {}", e))?;
Ok(combine(output))
}
fn combine(output: std::process::Output) -> Ran {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ran {
output: format!("{}\n{}", stdout, stderr),
success: output.status.success(),
}
}
fn parse_test_output(output: &str) -> TestResult {
let mut passed = 0u32;
let mut failed = 0u32;
let mut ignored = 0u32;
let mut errors = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains("test result: ok") {
if let Some(captures) = parse_test_result_line(trimmed) {
passed += captures.0;
ignored += captures.2;
}
} else if trimmed.contains("test result: FAILED") {
if let Some(captures) = parse_test_result_line(trimmed) {
passed += captures.0;
failed += captures.1;
ignored += captures.2;
}
} else if trimmed.starts_with("error")
|| trimmed.contains("panicked at")
|| (trimmed.contains("FAILED") && trimmed.contains("test"))
{
errors.push(trimmed.to_string());
}
}
TestResult {
passed,
failed,
ignored,
errors,
raw_output: output.to_string(),
}
}
fn parse_test_result_line(line: &str) -> Option<(u32, u32, u32)> {
let passed = extract_number(line, "passed")?;
let failed = extract_number(line, "failed").unwrap_or(0);
let ignored = extract_number(line, "ignored").unwrap_or(0);
Some((passed, failed, ignored))
}
fn extract_number(line: &str, keyword: &str) -> Option<u32> {
let idx = line.find(keyword)?;
let number_str = line[..idx].split_whitespace().next_back()?;
number_str.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_a_passing_cargo_suite() {
let output = "running 6 tests\n\
test result: ok. 5 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out\n";
let result = parse_test_output(output);
assert_eq!((result.passed, result.failed, result.ignored), (5, 0, 1));
}
#[test]
fn counts_a_failing_cargo_suite() {
let output =
"test result: FAILED. 2 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out\n";
let result = parse_test_output(output);
assert_eq!((result.passed, result.failed, result.ignored), (2, 3, 0));
}
#[test]
fn passing_and_failing_suites_are_distinguishable() {
let ok = parse_test_output("test result: ok. 5 passed; 0 failed; 0 ignored\n");
let bad = parse_test_output("test result: FAILED. 0 passed; 5 failed; 0 ignored\n");
assert_ne!(
(ok.passed, ok.failed),
(bad.passed, bad.failed),
"a green run must not look identical to a red one"
);
}
#[test]
fn collects_compiler_errors() {
let output = "error: cannot find value `foo` in this scope\n\
error[E0425]: unresolved import\n";
let result = parse_test_output(output);
assert_eq!(result.errors.len(), 2, "got {:?}", result.errors);
}
#[test]
fn collects_panics() {
let result = parse_test_output("thread 'main' panicked at src/lib.rs:4:5:\n");
assert_eq!(result.errors.len(), 1);
}
#[test]
fn sums_counts_across_multiple_suites() {
let output = "test result: ok. 3 passed; 0 failed; 0 ignored\n\
test result: ok. 4 passed; 0 failed; 2 ignored\n";
let result = parse_test_output(output);
assert_eq!((result.passed, result.ignored), (7, 2));
}
}