Skip to main content

aimdb_codegen/
mermaid.rs

1//! Mermaid diagram generator
2//!
3//! Converts an [`ArchitectureState`] into a `flowchart LR` Mermaid diagram
4//! following the conventions defined in the architecture conventions document.
5
6use crate::state::{ArchitectureState, BufferType, ConnectorDirection};
7
8/// Generate a Mermaid `flowchart LR` diagram from architecture state.
9///
10/// The returned string can be written directly to `.aimdb/architecture.mermaid`.
11///
12/// # Conventions
13/// - Stadium `(["…"])` = SpmcRing
14/// - Rounded rect `("…")` = SingleLatest  
15/// - Diamond `{"…"}` = Mailbox
16/// - Solid arrows → data flow (produce / consume)
17/// - Dashed arrows → connector metadata (link_to / link_from)
18pub fn generate_mermaid(state: &ArchitectureState) -> String {
19    let mut out = String::new();
20
21    out.push_str("flowchart LR\n");
22
23    // ── Record nodes ──────────────────────────────────────────────────────────
24    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    // ── Data flow arrows ──────────────────────────────────────────────────────
42    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    // ── Connector metadata (dashed arrows) ────────────────────────────────────
64    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        // Collect unique protocol bus node names
70        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
99/// Derive a stable Mermaid node ID from a record name.
100///
101/// Converts PascalCase to SCREAMING_SNAKE_CASE, e.g.
102/// `TemperatureReading` → `TEMPERATURE_READING`.
103pub 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
118/// Sanitize an arbitrary identifier for use as a Mermaid node ID.
119///
120/// Replaces hyphens and spaces with underscores, removes other non-alphanumeric chars.
121fn 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        // Stadium: ([" ... "])
212        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}