#![forbid(unsafe_code)]
pub mod class;
pub mod detect;
pub mod er;
pub mod layout;
pub mod parser;
pub mod pie;
pub mod render;
pub mod sequence;
pub mod types;
pub use class::{
Attribute as ClassAttribute, Class, ClassDiagram, Member, Method, RelKind, Relation,
Stereotype, Visibility,
};
pub use er::{Attribute, AttributeKey, Cardinality, Entity, ErDiagram, LineStyle, Relationship};
pub use pie::{PieChart, PieSlice};
pub use sequence::{Message, MessageStyle, Participant, SequenceDiagram};
pub use types::{Direction, Edge, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape};
use detect::DiagramKind;
use layout::layered::{LayoutBackend, LayoutConfig};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
EmptyInput,
UnsupportedDiagram(String),
ParseError(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::EmptyInput => write!(f, "empty or blank input"),
Error::UnsupportedDiagram(kind) => {
write!(f, "unsupported diagram type: '{kind}'")
}
Error::ParseError(msg) => write!(f, "parse error: {msg}"),
}
}
}
impl std::error::Error for Error {}
pub fn render(input: &str) -> Result<String, Error> {
render_with_width(input, None)
}
pub fn render_with_width(input: &str, max_width: Option<usize>) -> Result<String, Error> {
let kind = detect::detect(input)?;
let graph = match kind {
DiagramKind::Sequence => {
let diag = parser::sequence::parse(input)?;
return Ok(render::sequence::render(&diag));
}
DiagramKind::Pie => {
let chart = parser::pie::parse(input)?;
return Ok(render::pie::render(&chart, max_width));
}
DiagramKind::Er => {
let chart = parser::er::parse(input)?;
return Ok(render::er::render(&chart, max_width));
}
DiagramKind::Class => {
let chart = parser::class::parse(input)?;
return Ok(render::class::render(&chart, max_width));
}
DiagramKind::Flowchart => parser::parse(input)?,
DiagramKind::State => {
parser::state::parse(input)?
}
};
let default_cfg = LayoutConfig::default();
let result = render_with_config(&graph, &default_cfg);
let Some(budget) = max_width else {
return Ok(result);
};
if max_line_width(&result) <= budget {
return Ok(result);
}
const COMPACT_CONFIGS: &[LayoutConfig] = &[
LayoutConfig::with_gaps(4, 2),
LayoutConfig::with_gaps(2, 1),
LayoutConfig::with_gaps(1, 0),
];
let mut best = render_with_config(&graph, COMPACT_CONFIGS.last().expect("non-empty"));
for cfg in COMPACT_CONFIGS {
let candidate = render_with_config(&graph, cfg);
if max_line_width(&candidate) <= budget {
return Ok(candidate);
}
best = candidate;
}
Ok(best)
}
pub fn render_ascii(input: &str) -> Result<String, Error> {
render_ascii_with_width(input, None)
}
pub fn render_ascii_with_width(input: &str, max_width: Option<usize>) -> Result<String, Error> {
let unicode = render_with_width(input, max_width)?;
Ok(to_ascii(&unicode))
}
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub max_width: Option<usize>,
pub ascii: bool,
pub color: bool,
pub backend: LayoutBackend,
pub gaps_override: Option<(usize, usize)>,
}
pub fn render_with_options(input: &str, opts: &RenderOptions) -> Result<String, Error> {
let kind = detect::detect(input)?;
let unicode = match kind {
DiagramKind::Sequence => {
let diag = parser::sequence::parse(input)?;
render::sequence::render(&diag)
}
DiagramKind::Pie => {
let chart = parser::pie::parse(input)?;
render::pie::render(&chart, opts.max_width)
}
DiagramKind::Er => {
let chart = parser::er::parse(input)?;
render::er::render(&chart, opts.max_width)
}
DiagramKind::Class => {
let chart = parser::class::parse(input)?;
render::class::render(&chart, opts.max_width)
}
DiagramKind::Flowchart => {
let graph = parser::parse(input)?;
render_flowchart_with_color(
&graph,
opts.max_width,
opts.color,
opts.backend,
opts.gaps_override,
)
}
DiagramKind::State => {
let graph = parser::state::parse(input)?;
render_flowchart_with_color(
&graph,
opts.max_width,
opts.color,
opts.backend,
opts.gaps_override,
)
}
};
if opts.ascii {
Ok(to_ascii(&unicode))
} else {
Ok(unicode)
}
}
fn render_flowchart_with_color(
graph: &crate::types::Graph,
max_width: Option<usize>,
with_color: bool,
backend: LayoutBackend,
gaps_override: Option<(usize, usize)>,
) -> String {
let with_backend = |c: LayoutConfig| LayoutConfig { backend, ..c };
if let Some((layer_gap, node_gap)) = gaps_override {
let cfg = with_backend(LayoutConfig::with_gaps(layer_gap, node_gap));
return render_with_config_color(graph, &cfg, with_color);
}
let compact_configs: [LayoutConfig; 3] = [
with_backend(LayoutConfig::with_gaps(4, 2)),
with_backend(LayoutConfig::with_gaps(2, 1)),
with_backend(LayoutConfig::with_gaps(1, 0)),
];
let default_cfg = with_backend(LayoutConfig::default());
let Some(budget) = max_width else {
return render_with_config_color(graph, &default_cfg, with_color);
};
let plain = render_with_config(graph, &default_cfg);
if max_line_width(&plain) <= budget {
return if with_color {
render_with_config_color(graph, &default_cfg, true)
} else {
plain
};
}
for cfg in &compact_configs {
let candidate = render_with_config(graph, cfg);
if max_line_width(&candidate) <= budget {
return if with_color {
render_with_config_color(graph, cfg, true)
} else {
candidate
};
}
}
let last = compact_configs.last().expect("non-empty");
render_with_config_color(graph, last, with_color)
}
pub fn to_ascii(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
let ascii_ch = match ch {
'─' | '━' | '┄' => '-',
'│' | '┃' | '┆' => '|',
'┌' | '┐' | '└' | '┘' => '+',
'╭' | '╮' | '╰' | '╯' => '+',
'┏' | '┓' | '┗' | '┛' => '+',
'├' | '┤' | '┬' | '┴' | '┼' => '+',
'┣' | '┫' | '┳' | '┻' | '╋' => '+',
'▸' => '>',
'◂' => '<',
'▾' => 'v',
'▴' => '^',
'◇' => '*',
'◆' => '#',
'△' => '^',
'●' => '*',
'○' | '◯' => 'o',
'×' => 'x',
'║' | '╵' | '╷' | '╴' | '╶' => '|',
'═' => '-',
'╓' | '╖' | '╙' | '╜' | '╔' | '╗' | '╚' | '╝' => '+',
'╠' | '╣' | '╦' | '╩' | '╬' => '+',
other => other,
};
out.push(ascii_ch);
}
out
}
fn render_with_config(graph: &crate::types::Graph, config: &LayoutConfig) -> String {
render_with_config_color(graph, config, false)
}
fn render_with_config_color(
graph: &crate::types::Graph,
config: &LayoutConfig,
with_color: bool,
) -> String {
#[allow(deprecated)] let layout::layered::LayoutResult { mut positions, .. } = match config.backend {
LayoutBackend::Sugiyama => layout::sugiyama::sugiyama_layout(graph, config),
LayoutBackend::Native | LayoutBackend::LayeredLegacy => {
layout::layered::layout(graph, config)
}
};
if !graph.subgraphs.is_empty() {
let (col_offset, row_offset) = subgraph_position_offset(graph, &positions);
if col_offset != 0 || row_offset != 0 {
for (col, row) in positions.values_mut() {
*col += col_offset;
*row += row_offset;
}
}
}
let sg_bounds = layout::subgraph::compute_subgraph_bounds(graph, &positions);
if with_color {
render::render_color(graph, &positions, &sg_bounds)
} else {
render::render(graph, &positions, &sg_bounds)
}
}
fn subgraph_position_offset(
graph: &crate::types::Graph,
positions: &std::collections::HashMap<String, (usize, usize)>,
) -> (usize, usize) {
use layout::subgraph::SG_BORDER_PAD;
let node_sg_map = graph.node_to_subgraph();
let max_depth = compute_max_nesting_depth(graph);
let required_pad = SG_BORDER_PAD * (max_depth + 1);
let mut min_col = usize::MAX;
let mut min_row = usize::MAX;
for (node_id, &(col, row)) in positions.iter() {
if node_sg_map.contains_key(node_id) {
min_col = min_col.min(col);
min_row = min_row.min(row);
}
}
if min_col == usize::MAX {
return (0, 0);
}
(
required_pad.saturating_sub(min_col),
required_pad.saturating_sub(min_row),
)
}
fn compute_max_nesting_depth(graph: &crate::types::Graph) -> usize {
fn depth_of(graph: &crate::types::Graph, sg: &crate::types::Subgraph, cur: usize) -> usize {
let mut max = cur;
for child_id in &sg.subgraph_ids {
if let Some(child) = graph.find_subgraph(child_id) {
max = max.max(depth_of(graph, child, cur + 1));
}
}
max
}
graph
.subgraphs
.iter()
.map(|sg| depth_of(graph, sg, 0))
.max()
.unwrap_or(0)
}
fn max_line_width(text: &str) -> usize {
text.lines()
.map(unicode_width::UnicodeWidthStr::width)
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_simple_lr_flowchart() {
let out = render("graph LR; A-->B-->C").unwrap();
assert!(out.contains('A'), "missing A in:\n{out}");
assert!(out.contains('B'), "missing B in:\n{out}");
assert!(out.contains('C'), "missing C in:\n{out}");
assert!(
out.contains('▸') || out.contains('-'),
"no arrow found in:\n{out}"
);
}
#[test]
fn render_simple_td_flowchart() {
let out = render("graph TD; A-->B").unwrap();
let a_pos = out.find('A').unwrap_or(usize::MAX);
let b_pos = out.find('B').unwrap_or(usize::MAX);
assert!(a_pos < b_pos, "expected A before B in TD layout:\n{out}");
assert!(out.contains('▾'), "missing down arrow in:\n{out}");
}
#[test]
fn render_labeled_nodes() {
let out = render("graph LR; A[Start] --> B[End]").unwrap();
assert!(out.contains("Start"), "missing 'Start' in:\n{out}");
assert!(out.contains("End"), "missing 'End' in:\n{out}");
assert!(
out.contains('┌') || out.contains('╭'),
"no box corner:\n{out}"
);
}
#[test]
fn render_edge_labels() {
let out = render("graph LR; A -->|yes| B").unwrap();
assert!(out.contains("yes"), "missing edge label 'yes' in:\n{out}");
}
#[test]
fn render_diamond_node() {
let out = render("graph LR; A{Decision} --> B[OK]").unwrap();
assert!(out.contains("Decision"), "missing 'Decision' in:\n{out}");
assert!(out.contains('◇'), "no diamond marker in:\n{out}");
}
#[test]
fn parse_semicolons() {
let out = render("graph LR; A-->B; B-->C").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
assert!(out.contains('C'));
}
#[test]
fn parse_newlines() {
let src = "graph TD\nA[Alpha]\nB[Beta]\nA --> B";
let out = render(src).unwrap();
assert!(out.contains("Alpha"), "missing 'Alpha' in:\n{out}");
assert!(out.contains("Beta"), "missing 'Beta' in:\n{out}");
}
#[test]
fn unknown_diagram_type_returns_error() {
let err = render("gantt title Roadmap").unwrap_err();
assert!(
matches!(err, Error::UnsupportedDiagram(_)),
"expected UnsupportedDiagram, got {err:?}"
);
}
#[test]
fn empty_input_returns_error() {
assert!(matches!(render(""), Err(Error::EmptyInput)));
assert!(matches!(render(" "), Err(Error::EmptyInput)));
assert!(matches!(render("\n\n"), Err(Error::EmptyInput)));
}
#[test]
fn single_node_renders() {
let out = render("graph LR; A[Alone]").unwrap();
assert!(out.contains("Alone"), "missing 'Alone' in:\n{out}");
assert!(out.contains('┌') || out.contains('╭'));
}
#[test]
fn cyclic_graph_doesnt_hang() {
let out = render("graph LR; A-->B; B-->A").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn special_chars_in_labels() {
let out = render("graph LR; A[Hello World] --> B[Item (1)]").unwrap();
assert!(out.contains("Hello World"), "missing label in:\n{out}");
assert!(out.contains("Item (1)"), "missing label in:\n{out}");
}
#[test]
fn flowchart_keyword_accepted() {
let out = render("flowchart LR; A-->B").unwrap();
assert!(out.contains('A'));
}
#[test]
fn rl_direction_accepted() {
let out = render("graph RL; A-->B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn bt_direction_accepted() {
let out = render("graph BT; A-->B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn multiple_branches() {
let src = "graph LR; A[Start] --> B{Decision}; B -->|Yes| C[End]; B -->|No| D[Skip]";
let out = render(src).unwrap();
assert!(out.contains("Start"), "missing 'Start':\n{out}");
assert!(out.contains("Decision"), "missing 'Decision':\n{out}");
assert!(out.contains("End"), "missing 'End':\n{out}");
assert!(out.contains("Skip"), "missing 'Skip':\n{out}");
assert!(out.contains("Yes"), "missing 'Yes':\n{out}");
assert!(out.contains("No"), "missing 'No':\n{out}");
}
#[test]
fn dotted_arrow_parsed() {
let out = render("graph LR; A-.->B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn thick_arrow_parsed() {
let out = render("graph LR; A==>B").unwrap();
assert!(out.contains('A'));
assert!(out.contains('B'));
}
#[test]
fn rounded_node_renders() {
let out = render("graph LR; A(Rounded)").unwrap();
assert!(out.contains("Rounded"), "missing label in:\n{out}");
assert!(
out.contains('╭') || out.contains('╰'),
"no rounded corners:\n{out}"
);
}
#[test]
fn circle_node_renders() {
let out = render("graph LR; A((Circle))").unwrap();
assert!(out.contains("Circle"), "missing label in:\n{out}");
assert!(
out.contains('(') || out.contains('╭'),
"no circle markers:\n{out}"
);
}
#[test]
fn real_world_flowchart_with_subgraph() {
let src = r#"graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics/exits| F
end
W -->|beat| HB[Heartbeat]
HB --> WD[Watchdog]
W --> CB{Circuit Breaker}
CB -->|CLOSED| DB[(Database)]"#;
let out = render(src).expect("should parse real-world flowchart");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
assert!(out.contains("Database"), "missing Database:\n{out}");
assert!(
!out.contains("subgraph"),
"subgraph should be skipped:\n{out}"
);
assert!(
!out.contains("direction"),
"direction should be skipped:\n{out}"
);
}
#[test]
fn multiple_edges_from_same_node_spread() {
let out = render("graph LR; A-->B; A-->C; A-->D").unwrap();
let arrow_rows: Vec<usize> = out
.lines()
.enumerate()
.filter(|(_, line)| line.contains('▸'))
.map(|(i, _)| i)
.collect();
assert!(
arrow_rows.len() >= 3,
"expected at least 3 distinct arrow rows, got {arrow_rows:?}:\n{out}"
);
let unique: std::collections::HashSet<_> = arrow_rows.iter().collect();
assert_eq!(
unique.len(),
arrow_rows.len(),
"duplicate arrow rows {arrow_rows:?} — edges not spread:\n{out}"
);
}
#[test]
fn long_edge_label_not_truncated() {
let out = render("graph LR; A-->|panics and exits cleanly| B").unwrap();
assert!(
out.contains("panics and exits cleanly"),
"label truncated:\n{out}"
);
}
#[test]
fn diverging_labels_dont_collide() {
let out = render("graph TD; B{Ok?}; B-->|Yes|C; B-->|No|D").unwrap();
assert!(out.contains("Yes"), "missing 'Yes' label:\n{out}");
assert!(out.contains("No"), "missing 'No' label:\n{out}");
assert!(
!out.contains("NoYes") && !out.contains("YesNo"),
"labels collided:\n{out}"
);
}
#[test]
fn stadium_node_renders() {
let out = render("graph LR; A([Stadium])").unwrap();
assert!(out.contains("Stadium"), "missing label:\n{out}");
assert!(
out.contains('(') || out.contains('╭'),
"no stadium markers:\n{out}"
);
}
#[test]
fn subroutine_node_renders() {
let out = render("graph LR; A[[Subroutine]]").unwrap();
assert!(out.contains("Subroutine"), "missing label:\n{out}");
assert!(out.contains('│'), "no inner vertical bars:\n{out}");
}
#[test]
fn cylinder_node_renders() {
let out = render("graph LR; A[(Database)]").unwrap();
assert!(out.contains("Database"), "missing label:\n{out}");
assert!(
out.contains('╭') && out.contains('╰'),
"missing rounded corners:\n{out}",
);
assert!(
out.contains('├') && out.contains('┤'),
"missing lid-line T-junctions:\n{out}",
);
}
#[test]
fn hexagon_node_renders() {
let out = render("graph LR; A{{Hexagon}}").unwrap();
assert!(out.contains("Hexagon"), "missing label:\n{out}");
assert!(
out.contains('<') || out.contains('>'),
"no hexagon markers:\n{out}"
);
}
#[test]
fn asymmetric_node_renders() {
let out = render("graph LR; A>Async]").unwrap();
assert!(out.contains("Async"), "missing label:\n{out}");
assert!(out.contains('⟩'), "no asymmetric marker:\n{out}");
}
#[test]
fn parallelogram_node_renders() {
let out = render("graph LR; A[/Parallel/]").unwrap();
assert!(out.contains("Parallel"), "missing label:\n{out}");
assert!(out.contains('/'), "no parallelogram slant marker:\n{out}");
}
#[test]
fn trapezoid_node_renders() {
let out = render("graph LR; A[/Trap\\]").unwrap();
assert!(out.contains("Trap"), "missing label:\n{out}");
assert!(out.contains('/'), "no trapezoid slant marker:\n{out}");
}
#[test]
fn double_circle_node_renders() {
let out = render("graph LR; A(((DblCircle)))").unwrap();
assert!(out.contains("DblCircle"), "missing label:\n{out}");
let corner_count = out.chars().filter(|&c| c == '╭').count();
assert!(
corner_count >= 2,
"expected ≥2 rounded corners for double circle, got {corner_count}:\n{out}"
);
}
#[test]
fn dotted_edge_renders_with_dotted_glyph() {
let out = render("graph LR; A-.->B").unwrap();
assert!(
out.contains('┄') || out.contains('┆'),
"no dotted glyph in:\n{out}"
);
}
#[test]
fn thick_edge_renders_with_thick_glyph() {
let out = render("graph LR; A==>B").unwrap();
assert!(
out.contains('━') || out.contains('┃'),
"no thick glyph in:\n{out}"
);
}
#[test]
fn bidirectional_edge_has_two_arrows() {
let out = render("graph LR; A<-->B").unwrap();
assert!(
out.contains('◂') && out.contains('▸'),
"missing bidirectional arrows in:\n{out}"
);
}
#[test]
fn plain_line_edge_has_no_arrow() {
let out = render("graph LR; A---B").unwrap();
assert!(
!out.contains('▸') && !out.contains('◂'),
"unexpected arrow in plain line:\n{out}"
);
}
#[test]
fn circle_endpoint_renders_circle_glyph() {
let out = render("graph LR; A--oB").unwrap();
assert!(out.contains('○'), "no circle endpoint glyph in:\n{out}");
}
#[test]
fn cross_endpoint_renders_cross_glyph() {
let out = render("graph LR; A--xB").unwrap();
assert!(out.contains('×'), "no cross endpoint glyph in:\n{out}");
}
#[test]
fn subgraph_renders_with_border_and_label() {
let src = r#"graph LR
subgraph Supervisor
F[Factory] --> W[Worker]
end"#;
let out = render(src).unwrap();
assert!(out.contains("Supervisor"), "missing label:\n{out}");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(
out.contains('╭') || out.contains('╰'),
"missing rounded subgraph corner:\n{out}"
);
assert!(out.contains('│'), "missing vertical border:\n{out}");
}
#[test]
fn nested_subgraphs_render() {
let src = r#"graph TD
subgraph Outer
subgraph Inner
A[A]
end
B[B]
end"#;
let out = render(src).unwrap();
assert!(out.contains("Outer"), "missing Outer label:\n{out}");
assert!(out.contains("Inner"), "missing Inner label:\n{out}");
assert!(out.contains('A'), "missing A:\n{out}");
assert!(out.contains('B'), "missing B:\n{out}");
let corner_count = out.chars().filter(|&c| c == '╭').count();
assert!(
corner_count >= 2,
"expected at least 2 top-left rounded corners (one per subgraph), got {corner_count}:\n{out}"
);
}
#[test]
fn html_br_in_label_creates_multi_row_node() {
let out =
render(r#"graph LR; A[first line<br/>second line<br/>third line] --> B[End]"#).unwrap();
assert!(out.contains("first line"), "line 1 missing:\n{out}");
assert!(out.contains("second line"), "line 2 missing:\n{out}");
assert!(out.contains("third line"), "line 3 missing:\n{out}");
let row_of = |needle: &str| -> usize {
out.lines()
.position(|l| l.contains(needle))
.unwrap_or_else(|| panic!("label '{needle}' not found in:\n{out}"))
};
assert!(
row_of("first line") < row_of("second line"),
"line ordering wrong:\n{out}",
);
assert!(
row_of("second line") < row_of("third line"),
"line ordering wrong:\n{out}",
);
}
#[test]
fn long_label_without_br_is_soft_wrapped() {
let long = "alpha, beta, gamma, delta, epsilon, zeta, eta, theta";
let src = format!("graph LR; A[{long}] --> B[End]");
let out = render(&src).unwrap();
for tok in [
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
] {
assert!(out.contains(tok), "missing '{tok}' in:\n{out}");
}
let max_w = out
.lines()
.map(unicode_width::UnicodeWidthStr::width)
.max()
.unwrap_or(0);
assert!(
max_w < long.len() + 20,
"soft-wrap didn't shrink the diagram (max row={max_w}, raw label={}):\n{out}",
long.len(),
);
}
#[test]
fn sibling_subgraphs_do_not_overlap() {
let src = r#"graph LR
subgraph A
A1[a-one]
end
subgraph B
B1[b-one]
end
subgraph C
C1[c-one]
end
A1 --> X[External]
B1 --> X
C1 --> X"#;
let out = render(src).unwrap();
let row_of = |label: &str| -> usize {
out.lines()
.enumerate()
.find_map(|(i, l)| if l.contains(label) { Some(i) } else { None })
.unwrap_or_else(|| panic!("label '{label}' not found in:\n{out}"))
};
let row_a = row_of("─A─");
let row_b = row_of("─B─");
let row_c = row_of("─C─");
assert!(
row_b >= row_a + 4,
"subgraphs A and B overlap: A header at row {row_a}, B header at row {row_b}\n{out}",
);
assert!(
row_c >= row_b + 4,
"subgraphs B and C overlap: B header at row {row_b}, C header at row {row_c}\n{out}",
);
}
#[test]
fn edge_crossing_subgraph_boundary_renders() {
let src = r#"graph LR
subgraph S
F[Factory] --> W[Worker]
end
W --> HB[Heartbeat]"#;
let out = render(src).unwrap();
assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(out.contains('╭'), "missing subgraph border:\n{out}");
}
#[test]
fn subgraph_keywords_not_leaked_as_labels() {
let src = r#"graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics/exits| F
end
W -->|beat| HB[Heartbeat]"#;
let out = render(src).expect("should render");
assert!(out.contains("Factory"), "missing Factory:\n{out}");
assert!(out.contains("Worker"), "missing Worker:\n{out}");
assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
assert!(
!out.contains("subgraph"),
"bare 'subgraph' keyword leaked into output:\n{out}"
);
assert!(
!out.contains("direction"),
"bare 'direction' keyword leaked into output:\n{out}"
);
}
#[test]
fn sequence_parse_minimal() {
let src = "sequenceDiagram\nA->>B: hi";
let diag = parser::sequence::parse(src).unwrap();
assert_eq!(diag.participants.len(), 2, "expected 2 participants");
assert_eq!(diag.messages.len(), 1, "expected 1 message");
}
#[test]
fn sequence_parse_explicit_participants_with_aliases() {
let src = "sequenceDiagram\nparticipant W as Worker\nparticipant S as Server";
let diag = parser::sequence::parse(src).unwrap();
assert_eq!(diag.participants[0].label, "Worker");
assert_eq!(diag.participants[1].label, "Server");
}
#[test]
fn sequence_render_produces_participant_boxes() {
let src = "sequenceDiagram\nparticipant A as Alice\nparticipant B as Bob\nA->>B: Hello";
let out = render(src).unwrap();
assert!(out.contains("Alice"), "missing Alice in:\n{out}");
assert!(out.contains("Bob"), "missing Bob in:\n{out}");
}
#[test]
fn sequence_render_draws_lifelines() {
let out = render("sequenceDiagram\nA->>B: hi").unwrap();
assert!(out.contains('┆'), "missing lifeline in:\n{out}");
}
#[test]
fn sequence_render_solid_arrow() {
let out = render("sequenceDiagram\nA->>B: go").unwrap();
assert!(out.contains('▸'), "no solid arrowhead in:\n{out}");
}
#[test]
fn sequence_render_dashed_arrow() {
let out = render("sequenceDiagram\nA-->>B: back").unwrap();
assert!(out.contains('┄'), "no dashed glyph in:\n{out}");
}
#[test]
fn sequence_render_message_order_top_to_bottom() {
let out = render("sequenceDiagram\nA->>B: first\nB->>A: second").unwrap();
let first_row = out
.lines()
.position(|l| l.contains("first"))
.expect("'first' not found");
let second_row = out
.lines()
.position(|l| l.contains("second"))
.expect("'second' not found");
assert!(
first_row < second_row,
"'first' must appear above 'second':\n{out}"
);
}
#[test]
fn unknown_diagram_types_still_error() {
let err = render("journey\ntitle Onboarding\nsection sign-up\nfill out form: 5: User")
.unwrap_err();
assert!(
matches!(err, Error::UnsupportedDiagram(_)),
"expected UnsupportedDiagram, got {err:?}"
);
}
#[test]
fn render_existing_flowchart_unchanged() {
let out = render("graph LR; A-->B").unwrap();
assert!(out.contains('A'), "missing A in:\n{out}");
assert!(out.contains('B'), "missing B in:\n{out}");
assert!(
out.contains('▸') || out.contains('-'),
"no arrow in:\n{out}"
);
}
#[test]
fn subgraph_perpendicular_direction_lr_in_td() {
let src = r#"graph TD
subgraph Pipeline
direction LR
A[Input] --> B[Process] --> C[Output]
end
C --> D[Finish]"#;
let out = render(src).unwrap();
assert!(out.contains("Input"), "missing Input:\n{out}");
assert!(out.contains("Process"), "missing Process:\n{out}");
assert!(out.contains("Output"), "missing Output:\n{out}");
assert!(out.contains("Finish"), "missing Finish:\n{out}");
let row_of = |needle: &str| -> usize {
out.lines()
.position(|l| l.contains(needle))
.expect("label not found")
};
assert_eq!(
row_of("Input"),
row_of("Process"),
"Input/Process should share a row in LR subgraph:\n{out}"
);
assert_eq!(
row_of("Process"),
row_of("Output"),
"Process/Output should share a row in LR subgraph:\n{out}"
);
}
#[test]
fn subgraph_same_direction_as_parent_unchanged() {
let a = render(
r#"graph LR
subgraph S
direction LR
A-->B
end"#,
)
.unwrap();
let b = render(
r#"graph LR
subgraph S
A-->B
end"#,
)
.unwrap();
assert_eq!(
a, b,
"direction LR inside graph LR should match default\nA:\n{a}\nB:\n{b}"
);
}
#[test]
fn subgraph_inherits_when_no_direction() {
let out = render(
r#"graph TD
subgraph S
A-->B-->C
end"#,
)
.unwrap();
let row_of = |needle: &str| -> usize {
out.lines()
.position(|l| l.contains(needle))
.expect("label not found")
};
assert!(
row_of("A") < row_of("B"),
"A should be above B in TD:\n{out}"
);
assert!(
row_of("B") < row_of("C"),
"B should be above C in TD:\n{out}"
);
}
#[test]
fn ascii_render_has_no_unicode_box_chars() {
let out = render_ascii("graph LR; A[Hello] --> B[World]").unwrap();
for ch in out.chars() {
assert!(ch.is_ascii(), "non-ASCII char {ch:?} in output:\n{out}");
}
}
#[test]
fn ascii_render_preserves_labels() {
let out = render_ascii("graph LR; A[Cargo] --> B[Deploy]").unwrap();
assert!(out.contains("Cargo"), "label 'Cargo' missing in:\n{out}");
assert!(out.contains("Deploy"), "label 'Deploy' missing in:\n{out}");
}
#[test]
fn ascii_render_uses_plus_for_corners() {
let rect_out = render_ascii("graph LR; A[Rect]").unwrap();
let rounded_out = render_ascii("graph LR; A(Round)").unwrap();
assert!(
rect_out.contains('+'),
"expected '+' for box corners in:\n{rect_out}"
);
assert!(
rounded_out.contains('+'),
"expected '+' for rounded corners in:\n{rounded_out}"
);
for ch in rect_out.chars().chain(rounded_out.chars()) {
assert!(
ch.is_ascii(),
"non-ASCII char {ch:?} leaked through to_ascii"
);
}
}
#[test]
fn ascii_arrow_tips_use_gt_lt_v_caret() {
let lr = render_ascii("graph LR; A-->B").unwrap();
assert!(lr.contains('>'), "expected '>' for LR arrow in:\n{lr}");
let td = render_ascii("graph TD; A-->B").unwrap();
assert!(td.contains('v'), "expected 'v' for TD arrow in:\n{td}");
let bt = render_ascii("graph BT; A-->B").unwrap();
assert!(bt.contains('^'), "expected '^' for BT arrow in:\n{bt}");
let bidi = render_ascii("graph LR; A<-->B").unwrap();
assert!(bidi.contains('<'), "expected '<' for back-tip in:\n{bidi}");
}
#[test]
fn ascii_render_with_width_compacts() {
let out = render_ascii_with_width(
"graph LR; A[Alpha]-->B[Bravo]-->C[Charlie]-->D[Delta]",
Some(60),
)
.unwrap();
assert!(out.contains("Alpha"), "label missing in:\n{out}");
assert!(
out.is_ascii(),
"non-ASCII char in width-constrained ASCII output:\n{out}"
);
}
#[test]
fn back_edge_lr_exits_bottom() {
let out = render("graph LR; A-->B; B-->A").unwrap();
assert!(out.contains('A'), "missing A in:\n{out}");
assert!(out.contains('B'), "missing B in:\n{out}");
assert!(
out.contains('▴'),
"no up-arrow tip for LR back-edge in:\n{out}"
);
assert!(
out.contains('▸'),
"no right-arrow tip for LR forward edge in:\n{out}"
);
let lines: Vec<&str> = out.lines().collect();
let bottom_border_row = lines
.iter()
.position(|l| l.contains('└'))
.expect("no `└` corner found");
assert!(
lines[bottom_border_row].contains('▴'),
"LR back-edge ▴ should land on the destination box's bottom border row \
(the line with `└`), got line {bottom_border_row}:\n{out}"
);
}
#[test]
fn back_edge_td_exits_right() {
let out = render("graph TD; A-->B; B-->A").unwrap();
assert!(out.contains('A'), "missing A in:\n{out}");
assert!(out.contains('B'), "missing B in:\n{out}");
assert!(
out.contains('◂'),
"no left-arrow tip for TD back-edge in:\n{out}"
);
assert!(
out.contains('▾'),
"no down-arrow tip for TD forward edge in:\n{out}"
);
for (i, line) in out.lines().enumerate() {
if let Some(arrow_col) = line.chars().position(|c| c == '◂') {
let last_box_col = line
.chars()
.enumerate()
.filter(|(_, c)| matches!(*c, '┘' | '┐' | '│'))
.map(|(col, _)| col)
.max()
.unwrap_or(0);
assert!(
arrow_col > last_box_col,
"TD back-edge ◂ at row {i} col {arrow_col} is not to the right of box col {last_box_col}:\n{line}\nfull:\n{out}"
);
}
}
}
#[test]
fn supervisor_worker_diagram_back_edge() {
let src = "graph LR\nF[Factory]-->|creates|W[Worker]\nW-->|panics/exits|F";
let out = render(src).unwrap();
assert!(out.contains("Factory"), "missing 'Factory' in:\n{out}");
assert!(out.contains("Worker"), "missing 'Worker' in:\n{out}");
assert!(
out.contains("creates"),
"missing 'creates' label in:\n{out}"
);
assert!(
out.contains("panics/exits"),
"missing 'panics/exits' label in:\n{out}"
);
assert!(
out.contains('▴'),
"no ▴ tip for Worker→Factory back-edge in:\n{out}"
);
}
#[test]
fn forward_edges_unchanged() {
let out = render("graph LR; A-->B-->C").unwrap();
assert!(out.contains('A'), "missing A in:\n{out}");
assert!(out.contains('B'), "missing B in:\n{out}");
assert!(out.contains('C'), "missing C in:\n{out}");
assert!(
out.contains('▸'),
"no ▸ tip in forward-only LR graph:\n{out}"
);
assert!(
!out.contains('▴'),
"unexpected ▴ in forward-only LR graph:\n{out}"
);
}
}