Skip to main content

rust_igraph/algorithms/io/
ncol.rs

1//! NCOL (LGL edge list) I/O (ALGO-IO-002).
2//!
3//! Reads and writes graphs in the `.ncol` format used by the Large Graph
4//! Layout (LGL) program. This is a symbolic weighted edge list: one edge
5//! per line, two vertex names separated by whitespace, optionally followed
6//! by an edge weight.
7//!
8//! The resulting graph is always **undirected**. Vertex names are mapped to
9//! internal indices in first-occurrence order.
10//!
11//! Counterpart of `igraph_read_graph_ncol` / `igraph_write_graph_ncol`.
12
13use std::collections::HashMap;
14use std::io::{BufRead, BufReader, Read, Write};
15
16use crate::core::attributes::AttributeValue;
17use crate::core::{Graph, IgraphError, IgraphResult};
18
19/// Result of reading an NCOL file: the graph plus optional metadata.
20#[derive(Debug, Clone)]
21pub struct NcolGraph {
22    /// The parsed graph (always undirected).
23    pub graph: Graph,
24    /// Vertex names in index order (name at position `i` is the name of
25    /// vertex `i`).
26    pub names: Vec<String>,
27    /// Edge weights (one per edge, in edge order). `None` if no edge had
28    /// a weight specified. If some edges have weights and some don't, the
29    /// missing weights default to 0.0.
30    pub weights: Option<Vec<f64>>,
31}
32
33/// Read a graph from NCOL (`.ncol`) format.
34///
35/// Each non-empty, non-comment line defines an edge:
36/// ```text
37/// vertex1 vertex2 [weight]
38/// ```
39///
40/// Lines starting with `#` or `%` are comments. Vertex names are arbitrary
41/// non-whitespace strings. The graph is always undirected.
42///
43/// A line with a single name creates an isolated vertex (igraph extension).
44///
45/// # Examples
46///
47/// ```
48/// use rust_igraph::read_ncol;
49///
50/// let ncol = b"Alice Bob 1.0\nBob Carol\nAlice Carol 2.5\n";
51/// let result = read_ncol(&ncol[..]).unwrap();
52/// assert_eq!(result.graph.vcount(), 3);
53/// assert_eq!(result.graph.ecount(), 3);
54/// assert_eq!(result.names, vec!["Alice", "Bob", "Carol"]);
55/// assert!(result.weights.is_some());
56/// ```
57pub fn read_ncol<R: Read>(input: R) -> IgraphResult<NcolGraph> {
58    let reader = BufReader::new(input);
59    let mut name_map: HashMap<String, u32> = HashMap::new();
60    let mut names: Vec<String> = Vec::new();
61    let mut edges: Vec<(u32, u32)> = Vec::new();
62    let mut weights: Vec<f64> = Vec::new();
63    let mut has_any_weight = false;
64
65    for (line_idx, line_result) in reader.lines().enumerate() {
66        let line = line_result?;
67        let trimmed = line.trim();
68
69        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('%') {
70            continue;
71        }
72
73        let mut parts = trimmed.split_whitespace();
74
75        let vertex_a = parts.next().ok_or_else(|| IgraphError::Parse {
76            line: line_idx.wrapping_add(1),
77            message: "empty edge line".into(),
78        })?;
79
80        let id1 = get_or_insert_name(vertex_a, &mut name_map, &mut names);
81
82        // If there's no second token, this is an isolated vertex declaration
83        let Some(vertex_b) = parts.next() else {
84            continue;
85        };
86
87        let id2 = get_or_insert_name(vertex_b, &mut name_map, &mut names);
88        edges.push((id1, id2));
89
90        // Optional weight
91        let weight = if let Some(w_str) = parts.next() {
92            let w = w_str.parse::<f64>().map_err(|e| IgraphError::Parse {
93                line: line_idx.wrapping_add(1),
94                message: format!("invalid weight '{w_str}': {e}"),
95            })?;
96            has_any_weight = true;
97            w
98        } else {
99            0.0
100        };
101        weights.push(weight);
102    }
103
104    #[allow(clippy::cast_possible_truncation)]
105    let n = names.len() as u32;
106    let mut graph = Graph::with_vertices(n);
107    graph.add_edges(edges)?;
108
109    let has_any_label = names.iter().any(|l| !l.is_empty());
110    if has_any_label {
111        graph.set_vertex_attribute_all(
112            "name",
113            names
114                .iter()
115                .map(|l| AttributeValue::String(l.clone()))
116                .collect(),
117        )?;
118    }
119    if has_any_weight {
120        graph.set_edge_attribute_all(
121            "weight",
122            weights
123                .iter()
124                .map(|&w| AttributeValue::Numeric(w))
125                .collect(),
126        )?;
127    }
128
129    Ok(NcolGraph {
130        graph,
131        names,
132        weights: if has_any_weight { Some(weights) } else { None },
133    })
134}
135
136/// Write a graph in NCOL format.
137///
138/// If `names` is provided, uses them as vertex labels; otherwise uses
139/// numeric ids as strings. If `weights` is provided (one per edge),
140/// appends the weight after each edge.
141///
142/// # Examples
143///
144/// ```
145/// use rust_igraph::{Graph, write_ncol};
146///
147/// let mut g = Graph::with_vertices(3);
148/// g.add_edge(0, 1).unwrap();
149/// g.add_edge(1, 2).unwrap();
150///
151/// let names = vec!["A".to_string(), "B".to_string(), "C".to_string()];
152/// let mut buf = Vec::new();
153/// write_ncol(&g, Some(&names), None, &mut buf).unwrap();
154/// let s = String::from_utf8(buf).unwrap();
155/// assert!(s.contains("A B"));
156/// assert!(s.contains("B C"));
157/// ```
158pub fn write_ncol<W: Write>(
159    graph: &Graph,
160    names: Option<&[String]>,
161    weights: Option<&[f64]>,
162    writer: &mut W,
163) -> IgraphResult<()> {
164    if let Some(n) = names {
165        if n.len() != graph.vcount() as usize {
166            return Err(IgraphError::InvalidArgument(format!(
167                "names length {} does not match vcount {}",
168                n.len(),
169                graph.vcount()
170            )));
171        }
172    }
173    if let Some(w) = weights {
174        if w.len() != graph.ecount() {
175            return Err(IgraphError::InvalidArgument(format!(
176                "weights length {} does not match ecount {}",
177                w.len(),
178                graph.ecount()
179            )));
180        }
181    }
182
183    for eid in 0..graph.ecount() {
184        #[allow(clippy::cast_possible_truncation)]
185        let (src, tgt) = graph.edge(eid as u32)?;
186
187        let src_name = match names {
188            Some(n) => n[src as usize].as_str().to_owned(),
189            None => graph
190                .vertex_attribute("name", src)
191                .and_then(AttributeValue::as_str)
192                .map_or_else(|| src.to_string(), str::to_owned),
193        };
194        let tgt_name = match names {
195            Some(n) => n[tgt as usize].as_str().to_owned(),
196            None => graph
197                .vertex_attribute("name", tgt)
198                .and_then(AttributeValue::as_str)
199                .map_or_else(|| tgt.to_string(), str::to_owned),
200        };
201
202        #[allow(clippy::cast_possible_truncation)]
203        let eid_u32 = eid as u32;
204        match weights {
205            Some(w) => writeln!(writer, "{src_name} {tgt_name} {}", w[eid])?,
206            None => {
207                if let Some(w) = graph
208                    .edge_attribute("weight", eid_u32)
209                    .and_then(AttributeValue::as_f64)
210                {
211                    writeln!(writer, "{src_name} {tgt_name} {w}")?;
212                } else {
213                    writeln!(writer, "{src_name} {tgt_name}")?;
214                }
215            }
216        }
217    }
218
219    Ok(())
220}
221
222fn get_or_insert_name(name: &str, map: &mut HashMap<String, u32>, names: &mut Vec<String>) -> u32 {
223    if let Some(&id) = map.get(name) {
224        id
225    } else {
226        #[allow(clippy::cast_possible_truncation)]
227        let id = names.len() as u32;
228        map.insert(name.to_owned(), id);
229        names.push(name.to_owned());
230        id
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_empty() {
240        let result = read_ncol(&b""[..]).unwrap();
241        assert_eq!(result.graph.vcount(), 0);
242        assert_eq!(result.graph.ecount(), 0);
243        assert!(result.names.is_empty());
244        assert!(result.weights.is_none());
245    }
246
247    #[test]
248    fn test_basic_unweighted() {
249        let input = b"a b\nb c\n";
250        let result = read_ncol(&input[..]).unwrap();
251        assert_eq!(result.graph.vcount(), 3);
252        assert_eq!(result.graph.ecount(), 2);
253        assert_eq!(result.names, vec!["a", "b", "c"]);
254        assert!(result.weights.is_none());
255    }
256
257    #[test]
258    fn test_weighted() {
259        let input = b"a b 1.5\nb c 2.0\n";
260        let result = read_ncol(&input[..]).unwrap();
261        assert_eq!(result.graph.ecount(), 2);
262        let w = result.weights.unwrap();
263        assert!((w[0] - 1.5).abs() < 1e-10);
264        assert!((w[1] - 2.0).abs() < 1e-10);
265    }
266
267    #[test]
268    fn test_mixed_weights() {
269        let input = b"a b 3.0\nb c\n";
270        let result = read_ncol(&input[..]).unwrap();
271        let w = result.weights.unwrap();
272        assert!((w[0] - 3.0).abs() < 1e-10);
273        assert!((w[1] - 0.0).abs() < 1e-10);
274    }
275
276    #[test]
277    fn test_comments_and_blanks() {
278        let input = b"# comment\n\n% another comment\na b\n";
279        let result = read_ncol(&input[..]).unwrap();
280        assert_eq!(result.graph.vcount(), 2);
281        assert_eq!(result.graph.ecount(), 1);
282    }
283
284    #[test]
285    fn test_isolated_vertex() {
286        let input = b"a b\nc\n";
287        let result = read_ncol(&input[..]).unwrap();
288        assert_eq!(result.graph.vcount(), 3);
289        assert_eq!(result.graph.ecount(), 1);
290        assert_eq!(result.names[2], "c");
291    }
292
293    #[test]
294    fn test_self_loop() {
295        let input = b"a a\n";
296        let result = read_ncol(&input[..]).unwrap();
297        assert_eq!(result.graph.vcount(), 1);
298        assert_eq!(result.graph.ecount(), 1);
299    }
300
301    #[test]
302    fn test_always_undirected() {
303        let input = b"x y\ny x\n";
304        let result = read_ncol(&input[..]).unwrap();
305        assert!(!result.graph.is_directed());
306        assert_eq!(result.graph.ecount(), 2); // multi-edge allowed
307    }
308
309    #[test]
310    fn test_negative_weight() {
311        let input = b"a b -1.5\n";
312        let result = read_ncol(&input[..]).unwrap();
313        let w = result.weights.unwrap();
314        assert!((w[0] - (-1.5)).abs() < 1e-10);
315    }
316
317    #[test]
318    fn test_scientific_notation_weight() {
319        let input = b"a b 1.5e-3\n";
320        let result = read_ncol(&input[..]).unwrap();
321        let w = result.weights.unwrap();
322        assert!((w[0] - 0.0015).abs() < 1e-10);
323    }
324
325    #[test]
326    fn test_invalid_weight_error() {
327        let input = b"a b notanumber\n";
328        let result = read_ncol(&input[..]);
329        assert!(result.is_err());
330    }
331
332    #[test]
333    fn test_write_unweighted() {
334        let mut g = Graph::with_vertices(3);
335        g.add_edge(0, 1).unwrap();
336        g.add_edge(1, 2).unwrap();
337
338        let names = vec!["A".to_string(), "B".to_string(), "C".to_string()];
339        let mut buf = Vec::new();
340        write_ncol(&g, Some(&names), None, &mut buf).unwrap();
341        let s = String::from_utf8(buf).unwrap();
342        assert!(s.contains("A B\n"));
343        assert!(s.contains("B C\n"));
344    }
345
346    #[test]
347    fn test_write_weighted() {
348        let mut g = Graph::with_vertices(2);
349        g.add_edge(0, 1).unwrap();
350
351        let names = vec!["x".to_string(), "y".to_string()];
352        let weights = vec![2.75];
353        let mut buf = Vec::new();
354        write_ncol(&g, Some(&names), Some(&weights), &mut buf).unwrap();
355        let s = String::from_utf8(buf).unwrap();
356        assert!(s.contains("x y 2.75"));
357    }
358
359    #[test]
360    fn test_write_numeric_names() {
361        let mut g = Graph::with_vertices(2);
362        g.add_edge(0, 1).unwrap();
363
364        let mut buf = Vec::new();
365        write_ncol(&g, None, None, &mut buf).unwrap();
366        let s = String::from_utf8(buf).unwrap();
367        assert!(s.contains("0 1"));
368    }
369
370    #[test]
371    fn test_round_trip() {
372        let input = b"Alice Bob 1.0\nBob Carol 2.0\nAlice Carol 3.0\n";
373        let result = read_ncol(&input[..]).unwrap();
374
375        let mut buf = Vec::new();
376        write_ncol(
377            &result.graph,
378            Some(&result.names),
379            result.weights.as_deref(),
380            &mut buf,
381        )
382        .unwrap();
383
384        let result2 = read_ncol(&buf[..]).unwrap();
385        assert_eq!(result2.graph.vcount(), result.graph.vcount());
386        assert_eq!(result2.graph.ecount(), result.graph.ecount());
387        assert_eq!(result2.names, result.names);
388    }
389
390    #[test]
391    fn test_write_names_length_mismatch() {
392        let g = Graph::with_vertices(3);
393        let names = vec!["A".to_string()];
394        let mut buf = Vec::new();
395        assert!(write_ncol(&g, Some(&names), None, &mut buf).is_err());
396    }
397
398    #[test]
399    fn test_write_weights_length_mismatch() {
400        let mut g = Graph::with_vertices(2);
401        g.add_edge(0, 1).unwrap();
402        let weights = vec![1.0, 2.0]; // 2 weights but only 1 edge
403        let mut buf = Vec::new();
404        assert!(write_ncol(&g, None, Some(&weights), &mut buf).is_err());
405    }
406
407    // --- Attribute integration tests ---
408
409    #[test]
410    fn test_read_stores_name_attribute() {
411        let input = b"Alice Bob 1.0\nBob Carol\n";
412        let result = read_ncol(&input[..]).unwrap();
413        let g = &result.graph;
414
415        assert_eq!(
416            g.vertex_attribute("name", 0)
417                .and_then(AttributeValue::as_str),
418            Some("Alice")
419        );
420        assert_eq!(
421            g.vertex_attribute("name", 1)
422                .and_then(AttributeValue::as_str),
423            Some("Bob")
424        );
425        assert_eq!(
426            g.vertex_attribute("name", 2)
427                .and_then(AttributeValue::as_str),
428            Some("Carol")
429        );
430    }
431
432    #[test]
433    fn test_read_stores_weight_attribute() {
434        let input = b"a b 1.5\nb c 2.0\n";
435        let result = read_ncol(&input[..]).unwrap();
436        let g = &result.graph;
437
438        let w0 = g
439            .edge_attribute("weight", 0)
440            .and_then(AttributeValue::as_f64)
441            .unwrap();
442        let w1 = g
443            .edge_attribute("weight", 1)
444            .and_then(AttributeValue::as_f64)
445            .unwrap();
446        assert!((w0 - 1.5).abs() < 1e-10);
447        assert!((w1 - 2.0).abs() < 1e-10);
448    }
449
450    #[test]
451    fn test_read_no_weight_attribute_when_unweighted() {
452        let input = b"a b\nb c\n";
453        let result = read_ncol(&input[..]).unwrap();
454        assert!(result.graph.edge_attribute("weight", 0).is_none());
455    }
456
457    #[test]
458    fn test_write_fallback_to_name_attribute() {
459        let mut g = Graph::with_vertices(2);
460        g.add_edge(0, 1).unwrap();
461        g.set_vertex_attribute("name", 0, AttributeValue::String("X".into()))
462            .unwrap();
463        g.set_vertex_attribute("name", 1, AttributeValue::String("Y".into()))
464            .unwrap();
465
466        let mut buf = Vec::new();
467        write_ncol(&g, None, None, &mut buf).unwrap();
468        let s = String::from_utf8(buf).unwrap();
469        assert!(s.contains("X Y"));
470    }
471
472    #[test]
473    fn test_write_fallback_to_weight_attribute() {
474        let mut g = Graph::with_vertices(2);
475        g.add_edge(0, 1).unwrap();
476        g.set_edge_attribute("weight", 0, AttributeValue::Numeric(4.25))
477            .unwrap();
478
479        let mut buf = Vec::new();
480        write_ncol(&g, None, None, &mut buf).unwrap();
481        let s = String::from_utf8(buf).unwrap();
482        assert!(s.contains("4.25"));
483    }
484
485    #[test]
486    fn test_roundtrip_via_attributes() {
487        let input = b"Alice Bob 1.5\nBob Carol 2.5\n";
488        let result = read_ncol(&input[..]).unwrap();
489
490        // Write using attribute fallback (no explicit names/weights)
491        let mut buf = Vec::new();
492        write_ncol(&result.graph, None, None, &mut buf).unwrap();
493
494        let result2 = read_ncol(&buf[..]).unwrap();
495        assert_eq!(result2.names, vec!["Alice", "Bob", "Carol"]);
496        let w = result2.weights.unwrap();
497        assert!((w[0] - 1.5).abs() < 1e-10);
498        assert!((w[1] - 2.5).abs() < 1e-10);
499    }
500
501    #[test]
502    fn test_explicit_params_override_attributes() {
503        let mut g = Graph::with_vertices(2);
504        g.add_edge(0, 1).unwrap();
505        g.set_vertex_attribute("name", 0, AttributeValue::String("attr_A".into()))
506            .unwrap();
507        g.set_vertex_attribute("name", 1, AttributeValue::String("attr_B".into()))
508            .unwrap();
509        g.set_edge_attribute("weight", 0, AttributeValue::Numeric(9.0))
510            .unwrap();
511
512        let names = vec!["explicit_A".to_string(), "explicit_B".to_string()];
513        let weights = vec![1.0];
514        let mut buf = Vec::new();
515        write_ncol(&g, Some(&names), Some(&weights), &mut buf).unwrap();
516        let s = String::from_utf8(buf).unwrap();
517        assert!(s.contains("explicit_A explicit_B 1"));
518        assert!(!s.contains("attr_A"));
519        assert!(!s.contains('9'));
520    }
521}