use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;
use crate::error::Error;
use crate::util;
#[derive(Debug)]
#[allow(dead_code)]
pub struct BaselineResult {
pub pass: Option<u32>,
pub fail: Option<u32>,
pub unknown_count: u32,
pub exit_code: i32,
pub dur_ms: u64,
pub sidecar_path: PathBuf,
pub argv: Vec<String>,
pub mismatch_entries: Vec<BaselineMismatch>,
}
const SIDECAR_SCHEMA_VERSION_V1: u32 = 1;
const SIDECAR_SCHEMA_VERSION_V2: u32 = 2;
const SIGNAL_TERMINATED_EXIT_SENTINEL: i32 = -1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixtureId {
pub repo_relative_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaselineMismatch {
pub fixture: String,
pub baseline_verdict: BaselineVerdict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BaselineVerdict {
Pass,
Fail,
}
impl BaselineVerdict {
fn as_str(self) -> &'static str {
match self {
BaselineVerdict::Pass => "pass",
BaselineVerdict::Fail => "fail",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedBaseline {
pub pass: Option<u32>,
pub fail: Option<u32>,
pub unknown_count: u32,
pub mismatch_entries: Vec<BaselineMismatch>,
}
pub(crate) fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
i += 2;
while i < bytes.len() {
let b = bytes[i];
i += 1;
if (0x40..=0x7e).contains(&b) {
break;
}
}
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_default()
}
fn canonical_test_name(s: &str) -> String {
let forward = util::to_forward_slash(s);
let no_colons = forward.replace("::", "/");
no_colons
.strip_suffix(".rs")
.map(str::to_string)
.unwrap_or(no_colons)
}
pub fn parse_libtest_output(stdout: &str, recognized_fixtures: &[FixtureId]) -> ParsedBaseline {
let normalized: Vec<(String, &FixtureId)> = recognized_fixtures
.iter()
.map(|fid| {
let raw = fid.repo_relative_path.to_string_lossy().into_owned();
(canonical_test_name(&raw), fid)
})
.collect();
let cleaned = strip_ansi(stdout);
let mut pass_count: u32 = 0;
let mut fail_count: u32 = 0;
let mut unknown_count: u32 = 0;
let mut matched_indices: Vec<bool> = vec![false; normalized.len()];
let mut mismatch_entries: Vec<BaselineMismatch> = Vec::new();
for raw_line in cleaned.lines() {
let line = raw_line.trim_start();
if !line.starts_with("test ") {
continue;
}
let after_prefix = &line["test ".len()..];
let Some((test_name, verdict_part)) = after_prefix.split_once(" ... ") else {
if after_prefix.starts_with("result") || after_prefix.starts_with("result:") {
continue;
}
unknown_count = unknown_count.saturating_add(1);
continue;
};
let verdict_token = verdict_part.split_whitespace().next().unwrap_or("");
let verdict = match verdict_token {
"ok" => BaselineVerdict::Pass,
"FAILED" => BaselineVerdict::Fail,
_ => {
unknown_count = unknown_count.saturating_add(1);
continue;
}
};
if normalized.is_empty() {
unknown_count = unknown_count.saturating_add(1);
continue;
}
let test_name_canon = canonical_test_name(test_name);
let matched: Option<usize> = normalized
.iter()
.position(|(stem, _)| test_name_canon == stem.as_str());
let Some(idx) = matched else {
unknown_count = unknown_count.saturating_add(1);
continue;
};
matched_indices[idx] = true;
match verdict {
BaselineVerdict::Pass => pass_count = pass_count.saturating_add(1),
BaselineVerdict::Fail => fail_count = fail_count.saturating_add(1),
}
let original =
util::to_forward_slash(&normalized[idx].1.repo_relative_path.to_string_lossy());
mismatch_entries.push(BaselineMismatch {
fixture: original,
baseline_verdict: verdict,
});
}
for seen in &matched_indices {
if !*seen {
unknown_count = unknown_count.saturating_add(1);
}
}
mismatch_entries.sort_by(|a, b| a.fixture.cmp(&b.fixture));
let (pass, fail) = if normalized.is_empty() {
(None, None)
} else {
(Some(pass_count), Some(fail_count))
};
ParsedBaseline {
pass,
fail,
unknown_count,
mismatch_entries,
}
}
struct SpawnCapture {
exit_code: i32,
dur_ms: u64,
stdout: String,
stderr: String,
}
fn spawn_and_capture(argv: &[String], cwd: &Path) -> Result<SpawnCapture, Error> {
if argv.is_empty() {
return Err(Error::Cli {
clap_exit_code: 2,
message: "error: `--compat-cargo-test-argv` must contain at least one argument \
(the program to spawn, e.g. `\"cargo\"`)"
.to_string(),
});
}
let program = &argv[0];
let args = &argv[1..];
let started = Instant::now();
let output = Command::new(program)
.args(args)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| Error::SubprocessSpawn {
program: program.clone(),
source: e,
})?;
let dur_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
let exit_code = output
.status
.code()
.unwrap_or(SIGNAL_TERMINATED_EXIT_SENTINEL);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Ok(SpawnCapture {
exit_code,
dur_ms,
stdout,
stderr,
})
}
pub fn run_baseline(
argv: &[String],
cwd: &Path,
sidecar_path: &Path,
) -> Result<BaselineResult, Error> {
let SpawnCapture {
exit_code,
dur_ms,
stdout,
stderr,
} = spawn_and_capture(argv, cwd)?;
write_sidecar(sidecar_path, argv, exit_code, &stdout, &stderr)?;
Ok(BaselineResult {
pass: None,
fail: None,
unknown_count: 0,
exit_code,
dur_ms,
sidecar_path: sidecar_path.to_path_buf(),
argv: argv.to_vec(),
mismatch_entries: Vec::new(),
})
}
pub fn run_baseline_with_recognized_fixtures(
argv: &[String],
cwd: &Path,
sidecar_path: &Path,
recognized_fixtures: &[FixtureId],
) -> Result<BaselineResult, Error> {
let SpawnCapture {
exit_code,
dur_ms,
stdout,
stderr,
} = spawn_and_capture(argv, cwd)?;
let parsed = parse_libtest_output(&stdout, recognized_fixtures);
write_sidecar_v2(sidecar_path, argv, exit_code, &stdout, &stderr, &parsed)?;
Ok(BaselineResult {
pass: parsed.pass,
fail: parsed.fail,
unknown_count: parsed.unknown_count,
exit_code,
dur_ms,
sidecar_path: sidecar_path.to_path_buf(),
argv: argv.to_vec(),
mismatch_entries: parsed.mismatch_entries,
})
}
fn build_v1_envelope(
schema_version: u32,
argv: &[String],
exit_code: i32,
stdout: &str,
stderr: &str,
) -> serde_json::Map<String, serde_json::Value> {
let mut envelope = serde_json::Map::new();
envelope.insert(
"schema_version".to_string(),
serde_json::Value::from(schema_version),
);
envelope.insert(
"argv".to_string(),
serde_json::Value::Array(
argv.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
envelope.insert("exit_code".to_string(), serde_json::Value::from(exit_code));
envelope.insert(
"stdout".to_string(),
serde_json::Value::String(stdout.to_string()),
);
envelope.insert(
"stderr".to_string(),
serde_json::Value::String(stderr.to_string()),
);
envelope
}
fn write_sidecar(
sidecar_path: &Path,
argv: &[String],
exit_code: i32,
stdout: &str,
stderr: &str,
) -> Result<(), Error> {
let envelope = build_v1_envelope(SIDECAR_SCHEMA_VERSION_V1, argv, exit_code, stdout, stderr);
let mut bytes =
serde_json::to_vec_pretty(&serde_json::Value::Object(envelope)).map_err(|e| {
Error::JsonParse {
context: "serializing compat baseline sidecar".into(),
message: e.to_string(),
}
})?;
bytes.push(b'\n');
util::write_file_atomic(sidecar_path, &bytes)
}
fn write_sidecar_v2(
sidecar_path: &Path,
argv: &[String],
exit_code: i32,
stdout: &str,
stderr: &str,
parsed: &ParsedBaseline,
) -> Result<(), Error> {
let mut envelope =
build_v1_envelope(SIDECAR_SCHEMA_VERSION_V2, argv, exit_code, stdout, stderr);
envelope.insert(
"pass".to_string(),
match parsed.pass {
Some(n) => serde_json::Value::from(n),
None => serde_json::Value::Null,
},
);
envelope.insert(
"fail".to_string(),
match parsed.fail {
Some(n) => serde_json::Value::from(n),
None => serde_json::Value::Null,
},
);
envelope.insert(
"unknown_count".to_string(),
serde_json::Value::from(parsed.unknown_count),
);
let mismatch_array: Vec<serde_json::Value> = parsed
.mismatch_entries
.iter()
.map(|m| {
let mut obj = serde_json::Map::new();
obj.insert(
"fixture".to_string(),
serde_json::Value::String(m.fixture.clone()),
);
obj.insert(
"baseline_verdict".to_string(),
serde_json::Value::String(m.baseline_verdict.as_str().to_string()),
);
serde_json::Value::Object(obj)
})
.collect();
envelope.insert(
"mismatch_entries".to_string(),
serde_json::Value::Array(mismatch_array),
);
let mut bytes =
serde_json::to_vec_pretty(&serde_json::Value::Object(envelope)).map_err(|e| {
Error::JsonParse {
context: "serializing compat baseline sidecar (v2)".into(),
message: e.to_string(),
}
})?;
bytes.push(b'\n');
util::write_file_atomic(sidecar_path, &bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_argv_is_rejected_with_directed_message() {
let tmp = tempdir().unwrap();
let sidecar = tmp.path().join("baseline_capture.json");
let err = run_baseline(&[], tmp.path(), &sidecar).expect_err("empty argv must be rejected");
match err {
Error::Cli { message, .. } => {
assert!(
message.contains("--compat-cargo-test-argv"),
"diagnostic must name the flag; got: {message}"
);
assert!(
message.contains("at least one argument"),
"diagnostic must spell out the requirement; got: {message}"
);
}
other => panic!("expected Error::Cli, got {other:?}"),
}
}
#[test]
fn sidecar_shape_is_canonical() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("capture.json");
let argv = vec!["foo".to_string(), "bar".to_string()];
write_sidecar(&path, &argv, 0, "out", "err").unwrap();
let bytes = std::fs::read(&path).unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
let i_schema = text
.find("\"schema_version\"")
.expect("schema_version key must be present");
let i_argv = text.find("\"argv\"").expect("argv key must be present");
let i_exit = text
.find("\"exit_code\"")
.expect("exit_code key must be present");
let i_stdout = text.find("\"stdout\"").expect("stdout key must be present");
let i_stderr = text.find("\"stderr\"").expect("stderr key must be present");
assert!(
i_schema < i_argv && i_argv < i_exit && i_exit < i_stdout && i_stdout < i_stderr,
"sidecar JSON keys must appear in canonical order: schema_version, argv, \
exit_code, stdout, stderr; got:\n{text}"
);
}
#[test]
fn sidecar_schema_version_is_one() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("capture.json");
write_sidecar(&path, &["x".to_string()], 0, "", "").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
v.get("schema_version").and_then(serde_json::Value::as_u64),
Some(1)
);
}
#[test]
fn strip_ansi_removes_single_csi_sequence() {
assert_eq!(strip_ansi("\x1b[31mFAILED\x1b[0m"), "FAILED");
assert_eq!(strip_ansi("plain text"), "plain text");
assert_eq!(strip_ansi(""), "");
}
#[test]
fn strip_ansi_handles_mixed_content() {
assert_eq!(
strip_ansi("test foo ... \x1b[32mok\x1b[0m"),
"test foo ... ok"
);
assert_eq!(strip_ansi("[bracketed]"), "[bracketed]");
}
#[test]
fn strip_ansi_tolerates_truncated_csi() {
let s = "prefix\x1b[31";
let out = strip_ansi(s);
assert_eq!(out, "prefix");
}
#[test]
fn parse_empty_recognized_set_yields_all_unknown() {
let stdout = "test foo ... ok\ntest bar ... FAILED\n";
let result = parse_libtest_output(stdout, &[]);
assert_eq!(result.pass, None);
assert_eq!(result.fail, None);
assert_eq!(result.unknown_count, 2);
assert!(result.mismatch_entries.is_empty());
}
#[test]
fn parse_recognized_pass_correlates() {
let recognized = vec![FixtureId {
repo_relative_path: PathBuf::from("tests/foo.rs"),
}];
let stdout = "test tests/foo ... ok\n";
let result = parse_libtest_output(stdout, &recognized);
assert_eq!(result.pass, Some(1));
assert_eq!(result.fail, Some(0));
assert_eq!(result.unknown_count, 0);
assert_eq!(result.mismatch_entries.len(), 1);
assert_eq!(result.mismatch_entries[0].fixture, "tests/foo.rs");
assert_eq!(
result.mismatch_entries[0].baseline_verdict,
BaselineVerdict::Pass
);
}
#[test]
fn parse_ignores_summary_and_non_verdict_lines() {
let stdout = "\n\
running 5 tests\n\
test tests/foo ... ok\n\
test result: ok. 1 passed; 0 failed; 0 ignored\n\
\n\
failures:\n\
\n";
let recognized = vec![FixtureId {
repo_relative_path: PathBuf::from("tests/foo.rs"),
}];
let result = parse_libtest_output(stdout, &recognized);
assert_eq!(result.pass, Some(1));
assert_eq!(result.fail, Some(0));
assert_eq!(result.unknown_count, 0);
}
#[test]
fn sidecar_v2_shape_is_canonical() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("capture.json");
let parsed = ParsedBaseline {
pass: Some(3),
fail: Some(1),
unknown_count: 0,
mismatch_entries: vec![BaselineMismatch {
fixture: "tests/a.rs".to_string(),
baseline_verdict: BaselineVerdict::Pass,
}],
};
write_sidecar_v2(
&path,
&["cargo".to_string(), "test".to_string()],
0,
"stdout",
"stderr",
&parsed,
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
v.get("schema_version").and_then(serde_json::Value::as_u64),
Some(2)
);
assert_eq!(v.get("pass").and_then(serde_json::Value::as_u64), Some(3));
assert_eq!(v.get("fail").and_then(serde_json::Value::as_u64), Some(1));
assert_eq!(
v.get("unknown_count").and_then(serde_json::Value::as_u64),
Some(0)
);
let entries = v
.get("mismatch_entries")
.and_then(serde_json::Value::as_array)
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0]
.get("fixture")
.and_then(serde_json::Value::as_str),
Some("tests/a.rs")
);
assert_eq!(
entries[0]
.get("baseline_verdict")
.and_then(serde_json::Value::as_str),
Some("pass")
);
let i_schema = text.find("\"schema_version\"").unwrap();
let i_argv = text.find("\"argv\"").unwrap();
let i_exit = text.find("\"exit_code\"").unwrap();
let i_stdout = text.find("\"stdout\"").unwrap();
let i_stderr = text.find("\"stderr\"").unwrap();
let i_pass = text.find("\"pass\"").unwrap();
let i_fail = text.find("\"fail\"").unwrap();
let i_unknown = text.find("\"unknown_count\"").unwrap();
let i_mismatch = text.find("\"mismatch_entries\"").unwrap();
assert!(
i_schema < i_argv
&& i_argv < i_exit
&& i_exit < i_stdout
&& i_stdout < i_stderr
&& i_stderr < i_pass
&& i_pass < i_fail
&& i_fail < i_unknown
&& i_unknown < i_mismatch,
"v2 sidecar JSON keys must appear in canonical order; got:\n{text}"
);
}
#[test]
fn sidecar_v2_pass_fail_null_when_recognition_empty() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("capture.json");
let parsed = ParsedBaseline {
pass: None,
fail: None,
unknown_count: 5,
mismatch_entries: Vec::new(),
};
write_sidecar_v2(&path, &["x".to_string()], 0, "", "", &parsed).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(v.get("pass").is_some_and(serde_json::Value::is_null));
assert!(v.get("fail").is_some_and(serde_json::Value::is_null));
assert_eq!(
v.get("unknown_count").and_then(serde_json::Value::as_u64),
Some(5)
);
}
}