use std::path::{Path, PathBuf};
use crate::cli::Cli;
use crate::config;
use crate::discovery;
use crate::dylib;
use crate::error::{Error, Outcome};
use crate::exit::ExitCode;
use crate::freshness::FreshnessSnapshot;
use crate::manifest::Manifest;
use crate::normalize::NormalizationContext;
use crate::toolchain;
use crate::util;
use crate::verdict::FixtureResult;
use crate::worker::{self, WorkerContext};
#[derive(Debug)]
pub struct Report {
pub results: Vec<FixtureResult>,
pub cleanup_residue: bool,
pub wall_ms: u64,
}
impl Report {
pub fn exit_code(&self) -> ExitCode {
let mut code = ExitCode::Ok;
for r in &self.results {
code = code.merge(r.verdict.exit_code());
}
if self.cleanup_residue {
code = code.merge(ExitCode::CleanupResidue);
}
code
}
}
pub fn run(cli: Cli) -> Result<Report, Error> {
let manifest_path = resolve_manifest_path(&cli)?;
let crate_root = manifest_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let config = config::load(&manifest_path)?;
let toolchain = toolchain::capture()?;
if cli.list {
let fixtures = discovery::collect(&config, &crate_root, &cli.filter)?;
for f in &fixtures {
println!("{}", f.relative_path);
}
return Ok(Report {
results: Vec::new(),
cleanup_residue: false,
wall_ms: 0,
});
}
let workspace_target = dylib::workspace_target_dir(&manifest_path);
let lihaaf_build_dir = workspace_target.join("lihaaf-build");
if cli.no_cache {
let manifest_dest = workspace_target.join("lihaaf/manifest.json");
if manifest_dest.exists() {
let _ = std::fs::remove_file(&manifest_dest);
}
if lihaaf_build_dir.exists() {
let _ = std::fs::remove_dir_all(&lihaaf_build_dir);
}
if !cli.quiet {
eprintln!("lihaaf: --no-cache: removed any prior manifest + lihaaf-build dir");
}
}
let build_started = std::time::Instant::now();
let build_out = dylib::build(&dylib::BuildParams {
crate_name: &config.dylib_crate,
features: &config.features,
manifest_path: &manifest_path,
target_dir: &lihaaf_build_dir,
toolchain: &toolchain,
})?;
if !cli.quiet {
let secs = build_started.elapsed().as_secs_f64();
eprintln!("lihaaf: built {} dylib in {:.1}s", config.dylib_crate, secs);
}
let managed_path = dylib::managed_dylib_path(&workspace_target, &build_out.cargo_dylib_path);
if cli.use_symlink {
dylib::symlink_dylib(&build_out.cargo_dylib_path, &managed_path)?;
} else {
dylib::copy_dylib(&build_out.cargo_dylib_path, &managed_path)?;
}
let dylib_sha = util::sha256_file(&managed_path)?;
let dylib_mtime = dylib::mtime_unix_secs(&managed_path)?;
let metadata_snapshot = toml_value_to_json(&config.raw_metadata);
let manifest = Manifest {
lihaaf_version: crate::VERSION.to_string(),
rustc_release: toolchain.release_line.clone(),
rustc_commit_hash: toolchain.commit_hash.clone(),
host_triple: toolchain.host.clone(),
sysroot: toolchain.sysroot.clone(),
dylib_crate: config.dylib_crate.clone(),
cargo_dylib_path: build_out.cargo_dylib_path.clone(),
managed_dylib_path: managed_path.clone(),
dylib_sha256: dylib_sha.clone(),
dylib_mtime_unix_secs: dylib_mtime,
use_symlink: cli.use_symlink,
features: config.features.clone(),
extern_crates: config.extern_crates.clone(),
edition: config.edition.clone(),
metadata_snapshot,
};
let manifest_dest = workspace_target.join("lihaaf/manifest.json");
manifest.write(&manifest_dest)?;
let freshness_snapshot = FreshnessSnapshot {
managed_dylib_path: managed_path.clone(),
original_mtime_unix_secs: dylib_mtime,
original_sha256: dylib_sha.clone(),
original_rustc_release_line: toolchain.release_line.clone(),
};
let fixtures = discovery::collect(&config, &crate_root, &cli.filter)?;
if !cli.quiet {
let cf = fixtures
.iter()
.filter(|f| matches!(f.kind, discovery::FixtureKind::CompileFail))
.count();
let cp = fixtures.len() - cf;
eprintln!(
"lihaaf: {} fixtures discovered (compile_fail: {cf}, compile_pass: {cp})",
fixtures.len()
);
}
let parallelism = compute_parallelism(&cli, &config);
if !cli.quiet {
eprintln!("lihaaf: parallelism = {parallelism}");
}
let session_temp = tempfile::Builder::new()
.prefix("lihaaf-session-")
.tempdir_in(&workspace_target)
.map_err(|e| {
Error::io(
e,
"creating session temp dir",
Some(workspace_target.clone()),
)
})?;
let session_temp_path = session_temp.path().to_path_buf();
let norm_ctx = NormalizationContext::new(crate_root.clone(), toolchain.sysroot.clone());
let mut worker_ctx = WorkerContext::new(
crate_root.clone(),
managed_path.clone(),
build_out.deps_dir.clone(),
&config,
cli.effective_bless(),
cli.verbose,
cli.keep_output,
session_temp_path.clone(),
norm_ctx,
&toolchain.sysroot,
freshness_snapshot,
);
let mut extra_names = worker_ctx.extra_extern_crates.clone();
extra_names.extend(worker_ctx.dev_deps.iter().cloned());
worker_ctx.extern_paths = worker::resolve_extern_paths(&build_out.deps_dir, &extra_names)?;
let post_capture = toolchain::capture()?;
if !toolchain::matches(&toolchain, &post_capture) {
return Err(Error::Session(Outcome::ToolchainDrift {
original: toolchain.release_line.clone(),
current: post_capture.release_line.clone(),
}));
}
let dispatch_start = std::time::Instant::now();
let progress_quiet = cli.quiet;
let dispatch_outcome = worker::dispatch_pool(&fixtures, &worker_ctx, parallelism, move |r| {
if progress_quiet {
if !r.verdict.is_pass() {
eprintln!("lihaaf: {} {}", r.verdict.label(), r.relative_path);
}
} else {
eprintln!(
"lihaaf: {:>26} {} ({} ms)",
r.verdict.label(),
r.relative_path,
r.wall_ms
);
}
emit_fixture_warnings(r);
});
let wall_ms = dispatch_start.elapsed().as_millis() as u64;
if let Some(failure) = dispatch_outcome.freshness_failure {
return Err(Error::Session(Outcome::FreshnessDrift {
invariant: failure.invariant_label().to_string(),
detail: failure.detail(),
}));
}
let results = dispatch_outcome.results;
let cleanup_residue = results.iter().any(|r| r.cleanup_failure.is_some());
print_aggregate(&results, wall_ms, cleanup_residue);
if cli.keep_output {
eprintln!(
"lihaaf: --keep-output set; per-fixture workdirs preserved under {}",
session_temp_path.display()
);
std::mem::forget(session_temp);
} else if cleanup_residue {
eprintln!(
"lihaaf: CLEANUP_RESIDUE detected; session-temp parent preserved at {}",
session_temp_path.display()
);
std::mem::forget(session_temp);
}
Ok(Report {
results,
cleanup_residue,
wall_ms,
})
}
fn print_aggregate(results: &[FixtureResult], wall_ms: u64, cleanup_residue: bool) {
use crate::verdict::Verdict;
let mut counts: std::collections::BTreeMap<&'static str, usize> =
std::collections::BTreeMap::new();
for r in results {
*counts.entry(r.verdict.label()).or_insert(0) += 1;
}
let mut summary = String::new();
for (label, n) in &counts {
if !summary.is_empty() {
summary.push_str(", ");
}
summary.push_str(&format!("{n} {label}"));
}
if results.is_empty() {
summary.push_str("0 results");
}
eprintln!("lihaaf: {summary}");
let aggregate = aggregate_counts(results);
eprintln!(
"lihaaf: {} ok, {} failed, {} timeout, {} memory_exhausted",
aggregate.ok, aggregate.failed, aggregate.timeout, aggregate.memory_exhausted
);
eprintln!("lihaaf: total wall-clock: {:.1}s", wall_ms as f64 / 1000.0);
if cleanup_residue {
eprintln!("lihaaf: CLEANUP_RESIDUE — one or more workdirs could not be removed:");
for r in results {
if let Some(c) = &r.cleanup_failure {
eprintln!(
" {} (path={}, error={})",
r.relative_path,
c.path.display(),
c.message
);
}
}
}
for r in results {
match &r.verdict {
Verdict::SnapshotDiff { diff } => {
eprintln!("\n=== {} (SNAPSHOT_DIFF) ===\n{diff}", r.relative_path);
}
Verdict::SnapshotMissing { actual } => {
eprintln!(
"\n=== {} (SNAPSHOT_MISSING) ===\n--- actual normalized stderr ---\n{actual}",
r.relative_path
);
}
Verdict::ExpectedFailButPassed => {
eprintln!(
"\n=== {} (EXPECTED_FAIL_BUT_PASSED) ===\nfixture compiled successfully but is in a compile_fail dir",
r.relative_path
);
}
Verdict::ExpectedPassButFailed { stderr } => {
eprintln!(
"\n=== {} (EXPECTED_PASS_BUT_FAILED) ===\n{stderr}",
r.relative_path
);
}
Verdict::SnapshotDiffTooLarge {
actual_lines,
expected_lines,
actual_head,
expected_head,
} => {
eprintln!(
"\n=== {} (SNAPSHOT_DIFF_TOO_LARGE) ===\nactual: {actual_lines} lines, expected: {expected_lines} lines",
r.relative_path
);
eprintln!("--- actual head ---\n{actual_head}");
eprintln!("--- expected head ---\n{expected_head}");
}
_ => {}
}
}
}
fn emit_fixture_warnings(r: &FixtureResult) {
use crate::verdict::FixtureWarning;
if let Some(w) = &r.warning {
match w {
FixtureWarning::LargeSnapshot {
expected_lines,
actual_lines,
} => {
eprintln!(
"lihaaf: LARGE_SNAPSHOT {} ({}/{} lines)",
r.relative_path, expected_lines, actual_lines
);
}
}
}
}
#[derive(Debug, Default, Clone, Copy)]
struct AggregateCounts {
ok: usize,
failed: usize,
timeout: usize,
memory_exhausted: usize,
}
fn aggregate_counts(results: &[FixtureResult]) -> AggregateCounts {
use crate::verdict::Verdict;
let mut a = AggregateCounts::default();
for r in results {
match &r.verdict {
Verdict::Ok | Verdict::Blessed { .. } => a.ok += 1,
Verdict::Timeout => a.timeout += 1,
Verdict::MemoryExhausted => a.memory_exhausted += 1,
_ => a.failed += 1,
}
}
a
}
fn compute_parallelism(cli: &Cli, config: &config::Config) -> usize {
let cpu_cap: usize = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let ram_cap: usize = match util::total_ram_mb() {
Some(total) => {
let cap = total / config.per_fixture_memory_mb as u64;
cap.max(1) as usize
}
None => cpu_cap, };
let cli_cap: usize = cli.jobs.map(|n| n as usize).unwrap_or(cpu_cap);
cli_cap.min(ram_cap)
}
fn resolve_manifest_path(cli: &Cli) -> Result<PathBuf, Error> {
if let Some(p) = &cli.manifest_path {
if !p.is_file() {
return Err(Error::Session(Outcome::ConfigInvalid {
message: format!(
"--manifest-path={} does not point at an existing file.\nWhy this matters: lihaaf needs the consumer's Cargo.toml.",
p.display()
),
}));
}
return Ok(p.clone());
}
let mut dir =
std::env::current_dir().map_err(|e| Error::io(e, "reading current directory", None))?;
loop {
let candidate = dir.join("Cargo.toml");
if candidate.is_file() {
return Ok(candidate);
}
if !dir.pop() {
return Err(Error::Session(Outcome::ConfigInvalid {
message:
"no Cargo.toml found in the current directory or any parent.\nWhy this matters: lihaaf reads `[package.metadata.lihaaf]` from the consumer crate.\nFix: cd into a crate directory or pass --manifest-path."
.into(),
}));
}
}
}
fn toml_value_to_json(v: &toml::Value) -> serde_json::Value {
match v {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
toml::Value::Datetime(d) => serde_json::Value::String(d.to_string()),
toml::Value::Array(a) => {
serde_json::Value::Array(a.iter().map(toml_value_to_json).collect())
}
toml::Value::Table(t) => {
let mut map = serde_json::Map::new();
for (k, v) in t {
map.insert(k.clone(), toml_value_to_json(v));
}
serde_json::Value::Object(map)
}
}
}
#[allow(dead_code)]
fn _ensure_dir_exists(p: &Path) -> Result<(), Error> {
std::fs::create_dir_all(p).map_err(|e| Error::io(e, "creating dir", Some(p.to_path_buf())))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn cfg(per_fixture_mb: u32) -> Config {
Config {
dylib_crate: "x".into(),
extern_crates: vec!["x".into()],
fixture_dirs: vec![],
features: vec![],
edition: "2021".into(),
dev_deps: vec![],
compile_fail_marker: "compile_fail".into(),
fixture_timeout_secs: 90,
per_fixture_memory_mb: per_fixture_mb,
raw_metadata: toml::Value::Table(toml::map::Map::new()),
}
}
#[test]
fn parallelism_respects_explicit_jobs() {
let mut cli = Cli {
bless: false,
filter: vec![],
jobs: Some(2),
no_cache: false,
manifest_path: None,
list: false,
quiet: true,
verbose: false,
use_symlink: false,
keep_output: false,
};
let p = compute_parallelism(&cli, &cfg(1024));
assert!(p <= 2);
cli.jobs = Some(1);
let p2 = compute_parallelism(&cli, &cfg(1024));
assert_eq!(p2, 1);
}
#[test]
fn parallelism_is_at_least_one() {
let cli = Cli {
bless: false,
filter: vec![],
jobs: None,
no_cache: false,
manifest_path: None,
list: false,
quiet: true,
verbose: false,
use_symlink: false,
keep_output: false,
};
let p = compute_parallelism(&cli, &cfg(u32::MAX / 2));
assert!(p >= 1);
}
#[test]
fn fixture_warnings_default_to_none_on_construction() {
use crate::verdict::{FixtureResult, Verdict};
let r = FixtureResult {
relative_path: "x".into(),
verdict: Verdict::Ok,
cleanup_failure: None,
wall_ms: 0,
warning: None,
};
assert!(r.warning.is_none());
}
#[test]
fn aggregate_counts_buckets_per_section_3_3() {
use crate::verdict::{FixtureResult, Verdict};
let r = |label: &str, v: Verdict| FixtureResult {
relative_path: label.to_string(),
verdict: v,
cleanup_failure: None,
wall_ms: 0,
warning: None,
};
let results = vec![
r("a", Verdict::Ok),
r(
"b",
Verdict::Blessed {
snapshot_path: PathBuf::from("/tmp/x.stderr"),
},
),
r("c", Verdict::Timeout),
r("d", Verdict::Timeout),
r("e", Verdict::MemoryExhausted),
r(
"f",
Verdict::SnapshotDiff {
diff: "diff".into(),
},
),
r("g", Verdict::ExpectedFailButPassed),
r(
"h",
Verdict::WorkerCrashed {
cause: "signal: 11".into(),
},
),
];
let agg = aggregate_counts(&results);
assert_eq!(agg.ok, 2);
assert_eq!(agg.failed, 3);
assert_eq!(agg.timeout, 2);
assert_eq!(agg.memory_exhausted, 1);
}
#[test]
fn toml_to_json_round_trips_table_shape() {
let toml_text = r#"
a = 1
b = "two"
c = [3, 4]
[d]
e = true
"#;
let v: toml::Value = toml::from_str(toml_text).unwrap();
let j = toml_value_to_json(&v);
assert_eq!(j["a"], serde_json::json!(1));
assert_eq!(j["b"], serde_json::json!("two"));
assert_eq!(j["c"], serde_json::json!([3, 4]));
assert_eq!(j["d"]["e"], serde_json::json!(true));
}
}