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