procyon 0.1.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
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)?;

        // The project's own `test` script first. This used to run `npx @caatinga/cli test`
        // whenever it saw a `caatinga.config.ts`, and the Caatinga CLI has no `test` command —
        // it answers "unknown command 'test'". Meanwhile the template it generates declares the
        // real command in `package.json`, which is the thing that knows how this project is tested.
        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));
            }
        }

        // The exit status used to be dropped, so a runner that could not start at all was reported
        // as a passing run: the trace said "done" under the words "unknown command". A failing
        // test suite is a failure too — the output travels with it either way, so nothing is lost
        // by saying so.
        if ran.success {
            Ok(formatted)
        } else {
            Err(formatted)
        }
    }
}

/// What a runner did: its combined output, and whether it actually succeeded.
struct Ran {
    output: String,
    success: bool,
}

/// The `test` script from `package.json`, if there is one worth running.
///
/// Only a non-empty string counts. `npm test` on a project with no such script prints its own
/// error, which would read as a test failure rather than as "this project has no test script".
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 {
        // Everything after `--` belongs to the script, not to npm.
        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))
}

// Counts sit immediately before their keyword ("... 5 passed; 0 failed ..."), so the relevant
// token is the last one before it, not the first.
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));
    }
}