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();
let owned = |ext: &str| -> Vec<String> {
outputs.iter()
.filter_map(|(p, _)| p.to_str().map(str::to_string))
.filter(|name| name.starts_with(stem) && name.ends_with(&format!(".{ext}")))
.collect()
};
let links: Vec<String> = ["png", "step", "glb", "brep", "stl", "svg"]
.iter()
.flat_map(|ext| owned(ext))
.map(|name| format!("[{name}](https://lzpel.github.io/cadrum/{name})"))
.collect();
if links.is_empty() {
return String::new();
}
let mut out = format!("Output: {}\n", links.join(" | "));
for name in owned("svg") {
out.push_str(&format!("\n<div align=center><img src='https://lzpel.github.io/cadrum/{name}' alt='{stem}' width='360'/></div>\n"));
}
out
}
fn render_usage(entries: &[Entry], outputs: &[(PathBuf, Vec<u8>)]) -> String {
const COLS: usize = 4;
let mut s = String::from("<!--GALLERY-->\n\n<table>\n");
if !entries.is_empty() {
let rows = entries.len().div_ceil(COLS);
for row in 0..rows {
let cells: Vec<Option<(&Entry, &str)>> = (0..COLS).map(|col| {
let entry = entries.get(row * COLS + col)?;
let img = outputs.iter()
.filter_map(|(p, _)| p.to_str())
.find(|n| n.starts_with(entry.stem()) && (n.ends_with(".svg") || n.ends_with(".png")))?;
Some((entry, img))
}).collect();
s.push_str(&format!("<tr>{}</tr>\n",
cells.iter().map(|cell| {
let v=cell.map(|(e,_)| format!("<a href='#{anchor}'>{title}</a>", anchor = e.slug(), title = e.plain_title())).unwrap_or_default();
format!("<th width='25%'>{}</th>", v)
}).collect::<String>()
));
s.push_str(&format!("<tr>{}</tr>\n",
cells.iter().map(|cell| {
let v=cell.map(|(e, img)| format!("<a href='#{anchor}'><img src='https://lzpel.github.io/cadrum/{img}' width='100%' height='auto' alt='{title}'/></a>", anchor = e.slug(), title = e.plain_title())).unwrap_or_default();
format!("<td width='25%'>{}</td>", v)
}).collect::<String>()
));
}
}
s.push_str("</table>\n");
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() {
"<!--GALLERY-->" => 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[1..], &outputs);
} else {
eprintln!("unknown target: {arg} (expected SUMMARY.md or README.md)");
}
}
}