use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
const PREAMBLE_OPEN: &str = "<!--- stakk-docs";
const PREAMBLE_CLOSE: &str = "--->";
const KNOWN_KEYS: &[&str] = &["summary"];
struct Topic {
name: String,
variant: String,
summary: String,
body: String,
}
fn main() {
let manifest_dir = PathBuf::from(env("CARGO_MANIFEST_DIR"));
let out_dir = PathBuf::from(env("OUT_DIR"));
let docs_dir = manifest_dir.join("docs");
println!("cargo::rerun-if-changed=docs");
let topics = read_topics(&docs_dir);
assert!(
!topics.is_empty(),
"docs/ has no Markdown files, so `stakk docs` would have no topics",
);
write_bodies(&out_dir, &topics);
write_topics_rs(&out_dir, &topics);
}
fn env(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| panic!("cargo sets {key} for build scripts"))
}
fn read_topics(docs_dir: &Path) -> Vec<Topic> {
let entries = fs::read_dir(docs_dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", docs_dir.display()));
let mut paths: Vec<PathBuf> = entries
.map(|entry| entry.expect("readable dir entry").path())
.filter(|path| path.extension().is_some_and(|ext| ext == "md"))
.collect();
paths.sort();
let mut topics: Vec<Topic> = Vec::new();
for path in paths {
println!("cargo::rerun-if-changed={}", path.display());
topics.push(read_topic(&path));
}
topics.sort_by(|a, b| a.name.cmp(&b.name));
topics
}
fn read_topic(path: &Path) -> Topic {
let name = path
.file_stem()
.expect("a .md path has a stem")
.to_str()
.unwrap_or_else(|| fail(path, "the file name is not UTF-8"))
.to_string();
let variant = variant_name(path, &name);
let text =
fs::read_to_string(path).unwrap_or_else(|e| fail(path, &format!("cannot read: {e}")));
let (summary, body) = split_preamble(path, &text);
Topic {
name,
variant,
summary,
body,
}
}
fn variant_name(path: &Path, name: &str) -> String {
let valid = !name.is_empty()
&& name.starts_with(|c: char| c.is_ascii_lowercase())
&& name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !name.contains("--");
if !valid {
fail(
path,
"the file name must be lowercase letters, digits and single hyphens, starting with a \
letter (it becomes the `stakk docs <topic>` name)",
);
}
name.split('-')
.map(|word| {
let mut chars = word.chars();
let first = chars.next().expect("no empty words after the check above");
first.to_ascii_uppercase().to_string() + chars.as_str()
})
.collect()
}
fn split_preamble(path: &Path, text: &str) -> (String, String) {
let mut fields: Vec<(String, String)> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut opened = false;
let mut body_start = None;
let mut consumed = 0usize;
for line in text.split_inclusive('\n') {
consumed += line.len();
let line = line.strip_suffix('\n').unwrap_or(line);
if !opened {
if line != PREAMBLE_OPEN {
fail(
path,
&format!(
"must start with `{PREAMBLE_OPEN}`, the preamble every `stakk docs` topic \
carries"
),
);
}
opened = true;
continue;
}
if line == PREAMBLE_CLOSE {
body_start = Some(consumed);
break;
}
let Some((key, value)) = line.split_once(':') else {
fail(
path,
&format!("preamble line `{line}` is not `key: value` (or `{PREAMBLE_CLOSE}`)"),
);
};
let key = key.trim().to_string();
let value = value.trim().to_string();
if !KNOWN_KEYS.contains(&key.as_str()) {
fail(
path,
&format!(
"unknown preamble key `{key}` (known keys: {})",
KNOWN_KEYS.join(", ")
),
);
}
if !seen.insert(key.clone()) {
fail(path, &format!("duplicate preamble key `{key}`"));
}
if value.is_empty() {
fail(path, &format!("preamble key `{key}` has an empty value"));
}
fields.push((key, value));
}
let Some(body_start) = body_start else {
fail(
path,
&format!("the preamble is never closed — it needs a `{PREAMBLE_CLOSE}` line"),
);
};
let Some((_, summary)) = fields.into_iter().find(|(key, _)| key == "summary") else {
fail(
path,
"the preamble has no `summary:` line (it is the topic's one-line description in \
`stakk docs` and `stakk docs --help`)",
);
};
let rest = &text[body_start..];
let Some(body) = rest.strip_prefix('\n') else {
fail(
path,
&format!("`{PREAMBLE_CLOSE}` must be followed by a blank line, then the document"),
);
};
if body.starts_with('\n') {
fail(
path,
&format!("`{PREAMBLE_CLOSE}` must be followed by exactly one blank line"),
);
}
if body.is_empty() {
fail(path, "there is nothing below the preamble");
}
(summary, body.to_string())
}
fn fail(path: &Path, reason: &str) -> ! {
let shown = path
.strip_prefix(env("CARGO_MANIFEST_DIR"))
.unwrap_or(path)
.display();
panic!("{shown}: {reason}");
}
fn write_bodies(out_dir: &Path, topics: &[Topic]) {
let dir = out_dir.join("docs");
fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("cannot create {}: {e}", dir.display()));
for topic in topics {
let path = dir.join(format!("{}.md", topic.name));
fs::write(&path, &topic.body)
.unwrap_or_else(|e| panic!("cannot write {}: {e}", path.display()));
}
}
fn write_topics_rs(out_dir: &Path, topics: &[Topic]) {
let mut out = String::new();
out.push_str(
"// @generated by build.rs from the docs/ directory. Do not edit.\n\n/// A topic of the \
documentation bundled into the binary.\n///\n/// One variant per Markdown file in \
`docs/`, named after the file and\n/// documented with that file's `summary:` preamble \
line. The doc comments are\n/// load-bearing: clap renders them as possible-value help, \
and `docs::index`\n/// reads them back to build the topic list.\n#[derive(Debug, Clone, \
Copy, PartialEq, Eq, clap::ValueEnum)]\npub enum DocTopic {\n",
);
for topic in topics {
writeln!(out, " /// {}", topic.summary).expect("writing to a String cannot fail");
writeln!(out, " #[value(name = \"{}\")]", topic.name)
.expect("writing to a String cannot fail");
writeln!(out, " {},", topic.variant).expect("writing to a String cannot fail");
}
out.push_str("}\n\n");
out.push_str(
"/// The Markdown for a topic: its file in `docs/`, below the\n/// preamble.\npub(crate) \
fn source(topic: DocTopic) -> &'static str {\n match topic {\n",
);
for topic in topics {
writeln!(
out,
" DocTopic::{} => include_str!(concat!(env!(\"OUT_DIR\"), \"/docs/{}.md\")),",
topic.variant, topic.name,
)
.expect("writing to a String cannot fail");
}
out.push_str(" }\n}\n");
let path = out_dir.join("doc_topics.rs");
fs::write(&path, out).unwrap_or_else(|e| panic!("cannot write {}: {e}", path.display()));
}