use std::collections::HashMap;
use unicode_width::UnicodeWidthStr;
use crate::block_diagram::{Block, BlockDiagram, BlockEdge};
const COL_GAP: usize = 1;
const MIN_CELL_INNER: usize = 1;
pub fn render(diag: &BlockDiagram, max_width: Option<usize>) -> String {
if diag.blocks.is_empty() {
let unrouted: Vec<&BlockEdge> = diag.edges.iter().collect();
return render_edge_summary(&unrouted);
}
let cols = diag.columns.max(1);
let placements = compute_placements(&diag.blocks, cols);
let col_inner_widths = compute_col_widths(&diag.blocks, &placements, cols);
let col_inner_widths = apply_max_width(col_inner_widths, max_width, cols);
let block_pos_map = build_block_pos_map(diag, &placements);
let row_count = placements.iter().map(|p| p.row + 1).max().unwrap_or(0);
let mut out = String::new();
for row in 0..row_count {
let row_slots = collect_row_slots(&placements, &diag.blocks, row);
out.push_str(&build_top_line(&row_slots, &col_inner_widths));
out.push('\n');
let h_arrows = collect_horizontal_arrows(diag, &block_pos_map, &row_slots, &col_inner_widths, row);
out.push_str(&build_content_line(&row_slots, &col_inner_widths, &h_arrows));
out.push('\n');
out.push_str(&build_bottom_line(&row_slots, &col_inner_widths));
out.push('\n');
if row + 1 < row_count {
let v_arrows = collect_vertical_arrows(diag, &block_pos_map, &row_slots, &col_inner_widths, row);
let gap = build_gap_line(&v_arrows);
out.push_str(&gap);
out.push('\n');
}
}
let unrouted = collect_unrouted_edges(diag, &block_pos_map);
if !unrouted.is_empty() {
out.push('\n');
out.push_str(&render_edge_summary(&unrouted));
}
while out.ends_with('\n') {
out.pop();
}
out
}
#[derive(Debug)]
struct Placement {
block_idx: usize,
row: usize,
col_start: usize,
col_end: usize,
}
fn compute_placements(blocks: &[Block], cols: usize) -> Vec<Placement> {
let mut placements = Vec::with_capacity(blocks.len());
let mut row = 0usize;
let mut col = 0usize;
for (idx, block) in blocks.iter().enumerate() {
let span = block.col_span.min(cols).max(1);
if col + span > cols && col > 0 {
row += 1;
col = 0;
}
placements.push(Placement { block_idx: idx, row, col_start: col, col_end: col + span });
col += span;
if col >= cols {
row += 1;
col = 0;
}
}
placements
}
fn compute_col_widths(blocks: &[Block], placements: &[Placement], cols: usize) -> Vec<usize> {
let mut col_widths = vec![MIN_CELL_INNER; cols];
for p in placements {
let block = &blocks[p.block_idx];
let lw = UnicodeWidthStr::width(block.display_text());
let span = p.col_end - p.col_start;
if span == 1 {
col_widths[p.col_start] = col_widths[p.col_start].max(lw);
} else {
let gap_absorbed = (span - 1) * (COL_GAP + 2);
let needed = lw.saturating_sub(gap_absorbed).div_ceil(span).max(MIN_CELL_INNER);
for w in col_widths.iter_mut().take(p.col_end).skip(p.col_start) {
*w = (*w).max(needed);
}
}
}
col_widths
}
fn apply_max_width(mut col_widths: Vec<usize>, max_width: Option<usize>, cols: usize) -> Vec<usize> {
let Some(budget) = max_width else { return col_widths; };
if grid_natural_width(&col_widths, cols) <= budget {
return col_widths;
}
for _ in 0..100 {
if grid_natural_width(&col_widths, cols) <= budget {
break;
}
let max_w = *col_widths.iter().max().unwrap_or(&MIN_CELL_INNER);
if max_w <= MIN_CELL_INNER {
break;
}
for w in &mut col_widths {
if *w == max_w {
*w -= 1;
break;
}
}
}
col_widths
}
fn grid_natural_width(col_widths: &[usize], cols: usize) -> usize {
if cols == 0 { return 0; }
col_widths.iter().take(cols).sum::<usize>() + cols * 2 + (cols - 1) * COL_GAP
}
fn spanned_inner_width(col_widths: &[usize], col_start: usize, col_end: usize) -> usize {
let span = col_end - col_start;
let base: usize = col_widths[col_start..col_end.min(col_widths.len())].iter().sum();
if span <= 1 { base } else { base + (span - 1) * (COL_GAP + 2) }
}
fn truncate_to_width(s: &str, max_w: usize) -> String {
if max_w == 0 { return String::new(); }
if UnicodeWidthStr::width(s) <= max_w { return s.to_string(); }
let target = max_w.saturating_sub(1);
let mut result = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw > target { break; }
result.push(ch);
used += cw;
}
result.push('\u{2026}');
result
}
struct RowSlot<'d> {
col_start: usize,
col_end: usize,
block: &'d Block,
}
fn collect_row_slots<'d>(placements: &[Placement], blocks: &'d [Block], row: usize) -> Vec<RowSlot<'d>> {
let mut slots: Vec<RowSlot<'d>> = placements
.iter()
.filter(|p| p.row == row)
.map(|p| RowSlot { col_start: p.col_start, col_end: p.col_end, block: &blocks[p.block_idx] })
.collect();
slots.sort_by_key(|s| s.col_start);
slots
}
#[derive(Clone, Copy, Debug)]
struct BlockGridPos {
row: usize,
col_start: usize,
col_end: usize,
}
fn build_block_pos_map(diag: &BlockDiagram, placements: &[Placement]) -> HashMap<String, BlockGridPos> {
placements
.iter()
.map(|p| {
let id = diag.blocks[p.block_idx].id.clone();
(id, BlockGridPos { row: p.row, col_start: p.col_start, col_end: p.col_end })
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Adjacency {
HorizontalForward,
HorizontalReverse,
VerticalForward,
VerticalReverse,
NotAdjacent,
}
fn detect_adjacency(src: BlockGridPos, tgt: BlockGridPos) -> Adjacency {
if src.row == tgt.row {
if src.col_end == tgt.col_start { return Adjacency::HorizontalForward; }
if tgt.col_end == src.col_start { return Adjacency::HorizontalReverse; }
}
let cols_overlap = src.col_start < tgt.col_end && tgt.col_start < src.col_end;
if cols_overlap {
if src.row + 1 == tgt.row { return Adjacency::VerticalForward; }
if tgt.row + 1 == src.row { return Adjacency::VerticalReverse; }
}
Adjacency::NotAdjacent
}
struct HorizontalArrow {
gap_char_idx: usize,
glyph: char,
}
fn collect_horizontal_arrows(
diag: &BlockDiagram,
block_pos_map: &HashMap<String, BlockGridPos>,
row_slots: &[RowSlot<'_>],
col_inner_widths: &[usize],
row: usize,
) -> Vec<HorizontalArrow> {
let gap_map = build_gap_char_map(row_slots, col_inner_widths);
let mut arrows = Vec::new();
for edge in &diag.edges {
let Some(src) = block_pos_map.get(&edge.source) else { continue; };
let Some(tgt) = block_pos_map.get(&edge.target) else { continue; };
if src.row != row || tgt.row != row { continue; }
match detect_adjacency(*src, *tgt) {
Adjacency::HorizontalForward => {
if let Some(idx) = gap_map.get(&(src.col_end, tgt.col_start)) {
arrows.push(HorizontalArrow { gap_char_idx: *idx, glyph: '\u{25BA}' }); }
}
Adjacency::HorizontalReverse => {
if let Some(idx) = gap_map.get(&(tgt.col_end, src.col_start)) {
arrows.push(HorizontalArrow { gap_char_idx: *idx, glyph: '\u{25C4}' }); }
}
_ => {}
}
}
arrows
}
fn build_gap_char_map(
row_slots: &[RowSlot<'_>],
col_inner_widths: &[usize],
) -> HashMap<(usize, usize), usize> {
let mut map = HashMap::new();
let mut char_x = 0usize;
for (i, slot) in row_slots.iter().enumerate() {
let inner_w = spanned_inner_width(col_inner_widths, slot.col_start, slot.col_end);
let box_char_width = inner_w + 4;
if i + 1 < row_slots.len() {
let gap_char_idx = char_x + box_char_width; let next = &row_slots[i + 1];
map.insert((slot.col_end, next.col_start), gap_char_idx);
}
char_x += box_char_width + COL_GAP;
}
map
}
struct VerticalArrow {
centre_x: usize,
glyph: char,
}
fn collect_vertical_arrows(
diag: &BlockDiagram,
block_pos_map: &HashMap<String, BlockGridPos>,
upper_row_slots: &[RowSlot<'_>],
col_inner_widths: &[usize],
upper_row: usize,
) -> Vec<VerticalArrow> {
let centre_map = build_slot_centre_map(upper_row_slots, col_inner_widths);
let mut arrows = Vec::new();
for edge in &diag.edges {
let Some(src) = block_pos_map.get(&edge.source) else { continue; };
let Some(tgt) = block_pos_map.get(&edge.target) else { continue; };
let (above, glyph) = match detect_adjacency(*src, *tgt) {
Adjacency::VerticalForward => (*src, '\u{25BC}'), Adjacency::VerticalReverse => (*tgt, '\u{25B2}'), _ => continue,
};
if above.row != upper_row { continue; }
if let Some(centre_x) = centre_map.get(&(above.col_start, above.col_end)) {
arrows.push(VerticalArrow { centre_x: *centre_x, glyph });
}
}
arrows
}
fn build_slot_centre_map(
row_slots: &[RowSlot<'_>],
col_inner_widths: &[usize],
) -> HashMap<(usize, usize), usize> {
let mut map = HashMap::new();
let mut char_x = 0usize;
for slot in row_slots {
let inner_w = spanned_inner_width(col_inner_widths, slot.col_start, slot.col_end);
let centre = char_x + 1 + 1 + inner_w / 2;
map.insert((slot.col_start, slot.col_end), centre);
char_x += inner_w + 4 + COL_GAP;
}
map
}
fn build_top_line(row_slots: &[RowSlot<'_>], col_inner_widths: &[usize]) -> String {
let mut line = String::new();
let mut col_cursor = 0usize;
for slot in row_slots {
if slot.col_start > col_cursor {
let gap = (slot.col_start - col_cursor) * (MIN_CELL_INNER + 2 + COL_GAP);
for _ in 0..gap { line.push(' '); }
}
let inner_w = spanned_inner_width(col_inner_widths, slot.col_start, slot.col_end);
line.push('\u{250C}'); for _ in 0..inner_w + 2 { line.push('\u{2500}'); } line.push('\u{2510}'); col_cursor = slot.col_end;
for _ in 0..COL_GAP { line.push(' '); }
}
line.trim_end().to_string()
}
fn build_bottom_line(row_slots: &[RowSlot<'_>], col_inner_widths: &[usize]) -> String {
let mut line = String::new();
let mut col_cursor = 0usize;
for slot in row_slots {
if slot.col_start > col_cursor {
let gap = (slot.col_start - col_cursor) * (MIN_CELL_INNER + 2 + COL_GAP);
for _ in 0..gap { line.push(' '); }
}
let inner_w = spanned_inner_width(col_inner_widths, slot.col_start, slot.col_end);
line.push('\u{2514}'); for _ in 0..inner_w + 2 { line.push('\u{2500}'); } line.push('\u{2518}'); col_cursor = slot.col_end;
for _ in 0..COL_GAP { line.push(' '); }
}
line.trim_end().to_string()
}
fn build_content_line(
row_slots: &[RowSlot<'_>],
col_inner_widths: &[usize],
h_arrows: &[HorizontalArrow],
) -> String {
let mut buf = String::new();
let mut col_cursor = 0usize;
for slot in row_slots {
if slot.col_start > col_cursor {
let gap = (slot.col_start - col_cursor) * (MIN_CELL_INNER + 2 + COL_GAP);
for _ in 0..gap { buf.push(' '); }
}
let inner_w = spanned_inner_width(col_inner_widths, slot.col_start, slot.col_end);
let label = slot.block.display_text();
let label_w = UnicodeWidthStr::width(label);
let label = if label_w > inner_w {
truncate_to_width(label, inner_w)
} else {
label.to_string()
};
let label_w = UnicodeWidthStr::width(label.as_str());
let total_pad = inner_w.saturating_sub(label_w);
let left_pad = total_pad / 2;
let right_pad = total_pad - left_pad;
buf.push('\u{2502}'); buf.push(' ');
for _ in 0..left_pad { buf.push(' '); }
buf.push_str(&label);
for _ in 0..right_pad { buf.push(' '); }
buf.push(' ');
buf.push('\u{2502}');
col_cursor = slot.col_end;
for _ in 0..COL_GAP { buf.push(' '); }
}
if h_arrows.is_empty() {
return buf.trim_end().to_string();
}
let mut chars: Vec<char> = buf.chars().collect();
for arrow in h_arrows {
if arrow.gap_char_idx < chars.len() {
chars[arrow.gap_char_idx] = arrow.glyph;
}
}
while chars.last() == Some(&' ') { chars.pop(); }
chars.into_iter().collect()
}
fn build_gap_line(v_arrows: &[VerticalArrow]) -> String {
if v_arrows.is_empty() {
return String::new();
}
let max_x = v_arrows.iter().map(|a| a.centre_x).max().unwrap_or(0);
let mut chars: Vec<char> = vec![' '; max_x + 1];
for arrow in v_arrows {
chars[arrow.centre_x] = arrow.glyph;
}
while chars.last() == Some(&' ') { chars.pop(); }
chars.into_iter().collect()
}
fn collect_unrouted_edges<'a>(
diag: &'a BlockDiagram,
block_pos_map: &HashMap<String, BlockGridPos>,
) -> Vec<&'a BlockEdge> {
diag.edges
.iter()
.filter(|e| {
let Some(src) = block_pos_map.get(&e.source) else { return true; };
let Some(tgt) = block_pos_map.get(&e.target) else { return true; };
detect_adjacency(*src, *tgt) == Adjacency::NotAdjacent
})
.collect()
}
fn render_edge_summary(edges: &[&BlockEdge]) -> String {
if edges.is_empty() { return String::new(); }
let mut out = String::from("Edges:\n");
for edge in edges {
if let Some(label) = &edge.label {
out.push_str(&format!(
" {} \u{2500}\u{2500}\u{25BA} {} [{}]\n",
edge.source, edge.target, label
));
} else {
out.push_str(&format!(
" {} \u{2500}\u{2500}\u{25BA} {}\n",
edge.source, edge.target
));
}
}
while out.ends_with('\n') { out.pop(); }
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::block_diagram::parse;
fn parsed(src: &str) -> BlockDiagram {
parse(src).expect("parse should succeed")
}
#[test]
fn renders_single_block() {
let diag = parsed("block-beta\n A");
let out = render(&diag, None);
assert!(out.contains('A'), "block label 'A' must appear in output:\n{out}");
assert!(out.contains('\u{250C}'), "top-left corner ┌ must appear:\n{out}");
assert!(out.contains('\u{2518}'), "bottom-right corner ┘ must appear:\n{out}");
}
#[test]
fn renders_blocks_with_text_labels() {
let diag = parsed("block-beta\n columns 2\n a[\"Alpha\"] b[\"Beta\"]");
let out = render(&diag, None);
assert!(out.contains("Alpha"), "Alpha label missing:\n{out}");
assert!(out.contains("Beta"), "Beta label missing:\n{out}");
}
#[test]
fn horizontal_adjacent_edge_draws_inline_arrow() {
let diag = parsed("block-beta\n columns 2\n A B\n A --> B");
let out = render(&diag, None);
assert!(
out.contains('\u{25BA}'),
"inline ► arrow missing for horizontal adjacent edge:\n{out}"
);
assert!(
!out.contains("Edges:"),
"Edges: summary must be absent when edge is routed inline:\n{out}"
);
}
#[test]
fn vertical_adjacent_edge_draws_inline_arrow() {
let diag = parsed("block-beta\n columns 1\n A\n B\n A --> B");
let out = render(&diag, None);
assert!(
out.contains('\u{25BC}'),
"inline ▼ arrow missing for vertical adjacent edge:\n{out}"
);
assert!(
!out.contains("Edges:"),
"Edges: summary must be absent when edge is routed inline:\n{out}"
);
}
#[test]
fn non_adjacent_edge_falls_back_to_summary() {
let diag = parsed("block-beta\n columns 3\n A B C\n D E F\n A --> F");
let out = render(&diag, None);
assert!(
out.contains("Edges:"),
"non-adjacent edge must appear in Edges: summary:\n{out}"
);
}
#[test]
fn grid_integrity_preserved_with_edges() {
let diag = parsed(
"block-beta\n columns 3\n A B C\n D E F\n A --> B\n D --> E",
);
let out = render(&diag, None);
let corner_count: usize = out.chars().filter(|&c| c == '\u{250C}').count();
assert_eq!(
corner_count, 6,
"expected 6 ┌ corners for 6 blocks, got {corner_count}:\n{out}"
);
}
#[test]
fn empty_diagram_renders_without_panic() {
let diag = BlockDiagram::default();
let out = render(&diag, None);
assert!(!out.contains('\u{250C}'), "no box should be drawn for empty diagram");
}
#[test]
fn max_width_truncates_long_labels() {
let diag = parsed("block-beta\n a[\"This is a very long label that overflows\"]");
let out = render(&diag, Some(20));
for line in out.lines() {
let w = UnicodeWidthStr::width(line);
assert!(w <= 22, "line width {w} exceeds budget: {line:?}");
}
}
#[test]
fn spanning_block_renders_wider_box() {
let diag = parsed("block-beta\n columns 3\n a b:2 c\n d e f");
let out = render(&diag, None);
for id in &["a", "b", "c", "d", "e", "f"] {
assert!(out.contains(id), "block {id} missing from output:\n{out}");
}
let total_corners: usize = out
.lines()
.map(|l| l.chars().filter(|&c| c == '\u{250C}').count())
.sum();
assert!(
total_corners >= 6,
"expected ≥6 ┌ corners across all rows, got {total_corners}:\n{out}"
);
assert!(out.contains("b "), "b label with trailing space missing:\n{out}");
}
#[test]
fn labelled_edge_adjacent_draws_inline_arrow() {
let diag = parsed("block-beta\n columns 2\n A B\n A -->|calls| B");
let out = render(&diag, None);
assert!(out.contains('\u{25BA}'), "inline ► arrow missing:\n{out}");
}
#[test]
fn multi_row_grid_has_separator_lines() {
let diag = parsed("block-beta\n columns 1\n A\n B\n C");
let out = render(&diag, None);
let non_box_lines: Vec<&str> = out
.lines()
.filter(|l| !l.contains('\u{250C}') && !l.contains('\u{2502}') && !l.contains('\u{2514}'))
.collect();
assert!(
non_box_lines.len() >= 2,
"expected ≥2 separator lines between rows, got {}:\n{out}",
non_box_lines.len()
);
}
#[test]
fn reverse_horizontal_edge_draws_left_arrow() {
let diag = parsed("block-beta\n columns 2\n A B\n B --> A");
let out = render(&diag, None);
assert!(
out.contains('\u{25C4}'),
"inline ◄ arrow missing for reverse horizontal edge:\n{out}"
);
}
}