diff options
| author | Weicao-CatilGrass <1992414357@qq.com> | 2026-06-10 15:11:18 +0800 |
|---|---|---|
| committer | Weicao-CatilGrass <1992414357@qq.com> | 2026-06-10 15:11:18 +0800 |
| commit | ae49f93dd2aecdca0b4917539961b038b95a3beb (patch) | |
| tree | 6bba3610f5934944932b8edb218cf645e5685f5d /dev_tools/src/bin | |
| parent | 514929c3b8ee0d4f540be5eb4bc8c1a10e62095d (diff) | |
Add README code block testing to CI pipeline
Diffstat (limited to 'dev_tools/src/bin')
| -rw-r--r-- | dev_tools/src/bin/ci.rs | 6 | ||||
| -rw-r--r-- | dev_tools/src/bin/test-readme.rs | 349 |
2 files changed, 355 insertions, 0 deletions
diff --git a/dev_tools/src/bin/ci.rs b/dev_tools/src/bin/ci.rs index b7934e1..21763d1 100644 --- a/dev_tools/src/bin/ci.rs +++ b/dev_tools/src/bin/ci.rs @@ -73,6 +73,7 @@ fn ci() -> Result<(), i32> { clippy_all()?; test_all()?; test_examples()?; + test_readme()?; docs_refresh()?; run_cmd!("git add --renormalize .")?; @@ -85,6 +86,11 @@ fn test_examples() -> Result<(), i32> { run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin test-examples") } +fn test_readme() -> Result<(), i32> { + println_cargo_style!("Testing: readme code blocks"); + run_cmd!("cargo run --manifest-path dev_tools/Cargo.toml --bin test-readme") +} + fn build_all() -> Result<(), i32> { let cargo_tomls = cargo_tomls(); for cargo_toml in cargo_tomls { diff --git a/dev_tools/src/bin/test-readme.rs b/dev_tools/src/bin/test-readme.rs new file mode 100644 index 0000000..fd1962f --- /dev/null +++ b/dev_tools/src/bin/test-readme.rs @@ -0,0 +1,349 @@ +use colored::Colorize; +use std::path::{Path, PathBuf}; + +use tools::{eprintln_cargo_style, println_cargo_style, run_cmd_and_capture_stderr}; + +/// Represents a parsed code block from README.md +struct CodeBlock { + /// The line number in README.md where this block starts + line: usize, + /// The raw Rust source code + code: String, + /// Feature flags extracted from `// Features: [...]` comment + features: Vec<String>, + /// External dependencies extracted from `// Dependencies:` comments + external_deps: Vec<(String, String)>, + /// Whether this block has a `fn main` entry point + has_main: bool, + /// Whether this block has `gen_program!()` call + has_gen_program: bool, +} + +fn main() { + #[cfg(windows)] + let _ = colored::control::set_virtual_terminal(true); + + let readme_path = PathBuf::from("README.md"); + if !readme_path.exists() { + eprintln_cargo_style!("README.md not found in current directory"); + std::process::exit(1); + } + + let content = std::fs::read_to_string(&readme_path).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read README.md: {}", e); + std::process::exit(1); + }); + + let blocks = parse_code_blocks(&content); + if blocks.is_empty() { + eprintln_cargo_style!("No Rust code blocks found in README.md"); + std::process::exit(1); + } + + println_cargo_style!("Test: found {} Rust code blocks in README.md", blocks.len()); + + // Ensure temp directory exists + let temp_dir = PathBuf::from(".temp/readme-test"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("src")).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to create temp directory: {}", e); + std::process::exit(1); + }); + + let mut passed = 0usize; + let mut failed = 0usize; + let mut results: Vec<(usize, bool, String)> = Vec::new(); + + for (i, block) in blocks.iter().enumerate() { + let label = format!("Block {} (line {})", i + 1, block.line); + print!(" {label} ... "); + + let (ok, err) = build_block(&temp_dir, block); + if ok { + println!("{}", "passed".bold().bright_green()); + passed += 1; + results.push((block.line, true, String::new())); + } else { + println!("{}", "failed".bold().bright_red()); + failed += 1; + results.push((block.line, false, err.clone())); + eprintln_cargo_style!(" {} FAILED:\n{}", label, err); + } + } + + println_cargo_style!( + "Result: {passed}/{total} blocks passed", + total = blocks.len() + ); + + write_summary_report( + Path::new(".temp/README-TEST-RESULT.md"), + &results, + blocks.len(), + passed, + failed, + ); + + if failed > 0 { + eprintln_cargo_style!("{failed} block(s) failed to build"); + std::process::exit(1); + } + + println_cargo_style!("Done: All README code blocks build successfully!"); +} + +/// Parse all ```rust code blocks from README content +fn parse_code_blocks(content: &str) -> Vec<CodeBlock> { + let mut blocks = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + let mut i = 0; + + while i < lines.len() { + if lines[i].trim() == "```rust" { + if let Some(block) = parse_single_block(&lines, i) { + blocks.push(block); + } + i += 1; + while i < lines.len() && lines[i].trim() != "```" { + i += 1; + } + } + i += 1; + } + + blocks +} + +/// Parse a single code block starting at the ```rust line +fn parse_single_block(lines: &[&str], start: usize) -> Option<CodeBlock> { + let line_num = start + 1; // 1-based line number + + let mut code_lines: Vec<String> = Vec::new(); + let mut features: Vec<String> = Vec::new(); + let mut external_deps: Vec<(String, String)> = Vec::new(); + let mut has_main = false; + let mut has_gen_program = false; + + let mut idx = start + 1; + let mut in_header = true; + + while idx < lines.len() { + let raw_line = lines[idx]; + let trimmed = raw_line.trim(); + + if trimmed == "```" { + break; + } + + // Parse header comments + if in_header && trimmed.starts_with("// ") { + if trimmed.starts_with("// Features:") { + let feat_str = trimmed.trim_start_matches("// Features:").trim(); + if feat_str.starts_with('[') && feat_str.ends_with(']') { + let inner = &feat_str[1..feat_str.len() - 1]; + if !inner.is_empty() { + features = inner + .split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + idx += 1; + continue; + } + if trimmed == "// Dependencies:" { + idx += 1; + // Collect subsequent `// crate = "version"` lines + while idx < lines.len() { + let next = lines[idx].trim(); + if next == "```" { + break; + } + if next.starts_with("// ") { + let dep_line = next.trim_start_matches("// ").trim(); + if let Some((name, ver)) = dep_line.split_once(" = ") { + external_deps.push(( + name.trim().to_string(), + ver.trim().trim_matches('"').to_string(), + )); + } + idx += 1; + } else { + break; + } + } + continue; + } + } + + in_header = false; + + if raw_line.contains("fn main") { + has_main = true; + } + if raw_line.contains("gen_program!") { + has_gen_program = true; + } + + code_lines.push(raw_line.to_string()); + idx += 1; + } + + if code_lines.is_empty() { + return None; + } + + Some(CodeBlock { + line: line_num, + code: code_lines.join("\n"), + features, + external_deps, + has_main, + has_gen_program, + }) +} + +/// Generate a Cargo.toml for a block +fn generate_cargo_toml(block: &CodeBlock) -> String { + let features_str = if !block.features.is_empty() { + let feats: Vec<String> = block.features.iter().map(|f| format!("\"{f}\"")).collect(); + format!("features = [{}]", feats.join(", ")) + } else { + String::new() + }; + + let mut extra_deps = String::new(); + for (name, version) in &block.external_deps { + if !version.starts_with('{') { + // Plain version string, e.g. "1" + if name == "serde" || name == "clap" { + extra_deps.push_str(&format!( + "{name} = {{ version = \"{version}\", features = [\"derive\"] }}\n" + )); + } else { + extra_deps.push_str(&format!("{name} = \"{version}\"\n")); + } + } else { + // Already in TOML inline table format, e.g. { version = "1", features = [...] } + extra_deps.push_str(&format!("{name} = {version}\n")); + } + } + + let deps_section = if features_str.is_empty() { + format!("[dependencies]\nmingling = {{ path = \"../../mingling\" }}\n{extra_deps}") + } else { + format!( + "[dependencies]\nmingling = {{ path = \"../../mingling\", {features_str} }}\n{extra_deps}" + ) + }; + + format!( + r#"[package] +name = "readme-test-block" +version = "0.0.0" +edition = "2024" + +{deps_section} +[workspace] +"# + ) +} + +/// Generate main.rs for a block +fn generate_main_rs(block: &CodeBlock) -> String { + let mut output = String::from("#![allow(dead_code)]\n\n"); + + output.push_str(&block.code); + output.push('\n'); + + if !block.has_main { + output.push_str("\nfn main() {}\n"); + } + + if !block.has_gen_program { + output.push_str("\nmingling::macros::gen_program!();\n"); + } + + output +} + +/// Build a single code block as a Cargo project. +/// Always writes to `{temp_dir}/Cargo.toml` and `{temp_dir}/src/main.rs`. +/// Returns (success, error_message). +fn build_block(temp_dir: &Path, block: &CodeBlock) -> (bool, String) { + let src_dir = temp_dir.join("src"); + if let Err(e) = std::fs::create_dir_all(&src_dir) { + return (false, format!("mkdir: {e}")); + } + + // Write Cargo.toml + let cargo_toml = generate_cargo_toml(block); + if let Err(e) = std::fs::write(temp_dir.join("Cargo.toml"), &cargo_toml) { + return (false, format!("write Cargo.toml: {e}")); + } + + // Write main.rs + let main_rs = generate_main_rs(block); + if let Err(e) = std::fs::write(src_dir.join("main.rs"), &main_rs) { + return (false, format!("write main.rs: {e}")); + } + + // Build with release (single run via shared macro) + let manifest_path = temp_dir.join("Cargo.toml"); + match run_cmd_and_capture_stderr!( + "cargo build --release --manifest-path {}", + manifest_path.to_string_lossy() + ) { + Ok(_) => (true, String::new()), + Err((code, log)) => { + let mut last_lines: Vec<&str> = log.lines().rev().take(20).collect(); + last_lines.reverse(); + let detail = last_lines.join("\n"); + (false, format!("exit code {code}\n{detail}")) + } + } +} + +/// Write the .temp/README-TEST-RESULT.md summary report +fn write_summary_report( + path: &Path, + results: &[(usize, bool, String)], + total: usize, + passed: usize, + failed: usize, +) { + let mut content = String::new(); + content.push_str("# README Code Block Test Report\n\n"); + content.push_str(&format!( + "Tested **{total}** code blocks: **{passed}** passed, **{failed}** failed.\n\n" + )); + content.push_str("## Results\n\n"); + content.push_str("| Block | Line | Status |\n"); + content.push_str("|-------|------|--------|\n"); + + for (i, (line, ok, _)) in results.iter().enumerate() { + let status = if *ok { "PASS" } else { "FAIL" }; + content.push_str(&format!("| {} | {} | {status} |\n", i + 1, line)); + } + + let has_failures = results.iter().any(|(_, ok, _)| !ok); + if has_failures { + content.push_str("\n## Failed Blocks\n\n"); + for (i, (line, ok, err)) in results.iter().enumerate() { + if !ok { + content.push_str(&format!( + "### Block {} (line {})\n\n```\n{err}\n```\n\n", + i + 1, + line + )); + } + } + } + + std::fs::write(path, &content).unwrap_or_else(|e| { + eprintln!("Warning: failed to write {path:?}: {e}"); + }); + + println_cargo_style!("Report: written to {}", path.display()); +} |
