Skip to main content

rust_igraph/algorithms/io/
dot.rs

1//! DOT (Graphviz) writer (ALGO-IO-006).
2//!
3//! Writes graphs in the DOT language used by the Graphviz suite of
4//! tools. This is a write-only format (igraph does not read DOT files).
5//!
6//! ```text
7//! graph {
8//!   0 -- 1;
9//!   1 -- 2;
10//! }
11//! ```
12//!
13//! For directed graphs:
14//! ```text
15//! digraph {
16//!   0 -> 1;
17//!   1 -> 2;
18//! }
19//! ```
20//!
21//! Counterpart of `igraph_write_graph_dot`.
22
23use std::io::Write;
24
25use crate::core::{Graph, IgraphError, IgraphResult};
26
27/// Write a graph in DOT (Graphviz) format.
28///
29/// If `labels` is provided, uses them as vertex labels; otherwise uses
30/// numeric ids. Isolated vertices are listed explicitly.
31///
32/// # Examples
33///
34/// ```
35/// use rust_igraph::{Graph, write_dot};
36///
37/// let mut g = Graph::with_vertices(3);
38/// g.add_edge(0, 1).unwrap();
39/// g.add_edge(1, 2).unwrap();
40///
41/// let mut buf = Vec::new();
42/// write_dot(&g, None, &mut buf).unwrap();
43/// let s = String::from_utf8(buf).unwrap();
44/// assert!(s.contains("graph {"));
45/// assert!(s.contains("--"));
46/// ```
47pub fn write_dot<W: Write>(
48    graph: &Graph,
49    labels: Option<&[String]>,
50    writer: &mut W,
51) -> IgraphResult<()> {
52    if let Some(l) = labels {
53        if l.len() != graph.vcount() as usize {
54            return Err(IgraphError::InvalidArgument(format!(
55                "labels length {} does not match vcount {}",
56                l.len(),
57                graph.vcount()
58            )));
59        }
60    }
61
62    let edge_op = if graph.is_directed() { "->" } else { "--" };
63    let graph_type = if graph.is_directed() {
64        "digraph"
65    } else {
66        "graph"
67    };
68
69    writeln!(writer, "{graph_type} {{")?;
70
71    // Track which vertices appear in edges
72    let mut has_edge = vec![false; graph.vcount() as usize];
73
74    for eid in 0..graph.ecount() {
75        #[allow(clippy::cast_possible_truncation)]
76        let (src, tgt) = graph.edge(eid as u32)?;
77        has_edge[src as usize] = true;
78        has_edge[tgt as usize] = true;
79
80        let src_label = vertex_label(src, labels);
81        let tgt_label = vertex_label(tgt, labels);
82        writeln!(writer, "  {src_label} {edge_op} {tgt_label};")?;
83    }
84
85    // Emit isolated vertices
86    for v in 0..graph.vcount() {
87        if !has_edge[v as usize] {
88            let lbl = vertex_label(v, labels);
89            writeln!(writer, "  {lbl};")?;
90        }
91    }
92
93    writeln!(writer, "}}")?;
94
95    Ok(())
96}
97
98fn vertex_label(v: u32, labels: Option<&[String]>) -> String {
99    match labels {
100        Some(l) => dot_escape(&l[v as usize]),
101        None => v.to_string(),
102    }
103}
104
105fn dot_escape(s: &str) -> String {
106    // Check if the string is a simple identifier (alphanumeric + underscore, not starting with digit)
107    let is_simple = !s.is_empty()
108        && !s.as_bytes()[0].is_ascii_digit()
109        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
110
111    // Also check if it's a DOT reserved word
112    let reserved = matches!(
113        s.to_ascii_lowercase().as_str(),
114        "graph" | "digraph" | "node" | "edge" | "strict" | "subgraph"
115    );
116
117    if is_simple && !reserved {
118        s.to_owned()
119    } else {
120        // Quote the string
121        let mut out = String::with_capacity(s.len() + 2);
122        out.push('"');
123        for c in s.chars() {
124            match c {
125                '"' => out.push_str("\\\""),
126                '\\' => out.push_str("\\\\"),
127                '\n' => out.push_str("\\n"),
128                _ => out.push(c),
129            }
130        }
131        out.push('"');
132        out
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_undirected_basic() {
142        let mut g = Graph::with_vertices(3);
143        g.add_edge(0, 1).unwrap();
144        g.add_edge(1, 2).unwrap();
145
146        let mut buf = Vec::new();
147        write_dot(&g, None, &mut buf).unwrap();
148        let s = String::from_utf8(buf).unwrap();
149        assert!(s.starts_with("graph {\n"));
150        assert!(s.contains("0 -- 1;"));
151        assert!(s.contains("1 -- 2;"));
152        assert!(s.ends_with("}\n"));
153    }
154
155    #[test]
156    fn test_directed_basic() {
157        let mut g = Graph::new(3, true).unwrap();
158        g.add_edge(0, 1).unwrap();
159        g.add_edge(1, 2).unwrap();
160
161        let mut buf = Vec::new();
162        write_dot(&g, None, &mut buf).unwrap();
163        let s = String::from_utf8(buf).unwrap();
164        assert!(s.starts_with("digraph {\n"));
165        assert!(s.contains("0 -> 1;"));
166        assert!(s.contains("1 -> 2;"));
167    }
168
169    #[test]
170    fn test_with_labels() {
171        let mut g = Graph::with_vertices(3);
172        g.add_edge(0, 1).unwrap();
173
174        let labels = vec!["Alice".to_string(), "Bob".to_string(), "Carol".to_string()];
175        let mut buf = Vec::new();
176        write_dot(&g, Some(&labels), &mut buf).unwrap();
177        let s = String::from_utf8(buf).unwrap();
178        assert!(s.contains("Alice -- Bob;"));
179        // Carol is isolated
180        assert!(s.contains("Carol;"));
181    }
182
183    #[test]
184    fn test_isolated_vertices() {
185        let g = Graph::with_vertices(3);
186
187        let mut buf = Vec::new();
188        write_dot(&g, None, &mut buf).unwrap();
189        let s = String::from_utf8(buf).unwrap();
190        assert!(s.contains("  0;\n"));
191        assert!(s.contains("  1;\n"));
192        assert!(s.contains("  2;\n"));
193    }
194
195    #[test]
196    fn test_empty_graph() {
197        let g = Graph::with_vertices(0);
198
199        let mut buf = Vec::new();
200        write_dot(&g, None, &mut buf).unwrap();
201        let s = String::from_utf8(buf).unwrap();
202        assert_eq!(s, "graph {\n}\n");
203    }
204
205    #[test]
206    fn test_reserved_word_label_escaped() {
207        let mut g = Graph::with_vertices(2);
208        g.add_edge(0, 1).unwrap();
209
210        let labels = vec!["graph".to_string(), "node".to_string()];
211        let mut buf = Vec::new();
212        write_dot(&g, Some(&labels), &mut buf).unwrap();
213        let s = String::from_utf8(buf).unwrap();
214        assert!(s.contains("\"graph\" -- \"node\";"));
215    }
216
217    #[test]
218    fn test_label_with_spaces_escaped() {
219        let mut g = Graph::with_vertices(2);
220        g.add_edge(0, 1).unwrap();
221
222        let labels = vec!["hello world".to_string(), "foo bar".to_string()];
223        let mut buf = Vec::new();
224        write_dot(&g, Some(&labels), &mut buf).unwrap();
225        let s = String::from_utf8(buf).unwrap();
226        assert!(s.contains("\"hello world\" -- \"foo bar\";"));
227    }
228
229    #[test]
230    fn test_label_with_quotes_escaped() {
231        let mut g = Graph::with_vertices(2);
232        g.add_edge(0, 1).unwrap();
233
234        let labels = vec!["say \"hi\"".to_string(), "ok".to_string()];
235        let mut buf = Vec::new();
236        write_dot(&g, Some(&labels), &mut buf).unwrap();
237        let s = String::from_utf8(buf).unwrap();
238        assert!(s.contains("\"say \\\"hi\\\"\" -- ok;"));
239    }
240
241    #[test]
242    fn test_label_starting_with_digit() {
243        let mut g = Graph::with_vertices(2);
244        g.add_edge(0, 1).unwrap();
245
246        let labels = vec!["123abc".to_string(), "valid_name".to_string()];
247        let mut buf = Vec::new();
248        write_dot(&g, Some(&labels), &mut buf).unwrap();
249        let s = String::from_utf8(buf).unwrap();
250        assert!(s.contains("\"123abc\" -- valid_name;"));
251    }
252
253    #[test]
254    fn test_self_loop() {
255        let mut g = Graph::with_vertices(1);
256        g.add_edge(0, 0).unwrap();
257
258        let mut buf = Vec::new();
259        write_dot(&g, None, &mut buf).unwrap();
260        let s = String::from_utf8(buf).unwrap();
261        assert!(s.contains("0 -- 0;"));
262    }
263
264    #[test]
265    fn test_labels_mismatch_error() {
266        let g = Graph::with_vertices(3);
267        let labels = vec!["A".to_string()];
268        let mut buf = Vec::new();
269        assert!(write_dot(&g, Some(&labels), &mut buf).is_err());
270    }
271}