use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Entry {
path: PathBuf, content: String, }
impl Entry {
fn stem(&self) -> &str {
self.path.file_stem().unwrap().to_str().unwrap()
}
fn plain_title(&self) -> String {
self.stem()[3..].replace('_', " ")
}
fn title(&self) -> String {
let raw = self.plain_title();
let mut chars = raw.chars();
match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
}
}
fn slug(&self) -> String {
self.plain_title().replace(' ', "-")
}
fn description(&self) -> &str {
self.content.lines()
.find(|l| l.starts_with("//!"))
.map(|l| l.trim_start_matches("//!").trim())
.unwrap_or("")
}
}
fn collect_entries() -> Vec<Entry> {
let examples_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples");
let mut entries: Vec<Entry> = fs::read_dir(&examples_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
if name.len() >= 4 && name.ends_with(".rs") && name.as_bytes()[0].is_ascii_digit() && name.as_bytes()[1].is_ascii_digit() && name.as_bytes()[2] == b'_' {
let path = e.path();
let content = fs::read_to_string(&path).ok()?;
Some(Entry { path, content })
} else {
None
}
})
.collect();
entries.sort_by(|a, b| a.stem().cmp(b.stem()));
entries
}
fn collect_outputs(entries: &[Entry]) -> Vec<(PathBuf, Vec<u8>)> {
let tmp = std::env::temp_dir().join("cadrum_examples");
clean_dir(&tmp);
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
for entry in entries {
let stem = entry.stem();
eprintln!("running example: {stem}");
let status = Command::new("cargo")
.args(["run", "--manifest-path", manifest.to_str().unwrap(), "--example", stem])
.current_dir(&tmp)
.status()
.unwrap_or_else(|e| panic!("failed to run example {stem}: {e}"));
assert!(status.success(), "example {stem} failed with {status}");
}
let mut outputs: Vec<(PathBuf, Vec<u8>)> = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| {
let path = PathBuf::from(e.file_name());
let contents = fs::read(e.path()).ok()?;
Some((path, contents))
})
.collect();
outputs.sort_by(|a, b| a.0.cmp(&b.0));
let _ = fs::remove_dir_all(&tmp);
outputs
}
fn write_summary(summary_path: &Path, entries: &[Entry], outputs: &[(PathBuf, Vec<u8>)]) {
let out_dir = summary_path.parent().expect("summary_path must have a parent directory");
clean_dir(out_dir);
for (path, contents) in outputs {
fs::write(out_dir.join(path), contents).unwrap();
}
let mut summary = String::from("# Summary\n\n");
for entry in entries {
let (stem, title, desc) = (entry.stem(), entry.title(), entry.description());
summary.push_str(&format!("- [{}]({}.md)\n", title, stem));
let assets = render_assets(entry, outputs);
let desc_section = if desc.is_empty() { String::new() } else { format!("\n{}\n", desc) };
let assets_section = if assets.is_empty() { String::new() } else { format!("\n{}", assets) };
let md = format!("# {}\n{}\n```rust\n{}\n```{}", title, desc_section, entry.content, assets_section);
fs::write(out_dir.join(format!("{}.md", stem)), md).unwrap();
}
fs::write(summary_path, &summary).unwrap();
eprintln!("generated: {}", summary_path.display());
}
fn render_example(entry: &Entry, outputs: &[(PathBuf, Vec<u8>)]) -> String {
let (stem, desc) = (entry.stem(), entry.description());
let mut s = String::new();
if !desc.is_empty() {
s.push_str(&format!("\n{}\n", desc));
}
s.push_str(&format!("\n```sh\ncargo run --example {}\n```\n", stem));
s.push_str(&format!("\n```rust,no_run\n{}\n```\n", entry.content));
s.push_str(&render_assets(entry, outputs));
s.push('\n');
s
}
fn render_assets(entry: &Entry, outputs: &[(PathBuf, Vec<u8>)]) -> String {
let stem = entry.stem();
outputs.iter()
.filter_map(|(p, _)| {
let name = p.to_str()?;
if !name.starts_with(stem) { return None; }
match p.extension().and_then(|e| e.to_str()) {
Some("svg" | "png") => Some(format!("\n<p align=\"center\">\n <img src=\"https://lzpel.github.io/cadrum/{name}\" alt=\"{stem}\" width=\"360\"/>\n</p>")),
Some("step" | "brep" | "stl") => Some(format!("- [{name}](https://lzpel.github.io/cadrum/{name})")),
_ => None,
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_usage(entries: &[Entry], outputs: &[(PathBuf, Vec<u8>)]) -> String {
const COLS: usize = 4;
let mut s = String::from("## Usage\n\n");
if !entries.is_empty() {
let rows = entries.len().div_ceil(COLS);
for row in 0..rows {
let mut title_cells = Vec::with_capacity(COLS);
let mut image_cells = Vec::with_capacity(COLS);
for col in 0..COLS {
let idx = row * COLS + col;
if let Some(entry) = entries.get(idx) {
let (title, anchor) = (entry.plain_title(), entry.slug());
title_cells.push(format!("[{}](#{})", title, anchor));
let img_cell = outputs.iter()
.filter_map(|(p, _)| p.to_str())
.find(|n| n.starts_with(entry.stem()) && (n.ends_with(".svg") || n.ends_with(".png")))
.map(|img| format!(
"[<img src=\"https://lzpel.github.io/cadrum/{}\" width=\"180\" alt=\"{}\"/>](#{})",
img, title, anchor
))
.unwrap_or_default();
image_cells.push(img_cell);
} else {
title_cells.push(String::new());
image_cells.push(String::new());
}
}
s.push_str(&format!("| {} |\n", title_cells.join(" | ")));
if row == 0 {
s.push_str("|:---:|:---:|:---:|:---:|\n");
}
s.push_str(&format!("| {} |\n", image_cells.join(" | ")));
}
s.push('\n');
}
s.push_str("More examples with source code are available at [lzpel.github.io/cadrum](https://lzpel.github.io/cadrum).\n\n");
s.push_str("Add this to your `Cargo.toml`:\n\n");
let version = env!("CARGO_PKG_VERSION");
let mut parts = version.split('.');
let major = parts.next().unwrap();
let minor = parts.next().unwrap();
s.push_str(&format!("```toml\n[dependencies]\ncadrum = \"^{}.{}\"\n```\n", major, minor));
s
}
fn render_example_section(entries: &[Entry], outputs: &[(PathBuf, Vec<u8>)]) -> String {
let mut s = String::from("## Examples\n");
for entry in entries {
s.push_str(&format!("\n#### {}\n", entry.title()));
s.push_str(&render_example(entry, outputs));
}
s.push('\n');
s
}
fn write_readme(readme_path: &Path, entries: &[Entry], outputs: &[(PathBuf, Vec<u8>)]) {
let readme = fs::read_to_string(readme_path).expect("failed to read README.md");
let mut new_readme = String::with_capacity(readme.len());
let mut last_end = 0;
for (i, line) in readme.lines().enumerate() {
let content = match line.trim() {
"## Usage" => render_usage(entries, outputs),
"## Examples" => render_example_section(entries, outputs),
_ => continue,
};
let line_start = readme.lines().take(i).map(|l| l.len() + 1).sum::<usize>();
let line_end = line_start + line.len() + 1;
let section_end = readme[line_end..].find("\n## ")
.map(|j| line_end + j + 1)
.unwrap_or(readme.len());
new_readme.push_str(&readme[last_end..line_start]);
new_readme.push_str(&content);
new_readme.push('\n');
last_end = section_end;
}
new_readme.push_str(&readme[last_end..]);
fs::write(readme_path, &new_readme).unwrap();
eprintln!("updated: {}", readme_path.display());
}
fn clean_dir(dir: &Path) {
if dir.exists() {
fs::remove_dir_all(dir).expect("failed to clean directory");
}
fs::create_dir_all(dir).expect("failed to create directory");
}
fn main() {
let entries = collect_entries();
let outputs = collect_outputs(&entries);
for arg in std::env::args().skip(1) {
let path = PathBuf::from(&arg);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("SUMMARY") {
write_summary(&path, &entries, &outputs);
} else if name.starts_with("README") {
write_readme(&path, &entries, &outputs);
} else {
eprintln!("unknown target: {arg} (expected SUMMARY.md or README.md)");
}
}
}