1use bnto_core::context::ProcessContext;
7use bnto_core::errors::BntoError;
8use bnto_core::processor::{FileData, NodeInput, NodeOutput, NodeProcessor, OutputFile};
9use bnto_core::progress::ProgressReporter;
10
11pub struct CleanSpreadsheet;
13
14impl CleanSpreadsheet {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20impl Default for CleanSpreadsheet {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl NodeProcessor for CleanSpreadsheet {
29 fn name(&self) -> &str {
30 "spreadsheet-clean"
31 }
32
33 fn metadata(&self) -> bnto_core::NodeMetadata {
36 use bnto_core::metadata::*;
37 NodeMetadata {
38 node_type: "spreadsheet-clean".to_string(),
39 name: "Clean CSV".to_string(),
40 description: "Remove empty rows, trim whitespace, and deduplicate CSV data".to_string(),
41 category: NodeCategory::Spreadsheet,
42 accepts: vec!["text/csv".to_string()],
43 platforms: vec!["browser".to_string()],
44 parameters: build_clean_parameters(),
45 input_cardinality: InputCardinality::PerFile,
46 requires: vec![],
47 }
48 }
49
50 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 = CleanConfig::from_params(&input.params);
63 let csv_text = parse_csv_input(&data)?;
64
65 progress.report(10, "Reading CSV records...");
66 let (cleaned_headers, num_columns) = read_and_clean_headers(csv_text, config.trim)?;
67
68 progress.report(20, "Cleaning records...");
69 let (mut cleaned_rows, original_row_count) =
70 collect_cleaned_rows(csv_text, config.trim, config.remove_empty, num_columns)?;
71
72 progress.report(60, "Removing duplicates...");
73 let duplicates_removed = deduplicate_rows(&mut cleaned_rows, config.deduplicate);
74
75 progress.report(80, "Writing cleaned CSV...");
76 let output_bytes = write_csv_output(&cleaned_headers, &cleaned_rows)?;
77
78 progress.report(90, "Building result...");
79 let metadata =
80 build_clean_metadata(original_row_count, cleaned_rows.len(), duplicates_removed);
81
82 progress.report(100, "Done!");
83 Ok(build_clean_output(output_bytes, &input.filename, metadata))
84 }
85}
86
87struct CleanConfig {
90 trim: bool,
91 remove_empty: bool,
92 deduplicate: bool,
93}
94
95impl CleanConfig {
96 fn from_params(params: &serde_json::Map<String, serde_json::Value>) -> Self {
97 Self {
98 trim: bool_param(params, "trimWhitespace", true),
99 remove_empty: bool_param(params, "removeEmptyRows", true),
100 deduplicate: bool_param(params, "removeDuplicates", true),
101 }
102 }
103}
104
105fn bool_param(
106 params: &serde_json::Map<String, serde_json::Value>,
107 key: &str,
108 default: bool,
109) -> bool {
110 params.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
111}
112
113fn build_clean_output(
114 data: Vec<u8>,
115 input_filename: &str,
116 metadata: serde_json::Map<String, serde_json::Value>,
117) -> NodeOutput {
118 NodeOutput {
119 files: vec![OutputFile {
120 data: FileData::Bytes(data),
121 filename: generate_output_filename(input_filename),
122 mime_type: "text/csv".to_string(),
123 metadata: serde_json::Map::new(),
124 }],
125 metadata,
126 }
127}
128
129fn clean_bool_param(
133 name: &str,
134 label: &str,
135 description: &str,
136) -> bnto_core::metadata::ParameterDef {
137 use bnto_core::metadata::*;
138 ParameterDef {
139 name: name.to_string(),
140 label: label.to_string(),
141 description: description.to_string(),
142 param_type: ParameterType::Boolean,
143 default: Some(serde_json::json!(true)),
144 ..Default::default()
145 }
146}
147
148fn build_clean_parameters() -> Vec<bnto_core::metadata::ParameterDef> {
149 vec![
150 clean_bool_param(
151 "trimWhitespace",
152 "Trim Whitespace",
153 "Remove leading and trailing whitespace from every cell",
154 ),
155 clean_bool_param(
156 "removeEmptyRows",
157 "Remove Empty Rows",
158 "Skip rows where every cell is blank",
159 ),
160 clean_bool_param(
161 "removeDuplicates",
162 "Remove Duplicates",
163 "Remove duplicate rows, keeping the first occurrence",
164 ),
165 ]
166}
167
168fn parse_csv_input(data: &[u8]) -> Result<&str, BntoError> {
172 let csv_text = std::str::from_utf8(data).map_err(|e| {
173 BntoError::InvalidInput(format!(
174 "File is not valid UTF-8 text (is this really a CSV?): {e}"
175 ))
176 })?;
177
178 if csv_text.trim().is_empty() {
179 return Err(BntoError::InvalidInput(
180 "CSV file is empty — no data to clean".to_string(),
181 ));
182 }
183
184 Ok(csv_text)
185}
186
187fn read_and_clean_headers(csv_text: &str, trim: bool) -> Result<(Vec<String>, usize), BntoError> {
189 let mut reader = csv::ReaderBuilder::new()
190 .has_headers(true)
191 .flexible(true)
192 .from_reader(csv_text.as_bytes());
193
194 let headers = reader
195 .headers()
196 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read CSV headers: {e}")))?
197 .clone();
198
199 let cleaned: Vec<String> = if trim {
200 headers.iter().map(|h| h.trim().to_string()).collect()
201 } else {
202 headers.iter().map(|h| h.to_string()).collect()
203 };
204
205 let num_columns = cleaned.len();
206 Ok((cleaned, num_columns))
207}
208
209fn collect_cleaned_rows(
214 csv_text: &str,
215 trim: bool,
216 remove_empty: bool,
217 num_columns: usize,
218) -> Result<(Vec<Vec<String>>, usize), BntoError> {
219 let mut reader = csv::ReaderBuilder::new()
220 .has_headers(true)
221 .flexible(true)
222 .from_reader(csv_text.as_bytes());
223
224 let mut cleaned_rows: Vec<Vec<String>> = Vec::new();
225 let mut original_row_count: usize = 0;
226
227 for result in reader.records() {
228 original_row_count += 1;
229 let record = match result {
230 Ok(rec) => rec,
231 Err(_) => continue, };
233
234 let row = normalize_row(&record, trim, num_columns);
235
236 if remove_empty && row.iter().all(|cell| cell.is_empty()) {
237 continue;
238 }
239
240 cleaned_rows.push(row);
241 }
242
243 Ok((cleaned_rows, original_row_count))
244}
245
246fn normalize_row(record: &csv::StringRecord, trim: bool, num_columns: usize) -> Vec<String> {
248 let mut row: Vec<String> = record
249 .iter()
250 .map(|cell| {
251 if trim {
252 cell.trim().to_string()
253 } else {
254 cell.to_string()
255 }
256 })
257 .collect();
258
259 while row.len() < num_columns {
260 row.push(String::new());
261 }
262 row.truncate(num_columns);
263 row
264}
265
266fn deduplicate_rows(rows: &mut Vec<Vec<String>>, enabled: bool) -> usize {
268 if !enabled {
269 return 0;
270 }
271
272 let mut seen = std::collections::HashSet::new();
273 let before = rows.len();
274
275 rows.retain(|row| seen.insert(row.join("\0")));
277
278 before - rows.len()
279}
280
281fn write_csv_output(headers: &[String], rows: &[Vec<String>]) -> Result<Vec<u8>, BntoError> {
285 let mut writer = csv::WriterBuilder::new().from_writer(Vec::new());
286
287 writer
288 .write_record(headers)
289 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to write CSV headers: {e}")))?;
290
291 for row in rows {
292 writer
293 .write_record(row)
294 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to write CSV row: {e}")))?;
295 }
296
297 writer
298 .into_inner()
299 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to finalize CSV output: {e}")))
300}
301
302fn build_clean_metadata(
305 original_row_count: usize,
306 cleaned_row_count: usize,
307 duplicates_removed: usize,
308) -> serde_json::Map<String, serde_json::Value> {
309 let rows_removed = original_row_count - cleaned_row_count;
310 let mut metadata = serde_json::Map::new();
311 metadata.insert(
312 "originalRows".to_string(),
313 serde_json::Value::Number(original_row_count.into()),
314 );
315 metadata.insert(
316 "cleanedRows".to_string(),
317 serde_json::Value::Number(cleaned_row_count.into()),
318 );
319 metadata.insert(
320 "rowsRemoved".to_string(),
321 serde_json::Value::Number(rows_removed.into()),
322 );
323 metadata.insert(
324 "duplicatesRemoved".to_string(),
325 serde_json::Value::Number(duplicates_removed.into()),
326 );
327 metadata
328}
329
330fn generate_output_filename(original: &str) -> String {
334 if let Some(dot_pos) = original.rfind('.') {
335 let stem = &original[..dot_pos];
336 let ext = &original[dot_pos..];
337 format!("{stem}-cleaned{ext}")
338 } else {
339 format!("{original}-cleaned")
340 }
341}
342
343#[cfg(test)]
348mod tests {
349 use super::*;
350 use bnto_core::NoopContext;
351
352 fn make_csv_input(csv_text: &str) -> NodeInput {
361 NodeInput {
362 data: FileData::Bytes(csv_text.as_bytes().to_vec()),
363 filename: "test.csv".to_string(),
364 mime_type: Some("text/csv".to_string()),
365 params: serde_json::Map::new(),
366 }
367 }
368
369 fn make_csv_input_with_params(
371 csv_text: &str,
372 params: serde_json::Map<String, serde_json::Value>,
373 ) -> NodeInput {
374 NodeInput {
375 data: FileData::Bytes(csv_text.as_bytes().to_vec()),
376 filename: "test.csv".to_string(),
377 mime_type: Some("text/csv".to_string()),
378 params,
379 }
380 }
381
382 fn output_csv_text(output: &NodeOutput) -> String {
387 let bytes = output.files[0]
388 .data
389 .clone()
390 .into_bytes()
391 .expect("Should read bytes");
392 String::from_utf8(bytes).expect("Output should be valid UTF-8")
393 }
394
395 fn count_data_rows(csv_text: &str) -> usize {
397 let mut reader = csv::ReaderBuilder::new()
398 .has_headers(true)
399 .flexible(true)
400 .from_reader(csv_text.as_bytes());
401 reader.records().count()
402 }
403
404 #[test]
409 fn test_name_returns_clean_csv() {
410 let processor = CleanSpreadsheet::new();
412 assert_eq!(processor.name(), "spreadsheet-clean");
413 }
414
415 #[test]
416 fn test_default_creates_same_as_new() {
417 #[allow(clippy::default_constructed_unit_structs)]
421 let _processor = CleanSpreadsheet::default();
422 }
425
426 #[test]
427 fn test_basic_csv_passthrough() {
428 let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\n";
431 let processor = CleanSpreadsheet::new();
432 let progress = ProgressReporter::new_noop();
433 let input = make_csv_input(csv);
434
435 let output = processor.process(input, &progress, &NoopContext).unwrap();
436
437 assert_eq!(output.files.len(), 1);
439
440 assert_eq!(output.files[0].mime_type, "text/csv");
442
443 let text = output_csv_text(&output);
445 let rows = count_data_rows(&text);
446 assert_eq!(rows, 2, "Should preserve both data rows");
447
448 assert!(text.contains("Alice"), "Should contain Alice");
450 assert!(text.contains("Bob"), "Should contain Bob");
451 }
452
453 #[test]
458 fn test_remove_empty_rows() {
459 let csv = "name,age\nAlice,30\n,,\nBob,25\n,,\n";
461 let processor = CleanSpreadsheet::new();
462 let progress = ProgressReporter::new_noop();
463 let input = make_csv_input(csv);
464
465 let output = processor.process(input, &progress, &NoopContext).unwrap();
466 let text = output_csv_text(&output);
467 let rows = count_data_rows(&text);
468
469 assert_eq!(rows, 2, "Should remove empty rows, keeping 2 data rows");
471 assert!(text.contains("Alice"));
472 assert!(text.contains("Bob"));
473
474 let removed = output
476 .metadata
477 .get("rowsRemoved")
478 .unwrap()
479 .as_u64()
480 .unwrap();
481 assert_eq!(removed, 2, "Should report 2 rows removed");
482 }
483
484 #[test]
489 fn test_trim_whitespace_from_cells() {
490 let csv = "name,age\n Alice , 30 \n Bob ,25\n";
492 let processor = CleanSpreadsheet::new();
493 let progress = ProgressReporter::new_noop();
494 let input = make_csv_input(csv);
495
496 let output = processor.process(input, &progress, &NoopContext).unwrap();
497 let text = output_csv_text(&output);
498
499 assert!(text.contains("Alice"), "Should contain trimmed 'Alice'");
502 assert!(text.contains("Bob"), "Should contain trimmed 'Bob'");
503 assert!(
505 !text.contains(" Alice"),
506 "Should NOT contain ' Alice' with leading spaces"
507 );
508 }
509
510 #[test]
515 fn test_remove_duplicate_rows() {
516 let csv = "name,age\nAlice,30\nBob,25\nAlice,30\n";
518 let processor = CleanSpreadsheet::new();
519 let progress = ProgressReporter::new_noop();
520 let input = make_csv_input(csv);
521
522 let output = processor.process(input, &progress, &NoopContext).unwrap();
523 let text = output_csv_text(&output);
524 let rows = count_data_rows(&text);
525
526 assert_eq!(rows, 2, "Should remove 1 duplicate, keeping 2 rows");
528
529 let dupes = output
531 .metadata
532 .get("duplicatesRemoved")
533 .unwrap()
534 .as_u64()
535 .unwrap();
536 assert_eq!(dupes, 1, "Should report 1 duplicate removed");
537 }
538
539 #[test]
544 fn test_preserves_header_row() {
545 let csv = " name , age \nAlice,30\n";
547 let processor = CleanSpreadsheet::new();
548 let progress = ProgressReporter::new_noop();
549 let input = make_csv_input(csv);
550
551 let output = processor.process(input, &progress, &NoopContext).unwrap();
552 let text = output_csv_text(&output);
553
554 assert!(
556 text.starts_with("name,age"),
557 "Header should be trimmed: got '{}'",
558 text.lines().next().unwrap_or("")
559 );
560 }
561
562 #[test]
567 fn test_variable_length_rows_padded() {
568 let csv = "name,age,city\nAlice,30\nBob,25,LA\n";
571 let processor = CleanSpreadsheet::new();
572 let progress = ProgressReporter::new_noop();
573 let input = make_csv_input(csv);
574
575 let output = processor.process(input, &progress, &NoopContext).unwrap();
576 let text = output_csv_text(&output);
577
578 let rows = count_data_rows(&text);
580 assert_eq!(rows, 2, "Should keep both rows");
581
582 assert!(text.contains("Alice"), "Should contain Alice's row");
585 }
586
587 #[test]
592 fn test_headers_only_csv() {
593 let csv = "name,age,city\n";
595 let processor = CleanSpreadsheet::new();
596 let progress = ProgressReporter::new_noop();
597 let input = make_csv_input(csv);
598
599 let output = processor.process(input, &progress, &NoopContext).unwrap();
600 let text = output_csv_text(&output);
601
602 let rows = count_data_rows(&text);
604 assert_eq!(rows, 0, "Should have zero data rows");
605 assert!(text.contains("name"), "Should still contain header");
606
607 let original = output
609 .metadata
610 .get("originalRows")
611 .unwrap()
612 .as_u64()
613 .unwrap();
614 assert_eq!(original, 0, "Original row count should be 0");
615 }
616
617 #[test]
622 fn test_empty_input_returns_error() {
623 let csv = "";
625 let processor = CleanSpreadsheet::new();
626 let progress = ProgressReporter::new_noop();
627 let input = make_csv_input(csv);
628
629 let result = processor.process(input, &progress, &NoopContext);
630
631 assert!(result.is_err(), "Empty input should return an error");
632
633 if let Err(err) = result {
634 assert!(
635 err.to_string().contains("empty"),
636 "Error should mention 'empty': got '{}'",
637 err
638 );
639 }
640 }
641
642 #[test]
647 fn test_non_utf8_input_returns_error() {
648 let bad_bytes = vec![0xFF, 0xFE, 0x00, 0x41]; let processor = CleanSpreadsheet::new();
654 let progress = ProgressReporter::new_noop();
655 let input = NodeInput {
656 data: FileData::Bytes(bad_bytes),
657 filename: "bad.csv".to_string(),
658 mime_type: None,
659 params: serde_json::Map::new(),
660 };
661
662 let result = processor.process(input, &progress, &NoopContext);
663
664 assert!(result.is_err(), "Non-UTF8 input should return an error");
665
666 if let Err(err) = result {
669 assert!(
670 err.to_string().contains("UTF-8"),
671 "Error should mention UTF-8: got '{}'",
672 err
673 );
674 }
675 }
676
677 #[test]
682 fn test_combined_trim_remove_empty_deduplicate() {
683 let csv = "name,age\n Alice , 30 \n,,\nBob,25\n Alice , 30 \n,,\n";
685 let processor = CleanSpreadsheet::new();
686 let progress = ProgressReporter::new_noop();
687 let input = make_csv_input(csv);
688
689 let output = processor.process(input, &progress, &NoopContext).unwrap();
690 let text = output_csv_text(&output);
691 let rows = count_data_rows(&text);
692
693 assert_eq!(
700 rows, 2,
701 "Should have 2 rows (Alice + Bob), got text:\n{}",
702 text
703 );
704
705 let removed = output
707 .metadata
708 .get("rowsRemoved")
709 .unwrap()
710 .as_u64()
711 .unwrap();
712 assert_eq!(
713 removed, 3,
714 "Should remove 3 rows total (2 empty + 1 duplicate)"
715 );
716 let dupes = output
717 .metadata
718 .get("duplicatesRemoved")
719 .unwrap()
720 .as_u64()
721 .unwrap();
722 assert_eq!(dupes, 1, "Should report 1 duplicate removed");
723 }
724
725 #[test]
730 fn test_remove_duplicates_false_preserves_duplicates() {
731 let csv = "name,age\nAlice,30\nBob,25\nAlice,30\n";
733 let processor = CleanSpreadsheet::new();
734 let progress = ProgressReporter::new_noop();
735
736 let mut params = serde_json::Map::new();
737 params.insert(
738 "removeDuplicates".to_string(),
739 serde_json::Value::Bool(false),
740 );
741 let input = make_csv_input_with_params(csv, params);
742
743 let output = processor.process(input, &progress, &NoopContext).unwrap();
744 let text = output_csv_text(&output);
745 let rows = count_data_rows(&text);
746
747 assert_eq!(
749 rows, 3,
750 "Should keep all 3 rows when removeDuplicates is false"
751 );
752
753 let dupes = output
755 .metadata
756 .get("duplicatesRemoved")
757 .unwrap()
758 .as_u64()
759 .unwrap();
760 assert_eq!(dupes, 0);
761 }
762
763 #[test]
764 fn test_trim_whitespace_false_preserves_whitespace() {
765 let csv = "name,age\n Alice , 30 \nBob,25\n";
767 let processor = CleanSpreadsheet::new();
768 let progress = ProgressReporter::new_noop();
769
770 let mut params = serde_json::Map::new();
771 params.insert("trimWhitespace".to_string(), serde_json::Value::Bool(false));
772 let input = make_csv_input_with_params(csv, params);
773
774 let output = processor.process(input, &progress, &NoopContext).unwrap();
775 let text = output_csv_text(&output);
776
777 assert!(
781 text.contains("Alice") && text.contains("Bob"),
782 "Should contain both names"
783 );
784 assert!(
787 text.contains(" Alice "),
788 "Should preserve whitespace around Alice: got:\n{}",
789 text
790 );
791 }
792
793 #[test]
794 fn test_remove_empty_rows_false_preserves_empty_rows() {
795 let csv = "name,age\nAlice,30\n,,\nBob,25\n";
797 let processor = CleanSpreadsheet::new();
798 let progress = ProgressReporter::new_noop();
799
800 let mut params = serde_json::Map::new();
801 params.insert(
802 "removeEmptyRows".to_string(),
803 serde_json::Value::Bool(false),
804 );
805 let input = make_csv_input_with_params(csv, params);
806
807 let output = processor.process(input, &progress, &NoopContext).unwrap();
808 let text = output_csv_text(&output);
809 let rows = count_data_rows(&text);
810
811 assert_eq!(
813 rows, 3,
814 "Should keep all 3 rows when removeEmptyRows is false"
815 );
816 }
817
818 #[test]
823 fn test_large_csv_1000_rows() {
824 let mut csv = String::from("id,name,value\n");
826 for i in 0..1200 {
827 csv.push_str(&format!("{i},item_{i},{}\n", i * 10));
828 }
829
830 let processor = CleanSpreadsheet::new();
831 let progress = ProgressReporter::new_noop();
832 let input = make_csv_input(&csv);
833
834 let output = processor.process(input, &progress, &NoopContext).unwrap();
835 let text = output_csv_text(&output);
836 let rows = count_data_rows(&text);
837
838 assert_eq!(rows, 1200, "Should process all 1200 rows");
840
841 let removed = output
843 .metadata
844 .get("rowsRemoved")
845 .unwrap()
846 .as_u64()
847 .unwrap();
848 assert_eq!(removed, 0, "No rows should be removed from clean data");
849 }
850
851 #[test]
856 fn test_output_filename_with_extension() {
857 let result = generate_output_filename("data.csv");
858 assert_eq!(result, "data-cleaned.csv");
859 }
860
861 #[test]
862 fn test_output_filename_without_extension() {
863 let result = generate_output_filename("data");
864 assert_eq!(result, "data-cleaned");
865 }
866
867 #[test]
868 fn test_output_filename_with_multiple_dots() {
869 let result = generate_output_filename("my.data.csv");
870 assert_eq!(result, "my.data-cleaned.csv");
871 }
872
873 #[test]
874 fn test_output_filename_in_result() {
875 let csv = "name\nAlice\n";
877 let processor = CleanSpreadsheet::new();
878 let progress = ProgressReporter::new_noop();
879 let input = NodeInput {
880 data: FileData::Bytes(csv.as_bytes().to_vec()),
881 filename: "employees.csv".to_string(),
882 mime_type: None,
883 params: serde_json::Map::new(),
884 };
885
886 let output = processor.process(input, &progress, &NoopContext).unwrap();
887 assert_eq!(output.files[0].filename, "employees-cleaned.csv");
888 }
889
890 #[test]
895 fn test_metadata_contains_all_fields() {
896 let csv = "name,age\nAlice,30\nBob,25\n";
897 let processor = CleanSpreadsheet::new();
898 let progress = ProgressReporter::new_noop();
899 let input = make_csv_input(csv);
900
901 let output = processor.process(input, &progress, &NoopContext).unwrap();
902
903 assert!(output.metadata.contains_key("originalRows"));
905 assert!(output.metadata.contains_key("cleanedRows"));
906 assert!(output.metadata.contains_key("rowsRemoved"));
907 assert!(output.metadata.contains_key("duplicatesRemoved"));
908 }
909}