use std::collections::HashSet;
use std::fmt::Write;
use anyhow::{Context, Result};
use schemars::schema_for;
use crate::model::{BeforeAfterDiagram, Diagram, Document, Edge, Section, TimelineEvent, TreeNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Terminal,
Markdown,
Html,
}
pub fn schema_json() -> Result<String> {
let schema = schema_for!(Document);
serde_json::to_string_pretty(&schema).context("failed to serialize JSON schema")
}
pub fn render_document(document: &Document, format: OutputFormat) -> String {
match format {
OutputFormat::Terminal => render_terminal(document),
OutputFormat::Markdown => render_markdown(document),
OutputFormat::Html => render_html(document),
}
}
fn render_terminal(document: &Document) -> String {
let mut output = String::new();
writeln!(&mut output, "{}", document.title).unwrap();
writeln!(
&mut output,
"{}",
"=".repeat(document.title.chars().count())
)
.unwrap();
writeln!(&mut output).unwrap();
for paragraph in &document.summary {
writeln!(&mut output, "{paragraph}").unwrap();
writeln!(&mut output).unwrap();
}
for section in &document.sections {
writeln!(
&mut output,
"{}\n{}",
section.title,
"-".repeat(section.title.len())
)
.unwrap();
writeln!(&mut output).unwrap();
for paragraph in §ion.text {
writeln!(&mut output, "{paragraph}").unwrap();
writeln!(&mut output).unwrap();
}
if let Some(diagram) = §ion.diagram {
writeln!(&mut output, "{}", render_ascii_diagram(diagram)).unwrap();
writeln!(&mut output).unwrap();
}
}
if let Some(verification) = &document.verification {
writeln!(&mut output, "Verification\n------------").unwrap();
writeln!(&mut output).unwrap();
for paragraph in &verification.text {
writeln!(&mut output, "{paragraph}").unwrap();
writeln!(&mut output).unwrap();
}
}
output
}
fn render_markdown(document: &Document) -> String {
let mut output = String::new();
writeln!(&mut output, "# {}", document.title).unwrap();
writeln!(&mut output).unwrap();
for paragraph in &document.summary {
writeln!(&mut output, "{paragraph}").unwrap();
writeln!(&mut output).unwrap();
}
for section in &document.sections {
writeln!(&mut output, "## {}", section.title).unwrap();
writeln!(&mut output).unwrap();
for paragraph in §ion.text {
writeln!(&mut output, "{paragraph}").unwrap();
writeln!(&mut output).unwrap();
}
if let Some(diagram) = §ion.diagram {
writeln!(&mut output, "```mermaid").unwrap();
writeln!(&mut output, "{}", render_mermaid_diagram(diagram)).unwrap();
writeln!(&mut output, "```").unwrap();
writeln!(&mut output).unwrap();
}
}
if let Some(verification) = &document.verification {
writeln!(&mut output, "## Verification").unwrap();
writeln!(&mut output).unwrap();
for paragraph in &verification.text {
writeln!(&mut output, "{paragraph}").unwrap();
writeln!(&mut output).unwrap();
}
}
output
}
fn render_html(document: &Document) -> String {
let summary_html = paragraphs_to_html(&document.summary);
let toc_html = render_toc(document);
let sections_html = document
.sections
.iter()
.enumerate()
.map(|(index, section)| render_section_html(index, section))
.collect::<Vec<_>>()
.join("\n");
let verification_html = document
.verification
.as_ref()
.map(render_verification_html)
.unwrap_or_default();
let verification_toc = if document.verification.is_some() {
"\n <a class=\"toc-link\" href=\"#verification\">Verification</a>"
} else {
""
};
format!(
"<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{title}</title>
<link rel=\"icon\" href=\"data:,\">
<style>{style}</style>
</head>
<body>
<nav class=\"sidebar\" data-sidebar>
<div class=\"sidebar-header\">
<p class=\"eyebrow\">Contents</p>
<button class=\"theme-toggle\" type=\"button\" data-theme-toggle aria-label=\"Toggle light/dark mode\">Toggle theme</button>
</div>
<a class=\"toc-link is-active\" href=\"#summary\">Summary</a>
{toc_html}{verification_toc}
<button class=\"sidebar-close\" type=\"button\" data-sidebar-close aria-label=\"Close sidebar\">Close</button>
</nav>
<button class=\"hamburger\" type=\"button\" data-sidebar-open aria-label=\"Open sidebar\">Menu</button>
<main class=\"content\">
<header class=\"hero\" id=\"summary\">
<p class=\"eyebrow\">Magellan walkthrough</p>
<h1>{title}</h1>
{summary_html}
<p class=\"section-count\">{section_count} sections</p>
</header>
{sections_html}
{verification_html}
</main>
<div class=\"lightbox\" data-lightbox hidden>
<button class=\"lightbox-close\" type=\"button\" data-lightbox-close aria-label=\"Close enlarged diagram\">×</button>
<div class=\"lightbox-body\" data-lightbox-body></div>
</div>
<script>{script}</script>
</body>
</html>",
title = escape_html(&document.title),
summary_html = summary_html,
toc_html = toc_html,
verification_toc = verification_toc,
section_count = document.sections.len(),
sections_html = sections_html,
verification_html = verification_html,
style = html_style(),
script = html_script()
)
}
fn render_toc(document: &Document) -> String {
document
.sections
.iter()
.enumerate()
.map(|(index, section)| {
format!(
" <a class=\"toc-link\" href=\"#section-{}\">{}</a>",
index + 1,
escape_html(§ion.title)
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_section_html(index: usize, section: &Section) -> String {
let diagram_html = section
.diagram
.as_ref()
.map(|diagram| render_diagram_html(index, diagram))
.unwrap_or_default();
format!(
" <section class=\"section\" id=\"section-{number}\">
<div class=\"section-head\">
<p class=\"eyebrow\">Step {number}</p>
<h2>{title}</h2>
</div>
<div class=\"section-body\">
{text_html}
{diagram_html}
</div>
</section>",
number = index + 1,
title = escape_html(§ion.title),
text_html = paragraphs_to_html(§ion.text),
diagram_html = diagram_html
)
}
fn render_verification_html(verification: &crate::model::Verification) -> String {
format!(
" <section class=\"section verification\" id=\"verification\">
<div class=\"section-head\">
<p class=\"eyebrow\">Verification</p>
<h2>Verification</h2>
</div>
<div class=\"section-body\">
{text_html}
</div>
</section>",
text_html = paragraphs_to_html(&verification.text)
)
}
fn html_style() -> &'static str {
r#"
:root {
color-scheme: dark;
--bg: #131211;
--surface: #1b1a18;
--ink: #d9d5d0;
--ink-soft: #b5b0a9;
--muted: #8a847d;
--accent: #a09890;
--accent-strong: #c0b8b0;
--border: rgba(255, 255, 255, 0.08);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--code-bg: #161514;
--diagram-node-fill: #1b1a18;
--diagram-stroke: rgba(160, 152, 144, 0.4);
--diagram-lane: rgba(255, 255, 255, 0.12);
--diagram-dot: rgba(160, 152, 144, 0.8);
--diagram-dot-ring: rgba(160, 152, 144, 0.15);
--diagram-text: #d9d5d0;
--diagram-text-muted: #a09890;
--sidebar-width: 240px;
}
[data-theme="light"] {
color-scheme: light;
--bg: #f5f3f0;
--surface: #ffffff;
--ink: #2a2725;
--ink-soft: #4a4541;
--muted: #7a736c;
--accent: #7a736c;
--accent-strong: #5a534c;
--border: rgba(0, 0, 0, 0.1);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--code-bg: #edebe8;
--diagram-node-fill: #ffffff;
--diagram-stroke: rgba(90, 83, 76, 0.4);
--diagram-lane: rgba(0, 0, 0, 0.1);
--diagram-dot: rgba(90, 83, 76, 0.8);
--diagram-dot-ring: rgba(90, 83, 76, 0.15);
--diagram-text: #2a2725;
--diagram-text-muted: #7a736c;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--ink);
display: flex;
min-height: 100vh;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
width: var(--sidebar-width);
height: 100vh;
overflow-y: auto;
padding: 24px 16px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
z-index: 10;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sidebar-header .eyebrow {
margin: 0;
}
.sidebar-close {
display: none;
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
border-radius: 6px;
padding: 4px 10px;
font-size: 0.82rem;
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.theme-toggle {
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
border-radius: 6px;
padding: 4px 10px;
font-size: 0.78rem;
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: background-color 120ms ease, color 120ms ease;
}
.theme-toggle:hover {
color: var(--ink);
background: var(--bg);
}
.toc-link {
display: block;
padding: 6px 10px;
border-radius: 6px;
color: var(--muted);
text-decoration: none;
font-size: 0.88rem;
line-height: 1.4;
transition: background-color 120ms ease, color 120ms ease;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toc-link:hover {
color: var(--ink);
background: var(--bg);
}
.toc-link.is-active {
color: var(--ink);
background: var(--bg);
font-weight: 600;
}
.hamburger {
display: none;
position: fixed;
top: 16px;
left: 16px;
z-index: 20;
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
border-radius: 8px;
padding: 8px 14px;
font-size: 0.88rem;
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
box-shadow: var(--shadow);
}
.content {
margin-left: var(--sidebar-width);
flex: 1;
max-width: 820px;
padding: 32px 36px 72px;
}
.hero {
margin-bottom: 32px;
}
.section-count {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
color: var(--accent-strong);
margin: 0 0 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
h1,
h2 {
margin: 0 0 12px;
line-height: 1.1;
font-weight: 700;
color: var(--ink);
}
h1 {
font-size: clamp(1.6rem, 3vw, 2.2rem);
text-wrap: balance;
}
h2 {
font-size: clamp(1.2rem, 2vw, 1.5rem);
text-wrap: balance;
}
p {
margin: 0 0 14px;
color: var(--ink-soft);
font-size: 1rem;
line-height: 1.7;
}
.section {
padding: 28px 0;
border-top: 1px solid var(--border);
}
.section-head {
margin-bottom: 16px;
}
.section-body {
max-width: 64ch;
}
.section-body p:last-child {
margin-bottom: 0;
}
.verification {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 28px;
margin-top: 12px;
}
.diagram {
margin-top: 20px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
padding: 16px;
cursor: zoom-in;
}
.lightbox[hidden] {
display: none;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.7);
display: grid;
place-items: center;
padding: 24px;
cursor: zoom-out;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 20px;
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
border-radius: 8px;
width: 40px;
height: 40px;
font-size: 1.4rem;
cursor: pointer;
display: grid;
place-items: center;
z-index: 1;
}
.lightbox-body {
width: min(1200px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
padding: 24px;
}
.lightbox-body svg {
display: block;
width: 100%;
height: auto;
}
.diagram-label {
margin: 0 0 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent-strong);
}
.diagram-stage {
display: block;
}
.diagram svg {
display: block;
width: 100%;
height: auto;
}
details {
margin-top: 16px;
}
summary {
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--accent-strong);
font-size: 0.88rem;
}
pre {
margin: 18px 0 0;
padding: 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--code-bg);
overflow-x: auto;
color: var(--ink-soft);
font-size: 0.9rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (max-width: 840px) {
.sidebar {
transform: translateX(-100%);
transition: transform 200ms ease;
box-shadow: none;
}
.sidebar.is-open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
}
.sidebar-close {
display: block;
margin-top: auto;
}
.hamburger {
display: block;
}
.content {
margin-left: 0;
padding: 24px 20px 48px;
}
}
@media (max-width: 560px) {
.content {
padding: 20px 14px 40px;
}
}
"#
}
fn html_script() -> &'static str {
r#"
(() => {
const sidebar = document.querySelector('[data-sidebar]');
const openBtn = document.querySelector('[data-sidebar-open]');
const closeBtn = document.querySelector('[data-sidebar-close]');
const themeBtn = document.querySelector('[data-theme-toggle]');
const tocLinks = Array.from(document.querySelectorAll('.toc-link'));
const sections = Array.from(document.querySelectorAll('.hero, .section'));
const stored = localStorage.getItem('magellan-theme');
if (stored === 'light') document.documentElement.setAttribute('data-theme', 'light');
function toggleTheme() {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
if (isLight) {
document.documentElement.removeAttribute('data-theme');
localStorage.setItem('magellan-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
localStorage.setItem('magellan-theme', 'light');
}
}
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
if (openBtn) openBtn.addEventListener('click', () => sidebar && sidebar.classList.add('is-open'));
if (closeBtn) closeBtn.addEventListener('click', () => sidebar && sidebar.classList.remove('is-open'));
tocLinks.forEach(link => {
link.addEventListener('click', () => {
if (sidebar && window.innerWidth <= 840) sidebar.classList.remove('is-open');
});
});
// Lightbox
const lightbox = document.querySelector('[data-lightbox]');
const lightboxBody = document.querySelector('[data-lightbox-body]');
const lightboxClose = document.querySelector('[data-lightbox-close]');
const diagrams = Array.from(document.querySelectorAll('.diagram'));
function openLightbox(diagram) {
if (!lightbox || !lightboxBody) return;
const svg = diagram.querySelector('svg');
if (!svg) return;
lightboxBody.innerHTML = svg.outerHTML;
lightbox.hidden = false;
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
if (!lightbox) return;
lightbox.hidden = true;
if (lightboxBody) lightboxBody.innerHTML = '';
document.body.style.overflow = '';
}
diagrams.forEach(d => d.addEventListener('click', (e) => {
if (e.target.closest('details') || e.target.closest('summary')) return;
openLightbox(d);
}));
if (lightboxClose) lightboxClose.addEventListener('click', closeLightbox);
if (lightbox) lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
window.addEventListener('keydown', (e) => {
if (lightbox && !lightbox.hidden && e.key === 'Escape') closeLightbox();
});
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
tocLinks.forEach(link => {
link.classList.toggle('is-active', link.getAttribute('href') === '#' + id);
});
}
});
}, { rootMargin: '-20% 0px -60% 0px' });
sections.forEach(section => {
if (section.id) observer.observe(section);
});
})();
"#
}
fn render_diagram_html(index: usize, diagram: &Diagram) -> String {
let svg = render_svg_diagram(index, diagram);
let ascii = escape_html(&render_ascii_diagram(diagram));
let title = diagram_title(diagram);
format!(
"<figure class=\"diagram\">
<p class=\"diagram-label\">{title}</p>
<div class=\"diagram-stage\">{svg}</div>
<details><summary>ASCII fallback</summary><pre>{ascii}</pre></details>
</figure>",
title = title,
svg = svg,
ascii = ascii
)
}
fn render_svg_diagram(index: usize, diagram: &Diagram) -> String {
let diagram_id = format!("diagram-{index}");
match diagram {
Diagram::Sequence { nodes, edges } => render_sequence_svg(&diagram_id, nodes, edges),
Diagram::Flow { nodes, edges } => render_graph_svg(&diagram_id, "Flow", nodes, edges),
Diagram::ComponentGraph { nodes, edges } => {
render_graph_svg(&diagram_id, "Component graph", nodes, edges)
}
Diagram::Timeline { events } => render_timeline_svg(&diagram_id, events),
Diagram::BeforeAfter(before_after) => render_before_after_svg(&diagram_id, before_after),
Diagram::LayerStack { layers } => render_layer_stack_svg(&diagram_id, layers),
Diagram::StateMachine {
states,
transitions,
} => render_graph_svg(&diagram_id, "State machine", states, transitions),
Diagram::Table { headers, rows } => render_table_svg(&diagram_id, headers, rows),
Diagram::DependencyTree { root, children } => {
render_dependency_tree_svg(&diagram_id, root, children)
}
}
}
fn render_sequence_svg(id: &str, nodes: &[String], edges: &[Edge]) -> String {
let nodes = ordered_nodes(nodes, edges);
let wrapped_nodes = nodes
.iter()
.map(|node| wrap_text(node, 14))
.collect::<Vec<_>>();
let box_width = wrapped_nodes
.iter()
.flat_map(|lines| lines.iter())
.map(|line| estimate_text_width(line, 112, 180))
.max()
.unwrap_or(112);
let box_height = wrapped_nodes
.iter()
.map(|lines| 24 + (lines.len() as i32 * 16))
.max()
.unwrap_or(48);
let padding = 24;
let lane_gap = 42;
let width = padding * 2
+ (nodes.len() as i32 * box_width)
+ ((nodes.len().saturating_sub(1)) as i32 * lane_gap);
let lifeline_start = 28 + box_height + 14;
let height = lifeline_start + (edges.len() as i32 * 66) + 48;
let marker_id = format!("{id}-arrow");
let mut body = String::new();
for (index, node) in nodes.iter().enumerate() {
let x = padding + index as i32 * (box_width + lane_gap);
let center_x = x + box_width / 2;
write!(
&mut body,
"<rect class=\"node\" x=\"{x}\" y=\"28\" width=\"{box_width}\" height=\"{box_height}\" rx=\"16\" ry=\"16\"/>"
)
.unwrap();
write_multiline_svg_text(
&mut body,
center_x,
28 + 26,
&wrap_text(node, 14),
"middle",
"node-copy",
);
write!(
&mut body,
"<line class=\"lane\" x1=\"{center_x}\" y1=\"{}\" x2=\"{center_x}\" y2=\"{}\"/>",
lifeline_start,
height - 24
)
.unwrap();
}
for (index, edge) in edges.iter().enumerate() {
let from_index = nodes
.iter()
.position(|node| node == &edge.from)
.unwrap_or_default();
let to_index = nodes
.iter()
.position(|node| node == &edge.to)
.unwrap_or_default();
let from_x = padding + from_index as i32 * (box_width + lane_gap) + box_width / 2;
let to_x = padding + to_index as i32 * (box_width + lane_gap) + box_width / 2;
let y = lifeline_start + 18 + index as i32 * 66;
write!(
&mut body,
"<line class=\"connector\" x1=\"{from_x}\" y1=\"{y}\" x2=\"{to_x}\" y2=\"{y}\" marker-end=\"url(#{marker_id})\"/>"
)
.unwrap();
if let Some(label) = &edge.label {
write_multiline_svg_text(
&mut body,
(from_x + to_x) / 2,
y - 12,
&wrap_text(label, 18),
"middle",
"edge-copy",
);
}
}
svg_shell(
id,
width.max(320),
height.max(180),
&marker_id,
"Sequence diagram",
&body,
)
}
fn render_graph_svg(id: &str, title: &str, nodes: &[String], edges: &[Edge]) -> String {
let nodes = ordered_nodes(nodes, edges);
let columns = nodes.len().clamp(1, 3);
let wrapped_nodes = nodes
.iter()
.map(|node| wrap_text(node, 16))
.collect::<Vec<_>>();
let box_width = wrapped_nodes
.iter()
.flat_map(|lines| lines.iter())
.map(|line| estimate_text_width(line, 120, 184))
.max()
.unwrap_or(120);
let box_height = wrapped_nodes
.iter()
.map(|lines| 24 + (lines.len() as i32 * 16))
.max()
.unwrap_or(54);
let padding = 28;
let gap_x = 54;
let gap_y = 68;
let rows = nodes.len().div_ceil(columns).max(1);
let width =
padding * 2 + columns as i32 * box_width + (columns.saturating_sub(1)) as i32 * gap_x;
let height = padding * 2 + rows as i32 * box_height + (rows.saturating_sub(1)) as i32 * gap_y;
let marker_id = format!("{id}-arrow");
let positions = nodes
.iter()
.enumerate()
.map(|(index, node)| {
let row = index / columns;
let col = index % columns;
let x = padding + col as i32 * (box_width + gap_x);
let y = padding + row as i32 * (box_height + gap_y);
(node.as_str(), (x, y))
})
.collect::<Vec<_>>();
let mut body = String::new();
for edge in edges {
let Some((from_x, from_y)) = positions
.iter()
.find(|(node, _)| *node == edge.from.as_str())
.map(|(_, position)| *position)
else {
continue;
};
let Some((to_x, to_y)) = positions
.iter()
.find(|(node, _)| *node == edge.to.as_str())
.map(|(_, position)| *position)
else {
continue;
};
let start_x = from_x + box_width / 2;
let start_y = from_y + box_height / 2;
let end_x = to_x + box_width / 2;
let end_y = to_y + box_height / 2;
write!(
&mut body,
"<line class=\"connector\" x1=\"{start_x}\" y1=\"{start_y}\" x2=\"{end_x}\" y2=\"{end_y}\" marker-end=\"url(#{marker_id})\"/>"
)
.unwrap();
if let Some(label) = &edge.label {
write_multiline_svg_text(
&mut body,
(start_x + end_x) / 2,
(start_y + end_y) / 2 - 8,
&wrap_text(label, 14),
"middle",
"edge-copy",
);
}
}
for ((_, (x, y)), lines) in positions.iter().zip(wrapped_nodes.iter()) {
let center_x = *x + box_width / 2;
write!(
&mut body,
"<rect class=\"node\" x=\"{}\" y=\"{}\" width=\"{box_width}\" height=\"{box_height}\" rx=\"16\" ry=\"16\"/>",
x,
y
)
.unwrap();
write_multiline_svg_text(&mut body, center_x, *y + 26, lines, "middle", "node-copy");
}
svg_shell(
id,
width.max(320),
height.max(220),
&marker_id,
title,
&body,
)
}
fn render_timeline_svg(id: &str, events: &[TimelineEvent]) -> String {
let padding = 28;
let width = 760;
let height = 60 + events.len() as i32 * 92;
let axis_x = 86;
let marker_id = format!("{id}-arrow");
let mut body = String::new();
write!(
&mut body,
"<line class=\"timeline-axis\" x1=\"{axis_x}\" y1=\"32\" x2=\"{axis_x}\" y2=\"{}\"/>",
height - 32
)
.unwrap();
for (index, event) in events.iter().enumerate() {
let y = 56 + index as i32 * 92;
write!(
&mut body,
"<circle class=\"timeline-dot\" cx=\"{axis_x}\" cy=\"{y}\" r=\"9\"/>"
)
.unwrap();
write!(
&mut body,
"<rect class=\"panel-box\" x=\"132\" y=\"{}\" width=\"{}\" height=\"64\" rx=\"16\" ry=\"16\"/>",
y - 26,
width - padding - 132
)
.unwrap();
write_multiline_svg_text(
&mut body,
156,
y - 4,
&wrap_text(&event.label, 20),
"start",
"event-label",
);
write_multiline_svg_text(
&mut body,
156,
y + 18,
&wrap_text(&event.detail, 56),
"start",
"event-copy",
);
}
svg_shell(id, width, height.max(180), &marker_id, "Timeline", &body)
}
fn render_before_after_svg(id: &str, before_after: &BeforeAfterDiagram) -> String {
let gap = 26;
let padding = 24;
let width = 760;
let panel_width = (width - (padding * 2) - gap) / 2;
let left_x = padding;
let right_x = padding + panel_width + gap;
let before_lines = list_to_lines(&before_after.before, 24);
let after_lines = list_to_lines(&before_after.after, 24);
let line_height = 16;
let list_height = before_lines.len().max(after_lines.len()) as i32 * line_height + 26;
let panel_height = list_height + 46;
let height = panel_height + 72;
let marker_id = format!("{id}-arrow");
let mut body = String::new();
write!(
&mut body,
"<rect class=\"panel-box\" x=\"{left_x}\" y=\"34\" width=\"{panel_width}\" height=\"{panel_height}\" rx=\"18\" ry=\"18\"/>"
)
.unwrap();
write!(
&mut body,
"<rect class=\"panel-box\" x=\"{right_x}\" y=\"34\" width=\"{panel_width}\" height=\"{panel_height}\" rx=\"18\" ry=\"18\"/>"
)
.unwrap();
write_multiline_svg_text(
&mut body,
left_x + 22,
62,
&[String::from("Before")],
"start",
"event-label",
);
write_multiline_svg_text(
&mut body,
right_x + 22,
62,
&[String::from("After")],
"start",
"event-label",
);
write_bullet_lines(&mut body, left_x + 22, 92, &before_lines);
write_bullet_lines(&mut body, right_x + 22, 92, &after_lines);
svg_shell(id, width, height, &marker_id, "Before and after", &body)
}
fn render_layer_stack_svg(id: &str, layers: &[String]) -> String {
let padding = 24;
let width = 520;
let layer_height = 48;
let gap = 6;
let layer_width = width - padding * 2;
let height = padding * 2
+ layers.len() as i32 * layer_height
+ (layers.len().saturating_sub(1)) as i32 * gap;
let marker_id = format!("{id}-arrow");
let mut body = String::new();
for (index, layer) in layers.iter().enumerate() {
let y = padding + index as i32 * (layer_height + gap);
let center_x = padding + layer_width / 2;
let center_y = y + layer_height / 2 + 5;
write!(
&mut body,
"<rect class=\"node\" x=\"{padding}\" y=\"{y}\" width=\"{layer_width}\" height=\"{layer_height}\" rx=\"8\" ry=\"8\"/>"
)
.unwrap();
write_multiline_svg_text(
&mut body,
center_x,
center_y,
&wrap_text(layer, 40),
"middle",
"node-copy",
);
}
svg_shell(id, width, height.max(120), &marker_id, "Layer stack", &body)
}
fn render_dependency_tree_svg(id: &str, root: &str, children: &[TreeNode]) -> String {
let padding = 24;
let row_height = 32;
let indent_width = 28;
let marker_id = format!("{id}-arrow");
struct FlatRow {
label: String,
depth: i32,
}
fn flatten(node_children: &[TreeNode], depth: i32, rows: &mut Vec<FlatRow>) {
for child in node_children {
rows.push(FlatRow {
label: child.label.clone(),
depth,
});
flatten(&child.children, depth + 1, rows);
}
}
let mut rows = vec![FlatRow {
label: root.to_string(),
depth: 0,
}];
flatten(children, 1, &mut rows);
let max_depth = rows.iter().map(|r| r.depth).max().unwrap_or(0);
let width = padding * 2 + max_depth * indent_width + 300;
let height = padding * 2 + rows.len() as i32 * row_height;
let mut body = String::new();
for (index, row) in rows.iter().enumerate() {
let x = padding + row.depth * indent_width;
let y = padding + index as i32 * row_height;
if row.depth > 0 {
write!(
&mut body,
"<circle class=\"timeline-dot\" cx=\"{}\" cy=\"{}\" r=\"4\" style=\"stroke-width:2\"/>",
x + 4,
y + 12
)
.unwrap();
}
let text_x = if row.depth > 0 { x + 16 } else { x };
let class = if row.depth == 0 {
"event-label"
} else {
"event-copy"
};
write_multiline_svg_text(
&mut body,
text_x,
y + 16,
&wrap_text(&row.label, 30),
"start",
class,
);
}
svg_shell(
id,
width.max(320),
height.max(120),
&marker_id,
"Dependency tree",
&body,
)
}
fn render_table_svg(id: &str, headers: &[String], rows: &[Vec<String>]) -> String {
let padding = 24;
let row_height = 36;
let header_height = 40;
let col_count = headers.len() as i32;
let col_width = 160;
let width = padding * 2 + col_count * col_width;
let total_rows = rows.len() as i32;
let height = padding * 2 + header_height + total_rows * row_height + 2;
let marker_id = format!("{id}-arrow");
let mut body = String::new();
let table_width = col_count * col_width;
let table_height = header_height + total_rows * row_height;
write!(
&mut body,
"<rect class=\"panel-box\" x=\"{padding}\" y=\"{padding}\" width=\"{table_width}\" height=\"{table_height}\" rx=\"6\" ry=\"6\"/>"
)
.unwrap();
let sep_y = padding + header_height;
write!(
&mut body,
"<line class=\"connector\" x1=\"{padding}\" y1=\"{sep_y}\" x2=\"{}\" y2=\"{sep_y}\" style=\"stroke-width:1\"/>",
padding + table_width
)
.unwrap();
for col in 1..col_count {
let x = padding + col * col_width;
write!(
&mut body,
"<line class=\"lane\" x1=\"{x}\" y1=\"{padding}\" x2=\"{x}\" y2=\"{}\"/>",
padding + table_height
)
.unwrap();
}
for (col, header) in headers.iter().enumerate() {
let x = padding + col as i32 * col_width + col_width / 2;
let y = padding + header_height / 2 + 5;
write_multiline_svg_text(
&mut body,
x,
y,
std::slice::from_ref(header),
"middle",
"event-label",
);
}
for (row_index, row) in rows.iter().enumerate() {
let row_y = padding + header_height + row_index as i32 * row_height;
if row_index > 0 {
write!(
&mut body,
"<line class=\"lane\" x1=\"{padding}\" y1=\"{row_y}\" x2=\"{}\" y2=\"{row_y}\"/>",
padding + table_width
)
.unwrap();
}
for (col, cell) in row.iter().enumerate() {
let x = padding + col as i32 * col_width + col_width / 2;
let y = row_y + row_height / 2 + 5;
write_multiline_svg_text(
&mut body,
x,
y,
std::slice::from_ref(cell),
"middle",
"event-copy",
);
}
}
svg_shell(
id,
width.max(320),
height.max(120),
&marker_id,
"Table",
&body,
)
}
fn svg_shell(
id: &str,
width: i32,
height: i32,
marker_id: &str,
title: &str,
body: &str,
) -> String {
format!(
"<svg viewBox=\"0 0 {width} {height}\" role=\"img\" aria-labelledby=\"{id}-title\">
<title id=\"{id}-title\">{title}</title>
<style>
.node, .panel-box {{
fill: var(--diagram-node-fill, #1b1a18);
stroke: var(--diagram-stroke, rgba(160, 152, 144, 0.4));
stroke-width: 1;
}}
.lane {{
stroke: var(--diagram-lane, rgba(255, 255, 255, 0.12));
stroke-width: 1;
stroke-dasharray: 5 5;
}}
.connector {{
stroke: var(--diagram-stroke, rgba(160, 152, 144, 0.6));
stroke-width: 1.5;
fill: none;
}}
.timeline-axis {{
stroke: var(--diagram-lane, rgba(255, 255, 255, 0.15));
stroke-width: 2;
}}
.timeline-dot {{
fill: var(--diagram-dot, rgba(160, 152, 144, 0.8));
stroke: var(--diagram-dot-ring, rgba(160, 152, 144, 0.15));
stroke-width: 4;
}}
.node-copy, .edge-copy, .event-copy, .event-label, .bullet-copy {{
fill: var(--diagram-text, #d9d5d0);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}}
.node-copy {{
font-size: 13px;
font-weight: 600;
}}
.edge-copy {{
font-size: 11px;
font-weight: 500;
fill: var(--diagram-text-muted, #a09890);
}}
.event-label {{
font-size: 13px;
font-weight: 700;
}}
.event-copy, .bullet-copy {{
font-size: 12px;
fill: var(--diagram-text-muted, #a09890);
}}
</style>
<defs>
<marker id=\"{marker_id}\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">
<path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--diagram-stroke, rgba(160, 152, 144, 0.6))\" />
</marker>
</defs>
{body}
</svg>",
title = escape_html(title)
)
}
fn write_multiline_svg_text(
output: &mut String,
x: i32,
y: i32,
lines: &[String],
anchor: &str,
class_name: &str,
) {
if lines.is_empty() {
return;
}
write!(
output,
"<text class=\"{class_name}\" x=\"{x}\" y=\"{y}\" text-anchor=\"{anchor}\">"
)
.unwrap();
for (index, line) in lines.iter().enumerate() {
let dy = if index == 0 { 0 } else { 15 };
write!(
output,
"<tspan x=\"{x}\" dy=\"{dy}\">{}</tspan>",
escape_html(line)
)
.unwrap();
}
output.push_str("</text>");
}
fn write_bullet_lines(output: &mut String, x: i32, start_y: i32, lines: &[String]) {
for (index, line) in lines.iter().enumerate() {
let y = start_y + index as i32 * 16;
write!(
output,
"<circle cx=\"{}\" cy=\"{}\" r=\"2.6\" fill=\"var(--diagram-dot, rgba(160, 152, 144, 0.86))\"/>",
x,
y - 4
)
.unwrap();
write_multiline_svg_text(
output,
x + 12,
y,
std::slice::from_ref(line),
"start",
"bullet-copy",
);
}
}
fn ordered_nodes(explicit_nodes: &[String], edges: &[Edge]) -> Vec<String> {
let mut seen = HashSet::new();
let mut nodes = Vec::new();
for node in explicit_nodes {
if seen.insert(node.clone()) {
nodes.push(node.clone());
}
}
for edge in edges {
for node in [&edge.from, &edge.to] {
if seen.insert(node.clone()) {
nodes.push(node.clone());
}
}
}
nodes
}
fn list_to_lines(entries: &[String], max_chars: usize) -> Vec<String> {
let mut lines = Vec::new();
for entry in entries {
let wrapped = wrap_text(entry, max_chars);
for line in wrapped {
lines.push(line);
}
}
lines
}
fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut lines = Vec::new();
let mut current = String::new();
for word in trimmed.split_whitespace() {
let projected_len = if current.is_empty() {
word.len()
} else {
current.len() + 1 + word.len()
};
if projected_len > max_chars && !current.is_empty() {
lines.push(current);
current = word.to_string();
} else if current.is_empty() {
current = word.to_string();
} else {
current.push(' ');
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn estimate_text_width(text: &str, min: i32, max: i32) -> i32 {
((text.chars().count() as i32 * 8) + 28).clamp(min, max)
}
fn paragraphs_to_html(paragraphs: &[String]) -> String {
paragraphs
.iter()
.map(|paragraph| format!("<p>{}</p>", escape_html(paragraph)))
.collect::<Vec<_>>()
.join("")
}
fn diagram_title(diagram: &Diagram) -> &'static str {
match diagram {
Diagram::Sequence { .. } => "Sequence diagram",
Diagram::Flow { .. } => "Flow diagram",
Diagram::ComponentGraph { .. } => "Component diagram",
Diagram::Timeline { .. } => "Timeline",
Diagram::BeforeAfter(_) => "Before / after",
Diagram::LayerStack { .. } => "Layer stack",
Diagram::StateMachine { .. } => "State machine",
Diagram::Table { .. } => "Table",
Diagram::DependencyTree { .. } => "Dependency tree",
}
}
fn render_ascii_diagram(diagram: &Diagram) -> String {
match diagram {
Diagram::Sequence { edges, .. } => render_ascii_edges("Sequence", edges),
Diagram::Flow { edges, .. } => render_ascii_edges("Flow", edges),
Diagram::ComponentGraph { edges, .. } => render_ascii_edges("Component graph", edges),
Diagram::Timeline { events } => {
let mut output = String::from("Timeline\n");
for event in events {
writeln!(&mut output, " * {}: {}", event.label, event.detail).unwrap();
}
output.trim_end().to_owned()
}
Diagram::BeforeAfter(before_after) => {
let mut output = String::from("Before\n");
for entry in &before_after.before {
writeln!(&mut output, " - {entry}").unwrap();
}
writeln!(&mut output, "After").unwrap();
for entry in &before_after.after {
writeln!(&mut output, " + {entry}").unwrap();
}
output.trim_end().to_owned()
}
Diagram::LayerStack { layers } => {
let mut output = String::from("Layer stack\n");
for layer in layers {
writeln!(&mut output, " [{layer}]").unwrap();
}
output.trim_end().to_owned()
}
Diagram::StateMachine { transitions, .. } => {
render_ascii_edges("State machine", transitions)
}
Diagram::Table { headers, rows } => {
let cols = headers.len();
let widths: Vec<usize> = (0..cols)
.map(|col| {
let header_len = headers[col].len();
let max_row = rows
.iter()
.map(|row| row.get(col).map_or(0, |cell| cell.len()))
.max()
.unwrap_or(0);
header_len.max(max_row).max(3)
})
.collect();
let mut output = String::new();
for (col, header) in headers.iter().enumerate() {
if col > 0 {
output.push_str(" | ");
}
write!(&mut output, "{:<width$}", header, width = widths[col]).unwrap();
}
output.push('\n');
for (col, width) in widths.iter().enumerate() {
if col > 0 {
output.push_str("-+-");
}
output.push_str(&"-".repeat(*width));
}
output.push('\n');
for row in rows {
for (col, cell) in row.iter().enumerate() {
if col > 0 {
output.push_str(" | ");
}
write!(&mut output, "{:<width$}", cell, width = widths[col]).unwrap();
}
output.push('\n');
}
output.trim_end().to_owned()
}
Diagram::DependencyTree { root, children } => {
let mut output = format!("{root}\n");
render_ascii_tree_children(&mut output, children, "");
output.trim_end().to_owned()
}
}
}
fn render_ascii_tree_children(output: &mut String, children: &[TreeNode], prefix: &str) {
for (index, child) in children.iter().enumerate() {
let is_last = index == children.len() - 1;
let connector = if is_last { "└── " } else { "├── " };
writeln!(output, "{prefix}{connector}{}", child.label).unwrap();
if !child.children.is_empty() {
let child_prefix = if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
render_ascii_tree_children(output, &child.children, &child_prefix);
}
}
}
fn render_ascii_edges(title: &str, edges: &[Edge]) -> String {
let mut output = String::new();
writeln!(&mut output, "{title}").unwrap();
for edge in edges {
match &edge.label {
Some(label) => {
writeln!(&mut output, " {} --{}--> {}", edge.from, label, edge.to).unwrap()
}
None => writeln!(&mut output, " {} -------> {}", edge.from, edge.to).unwrap(),
}
}
output.trim_end().to_owned()
}
fn render_mermaid_diagram(diagram: &Diagram) -> String {
match diagram {
Diagram::Sequence { edges, .. } => {
let mut output = String::from("sequenceDiagram\n");
for edge in edges {
let label = edge.label.as_deref().unwrap_or("");
writeln!(
&mut output,
" {}->>{}: {}",
sanitize_node(&edge.from),
sanitize_node(&edge.to),
label
)
.unwrap();
}
output.trim_end().to_owned()
}
Diagram::Flow { edges, .. } | Diagram::ComponentGraph { edges, .. } => {
let mut output = String::from("flowchart LR\n");
for edge in edges {
let label = edge
.label
.as_ref()
.map(|label| format!("|{}|", label))
.unwrap_or_default();
writeln!(
&mut output,
" {} -->{} {}",
sanitize_node(&edge.from),
label,
sanitize_node(&edge.to)
)
.unwrap();
}
output.trim_end().to_owned()
}
Diagram::Timeline { events } => {
let mut output = String::from("timeline\n");
writeln!(&mut output, " title Timeline").unwrap();
for event in events {
writeln!(
&mut output,
" {} : {}",
escape_mermaid_text(&event.label),
escape_mermaid_text(&event.detail)
)
.unwrap();
}
output.trim_end().to_owned()
}
Diagram::BeforeAfter(before_after) => {
let mut output = String::from("flowchart TB\n");
writeln!(&mut output, " subgraph Before").unwrap();
for (index, entry) in before_after.before.iter().enumerate() {
writeln!(
&mut output,
" B{}[\"{}\"]",
index,
escape_mermaid_text(entry)
)
.unwrap();
}
writeln!(&mut output, " end").unwrap();
writeln!(&mut output, " subgraph After").unwrap();
for (index, entry) in before_after.after.iter().enumerate() {
writeln!(
&mut output,
" A{}[\"{}\"]",
index,
escape_mermaid_text(entry)
)
.unwrap();
}
writeln!(&mut output, " end").unwrap();
output.trim_end().to_owned()
}
Diagram::LayerStack { layers } => {
let mut output = String::from("block-beta\n");
for (index, layer) in layers.iter().enumerate() {
writeln!(
&mut output,
" L{}[\"{}\"]",
index,
escape_mermaid_text(layer)
)
.unwrap();
}
output.trim_end().to_owned()
}
Diagram::StateMachine { transitions, .. } => {
let mut output = String::from("stateDiagram-v2\n");
for edge in transitions {
let label = edge.label.as_deref().unwrap_or("");
writeln!(
&mut output,
" {} --> {}: {}",
sanitize_node(&edge.from),
sanitize_node(&edge.to),
label
)
.unwrap();
}
output.trim_end().to_owned()
}
Diagram::DependencyTree { root, children } => {
let mut output = String::from("flowchart TD\n");
fn emit_mermaid_tree(
output: &mut String,
parent: &str,
children: &[TreeNode],
counter: &mut usize,
) {
for child in children {
let child_id = format!("N{}", *counter);
*counter += 1;
writeln!(
output,
" {} --> {}[\"{}\"]",
parent,
child_id,
escape_mermaid_text(&child.label)
)
.unwrap();
emit_mermaid_tree(output, &child_id, &child.children, counter);
}
}
let root_id = "ROOT";
writeln!(
&mut output,
" {root_id}[\"{}\"]",
escape_mermaid_text(root)
)
.unwrap();
let mut counter = 0;
emit_mermaid_tree(&mut output, root_id, children, &mut counter);
output.trim_end().to_owned()
}
Diagram::Table { headers, rows } => {
let mut output = String::from("flowchart LR\n");
let mut table = format!("| {} |\\n", headers.join(" | "));
table.push_str(&format!(
"| {} |\\n",
headers
.iter()
.map(|_| "---")
.collect::<Vec<_>>()
.join(" | ")
));
for row in rows {
table.push_str(&format!("| {} |\\n", row.join(" | ")));
}
writeln!(&mut output, " T[\"{}\"]", table).unwrap();
output.trim_end().to_owned()
}
}
}
fn sanitize_node(name: &str) -> String {
name.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect()
}
fn escape_mermaid_text(value: &str) -> String {
value.replace('"', "\\\"")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Diagram, Document, Edge, Section, Verification};
use crate::{ExamplePreset, example_document};
fn sample_document() -> Document {
Document {
title: "Magellan demo".into(),
summary: vec![
"A short summary explains the outcome in product terms.".into(),
"A second paragraph adds only the necessary context.".into(),
],
sections: vec![Section {
title: "New flow".into(),
text: vec![
"The UI validates first.".into(),
"Only valid requests continue to the backend.".into(),
],
diagram: Some(Diagram::Sequence {
nodes: vec!["User".into(), "UI".into(), "API".into()],
edges: vec![
Edge {
from: "User".into(),
to: "UI".into(),
label: Some("submit".into()),
},
Edge {
from: "UI".into(),
to: "API".into(),
label: Some("valid request".into()),
},
],
}),
}],
verification: Some(Verification {
text: vec!["An integration test and a quick manual check passed.".into()],
}),
}
}
#[test]
fn renders_terminal_output_with_ascii_diagram() {
let rendered = render_document(&sample_document(), OutputFormat::Terminal);
assert!(rendered.contains("Magellan demo"));
assert!(rendered.contains("Sequence"));
assert!(rendered.contains("User --submit--> UI"));
}
#[test]
fn renders_markdown_with_mermaid_blocks() {
let rendered = render_document(&sample_document(), OutputFormat::Markdown);
assert!(rendered.contains("```mermaid"));
assert!(rendered.contains("sequenceDiagram"));
assert!(rendered.contains("User->>UI: submit"));
}
#[test]
fn renders_html_panels() {
let rendered = render_document(&sample_document(), OutputFormat::Html);
assert!(rendered.contains("<!DOCTYPE html>"));
assert!(rendered.contains("Magellan walkthrough"));
assert!(rendered.contains("class=\"sidebar\""));
assert!(rendered.contains("class=\"toc-link"));
assert!(rendered.contains("id=\"section-1\""));
assert!(rendered.contains("--bg: #131211"));
assert!(rendered.contains("data-theme-toggle"));
assert!(rendered.contains("[data-theme=\"light\"]"));
assert!(rendered.contains("<link rel=\"icon\" href=\"data:,\">"));
assert!(rendered.contains("<svg viewBox="));
assert!(rendered.contains("ASCII fallback"));
assert!(rendered.contains("color-scheme: dark;"));
assert!(rendered.contains("color-scheme: light;"));
assert!(!rendered.contains("cdn.jsdelivr"));
assert!(!rendered.contains("Book View"));
assert!(!rendered.contains("data-book-track"));
assert!(!rendered.contains("data-diagram-modal"));
assert!(!rendered.contains("Click to enlarge"));
}
#[test]
fn html_output_is_self_contained_for_all_examples() {
for preset in [
ExamplePreset::Walkthrough,
ExamplePreset::Timeline,
ExamplePreset::BeforeAfter,
ExamplePreset::Followup,
] {
let rendered = render_document(&example_document(preset), OutputFormat::Html);
assert!(rendered.contains("<svg viewBox="));
assert!(!rendered.contains("https://"));
assert!(!rendered.contains("cdn.jsdelivr"));
}
}
}