use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::sections::{rewrite_str, Ctx};
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)]
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 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 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());
}
}