diff options
Diffstat (limited to '.run')
| -rw-r--r-- | .run/src/bin/check-docs-structure.rs | 352 | ||||
| -rw-r--r-- | .run/src/bin/ci.rs | 42 | ||||
| -rw-r--r-- | .run/src/bin/cov-test.rs | 69 | ||||
| -rw-r--r-- | .run/src/bin/doc-nightly.ps1 | 2 | ||||
| -rwxr-xr-x | .run/src/bin/doc-nightly.sh | 2 | ||||
| -rw-r--r-- | .run/src/bin/doc.ps1 | 2 | ||||
| -rwxr-xr-x | .run/src/bin/doc.sh | 2 | ||||
| -rw-r--r-- | .run/src/bin/test-examples.rs | 113 |
8 files changed, 510 insertions, 74 deletions
diff --git a/.run/src/bin/check-docs-structure.rs b/.run/src/bin/check-docs-structure.rs new file mode 100644 index 0000000..ac13da2 --- /dev/null +++ b/.run/src/bin/check-docs-structure.rs @@ -0,0 +1,352 @@ +//! Checks that every translated docs directory mirrors the structure of the +//! reference (English) docs directory exactly. +//! +//! The language directories are declared in `.config/docs-lang.txt`, one path +//! per line (relative to `./docs/`). The first line is the reference +//! directory; every other line is a translation that must match it. +//! +//! For each file pair the tool compares a *structural signature*: one token per +//! line, classifying headings (both Markdown `#` and HTML `<hN>`), fenced code +//! blocks (including their language tag), `@@@` hidden-compilation lines, blank +//! lines, blockquotes, lists and plain text. Translated text is allowed to +//! differ; the structure is not. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use colored::Colorize; +use tools::println_cargo_style; + +const DOCS_DIR: &str = "./docs"; +const LANG_CONFIG: &str = ".config/docs-lang.txt"; + +fn main() { + println_cargo_style!("Checking: docs structure consistency across languages ..."); + + let repo_root = find_git_repo().expect("Cannot find git repo root"); + let docs_dir = repo_root.join(DOCS_DIR); + + let lang_lines = read_lang_config(&repo_root); + if lang_lines.is_empty() { + println!("No language directories declared in {LANG_CONFIG}, nothing to check."); + return; + } + + let reference = docs_dir.join(&lang_lines[0]); + if !reference.is_dir() { + eprintln!( + "Reference docs directory `{}` does not exist.", + reference.display() + ); + std::process::exit(1); + } + + let mut failed = false; + + for lang in &lang_lines[1..] { + let lang_dir = docs_dir.join(lang); + println!("\nChecking `{lang}` against `{}` ...", lang_lines[0]); + if !lang_dir.is_dir() { + eprintln!(" ERROR: `{}` does not exist.", lang_dir.display()); + failed = true; + continue; + } + if check_lang_dir(&reference, &lang_dir).is_err() { + failed = true; + } + } + + if failed { + println!(); + eprintln!( + "{} Fix the differences above.", + "Docs structure check FAILED.".red().bold() + ); + std::process::exit(1); + } + + println_cargo_style!("Done: docs structure is consistent across all languages!"); +} + +fn read_lang_config(repo_root: &Path) -> Vec<String> { + let path = repo_root.join(LANG_CONFIG); + let Ok(content) = fs::read_to_string(&path) else { + return Vec::new(); + }; + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|l| l.trim_start_matches("./").to_string()) + .collect() +} + +/// Returns `Err(())` when the translated directory does not mirror the reference. +fn check_lang_dir(reference: &Path, lang: &Path) -> Result<(), ()> { + let mut failed = false; + + let ref_files = collect_md_files(reference); + let lang_files = collect_md_files(lang); + + let ref_set: BTreeSet<PathBuf> = ref_files.clone().into_iter().collect(); + let lang_set: BTreeSet<PathBuf> = lang_files.clone().into_iter().collect(); + + let missing: Vec<PathBuf> = ref_set.difference(&lang_set).cloned().collect(); + let extra: Vec<PathBuf> = lang_set.difference(&ref_set).cloned().collect(); + + if !missing.is_empty() { + failed = true; + println!(" ERROR: files missing in translation:"); + for f in &missing { + println!(" - {}", f.display()); + } + } + if !extra.is_empty() { + failed = true; + println!(" ERROR: extra files in translation:"); + for f in &extra { + println!(" - {}", f.display()); + } + } + + // Compare the structural signature of every file present in both sides. + for file in &ref_files { + if !lang_set.contains(file) { + continue; + } + let ref_path = reference.join(file); + let lang_path = lang.join(file); + match compare_signature(&ref_path, &lang_path) { + Ok(()) => {} + Err(diff) => { + failed = true; + eprintln!( + " {}: structure mismatch in `{}`", + "ERROR".red().bold(), + file.display().to_string().cyan() + ); + for line in diff { + println!(" {line}"); + } + } + } + } + + if failed { Err(()) } else { Ok(()) } +} + +fn collect_md_files(dir: &Path) -> Vec<PathBuf> { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(entries) = fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "md") { + out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf()); + } + } + } + out.sort(); + out +} + +/// Compare the structural signatures of two markdown files. +/// +/// Returns a list of human-readable diff lines on the first structural +/// difference found (all differences up to a small window are reported). +fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<String>> { + let ref_content = fs::read_to_string(ref_path).unwrap_or_default(); + let lang_content = fs::read_to_string(lang_path).unwrap_or_default(); + + let ref_sig = signature_of(&ref_content); + let lang_sig = signature_of(&lang_content); + + if ref_sig == lang_sig { + return Ok(()); + } + + let ref_lines: Vec<&str> = ref_content.lines().collect(); + let lang_lines: Vec<&str> = lang_content.lines().collect(); + + let mut diffs = Vec::new(); + let mut window = 0; + let max = ref_sig.len().max(lang_sig.len()); + for i in 0..max { + let ref_tok = ref_sig.get(i); + let lang_tok = lang_sig.get(i); + if ref_tok == lang_tok { + continue; + } + if window >= 5 { + diffs.push(format!("... ({}-line window truncated)", max - i)); + break; + } + window += 1; + let ref_line = ref_lines.get(i).copied().unwrap_or("<missing>"); + let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>"); + diffs.push(format!( + " {}: {}", + "line".yellow().bold(), + (i + 1).to_string().yellow() + )); + diffs.push(format!( + " {} : {} {}", + "expect".green().bold(), + format!("`{}`", token_label(ref_tok.map_or("<eof>", String::as_str))).green(), + display_line(ref_line).cyan() + )); + diffs.push(format!( + " {} : {} {}", + "found".red().bold(), + format!( + "`{}`", + token_label(lang_tok.map_or("<eof>", String::as_str)) + ) + .red(), + display_line(lang_line).cyan() + )); + if ref_sig.len() != lang_sig.len() && window >= 5 { + diffs.push(format!( + " note: reference has {} lines, translation has {} lines", + ref_sig.len(), + lang_sig.len() + )); + break; + } + } + if diffs.is_empty() { + diffs.push("signatures differ in length (see line count note)".to_string()); + } + Err(diffs) +} + +/// Human-readable label for a structural token. +fn token_label(token: &str) -> String { + match token { + "B" => "blank".to_string(), + "A" => "@@@".to_string(), + "Q" => "quote".to_string(), + "L" => "list".to_string(), + "P" => "text".to_string(), + t if t.starts_with("H") => format!("heading-{}", &t[1..]), + t if t.starts_with("F:") => { + let lang = &t[2..]; + if lang.is_empty() { + "fence".to_string() + } else { + format!("fence:{lang}") + } + } + _ => token.to_string(), + } +} + +/// Render a source line for display: blank lines become `<blank>`. +fn display_line(line: &str) -> String { + if line.trim().is_empty() { + "<blank>".to_string() + } else { + truncate(line) + } +} + +/// Build the structural signature of a markdown file. +fn signature_of(content: &str) -> Vec<String> { + let mut sig = Vec::new(); + let mut in_fence = false; + let mut fence_lang = String::new(); + + for raw_line in content.lines() { + let line = raw_line.trim(); + + if in_fence { + if line.starts_with("```") { + in_fence = false; + sig.push(format!("F:{}", fence_lang)); + } else if line.starts_with("@@@") { + sig.push("A".to_string()); + } else if line.is_empty() { + sig.push("B".to_string()); + } else { + sig.push("P".to_string()); + } + continue; + } + + if line.starts_with("```") { + in_fence = true; + fence_lang = line.trim_start_matches("```").trim().to_string(); + sig.push(format!("F:{fence_lang}")); + } else if line.starts_with('#') { + let level = line.chars().take_while(|c| *c == '#').count(); + sig.push(format!("H{level}")); + } else if line.starts_with("<h") || line.starts_with("</h") { + // HTML headings (e.g. `<h1 align="center">` / `</h1>`) + let level = line + .trim_start_matches(['<', '/']) + .chars() + .next() + .and_then(|c| c.to_digit(10)) + .unwrap_or(1); + sig.push(format!("H{level}")); + } else if line.starts_with("@@@") { + sig.push("A".to_string()); + } else if line.is_empty() { + sig.push("B".to_string()); + } else if line.starts_with('>') { + sig.push("Q".to_string()); + } else if is_list_line(line) { + sig.push("L".to_string()); + } else { + sig.push("P".to_string()); + } + } + + // An unclosed fence is still a fence line; the signature already recorded it. + sig +} + +fn is_list_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("- ") + || trimmed.starts_with("* ") + || trimmed.starts_with("+ ") + || is_numbered_list(trimmed) +} + +/// A numbered list item: `1. text`, `1) text`, `10. text`, ... +fn is_numbered_list(line: &str) -> bool { + let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count(); + if digit_count == 0 { + return false; + } + let rest = &line[digit_count..]; + (rest.starts_with(". ") || rest.starts_with(") ")) + && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t') +} + +fn truncate(line: &str) -> String { + const MAX: usize = 60; + if line.chars().count() <= MAX { + line.to_string() + } else { + let cut: String = line.chars().take(MAX).collect(); + format!("{cut}...") + } +} + +fn find_git_repo() -> Option<PathBuf> { + let mut current = std::env::current_dir().ok()?; + loop { + if current.join(".git").is_dir() { + return Some(current); + } + current = current.parent()?.to_path_buf(); + } +} diff --git a/.run/src/bin/ci.rs b/.run/src/bin/ci.rs index c090eaa..b6d92b8 100644 --- a/.run/src/bin/ci.rs +++ b/.run/src/bin/ci.rs @@ -20,6 +20,7 @@ struct Checks { markdown_code: bool, examples: bool, docs_refresh: bool, + docs_structure: bool, api_docs: bool, } @@ -32,6 +33,7 @@ impl Checks { || self.markdown_code || self.examples || self.docs_refresh + || self.docs_structure || self.api_docs } } @@ -41,17 +43,18 @@ fn print_help() { r" Usage: ci [options] Options: - -h, --help Print this help message - -y Auto-confirm temporary commits - --dirty Run CI on dirty workspace (skip temp commit & clean check) - --check-build Build all crates - --check-clippy Run clippy on all crates (-D warnings) - --check-test Run unit tests for all crates - --check-arg-picker Test the arg-picker crate - --check-markdown-code Verify all *.md code blocks compile - --check-examples Test all examples - --check-docs-refresh Refresh docs and fail if the tree is contaminated - --check-api-docs Build API docs with docs.rs features + -h, --help Print this help message + -y Auto-confirm temporary commits + --dirty Run CI on dirty workspace (skip temp commit & clean check) + --check-build Build all crates + --check-clippy Run clippy on all crates (-D warnings) + --check-test Run unit tests for all crates + --check-arg-picker Test the arg-picker crate + --check-markdown-code Verify all *.md code blocks compile + --check-examples Test all examples + --check-docs-refresh Refresh docs and fail if the tree is contaminated + --check-docs-structure Verify translated docs mirror the English structure + --check-api-docs Build API docs with docs.rs features If no specific options are given, all checks are run. " @@ -73,6 +76,7 @@ fn main() { check_markdown_code, check_examples, check_docs_refresh, + check_docs_structure, check_api_docs, help, ) = Picker::from_args() @@ -85,6 +89,7 @@ fn main() { .pick_or_default(&arg![check_markdown_code: bool]) .pick_or_default(&arg![check_examples: bool]) .pick_or_default(&arg![check_docs_refresh: bool]) + .pick_or_default(&arg![check_docs_structure: bool]) .pick_or_default(&arg![check_api_docs: bool]) .pick_or_default(&arg![help: bool, 'h']) .unwrap(); @@ -102,6 +107,7 @@ fn main() { markdown_code: check_markdown_code, examples: check_examples, docs_refresh: check_docs_refresh, + docs_structure: check_docs_structure, api_docs: check_api_docs, }; let run_all = !checks.any(); @@ -242,6 +248,14 @@ fn ci(checks: &Checks, run_all: bool) -> Result<(), i32> { run_all, )?; } + if run_all || checks.docs_structure { + run_step( + &mut exit_code, + "Phase: Check translated docs structure consistency", + docs_structure, + run_all, + )?; + } if run_all || checks.api_docs { run_step( &mut exit_code, @@ -442,3 +456,9 @@ fn docs_refresh() -> Result<(), i32> { Ok(()) } + +fn docs_structure() -> Result<(), i32> { + println_cargo_style!("Check: docs structure consistency across languages"); + + run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin check-docs-structure") +} diff --git a/.run/src/bin/cov-test.rs b/.run/src/bin/cov-test.rs index 1b2342e..f62ff01 100644 --- a/.run/src/bin/cov-test.rs +++ b/.run/src/bin/cov-test.rs @@ -9,8 +9,12 @@ //! //! - `--object <PATH>`: include arbitrary binaries in the report //! (upstream issue taiki-e/cargo-llvm-cov#367) -//! - `--include-tests-examples-benches`: stop filtering those source dirs -//! (upstream issue taiki-e/cargo-llvm-cov#503) +//! - `--include-examples`: stop filtering source files under the +//! `examples` directory (upstream issue taiki-e/cargo-llvm-cov#503) +//! +//! The script itself does not use `--include-examples`; it passes +//! `--no-default-ignore-filename-regex` and supplies its own filter so that +//! `tests`/`benches` directories stay in the report too. //! //! Install it with: //! @@ -18,7 +22,6 @@ //! cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov //! ``` -use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -34,15 +37,16 @@ const OUTPUT_DIR: &str = "docs/cov-test"; /// in one place so the final `report` can merge everything. const COV_TARGET_DIR: &str = ".temp/cov-llvm"; -/// Parsed `examples/test-examples.toml` (`test.<example> = [ { command, expect } ]`). +/// An example's `test.toml` (`[[runs]]` entries). #[derive(Deserialize)] struct TestConfig { - test: HashMap<String, Vec<TestCase>>, + runs: Vec<TestCase>, } +/// One `[[runs]]` entry of an example's `test.toml`. #[derive(Deserialize)] struct TestCase { - command: String, + input: Vec<String>, } fn main() { @@ -108,7 +112,7 @@ fn main() { } // 3. Examples: build each example with explicit RUSTFLAGS, then execute - // every command from test-examples.toml directly. + // every command declared in the example's test.toml directly. // // NOTE: `cargo llvm-cov run` cannot be used here. Its rustc wrapper // only instruments the crates of the *current* cargo project (with @@ -127,7 +131,7 @@ fn main() { } let examples = load_example_commands(&repo_root); let mut built = std::collections::HashSet::new(); - for (example, command) in &examples { + for (example, input) in &examples { if built.insert(example.clone()) { println_cargo_style!("Building: {}", example); run_cmd!(format!( @@ -150,7 +154,7 @@ fn main() { example ); match std::process::Command::new(&binary) - .args(command.split_whitespace()) + .args(input) .env("LLVM_PROFILE_FILE", &profraw) .status() { @@ -279,22 +283,43 @@ fn find_test_crate_manifests(repo_root: &Path) -> Vec<PathBuf> { manifests } -/// Parse `examples/test-examples.toml` into `(example_name, command)` pairs. -fn load_example_commands(repo_root: &Path) -> Vec<(String, String)> { - let content = - fs::read_to_string(repo_root.join("examples/test-examples.toml")).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read examples/test-examples.toml: {}", e); +/// Parse every `examples/<name>/test.toml` into `(example_name, input)` pairs. +fn load_example_commands(repo_root: &Path) -> Vec<(String, Vec<String>)> { + let examples_dir = repo_root.join("examples"); + let mut entries: Vec<_> = std::fs::read_dir(&examples_dir) + .unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read {}: {}", examples_dir.display(), e); std::process::exit(1); - }); - let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to parse examples/test-examples.toml: {}", e); - std::process::exit(1); - }); + }) + .flatten() + .collect(); + entries.sort_by_key(|e| e.file_name()); let mut pairs = Vec::new(); - for (example, cases) in &config.test { - for case in cases { - pairs.push((example.clone(), case.command.clone())); + for entry in entries { + 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 content = fs::read_to_string(&test_toml).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read {}: {}", test_toml.display(), e); + std::process::exit(1); + }); + let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to parse {}: {}", test_toml.display(), e); + std::process::exit(1); + }); + for case in config.runs { + pairs.push((name.clone(), case.input)); } } pairs diff --git a/.run/src/bin/doc-nightly.ps1 b/.run/src/bin/doc-nightly.ps1 index 30d6aaf..58d7af4 100644 --- a/.run/src/bin/doc-nightly.ps1 +++ b/.run/src/bin/doc-nightly.ps1 @@ -1,6 +1,6 @@ cargo +nightly rustdoc ` --manifest-path mingling/Cargo.toml ` - --features docs_rs,core,macros,builds,structural_renderer,repl,comp,parser,picker,clap,extra_macros ` + --features docs_rs,core,macros,builds,structural_renderer,repl,comp,picker,clap,extra_macros ` --open ` -- ` --cfg docsrs diff --git a/.run/src/bin/doc-nightly.sh b/.run/src/bin/doc-nightly.sh index 944f4b3..d16b6fc 100755 --- a/.run/src/bin/doc-nightly.sh +++ b/.run/src/bin/doc-nightly.sh @@ -2,7 +2,7 @@ cargo rustdoc \ --manifest-path mingling/Cargo.toml \ - --features docs_rs,core,macros,builds,structural_renderer,repl,comp,parser,picker,clap,extra_macros \ + --features docs_rs,core,macros,builds,structural_renderer,repl,comp,picker,clap,extra_macros \ --open \ -- \ --cfg docsrs diff --git a/.run/src/bin/doc.ps1 b/.run/src/bin/doc.ps1 index 731168c..d400f76 100644 --- a/.run/src/bin/doc.ps1 +++ b/.run/src/bin/doc.ps1 @@ -1,5 +1,5 @@ $env:RUSTDOCFLAGS="--html-in-header mingling/arborium-header.html"; cargo doc ` --manifest-path mingling/Cargo.toml ` --no-deps ` - --features docs_rs,core,macros,builds,structural_renderer,repl,comp,parser,picker,clap,extra_macros,pathf ` + --features docs_rs,core,macros,builds,structural_renderer,repl,comp,picker,clap,extra_macros,pathf ` --open diff --git a/.run/src/bin/doc.sh b/.run/src/bin/doc.sh index d6181d3..4229853 100755 --- a/.run/src/bin/doc.sh +++ b/.run/src/bin/doc.sh @@ -3,5 +3,5 @@ RUSTDOCFLAGS="--html-in-header mingling/arborium-header.html" cargo doc \ --manifest-path mingling/Cargo.toml \ --no-deps \ - --features docs_rs,core,macros,builds,structural_renderer,repl,comp,parser,picker,clap,extra_macros,pathf \ + --features docs_rs,core,macros,builds,structural_renderer,repl,comp,picker,clap,extra_macros,pathf \ --open diff --git a/.run/src/bin/test-examples.rs b/.run/src/bin/test-examples.rs index 539459e..617a745 100644 --- a/.run/src/bin/test-examples.rs +++ b/.run/src/bin/test-examples.rs @@ -1,18 +1,20 @@ -use std::collections::HashMap; +use std::path::Path; use colored::Colorize; use indicatif::ProgressBar; use serde::Deserialize; -use tools::{eprintln_cargo_style, println_cargo_style}; +use tools::{eprintln_cargo_style, println_cargo_style, run_parallel}; +/// An example's `test.toml` (`[[runs]]` entries). #[derive(Deserialize)] struct TestConfig { - test: HashMap<String, Vec<TestCase>>, + runs: Vec<TestCase>, } +/// A single `[[runs]]` entry of an example's `test.toml`. #[derive(Deserialize)] struct TestCase { - command: String, + input: Vec<String>, expect: Expect, } @@ -27,10 +29,16 @@ fn main() { #[cfg(windows)] let _ = colored::control::set_virtual_terminal(true); - let config = load_config(); + let configs = load_all_test_configs(); - // Count total test cases upfront - let total: usize = config.test.values().map(|cases| cases.len()).sum(); + // Phase 1: build all examples in parallel. + if let Err(code) = build_all_examples(&configs) { + // `run_parallel` already printed every failed build above. + std::process::exit(code); + } + + // Phase 2: run the tests serially against the pre-built binaries. + let total: usize = configs.iter().map(|(_, cases)| cases.len()).sum(); let bar = ProgressBar::new(total as u64); bar.set_style( indicatif::ProgressStyle::default_bar() @@ -43,7 +51,7 @@ fn main() { ); bar.set_message("examples"); - let passed = run_all_tests(&config, &bar); + let passed = run_all_tests(&configs, &bar); bar.finish_and_clear(); @@ -55,31 +63,71 @@ fn main() { } } -/// Parse test config from TOML file -fn load_config() -> TestConfig { - let content = std::fs::read_to_string("examples/test-examples.toml").unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read TOML config file: {}", e); +/// Load `examples/<name>/test.toml` for every example that has one, in +/// alphabetical order of the example directory name. +fn load_all_test_configs() -> Vec<(String, Vec<TestCase>)> { + let examples_dir = Path::new("examples"); + let mut configs = Vec::new(); + + let entries = std::fs::read_dir(examples_dir).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read examples dir: {}", e); std::process::exit(1); }); - toml::from_str(&content).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to parse TOML config: {}", e); - std::process::exit(1); - }) + 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 content = std::fs::read_to_string(&test_toml).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read {}: {}", test_toml.display(), e); + std::process::exit(1); + }); + let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to parse {}: {}", test_toml.display(), e); + std::process::exit(1); + }); + configs.push((name, config.runs)); + } + + configs.sort_by(|a, b| a.0.cmp(&b.0)); + configs } -/// Run all example test groups, return number passed -fn run_all_tests(config: &TestConfig, bar: &ProgressBar) -> usize { +/// Phase 1: build every example that has a `test.toml` in parallel. +/// +/// Build tasks are spawned in parallel (like `ci.rs`'s `build_all`); on any +/// build failure the whole run aborts with the first failure's exit code. +fn build_all_examples(configs: &[(String, Vec<TestCase>)]) -> Result<(), i32> { + let tasks: Vec<(String, String, String)> = configs + .iter() + .map(|(name, _)| { + ( + format!("Build: {name}"), + name.clone(), + format!("cargo build --manifest-path examples/{name}/Cargo.toml --color always"), + ) + }) + .collect(); + run_parallel("Building", tasks) +} + +/// Phase 2: run all example test groups serially, return number passed +fn run_all_tests(configs: &[(String, Vec<TestCase>)], bar: &ProgressBar) -> usize { let mut passed = 0; - for (example_name, test_cases) in &config.test { + for (example_name, test_cases) in configs { bar.set_message(example_name.clone()); - if !build_example(example_name) { - bar.inc(test_cases.len() as u64); - continue; - } - for test_case in test_cases { if run_single_test(example_name, test_case, bar) { passed += 1; @@ -91,27 +139,18 @@ fn run_all_tests(config: &TestConfig, bar: &ProgressBar) -> usize { passed } -/// Build the example binary, return true on success -fn build_example(example_name: &str) -> bool { - let manifest = format!("examples/{example_name}/Cargo.toml"); - tools::run_cmd_capture(format!( - "cargo build --manifest-path {manifest} --color always", - )) - .is_ok() -} - /// Run a single test case, return true on pass fn run_single_test(example_name: &str, test_case: &TestCase, bar: &ProgressBar) -> bool { let binary_path = format!(".temp/target/debug/{}", get_binary_name(example_name)); - let args: Vec<&str> = test_case.command.split_whitespace().collect(); + let command = test_case.input.join(" "); let output = match std::process::Command::new(&binary_path) - .args(&args) + .args(&test_case.input) .output() { Ok(o) => o, Err(e) => { - bar.println(format!("'{}' - failed to run: {}", test_case.command, e)); + bar.println(format!("'{command}' - failed to run: {e}")); return false; } }; @@ -127,7 +166,7 @@ fn run_single_test(example_name: &str, test_case: &TestCase, bar: &ProgressBar) if exit_ok && result_ok { true } else { - bar.println(format!("failed: '{}'", test_case.command)); + bar.println(format!("failed: '{command}'")); if !exit_ok { bar.println(format!( " Expected exit code: {}, actual: {}", |
