crates/nornir-docgen/src/layout.rs
//! Per-repo nornir layout and the source→output render pipeline.
//!
//! Every repo owned by nornir gets a `.nornir/` directory at its root:
//!
//! ```text
//! repo/
//! ├── .nornir/ ← nornir's namespace
//! │ ├── README.md ← SOURCE (you edit this)
//! │ ├── CHANGELOG.md ← SOURCE (you edit this)
//! │ ├── assets/ ← TRACKED media (depgraph.svg, logos, figures)
//! │ ├── warehouse/ ← DATA ONLY: the Iceberg warehouse (gitignored)
//! │ ├── cache/ ← transient: images + the docs index (gitignored)
//! │ └── .gitignore ← ignores cache/ + warehouse/
//! ├── README.md ← GENERATED — DO NOT EDIT (chmod -w)
//! ├── CHANGELOG.md ← GENERATED — DO NOT EDIT (chmod -w)
//! ├── docs/ ← GENERATED rendered docs (book.pdf/.md, …); tracked
//! └── ...
//! ```
//!
//! Rendered exports (`docs export` / `docs book`) write the *current* artifact
//! to `<repo>/docs/` and historize the bytes in the Iceberg `doc_exports`
//! table; `.nornir/warehouse/` is data-only.
//!
//! The source files use the same `<!-- nornir:gen:start:NAME -->` markers as
//! [`super::sections`] for dynamic fills (bench tables, callgraphs, ...).
//! Render writes the whole output file (not just marker regions) and prepends
//! a loud header so anyone opening the artifact knows where the truth lives.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use super::sections::{Ctx, FileReport, rewrite_str};
/// Header prepended to every generated output file. The leading newline keeps
/// the comment on its own line; the warning lands in line 1 of the artifact.
pub const GENERATED_HEADER_PREFIX: &str = "<!-- ⚠ GENERATED by nornir from .nornir/";
pub const GENERATED_HEADER_SUFFIX: &str = " — DO NOT EDIT this file -->\n\n";
/// File names nornir manages by default. Adding new ones is a single-line
/// change: extend this list and they automatically get init / render / check.
pub const MANAGED_DOCS: &[&str] = &["README.md", "CHANGELOG.md"];
/// Per-repo render configuration, read from `<repo>/.nornir/docs.toml`.
///
/// Controls *which artifact(s)* `nornir docs render` emits for this repo:
///
/// - `"markdown"` (alias `"md"`) — the managed top-level `README.md` /
/// `CHANGELOG.md`, rendered from their `.nornir/` sources (nornir's default
/// and historical behaviour).
/// - `"pdf"` — the whole-documentation **book** rendered to `docs/book.pdf`
/// (needs a build with the `export` feature — nornir's `docs-export`).
/// - `"all"` — both of the above.
///
/// **Absent file ⇒ `["markdown"]`** — so existing repos are unchanged. A repo
/// that wants *only* a rendered PDF and keeps its markdown hand-edited sets:
///
/// ```toml
/// # <repo>/.nornir/docs.toml
/// outputs = ["pdf"]
/// ```
///
/// With `outputs = ["pdf"]`, `render` never writes any markdown into the repo —
/// it only (re)builds `docs/book.pdf` from the repo's doc set. This is the way
/// to add documentation rendering to a project whose `*.md` are authored by
/// hand and must not be overwritten.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DocsRenderCfg {
/// Artifact kinds to emit. See the type docs for accepted tokens.
pub outputs: Vec<String>,
}
impl Default for DocsRenderCfg {
fn default() -> Self {
Self {
outputs: vec!["markdown".to_string()],
}
}
}
impl DocsRenderCfg {
/// Path to the config file: `<repo>/.nornir/docs.toml`.
pub fn path(layout: &RepoLayout) -> PathBuf {
layout.nornir_dir().join("docs.toml")
}
/// Load the config for `layout`'s repo. Returns the default
/// (`["markdown"]`) when `.nornir/docs.toml` is absent.
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()))
}
/// Whether the managed markdown artifacts should be (re)rendered.
pub fn wants_markdown(&self) -> bool {
self.outputs
.iter()
.any(|o| matches!(o.to_ascii_lowercase().as_str(), "markdown" | "md" | "all"))
}
/// Whether the PDF documentation book should be rendered.
pub fn wants_pdf(&self) -> bool {
self.outputs
.iter()
.any(|o| matches!(o.to_ascii_lowercase().as_str(), "pdf" | "all"))
}
}
/// Layout of nornir-owned paths under one repo.
#[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(),
}
}
/// `<repo>/.nornir/`
pub fn nornir_dir(&self) -> PathBuf {
self.repo_root.join(".nornir")
}
/// `<repo>/.nornir/warehouse/`
pub fn warehouse_dir(&self) -> PathBuf {
self.nornir_dir().join("warehouse")
}
/// `<repo>/.nornir/cache/`
pub fn cache_dir(&self) -> PathBuf {
self.nornir_dir().join("cache")
}
/// Image-cache subdir used by [`super::export`].
pub fn image_cache_dir(&self) -> PathBuf {
self.cache_dir().join("images")
}
/// Source path for one managed doc, e.g. `.nornir/README.md`.
pub fn source_of(&self, doc_name: &str) -> PathBuf {
self.nornir_dir().join(doc_name)
}
/// Output (artifact) path for one managed doc, e.g. `README.md`.
pub fn output_of(&self, doc_name: &str) -> PathBuf {
self.repo_root.join(doc_name)
}
/// `<repo>/docs/` — the canonical, git-tracked output directory for
/// nornir-rendered documents (README/book PDF/HTML/MD). README links here.
pub fn docs_dir(&self) -> PathBuf {
self.repo_root.join("docs")
}
/// Path of a rendered export under `docs/`, e.g. `docs/book.pdf`. Stable
/// (version-free) so links from the README don't churn per release.
pub fn export_path(&self, doc_name: &str, ext: &str) -> PathBuf {
self.docs_dir().join(format!("{doc_name}.{ext}"))
}
}
/// Per-file render outcome.
#[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,
}
/// Render a single managed doc: read source → fill markers → prepend warning
/// → atomically write output → restore read-only bit if file was previously
/// locked. Returns `Ok(None)` when no source exists for that doc (so a repo
/// can opt into only README and not CHANGELOG, etc.).
pub fn render_doc(layout: &RepoLayout, doc_name: &str, ctx: &Ctx) -> Result<Option<RenderReport>> {
// ★ THE CLAIM GATE, on the WRITER. A render is the moment an old sentence is
// re-authorised with a fresh date and a DO-NOT-EDIT banner, so the gate has to
// sit where the bytes are written, not in one caller's wrapper. Runs before
// any assembly: on refusal nothing has been read for writing and no artifact
// is touched (SK190). See `super::claims`.
super::claims::gate_repo(&layout.repo_root)?;
let Some(a) = assemble_managed_doc(layout, doc_name, ctx)? else {
return Ok(None);
};
let changed = a.prev.as_deref() != Some(a.output.as_str());
if changed {
write_atomic(&a.dst, &a.output)?;
}
Ok(Some(RenderReport {
doc_name: doc_name.to_string(),
source: a.src,
output: a.dst,
sections: a.sections,
changed,
bytes: a.output.len(),
}))
}
/// What [`render_doc`] would write, and the state it would write it over.
struct Assembled {
src: PathBuf,
dst: PathBuf,
/// The exact bytes the artifact should contain — header + filled body.
output: String,
/// Section names filled, in document order.
sections: Vec<String>,
/// The artifact as it is on disk right now, if it exists. The change-detection
/// baseline for BOTH the writer and the freshness checker.
prev: Option<String>,
}
/// ★ **THE ONE ASSEMBLER** — the single writer of "what should this artifact
/// contain", read by [`render_doc`] (which writes it) and [`check_doc`] (which
/// compares against it).
///
/// ⚠ WHY IT EXISTS (SK190, measured 2026-08-01). The non-destructive rule — a
/// renderer with no fresh data must never blank a committed table — was wired into
/// the WRITER only. `render_doc` assembled with
/// `sections::rewrite_str_against_output(raw, ctx, prev)`; `check_doc` assembled the
/// same doc with `rewrite_str(raw, ctx)`, the TEMPLATE-ONLY path. A managed doc's
/// source template has EMPTY `nornir:gen` markers by design, so the checker's
/// `expected` was the placeholder version of every table, it never matched the real
/// committed artifact, and `release_gate_docs_fresh` reported DRIFT that did not
/// exist — the exact false drift the preserve-tables fix was written to cure,
/// against the one caller that gates a release.
///
/// Two copies of "assemble the doc" could not be kept in agreement by a test that
/// watched them agree, so there is now one copy. `check_doc` cannot diverge from
/// `render_doc` because it is no longer computing anything.
///
/// A managed doc's source is a TEMPLATE whose markers are empty, so the rendered
/// tables exist only in the OUTPUT — which is why `prev` is read first and handed to
/// the fallback. A render taken while no warehouse row was reachable used to
/// silently replace real measured tables with `_(no bench history yet …)_`: 58 lines
/// of them on znippy-zoomies, and skade's whole benchmark set once before that.
///
/// `Ok(None)` when the repo has no `.nornir/` source for that doc (a repo may opt
/// into README and not CHANGELOG).
fn assemble_managed_doc(
layout: &RepoLayout,
doc_name: &str,
ctx: &Ctx,
) -> Result<Option<Assembled>> {
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 dst = layout.output_of(doc_name);
let prev = std::fs::read_to_string(&dst).ok();
let (filled, sections) = match prev.as_deref() {
Some(p) => super::sections::rewrite_str_against_output(&raw, ctx, p)?,
None => 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);
Ok(Some(Assembled {
src,
dst,
output,
sections,
prev,
}))
}
/// Render every managed doc found under `.nornir/`.
pub fn render_all(layout: &RepoLayout, ctx: &Ctx) -> Result<Vec<RenderReport>> {
// Gated here as well as in `render_doc`: a repo whose MANAGED_DOCS have no
// source renders nothing, and the gate must still refuse rather than silently
// do nothing on the way to a book build.
super::claims::gate_repo(&layout.repo_root)?;
let mut reports = Vec::new();
for &doc in MANAGED_DOCS {
if let Some(r) = render_doc(layout, doc, ctx)? {
reports.push(r);
}
}
Ok(reports)
}
/// Fill `<!-- nornir:gen:* -->` markers **in place** for every non-managed
/// `.nornir/*.md` source (e.g. `design.md`, `plan.md`, `benching.md`).
///
/// Why this exists: [`render_doc`] only rewrites the *managed* docs
/// (`README.md` / `CHANGELOG.md`), reading `.nornir/<doc>.md` as a template and
/// writing the filled result to `<repo>/<doc>.md`. Every other `.nornir/*.md`
/// (design notes, plans, …) has **no separate output** — it *is* the published
/// file (Codeberg/GitHub render it directly). Those sources were only ever
/// marker-expanded transiently inside the PDF/book pipeline ([`super::book`]),
/// so their `nornir:gen:*` blocks — notably `depgraph` — stayed **empty on
/// disk** and were invisible when the file was viewed directly. This populates
/// them in place so the rendered dependency graph (and any other gen section)
/// is actually present in the source markdown.
///
/// Managed-doc sources are skipped here: they are templates whose empty markers
/// are intentional (they get filled into the root output, not the source).
///
/// Returns the report for each `.nornir/*.md` that carried markers.
pub fn render_sources_in_place(layout: &RepoLayout, ctx: &Ctx) -> Result<Vec<FileReport>> {
// ★ THE CLAIM GATE. This verb rewrites the SOURCE in place, so a refusal here
// is what keeps a false claimed sentence from being re-authorised inside the
// very file that carries the claim.
super::claims::gate_repo(&layout.repo_root)?;
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| {
// Skip the managed-doc *templates* — their markers fill into the
// root output, not the source.
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!MANAGED_DOCS.contains(&name)
})
.collect();
paths.sort();
for path in paths {
// `assemble_file` is a no-op (changed:false, empty sections) for files
// that carry no `nornir:gen` markers, so hand-authored notes without
// markers are left byte-for-byte untouched.
let report =
assemble_file(&path, ctx).with_context(|| format!("assemble {}", path.display()))?;
// Only surface files that actually carried marker sections.
if !report.sections.is_empty() {
reports.push(report);
}
}
Ok(reports)
}
/// In-memory dry-run of [`render_doc`] — bails if rendering would change
/// the output file. Use as a CI gate.
pub fn check_doc(layout: &RepoLayout, doc_name: &str, ctx: &Ctx) -> Result<Option<bool>> {
// ONE assembler, shared with `render_doc` (SK190). This used to compute its own
// `expected` from the TEMPLATE alone, whose markers are empty by design, so it
// reported drift against every committed table whenever bench data was
// unreachable — false drift, in the one caller that gates a release.
let Some(a) = assemble_managed_doc(layout, doc_name, ctx)? else {
return Ok(None);
};
if a.prev.as_deref() != Some(a.output.as_str()) {
bail!(
"{} is out of date with {} — run `nornir docs render <repo>` to regenerate",
a.dst.display(),
a.src.display()
);
}
Ok(Some(true))
}
/// Check every managed doc.
pub fn check_all(layout: &RepoLayout, ctx: &Ctx) -> Result<()> {
for &doc in MANAGED_DOCS {
check_doc(layout, doc, ctx)?;
}
Ok(())
}
/// One managed doc's freshness status — the per-doc shape behind
/// [`check_outcome`]. `status` is one of `"ok"` (matches its source),
/// `"drift"` (out of date — needs `docs render`), or `"absent"` (no `.nornir/`
/// source for this doc).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct DocCheckRow {
pub doc: String,
pub status: String,
}
/// Check every managed doc WITHOUT bailing, returning a per-doc status row.
/// This is the non-throwing twin of [`check_all`] that the AUT9
/// [`check_outcome`] builder shapes — it never short-circuits on the first
/// drift, so the outcome can report the full picture. Drift maps to `"drift"`
/// (the `check_doc` bail), a present+matching source to `"ok"`, a missing source
/// to `"absent"`.
pub fn check_all_status(layout: &RepoLayout, ctx: &Ctx) -> Vec<DocCheckRow> {
let mut rows = Vec::with_capacity(MANAGED_DOCS.len());
for &doc in MANAGED_DOCS {
let status = match check_doc(layout, doc, ctx) {
Ok(Some(true)) => "ok",
Ok(Some(false)) => "drift", // (check_doc bails on drift; kept for totality)
Ok(None) => "absent",
Err(_) => "drift",
};
rows.push(DocCheckRow {
doc: doc.to_string(),
status: status.to_string(),
});
}
rows
}
/// `docs check` rendered as the uniform [`nornir_cli_outcome::CommandOutcome`] —
/// the shared DATA FACE for the CLI's fat (`run_docs_check`) and thin
/// (`docs_remote(.., "check", ..)`) paths.
///
/// `rows` arrive ALREADY-COMPUTED ([`check_all_status`] in fat mode; projected
/// from the `Docs.Check` RPC's status/detail in thin mode). This fn only SHAPES
/// + renders them. `ok ⟺ ≥1 doc present AND none drifting` (RAGNARÖK: an
/// empty/drifting check is RED, never silently green). AUT9 —
/// see `.nornir/cli-command-contract.md`.
pub fn check_outcome(repo: &str, rows: Vec<DocCheckRow>) -> nornir_cli_outcome::CommandOutcome {
use nornir_cli_outcome::CommandOutcome;
let present: Vec<&DocCheckRow> = rows.iter().filter(|r| r.status != "absent").collect();
if present.is_empty() {
return CommandOutcome::fail("docs check", format!("no managed docs found for {repo}"));
}
let drifting: Vec<&str> = rows
.iter()
.filter(|r| r.status == "drift")
.map(|r| r.doc.as_str())
.collect();
let mut human = format!("docs check {repo}");
for r in &rows {
let mark = match r.status.as_str() {
"ok" => "✓",
"drift" => "✗",
_ => "·",
};
human.push_str(&format!("\n {mark} {:<14} {}", r.doc, r.status));
}
let data = serde_json::json!({ "repo": repo, "docs": rows });
if drifting.is_empty() {
human.push_str(&format!("\nok: every doc in {repo} matches its source"));
CommandOutcome::ok("docs check", data, human)
} else {
// Ran fine but the state is RED — a drifting doc fails the sannr check.
human.push_str(&format!(
"\nDRIFT: {} out of date — run `nornir docs render {repo}`",
drifting.join(", ")
));
CommandOutcome {
command: "docs check".into(),
ok: false,
data,
human,
}
}
}
/// Scaffold `.nornir/` for a repo:
/// - create `.nornir/`, `.nornir/cache/`, `.nornir/warehouse/`
/// - migrate any existing top-level managed docs (README.md, CHANGELOG.md)
/// into `.nornir/` as the source
/// - if no managed doc exists, write a minimal scaffold for README.md
/// - write `.nornir/.gitignore` (ignores cache/)
///
/// Returns the list of source files now present in `.nornir/`.
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() {
// Migrate the existing artifact into .nornir/ as the source.
// The artifact will be re-rendered (with the GENERATED warning)
// on the next `nornir docs render`.
let existing =
std::fs::read_to_string(&dst).with_context(|| format!("read {}", dst.display()))?;
// Strip any pre-existing GENERATED header so a re-init is a no-op.
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 {
// No README anywhere — scaffold a starter.
let starter = scaffold_readme(&layout.repo_root);
let src = layout.source_of("README.md");
std::fs::write(&src, starter)?;
sources.push(src);
}
Ok(sources)
}
/// Strip a pre-existing GENERATED-by-nornir header (if any) from the start
/// of `text`. Idempotent.
fn strip_generated_header(text: &str) -> String {
if let Some(rest) = text.strip_prefix(GENERATED_HEADER_PREFIX) {
// Find the matching suffix `-->` and the following blank line.
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.
"#
)
}
/// Write `content` to `dst` atomically (write to tmp, fsync, rename),
/// honouring a previous read-only bit by clearing it before write and
/// restoring it after.
fn write_atomic(dst: &Path, content: &str) -> Result<()> {
use std::io::Write;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
// Capture & clear any read-only bit so we can overwrite.
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)
}
/// RED-when-broken (INCIDENT 2026-07-22): a render with NO fresh bench data must
/// KEEP the tables already in the generated README, never blank them.
///
/// This is the generated-output door, which the in-place guard did not cover. A
/// managed doc's source is a TEMPLATE whose `nornir:gen` markers are intentionally
/// empty; the tables live only in the OUTPUT. So rewriting against the template
/// alone had nothing to preserve, the no-data placeholder always won, and
/// `nornir docs render znippy-zoomies` replaced 58 lines of committed, measured
/// `loki · 12 cores` tables with 9 `_(no bench history yet …)_` lines. It had
/// already destroyed skade's whole benchmark set once.
///
/// Absence of data is not evidence of absence of results.
#[test]
fn a_render_with_no_bench_data_keeps_the_tables_already_in_the_output() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
// The SOURCE is a template: the marker is empty, by design.
let src_marker = "<!-- nornir:gen:start:benches compare=znippy-gz,pigz winner=row -->";
let end = "<!-- nornir:gen:end:benches -->";
std::fs::write(
l.nornir_dir().join("README.md"),
format!("# zoomies\n\n{src_marker}\n{end}\n"),
)
.unwrap();
// The OUTPUT already carries a real, measured table — the thing that must
// survive a render taken while no warehouse row is reachable.
//
// Its marker spells the SAME arguments in a DIFFERENT ORDER, because that is
// what the renderer emits: it re-serialises args sorted. Matching the two
// sides on raw marker text therefore never matches, and a fallback keyed that
// way does nothing while looking right — which is precisely how the first
// attempt at this fix passed its own unit test and still destroyed 58 lines
// of the real README. The identity must be the marker's NAME + ARGS, not its
// spelling.
let out_marker = "<!-- nornir:gen:start:benches winner=row compare=znippy-gz,pigz -->";
let table =
"| system | decompress_mbs |\n|---|---|\n| znippy-gz | 5760 |\n| pigz | 589.32 |\n";
std::fs::write(
l.output_of("README.md"),
format!("# zoomies\n\n{out_marker}\n{table}{end}\n"),
)
.unwrap();
// A context with NO bench run at all — the warehouse-unreachable case.
let root = t.path().to_path_buf();
let ctx = Ctx::new(&root, &root, None);
render_doc(&l, "README.md", &ctx).expect("render");
let after = std::fs::read_to_string(l.output_of("README.md")).unwrap();
assert!(
after.contains("5760") && after.contains("589.32"),
"the measured numbers were destroyed by a render that had nothing to \
replace them with:\n{after}"
);
assert!(
!after.contains("no bench history yet"),
"a no-data placeholder was spliced over a real table:\n{after}"
);
}
#[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"));
}
/// Inject a known dep-graph (a crate `widget_lib` that depends on `serde`)
/// and assert `render_sources_in_place` fills the `depgraph` marker in
/// `.nornir/design.md` with that crate's name + dependency — proving the
/// marker is populated on disk, NOT left empty. (This is the I1 regression
/// guard: the design-doc depgraph used to render only inside the PDF book,
/// leaving the source file's marker blank.)
#[test]
fn render_sources_in_place_fills_depgraph_marker() {
let (t, l) = layout();
let root = t.path();
// A real (minimal) cargo crate so `cargo metadata` yields a known graph:
// node `widget_lib`, edge widget_lib -.-> serde.
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();
// A design doc whose depgraph marker is EMPTY (the broken state).
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();
// Precondition: nothing between the markers yet.
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"
);
// Inject-assert at the data layer the renderer reads: the extracted
// graph carries the exact node + edge we set up.
let g = nornir_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");
// It reported design.md and rewrote it.
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()]);
// The on-disk marker is now NON-empty.
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:?}"
);
// The injected crate + dependency must be present in the *rendered*
// graph. Two render backends:
// * export: the marker is an image ref and the crate names live
// in the generated SVG asset (typst box labels).
// * default: the marker holds the inline SVG graph (crate names
// survive as literal <text> labels).
#[cfg(feature = "export")]
{
// design.md lives under `.nornir/`, so the link must be
// doc-relative (`assets/depgraph.svg`), NOT the repo-root-relative
// `.nornir/assets/...` (which a git host would 404 from inside
// `.nornir/`).
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}"
);
// typst renders label text as glyph-outline <path>s (no literal crate
// strings survive), so we assert the SVG is a valid, non-trivial
// rendering whose geometry reflects THIS graph: an empty graph
// renders a much smaller SVG. This proves the marker is backed by a
// real, populated render — not an empty placeholder.
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::svg::render_graph_svg(&nornir_introspect::Graph::default())
.expect("render empty");
// Two boxes + an edge ⇒ strictly more drawing than an empty graph.
assert!(
svg.len() > empty_svg.len() + 200,
"rendered SVG ({} bytes) should be larger than an empty-graph SVG ({} bytes)",
svg.len(),
empty_svg.len()
);
// One <path> group per drawn glyph/shape — a 2-node, 1-edge graph
// draws many; an empty graph draws (near) none.
assert!(
svg.matches("<path").count() >= 10,
"expected glyph/shape geometry for 2 labelled nodes; got {} <path>s",
svg.matches("<path").count()
);
}
#[cfg(not(feature = "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}"
);
}
// The surrounding prose is preserved.
assert!(after.contains("# Design"));
assert!(after.contains("outro"));
}
/// A `.nornir/*.md` source with no `nornir:gen` markers is left untouched
/// (no churn) and not reported.
#[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);
}
/// Managed-doc *templates* (`.nornir/README.md`) are skipped by
/// `render_sources_in_place` — their markers fill into the root output, not
/// the source — so the template's empty markers are preserved.
#[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();
// Mutate source so the output is now stale.
std::fs::write(l.source_of("README.md"), "# changed\n").unwrap();
assert!(check_doc(&l, "README.md", &ctx).is_err());
}
/// The fixture SK190 is about: a managed doc whose SOURCE template has an empty
/// `benches` marker and whose committed OUTPUT carries a real measured table,
/// rendered with a context that has NO bench run (the warehouse-unreachable
/// case). Returns `(tempdir, layout, ctx-root)`.
///
/// The output's marker spells the same args in a DIFFERENT ORDER on purpose —
/// that is what the renderer emits (args are re-serialised sorted), and a
/// fallback keyed on raw marker text silently matches nothing. See
/// `a_render_with_no_bench_data_keeps_the_tables_already_in_the_output`.
fn preserved_table_fixture(prose: &str) -> (tempfile::TempDir, RepoLayout) {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
let src_marker = "<!-- nornir:gen:start:benches compare=znippy-gz,pigz winner=row -->";
let out_marker = "<!-- nornir:gen:start:benches winner=row compare=znippy-gz,pigz -->";
let end = "<!-- nornir:gen:end:benches -->";
let table =
"| system | decompress_mbs |\n|---|---|\n| znippy-gz | 5760 |\n| pigz | 589.32 |\n";
std::fs::write(
l.source_of("README.md"),
format!("{prose}\n\n{src_marker}\n{end}\n"),
)
.unwrap();
let header = format!("{GENERATED_HEADER_PREFIX}README.md{GENERATED_HEADER_SUFFIX}");
std::fs::write(
l.output_of("README.md"),
format!("{header}{prose}\n\n{out_marker}\n{table}{end}\n"),
)
.unwrap();
// Render ONCE with no bench run, so the artifact is exactly what the WRITER
// produces for this source (normalised marker, table preserved). That is the
// state a repo is in after `nornir docs render` — and the state the freshness
// gate is asked about.
let root = t.path().to_path_buf();
let ctx = Ctx::new(&root, &root, None);
render_doc(&l, "README.md", &ctx).expect("seed render");
let seeded = std::fs::read_to_string(l.output_of("README.md")).unwrap();
assert!(
seeded.contains("5760") && seeded.contains("589.32"),
"the seed render destroyed the measured table, so this fixture cannot \
test the gate:\n{seeded}"
);
(t, l)
}
/// ★ **SK190 — THE FRESHNESS GATE MUST NOT REPORT DRIFT IT INVENTED.**
///
/// `release_gate_docs_fresh` calls `check_doc`. The non-destructive
/// preserve-tables rule was wired into `render_doc` only, so `check_doc`
/// assembled `expected` from the TEMPLATE — empty markers — and declared every
/// committed bench table stale whenever no bench run was reachable. The gate was
/// therefore exposed to the exact false drift the fix was written to cure, and it
/// could not pass suite-wide.
///
/// This asserts the APPLIED verdict of the gate's own entry point, on a doc that
/// is genuinely in sync — and it asserts NON-VACUOUSLY: `render_doc` on the same
/// fixture must report `changed == false`, i.e. the writer agrees the artifact is
/// already correct. If the writer wanted to change it, "no drift" would be the
/// wrong answer and this test would be asserting the wrong thing.
#[test]
fn check_reports_no_drift_for_a_table_the_render_could_not_regenerate() {
let (t, l) = preserved_table_fixture("# zoomies");
let root = t.path().to_path_buf();
let ctx = Ctx::new(&root, &root, None);
// ANCHOR: the writer is content — so the artifact IS fresh, and any drift
// the checker reports is invented.
let r = render_doc(&l, "README.md", &ctx)
.expect("render")
.expect("reported");
assert!(
!r.changed,
"the writer wanted to change the artifact, so this fixture is not the \
in-sync case this test needs"
);
// ANCHOR: the measured numbers are actually there to be preserved.
let on_disk = std::fs::read_to_string(l.output_of("README.md")).unwrap();
assert!(
on_disk.contains("5760") && on_disk.contains("589.32"),
"{on_disk}"
);
check_doc(&l, "README.md", &ctx).expect(
"check_doc reported drift on a doc the renderer itself considers fresh — \
that is the SK190 false drift, and release_gate_docs_fresh fails on it",
);
}
/// …and the other half, which matters more: a gate that stopped reporting drift
/// AT ALL would be the worse bug. REAL drift beside a preserved table is still
/// red. Same fixture, source prose changed, so the assembled artifact genuinely
/// differs from what is committed.
#[test]
fn check_still_reports_real_drift_beside_a_preserved_table() {
let (t, l) = preserved_table_fixture("# zoomies");
let root = t.path().to_path_buf();
let ctx = Ctx::new(&root, &root, None);
// Sanity: in sync before the edit.
check_doc(&l, "README.md", &ctx).expect("in sync before the edit");
let src = std::fs::read_to_string(l.source_of("README.md")).unwrap();
std::fs::write(
l.source_of("README.md"),
src.replace("# zoomies", "# renamed"),
)
.unwrap();
let err = check_doc(&l, "README.md", &ctx)
.expect_err("a source edit MUST still be reported as drift");
let msg = format!("{err:#}");
assert!(msg.contains("is out of date with"), "{msg}");
// And rendering it resolves the drift while STILL keeping the table — the
// preserve rule and the drift report are not in tension.
render_doc(&l, "README.md", &ctx).expect("render");
let after = std::fs::read_to_string(l.output_of("README.md")).unwrap();
assert!(
after.contains("# renamed"),
"the prose edit must land: {after}"
);
assert!(
after.contains("5760") && after.contains("589.32"),
"the measured table must survive the drift-resolving render: {after}"
);
check_doc(&l, "README.md", &ctx).expect("no drift after render");
}
/// ★ **A RENDER WITH NO REACHABLE RUN MUST NOT DESTROY THE `bench_status` CARD.**
///
/// MEASURED on nornir's own README, 2026-08-01, with a real `nornir docs render
/// nornir` against an unreachable warehouse. The `benches` table survived; the
/// card above it did not:
///
/// ```text
/// -**v0.5.4 · oden · 32 cores · 2026-07-23**
/// -| tests | 154/156 passed — **2 FAILING** |
/// -⚠ **2 tests failed in this run:**
/// +**Not benched.** This repo contributed no numbers to the latest run.
/// ```
///
/// `sections::NO_DATA_PREFIXES` listed the five `_(no …)_` italic placeholders and
/// not the `**Not benched.**` card, so the non-destructive rule saw the absence
/// body as CONTENT. The protected half sitting next to the unprotected half is
/// what hid it.
///
/// Asserts APPLIED OUTPUT — the bytes on disk after a real `render_doc` — and
/// pins BOTH sections, because "one of the two is preserved" is the state this
/// test exists to distinguish from "the doc is preserved".
#[test]
fn a_render_with_no_reachable_run_keeps_the_bench_status_card_too() {
let (t, l) = layout();
std::fs::create_dir_all(l.nornir_dir()).unwrap();
let status_start = "<!-- nornir:gen:start:bench_status -->";
let status_end = "<!-- nornir:gen:end:bench_status -->";
let benches_start = "<!-- nornir:gen:start:benches winner=row -->";
let benches_end = "<!-- nornir:gen:end:benches -->";
// SOURCE: a template — both markers empty, by design.
std::fs::write(
l.source_of("README.md"),
format!("# nornir\n\n{benches_start}\n{benches_end}\n\n{status_start}\n{status_end}\n"),
)
.unwrap();
// OUTPUT: the committed artifact, carrying a real measured card AND a real
// measured table — nornir's own README, trimmed.
let card = "**v0.5.4 · oden · 32 cores · 2026-07-23**\n\n| | |\n|---|---|\n\
| host | `oden` · 32 cores |\n| arms | 154 |\n\
| tests | 154/156 passed — **2 FAILING** |\n\
| publishable | **no — 2 failing arms** |\n";
let table = "| workload | ns_per_op |\n|---|---|\n| nornir.mimir.scan | 335 |\n";
let header = format!("{GENERATED_HEADER_PREFIX}README.md{GENERATED_HEADER_SUFFIX}");
std::fs::write(
l.output_of("README.md"),
format!(
"{header}# nornir\n\n{benches_start}\n{table}{benches_end}\n\n\
{status_start}\n{card}{status_end}\n"
),
)
.unwrap();
// A Ctx with NO run — the warehouse-unreachable case, which is what a
// pre-commit hook or a release gate on a box with no warehouse actually has.
let root = t.path().to_path_buf();
let ctx = Ctx::new(&root, &root, None);
render_doc(&l, "README.md", &ctx).expect("render");
let after = std::fs::read_to_string(l.output_of("README.md")).unwrap();
assert!(
!after.contains("Not benched."),
"the absence card was spliced over a real measured one — a render with no \
reachable run destroyed published measurement:\n{after}"
);
assert!(
after.contains("154/156 passed") && after.contains("v0.5.4"),
"the measured bench_status card must survive a run-less render:\n{after}"
);
assert!(
after.contains("**no — 2 failing arms**"),
"the verdict row must survive with it:\n{after}"
);
// The protected half must STILL be protected — this is not a fix that traded
// one section for another.
assert!(
after.contains("335"),
"the benches table must survive too:\n{after}"
);
// NON-VACUOUS ANCHOR: the prose outside the markers is rewritten every time,
// so a render that silently did nothing at all would not satisfy this file's
// header contract. Prove the render actually ran and wrote the header.
assert!(
after.starts_with(GENERATED_HEADER_PREFIX),
"render_doc did not write its generated-artifact header, so this test did \
not exercise a real render:\n{after}"
);
}
#[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();
// Lock the output, then mutate source and re-render. write_atomic
// should clear+restore the bit.
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());
}
fn row(doc: &str, status: &str) -> DocCheckRow {
DocCheckRow {
doc: doc.into(),
status: status.into(),
}
}
/// RAGNARÖK: no managed docs present at all ⇒ RED, not silently green.
#[test]
fn docs_check_no_docs_is_red() {
let o = check_outcome(
"demo",
vec![row("README.md", "absent"), row("CHANGELOG.md", "absent")],
);
assert_eq!(o.command, "docs check");
assert!(!o.is_sannr(), "no managed docs => RED");
assert!(o.human.contains("no managed docs"));
}
/// All present + matching ⇒ sannr, with real per-doc data injected.
#[test]
fn docs_check_all_ok_is_sannr() {
let o = check_outcome(
"demo",
vec![row("README.md", "ok"), row("CHANGELOG.md", "ok")],
);
assert!(o.is_sannr(), "every doc matches => sannr");
assert_eq!(o.data["repo"], serde_json::json!("demo"));
let arr = o.data["docs"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["doc"], serde_json::json!("README.md"));
assert_eq!(arr[0]["status"], serde_json::json!("ok"));
assert!(o.human.contains("matches its source"));
}
/// A drifting doc ⇒ ran fine but the state is RED (ok=false), naming the doc.
#[test]
fn docs_check_drift_is_red_and_named() {
let o = check_outcome(
"demo",
vec![row("README.md", "ok"), row("CHANGELOG.md", "drift")],
);
assert!(!o.is_sannr(), "a drifting doc fails the sannr check");
assert!(!o.ok);
assert!(o.human.contains("CHANGELOG.md"));
assert!(o.human.contains("DRIFT"));
// data is still populated so the matrix sees WHICH doc drifted.
let arr = o.data["docs"].as_array().unwrap();
assert!(arr.iter().any(|d| d["status"] == "drift"));
}
}