diff options
Diffstat (limited to '.run/src/bin')
25 files changed, 933 insertions, 1685 deletions
diff --git a/.run/src/bin/build-all.ps1 b/.run/src/bin/build-all.ps1 deleted file mode 100644 index 4f35ed8..0000000 --- a/.run/src/bin/build-all.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$starting_dir = Get-Location -Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object { - $project_dir = $_.DirectoryName - Push-Location $project_dir - cargo build - Pop-Location -} -Set-Location $starting_dir diff --git a/.run/src/bin/build-all.sh b/.run/src/bin/build-all.sh deleted file mode 100644 index 2036b41..0000000 --- a/.run/src/bin/build-all.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -find . -name "Cargo.toml" -type f | while read -r cargo_file; do - project_dir=$(dirname "$cargo_file") - (cd "$project_dir" && cargo build) -done diff --git a/.run/src/bin/ci.py b/.run/src/bin/ci.py new file mode 100644 index 0000000..6234a19 --- /dev/null +++ b/.run/src/bin/ci.py @@ -0,0 +1,75 @@ +"""Full CI orchestration for the mingling project. + +Runs every `cargo ci` step in order: lock the workspace, run all checks, +refresh the generated artifacts, then unlock. The final `git-unlock` doubles +as the idempotency check: it fails with a non-zero exit code when the run +left the working tree dirty. + +The script locates the git repository root and runs with it as the working +directory, so it can be invoked from anywhere inside the repo. +""" + +import os +import subprocess +import sys +from pathlib import Path + +# The pipeline steps, in execution order, as (command, args) pairs. +STEPS: list[tuple[str, list[str]]] = [ + ("git-lock", []), + ("report-clean", []), + ("build-check", []), + ("clippy-check", []), + ("test-all", []), + ("example-check", []), + ("docs-check", []), + ("example-refresh", []), + ("docsify-refresh", []), + ("features-refresh", []), + # Idempotency check: exits non-zero if CI contaminated the workspace, and + # prints the diff of the contamination before restoring. + ("git-unlock", ["--show-diff"]), +] + + +def find_repo_root() -> Path: + """Return the nearest ancestor directory containing `.git`.""" + current = Path.cwd() + for directory in (current, *current.parents): + if (directory / ".git").is_dir(): + return directory + raise SystemExit("error: not inside a git repository") + + +def main() -> int: + root = find_repo_root() + os.chdir(root) + + # Signature banner: docs/res/ci_banner.txt, relative to this script + # (.run/src/bin -> four levels up is the repo root). + banner = ( + Path(__file__).resolve().parent.parent.parent.parent + / "docs" + / "res" + / "ci_banner.txt" + ) + try: + print(banner.read_text(encoding="utf-8"), end="") + except OSError: + pass + + for command, args in STEPS: + print(f"==> cargo ci {' '.join([command, *args])}") + result = subprocess.run(["cargo", "ci", command, *args], check=False) + if result.returncode != 0: + print( + f"error: step `{command}` failed with exit code {result.returncode}", + file=sys.stderr, + ) + return result.returncode + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.run/src/bin/ci.rs b/.run/src/bin/ci.rs deleted file mode 100644 index 954034c..0000000 --- a/.run/src/bin/ci.rs +++ /dev/null @@ -1,239 +0,0 @@ -use std::io::Write as _; -use std::process::exit; - -use arg_picker::{Picker, macros::arg}; -use tools::{ - cargo_tomls, crate_name_from, eprintln_cargo_style, println_cargo_style, run_cmd, run_parallel, -}; - -fn get_ignore_dirs() -> Vec<String> { - vec![ - ".temp".to_string(), - "mling/res".to_string(), - "mling\\res".to_string(), - ] -} - -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 - -If no specific options are given, all checks are run. - " - ); -} - -fn main() { - #[cfg(windows)] - 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() - .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![help: bool, 'h']) - .unwrap(); - - if help { - print_help(); - return; - } - - let any_specified = test_docs || refresh_docs || test_codes; - let run_all = !any_specified; - - let needs_commit_temp = !dirty && !{ run_cmd!("git diff-index --quiet HEAD --").is_ok() }; - - if needs_commit_temp { - if auto_yes { - run_cmd!("git add .").unwrap(); - run_cmd!("git commit -m \"[DO NOT PUSH] CI TEMP [DO NOT PUSH]\"").unwrap(); - } else { - print!("Working tree is not clean, temporarily commit? [y/N]:"); - std::io::stdout().flush().unwrap(); - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap(); - let input = input.trim(); - if input == "y" || input == "Y" || input == "yes" || input == "Yes" { - run_cmd!("git add .").unwrap(); - run_cmd!("git commit -m \"[DO NOT PUSH] CI TEMP [DO NOT PUSH]\"").unwrap(); - } else { - eprintln_cargo_style!("Aborting."); - exit(2) - } - } - } - - if let Err(exit_code) = ci(test_docs, test_codes, run_all) { - restore_workspace(needs_commit_temp).unwrap(); - exit(exit_code) - } - - if !dirty { - let is_worktree_clean = run_cmd!("git diff-index --quiet HEAD --").is_ok(); - if !is_worktree_clean { - eprintln_cargo_style!("The repository was contaminated during CI, failing!"); - - // Print git status - println!(); - let _ = run_cmd!("git status"); - - if needs_commit_temp { - restore_workspace(true).unwrap(); - } - exit(1) - } - } - - println_cargo_style!("Done: All check passed!"); - - if needs_commit_temp { - restore_workspace(true).unwrap(); - } -} - -fn restore_workspace(undo_commit: bool) -> Result<(), i32> { - run_cmd!("git reset --hard --quiet")?; - if undo_commit { - run_cmd!("git reset --soft HEAD~1 --quiet")?; - run_cmd!("git reset --quiet")?; - } - 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()?; - } - - 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); - } - - println_cargo_style!("Phase: Test all examples"); - if let Err(code) = test_examples() { - exit_code = exit_code.max(code); - } - - println_cargo_style!("Phase: Check all documentation is up to date"); - if let Err(code) = docs_refresh() { - exit_code = exit_code.max(code); - } - - if exit_code != 0 { - return Err(exit_code); - } - } - - run_cmd!("git add --renormalize .")?; - - Ok(()) -} - -fn test_examples() -> Result<(), i32> { - run_cmd!("cargo run --manifest-path .run/Cargo.toml --color always --bin test-examples") -} - -fn test_docs_code_blocks() -> Result<(), i32> { - run_cmd!( - "cargo run --manifest-path .run/Cargo.toml --color always --bin test-all-markdown-code" - ) -} - -fn build_all() -> Result<(), i32> { - let ignore_dirs = get_ignore_dirs(); - let cargo_tomls = cargo_tomls(); - let mut tasks = Vec::new(); - for cargo_toml in cargo_tomls { - let path = cargo_toml.parent().unwrap_or(std::path::Path::new("")); - let path_str = path.to_string_lossy(); - if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { - continue; - } - let label = format!("Build: {}", cargo_toml.to_string_lossy()); - let crate_name = crate_name_from(&cargo_toml); - let cmd = format!( - "cargo build --manifest-path {} --color always", - cargo_toml.to_string_lossy() - ); - tasks.push((label, crate_name, cmd)); - } - run_parallel("Building", tasks) -} - -fn clippy_all() -> Result<(), i32> { - let ignore_dirs = get_ignore_dirs(); - let cargo_tomls = cargo_tomls(); - let mut tasks = Vec::new(); - for cargo_toml in cargo_tomls { - let path = cargo_toml.parent().unwrap_or(std::path::Path::new("")); - let path_str = path.to_string_lossy(); - if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { - continue; - } - let label = format!("Clippy: {}", cargo_toml.to_string_lossy()); - let crate_name = crate_name_from(&cargo_toml); - let cmd = format!( - "cargo clippy --manifest-path {} --color always -- -D warnings", - cargo_toml.to_string_lossy() - ); - tasks.push((label, crate_name, cmd)); - } - run_parallel("Clippy", tasks) -} - -fn test_all() -> Result<(), i32> { - let ignore_dirs = get_ignore_dirs(); - let cargo_tomls = cargo_tomls(); - let mut tasks = Vec::new(); - for cargo_toml in cargo_tomls { - let path = cargo_toml.parent().unwrap_or(std::path::Path::new("")); - let path_str = path.to_string_lossy(); - if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { - continue; - } - let label = format!("Testing: {}", cargo_toml.to_string_lossy()); - let crate_name = crate_name_from(&cargo_toml); - let cmd = format!( - "cargo test --manifest-path {} --color always", - cargo_toml.to_string_lossy() - ); - tasks.push((label, crate_name, cmd)); - } - run_parallel("Testing", tasks) -} - -fn docs_refresh() -> Result<(), i32> { - println_cargo_style!("Refresh: document at `./docs/`"); - - run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin docs-code-box-fix")?; - run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin docsify-sidebar-gen")?; - run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin refresh-docs")?; - run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin refresh-feature-mod")?; - run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin sync-examples")?; - run_cmd!("cargo fmt")?; - - Ok(()) -} diff --git a/.run/src/bin/clippy.ps1 b/.run/src/bin/clippy.ps1 deleted file mode 100644 index 1858873..0000000 --- a/.run/src/bin/clippy.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$starting_dir = Get-Location -Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object { - $project_dir = $_.DirectoryName - Push-Location $project_dir - cargo clippy --quiet - Pop-Location -} -Set-Location $starting_dir diff --git a/.run/src/bin/clippy.sh b/.run/src/bin/clippy.sh deleted file mode 100644 index b393545..0000000 --- a/.run/src/bin/clippy.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -find . -name "Cargo.toml" -type f | while read -r cargo_file; do - project_dir=$(dirname "$cargo_file") - (cd "$project_dir" && cargo clippy --quiet) -done 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/deploy-api-docs.rs b/.run/src/bin/deploy-api-docs.rs index ac4486e..961eb04 100644 --- a/.run/src/bin/deploy-api-docs.rs +++ b/.run/src/bin/deploy-api-docs.rs @@ -6,9 +6,8 @@ use tools::{println_cargo_style, run_cmd}; const OUTPUT_DIR: &str = "docs/api-docs"; fn main() { - let (using_docsrs, open) = Picker::from_args() + let using_docsrs = Picker::from_args() .pick_or_default(&arg![docsrs: bool]) - .pick_or_default(&arg![open: bool, 'O']) .unwrap(); let repo_root = find_git_repo().expect("Failed to find git repository root"); @@ -28,15 +27,13 @@ fn main() { // Build cargo doc command let cmd = if using_docsrs { format!( - "cargo +nightly rustdoc {} --features \"{}\" -p mingling --target-dir \"{}\" --color always -- --cfg docsrs", - if open { "--open" } else { "" }, + "cargo +nightly rustdoc --features \"{}\" -p mingling --target-dir \"{}\" --color always -- --cfg docsrs", features_arg, output_path.join("target").to_string_lossy() ) } else { format!( - "cargo doc --no-deps {} --features \"{}\" -p mingling --target-dir \"{}\" --color always", - if open { "--open" } else { "" }, + "cargo doc --no-deps --features \"{}\" -p mingling --target-dir \"{}\" --color always", features_arg, output_path.join("target").to_string_lossy() ) diff --git a/.run/src/bin/doc-nightly.ps1 b/.run/src/bin/doc-nightly.ps1 deleted file mode 100644 index 3d8289e..0000000 --- a/.run/src/bin/doc-nightly.ps1 +++ /dev/null @@ -1 +0,0 @@ -cargo dev_tool deploy-api-docs -- --docsrs --open diff --git a/.run/src/bin/doc-nightly.sh b/.run/src/bin/doc-nightly.sh deleted file mode 100644 index 10f9021..0000000 --- a/.run/src/bin/doc-nightly.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -cargo dev_tool deploy-api-docs -- --docsrs --open diff --git a/.run/src/bin/doc.ps1 b/.run/src/bin/doc.ps1 deleted file mode 100644 index 7c3f222..0000000 --- a/.run/src/bin/doc.ps1 +++ /dev/null @@ -1 +0,0 @@ -cargo dev_tool deploy-api-docs -- --open diff --git a/.run/src/bin/doc.sh b/.run/src/bin/doc.sh deleted file mode 100755 index 8c809b7..0000000 --- a/.run/src/bin/doc.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -cargo dev_tool deploy-api-docs -- --open diff --git a/.run/src/bin/docs-code-box-fix.rs b/.run/src/bin/docs-code-box-fix.rs deleted file mode 100644 index 21d2cce..0000000 --- a/.run/src/bin/docs-code-box-fix.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::fs; -use std::path::Path; - -use tools::println_cargo_style; - -/// Docsify code blocks require that blank lines before and after code blocks are not completely empty, -/// but must contain at least one space, otherwise code block rendering will have issues. -/// -/// This tool scans all `.md` files in the docs directory, -/// and replaces completely empty lines before and after code blocks with blank lines containing a single space. -const DOCS_DIR: &str = "./docs"; - -fn main() { - println_cargo_style!("Fixing: code box empty lines in docs/**/*.md ..."); - let repo_root = find_git_repo().expect("Cannot find git repo root"); - let docs_dir = repo_root.join(DOCS_DIR); - - let mut fixed_count = 0; - let mut file_count = 0; - - collect_md_files(&docs_dir, &mut |path| { - if let Some(name) = path.file_name() { - let name = name.to_string_lossy(); - if name.to_lowercase() == "_sidebar.md" { - return; - } - } - - let content = fs::read_to_string(path).unwrap_or_default(); - if content.is_empty() { - return; - } - - let new_content = fix_code_box_empty_lines(&content); - if new_content != content { - fs::write(path, &new_content).unwrap(); - println_cargo_style!("Fixed: {}", path.display()); - fixed_count += 1; - } - file_count += 1; - }); - - println_cargo_style!( - "Done: Scanned {} files, fixed {} files.", - file_count, - fixed_count - ); -} - -fn fix_code_box_empty_lines(content: &str) -> String { - let mut result = String::new(); - let lines: Vec<&str> = content.lines().collect(); - let len = lines.len(); - - let mut i = 0; - while i < len { - let line = lines[i]; - - // detect beginning of code block: beginning with ``` - if line.trim_start().starts_with("```") { - // record the beginning line of the code block - result.push_str(line); - result.push('\n'); - i += 1; - - // find the end of the code block - let mut found_end = false; - let code_start = i; // record starting position of code content - let mut code_end = len; // index of code block end line - - while i < len { - let cline = lines[i]; - if cline.trim_start().starts_with("```") && cline.trim() != "" { - // this is the closing marker - code_end = i; - found_end = true; - break; - } - i += 1; - } - - // check the blank line before the code block - // if result ends with \n\n, add a space to turn it into \n \n - ensure_space_before_code_block(&mut result); - - // output code content - for code_line in lines.iter().take(code_end).skip(code_start) { - if code_line.is_empty() { - result.push(' '); - } else { - result.push_str(code_line); - } - result.push('\n'); - } - - if found_end { - result.push_str(lines[code_end]); - result.push('\n'); - i += 1; - - // check the blank line after the code block - // if the next line is blank, change it to one with a space - if i < len && lines[i].trim().is_empty() && lines[i].is_empty() { - // skip the original blank line, write " \n" - result.push(' '); - result.push('\n'); - i += 1; - } - } - } else { - result.push_str(line); - result.push('\n'); - i += 1; - } - } - - // remove trailing newlines - while result.ends_with('\n') { - result.pop(); - } - result.push('\n'); - - result -} - -/// ensure there is a blank line with a space before the code block -fn ensure_space_before_code_block(result: &mut String) { - // if result ends with \n\n, - // turn it into \n \n - let len = result.len(); - if len >= 2 && result[len - 2..] == *"\n\n" { - // insert a space before the last \n - result.insert(len - 1, ' '); - } -} - -/// recursively collect all .md files in the docs directory -fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_md_files(&path, callback); - } else if path.extension().is_some_and(|ext| ext == "md") { - callback(&path); - } - } - } -} - -fn find_git_repo() -> Option<std::path::PathBuf> { - let mut current_dir = std::env::current_dir().ok()?; - - loop { - let git_dir = current_dir.join(".git"); - if git_dir.exists() && git_dir.is_dir() { - return Some(current_dir); - } - - if !current_dir.pop() { - break; - } - } - - None -} diff --git a/.run/src/bin/docsify-sidebar-gen.rs b/.run/src/bin/docsify-sidebar-gen.rs deleted file mode 100644 index 5beda7f..0000000 --- a/.run/src/bin/docsify-sidebar-gen.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::collections::BTreeMap; -use std::fmt::Write; -use std::path::{Path, PathBuf}; - -use tools::println_cargo_style; - -const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n"; - -fn main() { - println_cargo_style!("Refresh: _sidebar.md"); - gen_all_sidebars(); -} - -/// Find all README.md under docs/, treat each as a site, and generate _sidebar.md for it. -fn gen_all_sidebars() { - let repo_root = find_git_repo().unwrap(); - let docs_root = repo_root.join("docs"); - - let readme_paths = find_all_readmes(&docs_root); - - for readme_path in &readme_paths { - let site_root = readme_path.parent().unwrap(); - - let content_dir = find_content_dir(site_root); - - if let Some(content_dir) = content_dir { - let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD); - - let sidebar_path = site_root.join("_sidebar.md"); - std::fs::write(&sidebar_path, lines).unwrap(); - println_cargo_style!("Generated: {}", sidebar_path.display()); - } - } -} - -/// Recursively find all README.md files under a directory. -fn find_all_readmes(dir: &Path) -> Vec<PathBuf> { - let mut results = Vec::new(); - if let Ok(read_dir) = std::fs::read_dir(dir) { - let mut entries: Vec<_> = read_dir.flatten().collect(); - entries.sort_by_key(|e| e.path()); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - results.extend(find_all_readmes(&path)); - } else if path.file_name().is_some_and(|n| n == "README.md") { - results.push(path); - } - } - } - results -} - -/// Find the content directory for a site: -/// 1. Prefer `pages/` if it exists (backward compatible) -/// 2. Fall back to the first subdirectory that contains .md files -fn find_content_dir(site_root: &Path) -> Option<PathBuf> { - // Try pages/ first - let pages_dir = site_root.join("pages"); - if pages_dir.exists() && pages_dir.is_dir() { - return Some(pages_dir); - } - - // Fall back to any subdirectory containing .md files - if let Ok(read_dir) = std::fs::read_dir(site_root) { - let mut entries: Vec<_> = read_dir.flatten().collect(); - 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); - } - } - } - - None -} - -/// Check if a directory (recursively) contains any .md files. -fn has_markdown_files(dir: &Path) -> bool { - if let Ok(read_dir) = std::fs::read_dir(dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - if path.is_dir() { - if has_markdown_files(&path) { - return true; - } - } else if path.extension().is_some_and(|ext| ext == "md") { - return true; - } - } - } - false -} - -/// Build sidebar content: scan .md files in `pages_dir` and return a formatted sidebar string -fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String { - let mut lines = String::from(sidebar_head); - - // Collect and sort entries at root level first - let mut root_files: Vec<SidebarEntry> = Vec::new(); - // Subdirectory name -> its files - let mut sub_dirs: BTreeMap<String, Vec<SidebarEntry>> = BTreeMap::new(); - - if let Ok(read_dir) = std::fs::read_dir(pages_dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - if path.is_dir() { - let dir_name = entry.file_name().to_string_lossy().to_string(); - let entries = collect_markdown_files(&path, base_dir); - if !entries.is_empty() { - // Check for .name file to override directory display name - let display_name = get_directory_display_name(&path, &dir_name); - sub_dirs.insert(display_name, entries); - } - } else if path.extension().is_some_and(|ext| ext == "md") { - let title = extract_title(&path); - let relative = path - .strip_prefix(base_dir) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - let link = relative - .strip_suffix(".md") - .unwrap_or(&relative) - .to_string(); - root_files.push(SidebarEntry { title, link }); - } - } - } - - // Sort root files — natural order (1, 2, ..., 10, 11) - root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link)); - - // Append root-level files - for f in &root_files { - let _ = writeln!(lines, "* [{}]({})", f.title, f.link); - } - - // Append subdirectory groups - for (dir_name, entries) in &sub_dirs { - let mut sorted_entries = entries.clone(); - sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link)); - - // Directory header with 2-space indent - let _ = writeln!(lines, "* {dir_name}"); - for f in &sorted_entries { - let _ = writeln!(lines, " * [{}]({})", f.title, f.link); - } - } - - lines -} - -#[derive(Clone)] -struct SidebarEntry { - title: String, - link: String, -} - -/// Collect all `.md` files directly under `dir` -fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec<SidebarEntry> { - let mut entries = Vec::new(); - - if let Ok(read_dir) = std::fs::read_dir(dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "md") { - let title = extract_title(&path); - let relative = path - .strip_prefix(base_dir) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - let link = relative - .strip_suffix(".md") - .unwrap_or(&relative) - .to_string(); - entries.push(SidebarEntry { title, link }); - } - } - } - - entries -} - -/// Extract title from the first line `<h1 align="center">TITLE</h1>`. -/// Fallback to filename stem. -fn extract_title(path: &Path) -> String { - let content = std::fs::read_to_string(path).unwrap_or_default(); - if let Some(first_line) = content.lines().next() { - let trimmed = first_line.trim(); - // Find `>TITLE<` between `<h1 align="center">` and `</h1>` - if let Some(start) = trimmed.find('>') { - let after_start = &trimmed[start + 1..]; - if let Some(end) = after_start.find('<') { - return after_start[..end].to_string(); - } - } - } - // Fallback: use file stem - path.file_stem().map_or_else( - || "Untitled".to_string(), - |s| s.to_string_lossy().to_string(), - ) -} - -/// Read `.name` file inside a directory to get its display name for the sidebar. -/// Falls back to the directory name itself if no `.name` file exists. -fn get_directory_display_name(dir_path: &std::path::Path, fallback: &str) -> String { - let name_file = dir_path.join(".name"); - if name_file.exists() && name_file.is_file() { - std::fs::read_to_string(&name_file) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| fallback.to_string()) - } else { - fallback.to_string() - } -} - -fn find_git_repo() -> Option<std::path::PathBuf> { - let mut current_dir = std::env::current_dir().ok()?; - - loop { - let git_dir = current_dir.join(".git"); - if git_dir.exists() && git_dir.is_dir() { - return Some(current_dir); - } - - if !current_dir.pop() { - break; - } - } - - None -} - -/// Natural (numeric-aware) comparison for sidebar links. -/// -/// Files prefixed with a number (e.g. `1-getting-started`) are sorted by that number; -/// files without a numeric prefix fall back to lexicographic order (after numbers). -fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { - let num_a = extract_leading_number(a); - let num_b = extract_leading_number(b); - num_a.cmp(&num_b).then_with(|| a.cmp(b)) -} - -/// Extract the leading numeric prefix from a sidebar link path. -/// -/// Looks at the filename stem (after the last `/`) for a number before the first `-`. -/// Returns `usize::MAX` for entries without a numeric prefix. -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; - } - 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/package-all.rs b/.run/src/bin/package-all.rs index 5d7cbbb..ecdd133 100644 --- a/.run/src/bin/package-all.rs +++ b/.run/src/bin/package-all.rs @@ -1,8 +1,10 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use flate2::read::GzDecoder; use serde::Deserialize; use tar::Archive; +use toml::Table as TomlTable; use tools::{ dependency_order::find_workspace_root, eprintln_cargo_style, println_cargo_style, run_cmd_capture_with_dir, wprintln_cargo_style, @@ -76,14 +78,12 @@ fn main() { } // Build version map: crate_name -> version - let mut version_map: std::collections::HashMap<String, String> = - std::collections::HashMap::new(); + let mut version_map: HashMap<String, String> = HashMap::new(); for m in &members { version_map.insert(m.name.clone(), m.version.clone()); } // Collect unique member directories that need to be copied - // Compute relative path by stripping workspace_root prefix let mut member_dirs: Vec<PathBuf> = Vec::new(); for m in &members { let dir = Path::new(&m.manifest_path) @@ -104,20 +104,17 @@ fn main() { // 4. Copy files to the temp directory, preserving the workspace directory structure println_cargo_style!("Copy: project structure to .temp/pre-release/"); - // Copy .cargo directory copy_dir( &workspace_root.join(".cargo"), &pre_release_dir.join(".cargo"), ); - // Copy each member directory for dir in &member_dirs { let src = workspace_root.join(dir); let dst = pre_release_dir.join(dir); copy_dir(&src, &dst); } - // Copy root Cargo.toml and Cargo.lock copy_file( &workspace_root.join("Cargo.toml"), &pre_release_dir.join("Cargo.toml"), @@ -127,24 +124,20 @@ fn main() { &pre_release_dir.join("Cargo.lock"), ); - // 5. Replace workspace dependency paths with version numbers in the root Cargo.toml - // For workspace-member crates, keep path so cargo can resolve locally during - // `cargo package --workspace`. `cargo package` automatically converts path deps - // to version deps in the final .crate manifest. - println_cargo_style!("Patch: resolve workspace dependency versions"); + // 5. Fully resolve ALL workspace inheritance in every member's Cargo.toml, + // so each crate becomes monomorphic (no `workspace = true` references). + // Then strip `[workspace.dependencies]` and `[workspace.package]` from the + // root Cargo.toml, since they are no longer needed. + println_cargo_style!("Resolve: inline all workspace inheritance"); - let pre_release_cargo = pre_release_dir.join("Cargo.toml"); - let content = std::fs::read_to_string(&pre_release_cargo) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", pre_release_cargo.display())); + // Parse workspace config from the COPIED root Cargo.toml + let root_cargo_path = pre_release_dir.join("Cargo.toml"); + let root_content = std::fs::read_to_string(&root_cargo_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", root_cargo_path.display())); - let patched = patch_workspace_deps(&content, &version_map); + let (ws_package, ws_deps) = parse_workspace_config(&root_content); - std::fs::write(&pre_release_cargo, &patched) - .unwrap_or_else(|e| panic!("failed to write {}: {e}", pre_release_cargo.display())); - - // Member cargo.toml files: replace direct `path = "..."` deps (pointing to other - // workspace members) with version qualification, so `cargo package` can produce - // a valid .crate without path dependencies. + // Resolve each member's Cargo.toml for dir in &member_dirs { let member_cargo = pre_release_dir.join(dir).join("Cargo.toml"); if !member_cargo.exists() { @@ -152,13 +145,18 @@ fn main() { } let member_content = std::fs::read_to_string(&member_cargo) .unwrap_or_else(|e| panic!("failed to read {}: {e}", member_cargo.display())); - let member_patched = patch_member_path_deps(&member_content, &version_map); - if member_patched != member_content { - std::fs::write(&member_cargo, &member_patched) - .unwrap_or_else(|e| panic!("failed to write {}: {e}", member_cargo.display())); - } + let resolved = + resolve_member_manifest(&member_content, dir, &ws_package, &ws_deps, &version_map); + std::fs::write(&member_cargo, &resolved) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", member_cargo.display())); } + // Strip [workspace.dependencies], [workspace.package], and root [package] + // from root Cargo.toml, making it a pure virtual manifest. + let stripped = strip_workspace_config(&root_content); + std::fs::write(&root_cargo_path, &stripped) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", root_cargo_path.display())); + println_cargo_style!("Package: running cargo package --workspace --no-verify"); // 6. Run cargo package in the temp directory @@ -274,6 +272,22 @@ fn main() { // Also remove Cargo.lock — standalone crate doesn't need it for publish let _ = std::fs::remove_file(target_dir.join("Cargo.lock")); + // Append an empty [workspace] section so each crate is a valid workspace root + let cargo_toml_path = target_dir.join("Cargo.toml"); + if cargo_toml_path.exists() { + let mut cargo_content = std::fs::read_to_string(&cargo_toml_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display())); + // Only append if there isn't already a [workspace] section + if !cargo_content.contains("\n[workspace]\n") + && !cargo_content.ends_with("\n[workspace]\n") + { + cargo_content.push_str("\n[workspace]\n"); + std::fs::write(&cargo_toml_path, &cargo_content).unwrap_or_else(|e| { + panic!("failed to write {}: {e}", cargo_toml_path.display()) + }); + } + } + println_cargo_style!("Export: {}", crate_dir_name); } @@ -286,57 +300,289 @@ fn main() { } } -/// Replace path-based workspace dependencies with version strings. -/// -/// Keeps the path form so that `cargo package --workspace` resolves to local workspace -/// members, but also adds `version = "..."` so the generated .crate has the correct -/// version dependency. -fn patch_workspace_deps( +/// Parse `[workspace.package]` and `[workspace.dependencies]` from the root Cargo.toml. +/// Returns (package_fields, dep_values). +fn parse_workspace_config( + content: &str, +) -> (HashMap<String, String>, HashMap<String, toml::Value>) { + let table: TomlTable = content.parse().expect("failed to parse root Cargo.toml"); + + let mut package_fields = HashMap::new(); + if let Some(workspace) = table.get("workspace").and_then(|w| w.as_table()) + && let Some(pkg_table) = workspace.get("package").and_then(|p| p.as_table()) + { + for (k, v) in pkg_table { + if let Some(s) = v.as_str() { + package_fields.insert(k.clone(), s.to_string()); + } + } + } + + let mut dep_values = HashMap::new(); + if let Some(workspace) = table.get("workspace").and_then(|w| w.as_table()) + && let Some(deps_table) = workspace.get("dependencies").and_then(|d| d.as_table()) + { + for (k, v) in deps_table { + dep_values.insert(k.clone(), v.clone()); + } + } + + (package_fields, dep_values) +} + +/// Serialize a `toml::Value` into Cargo-toml-compatible inline representation. +fn toml_value_str(v: &toml::Value) -> String { + match v { + toml::Value::String(s) => format!("\"{}\"", s), + toml::Value::Table(t) => { + let items: Vec<String> = t + .iter() + .map(|(k, val)| format!("{} = {}", k, toml_value_str(val))) + .collect(); + format!("{{ {} }}", items.join(", ")) + } + toml::Value::Array(a) => { + let items: Vec<String> = a.iter().map(toml_value_str).collect(); + format!("[{}]", items.join(", ")) + } + toml::Value::Boolean(b) => b.to_string(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Datetime(dt) => format!("\"{}\"", dt), + } +} + +/// Compute the relative path from `member_rel_dir` to `target_path`. +/// Both are relative to workspace root. +/// e.g. member_rel_dir="mingling", target_path="mingling_core" → "../mingling_core" +fn make_path_relative_to_member(target_path: &str, member_rel_dir: &Path) -> String { + if member_rel_dir.as_os_str().is_empty() || member_rel_dir == Path::new(".") { + return target_path.to_string(); + } + let depth = member_rel_dir.components().count(); + let mut result = PathBuf::new(); + for _ in 0..depth { + result.push(".."); + } + result.push(target_path); + result.to_string_lossy().to_string() +} + +/// Resolve a dependency definition from `[workspace.dependencies]` to an inline string. +/// If the definition contains a path to a workspace member, add `version = "..."` +/// and adjust the path to be relative to the member's directory. +fn resolve_dep_def( + dep_name: &str, + dep_def: &toml::Value, + member_rel_dir: &Path, + version_map: &HashMap<String, String>, +) -> String { + match dep_def { + toml::Value::String(ver) => { + format!("\"{}\"", ver) + } + toml::Value::Table(t) => { + let mut resolved = t.clone(); + let has_path = t.contains_key("path"); + let is_ws_member = version_map.contains_key(dep_name); + + // Fix path to be relative to member's directory + if has_path && let Some(path_val) = t.get("path").and_then(|v| v.as_str()) { + let rel = make_path_relative_to_member(path_val, member_rel_dir); + resolved.insert("path".to_string(), toml::Value::String(rel)); + } + + // Add version for workspace member path deps + if has_path + && is_ws_member + && let Some(version) = version_map.get(dep_name) + { + resolved.insert("version".to_string(), toml::Value::String(version.clone())); + } + + let items: Vec<String> = resolved + .iter() + .map(|(k, val)| format!("{} = {}", k, toml_value_str(val))) + .collect(); + format!("{{ {} }}", items.join(", ")) + } + _ => toml_value_str(dep_def), + } +} + +/// Merge an inline `{ workspace = true, optional = true, ... }` with the workspace definition. +/// Returns the full resolved dependency value string (without the leading `dep_name = `). +fn merge_inline_dep( + inline_rest: &str, + dep_name: &str, + dep_def: &toml::Value, + member_rel_dir: &Path, + version_map: &HashMap<String, String>, +) -> String { + // inline_rest is the part after `=`: `{ workspace = true, optional = true }` + let inner = inline_rest + .trim() + .strip_prefix('{') + .and_then(|s| s.strip_suffix('}')) + .unwrap_or(""); + + match dep_def { + toml::Value::String(ver) => { + // Workspace def is just a version string + // Collect extras: everything except `workspace = true` + let extras: Vec<&str> = inner + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty() && *s != "workspace = true") + .collect(); + + if extras.is_empty() { + format!("\"{}\"", ver) + } else { + // Serialize as inline table: version + extras + let mut parts = vec![format!("version = \"{}\"", ver)]; + parts.extend(extras.iter().map(|s| s.to_string())); + format!("{{ {} }}", parts.join(", ")) + } + } + toml::Value::Table(t) => { + // Start from workspace def + let mut merged = t.clone(); + + // Fix path to be relative to member's directory + if let Some(path_val) = t.get("path").and_then(|v| v.as_str()) { + let rel = make_path_relative_to_member(path_val, member_rel_dir); + merged.insert("path".to_string(), toml::Value::String(rel)); + } + + // If this dep is a workspace member with a path dep, add version + if t.contains_key("path") + && version_map.contains_key(dep_name) + && let Some(version) = version_map.get(dep_name) + { + merged.insert("version".to_string(), toml::Value::String(version.clone())); + } + + // Apply extra fields from the inline + for piece in inner.split(',').map(|s| s.trim()) { + let piece = piece.trim(); + if piece.is_empty() || piece == "workspace = true" { + continue; + } + // Parse `key = value` pairs + if let Some((raw_key, raw_val)) = piece.split_once('=') { + let k = raw_key.trim(); + let v = raw_val.trim(); + if k.is_empty() { + continue; + } + // Try to infer the value type + if v == "true" { + merged.insert(k.to_string(), toml::Value::Boolean(true)); + } else if v == "false" { + merged.insert(k.to_string(), toml::Value::Boolean(false)); + } else if v.starts_with('"') && v.ends_with('"') { + merged.insert( + k.to_string(), + toml::Value::String(v[1..v.len() - 1].to_string()), + ); + } else if v.starts_with('[') && v.ends_with(']') { + // Simple array parsing: strings only + let arr: Vec<toml::Value> = v[1..v.len() - 1] + .split(',') + .map(|s| { + let s = s.trim().trim_matches('"'); + toml::Value::String(s.to_string()) + }) + .collect(); + merged.insert(k.to_string(), toml::Value::Array(arr)); + } else if let Ok(n) = v.parse::<i64>() { + merged.insert(k.to_string(), toml::Value::Integer(n)); + } else if let Ok(f) = v.parse::<f64>() { + merged.insert(k.to_string(), toml::Value::Float(f)); + } else { + // Treat as string + merged.insert(k.to_string(), toml::Value::String(v.to_string())); + } + } + } + + let items: Vec<String> = merged + .iter() + .map(|(k, val)| format!("{} = {}", k, toml_value_str(val))) + .collect(); + format!("{{ {} }}", items.join(", ")) + } + _ => toml_value_str(dep_def), + } +} + +/// Resolve ALL workspace inheritance in a single member crate's Cargo.toml: +/// - `version.workspace = true` → `version = "0.3.0"` +/// - `dep.workspace = true` → inline the full definition from ws_deps +/// - `dep = { workspace = true, ... }` → merge with ws_deps definition +fn resolve_member_manifest( content: &str, - version_map: &std::collections::HashMap<String, String>, + member_rel_dir: &Path, + ws_package: &HashMap<String, String>, + ws_deps: &HashMap<String, toml::Value>, + version_map: &HashMap<String, String>, ) -> String { let mut result = String::new(); - let mut in_workspace_deps = false; + let mut in_package = false; + let mut in_section_with_deps = false; for line in content.lines() { let trimmed = line.trim(); - - if trimmed == "[workspace.dependencies]" { - in_workspace_deps = true; + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + + // Track sections + if trimmed.starts_with('[') { + in_package = trimmed == "[package]"; + in_section_with_deps = trimmed.starts_with("[dependencies") + || trimmed.starts_with("[build-dependencies") + || trimmed.starts_with("[dev-dependencies"); result.push_str(line); result.push('\n'); continue; } - // Detect end of workspace.dependencies section - if in_workspace_deps && trimmed.starts_with('[') { - in_workspace_deps = false; + // [package] section: resolve `field.workspace = true` + if in_package && trimmed.ends_with(".workspace = true") { + let key = trimmed.strip_suffix(".workspace = true").unwrap().trim(); + if let Some(value) = ws_package.get(key) { + result.push_str(&format!("{indent}{key} = \"{value}\"\n")); + continue; + } + // Also check workspace.dependencies (for fields like `version.workspace`) + // when the member has its own version field inherited from workspace.package } - if in_workspace_deps - && let Some(dep_name) = trimmed.split('=').next().map(|s| s.trim()) - && let Some(version) = version_map.get(dep_name) - { - let indent = line - .chars() - .take_while(|c| c.is_whitespace()) - .collect::<String>(); - - if trimmed.contains("path =") { - let path_value = extract_path_value(trimmed); - let patched_line: String = if let Some(pv) = path_value { - trimmed.replace( - &format!("path = \"{}\"", pv), - &format!("path = \"{}\", version = \"{}\"", pv, version), - ) - } else { - trimmed.to_string() - }; - result.push_str(&format!("{indent}{patched_line}\n")); - } else { - result.push_str(&format!("{indent}{dep_name} = \"{version}\"\n")); + // Dependency sections + if in_section_with_deps { + // Shorthand: `foo.workspace = true` + if trimmed.ends_with(".workspace = true") { + let key = trimmed.strip_suffix(".workspace = true").unwrap().trim(); + if let Some(dep_def) = ws_deps.get(key) { + let resolved = resolve_dep_def(key, dep_def, member_rel_dir, version_map); + result.push_str(&format!("{indent}{key} = {resolved}\n")); + continue; + } + } + + // Inline: `foo = { workspace = true, optional = true, ... }` + if let Some(eq_pos) = trimmed.find("= {") + && trimmed.contains("workspace = true") + { + let dep_name = trimmed[..eq_pos].trim(); + if let Some(dep_def) = ws_deps.get(dep_name) { + let after_eq = trimmed[eq_pos + 1..].trim(); + let merged = + merge_inline_dep(after_eq, dep_name, dep_def, member_rel_dir, version_map); + result.push_str(&format!("{indent}{dep_name} = {merged}\n")); + continue; + } } - continue; } result.push_str(line); @@ -346,62 +592,55 @@ fn patch_workspace_deps( result } -/// Extract the path value from a dependency line like: -/// `mingling_core = { path = "mingling_core", default-features = false }` -fn extract_path_value(line: &str) -> Option<String> { - let line = line.trim(); - if let Some(start) = line.find("path = \"") { - let after_path = &line[start + 8..]; - if let Some(end) = after_path.find('"') { - return Some(after_path[..end].to_string()); - } - } - None -} - -/// Patch a member crate's Cargo.toml: add `version = "..."` to direct `path = "..."` -/// dependencies that point to other workspace members. -fn patch_member_path_deps( - content: &str, - version_map: &std::collections::HashMap<String, String>, -) -> String { +/// Remove `[workspace.dependencies]`, `[workspace.package]`, and the root `[package]` +/// section from root Cargo.toml, making it a pure virtual manifest. +/// Keeps `[workspace]` with `members`, `resolver`, `exclude` so packaging still works. +fn strip_workspace_config(content: &str) -> String { let mut result = String::new(); - let mut in_deps = false; - let mut in_build_deps = false; + let mut in_ws_deps = false; + let mut in_ws_package = false; + let mut in_root_package = false; for line in content.lines() { let trimmed = line.trim(); - if trimmed == "[dependencies]" || trimmed.starts_with("[dependencies.") { - in_deps = true; - in_build_deps = false; - } else if trimmed == "[build-dependencies]" || trimmed.starts_with("[build-dependencies.") { - in_build_deps = true; - in_deps = false; - } else if trimmed.starts_with('[') { - in_deps = false; - in_build_deps = false; + if trimmed == "[workspace.dependencies]" { + in_ws_deps = true; + continue; } - - if (in_deps || in_build_deps) - && trimmed.contains("path = \"") - && !trimmed.contains("workspace = true") - && let Some(dep_name) = trimmed.split('=').next().map(|s| s.trim()) - && let Some(version) = version_map.get(dep_name.trim_end_matches(".workspace")) - && !trimmed.contains("version = \"") - { - let indent = line - .chars() - .take_while(|c| c.is_whitespace()) - .collect::<String>(); - let path_val = extract_path_value(trimmed).unwrap_or_default(); - let patched = trimmed.replace( - &format!("path = \"{path_val}\""), - &format!("path = \"{path_val}\", version = \"{version}\""), - ); - result.push_str(&format!("{indent}{patched}\n")); + if trimmed == "[workspace.package]" { + in_ws_package = true; continue; } + if trimmed == "[package]" && !in_ws_deps && !in_ws_package { + // Remove the root [package] section entirely (virtual manifest) + in_root_package = true; + continue; + } + + if in_ws_deps { + if trimmed.starts_with('[') { + in_ws_deps = false; + } else { + continue; + } + } + + if in_ws_package { + if trimmed.starts_with('[') { + in_ws_package = false; + } else { + continue; + } + } + + if in_root_package { + if trimmed.starts_with('[') { + in_root_package = false; + } else { + continue; + } + } result.push_str(line); result.push('\n'); diff --git a/.run/src/bin/refresh-docs.rs b/.run/src/bin/refresh-docs.rs deleted file mode 100644 index 82ef906..0000000 --- a/.run/src/bin/refresh-docs.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::path::Path; - -use just_fmt::snake_case; -use just_template::{Template, tmpl}; -use tools::println_cargo_style; - -const EXAMPLE_ROOT: &str = "./examples/"; -const OUTPUT_PATH: &str = "./mingling/src/example_docs.rs"; - -const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/example_docs.rs.tmpl"); - -fn main() { - gen_example_doc_module(); -} - -fn gen_example_doc_module() { - let mut template = Template::from(TEMPLATE_CONTENT); - let repo_root = find_git_repo().unwrap(); - let example_root = repo_root.join(EXAMPLE_ROOT); - let mut examples = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&example_root) { - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() - && file_type.is_dir() - { - let example_name = entry.file_name().to_string_lossy().to_string(); - // Ignore directories that don't start with "example-" - if !example_name.starts_with("example-") { - continue; - } - let example_content = ExampleContent::read(&example_name); - examples.push(example_content); - } - } - } - - examples.sort(); - - for example in examples { - tmpl!(template += { - examples { - ( - example_header = example.header, - example_import = example.cargo_toml, - example_code = example.code, - example_name = snake_case!(&example.name) - ) - } - }); - println_cargo_style!("Refresh: {}", example.name); - } - - let template_str = template.to_string(); - let template_str = template_str - .lines() - .map(str::trim_end) - .collect::<Vec<_>>() - .join("\n") - + "\n"; - std::fs::write(repo_root.join(OUTPUT_PATH), template_str).unwrap(); -} - -struct ExampleContent { - name: String, - header: String, - code: String, - cargo_toml: String, -} - -impl PartialOrd for ExampleContent { - fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { - Some(self.cmp(other)) - } -} - -impl Ord for ExampleContent { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.name.cmp(&other.name) - } -} - -impl PartialEq for ExampleContent { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - } -} - -impl Eq for ExampleContent {} - -impl ExampleContent { - pub fn read(name: &str) -> Self { - let repo = find_git_repo().unwrap(); - let cargo_toml = Self::read_cargo_toml(&repo, name); - let (header, code) = Self::read_header_and_code(&repo, name); - - let cargo_toml = cargo_toml - .lines() - .map(|line| format!("/// {line}")) - .collect::<Vec<_>>() - .join("\n"); - - let header = header - .lines() - .map(|line| format!("/// {line}")) - .collect::<Vec<_>>() - .join("\n"); - - let code = code - .lines() - .map(|line| format!("/// {line}")) - .collect::<Vec<_>>() - .join("\n"); - - ExampleContent { - name: name.to_string(), - header, - code, - cargo_toml, - } - } - - fn read_header_and_code(repo: &Path, name: &str) -> (String, String) { - let file_path = repo - .join(EXAMPLE_ROOT) - .join(name) - .join("src") - .join("main.rs"); - let content = std::fs::read_to_string(&file_path).unwrap_or_default(); - let mut lines = content.lines(); - let mut header = String::new(); - let mut code = String::new(); - - // Collect header lines (starting with //!) - for line in lines.by_ref() { - if line.trim_start().starts_with("//!") { - let trimmed = line.trim_start_matches("//!"); - header.push_str(trimmed); - header.push('\n'); - } else { - // First non-header line found, start collecting code - code.push_str(line); - code.push('\n'); - break; - } - } - - // Collect remaining code lines - for line in lines { - code.push_str(line); - code.push('\n'); - } - - (header.trim().to_string(), code.trim().to_string()) - } - - fn read_cargo_toml(repo: &Path, name: &str) -> String { - let file_path = repo.join(EXAMPLE_ROOT).join(name).join("Cargo.toml"); - - std::fs::read_to_string(&file_path).unwrap_or_default() - } -} - -fn find_git_repo() -> Option<std::path::PathBuf> { - let mut current_dir = std::env::current_dir().ok()?; - - loop { - let git_dir = current_dir.join(".git"); - if git_dir.exists() && git_dir.is_dir() { - return Some(current_dir); - } - - if !current_dir.pop() { - break; - } - } - - None -} diff --git a/.run/src/bin/refresh-feature-mod.rs b/.run/src/bin/refresh-feature-mod.rs deleted file mode 100644 index 2255dbc..0000000 --- a/.run/src/bin/refresh-feature-mod.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::collections::BTreeSet; -use std::path::Path; - -use just_fmt::snake_case; -use just_template::{tmpl, Template}; -use tools::println_cargo_style; - -const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml"; -const OUTPUT_PATH: &str = "./mingling/src/features.rs"; - -const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl"); - -fn main() { - gen_feature_module(); -} - -fn gen_feature_module() { - let repo_root = find_git_repo().unwrap(); - - let cargo_toml_path = repo_root.join(CARGO_TOML_PATH); - let output_path = repo_root.join(OUTPUT_PATH); - - let features = parse_features(&cargo_toml_path); - - let mut template = Template::from(TEMPLATE_CONTENT); - - for feat_name in &features { - let feat_const_name = snake_case!(feat_name).to_uppercase(); - - tmpl!(template += { - features { - ( - feat_name = feat_name, - feat_const_name = feat_const_name - ) - } - }); - println_cargo_style!("Refresh: feature `{}`", feat_name); - } - - let template_str = template.to_string(); - let template_str = template_str - .lines() - .map(str::trim_end) - .collect::<Vec<_>>() - .join("\n") - + "\n"; - std::fs::write(&output_path, template_str).unwrap(); - - println_cargo_style!("Written: features module to {}", OUTPUT_PATH); -} - -/// Parse all feature names from the `[features]` section of a Cargo.toml. -fn parse_features(cargo_toml_path: &Path) -> Vec<String> { - let content = std::fs::read_to_string(cargo_toml_path) - .unwrap_or_else(|e| panic!("Failed to read {}: {}", cargo_toml_path.display(), e)); - - let cargo_toml: toml::Value = content - .parse() - .unwrap_or_else(|e| panic!("Failed to parse {}: {}", cargo_toml_path.display(), e)); - - let features_table = cargo_toml - .get("features") - .and_then(|v| v.as_table()) - .unwrap_or_else(|| { - panic!( - "No [features] section found in {}", - cargo_toml_path.display() - ) - }); - - let mut feature_names: BTreeSet<String> = BTreeSet::new(); - for key in features_table.keys() { - feature_names.insert(key.clone()); - } - - let mut result: Vec<String> = feature_names.into_iter().collect(); - result.sort(); - result -} - -fn find_git_repo() -> Option<std::path::PathBuf> { - let mut current_dir = std::env::current_dir().ok()?; - - loop { - let git_dir = current_dir.join(".git"); - if git_dir.exists() && git_dir.is_dir() { - return Some(current_dir); - } - - if !current_dir.pop() { - break; - } - } - - None -} diff --git a/.run/src/bin/sync-examples.rs b/.run/src/bin/sync-examples.rs deleted file mode 100644 index 0923b33..0000000 --- a/.run/src/bin/sync-examples.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::fs; -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use tools::println_cargo_style; - -#[derive(Serialize)] -struct ExampleMeta { - id: String, - name: String, - icon: String, - category: String, - desc: String, - tags: Vec<String>, - files: Vec<String>, -} - -#[derive(Deserialize)] -struct PageToml { - example: PageTomlExample, -} - -#[derive(Deserialize)] -struct PageTomlExample { - id: String, - #[serde(default)] - name: String, - #[serde(default = "default_icon")] - icon: String, - #[serde(default)] - category: String, - #[serde(default)] - desc: String, - #[serde(default)] - tags: Vec<String>, - #[serde(default = "default_files")] - files: Vec<String>, -} - -fn default_icon() -> String { - "📦".to_string() -} - -fn default_files() -> Vec<String> { - vec!["Cargo.toml".to_string(), "src/main.rs".to_string()] -} - -fn main() { - #[cfg(windows)] - let _ = colored::control::set_virtual_terminal(true); - - let examples_dir = Path::new("examples"); - let output_dir = Path::new("docs/example-pages"); - fs::create_dir_all(output_dir).expect("failed to create docs/example-pages"); - - let mut examples: Vec<ExampleMeta> = Vec::new(); - - let entries = fs::read_dir(examples_dir).expect("failed to read examples/"); - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - - let id = dir_name.to_string(); - let page_toml_path = path.join("page.toml"); - - let meta = if page_toml_path.exists() { - match fs::read_to_string(&page_toml_path) - .map_err(|e| e.to_string()) - .and_then(|content| toml::from_str::<PageToml>(&content).map_err(|e| e.to_string())) - { - Ok(page) => { - let ex = page.example; - ExampleMeta { - id: if ex.id.is_empty() { id.clone() } else { ex.id }, - name: if ex.name.is_empty() { - id.clone() - } else { - ex.name - }, - icon: ex.icon, - category: ex.category, - desc: ex.desc, - tags: ex.tags, - files: if ex.files.is_empty() { - default_files() - } else { - ex.files - }, - } - } - Err(e) => { - eprintln!( - "Warning: failed to parse {}: {}", - page_toml_path.display(), - e - ); - continue; - } - } - } else { - continue; - }; - - examples.push(meta); - } - - // Sort: basic first, then alphabetical - examples.sort_by(|a, b| { - if a.id == "example-basic" { - return std::cmp::Ordering::Less; - } - if b.id == "example-basic" { - return std::cmp::Ordering::Greater; - } - a.id.cmp(&b.id) - }); - - let json = serde_json::to_string_pretty(&examples).expect("failed to serialize"); - let output_path = output_dir.join("examples.json"); - fs::write(&output_path, &json).expect("failed to write examples.json"); - - println_cargo_style!( - "Sync: {} examples -> {}", - examples.len(), - output_path.display() - ); -} diff --git a/.run/src/bin/test-all-markdown-code.rs b/.run/src/bin/test-all-markdown-code.rs deleted file mode 100644 index 1c0c9e2..0000000 --- a/.run/src/bin/test-all-markdown-code.rs +++ /dev/null @@ -1,261 +0,0 @@ -use std::collections::HashMap; -use std::env; -use std::path::{Path, PathBuf}; - -use colored::Colorize; -use indicatif::ProgressBar; -use tools::verify::{ - build_block, compute_block_hash, generate_build_rs, generate_cargo_toml, generate_main_rs, - is_block_testable, parse_code_blocks, write_summary_report, -}; -use tools::{eprintln_cargo_style, println_cargo_style}; - -/// Config from verified-docs.toml -#[derive(serde::Deserialize)] -struct Config { - verified: HashMap<String, String>, -} - -#[tokio::main] -async fn main() { - #[cfg(windows)] - let _ = colored::control::set_virtual_terminal(true); - - let config_path = PathBuf::from("verified-docs.toml"); - if !config_path.exists() { - eprintln_cargo_style!("verified-docs.toml not found in current directory"); - std::process::exit(1); - } - - let config: Config = { - let content = std::fs::read_to_string(&config_path).unwrap_or_else(|_e| { - eprintln_cargo_style!("Failed to read verified-docs.toml"); - std::process::exit(1); - }); - toml::from_str(&content).unwrap_or_else(|_e| { - eprintln_cargo_style!("Failed to parse verified-docs.toml"); - std::process::exit(1); - }) - }; - - // Parse optional path argument from env args - let single_file: Option<PathBuf> = { - let args: Vec<String> = env::args().collect(); - if args.len() > 1 { - let p = PathBuf::from(&args[1]); - if p.exists() { - Some(p) - } else { - eprintln_cargo_style!("error: specified file '{}' does not exist", args[1]); - std::process::exit(1); - } - } else { - None - } - }; - - // Collect all markdown files from config - // Keys are used as labels; values are either single file paths or directory globs - let mut files: Vec<(String, PathBuf)> = Vec::new(); - - for (key, value) in &config.verified { - let candidate = PathBuf::from(value); - if candidate.is_dir() { - // Directory — walk it for all .md files, using the key as label - collect_md_files(&candidate, &mut files, key); - } else if candidate.exists() && candidate.is_file() { - // Single file - files.push((key.to_string(), candidate)); - } else if candidate.extension().is_none() { - // No extension — treat as a glob like "docs/pages/**", walk the base dir instead - let base = PathBuf::from(value.trim_end_matches("/**").trim_end_matches('*')); - if base.is_dir() { - collect_md_files(&base, &mut files, key); - } - } - } - - // Sort for deterministic ordering - files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); - - // If a single file was specified, filter the list to only that file - if let Some(ref target) = single_file { - let target_canon = std::fs::canonicalize(target).unwrap_or_else(|_| target.clone()); - files.retain(|(_, path)| { - std::fs::canonicalize(path) - .map(|p| p == target_canon) - .unwrap_or(false) - }); - if files.is_empty() { - eprintln_cargo_style!( - "error: specified file '{}' is not among the configured documentation files", - target.display() - ); - std::process::exit(1); - } - } - - if files.is_empty() { - eprintln_cargo_style!("No markdown files found to verify"); - std::process::exit(1); - } - - // Parse all code blocks into a flat list with global indices - let mut flat_blocks: Vec<(usize, tools::verify::CodeBlock)> = Vec::new(); - - for (label, path) in &files { - let content = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read {}: {}", path.display(), e); - String::new() - }); - let source_file = format!("{label}/{}", path.file_name().unwrap().to_string_lossy()); - let blocks = parse_code_blocks(&content, &source_file); - let testable: Vec<_> = blocks.into_iter().filter(is_block_testable).collect(); - for block in testable { - let idx = flat_blocks.len() + 1; // 1-based global index - flat_blocks.push((idx, block)); - } - } - - let total_testable = flat_blocks.len(); - - if total_testable == 0 { - println_cargo_style!("No testable code blocks found"); - return; - } - - // Create a shared progress bar - let bar = ProgressBar::new(total_testable as u64); - bar.set_style( - indicatif::ProgressStyle::default_bar() - .template(&format!( - "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", - " Testing".bold().bright_cyan() - )) - .unwrap() - .progress_chars("=> "), - ); - bar.set_message("blocks"); - - // Group blocks by dependency hash - let mut groups: HashMap<String, Vec<(usize, tools::verify::CodeBlock)>> = HashMap::new(); - for (idx, block) in flat_blocks { - let hash = compute_block_hash(&block); - groups.entry(hash).or_default().push((idx, block)); - } - - let temp_base = PathBuf::from(".temp/doc-test"); - - // Sort groups by hash for deterministic output order - let mut group_vec: Vec<(String, Vec<(usize, tools::verify::CodeBlock)>)> = - groups.into_iter().collect(); - group_vec.sort_by(|a, b| a.0.cmp(&b.0)); - - // Spawn a blocking task per group — groups run in parallel, blocks within a group are serial - let mut handles = Vec::new(); - for (hash, blocks) in group_vec { - let temp_base = temp_base.clone(); - let bar = bar.clone(); // clone shares the same underlying progress - let handle = tokio::task::spawn_blocking(move || { - let crate_dir = temp_base.join(&hash); - let src_dir = crate_dir.join("src"); - let manifest_path = crate_dir.join("Cargo.toml"); - - // Generate a single Cargo.toml for the whole group (all blocks share same deps) - let first_block = &blocks[0].1; - let cargo_toml = generate_cargo_toml(first_block, "test-doc", &manifest_path); - - let mut group_results: Vec<(String, usize, bool, String)> = Vec::new(); - for (block_idx, block) in &blocks { - let block_label = - format!("Block {block_idx} ({}:{})", block.source_file, block.line); - - bar.set_message(block_label.clone()); - - let main_rs = if block.is_build_time { - // For build-time blocks, write a stub main.rs and generate build.rs - generate_build_rs(block) - } else { - generate_main_rs(block) - }; - let (ok, err) = build_block( - &src_dir, - &manifest_path, - &cargo_toml, - &main_rs, - block.is_build_time, - ); - if ok { - bar.inc(1); - } else { - bar.inc(1); - bar.println(format!(" {} {block_label}", "failed".bold().bright_red())); - bar.println(format!(" {block_label} FAILED:\n{err}")); - } - group_results.push((block.source_file.clone(), block.line, ok, err)); - } - group_results - }); - handles.push(handle); - } - - // Collect results from all groups - let mut results: Vec<(String, usize, bool, String)> = Vec::new(); - let mut passed = 0usize; - let mut failed = 0usize; - - for handle in handles { - match handle.await { - Ok(group_results) => { - for (file, line, ok, err) in group_results { - if ok { - passed += 1; - } else { - failed += 1; - } - results.push((file, line, ok, err)); - } - } - Err(e) => { - eprintln_cargo_style!("Task panicked: {}", e); - std::process::exit(1); - } - } - } - - bar.finish_and_clear(); - - let result_msg = format!("Result: {passed}/{total_testable} blocks passed"); - println_cargo_style!(result_msg); - - write_summary_report( - Path::new(".temp/DOCS-TEST-RESULT.md"), - "Documentation Code Block Test Report", - &results, - total_testable, - passed, - failed, - ); - - if failed > 0 { - let fail_msg = format!("{failed} block(s) failed to build"); - eprintln_cargo_style!(fail_msg); - std::process::exit(1); - } - - println_cargo_style!("Done: All verified code blocks build successfully!"); -} - -/// Recursively collect all `.md` files under a directory -fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, lang: &str) { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_md_files(&path, files, lang); - } else if path.extension().is_some_and(|ext| ext == "md") { - files.push((lang.to_string(), path)); - } - } - } -} diff --git a/.run/src/bin/test-all.ps1 b/.run/src/bin/test-all.ps1 deleted file mode 100644 index 231698a..0000000 --- a/.run/src/bin/test-all.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$starting_dir = Get-Location -Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object { - $project_dir = $_.DirectoryName - Push-Location $project_dir - cargo test - Pop-Location -} -Set-Location $starting_dir diff --git a/.run/src/bin/test-all.sh b/.run/src/bin/test-all.sh deleted file mode 100644 index b387463..0000000 --- a/.run/src/bin/test-all.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -find . -name "Cargo.toml" -type f | while read -r cargo_file; do - project_dir=$(dirname "$cargo_file") - (cd "$project_dir" && cargo test) -done diff --git a/.run/src/bin/test-examples.rs b/.run/src/bin/test-examples.rs deleted file mode 100644 index 539459e..0000000 --- a/.run/src/bin/test-examples.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::collections::HashMap; - -use colored::Colorize; -use indicatif::ProgressBar; -use serde::Deserialize; -use tools::{eprintln_cargo_style, println_cargo_style}; - -#[derive(Deserialize)] -struct TestConfig { - test: HashMap<String, Vec<TestCase>>, -} - -#[derive(Deserialize)] -struct TestCase { - command: String, - expect: Expect, -} - -#[derive(Deserialize)] -struct Expect { - #[serde(rename = "exit-code")] - exit_code: i32, - result: String, -} - -fn main() { - #[cfg(windows)] - let _ = colored::control::set_virtual_terminal(true); - - let config = load_config(); - - // Count total test cases upfront - let total: usize = config.test.values().map(|cases| cases.len()).sum(); - let bar = ProgressBar::new(total as u64); - bar.set_style( - indicatif::ProgressStyle::default_bar() - .template(&format!( - "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", - " Testing".bold().bright_cyan() - )) - .unwrap() - .progress_chars("=> "), - ); - bar.set_message("examples"); - - let passed = run_all_tests(&config, &bar); - - bar.finish_and_clear(); - - println_cargo_style!("Result: {}/{} tests passed", passed, total); - - if passed != total { - eprintln_cargo_style!("{} test(s) failed", total - passed); - std::process::exit(1); - } -} - -/// 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); - 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); - }) -} - -/// Run all example test groups, return number passed -fn run_all_tests(config: &TestConfig, bar: &ProgressBar) -> usize { - let mut passed = 0; - - for (example_name, test_cases) in &config.test { - 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; - } - bar.inc(1); - } - } - - 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 output = match std::process::Command::new(&binary_path) - .args(&args) - .output() - { - Ok(o) => o, - Err(e) => { - bar.println(format!("'{}' - failed to run: {}", test_case.command, e)); - return false; - } - }; - - let actual_exit_code = output.status.code().unwrap_or(-1); - let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - - let exit_ok = actual_exit_code == test_case.expect.exit_code; - let result_ok = actual_stdout == test_case.expect.result - || actual_stdout.contains(&test_case.expect.result); - - if exit_ok && result_ok { - true - } else { - bar.println(format!("failed: '{}'", test_case.command)); - if !exit_ok { - bar.println(format!( - " Expected exit code: {}, actual: {}", - test_case.expect.exit_code, actual_exit_code - )); - } - if !result_ok { - bar.println(format!(" Expected output: {:?}", test_case.expect.result)); - bar.println(format!(" Actual stdout: {:?}", actual_stdout)); - if !actual_stderr.is_empty() { - bar.println(format!(" Actual stderr: {:?}", actual_stderr)); - } - } - false - } -} - -/// 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() - } -} diff --git a/.run/src/bin/update-version.rs b/.run/src/bin/update-version.rs index 9d595bb..2283662 100644 --- a/.run/src/bin/update-version.rs +++ b/.run/src/bin/update-version.rs @@ -56,11 +56,11 @@ fn main() { println_cargo_style!("Version: {} -> {}", current_ver, new_ver); // Read version-files.toml - let config_path = Path::new(".run").join("version-files.toml"); + let config_path = Path::new(".config").join("version-files.toml"); let config_str = - std::fs::read_to_string(&config_path).expect("Failed to read .run/version-files.toml"); + std::fs::read_to_string(&config_path).expect("Failed to read .config/version-files.toml"); let config: Config = - toml::from_str(&config_str).expect("Failed to parse .run/version-files.toml"); + toml::from_str(&config_str).expect("Failed to parse .config/version-files.toml"); let mut updated_count = 0; let mut skipped_count = 0; |
