use anyhow::Result;
use std::collections::HashMap;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, error, info};
#[derive(Debug, Clone)]
pub struct TestRunner {
config: TestConfig,
}
#[derive(Debug, Clone)]
pub struct TestConfig {
pub timeout_seconds: u64,
pub parallel: bool,
pub verbose: bool,
pub test_patterns: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TestResults {
pub passed: usize,
pub failed: usize,
pub ignored: usize,
pub total: usize,
pub duration_ms: u64,
pub details: Vec<TestDetail>,
}
#[derive(Debug, Clone)]
pub struct TestDetail {
pub name: String,
pub status: TestStatus,
pub duration_ms: u64,
pub output: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TestStatus {
Passed,
Failed,
Ignored,
Timeout,
}
impl Default for TestConfig {
fn default() -> Self {
Self {
timeout_seconds: 60,
parallel: true,
verbose: false,
test_patterns: vec!["test".to_string()],
}
}
}
impl TestRunner {
#[must_use]
pub fn new() -> Self {
Self {
config: TestConfig::default(),
}
}
#[must_use]
pub fn with_config(config: TestConfig) -> Self {
Self { config }
}
pub async fn run_unit_tests(&self, crate_path: &PathBuf) -> Result<TestResults> {
info!("Running unit tests for crate at: {:?}", crate_path);
let mut cmd = Command::new("cargo");
let mut cmd = cmd.arg("test").current_dir(crate_path);
if self.config.verbose {
cmd = cmd.arg("--verbose");
}
if !self.config.parallel {
cmd = cmd.arg("--test-threads=1");
}
for pattern in &self.config.test_patterns {
cmd = cmd.arg(pattern);
}
debug!("Executing command: {:?}", cmd);
let output = cmd.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("Test output: {}", stdout);
if !stderr.is_empty() {
debug!("Test stderr: {}", stderr);
}
self.parse_test_output(&stdout, &stderr)
}
pub async fn run_integration_tests(&self, crate_path: &PathBuf) -> Result<TestResults> {
info!("Running integration tests for crate at: {:?}", crate_path);
let mut cmd = Command::new("cargo");
let mut cmd = cmd
.arg("test")
.arg("--test")
.arg("*")
.current_dir(crate_path);
if self.config.verbose {
cmd = cmd.arg("--verbose");
}
let output = cmd.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
self.parse_test_output(&stdout, &stderr)
}
pub async fn run_doc_tests(&self, crate_path: &PathBuf) -> Result<TestResults> {
info!("Running doc tests for crate at: {:?}", crate_path);
let mut cmd = Command::new("cargo");
let mut cmd = cmd.arg("test").arg("--doc").current_dir(crate_path);
if self.config.verbose {
cmd = cmd.arg("--verbose");
}
let output = cmd.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
self.parse_test_output(&stdout, &stderr)
}
pub async fn run_benchmarks(&self, crate_path: &PathBuf) -> Result<TestResults> {
info!("Running benchmarks for crate at: {:?}", crate_path);
let mut cmd = Command::new("cargo");
let mut cmd = cmd.arg("bench").current_dir(crate_path);
if self.config.verbose {
cmd = cmd.arg("--verbose");
}
let output = cmd.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
self.parse_test_output(&stdout, &stderr)
}
pub async fn run_all_tests(
&self,
crate_path: &PathBuf,
) -> Result<HashMap<String, TestResults>> {
info!("Running all tests for crate at: {:?}", crate_path);
let mut results = HashMap::new();
match self.run_unit_tests(crate_path).await {
Ok(unit_results) => {
let _ = results.insert("unit".to_string(), unit_results);
}
Err(e) => {
error!("Failed to run unit tests: {}", e);
}
}
match self.run_integration_tests(crate_path).await {
Ok(integration_results) => {
let _ = results.insert("integration".to_string(), integration_results);
}
Err(e) => {
error!("Failed to run integration tests: {}", e);
}
}
match self.run_doc_tests(crate_path).await {
Ok(doc_results) => {
let _ = results.insert("doc".to_string(), doc_results);
}
Err(e) => {
error!("Failed to run doc tests: {}", e);
}
}
Ok(results)
}
fn parse_test_output(&self, stdout: &str, stderr: &str) -> Result<TestResults> {
let mut passed = 0;
let mut failed = 0;
let mut ignored = 0;
let mut details = Vec::new();
for line in stdout.lines() {
if line.contains("test result:") {
if let Some(summary) = self.parse_summary_line(line) {
passed = summary.0;
failed = summary.1;
ignored = summary.2;
}
} else if line.starts_with("test ") {
if let Some(detail) = self.parse_test_line(line) {
details.push(detail);
}
}
}
if !stderr.is_empty() && failed == 0 {
failed = 1; }
let total = passed + failed + ignored;
Ok(TestResults {
passed,
failed,
ignored,
total,
duration_ms: 0, details,
})
}
fn parse_summary_line(&self, line: &str) -> Option<(usize, usize, usize)> {
if let Some(start) = line.find("test result:") {
let rest = &line[start..];
let parts: Vec<&str> = rest.split_whitespace().collect();
let mut passed = 0;
let mut failed = 0;
let mut ignored = 0;
for (i, part) in parts.iter().enumerate() {
if *part == "passed;" && i > 0 {
if let Ok(num) = parts[i - 1].parse::<usize>() {
passed = num;
}
} else if *part == "failed;" && i > 0 {
if let Ok(num) = parts[i - 1].parse::<usize>() {
failed = num;
}
} else if *part == "ignored;" && i > 0 {
if let Ok(num) = parts[i - 1].parse::<usize>() {
ignored = num;
}
}
}
return Some((passed, failed, ignored));
}
None
}
fn parse_test_line(&self, line: &str) -> Option<TestDetail> {
if line.starts_with("test ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
let name = parts[1].to_string();
let status = match parts[parts.len() - 1] {
"ok" => TestStatus::Passed,
"FAILED" => TestStatus::Failed,
"ignored" => TestStatus::Ignored,
_ => TestStatus::Failed,
};
return Some(TestDetail {
name,
status,
duration_ms: 0, output: None,
error: None,
});
}
}
None
}
#[must_use]
pub fn can_run_tests(&self, crate_path: &Path) -> bool {
crate_path.join("Cargo.toml").exists()
}
#[must_use]
pub fn config(&self) -> &TestConfig {
&self.config
}
pub fn set_config(&mut self, config: TestConfig) {
self.config = config;
}
}
impl Default for TestRunner {
fn default() -> Self {
Self::new()
}
}