use std::{
env,
ffi::{OsStr, OsString},
fs::remove_dir_all,
io::{self, Write},
ops::Range,
os::unix::ffi::{OsStrExt, OsStringExt},
process::{Command, ExitCode},
thread::available_parallelism,
};
use nix::{
errno::Errno,
fcntl::AT_FDCWD,
sys::stat::{fchmodat, umask, FchmodatFlags, Mode},
unistd::{chdir, getcwd, mkdir, mkdtemp},
};
use syd::{
err::SydResult,
path::{XPath, XPathBuf},
syslog::LogLevel,
wildmatch::inamematch,
};
use crate::util::shuffle_vec;
mod test;
mod util;
use test::*;
#[cfg(all(
not(coverage),
not(feature = "prof"),
not(target_os = "android"),
not(target_arch = "riscv64"),
target_page_size_4k,
target_pointer_width = "64"
))]
#[global_allocator]
static GLOBAL: hardened_malloc::HardenedMalloc = hardened_malloc::HardenedMalloc;
#[cfg(feature = "prof")]
#[global_allocator]
static GLOBAL: tcmalloc::TCMalloc = tcmalloc::TCMalloc;
#[derive(Debug)]
struct TempDir {
path: XPathBuf,
keep: bool,
}
impl TempDir {
#[expect(clippy::disallowed_methods)]
fn new(path: &XPath) -> Self {
TempDir {
path: XPathBuf::try_from(path).unwrap(),
keep: std::env::var_os("SYD_TEST_KEEP").is_some(),
}
}
}
impl Drop for TempDir {
fn drop(&mut self) {
if self.keep {
return;
}
match Command::new("rm").arg("-rf").arg(&self.path).status() {
Ok(status) => {
if !status.success() {
let path = self.path.display();
eprintln!(
"Failed to remove temporary directory \"{path}\": rm returned non-zero."
);
}
}
Err(error) => {
let path = self.path.display();
eprintln!("Failed to remove temporary directory \"{path}\": {error}.");
}
};
}
}
#[derive(Debug)]
enum Arguments {
Index(usize),
Range(Range<usize>),
Pattern(String),
}
struct ArgVec(Vec<Arguments>);
impl From<String> for Arguments {
fn from(arg: String) -> Self {
if let Ok(idx) = arg.parse::<usize>() {
Arguments::Index(idx)
} else if let Some(range) = arg.split_once("..") {
if let (Ok(start), Ok(end)) = (range.0.parse::<usize>(), range.1.parse::<usize>()) {
Arguments::Range(start..end)
} else {
Arguments::Pattern(arg)
}
} else {
Arguments::Pattern(arg)
}
}
}
impl From<String> for ArgVec {
fn from(arg: String) -> Self {
if let Ok(idx) = arg.parse::<usize>() {
ArgVec(vec![Arguments::Index(idx)])
} else if let Some(range) = arg.split_once("..") {
if let (Ok(start), Ok(end)) = (range.0.parse::<usize>(), range.1.parse::<usize>()) {
ArgVec(vec![Arguments::Range(start..end)])
} else {
ArgVec(vec![Arguments::Pattern(arg)])
}
} else {
ArgVec(vec![Arguments::Pattern(arg)])
}
}
}
fn main() -> SydResult<ExitCode> {
syd::set_sigpipe_dfl()?;
syd::log::log_init_simple(LogLevel::Warn)?;
umask(Mode::from_bits_truncate(0o077));
if let Some(dir) = env::var_os("SYD_PROVE_LOGDIR") {
let args = std::env::args().skip(1).collect::<Vec<_>>();
if let [index] = args.as_slice() {
if !index.starts_with("--prove") {
return prove_one(XPath::new(&dir), index);
}
}
}
println!("# syd-test: Welcome to the Machine!");
println!("# usage: syd-test [-hlq] [<name-glob>|<number>|<number>..<number>]..");
let args = std::env::args().skip(1).collect::<Vec<_>>();
if args
.iter()
.any(|a| a == "--prove" || a.starts_with("--prove="))
{
let mut selectors = Vec::new();
for a in &args {
if a == "--prove" {
continue;
} else if let Some(value) = a.strip_prefix("--prove=") {
selectors.extend(value.split_whitespace().map(|s| s.to_string()));
} else {
selectors.push(a.clone());
}
}
return run_prove(selectors);
}
let mut args_is_empty = args.is_empty();
let mut skip_args = 0;
if !args_is_empty && matches!(args[0].as_str(), "-h" | "--help" | "-l" | "--list") {
for (idx, (name, _)) in TESTS.iter().enumerate() {
#[expect(clippy::disallowed_methods)]
let name = name.strip_prefix("test_syd_").unwrap();
let idx = idx + 1;
println!("{idx:>3}: {name}");
}
return Ok(ExitCode::SUCCESS);
}
let fail_quick = !args_is_empty && matches!(args[0].as_str(), "-q" | "--quick");
if fail_quick {
args_is_empty = args.len() == 1;
skip_args = 1;
}
let fail_quick = fail_quick || std::env::var_os("SYD_TEST_QUICK").is_some();
let tsl = Command::new("tput")
.arg("tsl")
.status()
.map(|s| s.success())
.unwrap_or(false);
let tmpdir = {
#[expect(clippy::disallowed_methods)]
let tmp = format!(
"{}/syd_test_XXXXXX",
env::var("SYD_TEST_TMPDIR").unwrap_or(".".to_string())
);
match mkdtemp(XPath::new(&tmp)) {
Ok(path) => {
let path = path.canonicalize().map(XPathBuf::from)?;
match chdir(&path) {
Ok(_) => {
println!("# Running tests under '{}'.", path.display());
println!("# Use SYD_TEST_TMPDIR to override.");
Some(TempDir::new(&path))
}
Err(error) => {
println!("# chdir failed: {error}.");
None
}
}
}
Err(errno) => {
println!("# mkdtemp failed: {errno}.");
None
}
}
};
let mut test_indices = Vec::new();
let mut test_env_arg = false;
#[expect(clippy::disallowed_methods)]
if let Ok(env) = std::env::var("SYD_TEST") {
if !env.is_empty() {
test_env_arg = true;
let arg: Arguments = env.into();
match arg {
Arguments::Index(i) => test_indices.push(i),
Arguments::Range(r) => test_indices.extend(r),
Arguments::Pattern(p) => {
for (idx, (name, _)) in TESTS.iter().enumerate() {
#[expect(clippy::disallowed_methods)]
let name = name.strip_prefix("test_syd_").unwrap();
if inamematch(&p, name) {
test_indices.push(idx + 1);
if p.to_ascii_lowercase().contains("exp") && name.starts_with("exp_") {
env::set_var("SYD_TEST_EXPENSIVE", "1");
}
}
}
}
}
}
}
let args: Vec<Arguments> = args
.into_iter()
.skip(skip_args)
.map(ArgVec::from)
.flat_map(|arg_vec| arg_vec.0)
.collect();
for arg in args {
match arg {
Arguments::Index(i) => test_indices.push(i),
Arguments::Range(r) => test_indices.extend(r),
Arguments::Pattern(p) => {
for (idx, (name, _)) in TESTS.iter().enumerate() {
#[expect(clippy::disallowed_methods)]
let name = name.strip_prefix("test_syd_").unwrap();
if inamematch(&p, name) {
test_indices.push(idx + 1);
if p.to_ascii_lowercase().contains("exp") && name.starts_with("exp_") {
env::set_var("SYD_TEST_EXPENSIVE", "1");
}
}
}
}
}
}
if !test_env_arg && args_is_empty {
test_indices.extend(1..=TESTS.len());
}
#[expect(clippy::disallowed_methods)]
let (seed, seed_set) = match env::var("SYD_TEST_SEED") {
Err(env::VarError::NotPresent) => {
let mut buf = vec![0u8; size_of::<nix::libc::c_uint>()];
let ret = unsafe {
nix::libc::getrandom(buf.as_mut_ptr().cast(), buf.len(), nix::libc::GRND_RANDOM)
};
if ret == buf.len() as nix::libc::ssize_t {
let ret = nix::libc::c_uint::from_ne_bytes(buf[..4].try_into()?);
eprintln!("# Determined test seed using /dev/random.");
(ret, false)
} else {
eprintln!(
"# getrandom failed ({}), using default seed...",
Errno::last()
);
(31415926, false)
}
}
Ok(val) => match val.parse::<nix::libc::c_uint>() {
Ok(val) => (val, true),
Err(error) => {
eprintln!("# Invalid test seed: {error}!");
return Ok(ExitCode::from(1));
}
},
Err(error) => {
eprintln!("# Invalid test seed: {error}!");
return Ok(ExitCode::from(1));
}
};
if seed_set {
eprintln!("# Test seed: {seed}, manually set with SYD_TEST_SEED.");
} else {
eprintln!("# Test seed: {seed}, set SYD_TEST_SEED to override.");
}
eprintln!("# Shuffling tests using the Fischer-Yates algorithm...");
#[cfg(not(target_os = "android"))]
unsafe {
libc::srand(seed)
};
shuffle_vec(&mut test_indices);
let ntest = test_indices.len();
let ptest = if env::var_os("SYD_TEST_REEXEC").is_none() {
""
} else {
"# "
};
println!("{ptest}1..{ntest}");
let exp_test = env::var_os("SYD_TEST_EXPENSIVE").is_some();
let mut fail_hard = 0;
let mut fail_soft = 0;
let mut skip = 0;
let mut fail_names = Vec::new();
let mut skip_names = Vec::new();
let mut soft_fails = Vec::new();
let mut idx = 0;
let mut rtest = 0;
for &test_idx in test_indices.iter() {
let (name, test) = if let Some((name, test)) = TESTS.get(test_idx - 1) {
(name, test)
} else {
continue;
};
idx += 1;
#[expect(clippy::disallowed_methods)]
let name = name.strip_prefix("test_syd_").unwrap();
env::set_var("SYD_TEST_NAME", name);
if exp_test && !name.starts_with("exp_") {
println!("# ok {idx} - {name} # SKIP not expensive, unset SYD_TEST_EXPENSIVE to run");
skip += 1;
skip_names.push(name.to_string());
continue;
} else if !exp_test && name.starts_with("exp_") {
println!("# ok {idx} - {name} # SKIP expensive test, set SYD_TEST_EXPENSIVE to run");
skip += 1;
skip_names.push(name.to_string());
continue;
}
let status = format!(
"{name} ({idx} of {ntest}: {} ok, {} notok, {} todo, {} left)",
idx - fail_hard - fail_soft - skip - 1,
fail_hard,
fail_soft + skip,
ntest - idx,
);
if tsl {
print!("\x1b]0;syd-test: {status}\x07");
}
println!("\x1b[92m*** {status} ***\x1b[0m");
mkdir(name, Mode::from_bits_truncate(0o700))?;
fchmodat(
AT_FDCWD,
name,
Mode::from_bits_truncate(0o700),
FchmodatFlags::FollowSymlink,
)?;
chdir(name)?;
let cwd = std::env::current_dir()?.canonicalize()?;
std::env::set_var("PWD", &cwd);
std::env::set_var("TMP", &cwd);
std::env::set_var("TMPDIR", &cwd);
let result = test();
chdir("..")?;
rtest += 1;
match result {
Ok(_) => {
if std::env::var_os("SYD_TEST_SOFT_FAIL").is_some() {
fail_soft += 1;
soft_fails.push(name.to_string());
std::env::remove_var("SYD_TEST_SOFT_FAIL");
println!("{ptest}ok {idx} - {name} # TODO");
} else {
println!("{ptest}ok {idx} - {name}");
}
#[expect(clippy::disallowed_methods)]
if tmpdir.as_ref().map(|t| t.keep).unwrap_or(false) {
let cwd = getcwd()
.map(XPathBuf::from)
.unwrap_or_else(|_| XPathBuf::try_from("?").unwrap());
eprintln!("# Keeping test directory \"{cwd}\"");
} else {
let _ = remove_dir_all(name);
}
}
Err(error) => {
println!("{ptest}not ok {idx} - {name} - FAIL: {error}");
fail_hard += 1;
fail_names.push(name.to_string());
if fail_quick {
break;
}
}
}
}
let succ = rtest - fail_hard.saturating_sub(fail_soft).saturating_sub(skip);
println!("# {succ} tests passed.");
println!("# {skip} tests skipped.");
if fail_soft > 0 {
soft_fails.sort();
println!("# {fail_soft} tests failed soft, aka known failures:");
for (index, test) in soft_fails.iter().enumerate() {
println!("# {}. {}", index + 1, test);
}
} else {
println!("# {fail_soft} tests failed soft. No known failures! \\o/");
}
if fail_hard > 0 {
fail_names.sort();
println!("# {fail_hard} tests failed hard, aka breaking failures:");
for (index, test) in fail_names.iter().enumerate() {
println!("# {}. {}", index + 1, test);
}
} else {
println!("# {fail_hard} tests failed hard. No breaking failures! \\o/");
}
let code = if cfg!(coverage) {
0
} else {
fail_hard.try_into().unwrap_or(127)
};
if code != 0 {
if let Some(mut tmpdir) = tmpdir {
tmpdir.keep = true;
let tmpdir = tmpdir.path.display();
eprintln!("Keeping temporary test directory \"{tmpdir}\".");
}
}
Ok(ExitCode::from(code))
}
fn resolve_indices(selectors: &[String]) -> Vec<usize> {
let mut indices = Vec::new();
for sel in selectors {
let arg: Arguments = sel.clone().into();
match arg {
Arguments::Index(i) => indices.push(i),
Arguments::Range(r) => indices.extend(r),
Arguments::Pattern(p) => {
for (idx, (name, _)) in TESTS.iter().enumerate() {
let name = test_name(name);
if inamematch(&p, name) {
indices.push(idx + 1);
if p.to_ascii_lowercase().contains("exp") && name.starts_with("exp_") {
env::set_var("SYD_TEST_EXPENSIVE", "1");
}
}
}
}
}
}
indices
}
fn run_prove(selectors: Vec<String>) -> SydResult<ExitCode> {
let mut selectors = selectors;
if selectors.is_empty() {
if let Some(env) = env::var_os("SYD_TEST").as_deref().and_then(OsStr::to_str) {
if !env.is_empty() {
selectors.push(env.to_string());
}
}
}
let (indices, skipped) = prove_partition(&selectors);
if indices.is_empty() {
eprintln!("# syd-test --prove: no matching tests to run.");
let _ = std::fs::write("test-suite.log", b"");
return Ok(ExitCode::SUCCESS);
}
env::remove_var("SYD_TEST");
let exe = XPathBuf::from(env::current_exe()?);
let exename = exe
.as_bytes()
.rsplit(|&b| b == b'/')
.next()
.unwrap_or(b"syd-test");
let mut path = exe.parent().as_bytes().to_vec();
path.push(b':');
if let Some(old) = env::var_os("PATH") {
path.extend_from_slice(old.as_bytes());
}
let path = OsString::from_vec(path);
let mut tmpl = match env::var_os("SYD_TEST_TMPDIR") {
Some(dir) => dir.into_vec(),
None => vec![b'.'],
};
tmpl.extend_from_slice(b"/syd_test_prove_XXXXXX");
let logdir = match mkdtemp(XPath::new(OsStr::from_bytes(&tmpl))) {
Ok(path) => path.canonicalize().map(XPathBuf::from)?,
Err(errno) => {
eprintln!("# syd-test --prove: mkdtemp failed: {errno}.");
let _ = std::fs::write("test-suite.log", b"");
return Ok(ExitCode::from(1));
}
};
let jobs = prove_jobs();
eprintln!(
"# syd-test --prove: {} test(s), {jobs} parallel job(s).",
indices.len()
);
let status = Command::new("prove")
.arg(format!("-j{jobs}"))
.arg("--exec")
.arg(OsStr::from_bytes(exename))
.args(indices.iter().map(|i| format!("{i:04}")))
.env("PATH", &path)
.env("SYD_PROVE_LOGDIR", logdir.as_os_str())
.status();
let mut report = Vec::new();
let mut failed = Vec::new();
for &i in &indices {
if let Ok(path) = logdir.try_join(format!("{i:04}.fail").as_bytes()) {
if let Ok(bytes) = std::fs::read(path.as_os_str()) {
report.extend_from_slice(&bytes);
if let Some((name, _)) = TESTS.get(i - 1) {
failed.push(test_name(name).to_string());
}
}
}
}
let _ = std::fs::write("test-suite.log", &report);
let _ = io::stdout().write_all(&report);
let _ = io::stdout().flush();
println!("# {} tests passed.", indices.len() - failed.len());
println!("# {} tests skipped.", skipped.len());
if failed.is_empty() {
println!("# 0 tests failed. No breaking failures! \\o/");
} else {
failed.sort();
println!("# {} tests failed:", failed.len());
for (i, name) in failed.iter().enumerate() {
println!("# {}. {name}", i + 1);
}
}
if env::var_os("SYD_TEST_KEEP").is_some() {
eprintln!(
"# syd-test --prove: logs kept under '{}'.",
logdir.display()
);
} else {
let _ = Command::new("rm").arg("-rf").arg(&logdir).status();
}
match status {
Ok(status) => Ok(ExitCode::from(
u8::try_from(status.code().unwrap_or(1)).unwrap_or(1),
)),
Err(error) => {
eprintln!("# syd-test --prove: failed to execute prove(1): {error}.");
Ok(ExitCode::from(127))
}
}
}
fn prove_one(dir: &XPath, index: &str) -> SydResult<ExitCode> {
let exe = env::current_exe()?;
let out = match Command::new(&exe)
.arg(index)
.env_remove("SYD_PROVE_LOGDIR")
.output()
{
Ok(out) => out,
Err(error) => {
eprintln!("# syd-test: cannot run test {index}: {error}.");
return Ok(ExitCode::from(127));
}
};
let _ = io::stdout().write_all(&out.stdout);
let _ = io::stdout().flush();
if !out.status.success() {
let mut log = out.stdout;
log.extend_from_slice(&out.stderr);
if let Ok(path) = dir.try_join(format!("{index}.fail").as_bytes()) {
let _ = std::fs::write(path.as_os_str(), &log);
}
}
Ok(ExitCode::from(
u8::try_from(out.status.code().unwrap_or(1)).unwrap_or(1),
))
}
fn prove_partition(selectors: &[String]) -> (Vec<usize>, Vec<usize>) {
let mut indices = if selectors.is_empty() {
(1..=TESTS.len()).collect::<Vec<_>>()
} else {
resolve_indices(selectors)
};
indices.sort_unstable();
indices.dedup();
let exp_test = env::var_os("SYD_TEST_EXPENSIVE").is_some();
let mut run = Vec::new();
let mut skip = Vec::new();
for i in indices {
if let Some((name, _)) = TESTS.get(i - 1) {
if exp_test == test_name(name).starts_with("exp_") {
run.push(i);
} else {
skip.push(i);
}
}
}
(run, skip)
}
fn prove_jobs() -> usize {
match env::var_os("SYD_TEST_NPROC")
.and_then(|v| v.to_str().and_then(|s| s.parse::<usize>().ok()))
{
Some(n) if n > 0 => n,
_ => available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.max(16),
}
}
fn test_name(full: &str) -> &str {
full.strip_prefix("test_syd_").unwrap_or(full)
}