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 fn collect_chapters(repo_root: &Path, ctx: &Ctx) -> Result<Vec<Chapter>> {
let mut chapters = Vec::new();
for path in discover_sources(repo_root) {
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);
chapters.push(Chapter {
source: path,
title,
markdown: body,
});
}
Ok(chapters)
}
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 fn build_book(
repo_root: &Path,
ctx: &Ctx,
format: DocFormat,
) -> Result<(Vec<u8>, Vec<PathBuf>)> {
let chapters = collect_chapters(repo_root, ctx)?;
let sources: Vec<PathBuf> = chapters.iter().map(|c| c.source.clone()).collect();
let md = assemble_markdown(&chapters);
let (title, version) = read_meta(repo_root);
let meta = ExportMeta {
title: format!("{title} — documentation"),
version,
generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
};
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))
}
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
}
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 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 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(),
},
Chapter {
source: "b.md".into(),
title: "Beta".into(),
markdown: "## sub only\n\nno h1\n".into(),
},
];
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");
}
}