use unicode_width::UnicodeWidthStr;
use crate::packet::{Packet, PacketField};
const BITS_PER_ROW: u32 = 32;
pub fn render(diag: &Packet, max_width: Option<usize>) -> String {
if diag.fields.is_empty() {
return "(empty packet diagram)".to_string();
}
let cell_w: usize = if let Some(budget) = max_width {
let max_cell = budget.saturating_sub(1) / BITS_PER_ROW as usize;
let max_cell = max_cell.saturating_sub(1);
max_cell.max(1)
} else {
2
};
let total_bits = diag.total_bits();
let total_rows = total_bits.max(1).div_ceil(BITS_PER_ROW);
let mut out = String::new();
if let Some(title) = &diag.title {
out.push_str(title);
out.push('\n');
out.push('\n');
}
let total_bit_slots = total_rows * BITS_PER_ROW;
let mut field_index: Vec<Option<usize>> = vec![None; total_bit_slots as usize];
for (idx, f) in diag.fields.iter().enumerate() {
for bit in f.start_bit..=f.end_bit {
if (bit as usize) < field_index.len() {
field_index[bit as usize] = Some(idx);
}
}
}
for row in 0..total_rows {
let row_start = row * BITS_PER_ROW;
let row_end = row_start + BITS_PER_ROW - 1;
out.push_str(&render_ruler(row_start, row_end, cell_w));
out.push('\n');
let segments = collect_row_segments(&field_index, &diag.fields, row_start, row_end);
out.push_str(&render_top_border(&segments, cell_w, row == 0));
out.push('\n');
out.push_str(&render_content_line(&segments, &diag.fields, cell_w));
out.push('\n');
if row + 1 == total_rows {
out.push_str(&render_bottom_border(&segments, cell_w));
out.push('\n');
}
}
while out.ends_with('\n') {
out.pop();
}
out
}
#[derive(Debug)]
struct Segment {
start_bit: u32,
end_bit: u32,
field_idx: Option<usize>,
is_first_fragment: bool,
}
impl Segment {
fn bit_width(&self) -> u32 {
self.end_bit - self.start_bit + 1
}
fn inner_width(&self, cell_w: usize) -> usize {
self.bit_width() as usize * cell_w + self.bit_width() as usize - 1
}
}
fn collect_row_segments(
field_index: &[Option<usize>],
fields: &[PacketField],
row_start: u32,
row_end: u32,
) -> Vec<Segment> {
let mut segments: Vec<Segment> = Vec::new();
let mut bit = row_start;
while bit <= row_end {
let fi = field_index[bit as usize];
let mut end = bit;
while end < row_end && field_index[(end + 1) as usize] == fi {
end += 1;
}
let is_first_fragment = match fi {
None => false,
Some(idx) => fields[idx].start_bit == bit,
};
segments.push(Segment {
start_bit: bit,
end_bit: end,
field_idx: fi,
is_first_fragment,
});
bit = end + 1;
}
segments
}
fn render_ruler(row_start: u32, row_end: u32, cell_w: usize) -> String {
let width = (BITS_PER_ROW as usize) * (cell_w + 1) + 1;
let mut buf = vec![b' '; width];
let print_bit_label = |buf: &mut Vec<u8>, bit: u32| {
let col = 1 + (bit - row_start) as usize * (cell_w + 1);
let label = bit.to_string();
for (i, ch) in label.bytes().enumerate() {
if col + i < buf.len() {
buf[col + i] = ch;
}
}
};
print_bit_label(&mut buf, row_start);
let mid_bit = row_start + BITS_PER_ROW / 2;
if mid_bit <= row_end && cell_w >= 2 {
print_bit_label(&mut buf, mid_bit);
}
{
let last_label = row_end.to_string();
let last_col = 1 + (BITS_PER_ROW - 1) as usize * (cell_w + 1);
let label_bytes = last_label.as_bytes();
let start = last_col + cell_w - label_bytes.len().min(cell_w);
for (i, &ch) in label_bytes.iter().enumerate() {
if start + i < buf.len() {
buf[start + i] = ch;
}
}
}
String::from_utf8(buf)
.unwrap_or_default()
.trim_end()
.to_string()
}
fn render_top_border(segments: &[Segment], cell_w: usize, is_first_row: bool) -> String {
let mut line = String::new();
if is_first_row {
line.push('\u{250C}'); } else {
line.push('\u{251C}'); }
for (i, seg) in segments.iter().enumerate() {
let inner = seg.inner_width(cell_w);
for _ in 0..inner {
line.push('\u{2500}'); }
if i + 1 < segments.len() {
if is_first_row {
line.push('\u{252C}'); } else {
line.push('\u{253C}'); }
}
}
if is_first_row {
line.push('\u{2510}'); } else {
line.push('\u{2524}'); }
line
}
fn render_bottom_border(segments: &[Segment], cell_w: usize) -> String {
let mut line = String::new();
line.push('\u{2514}');
for (i, seg) in segments.iter().enumerate() {
let inner = seg.inner_width(cell_w);
for _ in 0..inner {
line.push('\u{2500}'); }
if i + 1 < segments.len() {
line.push('\u{2534}'); }
}
line.push('\u{2518}'); line
}
fn render_content_line(segments: &[Segment], fields: &[PacketField], cell_w: usize) -> String {
let mut line = String::new();
line.push('\u{2502}');
for seg in segments {
let inner = seg.inner_width(cell_w);
let label = match seg.field_idx {
None => String::new(),
Some(idx) if seg.is_first_fragment => fields[idx].label.clone(),
Some(_) => String::new(), };
let label = fit_label(&label, inner);
let label_w = UnicodeWidthStr::width(label.as_str());
let total_pad = inner.saturating_sub(label_w);
let left_pad = total_pad / 2;
let right_pad = total_pad - left_pad;
for _ in 0..left_pad {
line.push(' ');
}
line.push_str(&label);
for _ in 0..right_pad {
line.push(' ');
}
line.push('\u{2502}'); }
line
}
fn fit_label(label: &str, max_w: usize) -> String {
if max_w == 0 || label.is_empty() {
return String::new();
}
let w = UnicodeWidthStr::width(label);
if w <= max_w {
return label.to_string();
}
let target = max_w.saturating_sub(1);
if target == 0 {
return "\u{2026}".to_string(); }
let mut result = String::new();
let mut used = 0usize;
for ch in label.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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::packet::parse;
fn parsed(src: &str) -> Packet {
parse(src).expect("parse should succeed")
}
#[test]
fn title_appears_in_output() {
let diag = parsed("packet-beta\n title My Header\n 0-31: \"Data\"");
let out = render(&diag, None);
assert!(
out.contains("My Header"),
"title must appear in output:\n{out}"
);
}
#[test]
fn single_row_32_bit_fields_render() {
let diag =
parsed("packet-beta\n 0-15: \"Source Port\"\n 16-31: \"Destination Port\"");
let out = render(&diag, None);
assert!(out.contains("Source Port"), "Source Port missing:\n{out}");
assert!(
out.contains("Destination Port"),
"Destination Port missing:\n{out}"
);
assert!(
out.contains('\u{250C}'),
"top-left corner ┌ missing:\n{out}"
);
assert!(
out.contains('\u{2510}'),
"top-right corner ┐ missing:\n{out}"
);
assert!(
out.contains('\u{2514}'),
"bottom-left corner └ missing:\n{out}"
);
assert!(
out.contains('\u{2518}'),
"bottom-right corner ┘ missing:\n{out}"
);
assert!(out.contains('\u{252C}'), "top divider ┬ missing:\n{out}");
}
#[test]
fn multi_row_field_label_appears_in_first_row_only() {
let diag = parsed("packet-beta\n 0-63: \"Sequence Number\"");
let out = render(&diag, None);
assert!(out.contains("Sequence Number"), "label must appear:\n{out}");
let occurrences = out.matches("Sequence Number").count();
assert_eq!(
occurrences, 1,
"label should appear exactly once (first fragment only):\n{out}"
);
assert!(
out.contains('\u{251C}'),
"row continuation ├ missing:\n{out}"
);
}
#[test]
fn empty_diagram_renders_placeholder() {
let diag = Packet {
title: None,
fields: vec![],
};
let out = render(&diag, None);
assert!(
out.contains("empty packet diagram"),
"placeholder missing:\n{out}"
);
}
#[test]
fn single_bit_field_renders_without_panic() {
let diag = parsed("packet-beta\n 0-30: \"Data\"\n 31: \"Flag\"");
let out = render(&diag, None);
assert!(out.contains("Data"), "Data field missing:\n{out}");
let has_flag = out.contains("Flag") || out.contains('\u{2026}');
assert!(has_flag, "Flag or ellipsis missing:\n{out}");
}
#[test]
fn max_width_does_not_panic() {
let diag = parsed("packet-beta\n 0-31: \"Header\"");
let out = render(&diag, Some(40));
assert!(out.contains('\u{250C}'), "box must still render:\n{out}");
}
}