use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
use crate::bench::BenchRun;
use crate::introspect::{artifact, callgraph_dwarf, depgraph};
#[derive(Debug, Default)]
pub struct FileReport {
pub path: PathBuf,
pub sections: Vec<String>,
pub changed: bool,
}
pub struct Ctx<'a> {
pub repo_root: &'a Path,
pub workspace_root: &'a Path,
pub run: Option<&'a BenchRun>,
}
#[derive(Debug)]
struct Marker {
line_start: usize,
line_end: usize,
name: String,
args: HashMap<String, String>,
}
const START: &str = "<!-- nornir:gen:start:";
const END: &str = "<!-- nornir:gen:end:";
fn parse_markers(text: &str) -> Result<Vec<(Marker, usize)>> {
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let mut open: Vec<(usize, String, HashMap<String, String>)> = Vec::new();
let mut out: Vec<(Marker, usize)> = Vec::new();
let mut in_fence = false;
for (i, line) in lines.iter().enumerate() {
let t = line.trim();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if let Some(rest) = t.strip_prefix(START) {
let body = rest
.strip_suffix("-->")
.ok_or_else(|| anyhow!("malformed start marker at line {}: {t}", i + 1))?
.trim();
let mut parts = body.split_whitespace();
let name = parts
.next()
.ok_or_else(|| anyhow!("missing section name at line {}", i + 1))?
.to_string();
let mut args = HashMap::new();
for kv in parts {
if let Some((k, v)) = kv.split_once('=') {
args.insert(k.to_string(), v.to_string());
}
}
open.push((i, name, args));
} else if let Some(rest) = t.strip_prefix(END) {
let name = rest
.strip_suffix("-->")
.ok_or_else(|| anyhow!("malformed end marker at line {}: {t}", i + 1))?
.trim()
.to_string();
let (start_i, start_name, args) = open
.pop()
.ok_or_else(|| anyhow!("unmatched end marker `{name}` at line {}", i + 1))?;
if start_name != name {
bail!(
"marker mismatch: start `{start_name}` at line {} vs end `{name}` at line {}",
start_i + 1,
i + 1
);
}
out.push((
Marker {
line_start: start_i,
line_end: i,
name,
args,
},
0,
));
}
}
if let Some((i, name, _)) = open.pop() {
bail!("unclosed marker `{name}` opened at line {}", i + 1);
}
Ok(out)
}
fn render(ctx: &Ctx, m: &Marker) -> Result<String> {
let body = match m.name.as_str() {
"bench" => render_bench(ctx)?,
"depgraph" => render_depgraph(ctx)?,
"symbols" => render_symbols(ctx, &m.args)?,
"callgraph" => render_callgraph(ctx, &m.args)?,
other => bail!("unknown section `{other}`"),
};
let mut out = String::new();
out.push_str("<!-- nornir:gen:start:");
out.push_str(&m.name);
for (k, v) in args_sorted(&m.args) {
out.push(' ');
out.push_str(&k);
out.push('=');
out.push_str(&v);
}
out.push_str(" -->\n");
out.push_str(body.trim_end());
out.push('\n');
out.push_str("<!-- nornir:gen:end:");
out.push_str(&m.name);
out.push_str(" -->\n");
Ok(out)
}
fn args_sorted(a: &HashMap<String, String>) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = a.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
pub fn assemble_file(path: &Path, ctx: &Ctx) -> Result<FileReport> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let (new_text, sections) = rewrite(&text, ctx)?;
let changed = new_text != text;
if changed {
std::fs::write(path, &new_text)
.with_context(|| format!("write {}", path.display()))?;
}
Ok(FileReport {
path: path.to_path_buf(),
sections,
changed,
})
}
pub fn check_file(path: &Path, ctx: &Ctx) -> Result<FileReport> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let (new_text, sections) = rewrite(&text, ctx)?;
if new_text != text {
bail!(
"{} is out of date — run `nornir docs assemble` to regenerate",
path.display()
);
}
Ok(FileReport {
path: path.to_path_buf(),
sections,
changed: false,
})
}
pub fn rewrite_str(text: &str, ctx: &Ctx) -> Result<(String, Vec<String>)> {
rewrite(text, ctx)
}
fn rewrite(text: &str, ctx: &Ctx) -> Result<(String, Vec<String>)> {
let markers = parse_markers(text)?;
if markers.is_empty() {
return Ok((text.to_string(), Vec::new()));
}
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let mut out = String::new();
let mut cursor = 0usize;
let mut sections = Vec::new();
for (m, _) in &markers {
for l in &lines[cursor..m.line_start] {
out.push_str(l);
}
out.push_str(&render(ctx, m)?);
sections.push(m.name.clone());
cursor = m.line_end + 1;
}
for l in &lines[cursor..] {
out.push_str(l);
}
Ok((out, sections))
}
fn render_bench(ctx: &Ctx) -> Result<String> {
let Some(run) = ctx.run else {
return Ok("_(no bench history yet — run a benchmark to populate this section)_\n".to_string());
};
let mut out = String::new();
out.push_str(&format!(
"**v{ver} · {cores} cores**\n\n",
ver = run.version,
cores = run.cores
));
if run.results.is_empty() {
out.push_str("_(no bench results)_\n");
return Ok(out);
}
out.push_str("| name | metric | value |\n|------|--------|-------|\n");
let mut rows: Vec<(String, String, f64)> = Vec::new();
for r in &run.results {
for (k, v) in &r.metrics {
if let Some(f) = v.as_f64() {
rows.push((r.name.clone(), k.clone(), f));
}
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
for (n, k, v) in rows {
out.push_str(&format!("| {n} | {k} | {v:.2} |\n"));
}
Ok(out)
}
fn render_depgraph(ctx: &Ctx) -> Result<String> {
let g = depgraph::extract(ctx.repo_root)?;
#[cfg(feature = "docs-export")]
{
let rel = std::path::PathBuf::from(".nornir/cache/svg/depgraph.svg");
let abs = ctx.repo_root.join(&rel);
crate::docs::svg::render_to_file(&g, &abs)?;
return Ok(format!(
"\n",
rel.display()
));
}
#[cfg(not(feature = "docs-export"))]
{
let mut out = g.to_mermaid();
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
}
fn resolve_binary(ctx: &Ctx, raw: &str) -> PathBuf {
let p = PathBuf::from(raw);
if p.is_absolute() {
p
} else if ctx.repo_root.join(&p).exists() {
ctx.repo_root.join(&p)
} else {
ctx.workspace_root.join(&p)
}
}
fn render_symbols(ctx: &Ctx, args: &HashMap<String, String>) -> Result<String> {
let bin_arg = args
.get("binary")
.ok_or_else(|| anyhow!("section `symbols` needs binary=PATH"))?;
let limit: usize = args
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let binary = resolve_binary(ctx, bin_arg);
let syms = artifact::extract_symbols(&binary, ctx.workspace_root)?;
let mut visible: Vec<&artifact::Symbol> = syms
.iter()
.filter(|s| !s.name_demangled.contains("::{{closure}}"))
.collect();
visible.sort_by(|a, b| b.size_bytes.unwrap_or(0).cmp(&a.size_bytes.unwrap_or(0)));
visible.truncate(limit);
let mut out = String::new();
out.push_str(&format!(
"_top {} symbols by size from `{}`_\n\n",
visible.len(),
bin_arg
));
out.push_str("| symbol | size | file |\n|--------|------|------|\n");
for s in &visible {
let file = if s.file.is_empty() { "?" } else { s.file.as_str() };
let size = s
.size_bytes
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
out.push_str(&format!(
"| `{}` | {} | {} |\n",
md_escape(&s.name_demangled),
size,
file
));
}
Ok(out)
}
fn render_callgraph(ctx: &Ctx, args: &HashMap<String, String>) -> Result<String> {
let bin_arg = args
.get("binary")
.ok_or_else(|| anyhow!("section `callgraph` needs binary=PATH"))?;
let limit: usize = args
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(15);
let binary = resolve_binary(ctx, bin_arg);
let edges = callgraph_dwarf::extract_callgraph(&binary, ctx.workspace_root)?;
let mut count: HashMap<String, usize> = HashMap::new();
for e in &edges {
*count.entry(e.callee.clone()).or_default() += 1;
}
let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
ranked.truncate(limit);
let mut out = String::new();
out.push_str(&format!(
"_{} inline edges in `{}`; top {} callees:_\n\n",
edges.len(),
bin_arg,
ranked.len()
));
out.push_str("| callee | inline sites |\n|--------|-------------:|\n");
for (n, c) in ranked {
out.push_str(&format!("| `{}` | {} |\n", md_escape(&n), c));
}
Ok(out)
}
fn md_escape(s: &str) -> String {
s.replace('|', "\\|").replace('`', "\\`")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Map, Value};
fn ctx<'a>(root: &'a Path, run: Option<&'a BenchRun>) -> Ctx<'a> {
Ctx {
repo_root: root,
workspace_root: root,
run,
}
}
fn run_fixture() -> BenchRun {
let mut m: Map<String, Value> = Map::new();
m.insert("ops".to_string(), Value::from(123.45f64));
BenchRun {
date: "2026-05-30".into(),
timestamp: None,
version: "0.1.0".into(),
machine: "test".into(),
cores: 8,
results: vec![crate::bench::BenchResult {
name: "decode".into(),
metrics: m,
}],
tests: vec![],
}
}
#[test]
fn parses_and_rewrites_bench_section() {
let r = run_fixture();
let d = tempfile::tempdir().unwrap();
let p = d.path().join("README.md");
std::fs::write(
&p,
"# x\n\n<!-- nornir:gen:start:bench -->\nstale\n<!-- nornir:gen:end:bench -->\n\ntail\n",
)
.unwrap();
let c = ctx(d.path(), Some(&r));
let rep = assemble_file(&p, &c).unwrap();
assert!(rep.changed);
assert_eq!(rep.sections, vec!["bench".to_string()]);
let txt = std::fs::read_to_string(&p).unwrap();
assert!(txt.contains("v0.1.0"));
assert!(txt.contains("decode"));
assert!(txt.ends_with("tail\n"));
}
#[test]
fn check_errors_on_drift() {
let r = run_fixture();
let d = tempfile::tempdir().unwrap();
let p = d.path().join("README.md");
std::fs::write(
&p,
"<!-- nornir:gen:start:bench -->\nstale\n<!-- nornir:gen:end:bench -->\n",
)
.unwrap();
let c = ctx(d.path(), Some(&r));
assert!(check_file(&p, &c).is_err());
}
#[test]
fn idempotent_after_assemble() {
let r = run_fixture();
let d = tempfile::tempdir().unwrap();
let p = d.path().join("README.md");
std::fs::write(
&p,
"<!-- nornir:gen:start:bench -->\n<!-- nornir:gen:end:bench -->\n",
)
.unwrap();
let c = ctx(d.path(), Some(&r));
assemble_file(&p, &c).unwrap();
let r1 = check_file(&p, &c).unwrap();
assert!(!r1.changed);
}
#[test]
fn unknown_section_errors() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("README.md");
std::fs::write(
&p,
"<!-- nornir:gen:start:nope -->\n<!-- nornir:gen:end:nope -->\n",
)
.unwrap();
let c = ctx(d.path(), None);
assert!(assemble_file(&p, &c).is_err());
}
#[test]
fn unmatched_marker_errors() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("README.md");
std::fs::write(&p, "<!-- nornir:gen:start:bench -->\nno end\n").unwrap();
let c = ctx(d.path(), None);
assert!(assemble_file(&p, &c).is_err());
}
}