diff options
Diffstat (limited to '.run/src/bin')
| -rw-r--r-- | .run/src/bin/build-all.ps1 | 8 | ||||
| -rw-r--r-- | .run/src/bin/build-all.sh | 6 | ||||
| -rw-r--r-- | .run/src/bin/ci.rs | 225 | ||||
| -rw-r--r-- | .run/src/bin/clippy-fix.ps1 | 8 | ||||
| -rw-r--r-- | .run/src/bin/clippy-fix.sh | 6 | ||||
| -rw-r--r-- | .run/src/bin/clippy.ps1 | 8 | ||||
| -rw-r--r-- | .run/src/bin/clippy.sh | 6 | ||||
| -rw-r--r-- | .run/src/bin/doc.ps1 | 1 | ||||
| -rw-r--r-- | .run/src/bin/doc.sh | 3 | ||||
| -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/http-page-preview.ps1 | 3 | ||||
| -rw-r--r-- | .run/src/bin/http-page-preview.sh | 2 | ||||
| -rw-r--r-- | .run/src/bin/install-mling.ps1 | 7 | ||||
| -rw-r--r-- | .run/src/bin/install-mling.sh | 6 | ||||
| -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 | 276 | ||||
| -rw-r--r-- | .run/src/bin/test-all.ps1 | 8 | ||||
| -rw-r--r-- | .run/src/bin/test-all.sh | 6 | ||||
| -rw-r--r-- | .run/src/bin/test-examples.rs | 158 | ||||
| -rw-r--r-- | .run/src/bin/update-version.rs | 98 | ||||
| -rw-r--r-- | .run/src/bin/windows-folder-hide.ps1 | 43 |
24 files changed, 1712 insertions, 0 deletions
diff --git a/.run/src/bin/build-all.ps1 b/.run/src/bin/build-all.ps1 new file mode 100644 index 0000000..4f35ed8 --- /dev/null +++ b/.run/src/bin/build-all.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 build + Pop-Location +} +Set-Location $starting_dir diff --git a/.run/src/bin/build-all.sh b/.run/src/bin/build-all.sh new file mode 100644 index 0000000..2036b41 --- /dev/null +++ b/.run/src/bin/build-all.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 build) +done diff --git a/.run/src/bin/ci.rs b/.run/src/bin/ci.rs new file mode 100644 index 0000000..f8aed79 --- /dev/null +++ b/.run/src/bin/ci.rs @@ -0,0 +1,225 @@ +use std::io::Write as _; +use std::process::exit; + +use tools::{ + cargo_tomls, crate_name_from, eprintln_cargo_style, println_cargo_style, run_cmd, run_parallel, +}; + +fn get_ignore_dirs() -> Vec<String> { + vec![ + ".temp".to_string(), + "mling/res".to_string(), + "mling\\res".to_string(), + ] +} + +fn print_help() { + println!( + r" +Usage: ci [options] +Options: + -h, --help Print this help message + -y Auto-confirm temporary commits + --dirty Run CI on dirty workspace (skip temp commit & clean check) + --refresh-docs Refresh documentation files + --test-docs Run documentation tests (build, clippy, test) + --test-codes Test examples and documentation code blocks + +If no specific options are given, all checks are run. + " + ); +} + +fn main() { + #[cfg(windows)] + let _ = colored::control::set_virtual_terminal(true); + println!("{}", include_str!("../../../docs/res/ci_banner.txt")); + + let args: Vec<String> = std::env::args().collect(); + + if args.iter().any(|a| a == "-h" || a == "--help") { + print_help(); + return; + } + + let auto_yes = args.iter().any(|a| a == "-y"); + let dirty = args.iter().any(|a| a == "--dirty"); + + let test_docs = args.iter().any(|a| a == "--test-docs"); + let refresh_docs = args.iter().any(|a| a == "--refresh-docs"); + let test_codes = args.iter().any(|a| a == "--test-codes"); + let any_specified = test_docs || refresh_docs || test_codes; + let run_all = !any_specified; + + let needs_commit_temp = !dirty && !{ run_cmd!("git diff-index --quiet HEAD --").is_ok() }; + + if needs_commit_temp { + if auto_yes { + run_cmd!("git add .").unwrap(); + run_cmd!("git commit -m \"[DO NOT PUSH] CI TEMP [DO NOT PUSH]\"").unwrap(); + } else { + print!("Working tree is not clean, temporarily commit? [y/N]:"); + std::io::stdout().flush().unwrap(); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); + if input == "y" || input == "Y" || input == "yes" || input == "Yes" { + run_cmd!("git add .").unwrap(); + run_cmd!("git commit -m \"[DO NOT PUSH] CI TEMP [DO NOT PUSH]\"").unwrap(); + } else { + eprintln_cargo_style!("Aborting."); + exit(2) + } + } + } + + if let Err(exit_code) = ci(test_docs, test_codes, run_all) { + restore_workspace(needs_commit_temp).unwrap(); + exit(exit_code) + } + + if !dirty { + let is_worktree_clean = run_cmd!("git diff-index --quiet HEAD --").is_ok(); + if !is_worktree_clean { + eprintln_cargo_style!("The repository was contaminated during CI, failing!"); + + // Print git status + println!(); + let _ = run_cmd!("git status"); + + if needs_commit_temp { + restore_workspace(true).unwrap(); + } + exit(1) + } + } + + println_cargo_style!("Done: All check passed!"); + + if needs_commit_temp { + restore_workspace(true).unwrap(); + } +} + +fn restore_workspace(undo_commit: bool) -> Result<(), i32> { + run_cmd!("git reset --hard --quiet")?; + if undo_commit { + run_cmd!("git reset --soft HEAD~1 --quiet")?; + run_cmd!("git reset --quiet")?; + } + Ok(()) +} + +fn ci(test_docs: bool, test_codes: bool, run_all: bool) -> Result<(), i32> { + if run_all || test_codes { + println_cargo_style!("Phase: Scan and build all crates"); + build_all()?; + + println_cargo_style!("Phase: Run clippy for all crates"); + clippy_all()?; + + println_cargo_style!("Phase: Test all crates"); + test_all()?; + } + + if run_all || test_docs { + println_cargo_style!("Phase: Test all examples"); + test_examples()?; + + println_cargo_style!("Phase: Verify all *.md document code blocks are compilable"); + test_docs_code_blocks()?; + + println_cargo_style!("Phase: Check all documentation is up to date"); + docs_refresh()?; + } + + run_cmd!("git add --renormalize .")?; + + Ok(()) +} + +fn test_examples() -> Result<(), i32> { + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --color always --bin test-examples") +} + +fn test_docs_code_blocks() -> Result<(), i32> { + run_cmd!( + "cargo run --manifest-path dev_tools/Cargo.toml --color always --bin test-all-markdown-code" + ) +} + +fn build_all() -> Result<(), i32> { + let ignore_dirs = get_ignore_dirs(); + let cargo_tomls = cargo_tomls(); + let mut tasks = Vec::new(); + for cargo_toml in cargo_tomls { + let path = cargo_toml.parent().unwrap_or(std::path::Path::new("")); + let path_str = path.to_string_lossy(); + if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { + continue; + } + let label = format!("Build: {}", cargo_toml.to_string_lossy()); + let crate_name = crate_name_from(&cargo_toml); + let cmd = format!( + "cargo build --manifest-path {} --color always", + cargo_toml.to_string_lossy() + ); + tasks.push((label, crate_name, cmd)); + } + run_parallel("Building", tasks) +} + +fn clippy_all() -> Result<(), i32> { + let ignore_dirs = get_ignore_dirs(); + let cargo_tomls = cargo_tomls(); + let mut tasks = Vec::new(); + for cargo_toml in cargo_tomls { + let path = cargo_toml.parent().unwrap_or(std::path::Path::new("")); + let path_str = path.to_string_lossy(); + if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { + continue; + } + let label = format!("Clippy: {}", cargo_toml.to_string_lossy()); + let crate_name = crate_name_from(&cargo_toml); + let cmd = format!( + "cargo clippy --manifest-path {} --color always -- -D warnings", + cargo_toml.to_string_lossy() + ); + tasks.push((label, crate_name, cmd)); + } + run_parallel("Clippy", tasks) +} + +fn test_all() -> Result<(), i32> { + let ignore_dirs = get_ignore_dirs(); + let cargo_tomls = cargo_tomls(); + let mut tasks = Vec::new(); + for cargo_toml in cargo_tomls { + let path = cargo_toml.parent().unwrap_or(std::path::Path::new("")); + let path_str = path.to_string_lossy(); + if ignore_dirs.iter().any(|d| path_str.contains(d.as_str())) { + continue; + } + let label = format!("Testing: {}", cargo_toml.to_string_lossy()); + let crate_name = crate_name_from(&cargo_toml); + let cmd = format!( + "cargo test --manifest-path {} --color always", + cargo_toml.to_string_lossy() + ); + tasks.push((label, crate_name, cmd)); + } + run_parallel("Testing", tasks) +} + +fn docs_refresh() -> Result<(), i32> { + println_cargo_style!("Refresh: document at `./docs/`"); + + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin docs-code-box-fix")?; + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin docsify-sidebar-gen")?; + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin refresh-docs")?; + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin refresh-feature-mod")?; + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin sync-examples")?; + run_cmd!("cargo fmt")?; + + Ok(()) +} diff --git a/.run/src/bin/clippy-fix.ps1 b/.run/src/bin/clippy-fix.ps1 new file mode 100644 index 0000000..1d24f92 --- /dev/null +++ b/.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/.run/src/bin/clippy-fix.sh b/.run/src/bin/clippy-fix.sh new file mode 100644 index 0000000..9771ad4 --- /dev/null +++ b/.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/.run/src/bin/clippy.ps1 b/.run/src/bin/clippy.ps1 new file mode 100644 index 0000000..1858873 --- /dev/null +++ b/.run/src/bin/clippy.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 --quiet + Pop-Location +} +Set-Location $starting_dir diff --git a/.run/src/bin/clippy.sh b/.run/src/bin/clippy.sh new file mode 100644 index 0000000..b393545 --- /dev/null +++ b/.run/src/bin/clippy.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 --quiet) +done diff --git a/.run/src/bin/doc.ps1 b/.run/src/bin/doc.ps1 new file mode 100644 index 0000000..987f0de --- /dev/null +++ b/.run/src/bin/doc.ps1 @@ -0,0 +1 @@ +cargo doc --workspace --no-deps --features builds,structural_renderer,repl,comp,parser,clap,extra_macros --open diff --git a/.run/src/bin/doc.sh b/.run/src/bin/doc.sh new file mode 100644 index 0000000..5e8a311 --- /dev/null +++ b/.run/src/bin/doc.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +cargo doc --workspace --no-deps --features builds,structural_renderer,repl,comp,parser,clap,extra_macros --open diff --git a/.run/src/bin/docs-code-box-fix.rs b/.run/src/bin/docs-code-box-fix.rs new file mode 100644 index 0000000..21d2cce --- /dev/null +++ b/.run/src/bin/docs-code-box-fix.rs @@ -0,0 +1,166 @@ +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 new file mode 100644 index 0000000..5beda7f --- /dev/null +++ b/.run/src/bin/docsify-sidebar-gen.rs @@ -0,0 +1,262 @@ +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/http-page-preview.ps1 b/.run/src/bin/http-page-preview.ps1 new file mode 100644 index 0000000..8cc3579 --- /dev/null +++ b/.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/.run/src/bin/http-page-preview.sh b/.run/src/bin/http-page-preview.sh new file mode 100644 index 0000000..bed4b1c --- /dev/null +++ b/.run/src/bin/http-page-preview.sh @@ -0,0 +1,2 @@ +#!/bin/bash +python3 -m http.server 3000 diff --git a/.run/src/bin/install-mling.ps1 b/.run/src/bin/install-mling.ps1 new file mode 100644 index 0000000..bebe9ff --- /dev/null +++ b/.run/src/bin/install-mling.ps1 @@ -0,0 +1,7 @@ +cargo install --path mling + +New-Item -ItemType Directory -Force -Path .temp/comp | Out-Null +# Copy all files containing _comp from the debug directory +Get-ChildItem .temp/target/release/*_comp* | ForEach-Object { + Copy-Item $_.FullName .temp/comp/ +} diff --git a/.run/src/bin/install-mling.sh b/.run/src/bin/install-mling.sh new file mode 100644 index 0000000..5f2ee7a --- /dev/null +++ b/.run/src/bin/install-mling.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +cargo install --path mling + +mkdir -p .temp/comp +cp .temp/target/release/*_comp.* .temp/comp/ 2>/dev/null || echo "No matching files found" diff --git a/.run/src/bin/refresh-docs.rs b/.run/src/bin/refresh-docs.rs new file mode 100644 index 0000000..82ef906 --- /dev/null +++ b/.run/src/bin/refresh-docs.rs @@ -0,0 +1,178 @@ +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 new file mode 100644 index 0000000..2255dbc --- /dev/null +++ b/.run/src/bin/refresh-feature-mod.rs @@ -0,0 +1,97 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use just_fmt::snake_case; +use just_template::{tmpl, Template}; +use tools::println_cargo_style; + +const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml"; +const OUTPUT_PATH: &str = "./mingling/src/features.rs"; + +const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl"); + +fn main() { + gen_feature_module(); +} + +fn gen_feature_module() { + let repo_root = find_git_repo().unwrap(); + + let cargo_toml_path = repo_root.join(CARGO_TOML_PATH); + let output_path = repo_root.join(OUTPUT_PATH); + + let features = parse_features(&cargo_toml_path); + + let mut template = Template::from(TEMPLATE_CONTENT); + + for feat_name in &features { + let feat_const_name = snake_case!(feat_name).to_uppercase(); + + tmpl!(template += { + features { + ( + feat_name = feat_name, + feat_const_name = feat_const_name + ) + } + }); + println_cargo_style!("Refresh: feature `{}`", feat_name); + } + + let template_str = template.to_string(); + let template_str = template_str + .lines() + .map(str::trim_end) + .collect::<Vec<_>>() + .join("\n") + + "\n"; + std::fs::write(&output_path, template_str).unwrap(); + + println_cargo_style!("Written: features module to {}", OUTPUT_PATH); +} + +/// Parse all feature names from the `[features]` section of a Cargo.toml. +fn parse_features(cargo_toml_path: &Path) -> Vec<String> { + let content = std::fs::read_to_string(cargo_toml_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", cargo_toml_path.display(), e)); + + let cargo_toml: toml::Value = content + .parse() + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", cargo_toml_path.display(), e)); + + let features_table = cargo_toml + .get("features") + .and_then(|v| v.as_table()) + .unwrap_or_else(|| { + panic!( + "No [features] section found in {}", + cargo_toml_path.display() + ) + }); + + let mut feature_names: BTreeSet<String> = BTreeSet::new(); + for key in features_table.keys() { + feature_names.insert(key.clone()); + } + + let mut result: Vec<String> = feature_names.into_iter().collect(); + result.sort(); + result +} + +fn find_git_repo() -> Option<std::path::PathBuf> { + let mut current_dir = std::env::current_dir().ok()?; + + loop { + let git_dir = current_dir.join(".git"); + if git_dir.exists() && git_dir.is_dir() { + return Some(current_dir); + } + + if !current_dir.pop() { + break; + } + } + + None +} diff --git a/.run/src/bin/sync-examples.rs b/.run/src/bin/sync-examples.rs new file mode 100644 index 0000000..0923b33 --- /dev/null +++ b/.run/src/bin/sync-examples.rs @@ -0,0 +1,131 @@ +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 new file mode 100644 index 0000000..280fca7 --- /dev/null +++ b/.run/src/bin/test-all-markdown-code.rs @@ -0,0 +1,276 @@ +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_cargo_toml, generate_main_rs, generate_build_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: VerifiedPaths, +} + +#[derive(serde::Deserialize)] +struct VerifiedPaths { + readme: String, + #[serde(default)] + documents_en_us: Option<String>, + #[serde(default)] + documents_zh_cn: Option<String>, +} + +#[tokio::main] +async fn main() { + #[cfg(windows)] + let _ = colored::control::set_virtual_terminal(true); + + let config_path = PathBuf::from("verified-docs.toml"); + if !config_path.exists() { + eprintln_cargo_style!("verified-docs.toml not found in current directory"); + std::process::exit(1); + } + + let config: Config = { + let content = std::fs::read_to_string(&config_path).unwrap_or_else(|_e| { + eprintln_cargo_style!("Failed to read verified-docs.toml"); + std::process::exit(1); + }); + toml::from_str(&content).unwrap_or_else(|_e| { + eprintln_cargo_style!("Failed to parse verified-docs.toml"); + std::process::exit(1); + }) + }; + + // Parse optional path argument from env args + let single_file: Option<PathBuf> = { + let args: Vec<String> = env::args().collect(); + if args.len() > 1 { + let p = PathBuf::from(&args[1]); + if p.exists() { + Some(p) + } else { + eprintln_cargo_style!("error: specified file '{}' does not exist", args[1]); + std::process::exit(1); + } + } else { + None + } + }; + + // Collect all markdown files from config + let mut files: Vec<(String, PathBuf)> = Vec::new(); + + // README + let readme_path = PathBuf::from(&config.verified.readme); + if readme_path.exists() { + files.push(("README".to_string(), readme_path)); + } + + // English docs + if let Some(pattern) = &config.verified.documents_en_us { + let base = pattern.trim_end_matches("/**").trim_end_matches('*'); + let dir = PathBuf::from(base); + if dir.exists() && dir.is_dir() { + collect_md_files(&dir, &mut files, "en"); + } + } + + // Chinese docs + if let Some(pattern) = &config.verified.documents_zh_cn { + let base = pattern.trim_end_matches("/**").trim_end_matches('*'); + let dir = PathBuf::from(base); + if dir.exists() && dir.is_dir() { + collect_md_files(&dir, &mut files, "zh_CN"); + } + } + + // If a single file was specified, filter the list to only that file + if let Some(ref target) = single_file { + let target_canon = std::fs::canonicalize(target).unwrap_or_else(|_| target.clone()); + files.retain(|(_, path)| { + std::fs::canonicalize(path) + .map(|p| p == target_canon) + .unwrap_or(false) + }); + if files.is_empty() { + eprintln_cargo_style!( + "error: specified file '{}' is not among the configured documentation files", + target.display() + ); + std::process::exit(1); + } + } + + if files.is_empty() { + eprintln_cargo_style!("No markdown files found to verify"); + std::process::exit(1); + } + + // Parse all code blocks into a flat list with global indices + let mut flat_blocks: Vec<(usize, tools::verify::CodeBlock)> = Vec::new(); + + for (label, path) in &files { + let content = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read {}: {}", path.display(), e); + String::new() + }); + let source_file = format!("{label}/{}", path.file_name().unwrap().to_string_lossy()); + let blocks = parse_code_blocks(&content, &source_file); + let testable: Vec<_> = blocks.into_iter().filter(is_block_testable).collect(); + for block in testable { + let idx = flat_blocks.len() + 1; // 1-based global index + flat_blocks.push((idx, block)); + } + } + + let total_testable = flat_blocks.len(); + + if total_testable == 0 { + println_cargo_style!("No testable code blocks found"); + return; + } + + // Create a shared progress bar + let bar = ProgressBar::new(total_testable as u64); + bar.set_style( + indicatif::ProgressStyle::default_bar() + .template(&format!( + "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", + " Testing".bold().bright_cyan() + )) + .unwrap() + .progress_chars("=> "), + ); + bar.set_message("blocks"); + + // Group blocks by dependency hash + let mut groups: HashMap<String, Vec<(usize, tools::verify::CodeBlock)>> = HashMap::new(); + for (idx, block) in flat_blocks { + let hash = compute_block_hash(&block); + groups.entry(hash).or_default().push((idx, block)); + } + + let temp_base = PathBuf::from(".temp/doc-test"); + + // Sort groups by hash for deterministic output order + let mut group_vec: Vec<(String, Vec<(usize, tools::verify::CodeBlock)>)> = + groups.into_iter().collect(); + group_vec.sort_by(|a, b| a.0.cmp(&b.0)); + + // Spawn a blocking task per group — groups run in parallel, blocks within a group are serial + let mut handles = Vec::new(); + for (hash, blocks) in group_vec { + let temp_base = temp_base.clone(); + let bar = bar.clone(); // clone shares the same underlying progress + let handle = tokio::task::spawn_blocking(move || { + let crate_dir = temp_base.join(&hash); + let src_dir = crate_dir.join("src"); + let manifest_path = crate_dir.join("Cargo.toml"); + + // Generate a single Cargo.toml for the whole group (all blocks share same deps) + let first_block = &blocks[0].1; + let cargo_toml = generate_cargo_toml(first_block, "test-doc", &manifest_path); + + let mut group_results: Vec<(String, usize, bool, String)> = Vec::new(); + for (block_idx, block) in &blocks { + let block_label = + format!("Block {block_idx} ({}:{})", block.source_file, block.line); + + bar.set_message(block_label.clone()); + + let main_rs = if block.is_build_time { + // For build-time blocks, write a stub main.rs and generate build.rs + generate_build_rs(block) + } else { + generate_main_rs(block) + }; + let (ok, err) = build_block( + &src_dir, + &manifest_path, + &cargo_toml, + &main_rs, + block.is_build_time, + ); + if ok { + bar.inc(1); + } else { + bar.inc(1); + bar.println(format!( + " {} {block_label}", + "failed".bold().bright_red() + )); + bar.println(format!(" {block_label} FAILED:\n{err}")); + } + group_results.push((block.source_file.clone(), block.line, ok, err)); + } + group_results + }); + handles.push(handle); + } + + // Collect results from all groups + let mut results: Vec<(String, usize, bool, String)> = Vec::new(); + let mut passed = 0usize; + let mut failed = 0usize; + + for handle in handles { + match handle.await { + Ok(group_results) => { + for (file, line, ok, err) in group_results { + if ok { + passed += 1; + } else { + failed += 1; + } + results.push((file, line, ok, err)); + } + } + Err(e) => { + eprintln_cargo_style!("Task panicked: {}", e); + std::process::exit(1); + } + } + } + + bar.finish_and_clear(); + + let result_msg = format!("Result: {passed}/{total_testable} blocks passed"); + println_cargo_style!(result_msg); + + write_summary_report( + Path::new(".temp/DOCS-TEST-RESULT.md"), + "Documentation Code Block Test Report", + &results, + total_testable, + passed, + failed, + ); + + if failed > 0 { + let fail_msg = format!("{failed} block(s) failed to build"); + eprintln_cargo_style!(fail_msg); + std::process::exit(1); + } + + println_cargo_style!("Done: All verified code blocks build successfully!"); +} + +/// Recursively collect all `.md` files under a directory +fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, lang: &str) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_files(&path, files, lang); + } else if path.extension().is_some_and(|ext| ext == "md") { + files.push((lang.to_string(), path)); + } + } + } +} diff --git a/.run/src/bin/test-all.ps1 b/.run/src/bin/test-all.ps1 new file mode 100644 index 0000000..231698a --- /dev/null +++ b/.run/src/bin/test-all.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 test + Pop-Location +} +Set-Location $starting_dir diff --git a/.run/src/bin/test-all.sh b/.run/src/bin/test-all.sh new file mode 100644 index 0000000..b387463 --- /dev/null +++ b/.run/src/bin/test-all.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 test) +done diff --git a/.run/src/bin/test-examples.rs b/.run/src/bin/test-examples.rs new file mode 100644 index 0000000..539459e --- /dev/null +++ b/.run/src/bin/test-examples.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; + +use colored::Colorize; +use indicatif::ProgressBar; +use serde::Deserialize; +use tools::{eprintln_cargo_style, println_cargo_style}; + +#[derive(Deserialize)] +struct TestConfig { + test: HashMap<String, Vec<TestCase>>, +} + +#[derive(Deserialize)] +struct TestCase { + command: String, + expect: Expect, +} + +#[derive(Deserialize)] +struct Expect { + #[serde(rename = "exit-code")] + exit_code: i32, + result: String, +} + +fn main() { + #[cfg(windows)] + let _ = colored::control::set_virtual_terminal(true); + + let config = load_config(); + + // Count total test cases upfront + let total: usize = config.test.values().map(|cases| cases.len()).sum(); + let bar = ProgressBar::new(total as u64); + bar.set_style( + indicatif::ProgressStyle::default_bar() + .template(&format!( + "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}", + " Testing".bold().bright_cyan() + )) + .unwrap() + .progress_chars("=> "), + ); + bar.set_message("examples"); + + let passed = run_all_tests(&config, &bar); + + bar.finish_and_clear(); + + println_cargo_style!("Result: {}/{} tests passed", passed, total); + + if passed != total { + eprintln_cargo_style!("{} test(s) failed", total - passed); + std::process::exit(1); + } +} + +/// Parse test config from TOML file +fn load_config() -> TestConfig { + let content = std::fs::read_to_string("examples/test-examples.toml").unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read TOML config file: {}", e); + std::process::exit(1); + }); + + toml::from_str(&content).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to parse TOML config: {}", e); + std::process::exit(1); + }) +} + +/// Run all example test groups, return number passed +fn run_all_tests(config: &TestConfig, bar: &ProgressBar) -> usize { + let mut passed = 0; + + for (example_name, test_cases) in &config.test { + bar.set_message(example_name.clone()); + + if !build_example(example_name) { + bar.inc(test_cases.len() as u64); + continue; + } + + for test_case in test_cases { + if run_single_test(example_name, test_case, bar) { + passed += 1; + } + bar.inc(1); + } + } + + passed +} + +/// Build the example binary, return true on success +fn build_example(example_name: &str) -> bool { + let manifest = format!("examples/{example_name}/Cargo.toml"); + tools::run_cmd_capture(format!( + "cargo build --manifest-path {manifest} --color always", + )) + .is_ok() +} + +/// Run a single test case, return true on pass +fn run_single_test(example_name: &str, test_case: &TestCase, bar: &ProgressBar) -> bool { + let binary_path = format!(".temp/target/debug/{}", get_binary_name(example_name)); + let args: Vec<&str> = test_case.command.split_whitespace().collect(); + + let output = match std::process::Command::new(&binary_path) + .args(&args) + .output() + { + Ok(o) => o, + Err(e) => { + bar.println(format!("'{}' - failed to run: {}", test_case.command, e)); + return false; + } + }; + + let actual_exit_code = output.status.code().unwrap_or(-1); + let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + let exit_ok = actual_exit_code == test_case.expect.exit_code; + let result_ok = actual_stdout == test_case.expect.result + || actual_stdout.contains(&test_case.expect.result); + + if exit_ok && result_ok { + true + } else { + bar.println(format!("failed: '{}'", test_case.command)); + if !exit_ok { + bar.println(format!( + " Expected exit code: {}, actual: {}", + test_case.expect.exit_code, actual_exit_code + )); + } + if !result_ok { + bar.println(format!(" Expected output: {:?}", test_case.expect.result)); + bar.println(format!(" Actual stdout: {:?}", actual_stdout)); + if !actual_stderr.is_empty() { + bar.println(format!(" Actual stderr: {:?}", actual_stderr)); + } + } + false + } +} + +/// Resolve binary filename for the given example +/// +/// The binary name matches the package name. On Windows, the `.exe` suffix is required. +fn get_binary_name(example_name: &str) -> String { + let base = example_name; + if cfg!(target_os = "windows") { + format!("{base}.exe") + } else { + base.to_string() + } +} diff --git a/.run/src/bin/update-version.rs b/.run/src/bin/update-version.rs new file mode 100644 index 0000000..475e54b --- /dev/null +++ b/.run/src/bin/update-version.rs @@ -0,0 +1,98 @@ +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_tools").join("version-files.toml"); + let config_str = + std::fs::read_to_string(&config_path).expect("Failed to read dev_tools/version-files.toml"); + let config: Config = toml::from_str(&config_str) + .expect("Failed to parse dev_tools/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/.run/src/bin/windows-folder-hide.ps1 b/.run/src/bin/windows-folder-hide.ps1 new file mode 100644 index 0000000..0ab2632 --- /dev/null +++ b/.run/src/bin/windows-folder-hide.ps1 @@ -0,0 +1,43 @@ +# Check `last_check` + +$lastCheckFile = Join-Path $PSScriptRoot "last_check" +$currentTime = Get-Date +$timeThreshold = 10 + +if (Test-Path $lastCheckFile) { + $lastCheckTime = Get-Content $lastCheckFile | Get-Date + $timeDiff = ($currentTime - $lastCheckTime).TotalMinutes + + if ($timeDiff -lt $timeThreshold) { + exit + } +} + +$currentTime.ToString() | Out-File -FilePath $lastCheckFile -Force + +# Hide Files + +Set-Location -Path (Join-Path $PSScriptRoot "..\..") + +Get-ChildItem -Path . -Force -Recurse -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '\\.temp\\' -and $_.FullName -notmatch '\\.git\\' +} | ForEach-Object { + attrib -h $_.FullName 2>&1 | Out-Null +} + +Get-ChildItem -Path . -Force -Recurse -ErrorAction SilentlyContinue | Where-Object { + $_.Name -match '^\..*' -and $_.FullName -notmatch '\\\.\.$' -and $_.FullName -notmatch '\\\.$' +} | ForEach-Object { + attrib +h $_.FullName 2>&1 | Out-Null +} + +if (Get-Command git -ErrorAction SilentlyContinue) { + git status --ignored --short | ForEach-Object { + if ($_ -match '^!!\s+(.+)$') { + $ignoredPath = $matches[1] + if ($ignoredPath -notmatch '\.lnk$' -and (Test-Path $ignoredPath)) { + attrib +h $ignoredPath 2>&1 | Out-Null + } + } + } +} |
