hedl_csv/
to_csv.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Convert HEDL documents to CSV format.
19
20use crate::error::{CsvError, Result};
21use hedl_core::{Document, MatrixList, Tensor, Value};
22use std::io::Write;
23
24/// Configuration for CSV output.
25#[derive(Debug, Clone)]
26pub struct ToCsvConfig {
27    /// Field delimiter (default: ',')
28    pub delimiter: u8,
29    /// Include header row (default: true)
30    pub include_headers: bool,
31    /// Quote style for fields (default: necessary)
32    pub quote_style: csv::QuoteStyle,
33}
34
35impl Default for ToCsvConfig {
36    fn default() -> Self {
37        Self {
38            delimiter: b',',
39            include_headers: true,
40            quote_style: csv::QuoteStyle::Necessary,
41        }
42    }
43}
44
45/// Convert a HEDL document to CSV string.
46///
47/// # Example
48/// ```no_run
49/// use hedl_core::Document;
50/// use hedl_csv::to_csv;
51///
52/// let doc = Document::new((1, 0));
53/// let csv_string = to_csv(&doc).unwrap();
54/// ```
55pub fn to_csv(doc: &Document) -> Result<String> {
56    to_csv_with_config(doc, ToCsvConfig::default())
57}
58
59/// Convert a specific matrix list from a HEDL document to CSV string.
60///
61/// Exports only the specified named list to CSV format, with configurable
62/// header row and delimiter options. Nested children are skipped with a warning
63/// (logged as part of error handling if strict mode desired).
64///
65/// # Arguments
66///
67/// * `doc` - The HEDL document
68/// * `list_name` - The name of the matrix list to export (e.g., "people", "items")
69///
70/// # Returns
71///
72/// A CSV-formatted string containing the specified list data
73///
74/// # Errors
75///
76/// Returns `HedlError` if:
77/// - The named list does not exist in the document
78/// - The list is not a `MatrixList` (i.e., it's a scalar or object)
79/// - CSV serialization fails
80///
81/// # Example
82/// ```no_run
83/// use hedl_core::Document;
84/// use hedl_csv::to_csv_list;
85///
86/// let doc = Document::new((1, 0));
87/// let csv_string = to_csv_list(&doc, "people").unwrap();
88/// println!("{}", csv_string);
89/// ```
90pub fn to_csv_list(doc: &Document, list_name: &str) -> Result<String> {
91    to_csv_list_with_config(doc, list_name, ToCsvConfig::default())
92}
93
94/// Convert a specific matrix list from a HEDL document to CSV string with custom configuration.
95///
96/// # Arguments
97///
98/// * `doc` - The HEDL document
99/// * `list_name` - The name of the matrix list to export
100/// * `config` - Custom CSV configuration (delimiter, headers, quote style)
101///
102/// # Example
103/// ```no_run
104/// use hedl_core::Document;
105/// use hedl_csv::{to_csv_list_with_config, ToCsvConfig};
106///
107/// let doc = Document::new((1, 0));
108/// let config = ToCsvConfig {
109///     delimiter: b';',
110///     include_headers: true,
111///     ..Default::default()
112/// };
113/// let csv_string = to_csv_list_with_config(&doc, "people", config).unwrap();
114/// ```
115pub fn to_csv_list_with_config(
116    doc: &Document,
117    list_name: &str,
118    config: ToCsvConfig,
119) -> Result<String> {
120    let estimated_size = estimate_list_csv_size(doc, list_name);
121    let mut buffer = Vec::with_capacity(estimated_size);
122
123    to_csv_list_writer_with_config(doc, list_name, &mut buffer, config)?;
124    String::from_utf8(buffer).map_err(|_| {
125        CsvError::InvalidUtf8 {
126            context: "CSV output".to_string(),
127        }
128    })
129}
130
131/// Write a specific matrix list to CSV format using a writer.
132///
133/// # Arguments
134///
135/// * `doc` - The HEDL document
136/// * `list_name` - The name of the matrix list to export
137/// * `writer` - The output writer (file, buffer, etc.)
138///
139/// # Example
140/// ```no_run
141/// use hedl_core::Document;
142/// use hedl_csv::to_csv_list_writer;
143/// use std::fs::File;
144///
145/// let doc = Document::new((1, 0));
146/// let file = File::create("output.csv").unwrap();
147/// to_csv_list_writer(&doc, "people", file).unwrap();
148/// ```
149pub fn to_csv_list_writer<W: Write>(
150    doc: &Document,
151    list_name: &str,
152    writer: W,
153) -> Result<()> {
154    to_csv_list_writer_with_config(doc, list_name, writer, ToCsvConfig::default())
155}
156
157/// Write a specific matrix list to CSV format with custom configuration.
158///
159/// # Arguments
160///
161/// * `doc` - The HEDL document
162/// * `list_name` - The name of the matrix list to export
163/// * `writer` - The output writer (file, buffer, etc.)
164/// * `config` - Custom CSV configuration
165pub fn to_csv_list_writer_with_config<W: Write>(
166    doc: &Document,
167    list_name: &str,
168    writer: W,
169    config: ToCsvConfig,
170) -> Result<()> {
171    // Find the specified list
172    let matrix_list = find_matrix_list_by_name(doc, list_name)?;
173
174    let mut wtr = csv::WriterBuilder::new()
175        .delimiter(config.delimiter)
176        .quote_style(config.quote_style)
177        .from_writer(writer);
178
179    // Write header row if requested
180    if config.include_headers {
181        wtr.write_record(&matrix_list.schema).map_err(|e| {
182            CsvError::Other(format!(
183                "Failed to write CSV header for list '{}': {}",
184                list_name, e
185            ))
186        })?;
187    }
188
189    // Write each row, skipping nested children
190    for node in &matrix_list.rows {
191        let record: Vec<String> = node.fields.iter().map(value_to_csv_string).collect();
192
193        wtr.write_record(&record).map_err(|e| {
194            CsvError::Other(format!(
195                "Failed to write CSV record for id '{}' in list '{}': {}",
196                node.id, list_name, e
197            ))
198        })?;
199
200        // Note: Nested children in node.children are intentionally skipped.
201        // If a caller needs to export nested data, they should export those lists separately.
202    }
203
204    wtr.flush().map_err(|e| {
205        CsvError::Other(format!("Failed to flush CSV writer for list '{}': {}", list_name, e))
206    })?;
207
208    Ok(())
209}
210
211/// Convert a HEDL document to CSV string with custom configuration.
212/// P1 OPTIMIZATION: Pre-allocate buffer capacity (1.1-1.2x speedup)
213pub fn to_csv_with_config(doc: &Document, config: ToCsvConfig) -> Result<String> {
214    // Estimate output size based on matrix list size
215    // Approximate: rows * columns * 20 bytes/cell (conservative estimate)
216    let estimated_size = estimate_csv_size(doc);
217    let mut buffer = Vec::with_capacity(estimated_size);
218
219    to_csv_writer_with_config(doc, &mut buffer, config)?;
220    String::from_utf8(buffer).map_err(|_| {
221        CsvError::InvalidUtf8 {
222            context: "CSV output".to_string(),
223        }
224    })
225}
226
227/// Estimate CSV output size for pre-allocation
228fn estimate_csv_size(doc: &Document) -> usize {
229    let mut total = 0;
230
231    // Scan for matrix lists and estimate size
232    for item in doc.root.values() {
233        if let Some(list) = item.as_list() {
234            // Header row: column names + commas + newline
235            let header_size = list.schema.iter().map(|s| s.len()).sum::<usize>()
236                + list.schema.len()
237                + 1;
238
239            // Data rows: conservative estimate of 20 bytes per cell
240            let row_count = list.rows.len();
241            let col_count = list.schema.len();
242            let data_size = row_count * col_count * 20;
243
244            total += header_size + data_size;
245        }
246    }
247
248    // Return at least 1KB, max estimated size
249    total.max(1024)
250}
251
252/// Estimate CSV output size for a specific list
253fn estimate_list_csv_size(doc: &Document, list_name: &str) -> usize {
254    if let Some(item) = doc.root.get(list_name) {
255        if let Some(list) = item.as_list() {
256            // Header row: column names + commas + newline
257            let header_size = list.schema.iter().map(|s| s.len()).sum::<usize>()
258                + list.schema.len()
259                + 1;
260
261            // Data rows: conservative estimate of 20 bytes per cell
262            let row_count = list.rows.len();
263            let col_count = list.schema.len();
264            let data_size = row_count * col_count * 20;
265
266            return (header_size + data_size).max(1024);
267        }
268    }
269
270    // Fallback to minimal size
271    1024
272}
273
274/// Write a HEDL document to CSV format using a writer.
275///
276/// # Example
277/// ```no_run
278/// use hedl_core::Document;
279/// use hedl_csv::to_csv_writer;
280/// use std::fs::File;
281///
282/// let doc = Document::new((1, 0));
283/// let file = File::create("output.csv").unwrap();
284/// to_csv_writer(&doc, file).unwrap();
285/// ```
286pub fn to_csv_writer<W: Write>(doc: &Document, writer: W) -> Result<()> {
287    to_csv_writer_with_config(doc, writer, ToCsvConfig::default())
288}
289
290/// Write a HEDL document to CSV format with custom configuration.
291pub fn to_csv_writer_with_config<W: Write>(
292    doc: &Document,
293    writer: W,
294    config: ToCsvConfig,
295) -> Result<()> {
296    let mut wtr = csv::WriterBuilder::new()
297        .delimiter(config.delimiter)
298        .quote_style(config.quote_style)
299        .from_writer(writer);
300
301    // Find the first matrix list in the document
302    let matrix_list = find_first_matrix_list(doc)?;
303
304    // Write header row if requested
305    // Per SPEC.md: MatrixList.schema includes all column names with ID first
306    if config.include_headers {
307        wtr.write_record(&matrix_list.schema).map_err(|e| {
308            CsvError::Other(format!("Failed to write CSV header: {}", e))
309        })?;
310    }
311
312    // Write each row
313    // Per SPEC.md: Node.fields contains ALL values including ID (first column)
314    for node in &matrix_list.rows {
315        let record: Vec<String> = node.fields.iter().map(value_to_csv_string).collect();
316
317        wtr.write_record(&record).map_err(|e| {
318            CsvError::Other(format!("Failed to write CSV record for id '{}': {}", node.id, e))
319        })?;
320    }
321
322    wtr.flush().map_err(|e| {
323        CsvError::Other(format!("Failed to flush CSV writer: {}", e))
324    })?;
325
326    Ok(())
327}
328
329/// Find the first matrix list in the document.
330fn find_first_matrix_list(doc: &Document) -> Result<&MatrixList> {
331    for item in doc.root.values() {
332        if let Some(list) = item.as_list() {
333            return Ok(list);
334        }
335    }
336
337    Err(CsvError::NoLists)
338}
339
340/// Find a matrix list by name in the document.
341fn find_matrix_list_by_name<'a>(doc: &'a Document, list_name: &str) -> Result<&'a MatrixList> {
342    match doc.root.get(list_name) {
343        Some(item) => match item.as_list() {
344            Some(list) => Ok(list),
345            None => Err(CsvError::NotAList {
346                name: list_name.to_string(),
347                actual_type: match item {
348                        hedl_core::Item::Scalar(_) => "scalar",
349                        hedl_core::Item::Object(_) => "object",
350                        hedl_core::Item::List(_) => "list",
351                    }.to_string(),
352            }),
353        },
354        None => Err(CsvError::ListNotFound {
355                name: list_name.to_string(),
356                available: if doc.root.is_empty() {
357                    "none".to_string()
358                } else {
359                    doc.root
360                        .keys()
361                        .map(|k| format!("'{}'", k))
362                        .collect::<Vec<_>>()
363                        .join(", ")
364                },
365            }),
366    }
367}
368
369/// Convert a HEDL value to CSV string representation.
370fn value_to_csv_string(value: &Value) -> String {
371    match value {
372        Value::Null => String::new(),
373        Value::Bool(b) => b.to_string(),
374        Value::Int(n) => n.to_string(),
375        Value::Float(f) => {
376            // Handle special float values
377            if f.is_nan() {
378                "NaN".to_string()
379            } else if f.is_infinite() {
380                if f.is_sign_positive() {
381                    "Infinity".to_string()
382                } else {
383                    "-Infinity".to_string()
384                }
385            } else {
386                f.to_string()
387            }
388        }
389        Value::String(s) => s.clone(),
390        Value::Reference(r) => r.to_ref_string(),
391        Value::Tensor(t) => tensor_to_json_string(t),
392        Value::Expression(e) => format!("$({})", e),
393    }
394}
395
396/// Convert a tensor to JSON-like array string representation.
397/// Examples: `[1,2,3]` or `[[1,2],[3,4]]`
398fn tensor_to_json_string(tensor: &Tensor) -> String {
399    match tensor {
400        Tensor::Scalar(n) => {
401            if n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
402                // Format as integer if it's a whole number
403                format!("{}", *n as i64)
404            } else {
405                format!("{}", n)
406            }
407        }
408        Tensor::Array(items) => {
409            let inner: Vec<String> = items.iter().map(tensor_to_json_string).collect();
410            format!("[{}]", inner.join(","))
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use hedl_core::{Document, Item, MatrixList, Node, Reference, Value};
419    use hedl_core::lex::{Expression, Span};
420
421    fn create_test_document() -> Document {
422        let mut doc = Document::new((1, 0));
423
424        // Per SPEC.md: MatrixList.schema includes all column names with ID first
425        // Node.fields contains ALL values including ID (first column)
426        let mut list = MatrixList::new(
427            "Person",
428            vec![
429                "id".to_string(),
430                "name".to_string(),
431                "age".to_string(),
432                "active".to_string(),
433            ],
434        );
435
436        list.add_row(Node::new(
437            "Person",
438            "1",
439            vec![
440                Value::String("1".to_string()),
441                Value::String("Alice".to_string()),
442                Value::Int(30),
443                Value::Bool(true),
444            ],
445        ));
446
447        list.add_row(Node::new(
448            "Person",
449            "2",
450            vec![
451                Value::String("2".to_string()),
452                Value::String("Bob".to_string()),
453                Value::Int(25),
454                Value::Bool(false),
455            ],
456        ));
457
458        doc.root.insert("people".to_string(), Item::List(list));
459        doc
460    }
461
462    // ==================== ToCsvConfig tests ====================
463
464    #[test]
465    fn test_to_csv_config_default() {
466        let config = ToCsvConfig::default();
467        assert_eq!(config.delimiter, b',');
468        assert!(config.include_headers);
469        assert!(matches!(config.quote_style, csv::QuoteStyle::Necessary));
470    }
471
472    #[test]
473    fn test_to_csv_config_debug() {
474        let config = ToCsvConfig::default();
475        let debug = format!("{:?}", config);
476        assert!(debug.contains("ToCsvConfig"));
477        assert!(debug.contains("delimiter"));
478        assert!(debug.contains("include_headers"));
479        assert!(debug.contains("quote_style"));
480    }
481
482    #[test]
483    fn test_to_csv_config_clone() {
484        let config = ToCsvConfig {
485            delimiter: b'\t',
486            include_headers: false,
487            quote_style: csv::QuoteStyle::Always,
488        };
489        let cloned = config.clone();
490        assert_eq!(cloned.delimiter, b'\t');
491        assert!(!cloned.include_headers);
492    }
493
494    #[test]
495    fn test_to_csv_config_all_options() {
496        let config = ToCsvConfig {
497            delimiter: b';',
498            include_headers: true,
499            quote_style: csv::QuoteStyle::Always,
500        };
501        assert_eq!(config.delimiter, b';');
502        assert!(config.include_headers);
503    }
504
505    // ==================== to_csv basic tests ====================
506
507    #[test]
508    fn test_to_csv_basic() {
509        let doc = create_test_document();
510        let csv = to_csv(&doc).unwrap();
511
512        let expected = "id,name,age,active\n1,Alice,30,true\n2,Bob,25,false\n";
513        assert_eq!(csv, expected);
514    }
515
516    #[test]
517    fn test_to_csv_without_headers() {
518        let doc = create_test_document();
519        let config = ToCsvConfig {
520            include_headers: false,
521            ..Default::default()
522        };
523        let csv = to_csv_with_config(&doc, config).unwrap();
524
525        let expected = "1,Alice,30,true\n2,Bob,25,false\n";
526        assert_eq!(csv, expected);
527    }
528
529    #[test]
530    fn test_to_csv_custom_delimiter() {
531        let doc = create_test_document();
532        let config = ToCsvConfig {
533            delimiter: b'\t',
534            ..Default::default()
535        };
536        let csv = to_csv_with_config(&doc, config).unwrap();
537
538        let expected = "id\tname\tage\tactive\n1\tAlice\t30\ttrue\n2\tBob\t25\tfalse\n";
539        assert_eq!(csv, expected);
540    }
541
542    #[test]
543    fn test_to_csv_semicolon_delimiter() {
544        let doc = create_test_document();
545        let config = ToCsvConfig {
546            delimiter: b';',
547            ..Default::default()
548        };
549        let csv = to_csv_with_config(&doc, config).unwrap();
550
551        assert!(csv.contains(";"));
552        assert!(csv.contains("Alice"));
553    }
554
555    #[test]
556    fn test_to_csv_empty_list() {
557        let mut doc = Document::new((1, 0));
558        let list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
559        doc.root.insert("people".to_string(), Item::List(list));
560
561        let csv = to_csv(&doc).unwrap();
562        assert_eq!(csv, "id,name\n");
563    }
564
565    #[test]
566    fn test_to_csv_empty_list_no_headers() {
567        let mut doc = Document::new((1, 0));
568        let list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
569        doc.root.insert("people".to_string(), Item::List(list));
570
571        let config = ToCsvConfig {
572            include_headers: false,
573            ..Default::default()
574        };
575        let csv = to_csv_with_config(&doc, config).unwrap();
576        assert!(csv.is_empty());
577    }
578
579    // ==================== value_to_csv_string tests ====================
580
581    #[test]
582    fn test_value_to_csv_string_null() {
583        assert_eq!(value_to_csv_string(&Value::Null), "");
584    }
585
586    #[test]
587    fn test_value_to_csv_string_bool_true() {
588        assert_eq!(value_to_csv_string(&Value::Bool(true)), "true");
589    }
590
591    #[test]
592    fn test_value_to_csv_string_bool_false() {
593        assert_eq!(value_to_csv_string(&Value::Bool(false)), "false");
594    }
595
596    #[test]
597    fn test_value_to_csv_string_int_positive() {
598        assert_eq!(value_to_csv_string(&Value::Int(42)), "42");
599    }
600
601    #[test]
602    fn test_value_to_csv_string_int_negative() {
603        assert_eq!(value_to_csv_string(&Value::Int(-100)), "-100");
604    }
605
606    #[test]
607    fn test_value_to_csv_string_int_zero() {
608        assert_eq!(value_to_csv_string(&Value::Int(0)), "0");
609    }
610
611    #[test]
612    fn test_value_to_csv_string_int_large() {
613        assert_eq!(
614            value_to_csv_string(&Value::Int(i64::MAX)),
615            i64::MAX.to_string()
616        );
617    }
618
619    #[test]
620    fn test_value_to_csv_string_float_positive() {
621        assert_eq!(value_to_csv_string(&Value::Float(3.25)), "3.25");
622    }
623
624    #[test]
625    fn test_value_to_csv_string_float_negative() {
626        assert_eq!(value_to_csv_string(&Value::Float(-2.5)), "-2.5");
627    }
628
629    #[test]
630    fn test_value_to_csv_string_float_zero() {
631        assert_eq!(value_to_csv_string(&Value::Float(0.0)), "0");
632    }
633
634    #[test]
635    fn test_value_to_csv_string_string() {
636        assert_eq!(
637            value_to_csv_string(&Value::String("hello".to_string())),
638            "hello"
639        );
640    }
641
642    #[test]
643    fn test_value_to_csv_string_string_empty() {
644        assert_eq!(value_to_csv_string(&Value::String("".to_string())), "");
645    }
646
647    #[test]
648    fn test_value_to_csv_string_string_with_comma() {
649        // The CSV library will quote this, but value_to_csv_string just returns the raw value
650        assert_eq!(
651            value_to_csv_string(&Value::String("hello, world".to_string())),
652            "hello, world"
653        );
654    }
655
656    #[test]
657    fn test_value_to_csv_string_reference_local() {
658        assert_eq!(
659            value_to_csv_string(&Value::Reference(Reference::local("user1"))),
660            "@user1"
661        );
662    }
663
664    #[test]
665    fn test_value_to_csv_string_reference_qualified() {
666        assert_eq!(
667            value_to_csv_string(&Value::Reference(Reference::qualified("User", "123"))),
668            "@User:123"
669        );
670    }
671
672    #[test]
673    fn test_value_to_csv_string_expression_identifier() {
674        let expr = Value::Expression(Expression::Identifier {
675            name: "foo".to_string(),
676            span: Span::default(),
677        });
678        assert_eq!(value_to_csv_string(&expr), "$(foo)");
679    }
680
681    #[test]
682    fn test_value_to_csv_string_expression_call() {
683        let expr = Value::Expression(Expression::Call {
684            name: "add".to_string(),
685            args: vec![
686                Expression::Identifier {
687                    name: "x".to_string(),
688                    span: Span::default(),
689                },
690                Expression::Literal {
691                    value: hedl_core::lex::ExprLiteral::Int(1),
692                    span: Span::default(),
693                },
694            ],
695            span: Span::default(),
696        });
697        assert_eq!(value_to_csv_string(&expr), "$(add(x, 1))");
698    }
699
700    // ==================== Special float values ====================
701
702    #[test]
703    fn test_special_float_nan() {
704        assert_eq!(value_to_csv_string(&Value::Float(f64::NAN)), "NaN");
705    }
706
707    #[test]
708    fn test_special_float_infinity() {
709        assert_eq!(
710            value_to_csv_string(&Value::Float(f64::INFINITY)),
711            "Infinity"
712        );
713    }
714
715    #[test]
716    fn test_special_float_neg_infinity() {
717        assert_eq!(
718            value_to_csv_string(&Value::Float(f64::NEG_INFINITY)),
719            "-Infinity"
720        );
721    }
722
723    // ==================== Tensor tests ====================
724
725    #[test]
726    fn test_tensor_scalar_int() {
727        let tensor = Tensor::Scalar(42.0);
728        assert_eq!(tensor_to_json_string(&tensor), "42");
729    }
730
731    #[test]
732    fn test_tensor_scalar_float() {
733        let tensor = Tensor::Scalar(3.5);
734        assert_eq!(tensor_to_json_string(&tensor), "3.5");
735    }
736
737    #[test]
738    fn test_tensor_1d_array() {
739        let tensor = Tensor::Array(vec![
740            Tensor::Scalar(1.0),
741            Tensor::Scalar(2.0),
742            Tensor::Scalar(3.0),
743        ]);
744        assert_eq!(tensor_to_json_string(&tensor), "[1,2,3]");
745    }
746
747    #[test]
748    fn test_tensor_2d_array() {
749        let tensor = Tensor::Array(vec![
750            Tensor::Array(vec![Tensor::Scalar(1.0), Tensor::Scalar(2.0)]),
751            Tensor::Array(vec![Tensor::Scalar(3.0), Tensor::Scalar(4.0)]),
752        ]);
753        assert_eq!(tensor_to_json_string(&tensor), "[[1,2],[3,4]]");
754    }
755
756    #[test]
757    fn test_tensor_empty_array() {
758        let tensor = Tensor::Array(vec![]);
759        assert_eq!(tensor_to_json_string(&tensor), "[]");
760    }
761
762    #[test]
763    fn test_value_to_csv_string_tensor() {
764        let tensor = Tensor::Array(vec![Tensor::Scalar(1.0), Tensor::Scalar(2.0)]);
765        assert_eq!(value_to_csv_string(&Value::Tensor(tensor)), "[1,2]");
766    }
767
768    // ==================== Error cases ====================
769
770    #[test]
771    fn test_no_matrix_list_error() {
772        let doc = Document::new((1, 0));
773        let result = to_csv(&doc);
774
775        assert!(result.is_err());
776        let err = result.unwrap_err();
777        assert!(matches!(err, CsvError::NoLists | CsvError::NotAList { .. } | CsvError::ListNotFound { .. }));
778    }
779
780    #[test]
781    fn test_no_matrix_list_with_scalar() {
782        let mut doc = Document::new((1, 0));
783        doc.root
784            .insert("value".to_string(), Item::Scalar(Value::Int(42)));
785
786        let result = to_csv(&doc);
787        assert!(result.is_err());
788        assert!(matches!(result.unwrap_err(), CsvError::NoLists | CsvError::NotAList { .. } | CsvError::ListNotFound { .. }));
789    }
790
791    // ==================== to_csv_writer tests ====================
792
793    #[test]
794    fn test_to_csv_writer_basic() {
795        let doc = create_test_document();
796        let mut buffer = Vec::new();
797        to_csv_writer(&doc, &mut buffer).unwrap();
798
799        let csv = String::from_utf8(buffer).unwrap();
800        assert!(csv.contains("Alice"));
801        assert!(csv.contains("Bob"));
802    }
803
804    #[test]
805    fn test_to_csv_writer_with_config() {
806        let doc = create_test_document();
807        let config = ToCsvConfig {
808            include_headers: false,
809            ..Default::default()
810        };
811        let mut buffer = Vec::new();
812        to_csv_writer_with_config(&doc, &mut buffer, config).unwrap();
813
814        let csv = String::from_utf8(buffer).unwrap();
815        assert!(!csv.contains("id,name"));
816        assert!(csv.contains("Alice"));
817    }
818
819    // ==================== Quoting tests ====================
820
821    #[test]
822    fn test_quoting_with_comma() {
823        let mut doc = Document::new((1, 0));
824        let mut list = MatrixList::new("Item", vec!["id".to_string(), "text".to_string()]);
825        list.add_row(Node::new(
826            "Item",
827            "1",
828            vec![
829                Value::String("1".to_string()),
830                Value::String("hello, world".to_string()),
831            ],
832        ));
833        doc.root.insert("items".to_string(), Item::List(list));
834
835        let csv = to_csv(&doc).unwrap();
836        // The CSV library should quote fields with commas
837        assert!(csv.contains("\"hello, world\""));
838    }
839
840    #[test]
841    fn test_quoting_with_newline() {
842        let mut doc = Document::new((1, 0));
843        let mut list = MatrixList::new("Item", vec!["id".to_string(), "text".to_string()]);
844        list.add_row(Node::new(
845            "Item",
846            "1",
847            vec![
848                Value::String("1".to_string()),
849                Value::String("line1\nline2".to_string()),
850            ],
851        ));
852        doc.root.insert("items".to_string(), Item::List(list));
853
854        let csv = to_csv(&doc).unwrap();
855        // The CSV library should quote fields with newlines
856        assert!(csv.contains("\"line1\nline2\""));
857    }
858
859    // ==================== to_csv_list tests ====================
860
861    #[test]
862    fn test_to_csv_list_basic() {
863        let mut doc = Document::new((1, 0));
864        let mut list = MatrixList::new(
865            "Person",
866            vec![
867                "id".to_string(),
868                "name".to_string(),
869                "age".to_string(),
870                "active".to_string(),
871            ],
872        );
873
874        list.add_row(Node::new(
875            "Person",
876            "1",
877            vec![
878                Value::String("1".to_string()),
879                Value::String("Alice".to_string()),
880                Value::Int(30),
881                Value::Bool(true),
882            ],
883        ));
884
885        list.add_row(Node::new(
886            "Person",
887            "2",
888            vec![
889                Value::String("2".to_string()),
890                Value::String("Bob".to_string()),
891                Value::Int(25),
892                Value::Bool(false),
893            ],
894        ));
895
896        doc.root.insert("people".to_string(), Item::List(list));
897
898        let csv = to_csv_list(&doc, "people").unwrap();
899        let expected = "id,name,age,active\n1,Alice,30,true\n2,Bob,25,false\n";
900        assert_eq!(csv, expected);
901    }
902
903    #[test]
904    fn test_to_csv_list_selective_export() {
905        let mut doc = Document::new((1, 0));
906
907        // Add first list
908        let mut people_list = MatrixList::new(
909            "Person",
910            vec!["id".to_string(), "name".to_string(), "age".to_string()],
911        );
912        people_list.add_row(Node::new(
913            "Person",
914            "1",
915            vec![
916                Value::String("1".to_string()),
917                Value::String("Alice".to_string()),
918                Value::Int(30),
919            ],
920        ));
921        doc.root
922            .insert("people".to_string(), Item::List(people_list));
923
924        // Add second list
925        let mut items_list = MatrixList::new(
926            "Item",
927            vec!["id".to_string(), "name".to_string(), "price".to_string()],
928        );
929        items_list.add_row(Node::new(
930            "Item",
931            "101",
932            vec![
933                Value::String("101".to_string()),
934                Value::String("Widget".to_string()),
935                Value::Float(9.99),
936            ],
937        ));
938        doc.root.insert("items".to_string(), Item::List(items_list));
939
940        // Export only people
941        let csv_people = to_csv_list(&doc, "people").unwrap();
942        assert!(csv_people.contains("Alice"));
943        assert!(!csv_people.contains("Widget"));
944
945        // Export only items
946        let csv_items = to_csv_list(&doc, "items").unwrap();
947        assert!(csv_items.contains("Widget"));
948        assert!(!csv_items.contains("Alice"));
949    }
950
951    #[test]
952    fn test_to_csv_list_not_found() {
953        let doc = Document::new((1, 0));
954        let result = to_csv_list(&doc, "nonexistent");
955
956        assert!(result.is_err());
957        let err = result.unwrap_err();
958        assert!(matches!(err, CsvError::NoLists | CsvError::NotAList { .. } | CsvError::ListNotFound { .. }));
959        assert!(err.to_string().contains("not found"));
960    }
961
962    #[test]
963    fn test_to_csv_list_not_a_list() {
964        let mut doc = Document::new((1, 0));
965        doc.root
966            .insert("scalar".to_string(), Item::Scalar(Value::Int(42)));
967
968        let result = to_csv_list(&doc, "scalar");
969        assert!(result.is_err());
970        let err = result.unwrap_err();
971        assert!(matches!(err, CsvError::NoLists | CsvError::NotAList { .. } | CsvError::ListNotFound { .. }));
972        assert!(err.to_string().contains("not a matrix list"));
973    }
974
975    #[test]
976    fn test_to_csv_list_without_headers() {
977        let mut doc = Document::new((1, 0));
978        let mut list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
979
980        list.add_row(Node::new(
981            "Person",
982            "1",
983            vec![Value::String("1".to_string()), Value::String("Alice".to_string())],
984        ));
985
986        doc.root.insert("people".to_string(), Item::List(list));
987
988        let config = ToCsvConfig {
989            include_headers: false,
990            ..Default::default()
991        };
992        let csv = to_csv_list_with_config(&doc, "people", config).unwrap();
993
994        let expected = "1,Alice\n";
995        assert_eq!(csv, expected);
996    }
997
998    #[test]
999    fn test_to_csv_list_custom_delimiter() {
1000        let mut doc = Document::new((1, 0));
1001        let mut list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1002
1003        list.add_row(Node::new(
1004            "Person",
1005            "1",
1006            vec![Value::String("1".to_string()), Value::String("Alice".to_string())],
1007        ));
1008
1009        doc.root.insert("people".to_string(), Item::List(list));
1010
1011        let config = ToCsvConfig {
1012            delimiter: b';',
1013            ..Default::default()
1014        };
1015        let csv = to_csv_list_with_config(&doc, "people", config).unwrap();
1016
1017        let expected = "id;name\n1;Alice\n";
1018        assert_eq!(csv, expected);
1019    }
1020
1021    #[test]
1022    fn test_to_csv_list_tab_delimiter() {
1023        let mut doc = Document::new((1, 0));
1024        let mut list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1025
1026        list.add_row(Node::new(
1027            "Person",
1028            "1",
1029            vec![Value::String("1".to_string()), Value::String("Alice".to_string())],
1030        ));
1031
1032        doc.root.insert("people".to_string(), Item::List(list));
1033
1034        let config = ToCsvConfig {
1035            delimiter: b'\t',
1036            ..Default::default()
1037        };
1038        let csv = to_csv_list_with_config(&doc, "people", config).unwrap();
1039
1040        assert!(csv.contains("id\tname"));
1041        assert!(csv.contains("1\tAlice"));
1042    }
1043
1044    #[test]
1045    fn test_to_csv_list_empty() {
1046        let mut doc = Document::new((1, 0));
1047        let list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1048        doc.root.insert("people".to_string(), Item::List(list));
1049
1050        let csv = to_csv_list(&doc, "people").unwrap();
1051        let expected = "id,name\n";
1052        assert_eq!(csv, expected);
1053    }
1054
1055    #[test]
1056    fn test_to_csv_list_empty_no_headers() {
1057        let mut doc = Document::new((1, 0));
1058        let list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1059        doc.root.insert("people".to_string(), Item::List(list));
1060
1061        let config = ToCsvConfig {
1062            include_headers: false,
1063            ..Default::default()
1064        };
1065        let csv = to_csv_list_with_config(&doc, "people", config).unwrap();
1066        assert!(csv.is_empty());
1067    }
1068
1069    #[test]
1070    fn test_to_csv_list_writer() {
1071        let mut doc = Document::new((1, 0));
1072        let mut list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1073
1074        list.add_row(Node::new(
1075            "Person",
1076            "1",
1077            vec![Value::String("1".to_string()), Value::String("Alice".to_string())],
1078        ));
1079
1080        doc.root.insert("people".to_string(), Item::List(list));
1081
1082        let mut buffer = Vec::new();
1083        to_csv_list_writer(&doc, "people", &mut buffer).unwrap();
1084
1085        let csv = String::from_utf8(buffer).unwrap();
1086        assert!(csv.contains("Alice"));
1087    }
1088
1089    #[test]
1090    fn test_to_csv_list_writer_with_config() {
1091        let mut doc = Document::new((1, 0));
1092        let mut list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1093
1094        list.add_row(Node::new(
1095            "Person",
1096            "1",
1097            vec![Value::String("1".to_string()), Value::String("Alice".to_string())],
1098        ));
1099
1100        doc.root.insert("people".to_string(), Item::List(list));
1101
1102        let config = ToCsvConfig {
1103            include_headers: false,
1104            ..Default::default()
1105        };
1106        let mut buffer = Vec::new();
1107        to_csv_list_writer_with_config(&doc, "people", &mut buffer, config).unwrap();
1108
1109        let csv = String::from_utf8(buffer).unwrap();
1110        assert_eq!(csv, "1,Alice\n");
1111    }
1112
1113    #[test]
1114    fn test_to_csv_list_with_all_value_types() {
1115        let mut doc = Document::new((1, 0));
1116        let mut list = MatrixList::new(
1117            "Data",
1118            vec![
1119                "id".to_string(),
1120                "bool_val".to_string(),
1121                "int_val".to_string(),
1122                "float_val".to_string(),
1123                "string_val".to_string(),
1124                "null_val".to_string(),
1125                "ref_val".to_string(),
1126            ],
1127        );
1128
1129        list.add_row(Node::new(
1130            "Data",
1131            "1",
1132            vec![
1133                Value::String("1".to_string()),
1134                Value::Bool(true),
1135                Value::Int(42),
1136                Value::Float(3.14),
1137                Value::String("hello".to_string()),
1138                Value::Null,
1139                Value::Reference(Reference::local("user1")),
1140            ],
1141        ));
1142
1143        doc.root.insert("data".to_string(), Item::List(list));
1144
1145        let csv = to_csv_list(&doc, "data").unwrap();
1146        assert!(csv.contains("true"));
1147        assert!(csv.contains("42"));
1148        assert!(csv.contains("3.14"));
1149        assert!(csv.contains("hello"));
1150        assert!(csv.contains("@user1"));
1151    }
1152
1153    #[test]
1154    fn test_to_csv_list_with_nested_children_skipped() {
1155        let mut doc = Document::new((1, 0));
1156        let mut list = MatrixList::new("Person", vec!["id".to_string(), "name".to_string()]);
1157
1158        let mut person = Node::new(
1159            "Person",
1160            "1",
1161            vec![
1162                Value::String("1".to_string()),
1163                Value::String("Alice".to_string()),
1164            ],
1165        );
1166
1167        // Add nested children (should be skipped in CSV export)
1168        let child = Node::new(
1169            "Address",
1170            "addr1",
1171            vec![Value::String("addr1".to_string()), Value::String("123 Main St".to_string())],
1172        );
1173        person.add_child("Address", child);
1174
1175        list.add_row(person);
1176        doc.root.insert("people".to_string(), Item::List(list));
1177
1178        // CSV should only contain the parent row, not the nested children
1179        let csv = to_csv_list(&doc, "people").unwrap();
1180        assert!(csv.contains("Alice"));
1181        assert!(!csv.contains("Address"));
1182        assert!(!csv.contains("123 Main St"));
1183    }
1184
1185    #[test]
1186    fn test_to_csv_list_complex_quoting() {
1187        let mut doc = Document::new((1, 0));
1188        let mut list = MatrixList::new("Item", vec!["id".to_string(), "description".to_string()]);
1189
1190        list.add_row(Node::new(
1191            "Item",
1192            "1",
1193            vec![
1194                Value::String("1".to_string()),
1195                Value::String("Contains, comma and \"quotes\"".to_string()),
1196            ],
1197        ));
1198
1199        doc.root.insert("items".to_string(), Item::List(list));
1200
1201        let csv = to_csv_list(&doc, "items").unwrap();
1202        // CSV library should handle quoting
1203        assert!(csv.contains("comma"));
1204    }
1205
1206    #[test]
1207    fn test_to_csv_list_multiple_lists_independent() {
1208        let mut doc = Document::new((1, 0));
1209
1210        // First list with 2 rows
1211        let mut list1 = MatrixList::new("Type1", vec!["id".to_string(), "val".to_string()]);
1212        list1.add_row(Node::new(
1213            "Type1",
1214            "1",
1215            vec![Value::String("1".to_string()), Value::String("alpha".to_string())],
1216        ));
1217        list1.add_row(Node::new(
1218            "Type1",
1219            "2",
1220            vec![Value::String("2".to_string()), Value::String("bravo".to_string())],
1221        ));
1222        doc.root.insert("list1".to_string(), Item::List(list1));
1223
1224        // Second list with 3 rows
1225        let mut list2 = MatrixList::new("Type2", vec!["id".to_string(), "val".to_string()]);
1226        list2.add_row(Node::new(
1227            "Type2",
1228            "1",
1229            vec![Value::String("1".to_string()), Value::String("x_ray".to_string())],
1230        ));
1231        list2.add_row(Node::new(
1232            "Type2",
1233            "2",
1234            vec![Value::String("2".to_string()), Value::String("yankee".to_string())],
1235        ));
1236        list2.add_row(Node::new(
1237            "Type2",
1238            "3",
1239            vec![Value::String("3".to_string()), Value::String("zulu".to_string())],
1240        ));
1241        doc.root.insert("list2".to_string(), Item::List(list2));
1242
1243        // Export each list independently
1244        let csv1 = to_csv_list(&doc, "list1").unwrap();
1245        let csv2 = to_csv_list(&doc, "list2").unwrap();
1246
1247        // List1 should have 2 data rows
1248        let lines1: Vec<&str> = csv1.lines().collect();
1249        assert_eq!(lines1.len(), 3); // header + 2 rows
1250
1251        // List2 should have 3 data rows
1252        let lines2: Vec<&str> = csv2.lines().collect();
1253        assert_eq!(lines2.len(), 4); // header + 3 rows
1254
1255        // Each should contain only its own data
1256        assert!(csv1.contains("alpha") && csv1.contains("bravo"));
1257        assert!(csv2.contains("x_ray") && csv2.contains("yankee") && csv2.contains("zulu"));
1258        assert!(!csv1.contains("x_ray"));
1259        assert!(!csv2.contains("alpha"));
1260    }
1261
1262    #[test]
1263    fn test_to_csv_list_special_floats() {
1264        let mut doc = Document::new((1, 0));
1265        let mut list = MatrixList::new("Data", vec!["id".to_string(), "value".to_string()]);
1266
1267        list.add_row(Node::new(
1268            "Data",
1269            "1",
1270            vec![Value::String("1".to_string()), Value::Float(f64::NAN)],
1271        ));
1272
1273        list.add_row(Node::new(
1274            "Data",
1275            "2",
1276            vec![Value::String("2".to_string()), Value::Float(f64::INFINITY)],
1277        ));
1278
1279        list.add_row(Node::new(
1280            "Data",
1281            "3",
1282            vec![
1283                Value::String("3".to_string()),
1284                Value::Float(f64::NEG_INFINITY),
1285            ],
1286        ));
1287
1288        doc.root.insert("data".to_string(), Item::List(list));
1289
1290        let csv = to_csv_list(&doc, "data").unwrap();
1291        assert!(csv.contains("NaN"));
1292        assert!(csv.contains("Infinity"));
1293        assert!(csv.contains("-Infinity"));
1294    }
1295}