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