use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{bail, Result};
use uuid::Uuid;
use crate::cli::DocsCommand;
use crate::config::Config;
use crate::docs::{extract_verifiable, run_block, CodeBlock, VerifyOutcome};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::NodeKind;
use crate::store::Store;
pub fn run(project: &Path, cmd: DocsCommand) -> Result<()> {
match cmd {
DocsCommand::Verify { book_name, paragraph, dry_run, yes } => {
verify(project, book_name.as_deref(), paragraph.as_deref(), dry_run, yes)
}
}
}
fn open(project: &Path) -> Result<(Config, Store, Hierarchy)> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout, &cfg)?;
let hierarchy = Hierarchy::load(&store)?;
Ok((cfg, store, hierarchy))
}
struct Target {
breadcrumb: String,
blocks: Vec<CodeBlock>,
}
fn verify(
project: &Path,
book_name: Option<&str>,
paragraph: Option<&str>,
dry_run: bool,
yes: bool,
) -> Result<()> {
let (cfg, store, h) = open(project)?;
let vcfg = &cfg.docs.verify;
let mut targets: Vec<Target> = Vec::new();
for book in h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
{
if let Some(name) = book_name {
if !book.title.eq_ignore_ascii_case(name.trim()) {
continue;
}
}
for id in h.collect_subtree(book.id) {
let Some(node) = h.get(id) else { continue };
if node.kind != NodeKind::Paragraph {
continue;
}
if !node.tags.iter().any(|t| t == "para:code") {
continue;
}
let breadcrumb = h.slug_path(node);
if let Some(p) = paragraph {
if breadcrumb != p {
continue;
}
}
let body = read_body(&store, id);
let blocks: Vec<CodeBlock> =
extract_verifiable(&body).into_iter().filter(|b| b.verify).collect();
if !blocks.is_empty() {
targets.push(Target { breadcrumb, blocks });
}
}
}
let total_blocks: usize = targets.iter().map(|t| t.blocks.len()).sum();
if total_blocks == 0 {
println!("docs verify: no `verify`-marked code blocks found.");
println!(" Mark a `para:code` listing's fence, e.g. ```rust verify```, and configure a runner.");
return Ok(());
}
if dry_run {
println!("docs verify --dry-run: {total_blocks} block(s) would run:\n");
for t in &targets {
for b in &t.blocks {
let cmd = vcfg
.runners
.get(&b.lang)
.map(|c| c.replace("{file}", "<code>.tmp"))
.unwrap_or_else(|| format!("(no runner for `{}` — would skip)", b.lang));
println!(" {} [{}] → {cmd}", t.breadcrumb, b.lang);
}
}
return Ok(());
}
if !vcfg.enabled {
bail!(
"docs.verify is off. Enable it in inkhaven.hjson (`docs: {{ verify: {{ enabled: true }} }}`) \
and configure runners, then re-run. Use `--dry-run` to preview without executing."
);
}
if !yes {
let langs: BTreeSet<&str> = targets.iter().flat_map(|t| &t.blocks).map(|b| b.lang.as_str()).collect();
println!(
"docs verify: {total_blocks} block(s) marked `verify` in language(s): {}.",
langs.into_iter().collect::<Vec<_>>().join(", ")
);
println!("These runners will execute code with your privileges:");
for (lang, cmd) in &vcfg.runners {
println!(" {lang}: {cmd}");
}
bail!("re-run with --yes to execute, or --dry-run to preview.");
}
let (mut passed, mut failed, mut skipped) = (0usize, 0usize, 0usize);
for t in &targets {
for b in &t.blocks {
match run_block(b, vcfg) {
VerifyOutcome::Pass => {
passed += 1;
println!(" ✓ {} [{}]", t.breadcrumb, b.lang);
}
VerifyOutcome::Fail { detail } => {
failed += 1;
println!(" ✗ {} [{}]", t.breadcrumb, b.lang);
for line in detail.lines() {
println!(" {line}");
}
}
VerifyOutcome::Skipped { reason } => {
skipped += 1;
println!(" – {} [{}] — {reason}", t.breadcrumb, b.lang);
}
VerifyOutcome::Errored { reason } => {
failed += 1;
println!(" ! {} [{}] — {reason}", t.breadcrumb, b.lang);
}
}
}
}
println!("\ndocs verify: {passed} passed · {failed} failed · {skipped} skipped");
if failed > 0 {
bail!("{failed} code block(s) failed verification");
}
Ok(())
}
fn read_body(store: &Store, id: Uuid) -> String {
store
.get_content(id)
.ok()
.flatten()
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default()
}