use std::collections::BTreeSet;
use std::fmt::Write as _;
use crate::warehouse::dep_graph::DepGraphSnapshot;
use super::model::{BenchHistory, Lane, Timeline};
fn sanitize(s: &str) -> String {
s.replace([':', '-', '.', '/', ' '], "_")
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(7)]
}
fn cell(s: &str) -> String {
s.replace('|', "\\|")
}
fn node_version(versions: &[(String, String)]) -> String {
versions
.iter()
.map(|(c, v)| if c.is_empty() { format!("v{v}") } else { format!("{c} v{v}") })
.collect::<Vec<_>>()
.join(", ")
}
pub fn timeline_mermaid(tl: &Timeline) -> String {
if !tl.has_releases() {
return "_no releases recorded yet_\n".to_string();
}
let mut s = String::from("```mermaid\ntimeline\n");
let _ = writeln!(s, " title {} release timeline", tl.workspace_name);
for lane in &tl.lanes {
if lane.nodes.is_empty() {
continue;
}
let _ = writeln!(s, " section {}", lane.repo);
for n in &lane.nodes {
let date = n.timestamp.format("%Y-%m-%d");
let mut label = format!("{} {}", short_sha(&n.sha), n.gate_status);
let ver = node_version(&n.published_versions);
if !ver.is_empty() {
label.push_str(" — ");
label.push_str(&ver);
}
let _ = writeln!(s, " {} : {}", date, label.replace(':', "-"));
}
}
s.push_str("```\n");
s
}
pub fn lane_summary_table(tl: &Timeline) -> String {
if tl.lanes.is_empty() {
return "_no repos in this workspace_\n".to_string();
}
let mut s = String::from("| Repo | Releases | Latest sha | Gate | Version |\n");
s.push_str("|------|---------:|-----------|------|---------|\n");
for lane in &tl.lanes {
let (sha, gate, ver) = match lane.nodes.last() {
Some(n) => (
short_sha(&n.sha).to_string(),
n.gate_status.clone(),
node_version(&n.published_versions),
),
None => ("—".into(), "—".into(), String::new()),
};
let ver = if ver.is_empty() { "—".to_string() } else { ver };
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
cell(&lane.repo),
lane.nodes.len(),
cell(&sha),
cell(&gate),
cell(&ver),
);
}
s
}
fn latest_snapshot(tl: &Timeline) -> Option<&DepGraphSnapshot> {
tl.latest_snapshot
.as_ref()
.or_else(|| tl.snapshots.values().max_by_key(|s| s.timestamp))
}
pub fn depgraph_mermaid(tl: &Timeline) -> String {
let Some(snap) = latest_snapshot(tl) else {
return "_no dependency-graph snapshot captured yet_\n".to_string();
};
let mut nodes: BTreeSet<&str> = BTreeSet::new();
for e in &snap.edges {
nodes.insert(e.from.as_str());
nodes.insert(e.to.as_str());
}
let mut s = String::from("```mermaid\ngraph LR\n");
for n in &nodes {
let _ = writeln!(s, " {}[\"{}\"]", sanitize(n), n);
}
for e in &snap.edges {
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
if via.is_empty() {
let _ = writeln!(s, " {} -.-> {}", sanitize(&e.from), sanitize(&e.to));
} else {
let _ = writeln!(
s,
" {} -.->|{}| {}",
sanitize(&e.from),
via.join(", "),
sanitize(&e.to),
);
}
}
s.push_str("```\n");
s
}
pub fn snapshot_edge_list(tl: &Timeline) -> String {
let Some(snap) = latest_snapshot(tl) else {
return "_no dependency-graph snapshot captured yet_\n".to_string();
};
if snap.edges.is_empty() {
return "_no cross-repo dependency edges in the latest snapshot_\n".to_string();
}
let mut lines: Vec<String> = snap
.edges
.iter()
.map(|e| {
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
if via.is_empty() {
format!("- `{}` → `{}`", e.from, e.to)
} else {
format!("- `{}` → `{}` (via {})", e.from, e.to, via.join(", "))
}
})
.collect();
lines.sort();
let mut s = lines.join("\n");
s.push('\n');
s
}
pub fn gate_matrix_table(tl: &Timeline) -> String {
if !tl.has_releases() {
return "_no releases recorded yet_\n".to_string();
}
let repos: Vec<&Lane> = tl.lanes.iter().filter(|l| !l.nodes.is_empty()).collect();
if repos.is_empty() {
return "_no gated releases recorded yet_\n".to_string();
}
let mut header = String::from("| Release |");
let mut divider = String::from("|---------|");
for l in &repos {
let _ = write!(header, " {} |", cell(&l.repo));
divider.push_str(":--:|");
}
let mut s = format!("{header}\n{divider}\n");
for rid in &tl.release_order {
let label = repos
.iter()
.flat_map(|l| l.nodes.iter())
.find(|n| &n.release_id == rid)
.map(|n| n.timestamp.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| rid.to_string());
let mut row = format!("| {} |", cell(&label));
for l in &repos {
let mark = l
.nodes
.iter()
.find(|n| &n.release_id == rid)
.map(|n| gate_mark(&n.gate_status))
.unwrap_or("·".to_string());
let _ = write!(row, " {mark} |");
}
let _ = writeln!(s, "{row}");
}
s
}
fn gate_mark(status: &str) -> String {
if status.starts_with("succeeded") {
"✓".to_string()
} else if status.starts_with("failed") {
"✗".to_string()
} else {
cell(status)
}
}
pub fn release_versions_table(tl: &Timeline) -> String {
let mut rows: Vec<(String, String, String)> = Vec::new(); for lane in &tl.lanes {
for n in &lane.nodes {
if n.published_versions.is_empty() {
continue;
}
rows.push((
n.timestamp.format("%Y-%m-%d").to_string(),
lane.repo.clone(),
node_version(&n.published_versions),
));
}
}
if rows.is_empty() {
return "_no published versions recorded yet_\n".to_string();
}
rows.sort();
let mut s = String::from("| Date | Repo | Published |\n");
s.push_str("|------|------|-----------|\n");
for (date, repo, ver) in rows {
let _ = writeln!(s, "| {} | {} | {} |", date, cell(&repo), cell(&ver));
}
s
}
pub fn bench_history_table(tl: &Timeline, limit: Option<usize>) -> String {
let mut histories: Vec<&BenchHistory> =
tl.bench_history.values().filter(|h| !h.points.is_empty()).collect();
histories.sort_by(|a, b| a.repo.cmp(&b.repo));
if histories.is_empty() {
return "_no benchmark history recorded yet_\n".to_string();
}
let mut s = String::from("| Repo | Date | Version | Metric | Value | Machine |\n");
s.push_str("|------|------|---------|--------|------:|---------|\n");
for h in histories {
let mut pts: Vec<_> = h.points.iter().collect();
pts.sort_by_key(|p| std::cmp::Reverse(p.timestamp)); if let Some(n) = limit {
pts.truncate(n);
}
for p in pts {
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} | {} |",
cell(&h.repo),
p.timestamp.format("%Y-%m-%d"),
cell(&p.version),
cell(&p.primary_metric_name),
fmt_num(p.primary_metric_value),
cell(&p.machine),
);
}
}
s
}
pub fn bench_compare_table(tl: &Timeline) -> String {
let mut rows: Vec<(String, String, f64, String, String)> = Vec::new();
for (repo, h) in &tl.bench_history {
if let Some(p) = h.points.iter().max_by_key(|p| p.timestamp) {
rows.push((
repo.clone(),
p.primary_metric_name.clone(),
p.primary_metric_value,
p.version.clone(),
p.timestamp.format("%Y-%m-%d").to_string(),
));
}
}
if rows.is_empty() {
return "_no benchmark history recorded yet_\n".to_string();
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
let mut s = String::from("| Repo | Latest metric | Value | Version | Date |\n");
s.push_str("|------|---------------|------:|---------|------|\n");
for (repo, metric, val, ver, date) in rows {
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
cell(&repo),
cell(&metric),
fmt_num(val),
cell(&ver),
date,
);
}
s
}
fn fmt_num(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
let i = v as i64;
let s = i.abs().to_string();
let mut out = String::new();
for (idx, ch) in s.chars().enumerate() {
if idx > 0 && (s.len() - idx).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
if i < 0 { format!("-{out}") } else { out }
} else if v.abs() >= 1000.0 {
format!("{v:.0}")
} else {
format!("{v:.3}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warehouse::dep_graph::CrossRepoEdge;
use chrono::{TimeZone, Utc};
use std::collections::BTreeMap;
use uuid::Uuid;
fn sample_timeline() -> Timeline {
let sid = Uuid::from_u128(1);
let rid = Uuid::from_u128(2);
let ts = Utc.with_ymd_and_hms(2026, 6, 10, 12, 0, 0).unwrap();
let snap = DepGraphSnapshot {
snapshot_id: sid,
workspace_name: "skade".into(),
timestamp: ts,
edges: vec![CrossRepoEdge {
from: "skade".into(),
to: "skade-katalog".into(),
via: BTreeSet::from(["skade-katalog".to_string()]),
}],
};
let mut snapshots = BTreeMap::new();
snapshots.insert(sid, snap.clone());
let mut release_snapshot = BTreeMap::new();
release_snapshot.insert(rid, sid);
let node = super::super::model::LaneNode {
release_id: rid,
timestamp: ts,
sha: "deadbeefcafef00d".into(),
branch: "main".into(),
dirty: false,
gate_status: "succeeded".into(),
tests_passed: 9,
tests_failed: 0,
published_versions: vec![("skade".into(), "0.1.4".into())],
};
let mut bench_history = BTreeMap::new();
bench_history.insert(
"skade".to_string(),
BenchHistory {
repo: "skade".into(),
points: vec![super::super::model::BenchPoint {
timestamp: ts,
primary_metric_name: "skade_table_exists_ops_sec".into(),
primary_metric_value: 2_000_000.0,
metrics: vec![("skade_table_exists.ops_sec".into(), 2_000_000.0)],
version: "0.1.4".into(),
machine: "oden".into(),
}],
},
);
Timeline {
workspace_name: "skade".into(),
lanes: vec![Lane { repo: "skade".into(), nodes: vec![node] }],
release_order: vec![rid],
release_snapshot,
snapshots,
latest_snapshot: Some(snap),
bench_history,
}
}
#[test]
fn timeline_mermaid_has_section_and_event() {
let tl = sample_timeline();
let out = timeline_mermaid(&tl);
assert!(out.starts_with("```mermaid\ntimeline\n"), "{out}");
assert!(out.contains("section skade"), "{out}");
assert!(out.contains("deadbee"), "short sha: {out}");
assert!(out.contains("skade v0.1.4"), "version: {out}");
assert!(out.trim_end().ends_with("```"));
}
#[test]
fn depgraph_mermaid_labels_edge_via() {
let out = depgraph_mermaid(&sample_timeline());
assert!(out.contains("graph LR"), "{out}");
assert!(out.contains("skade_katalog"), "{out}");
assert!(out.contains("-.->|skade-katalog|"), "edge label: {out}");
}
#[test]
fn snapshot_edge_list_is_sorted_markdown() {
let out = snapshot_edge_list(&sample_timeline());
assert!(out.contains("- `skade` → `skade-katalog` (via skade-katalog)"), "{out}");
}
#[test]
fn gate_matrix_marks_success() {
let out = gate_matrix_table(&sample_timeline());
assert!(out.contains("| Release |"), "{out}");
assert!(out.contains("skade"), "{out}");
assert!(out.contains('✓'), "succeeded → check: {out}");
}
#[test]
fn release_versions_lists_published() {
let out = release_versions_table(&sample_timeline());
assert!(out.contains("skade v0.1.4"), "{out}");
assert!(out.contains("2026-06-10"), "{out}");
}
#[test]
fn bench_history_table_newest_first_with_thousands() {
let out = bench_history_table(&sample_timeline(), Some(20));
assert!(out.contains("skade_table_exists_ops_sec"), "{out}");
assert!(out.contains("2,000,000"), "thousands sep: {out}");
}
#[test]
fn bench_compare_table_one_row_per_repo() {
let out = bench_compare_table(&sample_timeline());
assert!(out.contains("| skade |"), "{out}");
assert!(out.contains("2,000,000"), "{out}");
}
#[test]
fn lane_summary_shows_latest() {
let out = lane_summary_table(&sample_timeline());
assert!(out.contains("| skade |"), "{out}");
assert!(out.contains("deadbee"), "{out}");
assert!(out.contains("succeeded"), "{out}");
}
#[test]
fn empty_timeline_placeholders() {
let tl = Timeline {
workspace_name: "empty".into(),
lanes: vec![],
release_order: vec![],
release_snapshot: BTreeMap::new(),
snapshots: BTreeMap::new(),
latest_snapshot: None,
bench_history: BTreeMap::new(),
};
assert!(timeline_mermaid(&tl).contains("no releases"));
assert!(depgraph_mermaid(&tl).contains("no dependency-graph snapshot"));
assert!(bench_history_table(&tl, None).contains("no benchmark history"));
assert!(gate_matrix_table(&tl).contains("no releases"));
}
}