procyon 0.1.0

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 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)?;

        // `npx @caatinga/cli --version` would install the package just to answer the question, so
        // the project's own config file is the signal instead.
        let uses_caatinga = tokio::fs::try_exists(path.join("caatinga.config.ts"))
            .await
            .unwrap_or(false)
            && which::which("npx").is_ok();

        let output = if uses_caatinga {
            run_caatinga_test(&path, filter).await?
        } else {
            run_cargo_test(&path, filter).await?
        };

        let result = parse_test_output(&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));
            }
        }

        Ok(formatted)
    }
}

async fn run_cargo_test(path: &Path, filter: Option<&str>) -> Result<String, 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))?;

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();

    Ok(format!("{}\n{}", stdout, stderr))
}

async fn run_caatinga_test(path: &Path, filter: Option<&str>) -> Result<String, String> {
    let mut args = vec!["@caatinga/cli".to_string(), "test".to_string()];

    if let Some(f) = filter {
        args.push("--filter".to_string());
        args.push(f.to_string());
    }

    let output = Command::new("npx")
        .args(&args)
        .current_dir(path)
        .output()
        .await
        .map_err(|e| format!("Failed to run caatinga test: {}", e))?;

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();

    Ok(format!("{}\n{}", stdout, stderr))
}

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));
    }
}