Skip to main content

bnto_spreadsheet/
convert.rs

1// CSV-to-JSON Node — convert CSV rows to a JSON array of objects.
2//
3// Each row becomes a JSON object keyed by column headers. All values are
4// strings (no type coercion). Supports configurable delimiter and optional
5// pretty-printing.
6
7use bnto_core::context::ProcessContext;
8use bnto_core::errors::BntoError;
9use bnto_core::processor::{FileData, NodeInput, NodeOutput, NodeProcessor, OutputFile};
10use bnto_core::progress::ProgressReporter;
11
12/// The spreadsheet-convert node processor. Stateless — config comes from `NodeInput.params`.
13pub struct ConvertFormat;
14
15impl ConvertFormat {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl Default for ConvertFormat {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27// --- NodeProcessor Implementation ---
28
29impl NodeProcessor for ConvertFormat {
30    fn name(&self) -> &str {
31        "spreadsheet-convert"
32    }
33
34    fn metadata(&self) -> bnto_core::NodeMetadata {
35        use bnto_core::metadata::*;
36        NodeMetadata {
37            node_type: "spreadsheet-convert".to_string(),
38            name: "CSV to JSON".to_string(),
39            description: "Convert CSV rows to a JSON array of objects".to_string(),
40            category: NodeCategory::Spreadsheet,
41            accepts: vec!["text/csv".to_string()],
42            platforms: vec!["browser".to_string()],
43            parameters: build_convert_parameters(),
44            input_cardinality: InputCardinality::PerFile,
45            requires: vec![],
46        }
47    }
48
49    fn process(
50        &self,
51        input: NodeInput,
52        progress: &ProgressReporter,
53        _ctx: &dyn ProcessContext,
54    ) -> Result<NodeOutput, BntoError> {
55        progress.report(0, "Parsing CSV...");
56        let data = input
57            .data
58            .into_bytes()
59            .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read input: {e}")))?;
60        let config = ConvertConfig::from_params(&input.params);
61        let csv_text = parse_and_strip_bom(&data)?;
62
63        progress.report(10, "Reading headers...");
64        let delimiter = resolve_delimiter(&config.delimiter);
65        let headers = read_headers(csv_text, delimiter)?;
66
67        progress.report(20, "Converting rows to JSON...");
68        let rows = read_rows(csv_text, delimiter, &headers)?;
69
70        progress.report(70, "Serializing JSON...");
71        let json_bytes = serialize_json(&rows, config.pretty)?;
72
73        progress.report(90, "Building result...");
74        let metadata = build_convert_metadata(headers.len(), rows.len(), json_bytes.len());
75        let output_filename = generate_output_filename(&input.filename);
76
77        progress.report(100, "Done!");
78        Ok(NodeOutput {
79            files: vec![OutputFile {
80                data: FileData::Bytes(json_bytes),
81                filename: output_filename,
82                mime_type: "application/json".to_string(),
83                metadata: serde_json::Map::new(),
84            }],
85            metadata,
86        })
87    }
88}
89
90// --- Configuration ---
91
92struct ConvertConfig {
93    delimiter: String,
94    pretty: bool,
95}
96
97impl ConvertConfig {
98    fn from_params(params: &serde_json::Map<String, serde_json::Value>) -> Self {
99        Self {
100            delimiter: params
101                .get("delimiter")
102                .and_then(|v| v.as_str())
103                .unwrap_or(",")
104                .to_string(),
105            pretty: params
106                .get("pretty")
107                .and_then(|v| v.as_bool())
108                .unwrap_or(false),
109        }
110    }
111}
112
113/// Map delimiter name to byte. Supports "comma", "semicolon", "tab", or literal single char.
114fn resolve_delimiter(delimiter: &str) -> u8 {
115    match delimiter {
116        "comma" | "," => b',',
117        "semicolon" | ";" => b';',
118        "tab" | "\t" => b'\t',
119        "pipe" | "|" => b'|',
120        other if other.len() == 1 => other.as_bytes()[0],
121        _ => b',',
122    }
123}
124
125// --- Metadata Parameter Definitions ---
126
127fn build_convert_parameters() -> Vec<bnto_core::metadata::ParameterDef> {
128    use bnto_core::metadata::*;
129    vec![
130        ParameterDef {
131            name: "delimiter".to_string(),
132            label: "Delimiter".to_string(),
133            description: "Column separator character".to_string(),
134            param_type: ParameterType::Enum {
135                options: vec![
136                    OptionEntry {
137                        value: "comma".to_string(),
138                        label: "Comma".to_string(),
139                    },
140                    OptionEntry {
141                        value: "semicolon".to_string(),
142                        label: "Semicolon".to_string(),
143                    },
144                    OptionEntry {
145                        value: "tab".to_string(),
146                        label: "Tab".to_string(),
147                    },
148                    OptionEntry {
149                        value: "pipe".to_string(),
150                        label: "Pipe".to_string(),
151                    },
152                ],
153            },
154            default: Some(serde_json::json!("comma")),
155            ..Default::default()
156        },
157        ParameterDef {
158            name: "pretty".to_string(),
159            label: "Pretty Print".to_string(),
160            description: "Format output JSON with indentation".to_string(),
161            param_type: ParameterType::Boolean,
162            default: Some(serde_json::json!(false)),
163            ..Default::default()
164        },
165    ]
166}
167
168// --- CSV Parsing ---
169
170/// Validate UTF-8 and strip BOM if present.
171fn parse_and_strip_bom(data: &[u8]) -> Result<&str, BntoError> {
172    // UTF-8 BOM is 3 bytes: EF BB BF
173    let data = if data.starts_with(&[0xEF, 0xBB, 0xBF]) {
174        &data[3..]
175    } else {
176        data
177    };
178
179    let text = std::str::from_utf8(data).map_err(|e| {
180        BntoError::InvalidInput(format!(
181            "File is not valid UTF-8 text (is this really a CSV?): {e}"
182        ))
183    })?;
184
185    if text.trim().is_empty() {
186        return Err(BntoError::InvalidInput(
187            "CSV file is empty — no data to convert".to_string(),
188        ));
189    }
190
191    Ok(text)
192}
193
194/// Read the header row from the CSV, returning column names.
195fn read_headers(csv_text: &str, delimiter: u8) -> Result<Vec<String>, BntoError> {
196    let mut reader = csv::ReaderBuilder::new()
197        .has_headers(true)
198        .delimiter(delimiter)
199        .flexible(true)
200        .from_reader(csv_text.as_bytes());
201
202    let headers = reader
203        .headers()
204        .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read CSV headers: {e}")))?
205        .clone();
206
207    let names: Vec<String> = headers.iter().map(|h| h.trim().to_string()).collect();
208
209    if names.is_empty() {
210        return Err(BntoError::InvalidInput(
211            "CSV has no columns — cannot convert to JSON".to_string(),
212        ));
213    }
214
215    Ok(names)
216}
217
218/// Read all data rows into a vec of JSON objects keyed by headers.
219fn read_rows(
220    csv_text: &str,
221    delimiter: u8,
222    headers: &[String],
223) -> Result<Vec<serde_json::Map<String, serde_json::Value>>, BntoError> {
224    let mut reader = csv::ReaderBuilder::new()
225        .has_headers(true)
226        .delimiter(delimiter)
227        .flexible(true)
228        .from_reader(csv_text.as_bytes());
229
230    let mut rows = Vec::new();
231
232    for result in reader.records() {
233        let record = result
234            .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read CSV row: {e}")))?;
235
236        let mut obj = serde_json::Map::new();
237        for (i, header) in headers.iter().enumerate() {
238            let value = record.get(i).unwrap_or("").to_string();
239            obj.insert(header.clone(), serde_json::Value::String(value));
240        }
241        rows.push(obj);
242    }
243
244    Ok(rows)
245}
246
247// --- JSON Serialization ---
248
249fn serialize_json(
250    rows: &[serde_json::Map<String, serde_json::Value>],
251    pretty: bool,
252) -> Result<Vec<u8>, BntoError> {
253    let json_value = serde_json::Value::Array(
254        rows.iter()
255            .map(|obj| serde_json::Value::Object(obj.clone()))
256            .collect(),
257    );
258
259    let bytes = if pretty {
260        serde_json::to_vec_pretty(&json_value)
261    } else {
262        serde_json::to_vec(&json_value)
263    }
264    .map_err(|e| BntoError::ProcessingFailed(format!("Failed to serialize JSON: {e}")))?;
265
266    Ok(bytes)
267}
268
269// --- Result Metadata ---
270
271fn build_convert_metadata(
272    column_count: usize,
273    row_count: usize,
274    output_size: usize,
275) -> serde_json::Map<String, serde_json::Value> {
276    let mut metadata = serde_json::Map::new();
277    metadata.insert(
278        "columnCount".to_string(),
279        serde_json::Value::Number(column_count.into()),
280    );
281    metadata.insert(
282        "rowCount".to_string(),
283        serde_json::Value::Number(row_count.into()),
284    );
285    metadata.insert(
286        "outputSize".to_string(),
287        serde_json::Value::Number(output_size.into()),
288    );
289    metadata
290}
291
292// --- Filename ---
293
294/// Replace .csv extension with .json: "data.csv" -> "data.json"
295fn generate_output_filename(original: &str) -> String {
296    if let Some(dot_pos) = original.rfind('.') {
297        let stem = &original[..dot_pos];
298        format!("{stem}.json")
299    } else {
300        format!("{original}.json")
301    }
302}
303
304// =============================================================================
305// Tests
306// =============================================================================
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use bnto_core::NoopContext;
312
313    // --- Test Helpers ---
314
315    fn make_input(csv_text: &str) -> NodeInput {
316        NodeInput {
317            data: FileData::Bytes(csv_text.as_bytes().to_vec()),
318            filename: "test.csv".to_string(),
319            mime_type: Some("text/csv".to_string()),
320            params: serde_json::Map::new(),
321        }
322    }
323
324    fn make_input_with_params(
325        csv_text: &str,
326        params: serde_json::Map<String, serde_json::Value>,
327    ) -> NodeInput {
328        NodeInput {
329            data: FileData::Bytes(csv_text.as_bytes().to_vec()),
330            filename: "test.csv".to_string(),
331            mime_type: Some("text/csv".to_string()),
332            params,
333        }
334    }
335
336    fn make_input_bytes(data: Vec<u8>) -> NodeInput {
337        NodeInput {
338            data: FileData::Bytes(data),
339            filename: "test.csv".to_string(),
340            mime_type: Some("text/csv".to_string()),
341            params: serde_json::Map::new(),
342        }
343    }
344
345    fn output_json(output: &NodeOutput) -> serde_json::Value {
346        let bytes = output.files[0]
347            .data
348            .clone()
349            .into_bytes()
350            .expect("Should read bytes");
351        serde_json::from_slice(&bytes).expect("Output should be valid JSON")
352    }
353
354    fn process_ok(input: NodeInput) -> NodeOutput {
355        let processor = ConvertFormat::new();
356        let progress = ProgressReporter::new_noop();
357        processor
358            .process(input, &progress, &NoopContext)
359            .expect("should succeed")
360    }
361
362    fn process_err(input: NodeInput) -> BntoError {
363        let processor = ConvertFormat::new();
364        let progress = ProgressReporter::new_noop();
365        let result = processor.process(input, &progress, &NoopContext);
366        assert!(result.is_err(), "expected an error");
367        match result {
368            Err(e) => e,
369            Ok(_) => unreachable!(),
370        }
371    }
372
373    // --- Trait Basics ---
374
375    #[test]
376    fn test_name() {
377        assert_eq!(ConvertFormat::new().name(), "spreadsheet-convert");
378    }
379
380    #[test]
381    fn test_default() {
382        #[allow(clippy::default_constructed_unit_structs)]
383        let _p = ConvertFormat::default();
384    }
385
386    #[test]
387    fn test_metadata_node_type() {
388        let meta = ConvertFormat::new().metadata();
389        assert_eq!(meta.node_type, "spreadsheet-convert");
390    }
391
392    #[test]
393    fn test_metadata_has_delimiter_and_pretty_params() {
394        let meta = ConvertFormat::new().metadata();
395        let names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
396        assert!(names.contains(&"delimiter"));
397        assert!(names.contains(&"pretty"));
398    }
399
400    // --- Happy Path ---
401
402    #[test]
403    fn test_simple_csv_to_json() {
404        let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\n";
405        let output = process_ok(make_input(csv));
406        let json = output_json(&output);
407
408        let arr = json.as_array().expect("should be array");
409        assert_eq!(arr.len(), 2);
410        assert_eq!(arr[0]["name"], "Alice");
411        assert_eq!(arr[0]["age"], "30");
412        assert_eq!(arr[0]["city"], "NYC");
413        assert_eq!(arr[1]["name"], "Bob");
414    }
415
416    #[test]
417    fn test_values_are_strings() {
418        let csv = "id,price,active\n1,19.99,true\n";
419        let output = process_ok(make_input(csv));
420        let json = output_json(&output);
421
422        let row = &json[0];
423        assert!(row["id"].is_string(), "id should be a string");
424        assert!(row["price"].is_string(), "price should be a string");
425        assert!(row["active"].is_string(), "active should be a string");
426        assert_eq!(row["id"], "1");
427        assert_eq!(row["price"], "19.99");
428        assert_eq!(row["active"], "true");
429    }
430
431    #[test]
432    fn test_headers_only_produces_empty_array() {
433        let csv = "name,age,city\n";
434        let output = process_ok(make_input(csv));
435        let json = output_json(&output);
436
437        let arr = json.as_array().expect("should be array");
438        assert_eq!(arr.len(), 0);
439    }
440
441    #[test]
442    fn test_single_row() {
443        let csv = "x\n42\n";
444        let output = process_ok(make_input(csv));
445        let json = output_json(&output);
446
447        assert_eq!(json.as_array().unwrap().len(), 1);
448        assert_eq!(json[0]["x"], "42");
449    }
450
451    #[test]
452    fn test_single_column() {
453        let csv = "email\nalice@example.com\nbob@example.com\n";
454        let output = process_ok(make_input(csv));
455        let json = output_json(&output);
456
457        assert_eq!(json[0]["email"], "alice@example.com");
458        assert_eq!(json[1]["email"], "bob@example.com");
459    }
460
461    // --- Empty Cells ---
462
463    #[test]
464    fn test_empty_cells_become_empty_strings() {
465        let csv = "name,age,city\nAlice,,NYC\n,25,\n";
466        let output = process_ok(make_input(csv));
467        let json = output_json(&output);
468
469        assert_eq!(json[0]["age"], "");
470        assert_eq!(json[1]["name"], "");
471        assert_eq!(json[1]["city"], "");
472    }
473
474    #[test]
475    fn test_short_row_gets_empty_string_for_missing_columns() {
476        let csv = "name,age,city\nAlice\n";
477        let output = process_ok(make_input(csv));
478        let json = output_json(&output);
479
480        assert_eq!(json[0]["name"], "Alice");
481        assert_eq!(json[0]["age"], "");
482        assert_eq!(json[0]["city"], "");
483    }
484
485    // --- Quoted Fields ---
486
487    #[test]
488    fn test_quoted_fields_with_commas() {
489        let csv = "name,bio\n\"Alice\",\"Loves coding, hiking\"\n";
490        let output = process_ok(make_input(csv));
491        let json = output_json(&output);
492
493        assert_eq!(json[0]["bio"], "Loves coding, hiking");
494    }
495
496    #[test]
497    fn test_quoted_fields_with_newlines() {
498        let csv = "name,bio\n\"Alice\",\"Line one\nLine two\"\n";
499        let output = process_ok(make_input(csv));
500        let json = output_json(&output);
501
502        assert_eq!(json[0]["bio"], "Line one\nLine two");
503    }
504
505    #[test]
506    fn test_escaped_quotes() {
507        let csv = "name,bio\n\"Alice\",\"Says \"\"hello\"\"\"\n";
508        let output = process_ok(make_input(csv));
509        let json = output_json(&output);
510
511        assert_eq!(json[0]["bio"], "Says \"hello\"");
512    }
513
514    // --- Unicode ---
515
516    #[test]
517    fn test_unicode_characters() {
518        let csv = "name,city\nMüller,München\n田中太郎,東京\n";
519        let output = process_ok(make_input(csv));
520        let json = output_json(&output);
521
522        assert_eq!(json[0]["name"], "Müller");
523        assert_eq!(json[0]["city"], "München");
524        assert_eq!(json[1]["name"], "田中太郎");
525    }
526
527    #[test]
528    fn test_emoji() {
529        let csv = "name,note\nEmma 🎉,rocket 🚀\n";
530        let output = process_ok(make_input(csv));
531        let json = output_json(&output);
532
533        assert_eq!(json[0]["name"], "Emma 🎉");
534        assert_eq!(json[0]["note"], "rocket 🚀");
535    }
536
537    // --- BOM Handling ---
538
539    #[test]
540    fn test_utf8_bom_stripped() {
541        let csv_with_bom = b"\xEF\xBB\xBFname,age\nAlice,30\n";
542        let output = process_ok(make_input_bytes(csv_with_bom.to_vec()));
543        let json = output_json(&output);
544
545        // BOM should be stripped — header should be "name", not "\u{FEFF}name"
546        assert_eq!(json[0]["name"], "Alice");
547        assert!(json[0].get("\u{FEFF}name").is_none());
548    }
549
550    // --- CRLF Handling ---
551
552    #[test]
553    fn test_crlf_line_endings() {
554        let csv = "name,age\r\nAlice,30\r\nBob,25\r\n";
555        let output = process_ok(make_input(csv));
556        let json = output_json(&output);
557
558        assert_eq!(json.as_array().unwrap().len(), 2);
559        assert_eq!(json[0]["name"], "Alice");
560        assert_eq!(json[1]["name"], "Bob");
561    }
562
563    // --- Delimiter Configuration ---
564
565    #[test]
566    fn test_semicolon_delimiter() {
567        let csv = "name;age;city\nAlice;30;NYC\n";
568        let mut params = serde_json::Map::new();
569        params.insert("delimiter".into(), serde_json::json!("semicolon"));
570        let output = process_ok(make_input_with_params(csv, params));
571        let json = output_json(&output);
572
573        assert_eq!(json[0]["name"], "Alice");
574        assert_eq!(json[0]["age"], "30");
575        assert_eq!(json[0]["city"], "NYC");
576    }
577
578    #[test]
579    fn test_tab_delimiter() {
580        let csv = "name\tage\nAlice\t30\n";
581        let mut params = serde_json::Map::new();
582        params.insert("delimiter".into(), serde_json::json!("tab"));
583        let output = process_ok(make_input_with_params(csv, params));
584        let json = output_json(&output);
585
586        assert_eq!(json[0]["name"], "Alice");
587        assert_eq!(json[0]["age"], "30");
588    }
589
590    #[test]
591    fn test_pipe_delimiter() {
592        let csv = "name|age\nAlice|30\n";
593        let mut params = serde_json::Map::new();
594        params.insert("delimiter".into(), serde_json::json!("pipe"));
595        let output = process_ok(make_input_with_params(csv, params));
596        let json = output_json(&output);
597
598        assert_eq!(json[0]["name"], "Alice");
599    }
600
601    #[test]
602    fn test_default_delimiter_is_comma() {
603        let csv = "name,age\nAlice,30\n";
604        let output = process_ok(make_input(csv));
605        let json = output_json(&output);
606
607        assert_eq!(json[0]["name"], "Alice");
608    }
609
610    // --- Pretty Print ---
611
612    #[test]
613    fn test_compact_output_by_default() {
614        let csv = "name\nAlice\n";
615        let output = process_ok(make_input(csv));
616        let bytes = output.files[0].data.clone().into_bytes().unwrap();
617        let text = String::from_utf8(bytes).unwrap();
618
619        // Compact JSON has no newlines within the array
620        assert!(!text.contains('\n'), "Compact output should be single line");
621    }
622
623    #[test]
624    fn test_pretty_output() {
625        let csv = "name\nAlice\n";
626        let mut params = serde_json::Map::new();
627        params.insert("pretty".into(), serde_json::json!(true));
628        let output = process_ok(make_input_with_params(csv, params));
629        let bytes = output.files[0].data.clone().into_bytes().unwrap();
630        let text = String::from_utf8(bytes).unwrap();
631
632        assert!(text.contains('\n'), "Pretty output should have newlines");
633        assert!(text.contains("  "), "Pretty output should have indentation");
634    }
635
636    #[test]
637    fn test_pretty_changes_output() {
638        let csv = "name,age\nAlice,30\nBob,25\n";
639
640        let compact = process_ok(make_input(csv));
641        let compact_size = compact.files[0].data.len().unwrap();
642
643        let mut params = serde_json::Map::new();
644        params.insert("pretty".into(), serde_json::json!(true));
645        let pretty = process_ok(make_input_with_params(csv, params));
646        let pretty_size = pretty.files[0].data.len().unwrap();
647
648        assert!(
649            pretty_size > compact_size,
650            "Pretty should be larger: {pretty_size} vs {compact_size}"
651        );
652    }
653
654    // --- Error Cases ---
655
656    #[test]
657    fn test_empty_input() {
658        let err = process_err(make_input(""));
659        assert!(err.to_string().contains("empty"));
660    }
661
662    #[test]
663    fn test_whitespace_only_input() {
664        let err = process_err(make_input("   \n\n  "));
665        assert!(err.to_string().contains("empty"));
666    }
667
668    #[test]
669    fn test_non_utf8_input() {
670        let err = process_err(make_input_bytes(vec![0xFF, 0xFE, 0x00, 0x41]));
671        assert!(err.to_string().contains("UTF-8"));
672    }
673
674    // --- Output Filename ---
675
676    #[test]
677    fn test_output_filename_csv_to_json() {
678        assert_eq!(generate_output_filename("data.csv"), "data.json");
679    }
680
681    #[test]
682    fn test_output_filename_no_extension() {
683        assert_eq!(generate_output_filename("data"), "data.json");
684    }
685
686    #[test]
687    fn test_output_filename_multiple_dots() {
688        assert_eq!(generate_output_filename("my.data.csv"), "my.data.json");
689    }
690
691    #[test]
692    fn test_output_file_has_json_extension() {
693        let csv = "name\nAlice\n";
694        let output = process_ok(make_input(csv));
695        assert_eq!(output.files[0].filename, "test.json");
696    }
697
698    #[test]
699    fn test_output_mime_type() {
700        let csv = "name\nAlice\n";
701        let output = process_ok(make_input(csv));
702        assert_eq!(output.files[0].mime_type, "application/json");
703    }
704
705    // --- Metadata ---
706
707    #[test]
708    fn test_metadata_fields() {
709        let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\n";
710        let output = process_ok(make_input(csv));
711
712        assert_eq!(output.metadata["columnCount"], 3);
713        assert_eq!(output.metadata["rowCount"], 2);
714        assert!(output.metadata["outputSize"].as_u64().unwrap() > 0);
715    }
716
717    // --- Fixture Files ---
718
719    #[test]
720    fn test_fixture_simple() {
721        let data = include_bytes!("../../../../test-fixtures/csv/simple.csv");
722        let output = process_ok(make_input_bytes(data.to_vec()));
723        let json = output_json(&output);
724        let arr = json.as_array().unwrap();
725
726        assert_eq!(arr.len(), 5);
727        assert_eq!(arr[0]["name"], "Alice");
728    }
729
730    #[test]
731    fn test_fixture_crlf() {
732        let data = include_bytes!("../../../../test-fixtures/csv/crlf.csv");
733        let output = process_ok(make_input_bytes(data.to_vec()));
734        let json = output_json(&output);
735
736        assert_eq!(json.as_array().unwrap().len(), 3);
737        assert_eq!(json[0]["name"], "Alice");
738    }
739
740    #[test]
741    fn test_fixture_bom() {
742        let data = include_bytes!("../../../../test-fixtures/csv/bom.csv");
743        let output = process_ok(make_input_bytes(data.to_vec()));
744        let json = output_json(&output);
745
746        // BOM stripped — "name" header should not have BOM prefix
747        assert_eq!(json[0]["name"], "Alice");
748    }
749
750    #[test]
751    fn test_fixture_unicode() {
752        let data = include_bytes!("../../../../test-fixtures/csv/unicode.csv");
753        let output = process_ok(make_input_bytes(data.to_vec()));
754        let json = output_json(&output);
755
756        assert_eq!(json[0]["name"], "Müller");
757        assert_eq!(json[1]["name"], "田中太郎");
758        assert!(json[3]["name"].as_str().unwrap().contains('🎉'));
759    }
760
761    #[test]
762    fn test_fixture_quoted_fields() {
763        let data = include_bytes!("../../../../test-fixtures/csv/quoted-fields.csv");
764        let output = process_ok(make_input_bytes(data.to_vec()));
765        let json = output_json(&output);
766
767        assert_eq!(json[0]["bio"], "Loves coding, hiking");
768        assert!(json[2]["bio"].as_str().unwrap().contains('\n'));
769    }
770
771    #[test]
772    fn test_fixture_semicolon() {
773        let data = include_bytes!("../../../../test-fixtures/csv/semicolon.csv");
774        let mut params = serde_json::Map::new();
775        params.insert("delimiter".into(), serde_json::json!("semicolon"));
776        let input = make_input_with_params(std::str::from_utf8(data).unwrap(), params);
777        let output = process_ok(input);
778        let json = output_json(&output);
779
780        assert_eq!(json[0]["name"], "Alice");
781        assert_eq!(json[0]["age"], "30");
782    }
783
784    #[test]
785    fn test_fixture_empty_cells() {
786        let data = include_bytes!("../../../../test-fixtures/csv/empty-cells.csv");
787        let output = process_ok(make_input_bytes(data.to_vec()));
788        let json = output_json(&output);
789
790        // Row 1: Alice has empty city
791        assert_eq!(json[0]["city"], "");
792        // Row 2: Bob has empty age
793        assert_eq!(json[1]["age"], "");
794    }
795
796    #[test]
797    fn test_fixture_numeric_values_stay_strings() {
798        let data = include_bytes!("../../../../test-fixtures/csv/numeric-values.csv");
799        let output = process_ok(make_input_bytes(data.to_vec()));
800        let json = output_json(&output);
801
802        // All values should be strings, not JSON numbers/bools
803        assert!(json[0]["price"].is_string());
804        assert!(json[0]["active"].is_string());
805        assert_eq!(json[0]["price"], "19.99");
806        assert_eq!(json[0]["active"], "true");
807    }
808
809    // --- Large CSV ---
810
811    #[test]
812    fn test_large_csv_1000_rows() {
813        let mut csv = String::from("id,name,value\n");
814        for i in 0..1000 {
815            csv.push_str(&format!("{i},item_{i},{}\n", i * 10));
816        }
817        let output = process_ok(make_input(&csv));
818        let json = output_json(&output);
819
820        assert_eq!(json.as_array().unwrap().len(), 1000);
821        assert_eq!(output.metadata["rowCount"], 1000);
822        assert_eq!(output.metadata["columnCount"], 3);
823    }
824
825    // --- Delimiter Resolution ---
826
827    #[test]
828    fn test_resolve_delimiter_names() {
829        assert_eq!(resolve_delimiter("comma"), b',');
830        assert_eq!(resolve_delimiter("semicolon"), b';');
831        assert_eq!(resolve_delimiter("tab"), b'\t');
832        assert_eq!(resolve_delimiter("pipe"), b'|');
833    }
834
835    #[test]
836    fn test_resolve_delimiter_literals() {
837        assert_eq!(resolve_delimiter(","), b',');
838        assert_eq!(resolve_delimiter(";"), b';');
839        assert_eq!(resolve_delimiter("|"), b'|');
840    }
841
842    #[test]
843    fn test_resolve_delimiter_unknown_defaults_to_comma() {
844        assert_eq!(resolve_delimiter("unknown"), b',');
845        assert_eq!(resolve_delimiter(""), b',');
846    }
847}