use straymark_core::architecture::ArchModel;
const MARGIN_X: i32 = 40;
const MARGIN_Y: i32 = 40;
const BOX_W: i32 = 200;
const BOX_H: i32 = 60;
const GAP_X: i32 = 40;
const GAP_Y: i32 = 80;
pub fn render_drawio(model: &ArchModel) -> String {
let mut cells = String::new();
let mut row = 0;
let mut layer_ids: Vec<&str> = Vec::new();
let mut ordered = model.layers.clone();
ordered.sort_by_key(|l| l.order);
for layer in &ordered {
layer_ids.push(&layer.id);
emit_row(
&mut cells,
row,
model.components.iter().filter(|c| c.layer == layer.id),
);
row += 1;
}
let orphans = model
.components
.iter()
.filter(|c| !layer_ids.contains(&c.layer.as_str()));
if orphans.clone().next().is_some() {
emit_row(&mut cells, row, orphans);
}
format!(
r#"<mxfile host="straymark" type="device">
<diagram name="Architecture" id="straymark-architecture">
<mxGraphModel dx="1024" dy="768" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="850" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
{cells} </root>
</mxGraphModel>
</diagram>
</mxfile>
"#
)
}
fn emit_row<'a>(out: &mut String, row: i32, components: impl Iterator<Item = &'a straymark_core::architecture::Component>) {
let y = MARGIN_Y + row * (BOX_H + GAP_Y);
for (col, comp) in components.enumerate() {
let x = MARGIN_X + col as i32 * (BOX_W + GAP_X);
out.push_str(&render_cell(comp, x, y));
}
}
pub fn render_cell(comp: &straymark_core::architecture::Component, x: i32, y: i32) -> String {
let id = xml_escape(&comp.id);
let label = xml_escape(&comp.label);
format!(
r#" <object label="{label}" straymark_component_id="{id}" id="comp-{id}">
<mxCell style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="{x}" y="{y}" width="{BOX_W}" height="{BOX_H}" as="geometry" />
</mxCell>
</object>
"#
)
}
pub fn append_cells(
existing_xml: &str,
components: &[straymark_core::architecture::Component],
) -> Option<String> {
if components.is_empty() {
return Some(existing_xml.to_string());
}
let anchor = existing_xml.rfind("</root>")?;
let y = match max_y(existing_xml) {
Some(prev) => prev + BOX_H + GAP_Y,
None => MARGIN_Y,
};
let mut cells = String::new();
for (col, comp) in components.iter().enumerate() {
let x = MARGIN_X + col as i32 * (BOX_W + GAP_X);
cells.push_str(&render_cell(comp, x, y));
}
let mut out = String::with_capacity(existing_xml.len() + cells.len());
out.push_str(&existing_xml[..anchor]);
out.push_str(&cells);
out.push_str(&existing_xml[anchor..]);
Some(out)
}
pub fn max_y(xml: &str) -> Option<i32> {
let mut best: Option<i32> = None;
let mut rest = xml;
while let Some(i) = rest.find("y=\"") {
rest = &rest[i + 3..];
if let Some(end) = rest.find('"') {
if let Ok(v) = rest[..end].parse::<i32>() {
best = Some(best.map_or(v, |b| b.max(v)));
}
rest = &rest[end + 1..];
} else {
break;
}
}
best
}
pub fn parse_component_ids(xml: &str) -> Vec<String> {
const ATTR: &str = "straymark_component_id=\"";
let mut ids = Vec::new();
let mut rest = xml;
while let Some(i) = rest.find(ATTR) {
rest = &rest[i + ATTR.len()..];
if let Some(end) = rest.find('"') {
ids.push(xml_unescape(&rest[..end]));
rest = &rest[end + 1..];
} else {
break;
}
}
ids
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn xml_unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
#[cfg(test)]
mod tests {
use super::*;
use straymark_core::architecture::{Component, Layer};
fn comp(id: &str, layer: &str, label: &str) -> Component {
Component {
id: id.to_string(),
label: label.to_string(),
layer: layer.to_string(),
globs: vec![format!("{id}/**")],
links: Vec::new(),
docs: Vec::new(),
external: false,
}
}
fn model() -> ArchModel {
ArchModel {
version: 0,
layers: vec![
Layer { id: "core".into(), label: "Core".into(), order: 1 },
Layer { id: "frontend".into(), label: "Frontend".into(), order: 0 },
],
components: vec![
comp("cli", "core", "CLI"),
comp("core", "core", "Core"),
comp("web", "frontend", "Web"),
],
}
}
#[test]
fn one_cell_per_component_with_attr() {
let xml = render_drawio(&model());
assert!(xml.contains("<mxfile"));
assert!(xml.contains("<mxGraphModel"));
for id in ["cli", "core", "web"] {
assert_eq!(
xml.matches(&format!("straymark_component_id=\"{id}\"")).count(),
1,
"expected exactly one cell for {id}"
);
}
assert!(xml.contains(r#"<mxCell id="0" />"#));
assert!(xml.contains(r#"<mxCell id="1" parent="0" />"#));
}
#[test]
fn orphan_layer_component_still_emitted() {
let mut m = model();
m.components.push(comp("ghost", "nonexistent", "Ghost"));
let xml = render_drawio(&m);
assert!(xml.contains("straymark_component_id=\"ghost\""));
}
#[test]
fn labels_are_xml_escaped() {
let mut m = model();
m.components.push(comp("amp", "core", "A & B <x>"));
let xml = render_drawio(&m);
assert!(xml.contains("label=\"A & B <x>\""));
}
#[test]
fn parse_component_ids_roundtrips_and_unescapes() {
let m = model();
let xml = render_drawio(&m);
let mut ids = parse_component_ids(&xml);
ids.sort();
assert_eq!(ids, vec!["cli", "core", "web"]);
let raw = r#"<object straymark_component_id="a&b" />"#;
assert_eq!(parse_component_ids(raw), vec!["a&b"]);
}
#[test]
fn append_cells_preserves_existing_bytes() {
let xml = render_drawio(&model());
let before_anchor = &xml[..xml.rfind("</root>").unwrap()];
let new = comp("extra", "core", "Extra");
let appended = append_cells(&xml, std::slice::from_ref(&new)).unwrap();
assert!(appended.starts_with(before_anchor));
assert!(appended.contains("straymark_component_id=\"extra\""));
let prev_max = max_y(&xml).unwrap();
assert!(max_y(&appended).unwrap() > prev_max);
assert_eq!(
parse_component_ids(&appended).len(),
parse_component_ids(&xml).len() + 1
);
}
#[test]
fn append_cells_none_without_root() {
assert!(append_cells("<not-drawio/>", &[comp("x", "core", "X")]).is_none());
}
}