aboutsummaryrefslogtreecommitdiff
path: root/.run/src
diff options
context:
space:
mode:
Diffstat (limited to '.run/src')
-rw-r--r--.run/src/bin/check-docs-structure.rs352
-rw-r--r--.run/src/bin/ci.rs221
-rw-r--r--.run/src/bin/cov-test.rs493
-rw-r--r--.run/src/bin/doc-nightly.ps12
-rwxr-xr-x.run/src/bin/doc-nightly.sh2
-rw-r--r--.run/src/bin/doc.ps12
-rwxr-xr-x.run/src/bin/doc.sh2
-rw-r--r--.run/src/bin/docsify-sidebar-gen.rs14
-rw-r--r--.run/src/bin/install-mling.ps115
-rwxr-xr-x[-rw-r--r--].run/src/bin/install-mling.sh17
-rw-r--r--.run/src/bin/refresh-feature-mod.rs2
-rw-r--r--.run/src/bin/test-examples.rs113
-rw-r--r--.run/src/lib.rs35
-rw-r--r--.run/src/verify.rs23
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(&current) 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 {