use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant};
use fairlead_core::config::{Config, Runner};
use fairlead_core::pattern::Pattern;
use fairlead_core::plan::{Invocation, Reason, TestSelection};
use fairlead_lang::tree::Tree;
use fairlead_tests::modules::Modules;
use fairlead_tests::testfiles;
use serde::{Deserialize, Serialize};
pub const LIMIT: Duration = Duration::from_secs(60);
const FILE: &str = "setup.json";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Proof {
pub runner: String,
pub file: String,
pub passed: Option<bool>,
pub seconds: f64,
pub why: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Record {
#[serde(default)]
pub proved: Vec<Proof>,
}
fn path(repo: &Path) -> Option<PathBuf> {
fairlead_guard::git::git_dir(repo).map(|dir| dir.join("fairlead").join(FILE))
}
pub fn read(repo: &Path) -> Record {
path(repo)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn write(repo: &Path, record: &Record) -> Result<(), String> {
let path = path(repo).ok_or("not inside a git repository")?;
let dir = path.parent().expect("the record has a directory");
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let text = serde_json::to_string_pretty(record).expect("the record serializes") + "\n";
std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))
}
pub struct Chosen {
pub runner: String,
pub file: String,
pub inv: Invocation,
}
enum Step {
Kept(Proof),
Run(Chosen),
Blocked(Proof),
}
fn steps(
tree: &Tree,
config: &Config,
old: &Record,
usable: &dyn Fn(&str) -> Result<(), String>,
) -> Result<Vec<Step>, String> {
let packages = fairlead_lang::workspace::discover(tree);
let modules = Modules::discover(tree, &packages, &config.modules)?;
let found = testfiles::discover(tree, config, &modules)?;
let size = |path: &str| std::fs::metadata(tree.abs(path)).map_or(u64::MAX, |m| m.len());
let mut out = Vec::new();
for (index, runner) in config.tests.runners.items().iter().enumerate() {
let mut mine: Vec<&testfiles::TestFile> = found
.tests
.iter()
.filter(|t| t.runner == Some(index))
.collect();
mine.sort_by_key(|t| (size(&t.path), t.path.clone()));
let Some(smallest) = mine.first() else {
continue;
};
let passed = |p: &&Proof| p.runner == runner.id && p.passed == Some(true);
if let Some(kept) = old.proved.iter().find(passed) {
out.push(Step::Kept(kept.clone()));
continue;
}
let pick = mine.iter().find(|t| usable(&t.path).is_ok());
out.push(match pick.and_then(|t| invocation(&modules, runner, t)) {
Some(chosen) => Step::Run(chosen),
None => Step::Blocked(Proof {
runner: runner.id.clone(),
file: smallest.path.clone(),
passed: None,
seconds: 0.0,
why: Some(
usable(&smallest.path)
.err()
.unwrap_or_else(|| "its command names no test".into()),
),
}),
});
}
Ok(out)
}
fn invocation(modules: &Modules, runner: &Runner, test: &testfiles::TestFile) -> Option<Chosen> {
let selection = TestSelection {
path: test.path.clone(),
module: test.module.map(|m| modules.get(m).name.clone()),
runner: Some(runner.id.clone()),
class: test.class,
reason: Reason::Changed,
};
let inv = fairlead_tests::invoke::naming(modules, runner, &[&selection])
.into_iter()
.next()?;
Some(Chosen {
runner: runner.id.clone(),
file: test.path.clone(),
inv,
})
}
pub fn plan(repo: &Path, tree: &Tree, config: &Config) -> Result<Vec<Chosen>, String> {
Ok(steps(tree, config, &read(repo), &|_| Ok(()))?
.into_iter()
.filter_map(|step| match step {
Step::Run(chosen) => Some(chosen),
_ => None,
})
.collect())
}
fn unmet_needs(tree: &Tree, config: &Config) -> Vec<(String, Vec<Pattern>)> {
let needs: Vec<_> = config
.needs
.items()
.iter()
.filter(|n| !n.tests.is_empty())
.cloned()
.collect();
let checked = crate::needs::check_all(&needs, &tree.root);
needs
.iter()
.zip(checked)
.filter(|(_, c)| !c.met)
.map(|(n, _)| {
let globs = n.tests.iter().filter_map(|g| Pattern::new(g).ok());
(n.id.clone(), globs.collect())
})
.collect()
}
pub fn prove(repo: &Path, tree: &Tree, config: &Config, limit: Duration) -> Result<usize, String> {
let old = read(repo);
let unmet: std::cell::OnceCell<Vec<(String, Vec<Pattern>)>> = std::cell::OnceCell::new();
let usable = |path: &str| {
let unmet = unmet.get_or_init(|| unmet_needs(tree, config));
match unmet
.iter()
.find(|(_, globs)| globs.iter().any(|p| p.is_match(path)))
{
Some((need, _)) => Err(format!("need \"{need}\" not met")),
None => Ok(()),
}
};
let mut record = Record::default();
let mut ran = 0;
for step in steps(tree, config, &old, &usable)? {
record.proved.push(match step {
Step::Kept(proof) => proof,
Step::Blocked(proof) => {
ran += 1;
proof
}
Step::Run(chosen) => {
ran += 1;
eprintln!(
"setup: running {} on {} ({} s limit)",
chosen.runner,
chosen.file,
limit.as_secs()
);
let (passed, seconds, why) = run_one(&tree.root, &chosen.inv, limit);
Proof {
runner: chosen.runner,
file: chosen.file,
passed,
seconds,
why,
}
}
});
}
if ran > 0 || record.proved.len() != old.proved.len() {
write(repo, &record)?;
}
Ok(ran)
}
fn run_one(root: &Path, inv: &Invocation, limit: Duration) -> (Option<bool>, f64, Option<String>) {
let Some((program, args)) = inv.argv.split_first() else {
return (Some(false), 0.0, Some("it has no command".into()));
};
let mut cmd = fairlead_core::process::command(program, &root.join(&inv.cwd));
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let started = Instant::now();
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (None, 0.0, Some(format!("`{program}` isn't installed")));
}
Err(e) => {
return (
Some(false),
0.0,
Some(format!("could not start: {program}: {e}")),
)
}
};
let log = Arc::new(Mutex::new(Vec::new()));
let (done, drained) = mpsc::channel();
let streams: [Option<Box<dyn std::io::Read + Send>>; 2] = [
child.stdout.take().map(|s| Box::new(s) as _),
child.stderr.take().map(|s| Box::new(s) as _),
];
for mut from in streams.into_iter().flatten() {
let (log, done) = (log.clone(), done.clone());
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
while let Ok(n) = from.read(&mut buf) {
if n == 0 {
break;
}
log.lock().expect("log lock").extend_from_slice(&buf[..n]);
}
let _ = done.send(());
});
}
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if started.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)),
_ => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let seconds = (started.elapsed().as_secs_f64() * 10.0).round() / 10.0;
for _ in 0..2 {
let _ = drained.recv_timeout(Duration::from_secs(1));
}
let text = String::from_utf8_lossy(&log.lock().expect("log lock")).into_owned();
match status {
None => {
let why = format!("no result within {} s", limit.as_secs());
(Some(false), seconds, Some(why))
}
Some(status) if status.success() => (Some(true), seconds, None),
Some(status) if status.code() == Some(127) => (
None,
seconds,
Some(format!("`{program}` couldn't find its tool (exit 127)")),
),
Some(status) => {
let why = crate::step::why_failed(inv, &text, status.code());
(Some(false), seconds, Some(why))
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use fairlead_core::plan::InvocationKind;
fn inv(argv: &[&str]) -> Invocation {
Invocation {
id: "unit".into(),
kind: InvocationKind::Runner,
cwd: ".".into(),
argv: argv.iter().map(|a| a.to_string()).collect(),
quarantined: None,
}
}
#[test]
fn a_run_passes_fails_with_its_reason_or_cannot_start_for_want_of_its_tool() {
let here = Path::new(".");
let (passed, _, why) = run_one(here, &inv(&["sh", "-c", "echo 1 passed"]), LIMIT);
assert_eq!((passed, why), (Some(true), None));
let failing = inv(&[
"sh",
"-c",
"echo '1 failed'; echo 'error: boom' >&2; exit 1",
]);
let (passed, _, why) = run_one(here, &failing, LIMIT);
assert_eq!(passed, Some(false));
assert!(why.as_deref().unwrap().contains("error: boom"), "{why:?}");
let (passed, _, why) = run_one(here, &inv(&["no-such-tool-x", "a.test.ts"]), LIMIT);
assert_eq!(
(passed, why.as_deref()),
(None, Some("`no-such-tool-x` isn't installed"))
);
let (passed, _, _) = run_one(here, &inv(&["sh", "-c", "no-such-tool-x"]), LIMIT);
assert_eq!(passed, None, "a shell's exit 127 is a missing tool");
}
#[test]
fn a_run_past_the_limit_is_stopped_and_counts_as_failed() {
let started = Instant::now();
let slow = inv(&["sleep", "30"]);
let (passed, seconds, why) = run_one(Path::new("."), &slow, Duration::from_millis(200));
assert_eq!(passed, Some(false));
assert_eq!(why.as_deref(), Some("no result within 0 s"));
assert!(seconds < 5.0 && started.elapsed() < Duration::from_secs(10));
}
}