diff options
Diffstat (limited to '.run/src')
| -rw-r--r-- | .run/src/bin/check-docs-structure.rs | 352 | ||||
| -rw-r--r-- | .run/src/bin/ci.rs | 221 | ||||
| -rw-r--r-- | .run/src/bin/cov-test.rs | 493 | ||||
| -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/docsify-sidebar-gen.rs | 14 | ||||
| -rw-r--r-- | .run/src/bin/install-mling.ps1 | 15 | ||||
| -rwxr-xr-x[-rw-r--r--] | .run/src/bin/install-mling.sh | 17 | ||||
| -rw-r--r-- | .run/src/bin/refresh-feature-mod.rs | 2 | ||||
| -rw-r--r-- | .run/src/bin/test-examples.rs | 113 | ||||
| -rw-r--r-- | .run/src/lib.rs | 35 | ||||
| -rw-r--r-- | .run/src/verify.rs | 23 |
14 files changed, 1153 insertions, 140 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 39d55eb..b6d92b8 100644 --- a/.run/src/bin/ci.rs +++ b/.run/src/bin/ci.rs @@ -11,17 +11,50 @@ fn get_ignore_dirs() -> Vec<String> { vec![".temp".to_string()] } +/// A single CI step, each individually toggleable via `--check-*`. +struct Checks { + build: bool, + clippy: bool, + test: bool, + arg_picker: bool, + markdown_code: bool, + examples: bool, + docs_refresh: bool, + docs_structure: bool, + api_docs: bool, +} + +impl Checks { + fn any(&self) -> bool { + self.build + || self.clippy + || self.test + || self.arg_picker + || self.markdown_code + || self.examples + || self.docs_refresh + || self.docs_structure + || self.api_docs + } +} + fn print_help() { println!( 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) - --refresh-docs Refresh documentation files - --test-docs Run documentation tests (build, clippy, test) - --test-codes Test examples and documentation code blocks + -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. " @@ -33,12 +66,31 @@ fn main() { let _ = colored::control::set_virtual_terminal(true); println!("{}", include_str!("../../../docs/res/ci_banner.txt")); - let (auto_yes, dirty, test_docs, refresh_docs, test_codes, help) = Picker::from_args() + let ( + auto_yes, + dirty, + check_build, + check_clippy, + check_test, + check_arg_picker, + check_markdown_code, + check_examples, + check_docs_refresh, + check_docs_structure, + check_api_docs, + help, + ) = Picker::from_args() .pick_or_default(&arg![yes: bool, 'y']) .pick_or_default(&arg![dirty: bool]) - .pick_or_default(&arg![test_docs: bool]) - .pick_or_default(&arg![refresh_docs: bool]) - .pick_or_default(&arg![test_codes: bool]) + .pick_or_default(&arg![check_build: bool]) + .pick_or_default(&arg![check_clippy: bool]) + .pick_or_default(&arg![check_test: bool]) + .pick_or_default(&arg![check_arg_picker: bool]) + .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(); @@ -47,8 +99,18 @@ fn main() { return; } - let any_specified = test_docs || refresh_docs || test_codes; - let run_all = !any_specified; + let checks = Checks { + build: check_build, + clippy: check_clippy, + test: check_test, + arg_picker: check_arg_picker, + 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(); let needs_commit_temp = !dirty && !{ run_cmd!("git diff-index --quiet HEAD --").is_ok() }; @@ -72,7 +134,7 @@ fn main() { } } - if let Err(exit_code) = ci(test_docs, test_codes, run_all) { + if let Err(exit_code) = ci(&checks, run_all) { restore_workspace(needs_commit_temp).unwrap(); exit(exit_code) } @@ -109,47 +171,102 @@ fn restore_workspace(undo_commit: bool) -> Result<(), i32> { Ok(()) } -fn ci(test_docs: bool, test_codes: bool, run_all: bool) -> Result<(), i32> { - if run_all || test_codes { - println_cargo_style!("Phase: Scan and build all crates"); - build_all()?; - - println_cargo_style!("Phase: Run clippy for all crates"); - clippy_all()?; - - println_cargo_style!("Phase: Test all crates"); - test_all()?; - - println_cargo_style!("Phase: Test arg picker"); - test_arg_picker()?; - } - - if run_all || test_docs { - let mut exit_code = 0; - - println_cargo_style!("Phase: Verify all *.md document code blocks are compilable"); - if let Err(code) = test_docs_code_blocks() { - exit_code = exit_code.max(code); +/// Run one CI step. +/// +/// When `continue_on_error` is set (used for the documentation steps in +/// "run all" mode), a failing step is recorded and the remaining steps still +/// execute, so every problem is reported in a single run. +fn run_step( + exit_code: &mut i32, + phase: &str, + step: fn() -> Result<(), i32>, + continue_on_error: bool, +) -> Result<(), i32> { + println_cargo_style!(phase); + match step() { + Ok(()) => Ok(()), + Err(code) if continue_on_error => { + *exit_code = (*exit_code).max(code); + Ok(()) } + Err(code) => Err(code), + } +} - println_cargo_style!("Phase: Test all examples"); - if let Err(code) = test_examples() { - exit_code = exit_code.max(code); - } +fn ci(checks: &Checks, run_all: bool) -> Result<(), i32> { + let mut exit_code = 0; - println_cargo_style!("Phase: Check all documentation is up to date"); - if let Err(code) = docs_refresh() { - exit_code = exit_code.max(code); - } + if run_all || checks.build { + run_step( + &mut exit_code, + "Phase: Scan and build all crates", + build_all, + false, + )?; + } + if run_all || checks.clippy { + run_step( + &mut exit_code, + "Phase: Run clippy for all crates", + clippy_all, + false, + )?; + } + if run_all || checks.test { + run_step(&mut exit_code, "Phase: Test all crates", test_all, false)?; + } + if run_all || checks.arg_picker { + run_step( + &mut exit_code, + "Phase: Test arg picker", + test_arg_picker, + false, + )?; + } - println_cargo_style!("Phase: Try Build API docs"); - if let Err(code) = deploy_api_docs() { - exit_code = exit_code.max(code); - } + if run_all || checks.markdown_code { + run_step( + &mut exit_code, + "Phase: Verify all *.md document code blocks are compilable", + test_docs_code_blocks, + run_all, + )?; + } + if run_all || checks.examples { + run_step( + &mut exit_code, + "Phase: Test all examples", + test_examples, + run_all, + )?; + } + if run_all || checks.docs_refresh { + run_step( + &mut exit_code, + "Phase: Check all documentation is up to date", + docs_refresh, + 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, + "Phase: Try Build API docs", + deploy_api_docs, + run_all, + )?; + } - if exit_code != 0 { - return Err(exit_code); - } + if exit_code != 0 { + return Err(exit_code); } run_cmd!("git add --renormalize .")?; @@ -339,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 a53f74c..f62ff01 100644 --- a/.run/src/bin/cov-test.rs +++ b/.run/src/bin/cov-test.rs @@ -1,35 +1,194 @@ +//! Coverage test generator for mingling. +//! +//! This script requires the **fork** of cargo-llvm-cov: +//! <https://github.com/Weicao-CatilGrass/cargo-llvm-cov> +//! +//! The upstream `report` command cannot include binaries of non-workspace +//! crates (examples and test crates) and unconditionally filters +//! `tests`/`examples` source files. The fork adds two flags to fix this: +//! +//! - `--object <PATH>`: include arbitrary binaries in the report +//! (upstream issue taiki-e/cargo-llvm-cov#367) +//! - `--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: +//! +//! ```bash +//! cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov +//! ``` + use std::fs; -use tools::{println_cargo_style, run_cmd}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use tools::{eprintln_cargo_style, println_cargo_style, run_cmd}; const OUTPUT_DIR: &str = "docs/cov-test"; +/// Shared target directory for all `cargo llvm-cov` runs. +/// +/// Pointing every run at the same target dir makes all of them share the +/// instrumented build cache and, more importantly, accumulate profraw files +/// in one place so the final `report` can merge everything. +const COV_TARGET_DIR: &str = ".temp/cov-llvm"; + +/// An example's `test.toml` (`[[runs]]` entries). +#[derive(Deserialize)] +struct TestConfig { + runs: Vec<TestCase>, +} + +/// One `[[runs]]` entry of an example's `test.toml`. +#[derive(Deserialize)] +struct TestCase { + input: Vec<String>, +} + fn main() { let repo_root = find_git_repo().expect("Failed to find git repository root"); let output_path = repo_root.join(OUTPUT_DIR); + let cov_target = repo_root.join(COV_TARGET_DIR); // Read features from [package.metadata.docs.rs] let features = tools::read_features().unwrap_or_else(|e| { eprintln!("Error: {}", e); std::process::exit(1); }); - let features_arg = features.join(","); // Ensure output directory exists std::fs::create_dir_all(&output_path).expect("Failed to create output directory"); + std::fs::create_dir_all(&cov_target).expect("Failed to create cov target directory"); - let cmd = format!( - "cargo llvm-cov --html --output-dir \"{}\" --workspace --features \"{}\" --color always", - output_path.to_string_lossy(), - features_arg, - ); + // All `cargo llvm-cov` invocations below share one target dir, so profraw + // files accumulate and are merged by the final `report` command. + // SAFETY: set before any thread is spawned; this process only shells out + // to subcommands via std::process. + unsafe { + std::env::set_var("CARGO_LLVM_COV_TARGET_DIR", &cov_target); + } + + // Drop stale profraw from previous runs (keep the instrumented build cache). + clean_old_profraw(&cov_target); println_cargo_style!("Features: {}", features_arg); - println_cargo_style!("Coverage: {}", output_path.display()); + println_cargo_style!("Target: {}", cov_target.display()); - println_cargo_style!("Running: cargo llvm-cov --html"); - run_cmd!(&cmd).unwrap_or_else(|code| { - eprintln!("Error: cargo llvm-cov failed with exit code {}", code); + // 1. Workspace tests + println_cargo_style!("Running: cargo llvm-cov test --workspace"); + run_cmd!(format!( + "cargo llvm-cov test --no-report --workspace --features \"{}\" --color always", + features_arg + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!("workspace tests failed with exit code {}", code); + std::process::exit(code); + }); + + // 2. Integration test crates under mingling_core/tests (excluded from the + // workspace, so they need their own `--manifest-path` runs) + for manifest in find_test_crate_manifests(&repo_root) { + println_cargo_style!( + "Running: cargo llvm-cov test {}", + manifest.file_name().unwrap_or_default().to_string_lossy() + ); + run_cmd!(format!( + "cargo llvm-cov test --no-report --manifest-path \"{}\" --color always", + manifest.display() + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!( + "test crate {} failed with exit code {}", + manifest.display(), + code + ); + std::process::exit(code); + }); + } + + // 3. Examples: build each example with explicit RUSTFLAGS, then execute + // 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 + // `--manifest-path` that is the example itself), so the mingling + // libraries — being dependencies — would not be instrumented and their + // coverage would silently be lost (once_exec.rs showed 0%). Building + // with plain RUSTFLAGS instruments the whole dependency graph. + // + // RUSTFLAGS/CARGO_TARGET_DIR are set process-wide here because only the + // `report` step (which does not compile) follows. Non-zero exit codes + // are expected for some examples (e.g. `--help` exits with 2); profraw + // is still written. + unsafe { + std::env::set_var("RUSTFLAGS", "-Cinstrument-coverage"); + std::env::set_var("CARGO_TARGET_DIR", &cov_target); + } + let examples = load_example_commands(&repo_root); + let mut built = std::collections::HashSet::new(); + for (example, input) in &examples { + if built.insert(example.clone()) { + println_cargo_style!("Building: {}", example); + run_cmd!(format!( + "cargo build --manifest-path examples/{}/Cargo.toml --color always", + example + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!( + "build of example {} failed with exit code {}", + example, + code + ); + std::process::exit(code); + }); + } + let binary = cov_target.join("debug").join(get_binary_name(example)); + let profraw = format!( + "{}/example-{}.%p.profraw", + cov_target.to_string_lossy(), + example + ); + match std::process::Command::new(&binary) + .args(input) + .env("LLVM_PROFILE_FILE", &profraw) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => println_cargo_style!( + "Warning: example {} exited with {:?}, profraw still recorded", + example, + status.code() + ), + Err(e) => eprintln_cargo_style!("Failed to run example {}: {}", example, e), + } + } + + // 4. Collect the binaries of non-workspace crates (examples + test crates). + // The automatic object-file detection only knows workspace members, so + // these must be passed explicitly via --object. + let member_names = workspace_member_names(&repo_root); + let object_args = collect_object_args(&cov_target, &member_names); + + // 5. Generate the merged HTML report. + // + // --no-default-ignore-filename-regex: the default regex unconditionally + // excludes `examples`/`tests` directories, which is exactly what we want + // to include here, so we take over the filter ourselves. + let ignore_re = build_ignore_regex(&cov_target); + println_cargo_style!("Running: cargo llvm-cov report --html"); + run_cmd!(format!( + "cargo llvm-cov report --html --output-dir \"{}\" --no-default-ignore-filename-regex --ignore-filename-regex \"{}\" {} --color always", + output_path.to_string_lossy(), + ignore_re, + object_args + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!("cargo llvm-cov report failed with exit code {}", code); std::process::exit(code); }); @@ -38,7 +197,6 @@ fn main() { if html_dir.exists() && html_dir.is_dir() { println_cargo_style!("Moving files from {}/html/ to {}/", OUTPUT_DIR, OUTPUT_DIR); - // Move each entry in html_dir up one level for entry in fs::read_dir(&html_dir).expect("Failed to read html directory") { let entry = entry.expect("Failed to read entry"); let entry_path = entry.path(); @@ -49,7 +207,6 @@ fn main() { .to_owned(); let dest_path = output_path.join(&file_name); - // Remove existing file/directory at destination if any if dest_path.exists() { if dest_path.is_dir() { fs::remove_dir_all(&dest_path).unwrap_or_else(|e| { @@ -74,7 +231,6 @@ fn main() { }); } - // Remove the now-empty html directory fs::remove_dir(&html_dir).unwrap_or_else(|e| { eprintln!("Warning: could not remove html directory: {}", e); }); @@ -82,12 +238,297 @@ fn main() { println_cargo_style!("Files moved successfully."); } + // 6. Recolor the per-file coverage summary with project-specific + // thresholds: 0-50% red, 51-80% yellow, 81-100% green. llvm-cov's + // built-in thresholds differ, and the color is assigned when the HTML + // is generated, so the summary table is rewritten here. + let index_path = output_path.join("index.html"); + if let Err(e) = recolor_report_index(&index_path) { + eprintln_cargo_style!("Warning: failed to recolor {}: {}", index_path.display(), e); + } + println_cargo_style!( "Done: coverage report generated at {}/index.html", OUTPUT_DIR ); } +/// Remove `*.profraw` from the shared target dir so stale data from previous +/// runs does not pollute the merged report. The instrumented build cache +/// (everything else) is kept. +fn clean_old_profraw(cov_target: &Path) { + if let Ok(entries) = fs::read_dir(cov_target) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "profraw") { + let _ = fs::remove_file(&path); + } + } + } +} + +/// All `mingling_core/tests/<crate>/Cargo.toml` manifests. +fn find_test_crate_manifests(repo_root: &Path) -> Vec<PathBuf> { + let tests_dir = repo_root.join("mingling_core/tests"); + let mut manifests = Vec::new(); + if let Ok(entries) = fs::read_dir(&tests_dir) { + for entry in entries.flatten() { + let manifest = entry.path().join("Cargo.toml"); + if manifest.is_file() { + manifests.push(manifest); + } + } + } + manifests.sort(); + manifests +} + +/// 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); + }) + .flatten() + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let mut pairs = Vec::new(); + 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 +} + +/// Names of all workspace members, from `cargo metadata --no-deps`. +fn workspace_member_names(repo_root: &Path) -> Vec<String> { + let Ok(output) = tools::run_cmd_capture_with_dir( + "cargo metadata --no-deps --format-version 1".to_string(), + repo_root, + ) else { + return Vec::new(); + }; + let Ok(json) = serde_json::from_str::<serde_json::Value>(&output) else { + return Vec::new(); + }; + json["packages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|p| p["name"].as_str().map(str::to_owned)) + .collect() +} + +/// Collect the binaries of non-workspace crates (examples and test crates) +/// from the shared target dir, as `--object <path>` arguments. +/// +/// - `debug/` root: example binaries (built via `cargo llvm-cov run`). +/// - `debug/deps/`: test crate binaries (e.g. `integration-<hash>`); their +/// names do not follow a single pattern, so anything that is not a +/// workspace-member binary and not a proc-macro `.so` is collected. +/// +/// Workspace member binaries are detected automatically by `report` and must +/// NOT be passed again (duplicate `-object` entries produce duplicated +/// output). Hard links to the same file are deduplicated by inode. +fn collect_object_args(cov_target: &Path, member_names: &[String]) -> String { + let debug_dir = cov_target.join("debug"); + let mut objects = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for dir in [debug_dir.clone(), debug_dir.join("deps")] { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || !is_executable(&path) { + continue; + } + if !seen.insert(file_id(&path)) { + continue; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + // Proc-macro shared objects are either workspace members (picked + // up automatically) or external deps (excluded from the report + // by the ignore regex), so never pass them explicitly. + if name.starts_with("lib") && name.ends_with(".so") { + continue; + } + if is_workspace_member_binary(name, member_names) { + continue; + } + objects.push(path); + } + } + + objects.sort(); + objects + .iter() + .map(|p| format!("--object \"{}\"", p.to_string_lossy())) + .collect::<Vec<_>>() + .join(" ") +} + +/// True if the binary name (e.g. `mingling_core-fea14a01b88afcaa`) belongs to +/// a workspace member. +fn is_workspace_member_binary(name: &str, member_names: &[String]) -> bool { + let stem = strip_cargo_hash(name); + member_names.iter().any(|m| stem == m) +} + +/// Strip the cargo-generated hash suffix: `mingling_core-fea14a01b88afcaa` -> +/// `mingling_core`. Returns the input unchanged if there is no such suffix. +fn strip_cargo_hash(name: &str) -> &str { + let Some(idx) = name.rfind('-') else { + return name; + }; + let (head, tail) = name.split_at(idx); + let hash = &tail[1..]; + if hash.len() == 16 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + head + } else { + name + } +} + +/// A stable identity for deduplicating hard links: device+inode on Unix, +/// canonicalized path elsewhere. +fn file_id(path: &Path) -> String { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if let Ok(metadata) = fs::metadata(path) { + return format!("{}:{}", metadata.dev(), metadata.ino()); + } + } + fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +/// Resolve binary filename for the given example. +/// +/// The binary name matches the package name. On Windows, the `.exe` suffix is +/// required. +fn get_binary_name(example_name: &str) -> String { + let base = example_name; + if cfg!(target_os = "windows") { + format!("{base}.exe") + } else { + base.to_string() + } +} + +/// Rewrite the per-file coverage colors in `index.html` with project-specific +/// thresholds: 0-50% red, 51-80% yellow, 81-100% green. +fn recolor_report_index(index_path: &Path) -> std::io::Result<()> { + let content = fs::read_to_string(index_path)?; + fs::write(index_path, recolor_coverage_table(&content)) +} + +/// Recolor every `<td class='column-entry-...'><pre>XX% ...</pre></td>` cell +/// in the coverage summary table according to the new thresholds. Cells with +/// no data (e.g. branch coverage `- (0/0)`, class `gray`) are left as-is. +fn recolor_coverage_table(input: &str) -> String { + const TD: &str = "<td class='column-entry-"; + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(pos) = rest.find(TD) { + out.push_str(&rest[..pos + TD.len()]); + rest = &rest[pos + TD.len()..]; + let Some(pre_end) = rest.find("'><pre>") else { + out.push_str(rest); + return out; + }; + let color = &rest[..pre_end]; + let tail = &rest[pre_end + "'><pre>".len()..]; + let pct: String = tail + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let new_color = match pct.parse::<f64>() { + Ok(v) if v <= 50.0 => "red", + Ok(v) if v <= 80.0 => "yellow", + Ok(_) => "green", + Err(_) => color, // no data (e.g. gray branch column) + }; + out.push_str(new_color); + out.push_str("'><pre>"); + rest = tail; + } + out.push_str(rest); + out +} + +/// True if the file is executable: mode bits on Unix, `.exe` on Windows. +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + path.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("exe")) + } +} + +/// Regex that keeps only the project's own sources in the report: +/// excludes the shared llvm-cov target dir, the standard library, and +/// external dependencies. +fn build_ignore_regex(cov_target: &Path) -> String { + let target = regex_escape_path(cov_target); + format!( + "^{target}($|/)|/rustc/([0-9a-f]+|[0-9]+\\.[0-9]+\\.[0-9]+)/|/\\.cargo/(registry|git)/|/\\.rustup/toolchains($|/)" + ) +} + +/// Escape a path for use inside a regular expression (as a literal prefix). +fn regex_escape_path(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + let mut escaped = String::with_capacity(s.len()); + for ch in s.chars() { + if ch == '.' || ch == '-' { + escaped.push('\\'); + } + escaped.push(ch); + } + escaped +} + fn find_git_repo() -> Option<std::path::PathBuf> { let mut current_dir = std::env::current_dir().ok()?; @@ -104,3 +545,27 @@ fn find_git_repo() -> Option<std::path::PathBuf> { None } + +#[cfg(test)] +mod tests { + use super::recolor_coverage_table; + + #[test] + fn recolor_thresholds() { + let input = concat!( + "<td class='column-entry-red'><pre> 50.00% (2/4)</pre></td>", + "<td class='column-entry-yellow'><pre> 51.23% (32/52)</pre></td>", + "<td class='column-entry-red'><pre> 80.00% (48/89)</pre></td>", + "<td class='column-entry-green'><pre> 81.00% (1/1)</pre></td>", + "<td class='column-entry-yellow'><pre> 90.00% (6/7)</pre></td>", + "<td class='column-entry-gray'><pre>- (0/0)</pre></td>", + ); + let out = recolor_coverage_table(input); + assert!(out.contains("class='column-entry-red'><pre> 50.00%")); + assert!(out.contains("class='column-entry-yellow'><pre> 51.23%")); + assert!(out.contains("class='column-entry-yellow'><pre> 80.00%")); + assert!(out.contains("class='column-entry-green'><pre> 81.00%")); + assert!(out.contains("class='column-entry-green'><pre> 90.00%")); + assert!(out.contains("class='column-entry-gray'><pre>- (0/0)")); + } +} diff --git a/.run/src/bin/doc-nightly.ps1 b/.run/src/bin/doc-nightly.ps1 index 30d6aaf..a81f7e6 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,structural_renderer,repl,comp,picker,clap,extras ` --open ` -- ` --cfg docsrs diff --git a/.run/src/bin/doc-nightly.sh b/.run/src/bin/doc-nightly.sh index 944f4b3..ccf3aa0 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,structural_renderer,repl,comp,picker,clap,extras \ --open \ -- \ --cfg docsrs diff --git a/.run/src/bin/doc.ps1 b/.run/src/bin/doc.ps1 index 731168c..64150b1 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,structural_renderer,repl,comp,picker,clap,extras,pathf ` --open diff --git a/.run/src/bin/doc.sh b/.run/src/bin/doc.sh index d6181d3..79c74e7 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,structural_renderer,repl,comp,picker,clap,extras,pathf \ --open diff --git a/.run/src/bin/docsify-sidebar-gen.rs b/.run/src/bin/docsify-sidebar-gen.rs index 5beda7f..15ae184 100644 --- a/.run/src/bin/docsify-sidebar-gen.rs +++ b/.run/src/bin/docsify-sidebar-gen.rs @@ -67,10 +67,9 @@ fn find_content_dir(site_root: &Path) -> Option<PathBuf> { entries.sort_by_key(|e| e.path()); for entry in entries { let path = entry.path(); - if path.is_dir() - && has_markdown_files(&path) { - return Some(path); - } + if path.is_dir() && has_markdown_files(&path) { + return Some(path); + } } } @@ -255,8 +254,9 @@ fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { fn extract_leading_number(link: &str) -> usize { if let Some(file_stem) = link.rsplit('/').next() && let Some(num_end) = file_stem.find('-') - && let Ok(num) = file_stem[..num_end].parse::<usize>() { - return num; - } + && let Ok(num) = file_stem[..num_end].parse::<usize>() + { + return num; + } usize::MAX } diff --git a/.run/src/bin/install-mling.ps1 b/.run/src/bin/install-mling.ps1 index bebe9ff..2b55a09 100644 --- a/.run/src/bin/install-mling.ps1 +++ b/.run/src/bin/install-mling.ps1 @@ -1,7 +1,10 @@ -cargo install --path mling +$ErrorActionPreference = "Stop" -New-Item -ItemType Directory -Force -Path .temp/comp | Out-Null -# Copy all files containing _comp from the debug directory -Get-ChildItem .temp/target/release/*_comp* | ForEach-Object { - Copy-Item $_.FullName .temp/comp/ -} +cargo build --release --manifest-path mingling_cli/Cargo.toml + +New-Item -ItemType Directory -Force -Path .temp/mling/bin, .temp/mling/scripts | Out-Null + +Copy-Item .temp/target/release/mling.exe .temp/mling/bin/ +Copy-Item .temp/target/release/mingling-cli.exe .temp/mling/bin/ +Copy-Item .temp/target/mingling/mling_comp.ps1 .temp/mling/scripts/mling_comp.ps1 +Copy-Item mingling_cli/scripts/load_mling.ps1 .temp/mling/ diff --git a/.run/src/bin/install-mling.sh b/.run/src/bin/install-mling.sh index 5f2ee7a..e8cfa18 100644..100755 --- a/.run/src/bin/install-mling.sh +++ b/.run/src/bin/install-mling.sh @@ -1,6 +1,17 @@ #!/bin/bash -cargo install --path mling +set -e -mkdir -p .temp/comp -cp .temp/target/release/*_comp.* .temp/comp/ 2>/dev/null || echo "No matching files found" +cargo build --release --manifest-path mingling_cli/Cargo.toml + +mkdir -p .temp/mling/bin .temp/mling/scripts + +cp .temp/target/release/mling .temp/mling/bin/ +cp .temp/target/release/mingling-cli .temp/mling/bin/ + +for comp in zsh sh fish; do + cp ".temp/target/mingling/mling_comp.$comp" ".temp/mling/scripts/mling_comp.$comp" +done +cp mingling_cli/scripts/load_mling.zsh .temp/mling/ +cp mingling_cli/scripts/load_mling.sh .temp/mling/ +cp mingling_cli/scripts/load_mling.fish .temp/mling/ diff --git a/.run/src/bin/refresh-feature-mod.rs b/.run/src/bin/refresh-feature-mod.rs index 2255dbc..4cd6532 100644 --- a/.run/src/bin/refresh-feature-mod.rs +++ b/.run/src/bin/refresh-feature-mod.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::path::Path; use just_fmt::snake_case; -use just_template::{tmpl, Template}; +use just_template::{Template, tmpl}; use tools::println_cargo_style; const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml"; 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: {}", diff --git a/.run/src/lib.rs b/.run/src/lib.rs index cff606a..b17a61f 100644 --- a/.run/src/lib.rs +++ b/.run/src/lib.rs @@ -3,6 +3,8 @@ pub mod verify; use colored::Colorize; +use std::io::IsTerminal as _; + #[macro_export] macro_rules! run_cmd { ($fmt:literal, $($arg:tt)*) => { @@ -201,7 +203,15 @@ pub fn run_cmd_capture_with_dir( let exit_code = output.status.code().unwrap_or(1); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let combined = if stderr.is_empty() { stdout } else { stderr }; + // Keep both streams so a failure is never hidden: when stderr carries + // warnings, the real failure details (e.g. the failing test name and + // assertion diff) usually live on stdout and must not be dropped. + let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) { + (false, false) => format!("{stdout}\n{stderr}"), + (false, true) => stdout, + (true, false) => stderr, + (true, true) => stdout, + }; if exit_code == 0 { Ok(combined) @@ -275,15 +285,30 @@ pub fn run_parallel(phase: &str, tasks: Vec<(String, String, String)>) -> Result if first_exit_code == 0 { first_exit_code = code; } - pb.println(format!( + let msg = format!( "{}: {} failed (exit code {})", "error".bright_red().bold(), labels[i], code, - )); + ); + let mut lines = Vec::new(); if !output.is_empty() { - for line in output.lines() { - pb.println(format!(" {line}")); + lines.extend(output.lines().map(|l| format!(" {l}"))); + } + if std::io::stdout().is_terminal() { + // On a TTY, render errors through the progress bar so they + // appear above it. + pb.println(&msg); + for line in &lines { + pb.println(line); + } + } else { + // On a non-TTY (CI, piped output), `ProgressBar::println` can + // be swallowed, hiding the failure. Emit to plain stdout so the + // failure is always visible. + println!("{msg}"); + for line in &lines { + println!("{line}"); } } } diff --git a/.run/src/verify.rs b/.run/src/verify.rs index 0a4b354..b79bb73 100644 --- a/.run/src/verify.rs +++ b/.run/src/verify.rs @@ -215,16 +215,15 @@ pub fn generate_cargo_toml(block: &CodeBlock, package_name: &str, manifest_path: ) }; - // Build-time blocks: add `builds` by default, merge with explicit features + // Build-time blocks: mirror the declared features into [build-dependencies] + // so that build.rs can use the same feature set as the crate itself. let build_deps_section = if block.is_build_time { - let mut all_feats = vec!["builds".to_string()]; - for f in &block.features { - if f != "builds" { - all_feats.push(f.clone()); - } - } - let feats_str: Vec<String> = all_feats.iter().map(|f| format!("\"{f}\"")).collect(); - let build_feats = format!("features = [{}]", feats_str.join(", ")); + let feats_str: Vec<String> = block.features.iter().map(|f| format!("\"{f}\"")).collect(); + let build_feats = if feats_str.is_empty() { + String::new() + } else { + format!("features = [{}]", feats_str.join(", ")) + }; format!( "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n" ) @@ -292,14 +291,10 @@ pub fn generate_main_rs(block: &CodeBlock) -> String { /// Generate build.rs for a build-time block /// -/// Default: `use mingling::builds::*;`, code wrapped in `fn main() { }`. +/// Default: code wrapped in `fn main() { }`. pub fn generate_build_rs(block: &CodeBlock) -> String { let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); - if !block.code.contains("use mingling::build::*;") { - output.push_str("#[allow(unused_imports)]\nuse mingling::build::*;\n\n"); - } - if block.has_main { output.push_str(&block.code); } else { |
