use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct QualityGate {
pub name: String,
pub command: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GateResult {
pub gate_name: String,
pub passed: bool,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
pub fn detect_gates(project_root: &Path) -> Vec<QualityGate> {
let mut gates = Vec::new();
if project_root.join("Cargo.toml").exists() {
gates.push(QualityGate {
name: "Cargo Test".into(),
command: "cargo".into(),
args: vec!["test".into()],
});
gates.push(QualityGate {
name: "Cargo Clippy".into(),
command: "cargo".into(),
args: vec![
"clippy".into(),
"--".into(),
"-W".into(),
"clippy::all".into(),
],
});
}
if project_root.join("package.json").exists()
&& let Ok(content) = std::fs::read_to_string(project_root.join("package.json"))
&& let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content)
{
let scripts = pkg.get("scripts").and_then(|s| s.as_object());
if scripts.is_some_and(|s| s.contains_key("typecheck")) {
gates.push(QualityGate {
name: "TypeCheck".into(),
command: "npm".into(),
args: vec!["run".into(), "typecheck".into()],
});
} else if project_root.join("tsconfig.json").exists() {
gates.push(QualityGate {
name: "TypeCheck".into(),
command: "npx".into(),
args: vec!["tsc".into(), "--noEmit".into()],
});
}
if scripts.is_some_and(|s| s.contains_key("test")) {
gates.push(QualityGate {
name: "Test Suite".into(),
command: "npm".into(),
args: vec!["test".into(), "--".into(), "--run".into()],
});
}
if scripts.is_some_and(|s| s.contains_key("lint")) {
gates.push(QualityGate {
name: "Lint".into(),
command: "npm".into(),
args: vec!["run".into(), "lint".into()],
});
}
}
if project_root.join("pyproject.toml").exists() || project_root.join("setup.py").exists() {
gates.push(QualityGate {
name: "Pytest".into(),
command: "python".into(),
args: vec!["-m".into(), "pytest".into()],
});
}
if project_root.join("playwright.config.ts").exists()
|| project_root.join("playwright.config.js").exists()
{
gates.push(QualityGate {
name: "Playwright E2E".into(),
command: "npx".into(),
args: vec!["playwright".into(), "test".into(), "--reporter=list".into()],
});
}
gates
}
fn truncate_tail(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("...truncated...\n{}", &s[s.len() - max..])
}
}
pub fn run_gates(project_root: &Path, gates: &[QualityGate]) -> Vec<GateResult> {
gates
.iter()
.map(|gate| run_single_gate(project_root, gate))
.collect()
}
fn run_single_gate(project_root: &Path, gate: &QualityGate) -> GateResult {
let start = Instant::now();
let result = std::process::Command::new(&gate.command)
.args(&gate.args)
.current_dir(project_root)
.output();
let duration_ms = start.elapsed().as_millis() as u64;
match result {
Ok(output) => {
let exit_code = output.status.code().unwrap_or(-1);
GateResult {
gate_name: gate.name.clone(),
passed: output.status.success(),
exit_code,
stdout: truncate_tail(&String::from_utf8_lossy(&output.stdout), 2000),
stderr: truncate_tail(&String::from_utf8_lossy(&output.stderr), 2000),
duration_ms,
}
}
Err(e) => GateResult {
gate_name: gate.name.clone(),
passed: false,
exit_code: -1,
stdout: String::new(),
stderr: format!("Failed to run: {e}"),
duration_ms,
},
}
}
pub fn summarize_gate_failures(results: &[GateResult]) -> String {
results
.iter()
.filter(|r| !r.passed)
.map(|r| {
let stderr_tail = truncate_tail(&r.stderr, 500);
format!(
"FAILED: {} (exit {})\n{}\n---",
r.gate_name, r.exit_code, stderr_tail
)
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_detect_gates_node_project() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"scripts":{"test":"vitest","lint":"eslint ."}}"#,
)
.unwrap();
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
let gates = detect_gates(dir.path());
let names: Vec<&str> = gates.iter().map(|g| g.name.as_str()).collect();
assert!(
names.contains(&"TypeCheck"),
"expected TypeCheck, got {names:?}"
);
assert!(
names.contains(&"Test Suite"),
"expected Test Suite, got {names:?}"
);
assert!(names.contains(&"Lint"), "expected Lint, got {names:?}");
assert_eq!(gates.len(), 3);
}
#[test]
fn test_detect_gates_node_with_typecheck_script() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"scripts":{"test":"vitest","typecheck":"tsc --noEmit"}}"#,
)
.unwrap();
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
let gates = detect_gates(dir.path());
let tc = gates.iter().find(|g| g.name == "TypeCheck").unwrap();
assert_eq!(tc.command, "npm");
assert_eq!(tc.args, vec!["run", "typecheck"]);
}
#[test]
fn test_detect_gates_rust_project() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let gates = detect_gates(dir.path());
assert_eq!(gates.len(), 2);
assert_eq!(gates[0].name, "Cargo Test");
assert_eq!(gates[1].name, "Cargo Clippy");
}
#[test]
fn test_detect_gates_empty_project() {
let dir = tempdir().unwrap();
let gates = detect_gates(dir.path());
assert!(gates.is_empty());
}
#[test]
fn test_detect_gates_python_project() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
let gates = detect_gates(dir.path());
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].name, "Pytest");
}
#[test]
fn test_detect_gates_playwright() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("playwright.config.ts"), "export default {}").unwrap();
let gates = detect_gates(dir.path());
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].name, "Playwright E2E");
assert_eq!(gates[0].command, "npx");
assert_eq!(gates[0].args, vec!["playwright", "test", "--reporter=list"]);
}
#[test]
fn test_detect_gates_playwright_js() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("playwright.config.js"),
"module.exports = {}",
)
.unwrap();
let gates = detect_gates(dir.path());
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].name, "Playwright E2E");
}
#[test]
fn test_run_gates_success() {
let dir = tempdir().unwrap();
let gate = QualityGate {
name: "Echo".into(),
command: "echo".into(),
args: vec!["hello".into()],
};
let results = run_gates(dir.path(), &[gate]);
assert_eq!(results.len(), 1);
assert!(results[0].passed);
assert_eq!(results[0].exit_code, 0);
assert!(results[0].stdout.contains("hello"));
}
#[test]
fn test_run_gates_failure() {
let dir = tempdir().unwrap();
let gate = QualityGate {
name: "Fail".into(),
command: "false".into(),
args: vec![],
};
let results = run_gates(dir.path(), &[gate]);
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert_ne!(results[0].exit_code, 0);
}
#[test]
fn test_run_gates_missing_command() {
let dir = tempdir().unwrap();
let gate = QualityGate {
name: "Missing".into(),
command: "nonexistent_binary_xyz_42".into(),
args: vec![],
};
let results = run_gates(dir.path(), &[gate]);
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert_eq!(results[0].exit_code, -1);
assert!(results[0].stderr.contains("Failed to run"));
}
#[test]
fn test_truncate_tail_short() {
let s = "short";
assert_eq!(truncate_tail(s, 100), "short");
}
#[test]
fn test_truncate_tail_long() {
let s = "a".repeat(3000);
let result = truncate_tail(&s, 2000);
assert!(result.starts_with("...truncated..."));
assert!(result.len() <= 2020); }
#[test]
fn test_summarize_failures() {
let results = vec![
GateResult {
gate_name: "TypeCheck".into(),
passed: false,
exit_code: 2,
stdout: String::new(),
stderr: "TS2345: type error".into(),
duration_ms: 100,
},
GateResult {
gate_name: "Lint".into(),
passed: true,
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
duration_ms: 50,
},
GateResult {
gate_name: "Tests".into(),
passed: false,
exit_code: 1,
stdout: String::new(),
stderr: "FAIL src/foo.test.ts".into(),
duration_ms: 200,
},
];
let summary = summarize_gate_failures(&results);
assert!(summary.contains("FAILED: TypeCheck"));
assert!(summary.contains("FAILED: Tests"));
assert!(!summary.contains("Lint")); }
}