byteor_pipeline_spec/
dot.rs

1//! DOT / Graphviz rendering helpers.
2
3#[cfg(feature = "alloc")]
4use alloc::string::{String, ToString};
5
6#[cfg(feature = "alloc")]
7use alloc::vec::Vec;
8
9#[cfg(feature = "alloc")]
10use crate::{
11    EndpointKindV1, LaneGraphV1, LaneKindV1, MergePolicyV1, PipelineSpecV1, RoleCfgV1, SpecError,
12    StageOpV1,
13};
14
15#[cfg(feature = "alloc")]
16fn dot_escape(value: &str) -> String {
17    let mut out = String::with_capacity(value.len());
18    for ch in value.chars() {
19        match ch {
20            '\\' => out.push_str("\\\\"),
21            '"' => out.push_str("\\\""),
22            '\n' => out.push_str("\\n"),
23            _ => out.push(ch),
24        }
25    }
26    out
27}
28
29#[cfg(feature = "alloc")]
30fn node_id(prefix: &str, name: &str) -> String {
31    let mut out = String::from(prefix);
32    out.push('_');
33    for ch in name.chars() {
34        if ch.is_ascii_alphanumeric() || ch == '_' {
35            out.push(ch);
36        } else {
37            out.push('_');
38        }
39    }
40    out
41}
42
43#[cfg(feature = "alloc")]
44fn lane_kind_label(kind: LaneKindV1) -> String {
45    match kind {
46        LaneKindV1::Events => "Events".to_string(),
47        LaneKindV1::Journal => "Journal".to_string(),
48        LaneKindV1::SequencedSlots { capacity, gating } => {
49            let mut s = String::from("SequencedSlots");
50            s.push_str("\\ncapacity=");
51            s.push_str(&capacity.to_string());
52            s.push_str(" gating=");
53            s.push_str(&gating.to_string());
54            s
55        }
56        LaneKindV1::FanoutBroadcast { consumers } => {
57            let mut s = String::from("FanoutBroadcast");
58            s.push_str("\\nconsumers=");
59            s.push_str(&consumers.to_string());
60            s
61        }
62    }
63}
64
65#[cfg(feature = "alloc")]
66fn stage_label(cfg: &crate::StageRoleCfgV1) -> String {
67    let mut label = String::from("Stage");
68    label.push_str("\\nrole=");
69    label.push_str(&dot_escape(&cfg.name));
70    label.push_str("\\nstage=");
71    label.push_str(&dot_escape(&cfg.stage));
72    label
73}
74
75#[cfg(feature = "alloc")]
76fn bridge_label(cfg: &crate::BridgeRoleCfgV1) -> String {
77    let mut label = String::from("Bridge");
78    label.push_str("\\nrole=");
79    label.push_str(&dot_escape(&cfg.name));
80    label
81}
82
83#[cfg(feature = "alloc")]
84fn router_label(cfg: &crate::RouterRoleCfgV1) -> String {
85    let mut label = String::from("Router");
86    label.push_str("\\nrole=");
87    label.push_str(&dot_escape(&cfg.name));
88    label
89}
90
91#[cfg(feature = "alloc")]
92fn merge_label(cfg: &crate::MergeRoleCfgV1) -> String {
93    let mut label = String::from("Merge");
94    label.push_str("\\nrole=");
95    label.push_str(&dot_escape(&cfg.name));
96    label.push_str("\\npolicy=");
97    match cfg.policy {
98        MergePolicyV1::RoundRobin => label.push_str("round_robin"),
99    }
100    label
101}
102
103#[cfg(feature = "alloc")]
104fn lane_endpoints(lg: &LaneGraphV1, lane_name: &str) -> Result<(String, Vec<String>), SpecError> {
105    let mut producer: Option<String> = None;
106    let mut consumers: Vec<String> = Vec::new();
107
108    for ep in &lg.endpoints {
109        if ep.lane == lane_name {
110            match ep.kind {
111                EndpointKindV1::Ingress => {
112                    producer = Some(node_id("endpoint", &ep.name));
113                }
114                EndpointKindV1::Egress => {
115                    consumers.push(node_id("endpoint", &ep.name));
116                }
117            }
118        }
119    }
120
121    for role in &lg.roles {
122        match role {
123            RoleCfgV1::Stage(cfg) => {
124                if cfg.tx == lane_name {
125                    producer = Some(node_id("role", &cfg.name));
126                }
127                if cfg.rx == lane_name {
128                    consumers.push(node_id("role", &cfg.name));
129                }
130            }
131            RoleCfgV1::Bridge(cfg) => {
132                if cfg.tx == lane_name {
133                    producer = Some(node_id("role", &cfg.name));
134                }
135                if cfg.rx == lane_name {
136                    consumers.push(node_id("role", &cfg.name));
137                }
138            }
139            RoleCfgV1::Router(cfg) => {
140                if cfg.rx == lane_name {
141                    consumers.push(node_id("role", &cfg.name));
142                }
143                if cfg.tx.iter().any(|tx| tx == lane_name) {
144                    producer = Some(node_id("role", &cfg.name));
145                }
146            }
147            RoleCfgV1::Merge(cfg) => {
148                if cfg.tx == lane_name {
149                    producer = Some(node_id("role", &cfg.name));
150                }
151                if cfg.rx.iter().any(|rx| rx == lane_name) {
152                    consumers.push(node_id("role", &cfg.name));
153                }
154            }
155        }
156    }
157
158    let producer = producer.ok_or(SpecError::new(
159        "lane graph dot rendering requires a producer for every lane",
160    ))?;
161    if consumers.is_empty() {
162        return Err(SpecError::new(
163            "lane graph dot rendering requires at least one consumer for every lane",
164        ));
165    }
166    Ok((producer, consumers))
167}
168
169#[cfg(feature = "alloc")]
170fn single_ring_stage_label(op: &StageOpV1) -> String {
171    match op {
172        StageOpV1::Identity => "Identity".to_string(),
173        StageOpV1::AddU8 { delta } => {
174            let mut label = String::from("AddU8");
175            label.push_str("\\ndelta=");
176            label.push_str(&delta.to_string());
177            label
178        }
179        StageOpV1::ResolverKey { stage } => {
180            let mut label = String::from("ResolverKey");
181            label.push_str("\\nstage=");
182            label.push_str(&dot_escape(stage));
183            label
184        }
185    }
186}
187
188/// Render a v1 spec as Graphviz DOT.
189#[cfg(feature = "alloc")]
190pub fn dot_v1(spec: &PipelineSpecV1) -> Result<String, SpecError> {
191    match spec {
192        PipelineSpecV1::LaneGraph(_) => dot_lane_graph_v1(spec),
193        PipelineSpecV1::SingleRing(ring) => {
194            if ring.stages.is_empty() {
195                return Err(SpecError::new("dot rendering requires at least one single ring stage"));
196            }
197
198            let mut out = String::from("digraph byteor_single_ring_v1 {\n");
199            out.push_str("  rankdir=LR;\n");
200            out.push_str("  graph [fontname=\"Helvetica\"];\n");
201            out.push_str("  node [fontname=\"Helvetica\", shape=box];\n");
202            out.push_str("  ingress [shape=oval, style=rounded, label=\"Ingress\"];\n");
203            out.push_str("  egress [shape=oval, style=rounded, label=\"Egress\"];\n");
204
205            for (index, stage) in ring.stages.iter().enumerate() {
206                let mut label = String::from("Stage ");
207                label.push_str(&index.to_string());
208                label.push_str("\\n");
209                label.push_str(&single_ring_stage_label(&stage.op));
210                out.push_str("  stage_");
211                out.push_str(&index.to_string());
212                out.push_str(" [label=\"");
213                out.push_str(&label);
214                out.push_str("\"];\n");
215            }
216
217            for (index, stage) in ring.stages.iter().enumerate() {
218                if stage.depends_on.is_empty() {
219                    out.push_str("  ingress -> stage_");
220                    out.push_str(&index.to_string());
221                    out.push_str(" [label=\"ring\"];\n");
222                } else {
223                    for dependency in &stage.depends_on {
224                        out.push_str("  stage_");
225                        out.push_str(&dependency.to_string());
226                        out.push_str(" -> stage_");
227                        out.push_str(&index.to_string());
228                        out.push_str(" [label=\"depends\"];\n");
229                    }
230                }
231            }
232
233            out.push_str("  stage_");
234            out.push_str(&(ring.stages.len() - 1).to_string());
235            out.push_str(" -> egress [label=\"ring\"];\n");
236            out.push_str("}\n");
237            Ok(out)
238        }
239    }
240}
241
242/// Render a LaneGraph v1 spec as Graphviz DOT.
243#[cfg(feature = "alloc")]
244pub fn dot_lane_graph_v1(spec: &PipelineSpecV1) -> Result<String, SpecError> {
245    let PipelineSpecV1::LaneGraph(lg) = spec else {
246        return Err(SpecError::new("dot rendering requires a lane graph spec"));
247    };
248
249    let mut out = String::from("digraph byteor_lane_graph_v1 {\n");
250    out.push_str("  rankdir=LR;\n");
251    out.push_str("  graph [fontname=\"Helvetica\"];\n");
252    out.push_str("  node [fontname=\"Helvetica\"];\n");
253    out.push_str("  edge [fontname=\"Helvetica\"];\n");
254
255    for ep in &lg.endpoints {
256        let id = node_id("endpoint", &ep.name);
257        let label = match ep.kind {
258            EndpointKindV1::Ingress => {
259                let mut s = String::from("Ingress");
260                s.push_str("\\nendpoint=");
261                s.push_str(&dot_escape(&ep.name));
262                s
263            }
264            EndpointKindV1::Egress => {
265                let mut s = String::from("Egress");
266                s.push_str("\\nendpoint=");
267                s.push_str(&dot_escape(&ep.name));
268                s
269            }
270        };
271        out.push_str("  ");
272        out.push_str(&id);
273        out.push_str(" [shape=oval, style=rounded, label=\"");
274        out.push_str(&label);
275        out.push_str("\"];\n");
276    }
277
278    for role in &lg.roles {
279        match role {
280            RoleCfgV1::Stage(cfg) => {
281                out.push_str("  ");
282                out.push_str(&node_id("role", &cfg.name));
283                out.push_str(" [shape=box, label=\"");
284                out.push_str(&stage_label(cfg));
285                out.push_str("\"];\n");
286            }
287            RoleCfgV1::Bridge(cfg) => {
288                out.push_str("  ");
289                out.push_str(&node_id("role", &cfg.name));
290                out.push_str(" [shape=diamond, label=\"");
291                out.push_str(&bridge_label(cfg));
292                out.push_str("\"];\n");
293            }
294            RoleCfgV1::Router(cfg) => {
295                out.push_str("  ");
296                out.push_str(&node_id("role", &cfg.name));
297                out.push_str(" [shape=hexagon, label=\"");
298                out.push_str(&router_label(cfg));
299                out.push_str("\"];\n");
300            }
301            RoleCfgV1::Merge(cfg) => {
302                out.push_str("  ");
303                out.push_str(&node_id("role", &cfg.name));
304                out.push_str(" [shape=invtriangle, label=\"");
305                out.push_str(&merge_label(cfg));
306                out.push_str("\"];\n");
307            }
308        }
309    }
310
311    for lane in &lg.lanes {
312        let (producer, consumers) = lane_endpoints(lg, &lane.name)?;
313        for consumer in consumers {
314            out.push_str("  ");
315            out.push_str(&producer);
316            out.push_str(" -> ");
317            out.push_str(&consumer);
318            out.push_str(" [label=\"");
319            out.push_str(&dot_escape(&lane.name));
320            out.push_str("\\n");
321            out.push_str(&lane_kind_label(lane.kind));
322            out.push_str("\"];\n");
323        }
324    }
325
326    out.push_str("}\n");
327    Ok(out)
328}