use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::test_support::toolchain;
pub(super) struct GoBatchCase {
pub name: String,
pub files: Vec<(String, String)>,
}
pub(super) struct GoBatchLayout {
pub root_files: Vec<(PathBuf, String)>,
pub module_dir: PathBuf,
pub module_path: String,
pub extra_args: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GoCaseOutcome {
Passed,
Failed,
}
pub(super) struct GoCaseReport {
pub outcome: GoCaseOutcome,
pub output: String,
pub test_case_count: usize,
}
pub(super) struct GoBatchReport {
cases: BTreeMap<String, GoCaseReport>,
stderr: String,
_root: tempfile::TempDir,
}
impl GoBatchReport {
pub fn case(&self, name: &str) -> &GoCaseReport {
self.cases.get(name).unwrap_or_else(|| {
panic!(
"batched Go case `{name}` produced no `go test` result; observed cases: {:?}\nstderr:\n{}",
self.cases.keys().collect::<Vec<_>>(),
self.stderr
)
})
}
pub fn total_test_cases(&self) -> usize {
self.cases.values().map(|case| case.test_case_count).sum()
}
pub fn assert_inventory(&self, expected: &[String]) {
let mut wanted: Vec<&str> = expected.iter().map(String::as_str).collect();
wanted.sort_unstable();
let unique = {
let mut deduped = wanted.clone();
deduped.dedup();
deduped.len()
};
assert_eq!(
unique,
expected.len(),
"batched Go case names must be unique: {expected:?}"
);
assert!(!expected.is_empty(), "a batched Go run must contain at least one case");
let observed: Vec<&str> = self.cases.keys().map(String::as_str).collect();
assert_eq!(
observed, wanted,
"batched `go test` did not report on exactly the requested cases\nstderr:\n{}",
self.stderr
);
}
pub fn assert_outcome(&self, name: &str, expected: GoCaseOutcome) {
let case = self.case(name);
assert_eq!(
case.outcome, expected,
"batched Go case `{name}` outcome mismatch:\n{}",
case.output
);
}
pub fn assert_output_contains(&self, name: &str, needle: &str) {
let case = self.case(name);
assert!(
case.output.contains(needle),
"batched Go case `{name}` output is missing {needle:?}:\n{}",
case.output
);
}
}
pub(super) fn run_go_batch(layout: &GoBatchLayout, cases: &[GoBatchCase]) -> Option<GoBatchReport> {
assert!(!cases.is_empty(), "a batched Go run must contain at least one case");
let go = toolchain::GO.open()?;
let root = tempfile::tempdir().expect("create batched Go module root");
for (path, content) in &layout.root_files {
write_batch_file(&root.path().join(path), content);
}
let module_root = root.path().join(&layout.module_dir);
for case in cases {
let case_root = module_root.join(&case.name);
for (path, content) in &case.files {
write_batch_file(&case_root.join(path), content);
}
}
let mut command = Command::new(go);
command.arg("test");
for arg in &layout.extra_args {
command.arg(arg);
}
let output = command
.args(["-v", "./..."])
.current_dir(&module_root)
.output()
.expect("run batched Go packages");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let parsed = parse_case_blocks(&stdout, &stderr, &layout.module_path);
Some(GoBatchReport {
cases: parsed,
stderr,
_root: root,
})
}
fn write_batch_file(path: &Path, content: &str) {
let parent = path.parent().expect("batched Go file has a parent directory");
std::fs::create_dir_all(parent).expect("create batched Go case directory");
std::fs::write(path, content).expect("write batched Go case file");
}
fn parse_case_blocks(stdout: &str, stderr: &str, module_path: &str) -> BTreeMap<String, GoCaseReport> {
let prefix = format!("{module_path}/");
let mut parsed = BTreeMap::new();
let mut block = String::new();
for line in stdout.lines() {
block.push_str(line);
block.push('\n');
let Some((status, package)) = package_terminator(line) else {
continue;
};
let Some(name) = package.strip_prefix(prefix.as_str()) else {
continue;
};
let block_text = std::mem::take(&mut block);
let test_case_count = block_text
.lines()
.filter(|line| {
line.starts_with("--- PASS:") || line.starts_with("--- FAIL:") || line.starts_with("--- SKIP:")
})
.count();
let outcome = if status == "FAIL" {
GoCaseOutcome::Failed
} else {
GoCaseOutcome::Passed
};
let diagnostics = build_diagnostics(stderr, package);
parsed.insert(
name.to_owned(),
GoCaseReport {
outcome,
output: format!("{block_text}{diagnostics}"),
test_case_count,
},
);
}
parsed
}
fn package_terminator(line: &str) -> Option<(&str, &str)> {
let mut fields = line.split_whitespace();
let status = fields.next()?;
if !matches!(status, "ok" | "FAIL" | "?") {
return None;
}
let package = fields.next()?;
package.contains('/').then_some((status, package))
}
fn build_diagnostics(stderr: &str, package: &str) -> String {
let mut collected = String::new();
let mut capturing = false;
for line in stderr.lines() {
if line.starts_with("# ") {
capturing = diagnostic_header_matches(line, package);
}
if capturing {
collected.push_str(line);
collected.push('\n');
}
}
collected
}
fn diagnostic_header_matches(line: &str, package: &str) -> bool {
line.split_whitespace().nth(1).is_some_and(|reported| {
reported == package
|| reported
.strip_prefix(package)
.is_some_and(|rest| rest == "_test" || rest == ".test")
})
}