rust_igraph/algorithms/io/
ncol.rs1use std::collections::HashMap;
14use std::io::{BufRead, BufReader, Read, Write};
15
16use crate::core::attributes::AttributeValue;
17use crate::core::{Graph, IgraphError, IgraphResult};
18
19#[derive(Debug, Clone)]
21pub struct NcolGraph {
22 pub graph: Graph,
24 pub names: Vec<String>,
27 pub weights: Option<Vec<f64>>,
31}
32
33pub 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 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 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
136pub 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); }
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]; let mut buf = Vec::new();
404 assert!(write_ncol(&g, None, Some(&weights), &mut buf).is_err());
405 }
406
407 #[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 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}