diff options
Diffstat (limited to 'dev/run/src')
| -rw-r--r-- | dev/run/src/bin/ci.py | 75 | ||||
| -rw-r--r-- | dev/run/src/bin/clippy-fix.ps1 | 8 | ||||
| -rwxr-xr-x | dev/run/src/bin/clippy-fix.sh | 6 | ||||
| -rw-r--r-- | dev/run/src/bin/cov-test.rs | 571 | ||||
| -rw-r--r-- | dev/run/src/bin/deploy-api-docs.rs | 118 | ||||
| -rw-r--r-- | dev/run/src/bin/display-dependency-order.rs | 12 | ||||
| -rw-r--r-- | dev/run/src/bin/http-page-preview.ps1 | 3 | ||||
| -rwxr-xr-x | dev/run/src/bin/http-page-preview.sh | 2 | ||||
| -rw-r--r-- | dev/run/src/bin/install-mling.ps1 | 10 | ||||
| -rwxr-xr-x | dev/run/src/bin/install-mling.sh | 17 | ||||
| -rw-r--r-- | dev/run/src/bin/package-all.rs | 736 | ||||
| -rw-r--r-- | dev/run/src/bin/update-version.rs | 104 | ||||
| -rw-r--r-- | dev/run/src/bin/windows-folder-hide.ps1 | 115 | ||||
| -rw-r--r-- | dev/run/src/dependency_order.rs | 196 | ||||
| -rw-r--r-- | dev/run/src/lib.rs | 459 | ||||
| -rw-r--r-- | dev/run/src/verify.rs | 506 |
16 files changed, 2938 insertions, 0 deletions
diff --git a/dev/run/src/bin/ci.py b/dev/run/src/bin/ci.py new file mode 100644 index 0000000..6234a19 --- /dev/null +++ b/dev/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/dev/run/src/bin/clippy-fix.ps1 b/dev/run/src/bin/clippy-fix.ps1 new file mode 100644 index 0000000..1d24f92 --- /dev/null +++ b/dev/run/src/bin/clippy-fix.ps1 @@ -0,0 +1,8 @@ +$starting_dir = Get-Location +Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object { + $project_dir = $_.DirectoryName + Push-Location $project_dir + cargo clippy --fix --allow-dirty --allow-no-vcs --quiet + Pop-Location +} +Set-Location $starting_dir diff --git a/dev/run/src/bin/clippy-fix.sh b/dev/run/src/bin/clippy-fix.sh new file mode 100755 index 0000000..9771ad4 --- /dev/null +++ b/dev/run/src/bin/clippy-fix.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +find . -name "Cargo.toml" -type f | while read -r cargo_file; do + project_dir=$(dirname "$cargo_file") + (cd "$project_dir" && cargo clippy --fix --allow-dirty --allow-no-vcs --quiet) +done diff --git a/dev/run/src/bin/cov-test.rs b/dev/run/src/bin/cov-test.rs new file mode 100644 index 0000000..f62ff01 --- /dev/null +++ b/dev/run/src/bin/cov-test.rs @@ -0,0 +1,571 @@ +//! 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 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"); + + // 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!("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); + }); + } + + // 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); + }); + + // Move files from <output_path>/html/ to <output_path> + let html_dir = output_path.join("html"); + if html_dir.exists() && html_dir.is_dir() { + println_cargo_style!("Moving files from {}/html/ to {}/", OUTPUT_DIR, OUTPUT_DIR); + + 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(); + let file_name = entry + .file_name() + .to_str() + .expect("Invalid filename") + .to_owned(); + + let dest_path = output_path.join(&file_name); + if dest_path.exists() { + if dest_path.is_dir() { + fs::remove_dir_all(&dest_path).unwrap_or_else(|e| { + eprintln!( + "Warning: could not remove directory {}: {}", + dest_path.display(), + e + ); + }); + } else { + fs::remove_file(&dest_path).unwrap_or_else(|e| { + eprintln!( + "Warning: could not remove file {}: {}", + dest_path.display(), + e + ); + }); + } + } + fs::rename(&entry_path, &dest_path).unwrap_or_else(|e| { + eprintln!("Warning: could not move {}: {}", entry_path.display(), e); + }); + } + + fs::remove_dir(&html_dir).unwrap_or_else(|e| { + eprintln!("Warning: could not remove html directory: {}", e); + }); + + 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()?; + + 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 +} + +#[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/dev/run/src/bin/deploy-api-docs.rs b/dev/run/src/bin/deploy-api-docs.rs new file mode 100644 index 0000000..961eb04 --- /dev/null +++ b/dev/run/src/bin/deploy-api-docs.rs @@ -0,0 +1,118 @@ +use std::path::Path; + +use arg_picker::{Picker, macros::arg}; +use tools::{println_cargo_style, run_cmd}; + +const OUTPUT_DIR: &str = "docs/api-docs"; + +fn main() { + let using_docsrs = Picker::from_args() + .pick_or_default(&arg![docsrs: bool]) + .unwrap(); + + let repo_root = find_git_repo().expect("Failed to find git repository root"); + + // 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 + let output_path = repo_root.join(OUTPUT_DIR); + std::fs::create_dir_all(&output_path).expect("Failed to create output directory"); + + // Build cargo doc command + let cmd = if using_docsrs { + format!( + "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", + features_arg, + output_path.join("target").to_string_lossy() + ) + }; + + println_cargo_style!("Features: {}", features_arg); + println_cargo_style!("Output: {}", output_path.display()); + + // Run cargo doc, then copy generated docs to output directory + println_cargo_style!("Building: docs (cargo doc --no-deps)"); + run_cmd!(&cmd).unwrap_or_else(|code| { + eprintln!("Error: cargo doc failed with exit code {}", code); + std::process::exit(code); + }); + + // Copy generated docs from target/doc to OUTPUT_DIR (top level) + let doc_source = output_path.join("target").join("doc"); + let doc_dest = &output_path; + + if doc_source.exists() { + println_cargo_style!("Copying: docs to output directory"); + // Remove old docs in destination (except target/) + if let Ok(entries) = std::fs::read_dir(doc_dest) { + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()) == Some("target") { + continue; + } + if path.is_dir() { + std::fs::remove_dir_all(&path).ok(); + } else { + std::fs::remove_file(&path).ok(); + } + } + } + copy_dir_recursively(&doc_source, doc_dest).expect("Failed to copy documentation"); + } + + // Clean up the intermediate target directory to save space + std::fs::remove_dir_all(output_path.join("target")).ok(); + + println_cargo_style!("Done: API docs deployed to {}", output_path.display()); +} + +fn copy_dir_recursively(src: &Path, dst: &Path) -> std::io::Result<()> { + if !dst.exists() { + std::fs::create_dir_all(dst)?; + } + + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let file_name = src_path.file_name().expect("Failed to get file name"); + let dst_path = dst.join(file_name); + + if file_type.is_dir() { + copy_dir_recursively(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) +} + +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/dev/run/src/bin/display-dependency-order.rs b/dev/run/src/bin/display-dependency-order.rs new file mode 100644 index 0000000..a31c67a --- /dev/null +++ b/dev/run/src/bin/display-dependency-order.rs @@ -0,0 +1,12 @@ +use tools::{dependency_order::display_dependency_order, eprintln_cargo_style}; + +fn main() { + let order = display_dependency_order(); + if order.is_empty() { + eprintln_cargo_style!("could not find workspace root or mingling crates"); + std::process::exit(1); + } + for path in order { + println!("{}", path.display()); + } +} diff --git a/dev/run/src/bin/http-page-preview.ps1 b/dev/run/src/bin/http-page-preview.ps1 new file mode 100644 index 0000000..8cc3579 --- /dev/null +++ b/dev/run/src/bin/http-page-preview.ps1 @@ -0,0 +1,3 @@ +$starting_dir = Get-Location +python -m http.server 3000 +Set-Location $starting_dir diff --git a/dev/run/src/bin/http-page-preview.sh b/dev/run/src/bin/http-page-preview.sh new file mode 100755 index 0000000..bed4b1c --- /dev/null +++ b/dev/run/src/bin/http-page-preview.sh @@ -0,0 +1,2 @@ +#!/bin/bash +python3 -m http.server 3000 diff --git a/dev/run/src/bin/install-mling.ps1 b/dev/run/src/bin/install-mling.ps1 new file mode 100644 index 0000000..2b55a09 --- /dev/null +++ b/dev/run/src/bin/install-mling.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = "Stop" + +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/dev/run/src/bin/install-mling.sh b/dev/run/src/bin/install-mling.sh new file mode 100755 index 0000000..e8cfa18 --- /dev/null +++ b/dev/run/src/bin/install-mling.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +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/dev/run/src/bin/package-all.rs b/dev/run/src/bin/package-all.rs new file mode 100644 index 0000000..ecdd133 --- /dev/null +++ b/dev/run/src/bin/package-all.rs @@ -0,0 +1,736 @@ +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, +}; + +/// A single member from `cargo metadata` output. +#[derive(Deserialize, Debug)] +struct MetadataPackage { + name: String, + version: String, + manifest_path: String, +} + +/// The top-level metadata structure. +#[derive(Deserialize, Debug)] +struct Metadata { + #[allow(dead_code)] + workspace_root: String, + packages: Vec<MetadataPackage>, +} + +fn main() { + // 1. Determine project root + let cwd = std::env::current_dir().expect("failed to get current working directory"); + let workspace_root = find_workspace_root(&cwd).expect("not inside a Cargo workspace"); + println_cargo_style!("Workspace: {}", workspace_root.display()); + + let pre_release_dir = workspace_root.join(".temp/pre-release"); + + // 2. Clean `.temp/pre-release/` + println_cargo_style!("Clean: .temp/pre-release/"); + let _ = std::fs::remove_dir_all(&pre_release_dir); + std::fs::create_dir_all(&pre_release_dir).expect("failed to create .temp/pre-release/"); + + // 3. Run `cargo metadata` to get workspace members info + println_cargo_style!("Metadata: querying workspace members"); + let metadata_json = run_cmd_capture_with_dir( + "cargo metadata --format-version 1 --no-deps", + &workspace_root, + ) + .unwrap_or_else(|(code, _msg)| { + eprintln_cargo_style!(format!("cargo metadata failed (exit {code}):\n{{msg}}")); + std::process::exit(1); + }); + + let metadata: Metadata = serde_json::from_str(&metadata_json).unwrap_or_else(|e| { + eprintln_cargo_style!("failed to parse cargo metadata: {}", e); + std::process::exit(1); + }); + + // Filter workspace members: skip the root virtual manifest + let workspace_root_str = workspace_root.to_string_lossy().replace('\\', "/"); + let members: Vec<&MetadataPackage> = metadata + .packages + .iter() + .filter(|p| { + let mp = p.manifest_path.replace('\\', "/"); + mp.starts_with(&workspace_root_str) + && mp != format!("{}/Cargo.toml", workspace_root_str) + }) + .collect(); + + if members.is_empty() { + eprintln_cargo_style!("No workspace members found!"); + std::process::exit(1); + } + + // Print member info + for m in &members { + println_cargo_style!("Member: {}@{}", m.name, m.version); + } + + // Build version map: crate_name -> version + 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 + let mut member_dirs: Vec<PathBuf> = Vec::new(); + for m in &members { + let dir = Path::new(&m.manifest_path) + .parent() + .expect("manifest_path has no parent"); + let canonical_dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let canonical_root = + std::fs::canonicalize(&workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf()); + let relative = canonical_dir + .strip_prefix(&canonical_root) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| PathBuf::from(dir.file_name().unwrap_or_default())); + if !member_dirs.contains(&relative) { + member_dirs.push(relative); + } + } + + // 4. Copy files to the temp directory, preserving the workspace directory structure + println_cargo_style!("Copy: project structure to .temp/pre-release/"); + + copy_dir( + &workspace_root.join(".cargo"), + &pre_release_dir.join(".cargo"), + ); + + for dir in &member_dirs { + let src = workspace_root.join(dir); + let dst = pre_release_dir.join(dir); + copy_dir(&src, &dst); + } + + copy_file( + &workspace_root.join("Cargo.toml"), + &pre_release_dir.join("Cargo.toml"), + ); + copy_file( + &workspace_root.join("Cargo.lock"), + &pre_release_dir.join("Cargo.lock"), + ); + + // 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"); + + // 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 (ws_package, ws_deps) = parse_workspace_config(&root_content); + + // 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() { + continue; + } + let member_content = std::fs::read_to_string(&member_cargo) + .unwrap_or_else(|e| panic!("failed to read {}: {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 + let package_ok = run_cmd_capture_with_dir( + "cargo package --workspace --no-verify --color always", + &pre_release_dir, + ); + + match &package_ok { + Ok(out) => { + println!("{out}"); + } + Err((code, msg)) => { + // Print output but don't fail yet + eprintln_cargo_style!(format!("cargo package exited with code {code}:")); + println!("{msg}"); + } + } + + // 7. Copy built packages back to .temp/target/package + let temp_package_dir = workspace_root.join(".temp/target/package"); + std::fs::create_dir_all(&temp_package_dir) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", temp_package_dir.display())); + + // cargo package puts .crate files in target/package + let pre_release_target_package = pre_release_dir.join(".temp/target/package"); + if pre_release_target_package.exists() { + println_cargo_style!("Copy: packages to .temp/target/package"); + copy_dir_contents(&pre_release_target_package, &temp_package_dir); + } else { + wprintln_cargo_style!("No packages found in .temp/pre-release/.temp/target/package"); + } + + // 8. Export each crate as a standalone project from the .crate packages. + // The .crate files contain the final publish-ready Cargo.toml with all + // workspace/path deps already resolved by `cargo package`. + let release_dir = workspace_root.join(".temp/release"); + println_cargo_style!("Export: standalone crates to .temp/release/"); + let _ = std::fs::remove_dir_all(&release_dir); + std::fs::create_dir_all(&release_dir) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", release_dir.display())); + + for entry in std::fs::read_dir(&temp_package_dir).expect("failed to read target/package") { + let entry = entry.expect("failed to read entry"); + let path = entry.path(); + if path.extension().is_none_or(|e| e != "crate") { + continue; + } + + // .crate files are gzipped tarballs. Extract to .temp/release/<name>/ + // fname is like "mingling-0.3.0" + let fname = path.file_stem().unwrap().to_string_lossy().to_string(); + + // Derive crate directory name by stripping the version suffix + // mingling-0.3.0 -> mingling, arg-picker-0.1.0 -> arg-picker + let crate_dir_name = fname + .rfind('-') + .and_then(|dash| { + // Check if what follows looks like a semver + let ver_part = &fname[dash + 1..]; + if ver_part.chars().next().is_some_and(|c| c.is_ascii_digit()) { + Some(&fname[..dash]) + } else { + None + } + }) + .unwrap_or(&fname) + .to_string(); + + let target_dir = release_dir.join(&crate_dir_name); + std::fs::create_dir_all(&target_dir) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", target_dir.display())); + + // Extract using flate2 + tar (cross-platform) + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + eprintln_cargo_style!("Failed to open {}: {e}", path.display()); + continue; + } + }; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + if let Err(e) = archive.unpack(&target_dir) { + eprintln_cargo_style!("Failed to extract {}: {e}", fname); + continue; + } + + // Move the contents from the inner dir up one level + // .crate contains a single top-level dir named after the package + let inner = target_dir.join(&fname); + if inner.exists() { + for inner_entry in std::fs::read_dir(&inner).expect("failed to read inner dir") { + let inner_entry = inner_entry.expect("failed to read entry"); + let inner_path = inner_entry.path(); + let dest = target_dir.join(inner_path.file_name().unwrap()); + if dest.exists() { + let _ = std::fs::remove_dir_all(&dest); + } + std::fs::rename(&inner_path, &dest).unwrap_or_else(|e| { + panic!( + "failed to rename {} -> {}: {e}", + inner_path.display(), + dest.display() + ) + }); + } + let _ = std::fs::remove_dir_all(&inner); + } + + // Clean up: remove .orig file (only the normalized Cargo.toml is needed) + let _ = std::fs::remove_file(target_dir.join("Cargo.toml.orig")); + // 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); + } + + println_cargo_style!("Done: .temp/release/ is ready"); + + // If package failed, report it + if package_ok.is_err() { + eprintln_cargo_style!("cargo package reported errors above"); + std::process::exit(1); + } +} + +/// 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, + 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_package = false; + let mut in_section_with_deps = false; + + for line in content.lines() { + let trimmed = line.trim(); + 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; + } + + // [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 + } + + // 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; + } + } + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +/// 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_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 == "[workspace.dependencies]" { + in_ws_deps = true; + continue; + } + 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'); + } + + result +} + +/// Recursively copy a directory. +fn copy_dir(src: &Path, dst: &Path) { + copy_dir_filtered(src, dst, &|_: &Path| true) +} + +/// Recursively copy a directory with a filter function. +/// The filter receives the source path and returns `true` if the entry should be copied. +fn copy_dir_filtered(src: &Path, dst: &Path, filter: &dyn Fn(&Path) -> bool) { + if !src.exists() { + return; + } + if !filter(src) { + return; + } + std::fs::create_dir_all(dst) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", dst.display())); + + for entry in std::fs::read_dir(src).expect("failed to read directory") { + let entry = entry.expect("failed to read entry"); + let entry_type = entry.file_type().expect("failed to get file type"); + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if !filter(&src_path) { + continue; + } + + if entry_type.is_dir() { + copy_dir_filtered(&src_path, &dst_path, filter); + } else if entry_type.is_file() || entry_type.is_symlink() { + copy_file(&src_path, &dst_path); + } + } +} + +/// Copy a file, creating parent directories as needed. +/// If src is a symlink, copies the target content (follow symlinks). +fn copy_file(src: &Path, dst: &Path) { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", parent.display())); + } + + let resolved = if src.is_symlink() { + let target = std::fs::read_link(src) + .unwrap_or_else(|e| panic!("failed to read symlink {}: {e}", src.display())); + if target.is_relative() { + src.parent().unwrap().join(target) + } else { + target + } + } else { + src.to_path_buf() + }; + + std::fs::copy(&resolved, dst).unwrap_or_else(|e| { + panic!( + "failed to copy {} -> {}: {e}", + resolved.display(), + dst.display() + ) + }); +} + +/// Copy all files/directories from one directory into another. +fn copy_dir_contents(src: &Path, dst: &Path) { + if !src.exists() { + return; + } + std::fs::create_dir_all(dst) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", dst.display())); + + for entry in std::fs::read_dir(src).expect("failed to read directory") { + let entry = entry.expect("failed to read entry"); + let entry_type = entry.file_type().expect("failed to get file type"); + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if entry_type.is_dir() { + copy_dir(&src_path, &dst_path); + } else if entry_type.is_file() || entry_type.is_symlink() { + copy_file(&src_path, &dst_path); + } + } +} diff --git a/dev/run/src/bin/update-version.rs b/dev/run/src/bin/update-version.rs new file mode 100644 index 0000000..679f419 --- /dev/null +++ b/dev/run/src/bin/update-version.rs @@ -0,0 +1,104 @@ +use std::io::Write as _; +use std::path::Path; + +use serde::Deserialize; +use tools::println_cargo_style; + +#[derive(Deserialize)] +struct VersionFile { + file: String, + pattern: String, +} + +#[derive(Deserialize)] +struct Config { + #[serde(rename = "file")] + files: Vec<VersionFile>, +} + +fn main() { + let args: Vec<String> = std::env::args().collect(); + + // Get new version + let new_ver = if args.len() > 1 { + args[1].clone() + } else { + print!("Update version to: "); + std::io::stdout().flush().unwrap(); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + input.trim().to_string() + }; + + if new_ver.is_empty() { + eprintln!("Error: Version cannot be empty."); + std::process::exit(1); + } + + // Read current version from root Cargo.toml's workspace.package.version + let root_cargo_path = "Cargo.toml"; + let root_cargo_content = + std::fs::read_to_string(root_cargo_path).expect("Failed to read Cargo.toml"); + let cargo_value: toml::Value = root_cargo_content + .parse() + .expect("Failed to parse Cargo.toml"); + + let current_ver = cargo_value["workspace"]["package"]["version"] + .as_str() + .expect("workspace.package.version not found in Cargo.toml") + .to_string(); + + if new_ver == current_ver { + println!("Version is already {}. Nothing to do.", current_ver); + return; + } + + println_cargo_style!("Version: {} -> {}", current_ver, new_ver); + + // Read version-files.toml + let config_path = Path::new("dev/configs").join("version-files.toml"); + let config_str = std::fs::read_to_string(&config_path) + .expect("Failed to read dev/configs/version-files.toml"); + let config: Config = + toml::from_str(&config_str).expect("Failed to parse dev/configs/version-files.toml"); + + let mut updated_count = 0; + let mut skipped_count = 0; + + for vf in &config.files { + let file_path = &vf.file; + let old_pattern = vf.pattern.replace("{VER}", ¤t_ver); + let new_pattern = vf.pattern.replace("{VER}", &new_ver); + + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: Could not read {}: {}", file_path, e); + skipped_count += 1; + continue; + } + }; + + let new_content = content.replace(&old_pattern, &new_pattern); + + if new_content == content { + eprintln!( + "Warning: Pattern '{}' not found in {}", + old_pattern, file_path + ); + skipped_count += 1; + continue; + } + + std::fs::write(file_path, &new_content) + .unwrap_or_else(|e| panic!("Failed to write {}: {}", file_path, e)); + println_cargo_style!("Updated: {}", file_path); + updated_count += 1; + } + + println_cargo_style!( + "Done: {} file(s) updated, {} file(s) skipped", + updated_count, + skipped_count + ); +} diff --git a/dev/run/src/bin/windows-folder-hide.ps1 b/dev/run/src/bin/windows-folder-hide.ps1 new file mode 100644 index 0000000..ff53202 --- /dev/null +++ b/dev/run/src/bin/windows-folder-hide.ps1 @@ -0,0 +1,115 @@ +$skipDirs = @('.git', '.temp', 'target', 'node_modules', '.pnpm') +$selfPath = (Get-Item -LiteralPath $MyInvocation.MyCommand.Path).Directory.FullName + +function Test-InSkipDir { + param( + [object]$Item + ) + $path = if ($Item -is [string]) { + $Item + } elseif ($Item.PSPath) { + $Item.PSPath -replace '^.*::', '' + } else { + $Item.FullName + } + + $parts = $path.Split([System.IO.Path]::DirectorySeparatorChar) + for ($i = 0; $i -lt $parts.Length - 1; $i++) { + if ($parts[$i] -in $skipDirs) { + return $true + } + } + return $false +} + +function Invoke-UnhideRecursive { + param([string]$Path) + Get-ChildItem -LiteralPath $Path -Force | ForEach-Object { + if ($_.PSIsContainer) { + if ($_.Name -in $skipDirs) { + if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) { + Write-Host " -> unhiding skip directory (self only): `"$($_.FullName)`"" + $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden + } + return + } + Invoke-UnhideRecursive $_.FullName + } else { + if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) { + Write-Host " -> unhiding: `"$($_.FullName)`"" + $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden + } + } + } +} + +function Test-GitPathSkippable { + param([string]$GitPath) + $parts = $GitPath.Split(@('/', '\')) + for ($i = 0; $i -lt $parts.Length - 1; $i++) { + if ($parts[$i] -in $skipDirs) { + return $true + } + } + return $false +} + +Write-Host "Step 1: Unhiding all files and directories (skipping $($skipDirs -join ', '))..." + +Invoke-UnhideRecursive -Path (Get-Location).Path + +Write-Host "Step 2: Hiding git-ignored items..." + +git ls-files --others --ignored --exclude-standard | Where-Object { + -not (Test-GitPathSkippable $_) +} | ForEach-Object { + $itemPath = $_ + Write-Host "... checking: `"$itemPath`"" + $item = Get-Item $_ -Force -ErrorAction SilentlyContinue + if (-not $item) { return } + + if ($item.FullName -eq $selfPath) { return } + + if (Test-InSkipDir $item) { + Write-Host " -> skipping (inside skip directory)" + return + } + + if ($item.PSIsContainer) { + if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding directory (non-recursive)" + $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden + } + } else { + if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding" + $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden + } + } +} + +Write-Host "Step 3: Hiding dot-prefixed items..." +Get-ChildItem -Path . -Force -Directory | Where-Object { $_.Name -match '^\.' } | ForEach-Object { + Write-Host "... checking: `"$($_.FullName)`"" + if (Test-InSkipDir $_) { + Write-Host " -> skipping (inside skip directory)" + return + } + if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding directory" + $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden + } +} + +Get-ChildItem -Path . -Force -File | Where-Object { $_.Name -match '^\.' } | ForEach-Object { + if ($_.FullName -eq $selfPath) { return } + Write-Host "... checking: `"$($_.FullName)`"" + if (Test-InSkipDir $_) { + Write-Host " -> skipping (inside skip directory)" + return + } + if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding file" + $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden + } +} diff --git a/dev/run/src/dependency_order.rs b/dev/run/src/dependency_order.rs new file mode 100644 index 0000000..145bdbd --- /dev/null +++ b/dev/run/src/dependency_order.rs @@ -0,0 +1,196 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Parse Cargo.toml content and return dependency names that start with `prefix`. +fn parse_mingling_deps(content: &str, prefix: &str) -> Vec<String> { + let value: toml::Value = match content.parse() { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + let mut names = Vec::new(); + + // Check [dependencies] + if let Some(deps) = value.get("dependencies").and_then(|d| d.as_table()) { + for key in deps.keys() { + if key.starts_with(prefix) { + names.push(key.clone()); + } + } + } + + // Check [build-dependencies] + if let Some(deps) = value.get("build-dependencies").and_then(|d| d.as_table()) { + for key in deps.keys() { + if key.starts_with(prefix) { + names.push(key.clone()); + } + } + } + + names +} + +/// Read workspace members from the root Cargo.toml. +fn get_workspace_members(workspace_root: &std::path::Path) -> Vec<String> { + let cargo_path = workspace_root.join("Cargo.toml"); + let content = match std::fs::read_to_string(&cargo_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let value: toml::Value = match content.parse() { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + value + .get("workspace") + .and_then(|w| w.get("members")) + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +/// Hierarchical topological sort (process layer by layer, sort siblings alphabetically). +/// +/// `dep_map` maps each crate to the list of crates it depends on. +/// Returns the dependency order (dependent crates come first, dependents come later), +/// with crates at the same layer (which can be built in parallel) sorted alphabetically. +fn topological_sort( + all_crates: &HashSet<String>, + dep_map: &HashMap<String, Vec<String>>, +) -> Vec<String> { + // in_degree[crate] = number of remaining mingling_* dependencies not yet processed + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + // reverse[dependency] = list of crates that depend on it + let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new(); + + for name in all_crates { + in_degree.entry(name.as_str()).or_insert(0); + reverse.entry(name.as_str()).or_default(); + } + + for (crate_name, deps) in dep_map { + for dep in deps { + if all_crates.contains(dep.as_str()) { + reverse + .get_mut(dep.as_str()) + .unwrap() + .push(crate_name.as_str()); + *in_degree.get_mut(crate_name.as_str()).unwrap() += 1; + } + } + } + + let mut result: Vec<String> = Vec::new(); + + // Process layer by layer: all crates with in_degree == 0 in one batch form a layer + loop { + let mut current: Vec<&str> = all_crates + .iter() + .filter(|n| in_degree.get(n.as_str()).copied().unwrap_or(0) == 0) + .filter(|n| !result.iter().any(|r| r.as_str() == n.as_str())) + .map(|s| s.as_str()) + .collect(); + + if current.is_empty() { + break; + } + + current.sort(); + result.extend(current.iter().map(|s| s.to_string())); + + for &node in ¤t { + if let Some(dependents) = reverse.get(node) { + for &dependent in dependents { + if let Some(degree) = in_degree.get_mut(dependent) { + *degree -= 1; + } + } + } + } + } + + result +} + +/// Strip the `\\?\` prefix that `std::fs::canonicalize` may add on Windows. +fn strip_verbatim_prefix(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + let s_ref: &str = &s; + if let Some(rest) = s_ref.strip_prefix("\\\\?\\") { + PathBuf::from(rest) + } else { + p.to_path_buf() + } +} + +/// Find the workspace root by looking for a Cargo.toml with `[workspace]` members. +/// Starts from `start` and walks up the directory tree. +pub fn find_workspace_root(start: &std::path::Path) -> Option<PathBuf> { + let mut current = Some(strip_verbatim_prefix( + &std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf()), + )); + while let Some(dir) = current { + let members = get_workspace_members(&dir); + if !members.is_empty() { + return Some(dir); + } + current = dir.parent().map(|p| p.to_path_buf()); + } + None +} + +/// Output all crate paths in dependency order +#[allow(unused)] +pub fn display_dependency_order() -> Vec<PathBuf> { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + let workspace_root = match find_workspace_root(&cwd) { + Some(root) => root, + None => return Vec::new(), + }; + + // Read workspace members from root Cargo.toml + let members = get_workspace_members(&workspace_root); + + // Filter to crates starting with "mingling" or "arg" + let mingling_crates: HashSet<String> = members + .into_iter() + .filter(|m| m.starts_with("mingling") || m.starts_with("arg")) + .collect(); + + if mingling_crates.is_empty() { + return Vec::new(); + } + + // Build dependency graph + let mut dep_map: HashMap<String, Vec<String>> = HashMap::new(); + + for crate_name in &mingling_crates { + let cargo_path = workspace_root.join(crate_name).join("Cargo.toml"); + let content = match std::fs::read_to_string(&cargo_path) { + Ok(c) => c, + Err(_) => { + dep_map.insert(crate_name.clone(), Vec::new()); + continue; + } + }; + let deps = parse_mingling_deps(&content, "mingling"); + // Only keep deps that are actually in our set + let filtered: Vec<String> = deps + .into_iter() + .filter(|d| mingling_crates.contains(d.as_str())) + .collect(); + dep_map.insert(crate_name.clone(), filtered); + } + + let sorted = topological_sort(&mingling_crates, &dep_map); + + sorted.into_iter().map(PathBuf::from).collect() +} diff --git a/dev/run/src/lib.rs b/dev/run/src/lib.rs new file mode 100644 index 0000000..b17a61f --- /dev/null +++ b/dev/run/src/lib.rs @@ -0,0 +1,459 @@ +pub mod dependency_order; +pub mod verify; + +use colored::Colorize; + +use std::io::IsTerminal as _; + +#[macro_export] +macro_rules! run_cmd { + ($fmt:literal, $($arg:tt)*) => { + $crate::run_cmd(format!($fmt, $($arg)*)) + }; + ($cmd:expr) => { + $crate::run_cmd($cmd) + }; +} + +/// Run a shell command and capture its combined stdout+stderr output. +/// Returns `Ok(output)` on success, `Err((exit_code, stderr))` on failure. +#[macro_export] +macro_rules! run_cmd_and_capture_stderr { + ($fmt:literal, $($arg:tt)*) => { + $crate::run_cmd_capture(format!($fmt, $($arg)*)) + }; + ($cmd:expr) => { + $crate::run_cmd_capture($cmd) + }; +} + +#[macro_export] +macro_rules! println_cargo_style { + ($fmt:literal, $($arg:tt)*) => { + $crate::println_cargo_style(format!($fmt, $($arg)*)) + }; + ($cmd:expr) => { + $crate::println_cargo_style($cmd) + }; +} + +#[macro_export] +macro_rules! eprintln_cargo_style { + ($fmt:literal, $($arg:tt)*) => { + $crate::eprintln_cargo_style(format!($fmt, $($arg)*)) + }; + ($cmd:expr) => { + $crate::eprintln_cargo_style($cmd) + }; +} + +#[macro_export] +macro_rules! wprintln_cargo_style { + ($fmt:literal, $($arg:tt)*) => { + $crate::wprintln_cargo_style(format!($fmt, $($arg)*)) + }; + ($cmd:expr) => { + $crate::wprintln_cargo_style($cmd) + }; +} + +/// Print a message in cargo style format, with bold green prefix. +/// +/// # Panics +/// +/// Panics if the prefix (text before the first `:`) exceeds 12 characters. +pub fn println_cargo_style(str: impl Into<String>) { + let s = str.into(); + let (prefix, content) = if let Some(pos) = s.find(':') { + ( + s[..pos].trim().to_string(), + s[pos + 1..].trim_start().to_string(), + ) + } else { + (String::new(), s.trim().to_string()) + }; + + assert!( + prefix.len() <= 12, + "prefix length exceeds 12: '{}' has length {}", + prefix, + prefix.len() + ); + + let padding = " ".repeat(12 - prefix.len()); + + println!( + "{}{} {}", + padding, + prefix.bold().bright_green(), + content.trim() + ); +} + +pub fn eprintln_cargo_style(str: impl Into<String>) { + println!("{}: {}", "error".bold().bright_red(), str.into()); +} + +/// Print a message in cargo style format, with bold yellow prefix (warning style). +/// +/// # Panics +/// +/// Panics if the prefix (text before the first `:`) exceeds 12 characters. +pub fn wprintln_cargo_style(str: impl Into<String>) { + let s = str.into(); + let (prefix, content) = if let Some(pos) = s.find(':') { + ( + s[..pos].trim().to_string(), + s[pos + 1..].trim_start().to_string(), + ) + } else { + (String::new(), s.trim().to_string()) + }; + + assert!( + prefix.len() <= 12, + "prefix length exceeds 12: '{}' has length {}", + prefix, + prefix.len() + ); + + let padding = " ".repeat(12 - prefix.len()); + + println!( + "{}{} {}", + padding, + prefix.bold().bright_yellow(), + content.trim() + ); +} + +/// Run a shell command in the current directory and return its exit status. +/// +/// # Panics +/// +/// Panics if the shell command cannot be spawned (e.g. the shell binary is not found). +/// +/// # Errors +/// +/// Returns `Err` with the exit code if the command finishes with a non-zero exit code. +pub fn run_cmd(cmd: impl Into<String>) -> Result<(), i32> { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + run_cmd_with_dir(cmd.into(), &cwd) +} + +/// Run a shell command in the specified directory and return its exit status. +/// +/// # Panics +/// +/// Panics if the shell command cannot be spawned (e.g. the shell binary is not found). +/// +/// # Errors +/// +/// Returns `Err` with the exit code if the command finishes with a non-zero exit code. +pub fn run_cmd_with_dir(cmd: impl Into<String>, dir: &std::path::Path) -> Result<(), i32> { + let shell = if cfg!(target_os = "windows") { + "powershell" + } else { + "sh" + }; + let status = std::process::Command::new(shell) + .arg("-c") + .arg(cmd.into()) + .current_dir(dir) + .status() + .expect("failed to execute command"); + + let exit_code = status.code().unwrap_or(1); + if exit_code == 0 { + Ok(()) + } else { + Err(exit_code) + } +} + +/// Run a shell command and capture its combined stdout+stderr output. +/// +/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`. +/// Stderr falls back to stdout if stderr is empty. +pub fn run_cmd_capture(cmd: impl Into<String>) -> Result<String, (i32, String)> { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + run_cmd_capture_with_dir(cmd.into(), &cwd) +} + +/// Run a shell command in the specified directory and capture its combined stdout+stderr output. +/// +/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`. +/// Stderr falls back to stdout if stderr is empty. +pub fn run_cmd_capture_with_dir( + cmd: impl Into<String>, + dir: &std::path::Path, +) -> Result<String, (i32, String)> { + let shell = if cfg!(target_os = "windows") { + "powershell" + } else { + "sh" + }; + let output = std::process::Command::new(shell) + .arg("-c") + .arg(cmd.into()) + .current_dir(dir) + .output() + .expect("failed to execute command"); + + let exit_code = output.status.code().unwrap_or(1); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + // Keep both streams so a failure is never hidden: when stderr carries + // warnings, the real failure details (e.g. the failing test name and + // assertion diff) usually live on stdout and must not be dropped. + let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) { + (false, false) => format!("{stdout}\n{stderr}"), + (false, true) => stdout, + (true, false) => stderr, + (true, true) => stdout, + }; + + if exit_code == 0 { + Ok(combined) + } else { + Err((exit_code, combined)) + } +} + +/// Extract a crate-style name from a `Cargo.toml` path. +/// +/// Examples: +/// - `mingling_core/Cargo.toml` → `mingling_core` +/// - `.` → `(root)` +pub fn crate_name_from(path: &std::path::Path) -> String { + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("(root)") + .to_string() +} + +/// Run a list of `(label_for_errors, crate_name_for_bar, shell_command)` tuples +/// in parallel with a progress bar. +/// +/// - Success: silent, the bar tracks progress: +/// ` Building [============================] 32/32: mingling_core` +/// - Failure: `pb.println()` prints the error immediately above the bar. +pub fn run_parallel(phase: &str, tasks: Vec<(String, String, String)>) -> Result<(), i32> { + let n = tasks.len(); + if n == 0 { + return Ok(()); + } + + // Cargo-style prefix: right-aligned to 12 chars, bold bright cyan + let padding = " ".repeat(12 - phase.len()); + let styled_prefix = format!("{}{}", padding, phase.bold().bright_cyan()); + + let pb = indicatif::ProgressBar::new(n as u64); + pb.set_style( + indicatif::ProgressStyle::default_bar() + .template(&format!( + "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", + styled_prefix + )) + .unwrap() + .progress_chars("=> "), + ); + pb.set_position(0); + + // Pre-extract labels for error messages + let labels: Vec<String> = tasks.iter().map(|(l, _, _)| l.clone()).collect(); + + let (tx, rx) = std::sync::mpsc::channel::<(usize, String, Result<String, (i32, String)>)>(); + + for (i, (_label, crate_name, cmd)) in tasks.into_iter().enumerate() { + let tx = tx.clone(); + std::thread::spawn(move || { + let result = run_cmd_capture(&cmd); + let _ = tx.send((i, crate_name, result)); + }); + } + drop(tx); + + let mut first_exit_code = 0; + + while let Ok((i, crate_name, result)) = rx.recv() { + pb.inc(1); + pb.set_message(crate_name); + + if let Err((code, output)) = result { + if first_exit_code == 0 { + first_exit_code = code; + } + let msg = format!( + "{}: {} failed (exit code {})", + "error".bright_red().bold(), + labels[i], + code, + ); + let mut lines = Vec::new(); + if !output.is_empty() { + lines.extend(output.lines().map(|l| format!(" {l}"))); + } + if std::io::stdout().is_terminal() { + // On a TTY, render errors through the progress bar so they + // appear above it. + pb.println(&msg); + for line in &lines { + pb.println(line); + } + } else { + // On a non-TTY (CI, piped output), `ProgressBar::println` can + // be swallowed, hiding the failure. Emit to plain stdout so the + // failure is always visible. + println!("{msg}"); + for line in &lines { + println!("{line}"); + } + } + } + } + + pb.finish_and_clear(); + + if first_exit_code != 0 { + Err(first_exit_code) + } else { + Ok(()) + } +} + +/// Run a single shell command with a progress bar, capturing its output. +/// +/// - Success: bar clears silently. +/// - Failure: error is printed above the bar, then the bar clears. +pub fn run_cmd_with_progress(phase: &str, label: &str, cmd: String) -> Result<(), i32> { + let padding = " ".repeat(12 - phase.len()); + let styled_prefix = format!("{}{}", padding, phase.bold().bright_cyan()); + + let pb = indicatif::ProgressBar::new(1); + pb.set_style( + indicatif::ProgressStyle::default_bar() + .template(&format!( + "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", + styled_prefix + )) + .unwrap() + .progress_chars("=> "), + ); + pb.set_message(label.to_owned()); + + let result = run_cmd_capture(&cmd); + pb.inc(1); + pb.finish_and_clear(); + + match result { + Ok(_) => Ok(()), + Err((code, output)) => { + eprintln_cargo_style(format!("{} failed (exit code {})", label, code)); + if !output.is_empty() { + println!("{}", output.trim_end()); + } + Err(code) + } + } +} + +/// Read `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`. +/// +/// Finds the git repository root, reads `mingling/Cargo.toml`, parses it as TOML, +/// and extracts the feature list under `[package.metadata.docs.rs].features`. +/// +/// # Errors +/// +/// Returns `std::io::Error` if: +/// - The git repository root cannot be found. +/// - The manifest file cannot be read. +/// - The TOML cannot be parsed. +/// - The `[package.metadata.docs.rs].features` key is missing or empty. +pub fn read_features() -> Result<Vec<String>, std::io::Error> { + // Find git repo root + let mut current_dir = std::env::current_dir()?; + let repo_root = loop { + let git_dir = current_dir.join(".git"); + if git_dir.exists() && git_dir.is_dir() { + break Some(current_dir); + } + if !current_dir.pop() { + break None; + } + }; + let repo_root = repo_root.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Failed to find git repository root", + ) + })?; + + let manifest_path = repo_root.join("mingling/Cargo.toml"); + if !manifest_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Manifest not found at {}", manifest_path.display()), + )); + } + + let manifest_content = std::fs::read_to_string(&manifest_path)?; + let cargo_toml: toml::Value = manifest_content.parse().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse Cargo.toml: {}", e), + ) + })?; + + let doc_features = cargo_toml + .get("package") + .and_then(|p| p.get("metadata")) + .and_then(|m| m.get("docs")) + .and_then(|d| d.get("rs")) + .and_then(|rs| rs.get("features")) + .and_then(|f| f.as_array()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "[package.metadata.docs.rs] or its 'features' key not found in mingling/Cargo.toml", + ) + })?; + + let features: Vec<String> = doc_features + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + if features.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "No features defined in [package.metadata.docs.rs]", + )); + } + + Ok(features) +} + +#[must_use] +pub fn cargo_tomls() -> Vec<std::path::PathBuf> { + let mut cargo_tomls = Vec::new(); + let mut dirs = vec![std::path::PathBuf::from(".")]; + while let Some(dir) = dirs.pop() { + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Skip the .run directory + if path.file_name().and_then(|n| n.to_str()) == Some(".run") { + continue; + } + dirs.push(path); + } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") { + cargo_tomls.push(path); + } + } + } + } + cargo_tomls +} diff --git a/dev/run/src/verify.rs b/dev/run/src/verify.rs new file mode 100644 index 0000000..b79bb73 --- /dev/null +++ b/dev/run/src/verify.rs @@ -0,0 +1,506 @@ +use std::path::Path; + +use crate::println_cargo_style; + +/// Represents a parsed code block from a markdown file +#[derive(Debug, Clone)] +pub struct CodeBlock { + /// Source file path (for reporting) + pub source_file: String, + /// The line number in source file where this block starts + pub line: usize, + /// The raw Rust source code + pub code: String, + /// Feature flags extracted from `// Features: [...]` comment + pub features: Vec<String>, + /// Whether the block had an explicit `// Features:` header + pub has_features_header: bool, + /// Whether the block has `// NOT VERIFIED` to opt out of testing + pub not_verified: bool, + /// External dependencies extracted from `// Dependencies:` comments + pub external_deps: Vec<(String, String)>, + /// Whether this block has a `fn main` entry point + pub has_main: bool, + /// Whether this block has `gen_program!()` call + pub has_gen_program: bool, + /// Whether this block has `// BUILD TIME` annotation (write to build.rs, not main.rs) + pub is_build_time: bool, +} + +/// Parse all ```rust code blocks from markdown content +pub fn parse_code_blocks(content: &str, source_file: &str) -> Vec<CodeBlock> { + let mut blocks = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + let mut i = 0; + + while i < lines.len() { + if lines[i].trim() == "```rust" { + if let Some(block) = parse_single_block(&lines, i, source_file) { + blocks.push(block); + } + i += 1; + while i < lines.len() && lines[i].trim() != "```" { + i += 1; + } + } + i += 1; + } + + blocks +} + +/// Parse a single code block starting at the ```rust line +fn parse_single_block(lines: &[&str], start: usize, source_file: &str) -> Option<CodeBlock> { + let line_num = start + 1; // 1-based line number + + let mut code_lines: Vec<String> = Vec::new(); + let mut features: Vec<String> = Vec::new(); + let mut has_features_header = false; + let mut not_verified = false; + let mut external_deps: Vec<(String, String)> = Vec::new(); + let mut has_main = false; + let mut has_gen_program = false; + let mut is_build_time = false; + + let mut idx = start + 1; + let mut in_header = true; + + while idx < lines.len() { + let raw_line = lines[idx]; + let trimmed = raw_line.trim(); + + if trimmed == "```" { + break; + } + + // @@@ lines: strip the prefix and treat as regular Rust code + // These lines are hidden in the rendered docs (filtered by a docsify plugin) + // but must still compile. + if let Some(stripped) = trimmed.strip_prefix("@@@") { + in_header = false; + // Strip @@@ and optionally one following space + let code = stripped.trim_start(); + if code.contains("fn main") { + has_main = true; + } + if code.contains("gen_program!") { + has_gen_program = true; + } + code_lines.push(code.to_string()); + idx += 1; + continue; + } + + // Parse header comments + // Check for NOT VERIFIED marker + if in_header && trimmed == "// NOT VERIFIED" { + not_verified = true; + idx += 1; + continue; + } + + if in_header && trimmed == "// BUILD TIME" { + is_build_time = true; + idx += 1; + continue; + } + + if in_header && trimmed.starts_with("// ") { + if trimmed.starts_with("// Features:") { + has_features_header = true; + let feat_str = trimmed.trim_start_matches("// Features:").trim(); + if feat_str.starts_with('[') && feat_str.ends_with(']') { + let inner = &feat_str[1..feat_str.len() - 1]; + if !inner.is_empty() { + features = inner + .split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + idx += 1; + continue; + } + if trimmed == "// Dependencies:" { + idx += 1; + // Collect subsequent `// crate = "version"` lines + while idx < lines.len() { + let next = lines[idx].trim(); + if next == "```" { + break; + } + if next.starts_with("// ") { + let dep_line = next.trim_start_matches("// ").trim(); + if let Some((name, ver)) = dep_line.split_once(" = ") { + external_deps.push(( + name.trim().to_string(), + ver.trim().trim_matches('"').to_string(), + )); + } + idx += 1; + } else { + break; + } + } + continue; + } + } + + in_header = false; + + if raw_line.contains("fn main") { + has_main = true; + } + if raw_line.contains("gen_program!") { + has_gen_program = true; + } + + code_lines.push(raw_line.to_string()); + idx += 1; + } + + if code_lines.is_empty() { + return None; + } + + Some(CodeBlock { + source_file: source_file.to_string(), + line: line_num, + code: code_lines.join("\n"), + features, + has_features_header, + not_verified, + external_deps, + has_main, + has_gen_program, + is_build_time, + }) +} + +/// Generate a Cargo.toml for a block +/// +/// `manifest_path` is the full path to the Cargo.toml file being written; it is used to +/// compute the relative path to the `mingling` crate. +pub fn generate_cargo_toml(block: &CodeBlock, package_name: &str, manifest_path: &Path) -> String { + let features_str = if !block.features.is_empty() { + let feats: Vec<String> = block.features.iter().map(|f| format!("\"{f}\"")).collect(); + format!("features = [{}]", feats.join(", ")) + } else { + String::new() + }; + + let mut extra_deps = String::new(); + for (name, version) in &block.external_deps { + if !version.starts_with('{') { + if name == "serde" || name == "clap" { + extra_deps.push_str(&format!( + "{name} = {{ version = \"{version}\", features = [\"derive\"] }}\n" + )); + } else { + extra_deps.push_str(&format!("{name} = \"{version}\"\n")); + } + } else { + extra_deps.push_str(&format!("{name} = {version}\n")); + } + } + + let mingling_path = find_mingling_relative_path(manifest_path); + + let deps_section = if features_str.is_empty() { + format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}",) + } else { + format!( + "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}", + ) + }; + + // Build-time blocks: mirror the declared features into [build-dependencies] + // so that build.rs can use the same feature set as the crate itself. + let build_deps_section = if block.is_build_time { + let feats_str: Vec<String> = block.features.iter().map(|f| format!("\"{f}\"")).collect(); + let build_feats = if feats_str.is_empty() { + String::new() + } else { + format!("features = [{}]", feats_str.join(", ")) + }; + format!( + "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n" + ) + } else { + String::new() + }; + + format!( + r#"[package] + name = "{package_name}" + version = "0.0.0" + edition = "2024" + +{deps_section}{build_deps_section} +[workspace] +"# + ) +} + +/// Compute the relative path from a Cargo.toml's parent directory to the `mingling` crate. +/// +/// The process current directory is expected to be the project root (where `mingling/` lives). +/// Returns a forward-slash path safe for embedding in TOML strings. +fn find_mingling_relative_path(manifest_path: &Path) -> String { + let manifest_dir = manifest_path + .parent() + .expect("manifest_path has no parent directory"); + let cwd = std::env::current_dir().expect("failed to get current directory"); + + // Strip cwd prefix to get the relative components of the manifest directory + let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir); + let depth = relative_to_root.components().count(); + + let mut result = String::new(); + for _ in 0..depth { + result.push_str("../"); + } + result.push_str("mingling"); + result +} + +/// Generate main.rs for a block +/// +/// Automatically prepends `use mingling::prelude::*;` if the block doesn't already have it. +pub fn generate_main_rs(block: &CodeBlock) -> String { + let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); + + if !block.code.contains("use mingling::prelude::*;") { + output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n"); + } + + output.push_str(&block.code); + output.push('\n'); + + if !block.has_main { + output.push_str("\nfn main() {}\n"); + } + + if !block.has_gen_program { + output.push_str("\nmingling::macros::gen_program!();\n"); + } + + output +} + +/// Generate build.rs for a build-time block +/// +/// Default: code wrapped in `fn main() { }`. +pub fn generate_build_rs(block: &CodeBlock) -> String { + let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); + + if block.has_main { + output.push_str(&block.code); + } else { + output.push_str("fn main() {\n"); + for line in block.code.lines() { + output.push_str(" "); + output.push_str(line); + output.push('\n'); + } + output.push_str("}\n"); + } + + output +} + +/// Build a single code block as a Cargo project. +/// +/// When `is_build_time` is true, `src_content` is written to `build.rs` instead of `src/main.rs`, +/// and a minimal `src/main.rs` stub (`fn main() {}`) is created. +pub fn build_block( + src_dir: &Path, + manifest_path: &Path, + cargo_toml: &str, + src_content: &str, + is_build_time: bool, +) -> (bool, String) { + if let Err(e) = std::fs::create_dir_all(src_dir) { + return (false, format!("mkdir: {e}")); + } + + // Write Cargo.toml + if let Err(e) = std::fs::write(manifest_path, cargo_toml) { + return (false, format!("write Cargo.toml: {e}")); + } + + if is_build_time { + // Write build.rs and a stub main.rs + let crate_dir = manifest_path.parent().unwrap(); + if let Err(e) = std::fs::write(crate_dir.join("build.rs"), src_content) { + return (false, format!("write build.rs: {e}")); + } + if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") { + return (false, format!("write main.rs: {e}")); + } + } else { + // Normal: write src/main.rs + if let Err(e) = std::fs::write(src_dir.join("main.rs"), src_content) { + return (false, format!("write main.rs: {e}")); + } + } + + // Check code — inherit stderr so cargo output is real-time and colored + let shell = if cfg!(target_os = "windows") { + "powershell" + } else { + "sh" + }; + let cmd = format!( + "cargo check --color=always --manifest-path {}", + manifest_path.to_string_lossy() + ); + + let mut child = match std::process::Command::new(shell) + .arg("-c") + .arg(&cmd) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::piped()) + .current_dir(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))) + .spawn() + { + Ok(c) => c, + Err(e) => return (false, format!("spawn: {e}")), + }; + + // Read stderr (buffered, not forwarded — groups print their own output contiguously) + use std::io::BufRead; + let stderr_handle = child.stderr.take().unwrap(); + let reader = std::io::BufReader::new(stderr_handle); + let mut captured = String::new(); + for line in reader.lines() { + match line { + Ok(l) => { + captured.push_str(&l); + captured.push('\n'); + } + Err(_) => break, + } + } + + let status = child.wait().unwrap_or_else(|_| std::process::exit(1)); + let exit_code = status.code().unwrap_or(1); + + if exit_code == 0 { + (true, String::new()) + } else { + let mut last_lines: Vec<&str> = captured.lines().rev().take(20).collect(); + last_lines.reverse(); + let detail = last_lines.join("\n"); + (false, format!("exit code {exit_code}\n{detail}")) + } +} + +/// Compute a stable hash for a code block based on its dependency configuration. +/// +/// Blocks with the same features and external dependencies produce the same hash, +/// allowing them to share a compiled crate and avoid redundant recompilation. +/// +/// Hash input (all sorted for stability): +/// - Sorted mingling feature strings +/// - Sorted external dependency names +/// - Sorted external dependency versions +/// - Sorted external deps as `name=version` pairs +pub fn compute_block_hash(block: &CodeBlock) -> String { + let mut features: Vec<&str> = block.features.iter().map(|s| s.as_str()).collect(); + features.sort(); + let features_str = features.join(","); + + let mut dep_names: Vec<&str> = block + .external_deps + .iter() + .map(|(n, _)| n.as_str()) + .collect(); + dep_names.sort(); + let dep_names_str = dep_names.join(","); + + let mut dep_versions: Vec<&str> = block + .external_deps + .iter() + .map(|(_, v)| v.as_str()) + .collect(); + dep_versions.sort(); + let dep_versions_str = dep_versions.join(","); + + let mut deps: Vec<String> = block + .external_deps + .iter() + .map(|(n, v)| format!("{n}={v}")) + .collect(); + deps.sort(); + let deps_str = deps.join(","); + + let canonical = format!("{features_str}\n{dep_names_str}\n{dep_versions_str}\n{deps_str}"); + + // FNV-1a 64-bit hash — stable across runs (no random seed) + let mut hash: u64 = 0xcbf29ce484222325; + for &byte in canonical.as_bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + + format!("{:016x}", hash) +} + +/// Determine if a block should be treated as a test candidate. +/// A block is NOT testable only if it has `// NOT VERIFIED` marker. +pub fn is_block_testable(block: &CodeBlock) -> bool { + !block.not_verified +} + +/// Write a summary report +pub fn write_summary_report( + path: &Path, + title: &str, + results: &[(String, usize, bool, String)], + total: usize, + passed: usize, + failed: usize, +) { + let mut content = String::new(); + content.push_str(&format!("# {title}\n\n")); + content.push_str(&format!( + "Tested **{total}** code blocks: **{passed}** passed, **{failed}** failed.\n\n" + )); + content.push_str("## Results\n\n"); + content.push_str("| Block | File | Line | Status |\n"); + content.push_str("|-------|------|------|--------|\n"); + + for (i, (file, line, ok, _)) in results.iter().enumerate() { + let status = if *ok { "PASS" } else { "FAIL" }; + let short_file = file.rsplit('/').next().unwrap_or(file); + content.push_str(&format!( + "| {} | {} | {} | {status} |\n", + i + 1, + short_file, + line + )); + } + + let has_failures = results.iter().any(|(_, _, ok, _)| !ok); + if has_failures { + content.push_str("\n## Failed Blocks\n\n"); + for (i, (file, line, ok, err)) in results.iter().enumerate() { + if !ok { + content.push_str(&format!( + "### Block {} (`{}`, line {})\n\n```\n{err}\n```\n\n", + i + 1, + file, + line + )); + } + } + } + + std::fs::write(path, &content).unwrap_or_else(|e| { + eprintln!("Warning: failed to write {path:?}: {e}"); + }); + + println_cargo_style!("Report: written to {}", path.display()); +} |
