1use crate::state::{ArchitectureState, BufferType, ConnectorDirection};
7
8pub fn generate_mermaid(state: &ArchitectureState) -> String {
19 let mut out = String::new();
20
21 out.push_str("flowchart LR\n");
22
23 if !state.records.is_empty() {
25 out.push_str(
26 "\n %% ── Records ────────────────────────────────────────────────────────────\n",
27 );
28 }
29 for rec in &state.records {
30 let node_id = node_id(&rec.name);
31 let label = format!("{}\\n{}", rec.name, rec.buffer.label(rec.capacity));
32 let node_def = match rec.buffer {
33 BufferType::SpmcRing => format!(" {node_id}([\"{label}\"])"),
34 BufferType::SingleLatest => format!(" {node_id}(\"{label}\")"),
35 BufferType::Mailbox => format!(" {node_id}{{\"{label}\"}}"),
36 };
37 out.push_str(&node_def);
38 out.push('\n');
39 }
40
41 if state
43 .records
44 .iter()
45 .any(|r| !r.producers.is_empty() || !r.consumers.is_empty())
46 {
47 out.push_str(
48 "\n %% ── Data flow (solid arrows) ──────────────────────────────────────────\n",
49 );
50 }
51 for rec in &state.records {
52 let nid = node_id(&rec.name);
53 for producer in &rec.producers {
54 let pid = sanitize_id(producer);
55 out.push_str(&format!(" {pid} -->|produce| {nid}\n"));
56 }
57 for consumer in &rec.consumers {
58 let cid = sanitize_id(consumer);
59 out.push_str(&format!(" {nid} -->|consume| {cid}\n"));
60 }
61 }
62
63 let has_connectors = state.records.iter().any(|r| !r.connectors.is_empty());
65 if has_connectors {
66 out.push_str(
67 "\n %% ── Connector metadata (dashed arrows) ────────────────────────────────\n",
68 );
69 let mut protocols_seen: Vec<String> = Vec::new();
71 for rec in &state.records {
72 for conn in &rec.connectors {
73 let bus = conn.protocol.to_uppercase();
74 if !protocols_seen.contains(&bus) {
75 protocols_seen.push(bus);
76 }
77 }
78 }
79 for rec in &state.records {
80 let nid = node_id(&rec.name);
81 for conn in &rec.connectors {
82 let bus = conn.protocol.to_uppercase();
83 let url = &conn.url;
84 match conn.direction {
85 ConnectorDirection::Outbound => {
86 out.push_str(&format!(" {nid} -.->|\"link_to {url}\"| {bus}\n"));
87 }
88 ConnectorDirection::Inbound => {
89 out.push_str(&format!(" {bus} -.->|\"link_from {url}\"| {nid}\n"));
90 }
91 }
92 }
93 }
94 }
95
96 out
97}
98
99pub fn node_id(name: &str) -> String {
104 let mut out = String::new();
105 let chars: Vec<char> = name.chars().collect();
106 for (i, &c) in chars.iter().enumerate() {
107 if c.is_uppercase()
108 && i > 0
109 && (chars[i - 1].is_lowercase() || chars[i - 1].is_ascii_digit())
110 {
111 out.push('_');
112 }
113 out.push(c.to_ascii_uppercase());
114 }
115 out
116}
117
118fn sanitize_id(s: &str) -> String {
122 s.chars()
123 .map(|c| {
124 if c.is_alphanumeric() || c == '_' {
125 c
126 } else {
127 '_'
128 }
129 })
130 .collect()
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::state::ArchitectureState;
137
138 const SAMPLE_TOML: &str = r#"
139[meta]
140aimdb_version = "0.5.0"
141created_at = "2026-02-22T14:00:00Z"
142last_modified = "2026-02-22T14:33:00Z"
143
144[[records]]
145name = "TemperatureReading"
146buffer = "SpmcRing"
147capacity = 256
148key_prefix = "sensors.temp."
149key_variants = ["indoor", "outdoor", "garage"]
150producers = ["sensor_task"]
151consumers = ["dashboard", "anomaly_detector"]
152
153[[records.fields]]
154name = "celsius"
155type = "f64"
156description = "Temperature in degrees Celsius"
157
158[[records.connectors]]
159protocol = "mqtt"
160direction = "outbound"
161url = "mqtt://sensors/temp/{variant}"
162
163[[records]]
164name = "OtaCommand"
165buffer = "Mailbox"
166key_prefix = "device.ota."
167key_variants = ["gateway-01"]
168producers = ["cloud_ota_service"]
169consumers = ["device_update_task"]
170
171[[records.fields]]
172name = "action"
173type = "String"
174description = "Command action"
175
176[[records.connectors]]
177protocol = "mqtt"
178direction = "inbound"
179url = "mqtt://ota/cmd/{variant}"
180
181[[records]]
182name = "FirmwareVersion"
183buffer = "SingleLatest"
184key_prefix = "device.firmware."
185key_variants = ["gateway-01"]
186producers = ["cloud_service"]
187consumers = ["updater"]
188
189[[records.fields]]
190name = "version"
191type = "String"
192description = "Semantic version"
193"#;
194
195 fn state() -> ArchitectureState {
196 ArchitectureState::from_toml(SAMPLE_TOML).unwrap()
197 }
198
199 #[test]
200 fn contains_flowchart_header() {
201 let out = generate_mermaid(&state());
202 assert!(
203 out.starts_with("flowchart LR\n"),
204 "Must start with flowchart LR"
205 );
206 }
207
208 #[test]
209 fn spmc_ring_uses_stadium_shape() {
210 let out = generate_mermaid(&state());
211 assert!(
213 out.contains("TEMPERATURE_READING([\"TemperatureReading\\nSpmcRing · 256\"])"),
214 "SpmcRing node should use stadium shape:\n{out}"
215 );
216 }
217
218 #[test]
219 fn mailbox_uses_diamond_shape() {
220 let out = generate_mermaid(&state());
221 assert!(
222 out.contains("OTA_COMMAND{\"OtaCommand\\nMailbox\"}"),
223 "Mailbox node should use diamond shape:\n{out}"
224 );
225 }
226
227 #[test]
228 fn single_latest_uses_rounded_rect() {
229 let out = generate_mermaid(&state());
230 assert!(
231 out.contains("FIRMWARE_VERSION(\"FirmwareVersion\\nSingleLatest\")"),
232 "SingleLatest node should use rounded rect:\n{out}"
233 );
234 }
235
236 #[test]
237 fn produce_arrows_present() {
238 let out = generate_mermaid(&state());
239 assert!(
240 out.contains("sensor_task -->|produce| TEMPERATURE_READING"),
241 "Producer arrow missing:\n{out}"
242 );
243 }
244
245 #[test]
246 fn consume_arrows_present() {
247 let out = generate_mermaid(&state());
248 assert!(
249 out.contains("TEMPERATURE_READING -->|consume| dashboard"),
250 "Consumer arrow missing:\n{out}"
251 );
252 assert!(
253 out.contains("TEMPERATURE_READING -->|consume| anomaly_detector"),
254 "Consumer arrow missing:\n{out}"
255 );
256 }
257
258 #[test]
259 fn outbound_connector_dashed_arrow() {
260 let out = generate_mermaid(&state());
261 assert!(
262 out.contains(
263 "TEMPERATURE_READING -.->|\"link_to mqtt://sensors/temp/{variant}\"| MQTT"
264 ),
265 "Outbound dashed arrow missing:\n{out}"
266 );
267 }
268
269 #[test]
270 fn inbound_connector_dashed_arrow() {
271 let out = generate_mermaid(&state());
272 assert!(
273 out.contains("MQTT -.->|\"link_from mqtt://ota/cmd/{variant}\"| OTA_COMMAND"),
274 "Inbound dashed arrow missing:\n{out}"
275 );
276 }
277
278 #[test]
279 fn node_id_pascal_to_screaming_snake() {
280 assert_eq!(node_id("TemperatureReading"), "TEMPERATURE_READING");
281 assert_eq!(node_id("OtaCommand"), "OTA_COMMAND");
282 assert_eq!(node_id("FirmwareVersion"), "FIRMWARE_VERSION");
283 assert_eq!(node_id("AppConfig"), "APP_CONFIG");
284 assert_eq!(node_id("Temp"), "TEMP");
285 }
286}