1use 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
14pub 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
29impl 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
91struct 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
119fn 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
144fn 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
156fn 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() };
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 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
200fn 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
245fn 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#[cfg(test)]
306mod tests {
307 use super::*;
308 use bnto_core::NoopContext;
309
310 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let params = serde_json::Map::new();
568 let config = ReadConfig::from_params(¶ms);
569 assert_eq!(config.max_rows, 100_000);
570 }
571
572 #[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 #[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 #[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 #[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 assert_eq!(output.files[0].metadata.get("id").unwrap(), "0");
650 assert_eq!(output.files[999].metadata.get("id").unwrap(), "999");
651 }
652}