aboutsummaryrefslogtreecommitdiff
path: root/mingling_ci/src/examples.rs
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_ci/src/examples.rs')
-rw-r--r--mingling_ci/src/examples.rs186
1 files changed, 186 insertions, 0 deletions
diff --git a/mingling_ci/src/examples.rs b/mingling_ci/src/examples.rs
new file mode 100644
index 0000000..92d3475
--- /dev/null
+++ b/mingling_ci/src/examples.rs
@@ -0,0 +1,186 @@
+//! Example binary testing: build each example and run its `test.toml` cases.
+
+use std::process::Output;
+
+/// A single `[[runs]]` entry of an example's `test.toml`.
+pub(crate) struct TestCase {
+ input: Vec<String>,
+ expect: Expect,
+}
+
+struct Expect {
+ exit_code: i32,
+ result: String,
+}
+
+/// One example and its test cases.
+pub(crate) struct ExampleCase {
+ name: String,
+ cases: Vec<TestCase>,
+}
+
+/// Outcome of checking one example.
+pub(crate) struct ExampleOutcome {
+ pub name: String,
+ pub location: String,
+ pub ok: bool,
+ pub output: String,
+}
+
+/// Loads `examples/<name>/test.toml` for every example that has one, in
+/// alphabetical order of the example directory name.
+pub(crate) fn load_test_configs() -> Vec<ExampleCase> {
+ let mut configs = Vec::new();
+ if let Ok(entries) = std::fs::read_dir("examples") {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+ let test_toml = path.join("test.toml");
+ if !test_toml.is_file() {
+ continue;
+ }
+ let name = path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or_default()
+ .to_string();
+ let Ok(content) = std::fs::read_to_string(&test_toml) else {
+ continue;
+ };
+ let Ok(table) = content.parse::<toml::Value>() else {
+ continue;
+ };
+ let Some(cases) = parse_cases(&table) else {
+ continue;
+ };
+ configs.push(ExampleCase { name, cases });
+ }
+ }
+ configs.sort_by(|a, b| a.name.cmp(&b.name));
+ configs
+}
+
+fn parse_cases(table: &toml::Value) -> Option<Vec<TestCase>> {
+ let runs = table.get("runs")?.as_array()?;
+ let mut cases = Vec::new();
+ for run in runs {
+ let input: Vec<String> = run
+ .get("input")?
+ .as_array()?
+ .iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect();
+ let expect = run.get("expect")?;
+ let exit_code = expect
+ .get("exit-code")?
+ .as_integer()
+ .and_then(|e| i32::try_from(e).ok())
+ .unwrap_or(-1);
+ let result = expect
+ .get("result")
+ .and_then(|r| r.as_str())
+ .unwrap_or_default()
+ .to_string();
+ cases.push(TestCase {
+ input,
+ expect: Expect { exit_code, result },
+ });
+ }
+ Some(cases)
+}
+
+/// Builds the example, then runs all of its test cases.
+pub(crate) fn check_example(example: ExampleCase) -> ExampleOutcome {
+ let location = format!("./examples/{}", example.name);
+
+ // Phase 1: build.
+ let manifest = format!("examples/{}/Cargo.toml", example.name);
+ let build = std::process::Command::new("cargo")
+ .args(["build", "--manifest-path", &manifest])
+ .output();
+ match build {
+ Ok(output) if !output.status.success() => ExampleOutcome {
+ name: example.name,
+ location,
+ ok: false,
+ output: build_error(&output),
+ },
+ Err(e) => ExampleOutcome {
+ name: example.name,
+ location,
+ ok: false,
+ output: format!("failed to run cargo: {e}"),
+ },
+ Ok(_) => {
+ // Phase 2: run the test cases against the built binary.
+ let mut failures = Vec::new();
+ for case in &example.cases {
+ if let Err(detail) = run_case(&example.name, case) {
+ failures.push(detail);
+ }
+ }
+ ExampleOutcome {
+ name: example.name,
+ location,
+ ok: failures.is_empty(),
+ output: failures.join("\n\n"),
+ }
+ }
+ }
+}
+
+/// Runs a single test case against the built binary.
+fn run_case(name: &str, case: &TestCase) -> Result<(), String> {
+ let exe = if cfg!(target_os = "windows") {
+ ".exe"
+ } else {
+ ""
+ };
+ let binary = format!(".temp/target/debug/{name}{exe}");
+
+ let output = std::process::Command::new(&binary)
+ .args(&case.input)
+ .output();
+ let Ok(output) = output else {
+ return Err(format!("failed to run {binary}"));
+ };
+
+ let actual_exit_code = output.status.code().unwrap_or(-1);
+ let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+
+ let exit_ok = actual_exit_code == case.expect.exit_code;
+ let result_ok =
+ actual_stdout == case.expect.result || actual_stdout.contains(&case.expect.result);
+
+ if exit_ok && result_ok {
+ return Ok(());
+ }
+
+ let mut details = vec![format!("input: {}", case.input.join(" "))];
+ if !exit_ok {
+ details.push(format!(
+ "expected exit code {}, actual {actual_exit_code}",
+ case.expect.exit_code
+ ));
+ }
+ if !result_ok {
+ details.push(format!("expected output {:?}", case.expect.result));
+ details.push(format!("actual stdout {actual_stdout:?}"));
+ if !actual_stderr.is_empty() {
+ details.push(format!("actual stderr {actual_stderr:?}"));
+ }
+ }
+ Err(details.join("\n"))
+}
+
+/// Tail of a failed build's combined output.
+fn build_error(output: &Output) -> String {
+ let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+ log.push_str(&String::from_utf8_lossy(&output.stderr));
+ let lines: Vec<&str> = log.lines().collect();
+ let tail = &lines[lines.len().saturating_sub(20)..];
+ format!("build failed\n{}", tail.join("\n"))
+}