diff options
| -rw-r--r-- | .github/workflows/ci.yml | 2 | ||||
| -rw-r--r-- | .run/src/bin/cov-test.rs | 335 |
2 files changed, 322 insertions, 15 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71b63ca..d8b133f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: run: cargo +nightly run --manifest-path .run/Cargo.toml --bin deploy-api-docs -- --docsrs - name: Install cargo-llvm-cov - run: cargo install cargo-llvm-cov + run: cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov - name: Run cov-test run: cargo run --manifest-path .run/Cargo.toml --bin cov-test diff --git a/.run/src/bin/cov-test.rs b/.run/src/bin/cov-test.rs index a53f74c..93318ee 100644 --- a/.run/src/bin/cov-test.rs +++ b/.run/src/bin/cov-test.rs @@ -1,35 +1,146 @@ +//! 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-tests-examples-benches`: stop filtering those source dirs +//! (upstream issue taiki-e/cargo-llvm-cov#503) +//! +//! Install it with: +//! +//! ```bash +//! cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov +//! ``` + +use std::collections::HashMap; 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"; + +/// Parsed `examples/test-examples.toml` (`test.<example> = [ { command, expect } ]`). +#[derive(Deserialize)] +struct TestConfig { + test: HashMap<String, Vec<TestCase>>, +} + +#[derive(Deserialize)] +struct TestCase { + command: 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()); + + // 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); + }); + } - 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); + // 3. Examples: execute every command from test-examples.toml so their + // code paths are recorded. Non-zero exit codes are expected for some + // examples (e.g. `--help` exits with 2); profraw is still written. + let examples = load_example_commands(&repo_root); + for (example, command) in &examples { + if let Err(code) = run_cmd!(format!( + "cargo llvm-cov run --no-report --manifest-path examples/{}/Cargo.toml --color always -- {}", + example, command + )) { + println_cargo_style!( + "Warning: example {} exited with {}, profraw still recorded", + example, + code + ); + } + } + + // 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 +149,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 +159,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 +183,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); }); @@ -88,6 +196,205 @@ fn main() { ); } +/// 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 `examples/test-examples.toml` into `(example_name, command)` pairs. +fn load_example_commands(repo_root: &Path) -> Vec<(String, String)> { + let content = + fs::read_to_string(repo_root.join("examples/test-examples.toml")).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read examples/test-examples.toml: {}", e); + std::process::exit(1); + }); + let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to parse examples/test-examples.toml: {}", e); + std::process::exit(1); + }); + + let mut pairs = Vec::new(); + for (example, cases) in &config.test { + for case in cases { + pairs.push((example.clone(), case.command.clone())); + } + } + 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() +} + +/// 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()?; |
