use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use super::export::{self, DocFormat, ExportMeta};
use super::layout::MANAGED_DOCS;
use super::sections::{rewrite_str, Ctx};
#[derive(Debug)]
pub struct Chapter {
pub source: PathBuf,
pub title: String,
pub markdown: String,
pub is_section: bool,
}
const TANKEGANGAR: &str = "Tankegångar";
const CRATES_SECTION: &str = "Crates";
enum PlanItem {
Divider(String),
File(PathBuf),
}
#[derive(serde::Deserialize)]
struct BookManifest {
#[serde(default)]
section: Vec<ManifestSection>,
}
#[derive(serde::Deserialize)]
struct ManifestSection {
title: String,
#[serde(default)]
files: Vec<String>,
}
pub fn collect_chapters(repo_root: &Path, ctx: &Ctx) -> Result<Vec<Chapter>> {
let mut chapters = Vec::new();
for item in book_plan(repo_root, ctx.manual) {
let path = match item {
PlanItem::Divider(title) => {
let markdown = if title == TANKEGANGAR {
format!(
"# {title}\n\n_Working notes, design musings and history — \
**not authoritative**. The source of truth is the code and \
the curated chapters above._\n"
)
} else {
format!("# {title}\n")
};
chapters.push(Chapter {
source: repo_root.join(".nornir"),
title,
markdown,
is_section: true,
});
continue;
}
PlanItem::File(path) => path,
};
chapters.push(build_chapter(&path, repo_root, ctx)?);
}
Ok(chapters)
}
fn build_chapter(path: &Path, repo_root: &Path, ctx: &Ctx) -> Result<Chapter> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let body = match rewrite_str(&raw, ctx) {
Ok((filled, _)) => filled,
Err(_) => raw,
};
let body = rewrite_image_paths(&body, path, repo_root);
let title = chapter_title(path, &body);
Ok(Chapter {
source: path.to_path_buf(),
title,
markdown: body,
is_section: false,
})
}
fn book_plan(repo_root: &Path, manual: bool) -> Vec<PlanItem> {
match load_manifest(repo_root) {
Some(sections) if !sections.is_empty() => sectioned_plan(repo_root, §ions, manual),
_ => discover_sources(repo_root)
.into_iter()
.filter(|p| !manual || is_manual_chapter(p))
.map(PlanItem::File)
.collect(),
}
}
fn is_manual_chapter(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if name == "docs-generation.md" {
return false;
}
const NON_MANUAL_SUFFIXES: &[&str] = &[
"-reasoning.md",
"-history.md",
"-unsorted.md",
"-for-idiots.md",
"-for-dummies.md",
"-idiots.md",
"-idiot-guide.md",
];
if let Some(stem) = name.strip_suffix(".md") {
if matches!(stem, "reasoning" | "history" | "unsorted") {
return false;
}
}
!NON_MANUAL_SUFFIXES.iter().any(|s| name.ends_with(s))
}
fn sectioned_plan(repo_root: &Path, sections: &[ManifestSection], manual: bool) -> Vec<PlanItem> {
let nornir_md = list_md(&repo_root.join(".nornir"));
let root_md: Vec<PathBuf> = list_md(repo_root)
.into_iter()
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!MANAGED_DOCS.contains(&name) && is_book_chapter(name)
})
.collect();
let find = |name: &str| -> Option<PathBuf> {
nornir_md
.iter()
.find(|p| p.file_name().and_then(|n| n.to_str()) == Some(name))
.cloned()
};
let mut plan = Vec::new();
let mut used: HashSet<PathBuf> = HashSet::new();
for lead in MANAGED_DOCS {
if let Some(p) = find(lead) {
used.insert(p.clone());
plan.push(PlanItem::File(p));
}
}
for sec in sections {
let mut files = Vec::new();
for fname in &sec.files {
if let Some(p) = find(fname) {
if manual && !is_manual_chapter(&p) {
continue; }
if used.insert(p.clone()) {
files.push(p);
}
}
}
if !files.is_empty() {
plan.push(PlanItem::Divider(sec.title.clone()));
plan.extend(files.into_iter().map(PlanItem::File));
}
}
let mut cards: Vec<PathBuf> = discover_crate_cards(repo_root)
.into_iter()
.filter(|p| !used.contains(p))
.filter(|p| !manual || is_manual_chapter(p))
.collect();
cards.sort();
if !cards.is_empty() {
plan.push(PlanItem::Divider(CRATES_SECTION.to_string()));
for c in &cards {
used.insert(c.clone());
}
plan.extend(cards.into_iter().map(PlanItem::File));
}
if !manual {
let mut leftover: Vec<PathBuf> = nornir_md
.into_iter()
.chain(root_md)
.filter(|p| !used.contains(p))
.collect();
leftover.sort();
if !leftover.is_empty() {
plan.push(PlanItem::Divider(TANKEGANGAR.to_string()));
plan.extend(leftover.into_iter().map(PlanItem::File));
}
}
plan
}
fn load_manifest(repo_root: &Path) -> Option<Vec<ManifestSection>> {
let path = repo_root.join(".nornir/book.toml");
let text = std::fs::read_to_string(&path).ok()?;
match toml::from_str::<BookManifest>(&text) {
Ok(m) => Some(m.section),
Err(e) => {
eprintln!("nornir docs: ignoring malformed .nornir/book.toml: {e}");
None
}
}
}
fn rewrite_image_paths(body: &str, source: &Path, repo_root: &Path) -> String {
let src_dir = source.parent().unwrap_or(repo_root);
let mut out = String::with_capacity(body.len());
let bytes = body.as_bytes();
let mut i = 0;
while i < body.len() {
if bytes[i] == b'!' && i + 1 < body.len() && bytes[i + 1] == b'[' {
if let Some(close_alt) = body[i..].find("](") {
let lp = i + close_alt + 2; if let Some(rel_close) = body[lp..].find(')') {
let inner = &body[lp..lp + rel_close]; let (raw_path, title) = match inner.find(char::is_whitespace) {
Some(sp) => (&inner[..sp], &inner[sp..]),
None => (inner, ""),
};
let skip = raw_path.is_empty()
|| raw_path.starts_with('/')
|| raw_path.starts_with("http://")
|| raw_path.starts_with("https://")
|| raw_path.starts_with("data:");
let resolved = if skip {
None
} else {
let src_rel = src_dir.join(raw_path);
let root_rel = repo_root.join(raw_path);
if src_rel.is_file() {
src_rel
.strip_prefix(repo_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
} else if root_rel.is_file() {
None } else {
None }
};
if let Some(newp) = resolved {
out.push_str(&body[i..lp]); out.push_str(&newp);
out.push_str(title);
out.push(')');
i = lp + rel_close + 1;
continue;
}
}
}
}
let ch = body[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
pub fn assemble_markdown(chapters: &[Chapter]) -> String {
let mut out = String::new();
for ch in chapters {
if !starts_with_h1(&ch.markdown) {
out.push_str("# ");
out.push_str(&ch.title);
out.push_str("\n\n");
}
out.push_str(ch.markdown.trim_end());
out.push_str("\n\n");
}
out
}
pub struct BuiltBook {
pub name: String,
pub out: PathBuf,
pub bytes: Vec<u8>,
pub sources: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct BookRoute {
pub name: String,
pub classes: Vec<String>,
pub out: String,
}
#[derive(serde::Deserialize)]
struct DocsRoutingToml {
#[serde(default)]
book: std::collections::BTreeMap<String, BookRouteRaw>,
}
#[derive(serde::Deserialize)]
struct BookRouteRaw {
#[serde(default)]
classes: Vec<String>,
out: String,
}
pub fn classify(path: &Path, body: &str) -> &'static str {
if let Some(c) = explicit_class(body) {
return c;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
const SUFFIX: &[(&str, &str)] = &[
("-design.md", "design"),
("-guide.md", "guide"),
("-for-idiots.md", "idiot"),
("-for-dummies.md", "idiot"),
("-idiots.md", "idiot"),
("-idiot-guide.md", "idiot"),
("-reasoning.md", "reasoning"),
("-history.md", "history"),
("-unsorted.md", "unsorted"),
("-benchmarks.md", "benchmarks"),
("-bench.md", "benchmarks"),
];
for (suf, class) in SUFFIX {
if name.ends_with(suf) {
return class;
}
}
if let Some(stem) = name.strip_suffix(".md") {
if let Some(c) = canonical_class(stem) {
return c;
}
if stem == "readme" {
return "readme";
}
if stem == "changelog" {
return "changelog";
}
}
"other"
}
fn canonical_class(s: &str) -> Option<&'static str> {
Some(match s.to_ascii_lowercase().as_str() {
"design" => "design",
"guide" => "guide",
"idiot" | "for-idiots" | "for-dummies" => "idiot",
"reasoning" => "reasoning",
"history" => "history",
"unsorted" => "unsorted",
"benchmarks" | "bench" => "benchmarks",
"readme" => "readme",
"changelog" => "changelog",
_ => return None,
})
}
fn explicit_class(body: &str) -> Option<&'static str> {
let mut cut = body.len().min(600);
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
let head = &body[..cut];
let idx = head.find("nornir:class")?;
let rest = &head[idx + "nornir:class".len()..];
let val: String = rest
.trim_start_matches(|c: char| c == ':' || c == '=' || c == '"' || c.is_whitespace())
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
canonical_class(&val)
}
pub fn load_routing(repo_root: &Path) -> Vec<BookRoute> {
let path = repo_root.join(".nornir/docs.toml");
if let Ok(text) = std::fs::read_to_string(&path) {
match toml::from_str::<DocsRoutingToml>(&text) {
Ok(cfg) if !cfg.book.is_empty() => {
return cfg
.book
.into_iter()
.map(|(name, r)| BookRoute { name, classes: r.classes, out: r.out })
.collect();
}
Ok(_) => {}
Err(e) => eprintln!("nornir docs: ignoring malformed .nornir/docs.toml: {e}"),
}
}
default_routing()
}
pub fn default_routing() -> Vec<BookRoute> {
let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
vec![
BookRoute {
name: "manual".into(),
classes: v(&["readme", "changelog", "design", "guide", "benchmarks"]),
out: "docs/manual.pdf".into(),
},
BookRoute {
name: "idiot_guide".into(),
classes: v(&["idiot"]),
out: "docs/idiot_guide.pdf".into(),
},
BookRoute {
name: "unsorted".into(),
classes: v(&["reasoning", "history", "unsorted"]),
out: "docs/unsorted.pdf".into(),
},
]
}
pub fn build_books(repo_root: &Path, ctx: &Ctx, format: DocFormat) -> Result<Vec<BuiltBook>> {
let routing = load_routing(repo_root);
let mut classed: Vec<(&'static str, Chapter)> = Vec::new();
for path in discover_sources(repo_root) {
let ch = build_chapter(&path, repo_root, ctx)?;
let class = classify(&path, &ch.markdown);
classed.push((class, ch));
}
let routed: HashSet<&str> = routing
.iter()
.flat_map(|b| b.classes.iter().map(|s| s.as_str()))
.collect();
for (class, ch) in &classed {
if !routed.contains(class) {
eprintln!(
"nornir docs: {} (class `{class}`) is routed to no book — omitted",
ch.source.strip_prefix(repo_root).unwrap_or(&ch.source).display(),
);
}
}
let (title, version) = read_meta(repo_root);
let cover_image = detect_cover_image(repo_root).unwrap_or_default();
let cache_dir = repo_root.join(".nornir/cache/images");
let ext = format.extension();
let mut out_books = Vec::new();
for route in &routing {
let wanted: HashSet<&str> = route.classes.iter().map(|s| s.as_str()).collect();
let chapters: Vec<&Chapter> = classed
.iter()
.filter(|(c, _)| wanted.contains(c))
.map(|(_, ch)| ch)
.collect();
if chapters.is_empty() {
continue; }
let md = assemble_markdown_refs(&chapters);
let meta = ExportMeta {
title: format!("{title} — {}", titleize(&route.name)),
version: version.clone(),
generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
cover_image: cover_image.clone(),
};
let bytes = export::export(&md, &meta, format, Some(&cache_dir), Some(repo_root))?;
let out = PathBuf::from(&route.out).with_extension(ext);
let sources = chapters.iter().map(|c| c.source.clone()).collect();
out_books.push(BuiltBook { name: route.name.clone(), out, bytes, sources });
}
Ok(out_books)
}
fn assemble_markdown_refs(chapters: &[&Chapter]) -> String {
let mut out = String::new();
for ch in chapters {
if !starts_with_h1(&ch.markdown) {
out.push_str("# ");
out.push_str(&ch.title);
out.push_str("\n\n");
}
out.push_str(ch.markdown.trim_end());
out.push_str("\n\n");
}
out
}
pub fn build_book(
repo_root: &Path,
ctx: &Ctx,
format: DocFormat,
) -> Result<(Vec<u8>, Vec<PathBuf>)> {
let (md, meta, sources) = assemble_book(repo_root, ctx)?;
let cache_dir = repo_root.join(".nornir/cache/images");
let bytes = export::export(&md, &meta, format, Some(&cache_dir), Some(repo_root))?;
Ok((bytes, sources))
}
pub fn build_book_svg_pages(
repo_root: &Path,
ctx: &Ctx,
) -> Result<(Vec<String>, Vec<String>, Vec<PathBuf>)> {
let (md, meta, sources) = assemble_book(repo_root, ctx)?;
let cache_dir = repo_root.join(".nornir/cache/images");
let pages = export::export_svg_pages(&md, &meta, Some(&cache_dir), Some(repo_root))?;
let chapter_titles = collect_chapters(repo_root, ctx)?
.iter()
.map(|c| c.title.clone())
.collect();
Ok((pages, chapter_titles, sources))
}
fn assemble_book(repo_root: &Path, ctx: &Ctx) -> Result<(String, ExportMeta, Vec<PathBuf>)> {
let chapters = collect_chapters(repo_root, ctx)?;
let sources: Vec<PathBuf> = chapters
.iter()
.filter(|c| !c.is_section)
.map(|c| c.source.clone())
.collect();
let md = assemble_markdown(&chapters);
let (title, version) = read_meta(repo_root);
let cover_image = detect_cover_image(repo_root).unwrap_or_default();
let meta = ExportMeta {
title: format!("{title} — documentation"),
version,
generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
cover_image,
};
Ok((md, meta, sources))
}
fn detect_cover_image(repo_root: &Path) -> Option<String> {
let assets = repo_root.join(".nornir/assets");
let name = repo_root.file_name().and_then(|n| n.to_str()).unwrap_or("");
let exts = ["svg", "png", "webp", "jpg", "jpeg"];
let mut stems = Vec::new();
if !name.is_empty() {
stems.push(name.to_string());
}
stems.push("cover".to_string());
for stem in stems {
for ext in exts {
let p = assets.join(format!("{stem}.{ext}"));
if p.is_file() {
return p
.strip_prefix(repo_root)
.ok()
.map(|r| r.to_string_lossy().replace('\\', "/"));
}
}
}
None
}
fn discover_sources(repo_root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let nornir_dir = repo_root.join(".nornir");
let mut nornir_md = list_md(&nornir_dir);
nornir_md.sort_by(|a, b| source_rank(a).cmp(&source_rank(b)).then_with(|| a.cmp(b)));
out.extend(nornir_md);
let mut root_md: Vec<PathBuf> = list_md(repo_root)
.into_iter()
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!MANAGED_DOCS.contains(&name) && is_book_chapter(name)
})
.collect();
root_md.sort();
out.extend(root_md);
out.extend(discover_crate_cards(repo_root));
out
}
fn is_book_chapter(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if lower == "claude.md" {
return false;
}
if let Some(stem) = lower.strip_suffix(".md") {
if stem == "inbox" || stem.ends_with("-inbox") || stem.ends_with("_inbox") {
return false;
}
}
true
}
fn source_rank(p: &Path) -> u8 {
match p.file_name().and_then(|n| n.to_str()) {
Some("README.md") => 0,
Some("CHANGELOG.md") => 1,
_ => 2,
}
}
fn discover_crate_cards(repo_root: &Path) -> Vec<PathBuf> {
let root_nornir = repo_root.join(".nornir");
let mut out = Vec::new();
let mut stack = vec![repo_root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == ".nornir" {
if path != root_nornir {
out.extend(list_md(&path));
}
continue; }
if name == "target" || name == "node_modules" || name.starts_with('.') {
continue;
}
stack.push(path);
}
}
out.sort();
out
}
fn list_md(dir: &Path) -> Vec<PathBuf> {
let mut v = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else {
return v;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("md"))
.unwrap_or(false)
{
v.push(path);
}
}
v.sort();
v
}
fn starts_with_h1(md: &str) -> bool {
md.lines()
.map(str::trim_start)
.find(|l| !l.is_empty())
.map(|l| l.starts_with("# "))
.unwrap_or(false)
}
fn chapter_title(path: &Path, body: &str) -> String {
if let Some(line) = body.lines().map(str::trim_start).find(|l| !l.is_empty()) {
if let Some(h) = line.strip_prefix("# ") {
return h.trim().to_string();
}
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
titleize(stem)
}
fn titleize(stem: &str) -> String {
stem.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn read_meta(repo_root: &Path) -> (String, String) {
let dir_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.to_string();
let name = std::fs::read_to_string(repo_root.join("Cargo.toml"))
.ok()
.and_then(|c| toml::from_str::<toml::Value>(&c).ok())
.and_then(|p| {
p.get("package")
.or_else(|| p.get("workspace").and_then(|w| w.get("package")))
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or(dir_name);
(name, resolve_version(repo_root))
}
pub fn resolve_version(repo_root: &Path) -> String {
let Ok(content) = std::fs::read_to_string(repo_root.join("Cargo.toml")) else {
return "0.0.0".to_string();
};
let Ok(parsed) = toml::from_str::<toml::Value>(&content) else {
return "0.0.0".to_string();
};
if let Some(v) = parsed
.get("package")
.or_else(|| parsed.get("workspace").and_then(|w| w.get("package")))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return v.to_string();
}
let ws_pkg_version = parsed
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str());
let members = parsed
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
modal_member_version(repo_root, &members, ws_pkg_version).unwrap_or_else(|| "0.0.0".into())
}
fn modal_member_version(
repo_root: &Path,
members: &[&str],
ws_pkg_version: Option<&str>,
) -> Option<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
let mut bump = |v: String| {
if let Some(e) = counts.iter_mut().find(|(k, _)| *k == v) {
e.1 += 1;
} else {
counts.push((v, 1));
}
};
for m in members {
let dirs = if let Some(prefix) = m.strip_suffix("/*") {
std::fs::read_dir(repo_root.join(prefix))
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
vec![repo_root.join(m)]
};
for dir in dirs {
let Ok(c) = std::fs::read_to_string(dir.join("Cargo.toml")) else {
continue;
};
let Ok(p) = toml::from_str::<toml::Value>(&c) else {
continue;
};
let ver = p.get("package").and_then(|pkg| pkg.get("version"));
let resolved = match ver {
Some(toml::Value::String(s)) => Some(s.clone()),
Some(toml::Value::Table(t)) if t.get("workspace").is_some() => {
ws_pkg_version.map(str::to_string)
}
_ => None,
};
if let Some(v) = resolved {
bump(v);
}
}
}
counts.into_iter().max_by_key(|(_, n)| *n).map(|(v, _)| v)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: &Path, s: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, s).unwrap();
}
#[test]
fn crate_cards_recurse_but_skip_root_and_target() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(&root.join(".nornir/design.md"), "# Design\n");
write(&root.join("crates/foo/.nornir/foo.md"), "# Foo\n");
write(&root.join("member/bar/.nornir/bar.md"), "# Bar\n");
write(&root.join("target/pkg/.nornir/junk.md"), "# Junk\n");
write(&root.join("crates/foo/.nornir/assets/skip.md"), "# Skip\n");
let got: Vec<String> = discover_crate_cards(root)
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(got.contains(&"foo.md".to_string()), "per-crate card found: {got:?}");
assert!(got.contains(&"bar.md".to_string()), "workspace-member card found: {got:?}");
assert!(!got.contains(&"design.md".to_string()), "root .nornir excluded: {got:?}");
assert!(!got.contains(&"junk.md".to_string()), "target/ pruned: {got:?}");
assert!(!got.contains(&"skip.md".to_string()), ".nornir/assets not descended: {got:?}");
}
#[test]
fn discovers_and_orders_sources() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(&root.join(".nornir/README.md"), "# Readme\n\nbody\n");
write(&root.join(".nornir/CHANGELOG.md"), "# Changelog\n");
write(&root.join(".nornir/design.md"), "# Design\n");
write(&root.join("plan.md"), "# Plan\n");
write(&root.join("README.md"), "generated\n");
write(&root.join("CLAUDE.md"), "agent instructions\n");
write(&root.join("znippy-inbox.md"), "scratch\n");
let got: Vec<String> = discover_sources(root)
.iter()
.map(|p| {
let parent = p.parent().unwrap().file_name().unwrap().to_str().unwrap();
let name = p.file_name().unwrap().to_str().unwrap();
format!("{parent}/{name}")
})
.collect();
let root_name = root.file_name().unwrap().to_str().unwrap();
assert_eq!(
got,
vec![
".nornir/README.md".to_string(),
".nornir/CHANGELOG.md".to_string(),
".nornir/design.md".to_string(),
format!("{root_name}/plan.md"),
]
);
}
#[test]
fn is_book_chapter_skips_non_docs() {
assert!(!is_book_chapter("CLAUDE.md"));
assert!(!is_book_chapter("claude.md"));
assert!(!is_book_chapter("znippy-inbox.md"));
assert!(!is_book_chapter("inbox.md"));
assert!(!is_book_chapter("notes_inbox.md"));
assert!(is_book_chapter("WORKSPACE.md"));
assert!(is_book_chapter("plan.md"));
assert!(is_book_chapter("design.md"));
}
#[test]
fn assemble_prepends_h1_only_when_missing() {
let chapters = vec![
Chapter {
source: "a.md".into(),
title: "Alpha".into(),
markdown: "# Alpha\n\nhas its own h1\n".into(),
is_section: false,
},
Chapter {
source: "b.md".into(),
title: "Beta".into(),
markdown: "## sub only\n\nno h1\n".into(),
is_section: false,
},
];
let md = assemble_markdown(&chapters);
assert_eq!(md.matches("# Alpha").count(), 1);
assert!(md.contains("# Beta"));
assert!(md.contains("## sub only"));
}
#[test]
fn resolve_version_prefers_package_then_modal_member() {
let t = tempfile::tempdir().unwrap();
write(&t.path().join("Cargo.toml"), "[package]\nname='x'\nversion='1.2.3'\n");
assert_eq!(resolve_version(t.path()), "1.2.3");
let w = tempfile::tempdir().unwrap();
write(
&w.path().join("Cargo.toml"),
"[workspace]\nmembers=['a','b','c','d']\n",
);
for (m, v) in [("a", "0.9.0"), ("b", "0.9.0"), ("c", "0.9.0"), ("d", "0.1.0")] {
write(
&w.path().join(m).join("Cargo.toml"),
&format!("[package]\nname='{m}'\nversion='{v}'\n"),
);
}
assert_eq!(resolve_version(w.path()), "0.9.0");
let e = tempfile::tempdir().unwrap();
write(&e.path().join("Cargo.toml"), "[workspace]\nmembers=[]\n");
assert_eq!(resolve_version(e.path()), "0.0.0");
}
#[test]
fn titleize_basic() {
assert_eq!(titleize("docs-generation"), "Docs Generation");
assert_eq!(titleize("design_notes"), "Design Notes");
}
#[test]
fn classify_by_suffix_bare_and_marker() {
let p = |n: &str| PathBuf::from(n);
assert_eq!(classify(&p("release-design.md"), ""), "design");
assert_eq!(classify(&p("warehouse-guide.md"), ""), "guide");
assert_eq!(classify(&p("build-for-idiots.md"), ""), "idiot");
assert_eq!(classify(&p("map-reasoning.md"), ""), "reasoning");
assert_eq!(classify(&p("test-history.md"), ""), "history");
assert_eq!(classify(&p("jobs-unsorted.md"), ""), "unsorted");
assert_eq!(classify(&p("znippy-benchmarks.md"), ""), "benchmarks");
assert_eq!(classify(&p("design.md"), ""), "design");
assert_eq!(classify(&p("for-idiots.md"), ""), "idiot");
assert_eq!(classify(&p("README.md"), ""), "readme");
assert_eq!(classify(&p("CHANGELOG.md"), ""), "changelog");
assert_eq!(
classify(&p("notes.md"), "<!-- nornir:class: reasoning -->\n# Notes\n"),
"reasoning"
);
assert_eq!(
classify(&p("release-design.md"), "<!-- nornir:class = \"guide\" -->\n"),
"guide"
);
assert_eq!(classify(&p("docs-generation.md"), ""), "other");
}
#[test]
fn load_routing_falls_back_to_default() {
let t = tempfile::tempdir().unwrap();
let names: Vec<String> = load_routing(t.path()).into_iter().map(|b| b.name).collect();
assert_eq!(names, vec!["manual", "idiot_guide", "unsorted"]);
write(
&t.path().join(".nornir/docs.toml"),
"[book.manual]\nclasses=[\"design\"]\nout=\"docs/m.pdf\"\n\
[book.zzz]\nclasses=[\"guide\"]\nout=\"docs/z.pdf\"\n",
);
let routes = load_routing(t.path());
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].name, "manual");
assert_eq!(routes[0].out, "docs/m.pdf");
assert_eq!(routes[1].name, "zzz");
}
#[cfg(feature = "docs-export")]
#[test]
fn build_books_routes_by_class_into_distinct_pdfs() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(&root.join("Cargo.toml"), "[package]\nname='demo'\nversion='1.0.0'\n");
let body = "Lorem ipsum dolor sit amet. ".repeat(60);
write(&root.join(".nornir/README.md"), &format!("# Demo\n\n{body}"));
write(&root.join(".nornir/release-design.md"), &format!("# Release design\n\n{body}"));
write(&root.join(".nornir/release-guide.md"), &format!("# Release guide\n\n{body}"));
write(&root.join(".nornir/release-for-idiots.md"), &format!("# Release idiot\n\n{body}"));
write(&root.join(".nornir/release-reasoning.md"), &format!("# Release why\n\n{body}"));
write(&root.join(".nornir/docs-generation.md"), &format!("# Doctrine\n\n{body}"));
let ctx = Ctx::new(root, root, None);
let books = build_books(root, &ctx, DocFormat::parse("md").unwrap()).unwrap();
let by = |name: &str| books.iter().find(|b| b.name == name).unwrap();
let mut names: Vec<&str> = books.iter().map(|b| b.name.as_str()).collect();
names.sort();
assert_eq!(names, vec!["idiot_guide", "manual", "unsorted"]);
let manual = String::from_utf8(by("manual").bytes.clone()).unwrap();
assert!(manual.contains("Release design"), "manual has design");
assert!(manual.contains("Release guide"), "manual has guide");
assert!(!manual.contains("Release idiot"), "manual excludes idiot");
assert!(!manual.contains("Release why"), "manual excludes reasoning");
assert!(!manual.contains("Doctrine"), "manual excludes `other`");
let idiot = String::from_utf8(by("idiot_guide").bytes.clone()).unwrap();
assert!(idiot.contains("Release idiot"));
assert!(!idiot.contains("Release design"));
let unsorted = String::from_utf8(by("unsorted").bytes.clone()).unwrap();
assert!(unsorted.contains("Release why"));
assert!(!unsorted.contains("Release guide"));
assert_eq!(by("manual").out, PathBuf::from("docs/manual.md"));
assert_eq!(by("idiot_guide").out, PathBuf::from("docs/idiot_guide.md"));
}
#[cfg(feature = "docs-export")]
#[test]
fn self_repo_book_lands_at_real_docs_path_not_tmp() {
use crate::config::{Nornir, Repo};
use crate::docs::RepoLayout;
let ws = tempfile::tempdir().unwrap();
let ws_root = ws.path();
let repo_dir = ws_root.join("nornir-orch");
write(&repo_dir.join("Cargo.toml"), "[package]\nname='nornir'\nversion='9.9.9'\n");
let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.\n\n"
.repeat(40);
for (name, title) in [
("README.md", "Nornir"),
("design.md", "Design"),
("guide.md", "Guide"),
("bencher.md", "Bencher"),
("warehouse.md", "Warehouse"),
] {
write(
&repo_dir.join(format!(".nornir/{name}")),
&format!("# {title}\n\n{body}"),
);
}
let mut nornir = Nornir::default();
nornir.repo.insert(
"nornir".into(),
Repo { path: "nornir-orch".into(), ..Default::default() },
);
let resolved = nornir.repo_dir_for(ws_root, "nornir");
assert_eq!(resolved, repo_dir, "override must redirect `nornir` → nornir-orch dir");
let ctx = Ctx::new(&resolved, ws_root, None);
let format = DocFormat::parse("pdf").unwrap();
let (bytes, sources) = build_book(&resolved, &ctx, format).unwrap();
let layout = RepoLayout::new(&resolved);
let out = layout.export_path("book", format.extension());
std::fs::create_dir_all(out.parent().unwrap()).unwrap();
std::fs::write(&out, &bytes).unwrap();
assert_eq!(out, repo_dir.join("docs/book.pdf"));
assert!(
out.starts_with(&repo_dir),
"book must land under the resolved repo root {}, got {}",
repo_dir.display(),
out.display(),
);
let self_stub = std::env::temp_dir().join("nornir/docs/book.pdf");
assert_ne!(out, self_stub, "book must not be written to the global self-stub");
assert!(sources.len() > 1, "expected >1 source, got {}", sources.len());
assert!(out.is_file(), "book.pdf must exist at {}", out.display());
let size = std::fs::metadata(&out).unwrap().len();
let empty = tempfile::tempdir().unwrap();
write(&empty.path().join("Cargo.toml"), "[package]\nname='nornir'\nversion='9.9.9'\n");
let empty_ctx = Ctx::new(empty.path(), empty.path(), None);
let (empty_bytes, empty_sources) = build_book(empty.path(), &empty_ctx, format).unwrap();
assert!(empty_sources.is_empty(), "empty repo must yield no sources");
assert!(
size as usize > empty_bytes.len() * 2,
"book.pdf ({size} bytes) must dwarf the empty stub ({} bytes) — \
empty-stub regression",
empty_bytes.len(),
);
assert!(
size > 50_000,
"book.pdf must be non-trivial, got {size} bytes — empty-stub regression",
);
}
}