use crate::cache::Cache;
use crate::config::{Config, ProfileKind};
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Output;
use std::str::FromStr;
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::Semaphore;
pub const CHECK_TIMEOUT_SECS: u64 = 300;
pub const TEST_TIMEOUT_SECS: u64 = 900;
mod cargo;
mod python;
mod semgrep;
mod typescript;
pub use cargo::{
CargoAuditCheck, CargoCheck, CargoGeigerCheck, CargoTestCheck, ClippyCheck, RustfmtCheck,
};
pub use python::{MypyCheck, PytestCheck, RuffCheck};
pub use semgrep::SemgrepCheck;
pub use typescript::{ESLintCheck, StylelintCheck, TypeScriptCheck, VitestCheck};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckProvenance {
pub command: String,
pub tool_version: Option<String>,
pub cwd: String,
pub exit_code: Option<i32>,
pub started_at: String,
pub finished_at: String,
pub hard_fail_signatures: Vec<String>,
pub cache_key: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SkippedCheck {
pub id: String,
pub name: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckEligibility {
Run,
Skip(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub name: String,
pub status: CheckStatus,
pub duration: Duration,
pub output: String,
pub cached: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub provenance: Option<CheckProvenance>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Passed,
Failed,
Warnings,
Skipped,
Error,
}
impl FromStr for CheckStatus {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"passed" => Self::Passed,
"failed" => Self::Failed,
"warnings" => Self::Warnings,
"skipped" => Self::Skipped,
_ => Self::Error,
})
}
}
impl CheckStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Passed => "passed",
Self::Failed => "failed",
Self::Warnings => "warnings",
Self::Skipped => "skipped",
Self::Error => "error",
}
}
}
#[async_trait]
pub trait Check: Send + Sync {
fn name(&self) -> &str;
fn check_eligibility(&self, config: &Config) -> CheckEligibility;
async fn run(&self, config: &Config) -> Result<CheckResult>;
fn cache_key(&self, _config: &Config) -> Option<String> {
None
}
}
pub async fn run_all(config: &Config) -> Result<(Vec<CheckResult>, Vec<SkippedCheck>)> {
use colored::Colorize;
use futures::stream::{FuturesUnordered, StreamExt};
use std::io::Write;
use std::sync::Arc;
let checks: Vec<Box<dyn Check>> = get_checks_for_profile(config);
let cache = Arc::new(Cache::new(config));
let emit = !config.json && !config.quiet;
if emit {
println!("{}", "Running quality checks...".cyan());
println!();
}
let mut results = Vec::new();
let mut skipped = Vec::new();
let mut runnable_checks = Vec::new();
for check in checks {
match check.check_eligibility(config) {
CheckEligibility::Skip(reason) => {
skipped.push(build_skipped_check(check.as_ref(), reason));
continue;
}
CheckEligibility::Run => {}
}
if let Some(result) = load_cached_result(check.as_ref(), config, cache.as_ref()) {
let status_str = format_status(result.status);
if emit {
println!(" {} {} (cached)", status_str, check.name());
}
results.push(result);
continue;
}
runnable_checks.push(check);
}
let has_python_checks = runnable_checks
.iter()
.any(|c| matches!(c.name(), "Ruff" | "Mypy" | "Pytest"));
if has_python_checks && config.profile.runs_python_checks() && which::which("uv").is_ok() {
if emit {
print!(" {} Syncing Python venv...", "●".blue());
let _ = std::io::stdout().flush();
}
match run_command_with_timeout(
"uv",
&["sync", "--quiet"],
&config.repo_root,
CHECK_TIMEOUT_SECS,
)
.await
{
Ok(output) => {
if emit {
if output.status.success() {
print!("\r\x1b[2K {} Python venv ready\n", "✓".green());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
print!(
"\r\x1b[2K {} uv sync failed: {}\n",
"⚠".yellow(),
stderr.lines().next().unwrap_or("unknown error")
);
}
let _ = std::io::stdout().flush();
}
}
Err(e) => {
if emit {
print!("\r\x1b[2K {} uv sync: {}\n", "⚠".yellow(), e);
let _ = std::io::stdout().flush();
}
}
}
}
if !runnable_checks.is_empty() {
let mut remaining: Vec<String> = runnable_checks
.iter()
.map(|c| c.name().to_string())
.collect();
if emit {
print!(" {} Running: {}", "●".blue(), remaining.join(", "));
let _ = std::io::stdout().flush();
}
let config = Arc::new(config.clone());
let cargo_lock = Arc::new(Semaphore::new(1));
let mut futs: FuturesUnordered<_> = runnable_checks
.into_iter()
.map(|check| {
let config = Arc::clone(&config);
let cache = Arc::clone(&cache);
let cargo_lock = Arc::clone(&cargo_lock);
async move {
let _permit = if is_cargo_target_check(check.name()) {
Some(
cargo_lock
.acquire()
.await
.expect("cargo-target semaphore never closed"),
)
} else {
None
};
execute_live_check(check, config.as_ref(), cache.as_ref()).await
}
})
.collect();
let start = std::time::Instant::now();
let mut timer = tokio::time::interval(tokio::time::Duration::from_secs(1));
timer.tick().await;
const SLOW_NOTICE_THRESHOLDS_SECS: [u64; 3] = [60, 300, 900];
let mut next_slow_notice = 0usize;
loop {
tokio::select! {
biased;
Some(result) = futs.next() => {
remaining.retain(|n| n != &result.name);
if emit {
print!("\r\x1b[2K");
let status_str = format_status(result.status);
println!(
" {} {} ({:.1}s)",
status_str,
result.name,
result.duration.as_secs_f32(),
);
if !remaining.is_empty() {
let elapsed = start.elapsed().as_secs();
print!(
" {} Running: {} ({}s)",
"●".blue(),
remaining.join(", "),
elapsed
);
let _ = std::io::stdout().flush();
}
}
results.push(result);
if remaining.is_empty() {
break;
}
}
_ = timer.tick(), if emit && !remaining.is_empty() => {
let elapsed = start.elapsed().as_secs();
if next_slow_notice < SLOW_NOTICE_THRESHOLDS_SECS.len()
&& elapsed >= SLOW_NOTICE_THRESHOLDS_SECS[next_slow_notice]
{
next_slow_notice += 1;
println!(
"\r\x1b[2K {} Still running after {}s: {}. Cargo checks compile \
the whole workspace and can take several minutes (each has its \
own timeout). Press Ctrl-C to abort.",
"ℹ".cyan(),
elapsed,
remaining.join(", "),
);
}
print!(
"\r\x1b[2K {} Running: {} ({}s)",
"●".blue(),
remaining.join(", "),
elapsed
);
let _ = std::io::stdout().flush();
}
}
}
}
if emit {
println!();
}
Ok((results, skipped))
}
pub type CheckEventCallback = Box<dyn Fn(CheckEvent) + Send + Sync>;
#[derive(Debug, Clone)]
pub enum CheckEvent {
Started { name: String },
Completed { result: Box<CheckResult> },
Skipped { name: String },
}
pub async fn run_all_with_events<F>(
config: &Config,
on_event: F,
) -> Result<(Vec<CheckResult>, Vec<SkippedCheck>)>
where
F: Fn(CheckEvent) + Send + Sync,
{
let checks: Vec<Box<dyn Check>> = get_checks_for_profile(config);
let cache = Cache::new(config);
let mut results = Vec::new();
let mut skipped = Vec::new();
let mut runnable_checks: Vec<Box<dyn Check>> = Vec::new();
for check in checks {
match check.check_eligibility(config) {
CheckEligibility::Skip(reason) => {
let skipped_check = build_skipped_check(check.as_ref(), reason);
let name = skipped_check.name.clone();
skipped.push(skipped_check);
on_event(CheckEvent::Skipped { name });
continue;
}
CheckEligibility::Run => {}
}
if let Some(result) = load_cached_result(check.as_ref(), config, &cache) {
on_event(CheckEvent::Completed {
result: Box::new(result.clone()),
});
results.push(result);
continue;
}
runnable_checks.push(check);
}
let has_python_checks = runnable_checks
.iter()
.any(|c| matches!(c.name(), "Ruff" | "Mypy" | "Pytest"));
if has_python_checks && config.profile.runs_python_checks() && which::which("uv").is_ok() {
let _ = run_command_with_timeout(
"uv",
&["sync", "--quiet"],
&config.repo_root,
CHECK_TIMEOUT_SECS,
)
.await;
}
{
use futures::stream::{FuturesUnordered, StreamExt};
use std::sync::Arc;
for check in &runnable_checks {
on_event(CheckEvent::Started {
name: check.name().to_string(),
});
}
let config = Arc::new(config.clone());
let cache = Arc::new(cache);
let cargo_lock = Arc::new(Semaphore::new(1));
let mut futs: FuturesUnordered<_> = runnable_checks
.into_iter()
.map(|check| {
let config = Arc::clone(&config);
let cache = Arc::clone(&cache);
let cargo_lock = Arc::clone(&cargo_lock);
async move {
let _permit = if is_cargo_target_check(check.name()) {
Some(
cargo_lock
.acquire()
.await
.expect("cargo-target semaphore never closed"),
)
} else {
None
};
execute_live_check(check, config.as_ref(), cache.as_ref()).await
}
})
.collect();
while let Some(result) = futs.next().await {
on_event(CheckEvent::Completed {
result: Box::new(result.clone()),
});
results.push(result);
}
}
Ok((results, skipped))
}
fn build_skipped_check(check: &dyn Check, reason: String) -> SkippedCheck {
let name = check.name().to_string();
let id = name.to_lowercase().replace([' ', '-', '/'], "_");
SkippedCheck { id, name, reason }
}
fn load_cached_result(check: &dyn Check, config: &Config, cache: &Cache) -> Option<CheckResult> {
let cache_key = check.cache_key(config)?;
let cached = cache.get(check.name(), &cache_key)?;
let output = cached.output.unwrap_or_default();
let mut status = cached.status.parse::<CheckStatus>().unwrap();
if matches!(status, CheckStatus::Passed | CheckStatus::Warnings) && has_tool_crash(&output) {
status = CheckStatus::Error;
}
Some(CheckResult {
name: check.name().to_string(),
status,
duration: Duration::from_secs(0),
output,
cached: true,
provenance: None,
})
}
async fn execute_live_check(check: Box<dyn Check>, config: &Config, cache: &Cache) -> CheckResult {
let start = std::time::Instant::now();
let name = check.name().to_string();
let cache_key = check.cache_key(config);
match check.run(config).await {
Ok(mut result) => {
if matches!(result.status, CheckStatus::Passed | CheckStatus::Warnings)
&& has_tool_crash(&result.output)
{
result.status = CheckStatus::Error;
}
if result.status != CheckStatus::Skipped
&& let Some(key) = cache_key
&& let Err(e) = cache.set(&name, &key, result.status.as_str(), Some(&result.output))
{
eprintln!(" warning: cache write failed for {name}: {e}");
}
result
}
Err(e) => {
let msg = e.to_string();
let status = if tool_unavailable_signature(&msg) && !is_security_check(&name) {
CheckStatus::Skipped
} else {
CheckStatus::Error
};
CheckResult {
name,
status,
duration: start.elapsed(),
output: msg,
cached: false,
provenance: None,
}
}
}
}
fn format_status(status: CheckStatus) -> String {
use colored::Colorize;
match status {
CheckStatus::Passed => "✓".green().to_string(),
CheckStatus::Failed => "✗".red().to_string(),
CheckStatus::Warnings => "⚠".yellow().to_string(),
CheckStatus::Skipped => "○".dimmed().to_string(),
CheckStatus::Error => "!".red().to_string(),
}
}
pub fn get_checks_for_profile(config: &Config) -> Vec<Box<dyn Check>> {
let mut checks: Vec<Box<dyn Check>> = Vec::new();
checks.push(Box::new(SemgrepCheck));
match config.profile.kind {
ProfileKind::Js => {
checks.push(Box::new(TypeScriptCheck));
checks.push(Box::new(ESLintCheck));
checks.push(Box::new(StylelintCheck));
checks.push(Box::new(VitestCheck));
}
ProfileKind::Rust => {
checks.push(Box::new(CargoCheck));
checks.push(Box::new(ClippyCheck));
checks.push(Box::new(RustfmtCheck));
checks.push(Box::new(CargoTestCheck));
checks.push(Box::new(CargoAuditCheck));
if config.security_full {
checks.push(Box::new(CargoGeigerCheck));
}
}
ProfileKind::Python => {
checks.push(Box::new(RuffCheck));
checks.push(Box::new(MypyCheck));
checks.push(Box::new(PytestCheck));
}
ProfileKind::Mixed => {
if config.profile.has_tsconfig {
checks.push(Box::new(TypeScriptCheck));
}
if config.profile.has_package_json {
checks.push(Box::new(ESLintCheck));
checks.push(Box::new(StylelintCheck));
checks.push(Box::new(VitestCheck));
}
if config.profile.has_cargo {
checks.push(Box::new(CargoCheck));
checks.push(Box::new(ClippyCheck));
checks.push(Box::new(RustfmtCheck));
checks.push(Box::new(CargoTestCheck));
checks.push(Box::new(CargoAuditCheck));
if config.security_full {
checks.push(Box::new(CargoGeigerCheck));
}
}
if config.profile.runs_python_checks() {
checks.push(Box::new(RuffCheck));
checks.push(Box::new(MypyCheck));
checks.push(Box::new(PytestCheck));
}
}
ProfileKind::Generic => {}
}
checks
}
const HARD_FAIL_SIGNATURES: &[(&str, &str)] = &[
("thread '", "Rust panic"),
("unhandledpromiserejection", "Node unhandled rejection"),
("err_unhandled_rejection", "Node unhandled rejection"),
("traceback (most recent call last):", "Python traceback"),
("segmentation fault", "Segfault"),
("addresssanitizer", "ASan"),
("ubsan:", "UBSan"),
("threadsanitizer", "TSan"),
("sigabrt", "SIGABRT"),
("sigsegv", "SIGSEGV"),
("fatal runtime error", "Fatal runtime error"),
("stack overflow", "Stack overflow"),
];
pub fn has_tool_crash(output: &str) -> bool {
!find_hard_fail_signatures(output).is_empty()
}
fn is_cargo_target_check(name: &str) -> bool {
matches!(
name,
"Cargo check" | "Clippy" | "Rustfmt" | "Cargo test" | "Cargo audit" | "Cargo geiger"
)
}
fn is_security_check(name: &str) -> bool {
matches!(name, "Semgrep scan" | "Cargo audit" | "Cargo geiger")
}
pub fn find_hard_fail_signatures(output: &str) -> Vec<String> {
let mut found = Vec::new();
for line in output.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("test ") {
continue;
}
let lower = trimmed.to_ascii_lowercase();
for &(pattern, label) in HARD_FAIL_SIGNATURES {
let matched = if pattern == "thread '" {
lower.contains("panic") && lower.contains(pattern)
} else {
lower.contains(pattern)
};
if matched && !found.iter().any(|existing| existing == label) {
found.push(label.to_string());
}
}
}
found
}
pub fn tool_unavailable_signature(err: &str) -> bool {
let lower = err.to_ascii_lowercase();
lower.contains("no such file or directory")
|| lower.contains("failed to spawn")
|| lower.contains("command not found")
|| lower.contains("program not found")
|| lower.contains("cannot find the file specified")
}
pub fn tool_spawn_failure_in_output(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
lower.contains("failed to spawn") || lower.contains("command not found")
}
pub struct ProvenanceBuilder<'a> {
pub cmd: &'a str,
pub args: &'a [&'a str],
pub cwd: &'a Path,
pub output: &'a Output,
pub combined_output: &'a str,
pub started_at: &'a str,
pub finished_at: &'a str,
pub cache_key: Option<String>,
}
impl<'a> ProvenanceBuilder<'a> {
pub fn build(self) -> CheckProvenance {
self.build_with_repo_root(None)
}
pub fn build_with_repo_root(self, repo_root: Option<&Path>) -> CheckProvenance {
let cwd_str = self.cwd.display().to_string();
let cwd_display = match repo_root {
Some(root) => crate::paths::normalize_path_display(&cwd_str, root),
None => cwd_str,
};
CheckProvenance {
command: format!("{} {}", self.cmd, self.args.join(" ")),
tool_version: None,
cwd: cwd_display,
exit_code: self.output.status.code(),
started_at: self.started_at.to_string(),
finished_at: self.finished_at.to_string(),
hard_fail_signatures: find_hard_fail_signatures(self.combined_output),
cache_key: self.cache_key,
}
}
}
const TIMEOUT_MARKER: &str = "timed out after";
fn timeout_error(cmd: &str, timeout_secs: u64) -> anyhow::Error {
anyhow::anyhow!("{} {} {}s", cmd, TIMEOUT_MARKER, timeout_secs)
}
pub fn is_timeout_error(err: &anyhow::Error) -> bool {
err.to_string().contains(TIMEOUT_MARKER)
}
pub async fn run_command(cmd: &str, args: &[&str], cwd: &Path) -> Result<Output> {
run_command_with_timeout(cmd, args, cwd, CHECK_TIMEOUT_SECS).await
}
pub async fn run_command_with_timeout(
cmd: &str,
args: &[&str],
cwd: &Path,
timeout_secs: u64,
) -> Result<Output> {
let mut command = Command::new(cmd);
command.args(args).current_dir(cwd);
crate::proc::run_capture_with_timeout(command, Duration::from_secs(timeout_secs), cmd, || {
timeout_error(cmd, timeout_secs)
})
.await
}
pub async fn run_js_command(tool: &str, args: &[&str], cwd: &Path) -> Result<Output> {
run_js_command_with_timeout(tool, args, cwd, CHECK_TIMEOUT_SECS).await
}
pub async fn run_js_command_with_timeout(
tool: &str,
args: &[&str],
cwd: &Path,
timeout_secs: u64,
) -> Result<Output> {
let pnpm_args: Vec<&str> = std::iter::once("exec")
.chain(std::iter::once(tool))
.chain(args.iter().copied())
.collect();
let npx_args: Vec<&str> = ["--no-install", tool]
.into_iter()
.chain(args.iter().copied())
.collect();
if let Some(bin) = local_js_bin(tool, cwd) {
let bin = bin.to_string_lossy().into_owned();
run_command_with_timeout(&bin, args, cwd, timeout_secs).await
} else if which::which("pnpm").is_ok() {
run_command_with_timeout("pnpm", &pnpm_args, cwd, timeout_secs).await
} else {
run_command_with_timeout("npx", &npx_args, cwd, timeout_secs).await
}
}
pub fn local_js_bin(tool: &str, cwd: &Path) -> Option<std::path::PathBuf> {
let bin = cwd.join("node_modules/.bin").join(tool);
bin.exists().then_some(bin)
}
pub fn js_tool_available(tool: &str, cwd: &Path) -> bool {
local_js_bin(tool, cwd).is_some()
}
impl CheckResult {
pub fn is_failure(&self) -> bool {
matches!(self.status, CheckStatus::Failed | CheckStatus::Error)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::ExecutionMode;
use crate::config::{Config, test_config, test_rust_profile};
use std::time::Duration;
fn rust_config(run_tests: bool, run_lint: bool, run_security: bool) -> Config {
let mut config = test_config();
config.profile = test_rust_profile(true);
config.execution_mode = ExecutionMode::Standard;
config.run_tests = run_tests;
config.run_lint = run_lint;
config.run_security = run_security;
config.do_fetch = false;
config.use_cache = false;
config.create_zip = false;
config
}
#[test]
fn test_check_status_from_str_passed() {
assert_eq!(
"passed".parse::<CheckStatus>().unwrap(),
CheckStatus::Passed
);
assert_eq!(
"PASSED".parse::<CheckStatus>().unwrap(),
CheckStatus::Passed
);
}
#[test]
fn test_check_status_from_str_failed() {
assert_eq!(
"failed".parse::<CheckStatus>().unwrap(),
CheckStatus::Failed
);
}
#[test]
fn test_check_status_from_str_warnings() {
assert_eq!(
"warnings".parse::<CheckStatus>().unwrap(),
CheckStatus::Warnings
);
}
#[test]
fn test_check_status_from_str_skipped() {
assert_eq!(
"skipped".parse::<CheckStatus>().unwrap(),
CheckStatus::Skipped
);
}
#[test]
fn test_check_status_from_str_unknown() {
assert_eq!(
"unknown".parse::<CheckStatus>().unwrap(),
CheckStatus::Error
);
}
#[test]
fn test_check_status_as_str() {
assert_eq!(CheckStatus::Passed.as_str(), "passed");
assert_eq!(CheckStatus::Failed.as_str(), "failed");
assert_eq!(CheckStatus::Warnings.as_str(), "warnings");
assert_eq!(CheckStatus::Skipped.as_str(), "skipped");
assert_eq!(CheckStatus::Error.as_str(), "error");
}
#[test]
fn test_check_result_is_failure_failed() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Failed,
duration: Duration::from_secs(1),
output: String::new(),
cached: false,
provenance: None,
};
assert!(result.is_failure());
}
#[test]
fn test_check_result_is_failure_error() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Error,
duration: Duration::from_secs(1),
output: String::new(),
cached: false,
provenance: None,
};
assert!(result.is_failure());
}
#[test]
fn is_timeout_error_matches_the_shared_constructor() {
assert!(is_timeout_error(&timeout_error("cargo", 600)));
assert!(!is_timeout_error(&anyhow::anyhow!(
"Failed to run cargo: No such file"
)));
}
#[test]
fn test_check_result_is_failure_passed() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Passed,
duration: Duration::from_secs(1),
output: String::new(),
cached: false,
provenance: None,
};
assert!(!result.is_failure());
}
#[test]
fn test_check_result_is_failure_warnings() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Warnings,
duration: Duration::from_secs(1),
output: String::new(),
cached: false,
provenance: None,
};
assert!(!result.is_failure());
}
#[test]
fn test_check_result_is_failure_skipped() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Skipped,
duration: Duration::from_secs(1),
output: String::new(),
cached: false,
provenance: None,
};
assert!(!result.is_failure());
}
#[test]
fn test_check_status_serialization() {
let passed = CheckStatus::Passed;
let serialized = serde_json::to_string(&passed).unwrap();
assert_eq!(serialized, "\"passed\"");
let failed = CheckStatus::Failed;
let serialized = serde_json::to_string(&failed).unwrap();
assert_eq!(serialized, "\"failed\"");
}
#[test]
fn test_check_status_deserialization() {
let passed: CheckStatus = serde_json::from_str("\"passed\"").unwrap();
assert_eq!(passed, CheckStatus::Passed);
let failed: CheckStatus = serde_json::from_str("\"failed\"").unwrap();
assert_eq!(failed, CheckStatus::Failed);
}
#[test]
fn test_check_result_serialization() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Passed,
duration: Duration::from_secs(5),
output: "output".to_string(),
cached: true,
provenance: None,
};
let serialized = serde_json::to_string(&result).unwrap();
assert!(serialized.contains("\"name\":\"test\""));
assert!(serialized.contains("\"status\":\"passed\""));
assert!(serialized.contains("\"cached\":true"));
}
#[test]
fn test_check_result_clone() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Passed,
duration: Duration::from_secs(1),
output: "output".to_string(),
cached: false,
provenance: None,
};
let cloned = result.clone();
assert_eq!(result.name, cloned.name);
assert_eq!(result.status, cloned.status);
assert_eq!(result.cached, cloned.cached);
}
#[test]
fn test_check_event_started_clone() {
let event = CheckEvent::Started {
name: "test".to_string(),
};
let cloned = event.clone();
match cloned {
CheckEvent::Started { name } => assert_eq!(name, "test"),
_ => panic!("wrong event type"),
}
}
#[test]
fn test_check_event_skipped_clone() {
let event = CheckEvent::Skipped {
name: "test".to_string(),
};
let cloned = event.clone();
match cloned {
CheckEvent::Skipped { name } => assert_eq!(name, "test"),
_ => panic!("wrong event type"),
}
}
#[test]
fn test_check_event_completed_clone() {
let result = CheckResult {
name: "test".to_string(),
status: CheckStatus::Passed,
duration: Duration::from_secs(1),
output: String::new(),
cached: false,
provenance: None,
};
let event = CheckEvent::Completed {
result: Box::new(result.clone()),
};
let cloned = event.clone();
match cloned {
CheckEvent::Completed { result } => assert_eq!(result.name, "test"),
_ => panic!("wrong event type"),
}
}
#[test]
fn test_format_status_passed() {
let status = format_status(CheckStatus::Passed);
assert!(status.contains('✓') || status.contains("✓"));
}
#[test]
fn test_format_status_failed() {
let status = format_status(CheckStatus::Failed);
assert!(status.contains('✗') || status.contains("✗"));
}
#[test]
fn test_format_status_warnings() {
let status = format_status(CheckStatus::Warnings);
assert!(status.contains('⚠') || status.contains("⚠"));
}
#[test]
fn test_format_status_skipped() {
let status = format_status(CheckStatus::Skipped);
assert!(status.contains('○') || status.contains("○"));
}
#[test]
fn test_format_status_error() {
let status = format_status(CheckStatus::Error);
assert!(status.contains('!') || status.contains("!"));
}
#[test]
fn test_get_checks_for_profile_keeps_rust_checks_visible_when_disabled() {
let config = rust_config(false, false, false);
let check_names: Vec<String> = get_checks_for_profile(&config)
.into_iter()
.map(|check| check.name().to_string())
.collect();
assert!(check_names.iter().any(|name| name == "Cargo check"));
assert!(check_names.iter().any(|name| name == "Clippy"));
assert!(check_names.iter().any(|name| name == "Cargo test"));
assert!(!check_names.iter().any(|name| name == "Cargo geiger"));
}
#[test]
fn test_get_checks_for_profile_adds_geiger_with_security_full() {
let mut config = rust_config(false, false, false);
config.security_full = true;
let check_names: Vec<String> = get_checks_for_profile(&config)
.into_iter()
.map(|check| check.name().to_string())
.collect();
assert!(check_names.iter().any(|name| name == "Cargo geiger"));
}
#[test]
fn test_get_checks_for_profile_mixed_geiger_follows_security_full() {
use crate::config::{DetectedProfile, ProfileKind, test_config_builder};
use std::path::PathBuf;
let mixed_cargo = || DetectedProfile {
kind: ProfileKind::Mixed,
has_package_json: false,
has_tsconfig: false,
has_cargo: true,
has_pyproject: false,
has_python_source: false,
has_js_source: false,
cargo_root: Some(PathBuf::from(".")),
rust_dirs: vec![PathBuf::from(".")],
is_workspace: false,
};
let default_cfg = test_config_builder().profile(mixed_cargo()).build();
let default_names: Vec<String> = get_checks_for_profile(&default_cfg)
.into_iter()
.map(|check| check.name().to_string())
.collect();
assert!(!default_names.iter().any(|name| name == "Cargo geiger"));
let full_cfg = test_config_builder()
.profile(mixed_cargo())
.security_full(true)
.build();
let full_names: Vec<String> = get_checks_for_profile(&full_cfg)
.into_iter()
.map(|check| check.name().to_string())
.collect();
assert!(full_names.iter().any(|name| name == "Cargo geiger"));
}
#[test]
fn test_get_checks_for_profile_runs_python_for_pyproject_only_in_mixed_rust_repo() {
use crate::config::{DetectedProfile, ProfileKind, test_config_builder};
use std::path::PathBuf;
let config = test_config_builder()
.profile(DetectedProfile {
kind: ProfileKind::Mixed,
has_package_json: false,
has_tsconfig: false,
has_cargo: true,
has_pyproject: true,
has_python_source: false,
has_js_source: false,
cargo_root: Some(PathBuf::from(".")),
rust_dirs: vec![PathBuf::from(".")],
is_workspace: false,
})
.run_lint(true)
.run_tests(true)
.build();
let check_names: Vec<String> = get_checks_for_profile(&config)
.into_iter()
.map(|check| check.name().to_string())
.collect();
assert!(check_names.iter().any(|name| name == "Cargo check"));
assert!(check_names.iter().any(|name| name == "Ruff"));
assert!(check_names.iter().any(|name| name == "Mypy"));
assert!(check_names.iter().any(|name| name == "Pytest"));
}
#[test]
fn test_has_tool_crash_rust_panic() {
let output = "thread 'main' panicked at 'index out of bounds'";
assert!(has_tool_crash(output));
}
#[test]
fn test_has_tool_crash_segfault() {
assert!(has_tool_crash("Segmentation fault (core dumped)"));
}
#[test]
fn test_has_tool_crash_sigabrt() {
assert!(has_tool_crash("Process received SIGABRT"));
}
#[test]
fn test_has_tool_crash_stack_overflow() {
assert!(has_tool_crash("fatal runtime error: stack overflow"));
}
#[test]
fn test_has_tool_crash_clean_output() {
assert!(!has_tool_crash("All checks passed successfully"));
}
#[test]
fn test_has_tool_crash_panic_word_without_thread() {
assert!(!has_tool_crash("Don't panic, everything is fine"));
}
#[test]
fn test_has_tool_crash_ignores_test_harness_names() {
let output = "\
test checks::tests::test_has_tool_crash_sigabrt ... ok
test checks::tests::test_has_tool_crash_rust_panic ... ok
test result: ok. 2 passed; 0 failed
";
assert!(!has_tool_crash(output));
}
#[tokio::test]
async fn test_run_command_with_timeout_public_fn_times_out() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = run_command_with_timeout("sleep", &["30"], tmp.path(), 1).await;
let err = result.expect_err("sleep 30 with 1s timeout must error");
assert!(is_timeout_error(&err), "error should be a timeout: {err}");
}
#[tokio::test]
async fn test_run_command_with_timeout_success_returns_output() {
let tmp = tempfile::tempdir().expect("tempdir");
let output = run_command_with_timeout("echo", &["hello"], tmp.path(), 10)
.await
.expect("echo should succeed");
assert!(output.status.success(), "echo should exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("hello"),
"stdout should contain hello: {stdout}"
);
}
#[test]
fn test_is_cargo_target_check() {
for name in [
"Cargo check",
"Clippy",
"Rustfmt",
"Cargo test",
"Cargo audit",
"Cargo geiger",
] {
assert!(
is_cargo_target_check(name),
"{name} should be a cargo check"
);
}
for name in [
"Semgrep scan",
"Ruff",
"Mypy",
"Pytest",
"TypeScript",
"ESLint",
] {
assert!(
!is_cargo_target_check(name),
"{name} should NOT be a cargo check"
);
}
}
#[tokio::test]
async fn test_cargo_semaphore_serializes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let sem = Arc::new(Semaphore::new(1));
let in_flight = Arc::new(AtomicUsize::new(0));
let max_seen = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let sem = Arc::clone(&sem);
let in_flight = Arc::clone(&in_flight);
let max_seen = Arc::clone(&max_seen);
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.expect("semaphore never closed");
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
max_seen.fetch_max(now, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await;
in_flight.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.expect("task join");
}
assert_eq!(
max_seen.load(Ordering::SeqCst),
1,
"Semaphore(1) must never allow more than one cargo task at a time"
);
}
#[test]
fn test_tool_unavailable_signature_matches_runner_enoent() {
let err = "Failed to run mypy: No such file or directory (os error 2)";
assert!(tool_unavailable_signature(err));
}
#[test]
fn test_tool_unavailable_signature_ignores_real_type_error() {
let err = "src/x.py:3: error: Incompatible return value type";
assert!(!tool_unavailable_signature(err));
}
#[test]
fn test_tool_spawn_failure_in_output_matches_uv_marker() {
let out =
"error: Failed to spawn: `mypy`\n Caused by: No such file or directory (os error 2)";
assert!(tool_spawn_failure_in_output(out));
}
#[test]
fn test_tool_spawn_failure_in_output_ignores_bare_enoent_in_diagnostics() {
let out = "src/a.py:10: error: Cannot read file: No such file or directory";
assert!(!tool_spawn_failure_in_output(out));
}
#[test]
fn test_skipped_result_is_not_failure() {
let result = CheckResult {
name: "Mypy".to_string(),
status: CheckStatus::Skipped,
duration: Duration::from_secs(0),
output: String::new(),
cached: false,
provenance: None,
};
assert!(!result.is_failure(), "Skipped must not count as a failure");
}
#[cfg(unix)]
#[tokio::test]
async fn run_js_command_prefers_local_node_modules_bin() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let bindir = tmp.path().join("node_modules/.bin");
std::fs::create_dir_all(&bindir).unwrap();
let toolpath = bindir.join("faketool");
std::fs::write(&toolpath, "#!/bin/sh\necho LOCAL_BIN_RAN\n").unwrap();
let mut perms = std::fs::metadata(&toolpath).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&toolpath, perms).unwrap();
assert!(
local_js_bin("faketool", tmp.path()).is_some(),
"installed tool must resolve to a local bin"
);
assert!(
local_js_bin("absent", tmp.path()).is_none(),
"absent tool must not resolve (caller falls back to npx)"
);
let output = run_js_command_with_timeout("faketool", &[], tmp.path(), 10)
.await
.expect("local bin should run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("LOCAL_BIN_RAN"),
"run_js_command must exec the local bin directly, got: {stdout}"
);
}
#[tokio::test]
async fn runtime_skipped_result_is_not_cached() {
use async_trait::async_trait;
struct MockCheck {
status: CheckStatus,
}
#[async_trait]
impl Check for MockCheck {
fn name(&self) -> &str {
"Mock"
}
fn check_eligibility(&self, _config: &Config) -> CheckEligibility {
CheckEligibility::Run
}
async fn run(&self, _config: &Config) -> Result<CheckResult> {
Ok(CheckResult {
name: "Mock".to_string(),
status: self.status,
duration: Duration::from_secs(0),
output: "ok".to_string(),
cached: false,
provenance: None,
})
}
fn cache_key(&self, _config: &Config) -> Option<String> {
Some("mock-key".to_string())
}
}
let tmp = tempfile::tempdir().expect("tempdir");
let config = rust_config(true, true, true);
let cache = Cache::with_dir(tmp.path().to_path_buf(), true);
let _ = execute_live_check(
Box::new(MockCheck {
status: CheckStatus::Skipped,
}),
&config,
&cache,
)
.await;
assert!(
cache.get("Mock", "mock-key").is_none(),
"a runtime Skipped result must not be cached"
);
let cache2 = Cache::with_dir(tmp.path().to_path_buf(), true);
let _ = execute_live_check(
Box::new(MockCheck {
status: CheckStatus::Passed,
}),
&config,
&cache2,
)
.await;
assert!(
cache2.get("Mock", "mock-key").is_some(),
"a Passed result must still be cached"
);
}
}