use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::normalize::RuntimeError;
use crate::process::{self, ProcessLimits, RawExecution};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ExecutorId(String);
impl ExecutorId {
pub fn new(raw: impl AsRef<str>) -> Result<Self, RuntimeError> {
let raw = raw.as_ref();
if raw.is_empty() || raw.chars().any(char::is_whitespace) {
return Err(RuntimeError::InvalidArg(
"executor id must be a non-empty token".into(),
));
}
Ok(Self(raw.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExecutorCapabilities {
pub cases: bool,
pub coverage: bool,
}
#[derive(Debug, Clone)]
pub struct ExecutorSpec {
pub id: ExecutorId,
pub program: String,
pub prefix: Vec<String>,
pub filter_flag: Option<String>,
pub capabilities: ExecutorCapabilities,
}
#[derive(Debug, Clone)]
pub struct PrepareRequest {
pub executor: ExecutorId,
pub cwd: PathBuf,
pub filters: Vec<String>,
pub exact_case: Option<String>,
pub extra: BTreeMap<String, String>,
pub limits: ProcessLimits,
pub cancel: Arc<AtomicBool>,
}
#[derive(Debug, Clone)]
pub struct PreparedRun {
pub executor: ExecutorId,
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
pub limits: ProcessLimits,
pub cancel: Arc<AtomicBool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionResult {
pub status_code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct ExecutorRegistry {
specs: BTreeMap<ExecutorId, ExecutorSpec>,
}
impl ExecutorRegistry {
#[must_use]
pub fn new() -> Self {
Self {
specs: BTreeMap::new(),
}
}
pub fn production() -> Result<Self, RuntimeError> {
let mut registry = Self::new();
let npm = if cfg!(windows) { "npm.cmd" } else { "npm" };
registry.register(spec(
"cargo-test",
"cargo",
&["test", "--color", "never", "--workspace", "--all-targets"],
None,
)?)?;
registry.register(spec("npm-test", npm, &["test", "--"], None)?)?;
registry.register(spec(
"vitest",
npm,
&[
"exec",
"--offline",
"--yes=false",
"--",
"vitest",
"run",
"--reporter=junit",
"--outputFile=.weavatrix-quality/junit.xml",
],
None,
)?)?;
registry.register(spec(
"storybook-vitest",
npm,
&[
"exec",
"--offline",
"--yes=false",
"--",
"vitest",
"run",
"--project=storybook",
"--reporter=junit",
"--outputFile=.weavatrix-quality/junit.xml",
],
None,
)?)?;
registry.register(spec(
"storybook-vitest-v8",
npm,
&[
"exec",
"--offline",
"--yes=false",
"--",
"vitest",
"run",
"--project=storybook",
"--coverage",
"--coverage.reporter=lcov",
"--reporter=junit",
"--outputFile=.weavatrix-quality/junit.xml",
],
None,
)?)?;
registry.register(spec(
"jest",
"jest",
&["--runInBand"],
Some("--runTestsByPath"),
)?)?;
registry.register(spec("bun-test", "bun", &["test"], None)?)?;
registry.register(spec(
"go-test",
"go",
&[
"test",
"-json",
"-coverprofile=.weavatrix-quality/go-cover.out",
"./...",
],
Some("-run"),
)?)?;
registry.register(spec("playwright", "playwright", &["test"], None)?)?;
Ok(registry)
}
pub fn register(&mut self, spec: ExecutorSpec) -> Result<(), RuntimeError> {
validate_program(&spec.program)?;
self.specs.insert(spec.id.clone(), spec);
Ok(())
}
pub fn prepare(&self, request: PrepareRequest) -> Result<PreparedRun, RuntimeError> {
reject_injected_command(&request.extra)?;
if !request.extra.is_empty() {
let keys = request.extra.keys().cloned().collect::<Vec<_>>();
return Err(RuntimeError::InvalidArg(format!(
"unknown executor fields: {}",
keys.join(", ")
)));
}
let spec = self
.specs
.get(&request.executor)
.ok_or_else(|| RuntimeError::UnknownExecutor(request.executor.as_str().to_owned()))?;
let mut args = spec.prefix.clone();
if !request.filters.is_empty() {
if let Some(flag) = &spec.filter_flag {
args.push(flag.clone());
}
for filter in &request.filters {
args.push(sanitize_filter(filter)?);
}
}
if let Some(case) = &request.exact_case {
let pattern = exact_case_pattern(case)?;
match spec.id.as_str() {
"vitest" | "storybook-vitest" | "storybook-vitest-v8" => {
args.push("--testNamePattern".into());
args.push(pattern);
}
"go-test" if request.filters.is_empty() => {
args.push("-run".into());
args.push(pattern);
}
runner => {
return Err(RuntimeError::InvalidArg(format!(
"executor `{runner}` does not support an exact case filter"
)));
}
}
}
Ok(PreparedRun {
executor: spec.id.clone(),
program: spec.program.clone(),
args,
cwd: request.cwd,
limits: request.limits,
cancel: request.cancel,
})
}
pub fn execute(&self, run: &PreparedRun) -> Result<ExecutionResult, RuntimeError> {
if run.cancel.load(Ordering::SeqCst) {
return Err(RuntimeError::Cancelled);
}
let raw: RawExecution =
process::run_bounded(&run.program, &run.args, &run.cwd, &run.limits, &run.cancel)?;
Ok(ExecutionResult {
status_code: raw.status_code,
stdout: raw.stdout,
stderr: raw.stderr,
})
}
}
impl Default for ExecutorRegistry {
fn default() -> Self {
Self::new()
}
}
pub trait Executor {
fn capabilities(&self, id: &ExecutorId) -> Option<ExecutorCapabilities>;
fn prepare(&self, request: PrepareRequest) -> Result<PreparedRun, RuntimeError>;
fn execute(&self, run: &PreparedRun) -> Result<ExecutionResult, RuntimeError>;
}
impl Executor for ExecutorRegistry {
fn capabilities(&self, id: &ExecutorId) -> Option<ExecutorCapabilities> {
self.specs.get(id).map(|spec| spec.capabilities)
}
fn prepare(&self, request: PrepareRequest) -> Result<PreparedRun, RuntimeError> {
Self::prepare(self, request)
}
fn execute(&self, run: &PreparedRun) -> Result<ExecutionResult, RuntimeError> {
Self::execute(self, run)
}
}
fn spec(
id: &str,
program: &str,
prefix: &[&str],
filter_flag: Option<&str>,
) -> Result<ExecutorSpec, RuntimeError> {
Ok(ExecutorSpec {
id: ExecutorId::new(id)?,
program: program.to_owned(),
prefix: prefix.iter().map(|item| (*item).to_owned()).collect(),
filter_flag: filter_flag.map(ToOwned::to_owned),
capabilities: ExecutorCapabilities {
cases: true,
coverage: matches!(
id,
"vitest" | "storybook-vitest-v8" | "jest" | "bun-test" | "go-test"
),
},
})
}
fn validate_program(program: &str) -> Result<(), RuntimeError> {
if program.is_empty()
|| program.contains('/')
|| program.contains('\\')
|| program.contains("..")
{
return Err(RuntimeError::InvalidArg(
"executor program must be a bare filename".into(),
));
}
Ok(())
}
fn reject_injected_command(extra: &BTreeMap<String, String>) -> Result<(), RuntimeError> {
const FORBIDDEN: &[&str] = &[
"command",
"cmd",
"shell",
"argv",
"executable",
"program",
"bin",
"script",
];
let forbidden = FORBIDDEN.iter().copied().collect::<BTreeSet<_>>();
for key in extra.keys() {
if forbidden.contains(key.as_str()) {
return Err(RuntimeError::InvalidArg(format!(
"field `{key}` cannot select an executable"
)));
}
}
Ok(())
}
fn sanitize_filter(filter: &str) -> Result<String, RuntimeError> {
if filter.is_empty()
|| filter.contains('\0')
|| filter
.chars()
.any(|ch| matches!(ch, '\n' | '\r' | '|' | '&' | ';' | '`'))
{
return Err(RuntimeError::InvalidArg(
"filter must be a single argv value without shell metacharacters".into(),
));
}
Ok(filter.to_owned())
}
fn exact_case_pattern(case: &str) -> Result<String, RuntimeError> {
if case.is_empty()
|| case.len() > 1024
|| case.contains('\0')
|| case
.chars()
.any(|character| matches!(character, '\n' | '\r'))
{
return Err(RuntimeError::InvalidArg(
"exact case must be one non-empty line of at most 1024 bytes".into(),
));
}
let mut escaped = String::with_capacity(case.len().saturating_add(2));
escaped.push('^');
for character in case.chars() {
if matches!(
character,
'.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped.push('$');
Ok(escaped)
}
#[must_use]
pub fn default_limits() -> ProcessLimits {
ProcessLimits {
deadline: Duration::from_secs(900),
max_output_bytes: 8 * 1024 * 1024,
}
}