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>,
pub history: &'a [BenchRun],
}
impl<'a> Ctx<'a> {
pub fn new(
repo_root: &'a Path,
workspace_root: &'a Path,
run: Option<&'a BenchRun>,
) -> Self {
Ctx { repo_root, workspace_root, run, history: &[] }
}
pub fn with_history(mut self, history: &'a [BenchRun]) -> Self {
self.history = history;
self
}
}
#[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)?,
"bench_history" => render_bench_history(ctx, &m.args)?,
"benches" => render_benches(ctx, &m.args)?,
"tests" => render_tests(ctx, &m.args)?,
"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 bench_header(run: &BenchRun) -> String {
let mut parts = vec![format!("v{}", run.version)];
if !run.machine.is_empty() {
parts.push(run.machine.clone());
}
parts.push(format!("{} cores", run.cores));
if !run.date.is_empty() {
parts.push(run.date.clone());
}
format!("**{}**\n\n", parts.join(" · "))
}
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(&bench_header(run));
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_bench_history(ctx: &Ctx, args: &HashMap<String, String>) -> Result<String> {
if ctx.history.is_empty() {
return Ok(
"_(no bench history yet — run benchmarks to populate this section)_\n".to_string()
);
}
let limit: usize = args.get("limit").and_then(|s| s.parse().ok()).unwrap_or(20);
let metric = args.get("metric").map(|s| s.as_str());
let mut runs: Vec<&BenchRun> = ctx.history.iter().collect();
runs.sort_by(|a, b| {
let ka = a.timestamp.as_deref().unwrap_or(&a.date);
let kb = b.timestamp.as_deref().unwrap_or(&b.date);
kb.cmp(ka)
});
let metric_label = metric.unwrap_or("best metric");
let mut out = String::new();
out.push_str(&format!(
"| Version | Date | Machine | {metric_label} |\n|---|---|---|---|\n"
));
for run in runs.into_iter().take(limit) {
let date = if run.date.is_empty() { "-" } else { run.date.as_str() };
let machine = if run.machine.is_empty() { "-" } else { run.machine.as_str() };
let val = best_metric(run, metric)
.map(|v| format!("{v:.2}"))
.unwrap_or_else(|| "-".to_string());
out.push_str(&format!("| v{} | {} | {} | {} |\n", run.version, date, machine, val));
}
Ok(out)
}
fn best_metric(run: &BenchRun, metric: Option<&str>) -> Option<f64> {
let mut best: Option<f64> = None;
for r in &run.results {
for (k, v) in &r.metrics {
let Some(f) = v.as_f64() else { continue };
let take = match metric {
Some(name) => k == name || r.name == name,
None => true,
};
if take {
best = Some(best.map_or(f, |b| b.max(f)));
}
}
}
best
}
fn render_benches(ctx: &Ctx, args: &HashMap<String, String>) -> Result<String> {
use crate::bench::{direction_of, unit_of, MetricDirection};
let Some(run) = ctx.run else {
return Ok("_(no bench history yet — run a benchmark to populate this section)_\n".to_string());
};
let overrides = parse_best_overrides(args.get("best").map(|s| s.as_str()));
let bold_enabled = args.get("bold").map(|v| v != "false").unwrap_or(true);
let filter = args.get("name").map(|s| s.as_str());
let exclude = args.get("exclude").map(|s| s.as_str());
let results: Vec<&crate::bench::BenchResult> = run
.results
.iter()
.filter(|r| filter.is_none_or(|p| r.name.contains(p)))
.filter(|r| exclude.is_none_or(|p| !r.name.contains(p)))
.collect();
if results.is_empty() {
return Ok("_(no bench results)_\n".to_string());
}
let mut columns: Vec<String> = Vec::new();
for r in &results {
for k in r.metrics.keys() {
if r.metrics[k].as_f64().is_some() && !columns.contains(k) {
columns.push(k.clone());
}
}
}
columns.sort();
let mut out = String::new();
out.push_str(&bench_header(run));
out.push_str("| workload |");
for c in &columns {
out.push_str(&format!(" {c} |"));
}
out.push('\n');
out.push_str("|---|");
for _ in &columns {
out.push_str("---|");
}
out.push('\n');
for r in &results {
let mut groups: HashMap<&'static str, Vec<(&String, f64)>> = HashMap::new();
for c in &columns {
if let Some(f) = r.metrics.get(c).and_then(|v| v.as_f64()) {
if let Some(u) = unit_of(c) {
groups.entry(u.name).or_default().push((c, f));
}
}
}
let mut bold: std::collections::HashSet<&String> = std::collections::HashSet::new();
for cells in groups.values() {
if !bold_enabled || cells.len() < 2 {
continue;
}
let dir = direction_of(&overrides, cells[0].0);
let best = match dir {
MetricDirection::High => cells.iter().max_by(|a, b| a.1.total_cmp(&b.1)),
MetricDirection::Low => cells.iter().min_by(|a, b| a.1.total_cmp(&b.1)),
MetricDirection::Neutral => None,
};
if let Some((k, _)) = best {
bold.insert(k);
}
}
out.push_str(&format!("| {} |", r.name));
for c in &columns {
match r.metrics.get(c).and_then(|v| v.as_f64()) {
Some(f) => {
let cell = fmt_metric(f);
if bold.contains(c) {
out.push_str(&format!(" **{cell}** |"));
} else {
out.push_str(&format!(" {cell} |"));
}
}
None => out.push_str(" |"),
}
}
out.push('\n');
}
Ok(out)
}
fn render_tests(ctx: &Ctx, args: &HashMap<String, String>) -> Result<String> {
let include_benches = args.get("include_benches").map(|v| v == "true").unwrap_or(false);
let inv = crate::docs::test_inventory::scan_opts(
ctx.repo_root,
args.get("crate").map(|s| s.as_str()),
include_benches,
);
let only = args.get("kind").map(|s| s.as_str());
let mut out = String::new();
let want = |k: &str| only.is_none_or(|o| o == k);
if want("unit") {
out.push_str(&format!("**Unit tests** ({})\n\n", inv.unit_total()));
out.push_str(&file_tests_table(&inv.unit));
out.push('\n');
}
if want("integration") {
out.push_str(&format!("**Integration tests** ({})\n\n", inv.integration_total()));
out.push_str(&file_tests_table(&inv.integration));
out.push('\n');
}
if want("doc") {
out.push_str(&format!("**Doc tests** ({})\n\n", inv.doc_total()));
if inv.doc.is_empty() {
out.push_str("_(none)_\n");
} else {
out.push_str("| file | doc-tests |\n|---|--:|\n");
for f in &inv.doc {
out.push_str(&format!("| `{}` | {} |\n", md_escape(&f.file), f.count));
}
}
}
Ok(out.trim_end().to_string() + "\n")
}
fn file_tests_table(files: &[crate::docs::test_inventory::FileTests]) -> String {
if files.is_empty() {
return "_(none)_\n".to_string();
}
let mut out = String::from("| file | tests |\n|---|---|\n");
for f in files {
let names = f
.tests
.iter()
.map(|t| format!("`{}`", md_escape(t)))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!("| `{}` | {} |\n", md_escape(&f.file), names));
}
out
}
fn parse_best_overrides(arg: Option<&str>) -> HashMap<String, crate::bench::MetricDirection> {
use crate::bench::MetricDirection;
let mut out = HashMap::new();
let Some(s) = arg else { return out };
for pair in s.split(',') {
if let Some((metric, dir)) = pair.split_once(':') {
let d = match dir.trim().to_ascii_lowercase().as_str() {
"high" | "max" | "up" => MetricDirection::High,
"low" | "min" | "down" => MetricDirection::Low,
"neutral" | "none" => MetricDirection::Neutral,
_ => continue,
};
out.insert(metric.trim().to_string(), d);
}
}
out
}
fn fmt_metric(f: f64) -> String {
if f.fract() == 0.0 && f.abs() < 1e15 {
format!("{}", f as i64)
} else {
format!("{f:.2}")
}
}
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/assets/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::new(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 bench_history_renders_rows_newest_first() {
let mk = |date: &str, ver: &str, ops: f64| -> BenchRun {
let mut m: Map<String, Value> = Map::new();
m.insert("ops".into(), Value::from(ops));
BenchRun {
date: date.into(),
timestamp: None,
version: ver.into(),
machine: "tr".into(),
cores: 8,
results: vec![crate::bench::BenchResult { name: "x".into(), metrics: m }],
tests: vec![],
}
};
let history = vec![mk("2026-05-01", "0.1.0", 100.0), mk("2026-06-01", "0.2.0", 200.0)];
let root = Path::new(".");
let c = Ctx::new(root, root, None).with_history(&history);
let text =
"<!-- nornir:gen:start:bench_history -->\n<!-- nornir:gen:end:bench_history -->\n";
let (out, sections) = rewrite_str(text, &c).unwrap();
assert_eq!(sections, vec!["bench_history".to_string()]);
assert!(out.contains("| Version | Date | Machine |"), "header: {out}");
let i2 = out.find("v0.2.0").expect("v0.2.0 present");
let i1 = out.find("v0.1.0").expect("v0.1.0 present");
assert!(i2 < i1, "newest (0.2.0) must come first:\n{out}");
assert!(out.contains("200.00") && out.contains("100.00"), "best-metric values: {out}");
}
#[test]
fn bench_history_empty_is_graceful() {
let root = Path::new(".");
let c = Ctx::new(root, root, None); let text =
"<!-- nornir:gen:start:bench_history -->\n<!-- nornir:gen:end:bench_history -->\n";
let (out, _) = rewrite_str(text, &c).unwrap();
assert!(out.contains("no bench history"), "graceful empty: {out}");
}
fn vs_run() -> BenchRun {
let mut m: Map<String, Value> = Map::new();
m.insert("ljar_mbs".into(), Value::from(2527.0f64));
m.insert("unzip_mbs".into(), Value::from(1404.0f64));
m.insert("speedup_x".into(), Value::from(1.8f64));
BenchRun {
date: "2026-06-05".into(),
timestamp: None,
version: "0.9.0".into(),
machine: "test".into(),
cores: 32,
results: vec![crate::bench::BenchResult { name: "jar_2000".into(), metrics: m }],
tests: vec![],
}
}
#[test]
fn benches_pivots_and_bolds_winner() {
let r = vs_run();
let c = ctx(Path::new("/tmp"), Some(&r));
let out = render_benches(&c, &HashMap::new()).unwrap();
assert!(out.contains("| workload |"), "got: {out}");
assert!(out.contains("ljar_mbs"));
assert!(out.contains("jar_2000"));
assert!(out.contains("**2527**"), "winner not bolded: {out}");
assert!(!out.contains("**1404**"), "loser bolded: {out}");
assert!(!out.contains("**1.8"), "neutral bolded: {out}");
}
#[test]
fn benches_exclude_and_bold_false() {
let mut a: Map<String, Value> = Map::new();
a.insert("ljar_mbs".into(), Value::from(1000.0f64));
a.insert("unzip_mbs".into(), Value::from(60.0f64));
let mut b: Map<String, Value> = Map::new();
b.insert("ljar_mbs".into(), Value::from(120.0f64));
b.insert("unzip_mbs".into(), Value::from(60.0f64));
let run = BenchRun {
date: "2026-06-06".into(), timestamp: None, version: "0.9.0".into(),
machine: "tr".into(), cores: 32,
results: vec![
crate::bench::BenchResult { name: "ljar_guava".into(), metrics: a },
crate::bench::BenchResult { name: "ljar_guava_st".into(), metrics: b },
],
tests: vec![],
};
let c = ctx(Path::new("/tmp"), Some(&run));
let mut args = HashMap::new();
args.insert("exclude".to_string(), "_st".to_string());
args.insert("bold".to_string(), "false".to_string());
let out = render_benches(&c, &args).unwrap();
assert!(out.contains("ljar_guava"), "got: {out}");
assert!(!out.contains("ljar_guava_st"), "exclude failed: {out}");
assert!(!out.contains("**1000**"), "bold=false should suppress cell bolding: {out}");
assert!(out.contains("tr · 32 cores"), "header missing machine: {out}");
}
#[test]
fn benches_best_override_flips_direction() {
let r = vs_run();
let c = ctx(Path::new("/tmp"), Some(&r));
let mut args = HashMap::new();
args.insert("best".to_string(), "ljar_mbs:low,unzip_mbs:low".to_string());
let out = render_benches(&c, &args).unwrap();
assert!(out.contains("**1404**"), "override not applied: {out}");
assert!(!out.contains("**2527**"), "override ignored: {out}");
}
#[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());
}
}