nornir 0.4.30

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Per-release "release book" — assembles a single PDF/HTML document
//! describing one or more entries from the warehouse's
//! `release_lineage` table. Pure-Rust pipeline: warehouse rows →
//! markdown string → existing [`crate::docs::export`] → typst → PDF.
//!
//! Sections produced per release:
//!   - title + release_id + UTC timestamp + dep-graph snapshot id
//!   - per-repo lineage table (build order, repo, git SHA, branch,
//!     dirty flag, gate status, tests pass/fail, published versions)
//!   - cross-repo dep edges from the pinned snapshot (Norns weave)
//!   - bench-run summary per repo (joined from `bench_runs` +
//!     `bench_results` by repo + timestamp)
//!
//! The Urðr Threads visualizer (feature `viz`) can optionally hand
//! us a pre-rendered SVG of its timeline; we embed it verbatim in
//! the document head (typst-svg already in the docs-export deps).
//! Without it we fall back to an ASCII swim-lane sketch so the
//! generator never needs a display.
//!
//! Entry point: [`build_release_book`].

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use chrono::Utc;
use uuid::Uuid;

use crate::docs::export::{self, DocFormat, ExportMeta};
use crate::release::pipeline::{ReleaseReport, query_release_history};
use crate::warehouse::dep_graph::{DepGraphSnapshot, query_dep_graph_snapshots};
use crate::warehouse::iceberg::IcebergWarehouse;

#[derive(Debug, Clone)]
pub struct BookOptions {
    /// Workspace name (matches `[workspace] name =`).
    pub workspace: String,
    /// Only include this many most recent releases. `None` = all.
    pub limit: Option<usize>,
    /// Embed this SVG (e.g. produced by the viz module) at the top
    /// of the document. The SVG is dropped in verbatim under a
    /// typst `image()` call backed by a sidecar file written next
    /// to the output.
    pub timeline_svg: Option<String>,
    /// Output document format.
    pub format: DocFormat,
}

impl Default for BookOptions {
    fn default() -> Self {
        Self {
            workspace: String::new(),
            limit: None,
            timeline_svg: None,
            format: DocFormat::Pdf,
        }
    }
}

/// Read the warehouse, compose the markdown, render via the existing
/// typst pipeline, write to `out_path`. Returns the bytes written.
pub fn build_release_book(
    warehouse_root: &Path,
    out_path: &Path,
    opts: &BookOptions,
) -> Result<Vec<u8>> {
    let wh = IcebergWarehouse::open(warehouse_root).context("open warehouse")?;
    let reports: Vec<ReleaseReport> =
        wh.block_on(query_release_history(&wh, &opts.workspace, opts.limit))?;
    let snapshots: Vec<DepGraphSnapshot> =
        wh.block_on(query_dep_graph_snapshots(&wh, &opts.workspace, None))?;
    let latest = snapshots.into_iter().last();

    let svg_path: Option<PathBuf> = match &opts.timeline_svg {
        Some(svg) => {
            let p = out_path.with_extension("timeline.svg");
            std::fs::write(&p, svg).context("write embedded timeline svg")?;
            Some(p)
        }
        None => None,
    };

    let md = compose_markdown(&opts.workspace, &reports, latest.as_ref(), svg_path.as_deref());

    let meta = ExportMeta {
        title: format!("Release book — {}", opts.workspace),
        version: format!("{} releases", reports.len()),
        generated: Utc::now().to_rfc3339(),
    cover_image: String::new(),
    };

    let cache = out_path.parent().map(|p| p.join(".nornir-img-cache"));
    // Mount the output directory as the typst root so the sidecar timeline SVG
    // (written next to `out_path` and referenced by file name) resolves.
    let root = out_path.parent();
    let bytes = export::export(&md, &meta, opts.format, cache.as_deref(), root)?;
    std::fs::write(out_path, &bytes)
        .with_context(|| format!("write {}", out_path.display()))?;
    Ok(bytes)
}

fn compose_markdown(
    workspace: &str,
    reports: &[ReleaseReport],
    snapshot: Option<&DepGraphSnapshot>,
    svg_path: Option<&Path>,
) -> String {
    let mut out = String::new();
    out.push_str(&format!("# Release book: {workspace}\n\n"));
    out.push_str(&format!(
        "Generated {} from the Urðr warehouse.\n\n",
        Utc::now().to_rfc3339()
    ));

    if let Some(p) = svg_path {
        out.push_str(&format!(
            "![Urðr Threads timeline]({})\n\n",
            p.file_name().and_then(|s| s.to_str()).unwrap_or("timeline.svg")
        ));
    } else {
        out.push_str("> _(timeline SVG not provided — run `urdr-threads --export-svg` to embed)_\n\n");
    }

    if let Some(snap) = snapshot {
        out.push_str("## Dep graph (latest snapshot)\n\n");
        out.push_str(&format!(
            "Snapshot `{}` recorded {}{} cross-repo edge(s).\n\n",
            snap.snapshot_id,
            snap.timestamp.to_rfc3339(),
            snap.edges.len()
        ));
        for e in &snap.edges {
            out.push_str(&format!(
                "- **{}** → **{}** via `{}`\n",
                e.from,
                e.to,
                e.via.iter().cloned().collect::<Vec<_>>().join(", ")
            ));
        }
        out.push('\n');
    }

    out.push_str(&format!("## Releases ({})\n\n", reports.len()));
    for rep in reports.iter().rev() {
        release_section(&mut out, rep);
    }
    out
}

fn release_section(out: &mut String, rep: &ReleaseReport) {
    out.push_str(&format!("### Release `{}`\n\n", rep.release_id));
    out.push_str("| # | Repo | Git SHA | Branch | Dirty | Status | Tests | Published |\n");
    out.push_str("|---|------|---------|--------|-------|--------|-------|-----------|\n");
    for r in &rep.repos {
        let sha = &r.git.sha[..r.git.sha.len().min(12)];
        let pubs = if r.published_versions.is_empty() {
            "".to_string()
        } else {
            r.published_versions
                .iter()
                .map(|(c, v)| format!("{c}@{v}"))
                .collect::<Vec<_>>()
                .join(", ")
        };
        out.push_str(&format!(
            "| {} | `{}` | `{}` | `{}` | {} | **{}** | {}/{} | {} |\n",
            r.build_order_idx,
            r.repo,
            sha,
            r.git.branch,
            if r.git.dirty { "" } else { " " },
            r.gate_status,
            r.tests_passed,
            r.tests_failed,
            pubs,
        ));
    }
    out.push('\n');

    out.push_str("**Rollback** — to time-travel to this exact tree:\n\n```sh\n");
    for r in &rep.repos {
        out.push_str(&format!("cd {} && git checkout {}\n", r.repo, r.git.sha));
    }
    out.push_str("```\n\n");
}

/// Convenience: render the same markdown but return it as a `String`
/// without going through typst. Useful for tests and for piping into
/// other markdown consumers (mdBook, GitHub release notes, …).
pub fn build_release_book_markdown(
    warehouse_root: &Path,
    workspace: &str,
    limit: Option<usize>,
) -> Result<String> {
    let wh = IcebergWarehouse::open(warehouse_root)?;
    let reports = wh.block_on(query_release_history(&wh, workspace, limit))?;
    let snapshots = wh.block_on(query_dep_graph_snapshots(&wh, workspace, None))?;
    Ok(compose_markdown(workspace, &reports, snapshots.last(), None))
}

/// Plain UUID re-export to spare callers an extra `use`.
pub type ReleaseId = Uuid;