use std::collections::HashMap;
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 title(&self) -> String {
let raw = self.stem()[3..].replace('_', " ");
let mut chars = raw.chars();
match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
}
}
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]) -> HashMap<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 outputs: HashMap<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();
let _ = fs::remove_dir_all(&tmp);
outputs
}
fn assets_for<'a>(outputs: &'a HashMap<PathBuf, Vec<u8>>, stem: &str) -> Vec<&'a PathBuf> {
let mut names: Vec<&PathBuf> = outputs.keys()
.filter(|p| {
let name = p.to_str().unwrap_or("");
name.starts_with(stem) && p.extension().map_or(false, |ext| matches!(ext.to_str(), Some("svg" | "png" | "step" | "brep" | "stl")))
})
.collect();
names.sort();
names
}
fn write_summary(summary_path: &Path, entries: &[Entry], outputs: &HashMap<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: String = assets_for(outputs, stem).iter()
.map(|p| {
let name = p.to_str().unwrap();
match p.extension().and_then(|e| e.to_str()) {
Some("svg" | "png") => format!("- {name}\n"),
_ => format!("- [{name}]({name})"),
}
})
.collect::<Vec<_>>()
.join("\n\n");
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: &HashMap<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\n{}\n```\n", entry.content));
if let Some(img) = first_image(outputs, stem) {
s.push_str(&format!(
"\n<p align=\"center\">\n <img src=\"https://lzpel.github.io/cadrum/{}\" alt=\"{}\" width=\"360\"/>\n</p>\n",
img, stem
));
}
s
}
fn first_image<'a>(outputs: &'a HashMap<PathBuf, Vec<u8>>, stem: &str) -> Option<&'a str> {
assets_for(outputs, stem).into_iter()
.find(|p| p.extension().map_or(false, |ext| matches!(ext.to_str(), Some("svg" | "png"))))
.and_then(|p| p.to_str())
}
fn resolve_marker<'a>(marker: &str, entries: &'a [Entry]) -> Vec<&'a Entry> {
let inner = marker.trim();
if inner.ends_with('+') {
let prefix = inner.trim_end_matches('+');
entries.iter().filter(|e| &e.stem()[..2] >= prefix).collect()
} else {
entries.iter().filter(|e| e.stem().starts_with(inner)).collect()
}
}
fn write_readme(readme_path: &Path, entries: &[Entry], outputs: &HashMap<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 trimmed = line.trim();
if !trimmed.starts_with("## ") || !trimmed.contains("<!--") { continue; }
let marker = match (trimmed.find("<!--"), trimmed.find("-->")) {
(Some(a), Some(b)) if a < b => trimmed[a + 4..b].trim(),
_ => continue,
};
if !marker.bytes().any(|b| b.is_ascii_digit()) { continue; }
let heading = trimmed[..trimmed.find("<!--").unwrap()].trim();
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]);
let matched = resolve_marker(marker, entries);
let is_single = matched.len() == 1;
new_readme.push_str(&format!("{} <!--{}-->\n", heading, marker));
for entry in &matched {
if !is_single {
new_readme.push_str(&format!("\n#### {}\n", entry.title()));
}
new_readme.push_str(&render_example(entry, outputs));
}
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)");
}
}
}