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 {
pub workspace: String,
pub limit: Option<usize>,
pub timeline_svg: Option<String>,
pub format: DocFormat,
}
impl Default for BookOptions {
fn default() -> Self {
Self {
workspace: String::new(),
limit: None,
timeline_svg: None,
format: DocFormat::Pdf,
}
}
}
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(),
};
let cache = out_path.parent().map(|p| p.join(".nornir-img-cache"));
let bytes = export::export(&md, &meta, opts.format, cache.as_deref())?;
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!(
"\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");
}
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))
}
pub type ReleaseId = Uuid;