diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-18 11:45:59 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-18 11:45:59 +0800 |
| commit | e79cb161eee49e1ac2b86f1469dda1e2af4c1aeb (patch) | |
| tree | d5ffe83a94bffc6608d83094360a3b4dd1cf8c66 | |
| parent | d41a33f7edc860624e4fb67000e2fc470a8d0df4 (diff) | |
chore(ci): remove tool binaries from .run/src/bin
| -rw-r--r-- | .run/src/bin/check-docs-structure.rs | 352 | ||||
| -rw-r--r-- | .run/src/bin/ci.rs | 464 | ||||
| -rw-r--r-- | .run/src/bin/docs-code-box-fix.rs | 166 | ||||
| -rw-r--r-- | .run/src/bin/docsify-sidebar-gen.rs | 262 | ||||
| -rw-r--r-- | .run/src/bin/refresh-docs.rs | 178 | ||||
| -rw-r--r-- | .run/src/bin/refresh-feature-mod.rs | 97 | ||||
| -rw-r--r-- | .run/src/bin/sync-examples.rs | 131 | ||||
| -rw-r--r-- | .run/src/bin/test-all-markdown-code.rs | 261 | ||||
| -rw-r--r-- | .run/src/bin/test-examples.rs | 197 |
9 files changed, 0 insertions, 2108 deletions
diff --git a/.run/src/bin/check-docs-structure.rs b/.run/src/bin/check-docs-structure.rs deleted file mode 100644 index ac13da2..0000000 --- a/.run/src/bin/check-docs-structure.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Checks that every translated docs directory mirrors the structure of the -//! reference (English) docs directory exactly. -//! -//! The language directories are declared in `.config/docs-lang.txt`, one path -//! per line (relative to `./docs/`). The first line is the reference -//! directory; every other line is a translation that must match it. -//! -//! For each file pair the tool compares a *structural signature*: one token per -//! line, classifying headings (both Markdown `#` and HTML `<hN>`), fenced code -//! blocks (including their language tag), `@@@` hidden-compilation lines, blank -//! lines, blockquotes, lists and plain text. Translated text is allowed to -//! differ; the structure is not. - -use std::collections::BTreeSet; -use std::fs; -use std::path::{Path, PathBuf}; - -use colored::Colorize; -use tools::println_cargo_style; - -const DOCS_DIR: &str = "./docs"; -const LANG_CONFIG: &str = ".config/docs-lang.txt"; - -fn main() { - println_cargo_style!("Checking: docs structure consistency across languages ..."); - - let repo_root = find_git_repo().expect("Cannot find git repo root"); - let docs_dir = repo_root.join(DOCS_DIR); - - let lang_lines = read_lang_config(&repo_root); - if lang_lines.is_empty() { - println!("No language directories declared in {LANG_CONFIG}, nothing to check."); - return; - } - - let reference = docs_dir.join(&lang_lines[0]); - if !reference.is_dir() { - eprintln!( - "Reference docs directory `{}` does not exist.", - reference.display() - ); - std::process::exit(1); - } - - let mut failed = false; - - for lang in &lang_lines[1..] { - let lang_dir = docs_dir.join(lang); - println!("\nChecking `{lang}` against `{}` ...", lang_lines[0]); - if !lang_dir.is_dir() { - eprintln!(" ERROR: `{}` does not exist.", lang_dir.display()); - failed = true; - continue; - } - if check_lang_dir(&reference, &lang_dir).is_err() { - failed = true; - } - } - - if failed { - println!(); - eprintln!( - "{} Fix the differences above.", - "Docs structure check FAILED.".red().bold() - ); - std::process::exit(1); - } - - println_cargo_style!("Done: docs structure is consistent across all languages!"); -} - -fn read_lang_config(repo_root: &Path) -> Vec<String> { - let path = repo_root.join(LANG_CONFIG); - let Ok(content) = fs::read_to_string(&path) else { - return Vec::new(); - }; - content - .lines() - .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .map(|l| l.trim_start_matches("./").to_string()) - .collect() -} - -/// Returns `Err(())` when the translated directory does not mirror the reference. -fn check_lang_dir(reference: &Path, lang: &Path) -> Result<(), ()> { - let mut failed = false; - - let ref_files = collect_md_files(reference); - let lang_files = collect_md_files(lang); - - let ref_set: BTreeSet<PathBuf> = ref_files.clone().into_iter().collect(); - let lang_set: BTreeSet<PathBuf> = lang_files.clone().into_iter().collect(); - - let missing: Vec<PathBuf> = ref_set.difference(&lang_set).cloned().collect(); - let extra: Vec<PathBuf> = lang_set.difference(&ref_set).cloned().collect(); - - if !missing.is_empty() { - failed = true; - println!(" ERROR: files missing in translation:"); - for f in &missing { - println!(" - {}", f.display()); - } - } - if !extra.is_empty() { - failed = true; - println!(" ERROR: extra files in translation:"); - for f in &extra { - println!(" - {}", f.display()); - } - } - - // Compare the structural signature of every file present in both sides. - for file in &ref_files { - if !lang_set.contains(file) { - continue; - } - let ref_path = reference.join(file); - let lang_path = lang.join(file); - match compare_signature(&ref_path, &lang_path) { - Ok(()) => {} - Err(diff) => { - failed = true; - eprintln!( - " {}: structure mismatch in `{}`", - "ERROR".red().bold(), - file.display().to_string().cyan() - ); - for line in diff { - println!(" {line}"); - } - } - } - } - - if failed { Err(()) } else { Ok(()) } -} - -fn collect_md_files(dir: &Path) -> Vec<PathBuf> { - let mut out = Vec::new(); - let mut stack = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - let Ok(entries) = fs::read_dir(¤t) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.extension().is_some_and(|e| e == "md") { - out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf()); - } - } - } - out.sort(); - out -} - -/// Compare the structural signatures of two markdown files. -/// -/// Returns a list of human-readable diff lines on the first structural -/// difference found (all differences up to a small window are reported). -fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<String>> { - let ref_content = fs::read_to_string(ref_path).unwrap_or_default(); - let lang_content = fs::read_to_string(lang_path).unwrap_or_default(); - - let ref_sig = signature_of(&ref_content); - let lang_sig = signature_of(&lang_content); - - if ref_sig == lang_sig { - return Ok(()); - } - - let ref_lines: Vec<&str> = ref_content.lines().collect(); - let lang_lines: Vec<&str> = lang_content.lines().collect(); - - let mut diffs = Vec::new(); - let mut window = 0; - let max = ref_sig.len().max(lang_sig.len()); - for i in 0..max { - let ref_tok = ref_sig.get(i); - let lang_tok = lang_sig.get(i); - if ref_tok == lang_tok { - continue; - } - if window >= 5 { - diffs.push(format!("... ({}-line window truncated)", max - i)); - break; - } - window += 1; - let ref_line = ref_lines.get(i).copied().unwrap_or("<missing>"); - let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>"); - diffs.push(format!( - " {}: {}", - "line".yellow().bold(), - (i + 1).to_string().yellow() - )); - diffs.push(format!( - " {} : {} {}", - "expect".green().bold(), - format!("`{}`", token_label(ref_tok.map_or("<eof>", String::as_str))).green(), - display_line(ref_line).cyan() - )); - diffs.push(format!( - " {} : {} {}", - "found".red().bold(), - format!( - "`{}`", - token_label(lang_tok.map_or("<eof>", String::as_str)) - ) - .red(), - display_line(lang_line).cyan() - )); - if ref_sig.len() != lang_sig.len() && window >= 5 { - diffs.push(format!( - " note: reference has {} lines, translation has {} lines", - ref_sig.len(), - lang_sig.len() - )); - break; - } - } - if diffs.is_empty() { - diffs.push("signatures differ in length (see line count note)".to_string()); - } - Err(diffs) -} - -/// Human-readable label for a structural token. -fn token_label(token: &str) -> String { - match token { - "B" => "blank".to_string(), - "A" => "@@@".to_string(), - "Q" => "quote".to_string(), - "L" => "list".to_string(), - "P" => "text".to_string(), - t if t.starts_with("H") => format!("heading-{}", &t[1..]), - t if t.starts_with("F:") => { - let lang = &t[2..]; - if lang.is_empty() { - "fence".to_string() - } else { - format!("fence:{lang}") - } - } - _ => token.to_string(), - } -} - -/// Render a source line for display: blank lines become `<blank>`. -fn display_line(line: &str) -> String { - if line.trim().is_empty() { - "<blank>".to_string() - } else { - truncate(line) - } -} - -/// Build the structural signature of a markdown file. -fn signature_of(content: &str) -> Vec<String> { - let mut sig = Vec::new(); - let mut in_fence = false; - let mut fence_lang = String::new(); - - for raw_line in content.lines() { - let line = raw_line.trim(); - - if in_fence { - if line.starts_with("```") { - in_fence = false; - sig.push(format!("F:{}", fence_lang)); - } else if line.starts_with("@@@") { - sig.push("A".to_string()); - } else if line.is_empty() { - sig.push("B".to_string()); - } else { - sig.push("P".to_string()); - } - continue; - } - - if line.starts_with("```") { - in_fence = true; - fence_lang = line.trim_start_matches("```").trim().to_string(); - sig.push(format!("F:{fence_lang}")); - } else if line.starts_with('#') { - let level = line.chars().take_while(|c| *c == '#').count(); - sig.push(format!("H{level}")); - } else if line.starts_with("<h") || line.starts_with("</h") { - // HTML headings (e.g. `<h1 align="center">` / `</h1>`) - let level = line - .trim_start_matches(['<', '/']) - .chars() - .next() - .and_then(|c| c.to_digit(10)) - .unwrap_or(1); - sig.push(format!("H{level}")); - } else if line.starts_with("@@@") { - sig.push("A".to_string()); - } else if line.is_empty() { - sig.push("B".to_string()); - } else if line.starts_with('>') { - sig.push("Q".to_string()); - } else if is_list_line(line) { - sig.push("L".to_string()); - } else { - sig.push("P".to_string()); - } - } - - // An unclosed fence is still a fence line; the signature already recorded it. - sig -} - -fn is_list_line(line: &str) -> bool { - let trimmed = line.trim_start(); - trimmed.starts_with("- ") - || trimmed.starts_with("* ") - || trimmed.starts_with("+ ") - || is_numbered_list(trimmed) -} - -/// A numbered list item: `1. text`, `1) text`, `10. text`, ... -fn is_numbered_list(line: &str) -> bool { - let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count(); - if digit_count == 0 { - return false; - } - let rest = &line[digit_count..]; - (rest.starts_with(". ") || rest.starts_with(") ")) - && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t') -} - -fn truncate(line: &str) -> String { - const MAX: usize = 60; - if line.chars().count() <= MAX { - line.to_string() - } else { - let cut: String = line.chars().take(MAX).collect(); - format!("{cut}...") - } -} - -fn find_git_repo() -> Option<PathBuf> { - let mut current = std::env::current_dir().ok()?; - loop { - if current.join(".git").is_dir() { - return Some(current); - } - current = current.parent()?.to_path_buf(); - } -} diff --git a/.run/src/bin/ci.rs b/.run/src/bin/ci.rs deleted file mode 100644 index b6d92b8..0000000 --- a/.run/src/bin/ci.rs +++ /dev/null @@ -1,464 +0,0 @@ -use std::io::Write as _; -use std::path::{Path, PathBuf}; -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()] -} - -/// A single CI step, each individually toggleable via `--check-*`. -struct Checks { - build: bool, - clippy: bool, - test: bool, - arg_picker: bool, - markdown_code: bool, - examples: bool, - docs_refresh: bool, - docs_structure: bool, - api_docs: bool, -} - -impl Checks { - fn any(&self) -> bool { - self.build - || self.clippy - || self.test - || self.arg_picker - || self.markdown_code - || self.examples - || self.docs_refresh - || self.docs_structure - || self.api_docs - } -} - -fn print_help() { - println!( - r" -Usage: ci [options] -Options: - -h, --help Print this help message - -y Auto-confirm temporary commits - --dirty Run CI on dirty workspace (skip temp commit & clean check) - --check-build Build all crates - --check-clippy Run clippy on all crates (-D warnings) - --check-test Run unit tests for all crates - --check-arg-picker Test the arg-picker crate - --check-markdown-code Verify all *.md code blocks compile - --check-examples Test all examples - --check-docs-refresh Refresh docs and fail if the tree is contaminated - --check-docs-structure Verify translated docs mirror the English structure - --check-api-docs Build API docs with docs.rs features - -If no specific options are given, all checks are run. - " - ); -} - -fn main() { - #[cfg(windows)] - let _ = colored::control::set_virtual_terminal(true); - println!("{}", include_str!("../../../docs/res/ci_banner.txt")); - - let ( - auto_yes, - dirty, - check_build, - check_clippy, - check_test, - check_arg_picker, - check_markdown_code, - check_examples, - check_docs_refresh, - check_docs_structure, - check_api_docs, - help, - ) = Picker::from_args() - .pick_or_default(&arg![yes: bool, 'y']) - .pick_or_default(&arg![dirty: bool]) - .pick_or_default(&arg![check_build: bool]) - .pick_or_default(&arg![check_clippy: bool]) - .pick_or_default(&arg![check_test: bool]) - .pick_or_default(&arg![check_arg_picker: bool]) - .pick_or_default(&arg![check_markdown_code: bool]) - .pick_or_default(&arg![check_examples: bool]) - .pick_or_default(&arg![check_docs_refresh: bool]) - .pick_or_default(&arg![check_docs_structure: bool]) - .pick_or_default(&arg![check_api_docs: bool]) - .pick_or_default(&arg![help: bool, 'h']) - .unwrap(); - - if help { - print_help(); - return; - } - - let checks = Checks { - build: check_build, - clippy: check_clippy, - test: check_test, - arg_picker: check_arg_picker, - markdown_code: check_markdown_code, - examples: check_examples, - docs_refresh: check_docs_refresh, - docs_structure: check_docs_structure, - api_docs: check_api_docs, - }; - let run_all = !checks.any(); - - let needs_commit_temp = !dirty && !{ run_cmd!("git diff-index --quiet HEAD --").is_ok() }; - - 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(&checks, 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(()) -} - -/// Run one CI step. -/// -/// When `continue_on_error` is set (used for the documentation steps in -/// "run all" mode), a failing step is recorded and the remaining steps still -/// execute, so every problem is reported in a single run. -fn run_step( - exit_code: &mut i32, - phase: &str, - step: fn() -> Result<(), i32>, - continue_on_error: bool, -) -> Result<(), i32> { - println_cargo_style!(phase); - match step() { - Ok(()) => Ok(()), - Err(code) if continue_on_error => { - *exit_code = (*exit_code).max(code); - Ok(()) - } - Err(code) => Err(code), - } -} - -fn ci(checks: &Checks, run_all: bool) -> Result<(), i32> { - let mut exit_code = 0; - - if run_all || checks.build { - run_step( - &mut exit_code, - "Phase: Scan and build all crates", - build_all, - false, - )?; - } - if run_all || checks.clippy { - run_step( - &mut exit_code, - "Phase: Run clippy for all crates", - clippy_all, - false, - )?; - } - if run_all || checks.test { - run_step(&mut exit_code, "Phase: Test all crates", test_all, false)?; - } - if run_all || checks.arg_picker { - run_step( - &mut exit_code, - "Phase: Test arg picker", - test_arg_picker, - false, - )?; - } - - if run_all || checks.markdown_code { - run_step( - &mut exit_code, - "Phase: Verify all *.md document code blocks are compilable", - test_docs_code_blocks, - run_all, - )?; - } - if run_all || checks.examples { - run_step( - &mut exit_code, - "Phase: Test all examples", - test_examples, - run_all, - )?; - } - if run_all || checks.docs_refresh { - run_step( - &mut exit_code, - "Phase: Check all documentation is up to date", - docs_refresh, - run_all, - )?; - } - if run_all || checks.docs_structure { - run_step( - &mut exit_code, - "Phase: Check translated docs structure consistency", - docs_structure, - run_all, - )?; - } - if run_all || checks.api_docs { - run_step( - &mut exit_code, - "Phase: Try Build API docs", - deploy_api_docs, - run_all, - )?; - } - - if exit_code != 0 { - return Err(exit_code); - } - - 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" - ) -} - -/// Returns the manifest paths of all workspace members (via `cargo metadata --no-deps`). -/// -/// These crates are tested/built/clipped together with `--workspace` so that -/// feature-gated code is covered, instead of relying on each crate's default features. -fn workspace_manifests() -> Vec<PathBuf> { - let Ok(output) = tools::run_cmd_capture("cargo metadata --no-deps --format-version 1") 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["manifest_path"].as_str().map(PathBuf::from)) - .collect() -} - -fn same_path(a: &Path, b: &Path) -> bool { - let norm = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); - norm(a) == norm(b) -} - -fn build_all() -> Result<(), i32> { - let ignore_dirs = get_ignore_dirs(); - let cargo_tomls = cargo_tomls(); - let workspace_manifests = workspace_manifests(); - let mut tasks = Vec::new(); - - // Workspace members: build with all documented features (same set used by cov-test) - let features_arg = doc_features_arg(); - tasks.push(( - "Build: workspace".to_string(), - "workspace".to_string(), - format!("cargo build --workspace{features_arg} --color always"), - )); - - for cargo_toml in cargo_tomls { - let path = cargo_toml.parent().unwrap_or(Path::new("")); - let path_str = path.to_string_lossy(); - if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { - continue; - } - if workspace_manifests - .iter() - .any(|m| same_path(m, &cargo_toml)) - { - 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 workspace_manifests = workspace_manifests(); - let mut tasks = Vec::new(); - - // Workspace members: clippy with all documented features - let features_arg = doc_features_arg(); - tasks.push(( - "Clippy: workspace".to_string(), - "workspace".to_string(), - format!("cargo clippy --workspace{features_arg} --color always -- -D warnings"), - )); - - for cargo_toml in cargo_tomls { - let path = cargo_toml.parent().unwrap_or(Path::new("")); - let path_str = path.to_string_lossy(); - if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { - continue; - } - if workspace_manifests - .iter() - .any(|m| same_path(m, &cargo_toml)) - { - 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) -} - -/// ` --features "<docs.rs features>"` (empty string when unavailable) -fn doc_features_arg() -> String { - match tools::read_features() { - Ok(features) if !features.is_empty() => format!(" --features \"{}\"", features.join(",")), - _ => String::new(), - } -} - -fn test_all() -> Result<(), i32> { - let ignore_dirs = get_ignore_dirs(); - let cargo_tomls = cargo_tomls(); - let workspace_manifests = workspace_manifests(); - let mut tasks = Vec::new(); - - // Workspace members: test with all documented features so that feature-gated - // tests (comp/repl/picker/structural_renderer/...) are actually executed. - // `arg-picker` is excluded here and tested separately via [`test_arg_picker`]. - let features_arg = doc_features_arg(); - tasks.push(( - "Test: workspace".to_string(), - "workspace".to_string(), - format!("cargo test --workspace{features_arg} --exclude arg-picker --color always"), - )); - - for cargo_toml in cargo_tomls { - let path = cargo_toml.parent().unwrap_or(Path::new("")); - let path_str = path.to_string_lossy(); - if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { - continue; - } - if workspace_manifests - .iter() - .any(|m| same_path(m, &cargo_toml)) - { - continue; - } - let label = format!("Test: {}", 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) -} - -/// `arg-picker` is excluded from the workspace test command: when built with -/// `mingling_support` (enabled via `mingling/picker`), its README doctests -/// expand `arg!` to `::mingling::picker::PickerArg`, which is not available -/// inside the arg-picker crate itself. Test it separately with its default -/// features instead. -fn test_arg_picker() -> Result<(), i32> { - run_cmd!("cargo test -p arg-picker --color always") -} - -fn deploy_api_docs() -> Result<(), i32> { - run_cmd!( - "cargo run --manifest-path .run/Cargo.toml --color always --bin deploy-api-docs -- --docsrs" - ) -} - -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(()) -} - -fn docs_structure() -> Result<(), i32> { - println_cargo_style!("Check: docs structure consistency across languages"); - - run_cmd!("cargo run --manifest-path .run/Cargo.toml --bin check-docs-structure") -} diff --git a/.run/src/bin/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 15ae184..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/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 4cd6532..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::{Template, tmpl}; -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 35c8bbe..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(".config/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-examples.rs b/.run/src/bin/test-examples.rs deleted file mode 100644 index 617a745..0000000 --- a/.run/src/bin/test-examples.rs +++ /dev/null @@ -1,197 +0,0 @@ -use std::path::Path; - -use colored::Colorize; -use indicatif::ProgressBar; -use serde::Deserialize; -use tools::{eprintln_cargo_style, println_cargo_style, run_parallel}; - -/// An example's `test.toml` (`[[runs]]` entries). -#[derive(Deserialize)] -struct TestConfig { - runs: Vec<TestCase>, -} - -/// A single `[[runs]]` entry of an example's `test.toml`. -#[derive(Deserialize)] -struct TestCase { - input: Vec<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 configs = load_all_test_configs(); - - // Phase 1: build all examples in parallel. - if let Err(code) = build_all_examples(&configs) { - // `run_parallel` already printed every failed build above. - std::process::exit(code); - } - - // Phase 2: run the tests serially against the pre-built binaries. - let total: usize = configs.iter().map(|(_, cases)| cases.len()).sum(); - let bar = ProgressBar::new(total as u64); - bar.set_style( - indicatif::ProgressStyle::default_bar() - .template(&format!( - "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", - " Testing".bold().bright_cyan() - )) - .unwrap() - .progress_chars("=> "), - ); - bar.set_message("examples"); - - let passed = run_all_tests(&configs, &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); - } -} - -/// Load `examples/<name>/test.toml` for every example that has one, in -/// alphabetical order of the example directory name. -fn load_all_test_configs() -> Vec<(String, Vec<TestCase>)> { - let examples_dir = Path::new("examples"); - let mut configs = Vec::new(); - - let entries = std::fs::read_dir(examples_dir).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read examples dir: {}", e); - std::process::exit(1); - }); - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let test_toml = path.join("test.toml"); - if !test_toml.is_file() { - continue; - } - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default() - .to_string(); - let content = std::fs::read_to_string(&test_toml).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read {}: {}", test_toml.display(), e); - std::process::exit(1); - }); - let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to parse {}: {}", test_toml.display(), e); - std::process::exit(1); - }); - configs.push((name, config.runs)); - } - - configs.sort_by(|a, b| a.0.cmp(&b.0)); - configs -} - -/// Phase 1: build every example that has a `test.toml` in parallel. -/// -/// Build tasks are spawned in parallel (like `ci.rs`'s `build_all`); on any -/// build failure the whole run aborts with the first failure's exit code. -fn build_all_examples(configs: &[(String, Vec<TestCase>)]) -> Result<(), i32> { - let tasks: Vec<(String, String, String)> = configs - .iter() - .map(|(name, _)| { - ( - format!("Build: {name}"), - name.clone(), - format!("cargo build --manifest-path examples/{name}/Cargo.toml --color always"), - ) - }) - .collect(); - run_parallel("Building", tasks) -} - -/// Phase 2: run all example test groups serially, return number passed -fn run_all_tests(configs: &[(String, Vec<TestCase>)], bar: &ProgressBar) -> usize { - let mut passed = 0; - - for (example_name, test_cases) in configs { - bar.set_message(example_name.clone()); - - for test_case in test_cases { - if run_single_test(example_name, test_case, bar) { - passed += 1; - } - bar.inc(1); - } - } - - passed -} - -/// 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 command = test_case.input.join(" "); - - let output = match std::process::Command::new(&binary_path) - .args(&test_case.input) - .output() - { - Ok(o) => o, - Err(e) => { - bar.println(format!("'{command}' - failed to run: {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: '{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() - } -} |
