node_flow/describe/
d2.rs

1use super::design::{Description, Edge, EdgeEnding, ExternalResource, Type};
2use std::{borrow::Cow, fmt::Write};
3
4/// A configurable formatter for converting [`Description`] structures into
5/// [D2](https://d2lang.com/) graph syntax.
6///
7/// # Examples
8///
9/// ```
10/// use node_flow::describe::{Description, D2Describer};
11/// use node_flow::node::{Node, NodeOutput};
12/// use node_flow::flows::FnFlow;
13///
14/// # struct ExampleNode;
15/// #
16/// # impl Node<i32, NodeOutput<String>, (), ()> for ExampleNode {
17/// #     async fn run(
18/// #         &mut self,
19/// #         input: i32,
20/// #         _context: &mut (),
21/// #     ) -> Result<NodeOutput<String>, ()> {
22/// #         Ok(NodeOutput::Ok(format!("Processed: {}", input)))
23/// #     }
24/// # }
25/// let flow = ExampleNode;
26/// let some_description = flow.describe();
27///
28/// let mut describer = D2Describer::new();
29/// describer.modify(|cfg| {
30///     cfg.show_description = true;
31///     cfg.show_externals = true;
32/// });
33///
34/// let d2_code = describer.format(&some_description);
35/// println!("{}", d2_code);
36/// // Output could be fed to a D2 renderer for visualization.
37/// ```
38#[expect(clippy::struct_excessive_bools)]
39#[derive(Debug)]
40pub struct D2Describer {
41    /// Whether to display simplified type names instead of full paths.
42    ///
43    /// When enabled, types like `my_crate::nodes::ExampleNode` become `ExampleNode`.
44    /// This makes diagrams more readable, especially for complex flows.
45    pub simple_type_name: bool,
46    /// Whether to display the node context type inside each node.
47    ///
48    /// When enabled, context will be added to node's description.
49    pub show_context_in_node: bool,
50    /// Whether to include the node's description.
51    ///
52    /// When enabled, description will be included in the node.
53    pub show_description: bool,
54    /// Whether to include information about external resources.
55    ///
56    /// When enabled, external resources will be included in the node.
57    pub show_externals: bool,
58}
59
60impl Default for D2Describer {
61    fn default() -> Self {
62        Self {
63            simple_type_name: true,
64            show_context_in_node: false,
65            show_description: false,
66            show_externals: false,
67        }
68    }
69}
70
71fn escape_str(val: &str) -> String {
72    val.replace('<', "\\<")
73        .replace('>', "\\>")
74        .replace('{', "\\{")
75        .replace('}', "\\}")
76}
77
78impl D2Describer {
79    /// Creates a new [`D2Describer`] using default configuration.
80    ///
81    /// Default settings:
82    /// - `simple_type_name`: `true`
83    /// - `show_context_in_node`: `false`
84    /// - `show_description`: `false`
85    /// - `show_externals`: `false`
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Allows modification of the configuration using a closure.
92    ///
93    /// # Examples
94    /// ```
95    /// # use node_flow::describe::D2Describer;
96    /// let mut describer = D2Describer::new();
97    /// describer.modify(|cfg| {
98    ///     cfg.show_description = true;
99    ///     cfg.show_externals = true;
100    /// });
101    /// ```
102    pub fn modify(&mut self, func: impl FnOnce(&mut Self)) -> &mut Self {
103        func(self);
104        self
105    }
106
107    fn get_type_name<'a>(&self, r#type: &'a Type) -> Cow<'a, str> {
108        if r#type.name.is_empty() {
109            return Cow::Borrowed("\"\"");
110        }
111
112        if self.simple_type_name {
113            let res = r#type.get_name_simple();
114            // fallback
115            if res.is_empty() {
116                return Cow::Borrowed(&r#type.name);
117            }
118            Cow::Owned(res)
119        } else {
120            Cow::Borrowed(&r#type.name)
121        }
122    }
123
124    /// Formats a [`Description`] into a D2 diagram text representation.
125    ///
126    /// The resulting string can be passed directly to the D2 CLI or rendered using
127    /// the [D2 playground](https://play.d2lang.com/).
128    ///
129    /// # Parameters
130    /// - `desc`: The [`Description`] to be rendered.
131    ///
132    /// # Returns
133    /// A string containing valid D2 source code representing the description graph.
134    #[must_use]
135    pub fn format(&self, desc: &Description) -> String {
136        let id = rand::random();
137        let (input, output, context) = {
138            let base = desc.get_base_ref();
139            (&base.input, &base.output, &base.context)
140        };
141        let mut res = format!(
142            r"direction: down
143classes: {{
144    node: {{
145        style.border-radius: 8
146    }}
147    flow: {{
148        style.border-radius: 8
149    }}
150    edge: {{
151        style.font-size: 18
152    }}
153    node_flow_description: {{
154        shape: page
155    }}
156    external_resource: {{
157        shape: parallelogram
158    }}
159    start_end: {{
160        shape: oval
161        style.italic: true
162    }}
163}}
164Start: {{
165    class: start_end
166    desc: |md
167      **Context**: {context}\
168      **Input**: {input}
169    |
170}}
171Start -> {id}: {input} {{
172    class: edge
173}}
174End: {{
175    class: start_end
176    desc: |md
177      **Output**: {output}
178    |
179}}
180{id} -> End: {output} {{
181    class: edge
182}}
183",
184            context = escape_str(&self.get_type_name(context)),
185            input = escape_str(&self.get_type_name(input)),
186            output = escape_str(&self.get_type_name(output)),
187        );
188
189        self.process(desc, id, &mut res);
190
191        res
192    }
193
194    fn process(&self, desc: &Description, id: u64, out: &mut String) {
195        self.start_define_base(desc, id, out);
196
197        let Description::Flow { base, nodes, edges } = desc else {
198            out.push_str("}\n");
199            return;
200        };
201
202        let nodes_and_ids = nodes
203            .iter()
204            .map(|node_desc| {
205                let id = rand::random();
206                self.process(node_desc, id, out);
207                (id, node_desc.get_base_ref())
208            })
209            .collect::<Vec<_>>();
210
211        writeln!(
212            out,
213            r"start: Start {{
214                class: start_end
215                desc: |md
216                    **Context**: {context}\
217                    **Input**: {input}
218                |
219            }}
220            end: End {{
221                class: start_end
222                desc: |md
223                    **Output**: {output}
224                |
225            }}",
226            context = escape_str(&self.get_type_name(&base.context)),
227            input = escape_str(&self.get_type_name(&base.input)),
228            output = escape_str(&self.get_type_name(&base.output))
229        )
230        .unwrap();
231        for Edge { start, end } in edges {
232            let start_type = match start {
233                EdgeEnding::ToFlow => {
234                    out.push_str("start");
235                    "\"\""
236                }
237                EdgeEnding::ToNode { node_index } => {
238                    let node = &nodes_and_ids[*node_index];
239                    out.push_str(&node.0.to_string());
240                    &escape_str(&self.get_type_name(&node.1.output))
241                }
242            };
243            out.push_str(" -> ");
244            let end_type = match end {
245                EdgeEnding::ToFlow => {
246                    out.push_str("end");
247                    "\"\""
248                }
249                EdgeEnding::ToNode { node_index } => {
250                    let node = &nodes_and_ids[*node_index];
251                    out.push_str(&node.0.to_string());
252                    &escape_str(&self.get_type_name(&node.1.input))
253                }
254            };
255            writeln!(
256                out,
257                r": {{
258                    class: edge
259                    source-arrowhead: {start_type}
260                    target-arrowhead: {end_type}
261                }}",
262            )
263            .unwrap();
264        }
265
266        out.push_str("}\n");
267    }
268
269    fn start_define_base(&self, desc: &Description, id: u64, out: &mut String) {
270        let base = desc.get_base_ref();
271        let is_node = matches!(desc, Description::Node { .. });
272        writeln!(
273            out,
274            r"{}:{} {{
275                class: {}",
276            id,
277            escape_str(&self.get_type_name(&base.r#type)),
278            if is_node { "node" } else { "flow" }
279        )
280        .unwrap();
281
282        let has_description = base.description.is_some() && self.show_description;
283        let show_context = is_node && self.show_context_in_node && !base.context.name.is_empty();
284        if has_description || show_context {
285            writeln!(out, "desc: |md").unwrap();
286            if show_context {
287                writeln!(
288                    out,
289                    r"**Context**: {}<br/>",
290                    escape_str(&self.get_type_name(&base.context))
291                )
292                .unwrap();
293            }
294            if has_description {
295                out.push_str(&escape_str(base.description.as_ref().unwrap()));
296            }
297            writeln!(
298                out,
299                "
300                | {{
301                    class: node_flow_description
302                }}",
303            )
304            .unwrap();
305        }
306
307        if !self.show_externals {
308            return;
309        }
310        let Some(externals) = &base.externals else {
311            return;
312        };
313
314        for ExternalResource {
315            r#type,
316            description,
317            output,
318        } in externals
319        {
320            let ext_id: u64 = rand::random();
321            writeln!(
322                out,
323                r"{}:{} {{
324                    class: external_resource
325                    desc: |md
326                        **output**: {}\
327                        {}
328                    |
329                }}",
330                ext_id,
331                escape_str(&self.get_type_name(r#type)),
332                escape_str(&self.get_type_name(output)),
333                escape_str(description.as_ref().map(String::as_str).unwrap_or_default()),
334            )
335            .unwrap();
336        }
337    }
338}