Skip to main content

rust_igraph/algorithms/io/
dl.rs

1//! UCINET DL format reader and writer (ALGO-IO-009 / IO-013).
2//!
3//! Reads and writes graphs in the DL format used by UCINET. Three data
4//! representations are supported for reading:
5//!
6//! - **fullmatrix**: adjacency matrix of 0/1 values
7//! - **edgelist1**: pairs of 1-based vertex IDs with optional weights
8//! - **nodelist1**: source vertex followed by its neighbors (1-based)
9//!
10//! Writing always uses **edgelist1** format.
11//!
12//! ```text
13//! DL n=5
14//! format = edgelist1
15//! data:
16//! 1 2
17//! 1 3
18//! 2 3
19//! ```
20//!
21//! Vertex labels can be provided via `labels:` or `labels embedded:`.
22//!
23//! Counterpart of `igraph_read_graph_dl` / `igraph_write_graph_dl`.
24
25use std::io::{BufRead, BufReader, Read, Write};
26
27use crate::core::attributes::AttributeValue;
28use crate::core::{Graph, IgraphError, IgraphResult};
29
30/// Result of reading a DL file.
31#[derive(Debug, Clone)]
32pub struct DlGraph {
33    /// The parsed graph.
34    pub graph: Graph,
35    /// Vertex labels (if provided).
36    pub labels: Option<Vec<String>>,
37    /// Edge weights (if provided, only for edgelist1 format).
38    pub weights: Option<Vec<f64>>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq)]
42enum DlFormat {
43    FullMatrix,
44    EdgeList1,
45    NodeList1,
46}
47
48/// Read a graph from UCINET DL format.
49///
50/// Supports fullmatrix, edgelist1, and nodelist1 data formats.
51/// Case-insensitive keywords. Vertex IDs in edgelist1 and nodelist1
52/// are 1-based.
53///
54/// # Examples
55///
56/// ```
57/// use rust_igraph::read_dl;
58///
59/// let input = b"DL n=3\nformat = edgelist1\ndata:\n1 2\n2 3\n1 3\n";
60/// let result = read_dl(&input[..], true).unwrap();
61/// assert_eq!(result.graph.vcount(), 3);
62/// assert_eq!(result.graph.ecount(), 3);
63/// ```
64#[allow(clippy::too_many_lines)]
65pub fn read_dl<R: Read>(input: R, directed: bool) -> IgraphResult<DlGraph> {
66    let reader = BufReader::new(input);
67    let mut lines: Vec<String> = Vec::new();
68
69    for line_result in reader.lines() {
70        let line = line_result?;
71        lines.push(line);
72    }
73
74    let mut pos = 0;
75    let mut n_vertices: Option<u32> = None;
76    let mut format = DlFormat::FullMatrix;
77    let mut labels: Vec<String> = Vec::new();
78    let mut labels_embedded = false;
79
80    // Parse header: DL n=N
81    pos = skip_empty(&lines, pos);
82    if pos >= lines.len() {
83        return Err(parse_err(0, "empty DL file"));
84    }
85
86    let header_line = lines[pos].trim().to_ascii_lowercase();
87    if !header_line.starts_with("dl") {
88        return Err(parse_err(pos + 1, "DL file must start with 'DL'"));
89    }
90
91    // Extract n= from header line or subsequent lines
92    let header_rest = &lines[pos].trim()[2..];
93    if let Some(n) = extract_n(header_rest) {
94        n_vertices = Some(n);
95    }
96    pos += 1;
97
98    // Parse directives until DATA:
99    loop {
100        pos = skip_empty(&lines, pos);
101        if pos >= lines.len() {
102            break;
103        }
104
105        let trimmed = lines[pos].trim();
106        let lower = trimmed.to_ascii_lowercase();
107
108        if lower.starts_with("data:") || lower == "data" {
109            pos += 1;
110            break;
111        }
112
113        if lower.starts_with("n=") || lower.starts_with("n =") {
114            if n_vertices.is_none() {
115                let val =
116                    extract_n(trimmed).ok_or_else(|| parse_err(pos + 1, "invalid n= value"))?;
117                n_vertices = Some(val);
118            }
119            pos += 1;
120            continue;
121        }
122
123        if lower.starts_with("format") {
124            if lower.contains("fullmatrix") || lower.contains("full matrix") {
125                format = DlFormat::FullMatrix;
126            } else if lower.contains("edgelist1")
127                || lower.contains("edge list1")
128                || lower.contains("edgelist 1")
129            {
130                format = DlFormat::EdgeList1;
131            } else if lower.contains("nodelist1")
132                || lower.contains("node list1")
133                || lower.contains("nodelist 1")
134            {
135                format = DlFormat::NodeList1;
136            }
137            pos += 1;
138            continue;
139        }
140
141        if lower.starts_with("labels embedded") {
142            labels_embedded = true;
143            pos += 1;
144            continue;
145        }
146
147        if lower.starts_with("labels:") || lower == "labels" {
148            pos += 1;
149            // Read label lines until next keyword or data:
150            while pos < lines.len() {
151                let lbl_line = lines[pos].trim();
152                let lbl_lower = lbl_line.to_ascii_lowercase();
153                if lbl_lower.starts_with("data")
154                    || lbl_lower.starts_with("format")
155                    || lbl_lower.starts_with("labels")
156                {
157                    break;
158                }
159                if !lbl_line.is_empty() {
160                    // Labels can be comma or whitespace separated on one line
161                    for token in split_labels(lbl_line) {
162                        labels.push(token);
163                    }
164                }
165                pos += 1;
166            }
167            continue;
168        }
169
170        // Try to extract n= from lines like "N = 5"
171        if let Some(n) = extract_n(trimmed) {
172            if n_vertices.is_none() {
173                n_vertices = Some(n);
174            }
175            pos += 1;
176            continue;
177        }
178
179        pos += 1;
180    }
181
182    let n = n_vertices.ok_or_else(|| parse_err(0, "no vertex count (n=) found in DL file"))?;
183
184    let mut edges: Vec<(u32, u32)> = Vec::new();
185    let mut weights: Vec<f64> = Vec::new();
186    let mut has_weights = false;
187
188    match format {
189        DlFormat::FullMatrix => {
190            if labels_embedded {
191                // First row is label row, then labeled rows
192                pos = skip_empty(&lines, pos);
193                if pos < lines.len() {
194                    let header_labels = split_labels(lines[pos].trim());
195                    if labels.is_empty() {
196                        labels = header_labels;
197                    }
198                    pos += 1;
199                }
200                let mut row = 0u32;
201                while pos < lines.len() && row < n {
202                    let trimmed = lines[pos].trim();
203                    if trimmed.is_empty() {
204                        pos += 1;
205                        continue;
206                    }
207                    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
208                    if tokens.is_empty() {
209                        pos += 1;
210                        continue;
211                    }
212                    // First token is the row label (skip), rest are 0/1
213                    let data_start = 1;
214                    for (col, &val) in tokens.iter().skip(data_start).enumerate() {
215                        if val == "1" {
216                            #[allow(clippy::cast_possible_truncation)]
217                            edges.push((row, col as u32));
218                        }
219                    }
220                    row += 1;
221                    pos += 1;
222                }
223            } else {
224                let mut row = 0u32;
225                while pos < lines.len() && row < n {
226                    let trimmed = lines[pos].trim();
227                    if trimmed.is_empty() {
228                        pos += 1;
229                        continue;
230                    }
231                    for (col, ch) in trimmed.split_whitespace().enumerate() {
232                        if ch == "1" {
233                            #[allow(clippy::cast_possible_truncation)]
234                            edges.push((row, col as u32));
235                        }
236                    }
237                    row += 1;
238                    pos += 1;
239                }
240            }
241        }
242        DlFormat::EdgeList1 => {
243            if labels_embedded {
244                while pos < lines.len() {
245                    let trimmed = lines[pos].trim();
246                    if trimmed.is_empty() {
247                        pos += 1;
248                        continue;
249                    }
250                    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
251                    if tokens.len() < 2 {
252                        pos += 1;
253                        continue;
254                    }
255                    let from_label = tokens[0].to_string();
256                    let to_label = tokens[1].to_string();
257                    let from_id = get_or_add_label(&mut labels, &from_label);
258                    let to_id = get_or_add_label(&mut labels, &to_label);
259                    edges.push((from_id, to_id));
260                    if tokens.len() >= 3 {
261                        if let Ok(w) = tokens[2].parse::<f64>() {
262                            has_weights = true;
263                            weights.push(w);
264                        } else {
265                            weights.push(0.0);
266                        }
267                    } else {
268                        weights.push(0.0);
269                    }
270                    pos += 1;
271                }
272            } else {
273                while pos < lines.len() {
274                    let trimmed = lines[pos].trim();
275                    if trimmed.is_empty() {
276                        pos += 1;
277                        continue;
278                    }
279                    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
280                    if tokens.len() < 2 {
281                        pos += 1;
282                        continue;
283                    }
284                    let from: u32 = tokens[0]
285                        .parse()
286                        .map_err(|e| parse_err(pos + 1, &format!("invalid source id: {e}")))?;
287                    let to: u32 = tokens[1]
288                        .parse()
289                        .map_err(|e| parse_err(pos + 1, &format!("invalid target id: {e}")))?;
290                    if from == 0 || to == 0 || from > n || to > n {
291                        return Err(parse_err(
292                            pos + 1,
293                            &format!("vertex ID out of range: {from} {to} (n={n})"),
294                        ));
295                    }
296                    edges.push((from - 1, to - 1));
297                    if tokens.len() >= 3 {
298                        if let Ok(w) = tokens[2].parse::<f64>() {
299                            has_weights = true;
300                            weights.push(w);
301                        } else {
302                            weights.push(0.0);
303                        }
304                    } else {
305                        weights.push(0.0);
306                    }
307                    pos += 1;
308                }
309            }
310        }
311        DlFormat::NodeList1 => {
312            if labels_embedded {
313                while pos < lines.len() {
314                    let trimmed = lines[pos].trim();
315                    if trimmed.is_empty() {
316                        pos += 1;
317                        continue;
318                    }
319                    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
320                    if tokens.is_empty() {
321                        pos += 1;
322                        continue;
323                    }
324                    let from_label = tokens[0].to_string();
325                    let from_id = get_or_add_label(&mut labels, &from_label);
326                    for &tok in &tokens[1..] {
327                        let to_label = tok.to_string();
328                        let to_id = get_or_add_label(&mut labels, &to_label);
329                        edges.push((from_id, to_id));
330                    }
331                    pos += 1;
332                }
333            } else {
334                while pos < lines.len() {
335                    let trimmed = lines[pos].trim();
336                    if trimmed.is_empty() {
337                        pos += 1;
338                        continue;
339                    }
340                    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
341                    if tokens.is_empty() {
342                        pos += 1;
343                        continue;
344                    }
345                    let from: u32 = tokens[0]
346                        .parse()
347                        .map_err(|e| parse_err(pos + 1, &format!("invalid source id: {e}")))?;
348                    if from == 0 || from > n {
349                        return Err(parse_err(
350                            pos + 1,
351                            &format!("source vertex ID out of range: {from} (n={n})"),
352                        ));
353                    }
354                    for &tok in &tokens[1..] {
355                        let to: u32 = tok
356                            .parse()
357                            .map_err(|e| parse_err(pos + 1, &format!("invalid target id: {e}")))?;
358                        if to == 0 || to > n {
359                            return Err(parse_err(
360                                pos + 1,
361                                &format!("target vertex ID out of range: {to} (n={n})"),
362                            ));
363                        }
364                        edges.push((from - 1, to - 1));
365                    }
366                    pos += 1;
367                }
368            }
369        }
370    }
371
372    let mut graph = Graph::new(n, directed)?;
373    graph.add_edges(edges)?;
374
375    let final_labels = if labels.is_empty() {
376        None
377    } else {
378        Some(labels)
379    };
380    let final_weights = if has_weights { Some(weights) } else { None };
381
382    if let Some(ref lbls) = final_labels {
383        graph.set_vertex_attribute_all(
384            "name",
385            lbls.iter()
386                .map(|l| AttributeValue::String(l.clone()))
387                .collect(),
388        )?;
389    }
390    if let Some(ref wts) = final_weights {
391        graph.set_edge_attribute_all(
392            "weight",
393            wts.iter().map(|&w| AttributeValue::Numeric(w)).collect(),
394        )?;
395    }
396
397    Ok(DlGraph {
398        graph,
399        labels: final_labels,
400        weights: final_weights,
401    })
402}
403
404fn parse_err(line: usize, msg: &str) -> IgraphError {
405    IgraphError::Parse {
406        line,
407        message: msg.to_string(),
408    }
409}
410
411fn skip_empty(lines: &[String], mut pos: usize) -> usize {
412    while pos < lines.len() && lines[pos].trim().is_empty() {
413        pos += 1;
414    }
415    pos
416}
417
418fn extract_n(s: &str) -> Option<u32> {
419    let lower = s.to_ascii_lowercase();
420    // Look for n=N or n = N pattern
421    for part in lower.split([',', ';']) {
422        let trimmed = part.trim();
423        if let Some(after_n) = trimmed.strip_prefix('n') {
424            let rest = after_n.trim();
425            if let Some(stripped) = rest.strip_prefix('=') {
426                if let Ok(val) = stripped.trim().parse::<u32>() {
427                    return Some(val);
428                }
429            }
430        }
431    }
432    None
433}
434
435fn split_labels(s: &str) -> Vec<String> {
436    if s.contains(',') {
437        s.split(',')
438            .map(|t| t.trim().trim_matches('"').to_string())
439            .filter(|t| !t.is_empty())
440            .collect()
441    } else {
442        s.split_whitespace()
443            .map(|t| t.trim_matches('"').to_string())
444            .collect()
445    }
446}
447
448fn get_or_add_label(labels: &mut Vec<String>, name: &str) -> u32 {
449    for (i, lbl) in labels.iter().enumerate() {
450        if lbl == name {
451            #[allow(clippy::cast_possible_truncation)]
452            return i as u32;
453        }
454    }
455    labels.push(name.to_string());
456    #[allow(clippy::cast_possible_truncation)]
457    let id = (labels.len() - 1) as u32;
458    id
459}
460
461/// Write a graph in UCINET DL edgelist1 format.
462///
463/// Uses the `edgelist1` representation with 1-based vertex IDs. Vertex
464/// labels are emitted as a `labels:` section if provided. Edge weights
465/// appear as a third field on each edge line.
466///
467/// # Examples
468///
469/// ```
470/// use rust_igraph::{Graph, write_dl};
471///
472/// let mut g = Graph::with_vertices(3);
473/// g.add_edge(0, 1).unwrap();
474/// g.add_edge(1, 2).unwrap();
475///
476/// let mut buf = Vec::new();
477/// write_dl(&g, None, None, &mut buf).unwrap();
478/// let s = String::from_utf8(buf).unwrap();
479/// assert!(s.contains("DL n=3"));
480/// assert!(s.contains("format = edgelist1"));
481/// ```
482pub fn write_dl<W: Write>(
483    graph: &Graph,
484    vertex_labels: Option<&[String]>,
485    edge_weights: Option<&[f64]>,
486    writer: &mut W,
487) -> IgraphResult<()> {
488    if let Some(l) = vertex_labels {
489        if l.len() != graph.vcount() as usize {
490            return Err(IgraphError::InvalidArgument(format!(
491                "vertex_labels length {} does not match vcount {}",
492                l.len(),
493                graph.vcount()
494            )));
495        }
496    }
497    if let Some(w) = edge_weights {
498        if w.len() != graph.ecount() {
499            return Err(IgraphError::InvalidArgument(format!(
500                "edge_weights length {} does not match ecount {}",
501                w.len(),
502                graph.ecount()
503            )));
504        }
505    }
506
507    writeln!(writer, "DL n={}", graph.vcount())?;
508    writeln!(writer, "format = edgelist1")?;
509
510    let has_attr_labels =
511        vertex_labels.is_none() && graph.vertex_attribute_names().contains(&"name");
512    if let Some(labels) = vertex_labels {
513        writeln!(writer, "labels:")?;
514        let joined: Vec<&str> = labels.iter().map(String::as_str).collect();
515        writeln!(writer, "{}", joined.join(","))?;
516    } else if has_attr_labels {
517        writeln!(writer, "labels:")?;
518        let attr_labels: Vec<String> = (0..graph.vcount())
519            .map(|v| {
520                graph
521                    .vertex_attribute("name", v)
522                    .and_then(AttributeValue::as_str)
523                    .unwrap_or("")
524                    .to_owned()
525            })
526            .collect();
527        let joined: Vec<&str> = attr_labels.iter().map(String::as_str).collect();
528        writeln!(writer, "{}", joined.join(","))?;
529    }
530
531    writeln!(writer, "data:")?;
532
533    for eid in 0..graph.ecount() {
534        #[allow(clippy::cast_possible_truncation)]
535        let (from, to) = graph.edge(eid as u32)?;
536
537        if let Some(w) = edge_weights {
538            writeln!(writer, "{} {} {}", from + 1, to + 1, w[eid])?;
539        } else {
540            #[allow(clippy::cast_possible_truncation)]
541            let eid_u32 = eid as u32;
542            if let Some(w) = graph
543                .edge_attribute("weight", eid_u32)
544                .and_then(AttributeValue::as_f64)
545            {
546                writeln!(writer, "{} {} {w}", from + 1, to + 1)?;
547            } else {
548                writeln!(writer, "{} {}", from + 1, to + 1)?;
549            }
550        }
551    }
552
553    Ok(())
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_edgelist1_basic() {
562        let input = b"DL n=3\nformat = edgelist1\ndata:\n1 2\n2 3\n1 3\n";
563        let result = read_dl(&input[..], true).unwrap();
564        assert_eq!(result.graph.vcount(), 3);
565        assert_eq!(result.graph.ecount(), 3);
566        assert!(result.graph.is_directed());
567    }
568
569    #[test]
570    fn test_edgelist1_undirected() {
571        let input = b"DL n=3\nformat = edgelist1\ndata:\n1 2\n2 3\n";
572        let result = read_dl(&input[..], false).unwrap();
573        assert_eq!(result.graph.vcount(), 3);
574        assert_eq!(result.graph.ecount(), 2);
575        assert!(!result.graph.is_directed());
576    }
577
578    #[test]
579    fn test_edgelist1_with_weights() {
580        let input = b"DL n=3\nformat = edgelist1\ndata:\n1 2 1.5\n2 3 2.5\n";
581        let result = read_dl(&input[..], true).unwrap();
582        let w = result.weights.unwrap();
583        assert!((w[0] - 1.5).abs() < 1e-10);
584        assert!((w[1] - 2.5).abs() < 1e-10);
585    }
586
587    #[test]
588    fn test_edgelist1_with_labels() {
589        let input = b"DL n=3\nformat = edgelist1\nlabels:\nA,B,C\ndata:\n1 2\n2 3\n";
590        let result = read_dl(&input[..], true).unwrap();
591        let labels = result.labels.unwrap();
592        assert_eq!(labels, vec!["A", "B", "C"]);
593    }
594
595    #[test]
596    fn test_edgelist1_labels_embedded() {
597        let input = b"DL n=3\nformat = edgelist1\nlabels embedded:\ndata:\nAlice Bob\nBob Carol\n";
598        let result = read_dl(&input[..], true).unwrap();
599        assert_eq!(result.graph.ecount(), 2);
600        let labels = result.labels.unwrap();
601        assert_eq!(labels[0], "Alice");
602        assert_eq!(labels[1], "Bob");
603        assert_eq!(labels[2], "Carol");
604    }
605
606    #[test]
607    fn test_fullmatrix_basic() {
608        let input = b"DL n=3\nformat = fullmatrix\ndata:\n0 1 0\n0 0 1\n1 0 0\n";
609        let result = read_dl(&input[..], true).unwrap();
610        assert_eq!(result.graph.vcount(), 3);
611        assert_eq!(result.graph.ecount(), 3);
612    }
613
614    #[test]
615    fn test_fullmatrix_default_format() {
616        // No format line = default fullmatrix
617        let input = b"DL n=3\ndata:\n0 1 1\n1 0 0\n0 1 0\n";
618        let result = read_dl(&input[..], true).unwrap();
619        assert_eq!(result.graph.vcount(), 3);
620        assert_eq!(result.graph.ecount(), 4);
621    }
622
623    #[test]
624    fn test_fullmatrix_labels_embedded() {
625        let input = b"DL n=3\nlabels embedded:\ndata:\nA B C\nA 0 1 0\nB 0 0 1\nC 1 0 0\n";
626        let result = read_dl(&input[..], true).unwrap();
627        assert_eq!(result.graph.vcount(), 3);
628        assert_eq!(result.graph.ecount(), 3);
629        let labels = result.labels.unwrap();
630        assert_eq!(labels, vec!["A", "B", "C"]);
631    }
632
633    #[test]
634    fn test_nodelist1_basic() {
635        let input = b"DL n=4\nformat = nodelist1\ndata:\n1 2 3\n2 3\n3 4\n";
636        let result = read_dl(&input[..], true).unwrap();
637        assert_eq!(result.graph.vcount(), 4);
638        assert_eq!(result.graph.ecount(), 4);
639    }
640
641    #[test]
642    fn test_nodelist1_labels_embedded() {
643        let input = b"DL n=3\nformat = nodelist1\nlabels embedded:\ndata:\nA B C\nB C\n";
644        let result = read_dl(&input[..], true).unwrap();
645        assert_eq!(result.graph.ecount(), 3);
646        let labels = result.labels.unwrap();
647        assert_eq!(labels[0], "A");
648    }
649
650    #[test]
651    fn test_case_insensitive() {
652        let input = b"dl N=2\nFORMAT = EDGELIST1\nDATA:\n1 2\n";
653        let result = read_dl(&input[..], true).unwrap();
654        assert_eq!(result.graph.vcount(), 2);
655        assert_eq!(result.graph.ecount(), 1);
656    }
657
658    #[test]
659    fn test_empty_graph() {
660        let input = b"DL n=5\nformat = edgelist1\ndata:\n";
661        let result = read_dl(&input[..], true).unwrap();
662        assert_eq!(result.graph.vcount(), 5);
663        assert_eq!(result.graph.ecount(), 0);
664    }
665
666    #[test]
667    fn test_no_dl_header_error() {
668        let input = b"n=3\ndata:\n1 2\n";
669        let result = read_dl(&input[..], true);
670        assert!(result.is_err());
671    }
672
673    #[test]
674    fn test_no_n_error() {
675        let input = b"DL\nformat = edgelist1\ndata:\n1 2\n";
676        let result = read_dl(&input[..], true);
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn test_vertex_id_out_of_range() {
682        let input = b"DL n=2\nformat = edgelist1\ndata:\n1 5\n";
683        let result = read_dl(&input[..], true);
684        assert!(result.is_err());
685    }
686
687    #[test]
688    fn test_zero_vertex_id_error() {
689        let input = b"DL n=2\nformat = edgelist1\ndata:\n0 1\n";
690        let result = read_dl(&input[..], true);
691        assert!(result.is_err());
692    }
693
694    #[test]
695    fn test_n_on_same_line() {
696        let input = b"DL n=4\nformat=edgelist1\ndata:\n1 2\n3 4\n";
697        let result = read_dl(&input[..], true).unwrap();
698        assert_eq!(result.graph.vcount(), 4);
699        assert_eq!(result.graph.ecount(), 2);
700    }
701
702    #[test]
703    fn test_labels_whitespace_separated() {
704        let input = b"DL n=3\nformat = edgelist1\nlabels:\nAlpha Beta Gamma\ndata:\n1 2\n";
705        let result = read_dl(&input[..], true).unwrap();
706        let labels = result.labels.unwrap();
707        assert_eq!(labels, vec!["Alpha", "Beta", "Gamma"]);
708    }
709
710    // --- write_dl tests ---
711
712    #[test]
713    fn test_write_basic_directed() {
714        let mut g = Graph::new(3, true).unwrap();
715        g.add_edge(0, 1).unwrap();
716        g.add_edge(1, 2).unwrap();
717
718        let mut buf = Vec::new();
719        write_dl(&g, None, None, &mut buf).unwrap();
720        let s = String::from_utf8(buf).unwrap();
721
722        assert!(s.contains("DL n=3"));
723        assert!(s.contains("format = edgelist1"));
724        assert!(s.contains("data:"));
725        assert!(s.contains("1 2\n"));
726        assert!(s.contains("2 3\n"));
727    }
728
729    #[test]
730    fn test_write_with_labels() {
731        let mut g = Graph::with_vertices(3);
732        g.add_edge(0, 1).unwrap();
733
734        let labels = vec!["A".to_string(), "B".to_string(), "C".to_string()];
735        let mut buf = Vec::new();
736        write_dl(&g, Some(&labels), None, &mut buf).unwrap();
737        let s = String::from_utf8(buf).unwrap();
738
739        assert!(s.contains("labels:"));
740        assert!(s.contains("A,B,C"));
741    }
742
743    #[test]
744    fn test_write_with_weights() {
745        let mut g = Graph::with_vertices(2);
746        g.add_edge(0, 1).unwrap();
747
748        let weights = vec![3.5];
749        let mut buf = Vec::new();
750        write_dl(&g, None, Some(&weights), &mut buf).unwrap();
751        let s = String::from_utf8(buf).unwrap();
752
753        assert!(s.contains("1 2 3.5\n"));
754    }
755
756    #[test]
757    fn test_write_empty_graph() {
758        let g = Graph::with_vertices(0);
759
760        let mut buf = Vec::new();
761        write_dl(&g, None, None, &mut buf).unwrap();
762        let s = String::from_utf8(buf).unwrap();
763
764        assert!(s.contains("DL n=0"));
765        assert!(s.contains("data:"));
766    }
767
768    #[test]
769    fn test_write_no_edges() {
770        let g = Graph::with_vertices(5);
771
772        let mut buf = Vec::new();
773        write_dl(&g, None, None, &mut buf).unwrap();
774        let s = String::from_utf8(buf).unwrap();
775
776        assert!(s.contains("DL n=5"));
777        let after_data = s.split("data:\n").nth(1).unwrap();
778        assert!(after_data.trim().is_empty());
779    }
780
781    #[test]
782    fn test_write_label_mismatch_error() {
783        let g = Graph::with_vertices(3);
784        let labels = vec!["A".to_string()];
785        let mut buf = Vec::new();
786        assert!(write_dl(&g, Some(&labels), None, &mut buf).is_err());
787    }
788
789    #[test]
790    fn test_write_weight_mismatch_error() {
791        let mut g = Graph::with_vertices(2);
792        g.add_edge(0, 1).unwrap();
793        let weights = vec![1.0, 2.0];
794        let mut buf = Vec::new();
795        assert!(write_dl(&g, None, Some(&weights), &mut buf).is_err());
796    }
797
798    #[test]
799    fn test_roundtrip_directed() {
800        let mut g = Graph::new(4, true).unwrap();
801        g.add_edge(0, 1).unwrap();
802        g.add_edge(1, 2).unwrap();
803        g.add_edge(2, 3).unwrap();
804
805        let mut buf = Vec::new();
806        write_dl(&g, None, None, &mut buf).unwrap();
807        let result = read_dl(&buf[..], true).unwrap();
808
809        assert_eq!(result.graph.vcount(), g.vcount());
810        assert_eq!(result.graph.ecount(), g.ecount());
811        assert!(result.graph.is_directed());
812    }
813
814    #[test]
815    fn test_roundtrip_undirected() {
816        let mut g = Graph::with_vertices(3);
817        g.add_edge(0, 1).unwrap();
818        g.add_edge(1, 2).unwrap();
819
820        let mut buf = Vec::new();
821        write_dl(&g, None, None, &mut buf).unwrap();
822        let result = read_dl(&buf[..], false).unwrap();
823
824        assert_eq!(result.graph.vcount(), g.vcount());
825        assert_eq!(result.graph.ecount(), g.ecount());
826        assert!(!result.graph.is_directed());
827    }
828
829    #[test]
830    fn test_roundtrip_with_labels() {
831        let mut g = Graph::with_vertices(3);
832        g.add_edge(0, 1).unwrap();
833        g.add_edge(1, 2).unwrap();
834
835        let labels = vec!["X".to_string(), "Y".to_string(), "Z".to_string()];
836        let mut buf = Vec::new();
837        write_dl(&g, Some(&labels), None, &mut buf).unwrap();
838        let result = read_dl(&buf[..], false).unwrap();
839
840        assert_eq!(result.labels.unwrap(), labels);
841    }
842
843    #[test]
844    fn test_roundtrip_with_weights() {
845        let mut g = Graph::with_vertices(2);
846        g.add_edge(0, 1).unwrap();
847
848        let weights = vec![2.75];
849        let mut buf = Vec::new();
850        write_dl(&g, None, Some(&weights), &mut buf).unwrap();
851        let result = read_dl(&buf[..], false).unwrap();
852
853        let w = result.weights.unwrap();
854        assert!((w[0] - 2.75).abs() < 1e-10);
855    }
856
857    #[test]
858    fn test_roundtrip_with_labels_and_weights() {
859        let mut g = Graph::new(3, true).unwrap();
860        g.add_edge(0, 1).unwrap();
861        g.add_edge(1, 2).unwrap();
862
863        let labels = vec!["A".to_string(), "B".to_string(), "C".to_string()];
864        let weights = vec![1.5, 2.5];
865        let mut buf = Vec::new();
866        write_dl(&g, Some(&labels), Some(&weights), &mut buf).unwrap();
867        let result = read_dl(&buf[..], true).unwrap();
868
869        assert_eq!(result.graph.vcount(), 3);
870        assert_eq!(result.graph.ecount(), 2);
871        assert_eq!(result.labels.unwrap(), labels);
872        let w = result.weights.unwrap();
873        assert!((w[0] - 1.5).abs() < 1e-10);
874        assert!((w[1] - 2.5).abs() < 1e-10);
875    }
876
877    #[test]
878    fn test_write_self_loop() {
879        let mut g = Graph::with_vertices(2);
880        g.add_edge(0, 0).unwrap();
881
882        let mut buf = Vec::new();
883        write_dl(&g, None, None, &mut buf).unwrap();
884        let s = String::from_utf8(buf).unwrap();
885
886        assert!(s.contains("1 1\n"));
887    }
888
889    #[test]
890    fn test_write_one_based_ids() {
891        let mut g = Graph::with_vertices(4);
892        g.add_edge(2, 3).unwrap();
893
894        let mut buf = Vec::new();
895        write_dl(&g, None, None, &mut buf).unwrap();
896        let s = String::from_utf8(buf).unwrap();
897
898        assert!(s.contains("3 4\n"));
899    }
900
901    // --- Attribute integration tests ---
902
903    #[test]
904    fn test_read_stores_label_attribute() {
905        let input = b"DL n=3\nformat = edgelist1\nlabels:\nAlice,Bob,Carol\ndata:\n1 2\n";
906        let result = read_dl(&input[..], false).unwrap();
907        assert_eq!(
908            result
909                .graph
910                .vertex_attribute("name", 0)
911                .and_then(AttributeValue::as_str),
912            Some("Alice")
913        );
914        assert_eq!(
915            result
916                .graph
917                .vertex_attribute("name", 2)
918                .and_then(AttributeValue::as_str),
919            Some("Carol")
920        );
921    }
922
923    #[test]
924    fn test_read_stores_weight_attribute() {
925        let input = b"DL n=2\nformat = edgelist1\ndata:\n1 2 4.5\n";
926        let result = read_dl(&input[..], false).unwrap();
927        let w = result
928            .graph
929            .edge_attribute("weight", 0)
930            .and_then(AttributeValue::as_f64)
931            .unwrap();
932        assert!((w - 4.5).abs() < 1e-10);
933    }
934
935    #[test]
936    fn test_read_no_label_attribute_when_absent() {
937        let input = b"DL n=2\nformat = edgelist1\ndata:\n1 2\n";
938        let result = read_dl(&input[..], false).unwrap();
939        assert!(result.graph.vertex_attribute("name", 0).is_none());
940    }
941
942    #[test]
943    fn test_write_fallback_to_label_attribute() {
944        let mut g = Graph::with_vertices(2);
945        g.add_edge(0, 1).unwrap();
946        g.set_vertex_attribute("name", 0, AttributeValue::String("X".into()))
947            .unwrap();
948        g.set_vertex_attribute("name", 1, AttributeValue::String("Y".into()))
949            .unwrap();
950
951        let mut buf = Vec::new();
952        write_dl(&g, None, None, &mut buf).unwrap();
953        let s = String::from_utf8(buf).unwrap();
954        assert!(s.contains("labels:"));
955        assert!(s.contains("X,Y"));
956    }
957
958    #[test]
959    fn test_write_fallback_to_weight_attribute() {
960        let mut g = Graph::with_vertices(2);
961        g.add_edge(0, 1).unwrap();
962        g.set_edge_attribute("weight", 0, AttributeValue::Numeric(7.5))
963            .unwrap();
964
965        let mut buf = Vec::new();
966        write_dl(&g, None, None, &mut buf).unwrap();
967        let s = String::from_utf8(buf).unwrap();
968        assert!(s.contains("1 2 7.5"));
969    }
970
971    #[test]
972    fn test_roundtrip_via_attributes() {
973        let input =
974            b"DL n=3\nformat = edgelist1\nlabels:\nAlice,Bob,Carol\ndata:\n1 2 1.5\n2 3 2.5\n";
975        let result = read_dl(&input[..], false).unwrap();
976
977        let mut buf = Vec::new();
978        write_dl(&result.graph, None, None, &mut buf).unwrap();
979
980        let result2 = read_dl(&buf[..], false).unwrap();
981        assert_eq!(result2.graph.vcount(), 3);
982        assert_eq!(result2.graph.ecount(), 2);
983        assert!(result2.labels.is_some());
984        assert!(result2.weights.is_some());
985    }
986
987    #[test]
988    fn test_explicit_params_override_attributes() {
989        let mut g = Graph::with_vertices(2);
990        g.add_edge(0, 1).unwrap();
991        g.set_vertex_attribute("name", 0, AttributeValue::String("attr_A".into()))
992            .unwrap();
993        g.set_vertex_attribute("name", 1, AttributeValue::String("attr_B".into()))
994            .unwrap();
995        g.set_edge_attribute("weight", 0, AttributeValue::Numeric(9.0))
996            .unwrap();
997
998        let labels = vec!["explicit_A".to_string(), "explicit_B".to_string()];
999        let weights = vec![1.0];
1000        let mut buf = Vec::new();
1001        write_dl(&g, Some(&labels), Some(&weights), &mut buf).unwrap();
1002        let s = String::from_utf8(buf).unwrap();
1003        assert!(s.contains("explicit_A,explicit_B"));
1004        assert!(!s.contains("attr_A"));
1005        assert!(s.contains("1 2 1"));
1006    }
1007}