Skip to main content

bnto_spreadsheet/
read.rs

1// CSV Row Reader — explode a CSV into one OutputFile per row.
2//
3// Each row becomes an OutputFile with empty `data` and column values
4// in `metadata`. Designed for loop containers: iterate over rows and
5// access per-row columns via `{{item.column_name}}` templates.
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
12const DEFAULT_MAX_ROWS: u64 = 100_000;
13
14/// The spreadsheet-read node processor. Stateless — config comes from `NodeInput.params`.
15pub struct ReadSpreadsheet;
16
17impl ReadSpreadsheet {
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23impl Default for ReadSpreadsheet {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29// --- NodeProcessor Implementation ---
30
31impl NodeProcessor for ReadSpreadsheet {
32    fn name(&self) -> &str {
33        "spreadsheet-read"
34    }
35
36    fn metadata(&self) -> bnto_core::NodeMetadata {
37        use bnto_core::metadata::*;
38        NodeMetadata {
39            node_type: "spreadsheet-read".to_string(),
40            name: "Read CSV Rows".to_string(),
41            description: "Explode a CSV into one item per row for loop iteration".to_string(),
42            category: NodeCategory::Spreadsheet,
43            accepts: vec!["text/csv".to_string()],
44            platforms: vec!["browser".to_string()],
45            parameters: build_read_parameters(),
46            input_cardinality: InputCardinality::PerFile,
47            requires: vec![],
48        }
49    }
50
51    fn process(
52        &self,
53        input: NodeInput,
54        progress: &ProgressReporter,
55        _ctx: &dyn ProcessContext,
56    ) -> Result<NodeOutput, BntoError> {
57        progress.report(0, "Parsing CSV...");
58        let data = input
59            .data
60            .into_bytes()
61            .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read input: {e}")))?;
62        let config = ReadConfig::from_params(&input.params);
63        let csv_text = parse_and_strip_bom(&data)?;
64
65        progress.report(10, "Reading CSV...");
66        let delimiter = resolve_delimiter(&config.delimiter);
67        let stem = file_stem(&input.filename);
68
69        let (headers, rows) = read_csv(csv_text, delimiter, config.has_headers)?;
70
71        if rows.len() as u64 > config.max_rows {
72            return Err(BntoError::InvalidInput(format!(
73                "CSV has {} rows, exceeds maxRows limit of {}. \
74                 Increase the maxRows parameter to process larger files.",
75                rows.len(),
76                config.max_rows
77            )));
78        }
79
80        progress.report(50, &format!("Building {} row files...", rows.len()));
81        let files = build_row_files(&stem, &headers, &rows);
82
83        progress.report(90, "Building result...");
84        let metadata = build_read_metadata(headers.len(), rows.len());
85
86        progress.report(100, "Done!");
87        Ok(NodeOutput { files, metadata })
88    }
89}
90
91// --- Configuration ---
92
93struct ReadConfig {
94    has_headers: bool,
95    delimiter: String,
96    max_rows: u64,
97}
98
99impl ReadConfig {
100    fn from_params(params: &serde_json::Map<String, serde_json::Value>) -> Self {
101        Self {
102            has_headers: params
103                .get("hasHeaders")
104                .and_then(|v| v.as_bool())
105                .unwrap_or(true),
106            delimiter: params
107                .get("delimiter")
108                .and_then(|v| v.as_str())
109                .unwrap_or("comma")
110                .to_string(),
111            max_rows: params
112                .get("maxRows")
113                .and_then(|v| v.as_u64())
114                .unwrap_or(DEFAULT_MAX_ROWS),
115        }
116    }
117}
118
119// --- CSV Parsing (shared patterns from convert.rs) ---
120
121/// Validate UTF-8 and strip BOM if present.
122fn parse_and_strip_bom(data: &[u8]) -> Result<&str, BntoError> {
123    let data = if data.starts_with(&[0xEF, 0xBB, 0xBF]) {
124        &data[3..]
125    } else {
126        data
127    };
128
129    let text = std::str::from_utf8(data).map_err(|e| {
130        BntoError::InvalidInput(format!(
131            "File is not valid UTF-8 text (is this really a CSV?): {e}"
132        ))
133    })?;
134
135    if text.trim().is_empty() {
136        return Err(BntoError::InvalidInput(
137            "CSV file is empty — no data to read".to_string(),
138        ));
139    }
140
141    Ok(text)
142}
143
144/// Map delimiter name to byte.
145fn resolve_delimiter(delimiter: &str) -> u8 {
146    match delimiter {
147        "comma" | "," => b',',
148        "semicolon" | ";" => b';',
149        "tab" | "\t" => b'\t',
150        "pipe" | "|" => b'|',
151        other if other.len() == 1 => other.as_bytes()[0],
152        _ => b',',
153    }
154}
155
156/// Read CSV text into headers + rows. When `has_headers` is false,
157/// generates synthetic column names (col0, col1, ...).
158fn read_csv(
159    csv_text: &str,
160    delimiter: u8,
161    has_headers: bool,
162) -> Result<(Vec<String>, Vec<Vec<String>>), BntoError> {
163    let mut reader = csv::ReaderBuilder::new()
164        .has_headers(has_headers)
165        .delimiter(delimiter)
166        .flexible(true)
167        .from_reader(csv_text.as_bytes());
168
169    let headers = if has_headers {
170        let raw = reader
171            .headers()
172            .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read CSV headers: {e}")))?
173            .clone();
174        raw.iter().map(|h| h.trim().to_string()).collect()
175    } else {
176        Vec::new() // filled after first record
177    };
178
179    let mut rows = Vec::new();
180    let mut headers = headers;
181
182    for result in reader.records() {
183        let record = result
184            .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read CSV row: {e}")))?;
185
186        // Generate synthetic headers from the first record when no headers
187        if !has_headers && headers.is_empty() {
188            headers = (0..record.len()).map(|i| format!("col{i}")).collect();
189        }
190
191        let row: Vec<String> = (0..headers.len())
192            .map(|i| record.get(i).unwrap_or("").to_string())
193            .collect();
194        rows.push(row);
195    }
196
197    Ok((headers, rows))
198}
199
200// --- Output Construction ---
201
202fn build_row_files(stem: &str, headers: &[String], rows: &[Vec<String>]) -> Vec<OutputFile> {
203    rows.iter()
204        .enumerate()
205        .map(|(i, row)| {
206            let mut metadata = serde_json::Map::new();
207            for (col_idx, header) in headers.iter().enumerate() {
208                let value = row.get(col_idx).cloned().unwrap_or_default();
209                metadata.insert(header.clone(), serde_json::Value::String(value));
210            }
211            OutputFile {
212                data: FileData::Bytes(Vec::new()),
213                filename: format!("{stem}_row{i}.csv"),
214                mime_type: "text/csv".to_string(),
215                metadata,
216            }
217        })
218        .collect()
219}
220
221fn build_read_metadata(
222    column_count: usize,
223    row_count: usize,
224) -> serde_json::Map<String, serde_json::Value> {
225    let mut metadata = serde_json::Map::new();
226    metadata.insert(
227        "columnCount".to_string(),
228        serde_json::Value::Number(column_count.into()),
229    );
230    metadata.insert(
231        "rowCount".to_string(),
232        serde_json::Value::Number(row_count.into()),
233    );
234    metadata
235}
236
237fn file_stem(filename: &str) -> String {
238    if let Some(dot_pos) = filename.rfind('.') {
239        filename[..dot_pos].to_string()
240    } else {
241        filename.to_string()
242    }
243}
244
245// --- Metadata Parameter Definitions ---
246
247fn build_read_parameters() -> Vec<bnto_core::metadata::ParameterDef> {
248    use bnto_core::metadata::{Constraints, OptionEntry, ParameterDef, ParameterType};
249    vec![
250        ParameterDef {
251            name: "hasHeaders".to_string(),
252            label: "Has Headers".to_string(),
253            description: "First row contains column headers".to_string(),
254            param_type: ParameterType::Boolean,
255            default: Some(serde_json::json!(true)),
256            ..Default::default()
257        },
258        ParameterDef {
259            name: "delimiter".to_string(),
260            label: "Delimiter".to_string(),
261            description: "Column separator character".to_string(),
262            param_type: ParameterType::Enum {
263                options: vec![
264                    OptionEntry {
265                        value: "comma".to_string(),
266                        label: "Comma".to_string(),
267                    },
268                    OptionEntry {
269                        value: "semicolon".to_string(),
270                        label: "Semicolon".to_string(),
271                    },
272                    OptionEntry {
273                        value: "tab".to_string(),
274                        label: "Tab".to_string(),
275                    },
276                    OptionEntry {
277                        value: "pipe".to_string(),
278                        label: "Pipe".to_string(),
279                    },
280                ],
281            },
282            default: Some(serde_json::json!("comma")),
283            ..Default::default()
284        },
285        ParameterDef {
286            name: "maxRows".to_string(),
287            label: "Max Rows".to_string(),
288            description: "Maximum rows to process. Error if CSV exceeds this limit.".to_string(),
289            param_type: ParameterType::Number,
290            default: Some(serde_json::json!(100000)),
291            constraints: Some(Constraints {
292                min: Some(1.0),
293                max: Some(10_000_000.0),
294                required: false,
295            }),
296            ..Default::default()
297        },
298    ]
299}
300
301// =============================================================================
302// Tests
303// =============================================================================
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use bnto_core::NoopContext;
309
310    // --- Test Helpers ---
311
312    fn make_input(csv_text: &str) -> NodeInput {
313        NodeInput {
314            data: FileData::Bytes(csv_text.as_bytes().to_vec()),
315            filename: "data.csv".to_string(),
316            mime_type: Some("text/csv".to_string()),
317            params: serde_json::Map::new(),
318        }
319    }
320
321    fn make_input_with_params(
322        csv_text: &str,
323        params: serde_json::Map<String, serde_json::Value>,
324    ) -> NodeInput {
325        NodeInput {
326            data: FileData::Bytes(csv_text.as_bytes().to_vec()),
327            filename: "data.csv".to_string(),
328            mime_type: Some("text/csv".to_string()),
329            params,
330        }
331    }
332
333    fn make_input_bytes(data: Vec<u8>) -> NodeInput {
334        NodeInput {
335            data: FileData::Bytes(data),
336            filename: "data.csv".to_string(),
337            mime_type: Some("text/csv".to_string()),
338            params: serde_json::Map::new(),
339        }
340    }
341
342    fn process_ok(input: NodeInput) -> NodeOutput {
343        let processor = ReadSpreadsheet::new();
344        let progress = ProgressReporter::new_noop();
345        processor
346            .process(input, &progress, &NoopContext)
347            .expect("should succeed")
348    }
349
350    fn process_err(input: NodeInput) -> BntoError {
351        let processor = ReadSpreadsheet::new();
352        let progress = ProgressReporter::new_noop();
353        match processor.process(input, &progress, &NoopContext) {
354            Err(e) => e,
355            Ok(_) => panic!("expected an error"),
356        }
357    }
358
359    // --- Trait Basics ---
360
361    #[test]
362    fn test_name() {
363        assert_eq!(ReadSpreadsheet::new().name(), "spreadsheet-read");
364    }
365
366    #[test]
367    fn test_metadata_node_type() {
368        let meta = ReadSpreadsheet::new().metadata();
369        assert_eq!(meta.node_type, "spreadsheet-read");
370        assert_eq!(
371            meta.category,
372            bnto_core::metadata::NodeCategory::Spreadsheet
373        );
374    }
375
376    #[test]
377    fn test_metadata_has_expected_params() {
378        let meta = ReadSpreadsheet::new().metadata();
379        let names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
380        assert!(names.contains(&"hasHeaders"));
381        assert!(names.contains(&"delimiter"));
382        assert!(names.contains(&"maxRows"));
383    }
384
385    #[test]
386    fn test_metadata_accepts_csv() {
387        let meta = ReadSpreadsheet::new().metadata();
388        assert_eq!(meta.accepts, vec!["text/csv"]);
389    }
390
391    #[test]
392    fn test_metadata_browser_platform() {
393        let meta = ReadSpreadsheet::new().metadata();
394        assert_eq!(meta.platforms, vec!["browser"]);
395    }
396
397    // --- Happy Path ---
398
399    #[test]
400    fn test_simple_csv_produces_per_row_files() {
401        let csv = "group,url\nAlpha,https://a.com\nBeta,https://b.com\nGamma,https://c.com\n";
402        let output = process_ok(make_input(csv));
403        assert_eq!(output.files.len(), 3, "3 rows should produce 3 files");
404    }
405
406    #[test]
407    fn test_row_metadata_has_column_values() {
408        let csv = "group,url,title\nAlpha,https://a.com,First\n";
409        let output = process_ok(make_input(csv));
410
411        let meta = &output.files[0].metadata;
412        assert_eq!(meta.get("group").unwrap(), "Alpha");
413        assert_eq!(meta.get("url").unwrap(), "https://a.com");
414        assert_eq!(meta.get("title").unwrap(), "First");
415    }
416
417    #[test]
418    fn test_output_data_is_empty() {
419        let csv = "name\nAlice\nBob\n";
420        let output = process_ok(make_input(csv));
421
422        for file in &output.files {
423            assert!(
424                file.data.is_empty().unwrap(),
425                "row files should have empty data"
426            );
427        }
428    }
429
430    #[test]
431    fn test_output_filename_includes_row_index() {
432        let csv = "name\nAlice\nBob\n";
433        let output = process_ok(make_input(csv));
434
435        assert_eq!(output.files[0].filename, "data_row0.csv");
436        assert_eq!(output.files[1].filename, "data_row1.csv");
437    }
438
439    #[test]
440    fn test_output_mime_type() {
441        let csv = "name\nAlice\n";
442        let output = process_ok(make_input(csv));
443        assert_eq!(output.files[0].mime_type, "text/csv");
444    }
445
446    #[test]
447    fn test_headers_only_produces_no_files() {
448        let csv = "name,age,city\n";
449        let output = process_ok(make_input(csv));
450        assert_eq!(
451            output.files.len(),
452            0,
453            "headers-only CSV should produce 0 files"
454        );
455    }
456
457    #[test]
458    fn test_single_row() {
459        let csv = "x\n42\n";
460        let output = process_ok(make_input(csv));
461
462        assert_eq!(output.files.len(), 1);
463        assert_eq!(output.files[0].metadata.get("x").unwrap(), "42");
464    }
465
466    // --- Empty Cells ---
467
468    #[test]
469    fn test_empty_cells_produce_empty_strings() {
470        let csv = "name,age,city\nAlice,,NYC\n";
471        let output = process_ok(make_input(csv));
472
473        assert_eq!(output.files[0].metadata.get("age").unwrap(), "");
474    }
475
476    #[test]
477    fn test_short_row_gets_empty_strings() {
478        let csv = "name,age,city\nAlice\n";
479        let output = process_ok(make_input(csv));
480
481        assert_eq!(output.files[0].metadata.get("name").unwrap(), "Alice");
482        assert_eq!(output.files[0].metadata.get("age").unwrap(), "");
483        assert_eq!(output.files[0].metadata.get("city").unwrap(), "");
484    }
485
486    // --- Delimiter Configuration ---
487
488    #[test]
489    fn test_semicolon_delimiter() {
490        let csv = "name;age\nAlice;30\n";
491        let mut params = serde_json::Map::new();
492        params.insert("delimiter".into(), serde_json::json!("semicolon"));
493        let output = process_ok(make_input_with_params(csv, params));
494
495        assert_eq!(output.files[0].metadata.get("name").unwrap(), "Alice");
496        assert_eq!(output.files[0].metadata.get("age").unwrap(), "30");
497    }
498
499    #[test]
500    fn test_tab_delimiter() {
501        let csv = "name\tage\nAlice\t30\n";
502        let mut params = serde_json::Map::new();
503        params.insert("delimiter".into(), serde_json::json!("tab"));
504        let output = process_ok(make_input_with_params(csv, params));
505
506        assert_eq!(output.files[0].metadata.get("name").unwrap(), "Alice");
507    }
508
509    // --- BOM Handling ---
510
511    #[test]
512    fn test_bom_stripped() {
513        let csv_with_bom = b"\xEF\xBB\xBFname,age\nAlice,30\n";
514        let output = process_ok(make_input_bytes(csv_with_bom.to_vec()));
515
516        assert_eq!(output.files[0].metadata.get("name").unwrap(), "Alice");
517        assert!(output.files[0].metadata.get("\u{FEFF}name").is_none());
518    }
519
520    // --- No Headers Mode ---
521
522    #[test]
523    fn test_no_headers_uses_synthetic_names() {
524        let csv = "Alice,30,NYC\nBob,25,LA\n";
525        let mut params = serde_json::Map::new();
526        params.insert("hasHeaders".into(), serde_json::json!(false));
527        let output = process_ok(make_input_with_params(csv, params));
528
529        assert_eq!(output.files.len(), 2);
530        assert_eq!(output.files[0].metadata.get("col0").unwrap(), "Alice");
531        assert_eq!(output.files[0].metadata.get("col1").unwrap(), "30");
532        assert_eq!(output.files[0].metadata.get("col2").unwrap(), "NYC");
533    }
534
535    // --- maxRows Guard ---
536
537    #[test]
538    fn test_max_rows_rejects_large_csv() {
539        let mut csv = String::from("id\n");
540        for i in 0..10 {
541            csv.push_str(&format!("{i}\n"));
542        }
543        let mut params = serde_json::Map::new();
544        params.insert("maxRows".into(), serde_json::json!(5));
545        let err = process_err(make_input_with_params(&csv, params));
546
547        let msg = err.to_string();
548        assert!(
549            msg.contains("10 rows"),
550            "should mention actual count: {msg}"
551        );
552        assert!(msg.contains("5"), "should mention limit: {msg}");
553    }
554
555    #[test]
556    fn test_max_rows_at_limit_passes() {
557        let csv = "id\n1\n2\n3\n";
558        let mut params = serde_json::Map::new();
559        params.insert("maxRows".into(), serde_json::json!(3));
560        let output = process_ok(make_input_with_params(csv, params));
561        assert_eq!(output.files.len(), 3);
562    }
563
564    #[test]
565    fn test_default_max_rows() {
566        // Verify the default is 100k — just check the config
567        let params = serde_json::Map::new();
568        let config = ReadConfig::from_params(&params);
569        assert_eq!(config.max_rows, 100_000);
570    }
571
572    // --- Error Cases ---
573
574    #[test]
575    fn test_empty_input() {
576        let err = process_err(make_input(""));
577        assert!(err.to_string().contains("empty"));
578    }
579
580    #[test]
581    fn test_whitespace_only_input() {
582        let err = process_err(make_input("   \n\n  "));
583        assert!(err.to_string().contains("empty"));
584    }
585
586    #[test]
587    fn test_non_utf8_input() {
588        let err = process_err(make_input_bytes(vec![0xFF, 0xFE, 0x00, 0x41]));
589        assert!(err.to_string().contains("UTF-8"));
590    }
591
592    // --- Output Metadata ---
593
594    #[test]
595    fn test_metadata_counts() {
596        let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\n";
597        let output = process_ok(make_input(csv));
598
599        assert_eq!(output.metadata["columnCount"], 3);
600        assert_eq!(output.metadata["rowCount"], 2);
601    }
602
603    // --- Fixture Files ---
604
605    #[test]
606    fn test_fixture_manifest() {
607        let data = include_bytes!("../../../../test-fixtures/csv/manifest.csv");
608        let output = process_ok(make_input_bytes(data.to_vec()));
609
610        assert_eq!(output.files.len(), 3);
611        assert_eq!(output.files[0].metadata.get("group").unwrap(), "Alpha");
612        assert_eq!(
613            output.files[0].metadata.get("url").unwrap(),
614            "https://example.com/video1.mp4"
615        );
616        assert_eq!(output.files[2].metadata.get("group").unwrap(), "Beta");
617    }
618
619    #[test]
620    fn test_fixture_simple() {
621        let data = include_bytes!("../../../../test-fixtures/csv/simple.csv");
622        let output = process_ok(make_input_bytes(data.to_vec()));
623
624        assert_eq!(output.files.len(), 5);
625        assert_eq!(output.files[0].metadata.get("name").unwrap(), "Alice");
626    }
627
628    #[test]
629    fn test_fixture_headers_only() {
630        let data = include_bytes!("../../../../test-fixtures/csv/headers-only.csv");
631        let output = process_ok(make_input_bytes(data.to_vec()));
632        assert_eq!(output.files.len(), 0);
633    }
634
635    // --- Large CSV ---
636
637    #[test]
638    fn test_large_csv_1000_rows() {
639        let mut csv = String::from("id,name,value\n");
640        for i in 0..1000 {
641            csv.push_str(&format!("{i},item_{i},{}\n", i * 10));
642        }
643        let output = process_ok(make_input(&csv));
644
645        assert_eq!(output.files.len(), 1000);
646        assert_eq!(output.metadata["rowCount"], 1000);
647        assert_eq!(output.metadata["columnCount"], 3);
648        // Verify first and last rows
649        assert_eq!(output.files[0].metadata.get("id").unwrap(), "0");
650        assert_eq!(output.files[999].metadata.get("id").unwrap(), "999");
651    }
652}