use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use super::sections::{rewrite_str, Ctx, FileReport};
pub const GENERATED_HEADER_PREFIX: &str =
"<!-- ⚠ GENERATED by nornir from .nornir/";
pub const GENERATED_HEADER_SUFFIX: &str = " — DO NOT EDIT this file -->\n\n";
pub const MANAGED_DOCS: &[&str] = &["README.md", "CHANGELOG.md"];
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DocsRenderCfg {
pub outputs: Vec<String>,
}
impl Default for DocsRenderCfg {
fn default() -> Self {
Self {
outputs: vec!["markdown".to_string()],
}
}
}
impl DocsRenderCfg {
pub fn path(layout: &RepoLayout) -> PathBuf {
layout.nornir_dir().join("docs.toml")
}
pub fn load(layout: &RepoLayout) -> Result<Self> {
let p = Self::path(layout);
if !p.exists() {
return Ok(Self::default());
}
let text =
std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
toml::from_str(&text).with_context(|| format!("parse {}", p.display()))
}
pub fn wants_markdown(&self) -> bool {
self.outputs
.iter()
.any(|o| matches!(o.to_ascii_lowercase().as_str(), "markdown" | "md" | "all"))
}
pub fn wants_pdf(&self) -> bool {
self.outputs
.iter()
.any(|o| matches!(o.to_ascii_lowercase().as_str(), "pdf" | "all"))
}
}
#[derive(Debug, Clone)]
pub struct RepoLayout {
pub repo_root: PathBuf,
}
impl RepoLayout {
pub fn new(repo_root: impl Into<PathBuf>) -> Self {
Self {
repo_root: repo_root.into(),
}
}
pub fn nornir_dir(&self) -> PathBuf {
self.repo_root.join(".nornir")
}
pub fn warehouse_dir(&self) -> PathBuf {
self.nornir_dir().join("warehouse")
}
pub fn cache_dir(&self) -> PathBuf {
self.nornir_dir().join("cache")
}
pub fn image_cache_dir(&self) -> PathBuf {
self.cache_dir().join("images")
}
pub fn source_of(&self, doc_name: &str) -> PathBuf {
self.nornir_dir().join(doc_name)
}
pub fn output_of(&self, doc_name: &str) -> PathBuf {
self.repo_root.join(doc_name)
}
pub fn docs_dir(&self) -> PathBuf {
self.repo_root.join("docs")
}
pub fn export_path(&self, doc_name: &str, ext: &str) -> PathBuf {
self.docs_dir().join(format!("{doc_name}.{ext}"))
}
}
#[derive(Debug)]
pub struct RenderReport {
pub doc_name: String,
pub source: PathBuf,
pub output: PathBuf,
pub sections: Vec<String>,
pub changed: bool,
pub bytes: usize,
}
pub fn render_doc(layout: &RepoLayout, doc_name: &str, ctx: &Ctx) -> Result<Option<RenderReport>> {
let src = layout.source_of(doc_name);
if !src.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&src)
.with_context(|| format!("read {}", src.display()))?;
let (filled, sections) = rewrite_str(&raw, ctx)?;
let header = format!("{GENERATED_HEADER_PREFIX}{doc_name}{GENERATED_HEADER_SUFFIX}");
let mut output = String::with_capacity(header.len() + filled.len());
output.push_str(&header);
output.push_str(&filled);
let dst = layout.output_of(doc_name);
let prev = std::fs::read_to_string(&dst).ok();
let changed = prev.as_deref() != Some(output.as_str());
if changed {
write_atomic(&dst, &output)?;
}
Ok(Some(RenderReport {
doc_name: doc_name.to_string(),
source: src,
output: dst,
sections,
changed,
bytes: output.len(),
}))
}
pub fn render_all(layout: &RepoLayout, ctx: &Ctx) -> Result<Vec<RenderReport>> {
let mut reports = Vec::new();
for &doc in MANAGED_DOCS {
if let Some(r) = render_doc(layout, doc, ctx)? {
reports.push(r);
}
}
Ok(reports)
}
pub fn render_sources_in_place(layout: &RepoLayout, ctx: &Ctx) -> Result<Vec<FileReport>> {
use super::sections::assemble_file;
let dir = layout.nornir_dir();
let mut reports = Vec::new();
let Ok(rd) = std::fs::read_dir(&dir) else {
return Ok(reports);
};
let mut paths: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& p.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("md"))
.unwrap_or(false)
})
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!MANAGED_DOCS.contains(&name)
})
.collect();
paths.sort();
for path in paths {
let report = assemble_file(&path, ctx)
.with_context(|| format!("assemble {}", path.display()))?;
if !report.sections.is_empty() {
reports.push(report);
}
}
Ok(reports)
}
pub fn check_doc(layout: &RepoLayout, doc_name: &str, ctx: &Ctx) -> Result<Option<bool>> {
let src = layout.source_of(doc_name);
if !src.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&src)?;
let (filled, _) = rewrite_str(&raw, ctx)?;
let header = format!("{GENERATED_HEADER_PREFIX}{doc_name}{GENERATED_HEADER_SUFFIX}");
let expected = format!("{header}{filled}");
let dst = layout.output_of(doc_name);
let prev = std::fs::read_to_string(&dst).unwrap_or_default();
if prev != expected {
bail!(
"{} is out of date with {} — run `nornir docs render <repo>` to regenerate",
dst.display(),
src.display()
);
}
Ok(Some(true))
}
pub fn check_all(layout: &RepoLayout, ctx: &Ctx) -> Result<()> {
for &doc in MANAGED_DOCS {
check_doc(layout, doc, ctx)?;
}
Ok(())
}
pub fn init_repo(layout: &RepoLayout) -> Result<Vec<PathBuf>> {
std::fs::create_dir_all(layout.nornir_dir())?;
std::fs::create_dir_all(layout.cache_dir())?;
std::fs::create_dir_all(layout.warehouse_dir())?;
std::fs::create_dir_all(layout.image_cache_dir())?;
let gi = layout.nornir_dir().join(".gitignore");
if !gi.exists() {
std::fs::write(&gi, "cache/\n")?;
}
let mut sources = Vec::new();
let mut had_any = false;
for &doc in MANAGED_DOCS {
let src = layout.source_of(doc);
let dst = layout.output_of(doc);
if src.exists() {
sources.push(src);
had_any = true;
continue;
}
if dst.exists() {
let existing = std::fs::read_to_string(&dst)
.with_context(|| format!("read {}", dst.display()))?;
let cleaned = strip_generated_header(&existing);
std::fs::write(&src, cleaned)
.with_context(|| format!("write {}", src.display()))?;
sources.push(src);
had_any = true;
}
}
if !had_any {
let starter = scaffold_readme(&layout.repo_root);
let src = layout.source_of("README.md");
std::fs::write(&src, starter)?;
sources.push(src);
}
Ok(sources)
}
fn strip_generated_header(text: &str) -> String {
if let Some(rest) = text.strip_prefix(GENERATED_HEADER_PREFIX) {
if let Some(end) = rest.find("-->") {
let after = &rest[end + 3..];
let after = after.trim_start_matches(['\n', '\r']);
return after.to_string();
}
}
text.to_string()
}
fn scaffold_readme(repo_root: &Path) -> String {
let name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
format!(
r#"# {name}
> One-line description of {name}.
<!-- Headline: paste your single most meaningful benchmark number here, e.g.
**9,950 MB/s decompress · 32 cores**. The full table renders below. -->
<!-- Write any intro prose here. Anything outside the marker blocks below
is preserved verbatim on every render. -->
## Benchmarks
<!-- The `benches` renderer pivots the latest run into a table and bolds the
winning cell per row (e.g. ours vs a legacy tool). Direction is inferred
from the metric unit (`_mbs` higher-better, `_ms` lower-better); override
odd cases with `benches best=metric:low`. -->
<!-- nornir:gen:start:benches -->
<!-- nornir:gen:end:benches -->
## Tests
<!-- Unit / integration / doc tests, enumerated from source via syn. -->
<!-- nornir:gen:start:tests -->
<!-- nornir:gen:end:tests -->
## Dependency graph
<!-- nornir:gen:start:depgraph -->
<!-- nornir:gen:end:depgraph -->
## License
MIT OR Apache-2.0.
"#
)
}
fn write_atomic(dst: &Path, content: &str) -> Result<()> {
use std::io::Write;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let prev_readonly = match std::fs::metadata(dst) {
Ok(m) => {
let ro = m.permissions().readonly();
if ro {
let mut p = m.permissions();
#[allow(clippy::permissions_set_readonly_false)]
p.set_readonly(false);
std::fs::set_permissions(dst, p)?;
}
ro
}
Err(_) => false,
};
let tmp = dst.with_extension(format!(
"{}.nornir-tmp",
dst.extension()
.and_then(|e| e.to_str())
.unwrap_or("tmp")
));
{
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("create {}", tmp.display()))?;
f.write_all(content.as_bytes())?;
f.sync_all().ok();
}
std::fs::rename(&tmp, dst)
.with_context(|| format!("rename {} -> {}", tmp.display(), dst.display()))?;
if prev_readonly {
let mut p = std::fs::metadata(dst)?.permissions();
p.set_readonly(true);
std::fs::set_permissions(dst, p)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn layout() -> (TempDir, RepoLayout) {
let t = TempDir::new().unwrap();
let l = RepoLayout::new(t.path());
(t, l)
}
#[test]
fn init_creates_skeleton_when_empty() {
let (_t, l) = layout();
let srcs = init_repo(&l).unwrap();
assert!(l.nornir_dir().exists());
assert!(l.cache_dir().exists());
assert!(l.warehouse_dir().exists());
assert!(l.nornir_dir().join(".gitignore").exists());
assert_eq!(srcs.len(), 1);
assert!(srcs[0].ends_with("README.md"));
let scaffold = std::fs::read_to_string(&srcs[0]).unwrap();
assert!(scaffold.contains("nornir:gen:start:benches"));
assert!(scaffold.contains("nornir:gen:start:tests"));
assert!(scaffold.contains("nornir:gen:start:depgraph"));
}
#[test]
fn render_sources_in_place_fills_depgraph_marker() {
let (t, l) = layout();
let root = t.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"widget_lib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nserde = \"1\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "pub fn noop() {}\n").unwrap();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
let design = l.nornir_dir().join("design.md");
std::fs::write(
&design,
"# Design\n\nintro\n\n\
<!-- nornir:gen:start:depgraph -->\n<!-- nornir:gen:end:depgraph -->\n\noutro\n",
)
.unwrap();
let before = std::fs::read_to_string(&design).unwrap();
assert!(
before.contains(
"<!-- nornir:gen:start:depgraph -->\n<!-- nornir:gen:end:depgraph -->"
),
"fixture should start with an empty depgraph marker"
);
let g = crate::introspect::depgraph::extract(root).expect("extract graph");
assert!(
g.nodes.iter().any(|n| n == "widget_lib"),
"extracted graph must contain the workspace crate; got {:?}",
g.nodes
);
assert!(
g.edges
.iter()
.any(|e| e.from == "widget_lib" && e.to == "serde"),
"extracted graph must contain the widget_lib -> serde edge; got {:?}",
g.edges
);
let ctx = Ctx::new(root, root, None);
let reports = render_sources_in_place(&l, &ctx).expect("render in place");
let r = reports
.iter()
.find(|r| r.path.ends_with("design.md"))
.expect("design.md reported");
assert!(r.changed, "design.md should have changed");
assert_eq!(r.sections, vec!["depgraph".to_string()]);
let after = std::fs::read_to_string(&design).unwrap();
let start = after.find("nornir:gen:start:depgraph").unwrap();
let end = after.find("nornir:gen:end:depgraph").unwrap();
let body = after[start..end].trim();
assert!(body.len() > 40, "depgraph body should be non-trivial: {body:?}");
#[cfg(feature = "docs-export")]
{
assert!(
body.contains(""),
"depgraph link must be relative to the .nornir/ doc; got: {body}"
);
assert!(
!body.contains(".nornir/assets/depgraph.svg"),
"link must not be repo-root-relative inside .nornir/; got: {body}"
);
let svg = std::fs::read_to_string(root.join(".nornir/assets/depgraph.svg"))
.expect("depgraph.svg written");
assert!(svg.starts_with("<svg"), "asset must be an SVG");
assert!(svg.contains("</svg>"));
let empty_svg = crate::docs::svg::render_graph_svg(&crate::introspect::Graph::default())
.expect("render empty");
assert!(
svg.len() > empty_svg.len() + 200,
"rendered SVG ({} bytes) should be larger than an empty-graph SVG ({} bytes)",
svg.len(),
empty_svg.len()
);
assert!(
svg.matches("<path").count() >= 10,
"expected glyph/shape geometry for 2 labelled nodes; got {} <path>s",
svg.matches("<path").count()
);
}
#[cfg(not(feature = "docs-export"))]
{
assert!(
body.contains("widget_lib"),
"inline depgraph must name the workspace crate; got: {body}"
);
assert!(
body.contains("serde"),
"inline depgraph must show the injected dependency; got: {body}"
);
}
assert!(after.contains("# Design"));
assert!(after.contains("outro"));
}
#[test]
fn render_sources_in_place_skips_markerless_files() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
let notes = l.nornir_dir().join("notes.md");
let original = "# Notes\n\njust prose, no markers\n";
std::fs::write(¬es, original).unwrap();
let ctx = Ctx::new(t.path(), t.path(), None);
let reports = render_sources_in_place(&l, &ctx).unwrap();
assert!(reports.iter().all(|r| !r.path.ends_with("notes.md")));
assert_eq!(std::fs::read_to_string(¬es).unwrap(), original);
}
#[test]
fn render_sources_in_place_skips_managed_templates() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
let readme_src = l.nornir_dir().join("README.md");
let tpl =
"# X\n\n<!-- nornir:gen:start:depgraph -->\n<!-- nornir:gen:end:depgraph -->\n";
std::fs::write(&readme_src, tpl).unwrap();
let ctx = Ctx::new(t.path(), t.path(), None);
let reports = render_sources_in_place(&l, &ctx).unwrap();
assert!(reports.iter().all(|r| !r.path.ends_with("README.md")));
assert_eq!(std::fs::read_to_string(&readme_src).unwrap(), tpl);
}
#[test]
fn init_migrates_existing_readme() {
let (t, l) = layout();
std::fs::write(t.path().join("README.md"), "# already here\n\nbody\n").unwrap();
let srcs = init_repo(&l).unwrap();
assert_eq!(srcs.len(), 1);
let s = std::fs::read_to_string(&srcs[0]).unwrap();
assert!(s.contains("# already here"));
}
#[test]
fn init_strips_old_header_on_remigrate() {
let (t, l) = layout();
let body = "# x\n\nbody\n";
let with_header = format!(
"{}README.md{}{}",
GENERATED_HEADER_PREFIX, GENERATED_HEADER_SUFFIX, body
);
std::fs::write(t.path().join("README.md"), &with_header).unwrap();
init_repo(&l).unwrap();
let s = std::fs::read_to_string(l.source_of("README.md")).unwrap();
assert!(!s.contains("GENERATED"));
assert!(s.starts_with("# x"));
}
#[test]
fn render_writes_header_and_preserves_source() {
let (t, l) = layout();
let src = l.source_of("README.md");
std::fs::create_dir_all(l.nornir_dir()).unwrap();
std::fs::write(&src, "# title\n\nhello\n").unwrap();
let ctx = Ctx::new(t.path(), t.path(), None);
let r = render_doc(&l, "README.md", &ctx).unwrap().unwrap();
assert!(r.changed);
let out = std::fs::read_to_string(&r.output).unwrap();
assert!(out.starts_with(GENERATED_HEADER_PREFIX));
assert!(out.contains("# title"));
}
#[test]
fn render_is_idempotent() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
std::fs::write(l.source_of("README.md"), "# x\n").unwrap();
let ctx = Ctx::new(t.path(), t.path(), None);
let r1 = render_doc(&l, "README.md", &ctx).unwrap().unwrap();
assert!(r1.changed);
let r2 = render_doc(&l, "README.md", &ctx).unwrap().unwrap();
assert!(!r2.changed);
}
#[test]
fn check_fails_on_drift() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
std::fs::write(l.source_of("README.md"), "# original\n").unwrap();
let ctx = Ctx::new(t.path(), t.path(), None);
render_doc(&l, "README.md", &ctx).unwrap();
std::fs::write(l.source_of("README.md"), "# changed\n").unwrap();
assert!(check_doc(&l, "README.md", &ctx).is_err());
}
#[test]
fn render_respects_readonly_bit() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
std::fs::write(l.source_of("README.md"), "# v1\n").unwrap();
let ctx = Ctx::new(t.path(), t.path(), None);
render_doc(&l, "README.md", &ctx).unwrap();
let out = l.output_of("README.md");
let mut p = std::fs::metadata(&out).unwrap().permissions();
p.set_readonly(true);
std::fs::set_permissions(&out, p).unwrap();
std::fs::write(l.source_of("README.md"), "# v2\n").unwrap();
render_doc(&l, "README.md", &ctx).unwrap();
let new = std::fs::read_to_string(&out).unwrap();
assert!(new.contains("# v2"));
assert!(std::fs::metadata(&out).unwrap().permissions().readonly());
}
#[test]
fn docs_render_cfg_defaults_to_markdown() {
let (_t, l) = layout();
let cfg = DocsRenderCfg::load(&l).unwrap();
assert!(cfg.wants_markdown());
assert!(!cfg.wants_pdf());
}
#[test]
fn docs_render_cfg_pdf_only_skips_markdown() {
let (_t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
std::fs::write(DocsRenderCfg::path(&l), "outputs = [\"pdf\"]\n").unwrap();
let cfg = DocsRenderCfg::load(&l).unwrap();
assert!(cfg.wants_pdf());
assert!(!cfg.wants_markdown());
}
#[test]
fn docs_render_cfg_all_wants_both() {
let (_t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
std::fs::write(DocsRenderCfg::path(&l), "outputs = [\"all\"]\n").unwrap();
let cfg = DocsRenderCfg::load(&l).unwrap();
assert!(cfg.wants_pdf());
assert!(cfg.wants_markdown());
}
}