1use bnto_core::context::ProcessContext;
7use bnto_core::errors::BntoError;
8use bnto_core::processor::{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 config = CleanConfig::from_params(&input.params);
59 let csv_text = parse_csv_input(&input.data)?;
60
61 progress.report(10, "Reading CSV records...");
62 let (cleaned_headers, num_columns) = read_and_clean_headers(csv_text, config.trim)?;
63
64 progress.report(20, "Cleaning records...");
65 let (mut cleaned_rows, original_row_count) =
66 collect_cleaned_rows(csv_text, config.trim, config.remove_empty, num_columns)?;
67
68 progress.report(60, "Removing duplicates...");
69 let duplicates_removed = deduplicate_rows(&mut cleaned_rows, config.deduplicate);
70
71 progress.report(80, "Writing cleaned CSV...");
72 let output_bytes = write_csv_output(&cleaned_headers, &cleaned_rows)?;
73
74 progress.report(90, "Building result...");
75 let metadata =
76 build_clean_metadata(original_row_count, cleaned_rows.len(), duplicates_removed);
77
78 progress.report(100, "Done!");
79 Ok(build_clean_output(output_bytes, &input.filename, metadata))
80 }
81}
82
83struct CleanConfig {
86 trim: bool,
87 remove_empty: bool,
88 deduplicate: bool,
89}
90
91impl CleanConfig {
92 fn from_params(params: &serde_json::Map<String, serde_json::Value>) -> Self {
93 Self {
94 trim: bool_param(params, "trimWhitespace", true),
95 remove_empty: bool_param(params, "removeEmptyRows", true),
96 deduplicate: bool_param(params, "removeDuplicates", true),
97 }
98 }
99}
100
101fn bool_param(
102 params: &serde_json::Map<String, serde_json::Value>,
103 key: &str,
104 default: bool,
105) -> bool {
106 params.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
107}
108
109fn build_clean_output(
110 data: Vec<u8>,
111 input_filename: &str,
112 metadata: serde_json::Map<String, serde_json::Value>,
113) -> NodeOutput {
114 NodeOutput {
115 files: vec![OutputFile {
116 data,
117 filename: generate_output_filename(input_filename),
118 mime_type: "text/csv".to_string(),
119 }],
120 metadata,
121 }
122}
123
124fn clean_bool_param(
128 name: &str,
129 label: &str,
130 description: &str,
131) -> bnto_core::metadata::ParameterDef {
132 use bnto_core::metadata::*;
133 ParameterDef {
134 name: name.to_string(),
135 label: label.to_string(),
136 description: description.to_string(),
137 param_type: ParameterType::Boolean,
138 default: Some(serde_json::json!(true)),
139 ..Default::default()
140 }
141}
142
143fn build_clean_parameters() -> Vec<bnto_core::metadata::ParameterDef> {
144 vec![
145 clean_bool_param(
146 "trimWhitespace",
147 "Trim Whitespace",
148 "Remove leading and trailing whitespace from every cell",
149 ),
150 clean_bool_param(
151 "removeEmptyRows",
152 "Remove Empty Rows",
153 "Skip rows where every cell is blank",
154 ),
155 clean_bool_param(
156 "removeDuplicates",
157 "Remove Duplicates",
158 "Remove duplicate rows, keeping the first occurrence",
159 ),
160 ]
161}
162
163fn parse_csv_input(data: &[u8]) -> Result<&str, BntoError> {
167 let csv_text = std::str::from_utf8(data).map_err(|e| {
168 BntoError::InvalidInput(format!(
169 "File is not valid UTF-8 text (is this really a CSV?): {e}"
170 ))
171 })?;
172
173 if csv_text.trim().is_empty() {
174 return Err(BntoError::InvalidInput(
175 "CSV file is empty — no data to clean".to_string(),
176 ));
177 }
178
179 Ok(csv_text)
180}
181
182fn read_and_clean_headers(csv_text: &str, trim: bool) -> Result<(Vec<String>, usize), BntoError> {
184 let mut reader = csv::ReaderBuilder::new()
185 .has_headers(true)
186 .flexible(true)
187 .from_reader(csv_text.as_bytes());
188
189 let headers = reader
190 .headers()
191 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read CSV headers: {e}")))?
192 .clone();
193
194 let cleaned: Vec<String> = if trim {
195 headers.iter().map(|h| h.trim().to_string()).collect()
196 } else {
197 headers.iter().map(|h| h.to_string()).collect()
198 };
199
200 let num_columns = cleaned.len();
201 Ok((cleaned, num_columns))
202}
203
204fn collect_cleaned_rows(
209 csv_text: &str,
210 trim: bool,
211 remove_empty: bool,
212 num_columns: usize,
213) -> Result<(Vec<Vec<String>>, usize), BntoError> {
214 let mut reader = csv::ReaderBuilder::new()
215 .has_headers(true)
216 .flexible(true)
217 .from_reader(csv_text.as_bytes());
218
219 let mut cleaned_rows: Vec<Vec<String>> = Vec::new();
220 let mut original_row_count: usize = 0;
221
222 for result in reader.records() {
223 original_row_count += 1;
224 let record = match result {
225 Ok(rec) => rec,
226 Err(_) => continue, };
228
229 let row = normalize_row(&record, trim, num_columns);
230
231 if remove_empty && row.iter().all(|cell| cell.is_empty()) {
232 continue;
233 }
234
235 cleaned_rows.push(row);
236 }
237
238 Ok((cleaned_rows, original_row_count))
239}
240
241fn normalize_row(record: &csv::StringRecord, trim: bool, num_columns: usize) -> Vec<String> {
243 let mut row: Vec<String> = record
244 .iter()
245 .map(|cell| {
246 if trim {
247 cell.trim().to_string()
248 } else {
249 cell.to_string()
250 }
251 })
252 .collect();
253
254 while row.len() < num_columns {
255 row.push(String::new());
256 }
257 row.truncate(num_columns);
258 row
259}
260
261fn deduplicate_rows(rows: &mut Vec<Vec<String>>, enabled: bool) -> usize {
263 if !enabled {
264 return 0;
265 }
266
267 let mut seen = std::collections::HashSet::new();
268 let before = rows.len();
269
270 rows.retain(|row| seen.insert(row.join("\0")));
272
273 before - rows.len()
274}
275
276fn write_csv_output(headers: &[String], rows: &[Vec<String>]) -> Result<Vec<u8>, BntoError> {
280 let mut writer = csv::WriterBuilder::new().from_writer(Vec::new());
281
282 writer
283 .write_record(headers)
284 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to write CSV headers: {e}")))?;
285
286 for row in rows {
287 writer
288 .write_record(row)
289 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to write CSV row: {e}")))?;
290 }
291
292 writer
293 .into_inner()
294 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to finalize CSV output: {e}")))
295}
296
297fn build_clean_metadata(
300 original_row_count: usize,
301 cleaned_row_count: usize,
302 duplicates_removed: usize,
303) -> serde_json::Map<String, serde_json::Value> {
304 let rows_removed = original_row_count - cleaned_row_count;
305 let mut metadata = serde_json::Map::new();
306 metadata.insert(
307 "originalRows".to_string(),
308 serde_json::Value::Number(original_row_count.into()),
309 );
310 metadata.insert(
311 "cleanedRows".to_string(),
312 serde_json::Value::Number(cleaned_row_count.into()),
313 );
314 metadata.insert(
315 "rowsRemoved".to_string(),
316 serde_json::Value::Number(rows_removed.into()),
317 );
318 metadata.insert(
319 "duplicatesRemoved".to_string(),
320 serde_json::Value::Number(duplicates_removed.into()),
321 );
322 metadata
323}
324
325fn generate_output_filename(original: &str) -> String {
329 if let Some(dot_pos) = original.rfind('.') {
330 let stem = &original[..dot_pos];
331 let ext = &original[dot_pos..];
332 format!("{stem}-cleaned{ext}")
333 } else {
334 format!("{original}-cleaned")
335 }
336}
337
338#[cfg(test)]
343mod tests {
344 use super::*;
345 use bnto_core::NoopContext;
346
347 fn make_csv_input(csv_text: &str) -> NodeInput {
356 NodeInput {
357 data: csv_text.as_bytes().to_vec(),
358 filename: "test.csv".to_string(),
359 mime_type: Some("text/csv".to_string()),
360 params: serde_json::Map::new(),
361 }
362 }
363
364 fn make_csv_input_with_params(
366 csv_text: &str,
367 params: serde_json::Map<String, serde_json::Value>,
368 ) -> NodeInput {
369 NodeInput {
370 data: csv_text.as_bytes().to_vec(),
371 filename: "test.csv".to_string(),
372 mime_type: Some("text/csv".to_string()),
373 params,
374 }
375 }
376
377 fn output_csv_text(output: &NodeOutput) -> String {
382 String::from_utf8(output.files[0].data.clone()).expect("Output should be valid UTF-8")
383 }
384
385 fn count_data_rows(csv_text: &str) -> usize {
387 let mut reader = csv::ReaderBuilder::new()
388 .has_headers(true)
389 .flexible(true)
390 .from_reader(csv_text.as_bytes());
391 reader.records().count()
392 }
393
394 #[test]
399 fn test_name_returns_clean_csv() {
400 let processor = CleanSpreadsheet::new();
402 assert_eq!(processor.name(), "spreadsheet-clean");
403 }
404
405 #[test]
406 fn test_default_creates_same_as_new() {
407 #[allow(clippy::default_constructed_unit_structs)]
411 let _processor = CleanSpreadsheet::default();
412 }
415
416 #[test]
417 fn test_basic_csv_passthrough() {
418 let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\n";
421 let processor = CleanSpreadsheet::new();
422 let progress = ProgressReporter::new_noop();
423 let input = make_csv_input(csv);
424
425 let output = processor.process(input, &progress, &NoopContext).unwrap();
426
427 assert_eq!(output.files.len(), 1);
429
430 assert_eq!(output.files[0].mime_type, "text/csv");
432
433 let text = output_csv_text(&output);
435 let rows = count_data_rows(&text);
436 assert_eq!(rows, 2, "Should preserve both data rows");
437
438 assert!(text.contains("Alice"), "Should contain Alice");
440 assert!(text.contains("Bob"), "Should contain Bob");
441 }
442
443 #[test]
448 fn test_remove_empty_rows() {
449 let csv = "name,age\nAlice,30\n,,\nBob,25\n,,\n";
451 let processor = CleanSpreadsheet::new();
452 let progress = ProgressReporter::new_noop();
453 let input = make_csv_input(csv);
454
455 let output = processor.process(input, &progress, &NoopContext).unwrap();
456 let text = output_csv_text(&output);
457 let rows = count_data_rows(&text);
458
459 assert_eq!(rows, 2, "Should remove empty rows, keeping 2 data rows");
461 assert!(text.contains("Alice"));
462 assert!(text.contains("Bob"));
463
464 let removed = output
466 .metadata
467 .get("rowsRemoved")
468 .unwrap()
469 .as_u64()
470 .unwrap();
471 assert_eq!(removed, 2, "Should report 2 rows removed");
472 }
473
474 #[test]
479 fn test_trim_whitespace_from_cells() {
480 let csv = "name,age\n Alice , 30 \n Bob ,25\n";
482 let processor = CleanSpreadsheet::new();
483 let progress = ProgressReporter::new_noop();
484 let input = make_csv_input(csv);
485
486 let output = processor.process(input, &progress, &NoopContext).unwrap();
487 let text = output_csv_text(&output);
488
489 assert!(text.contains("Alice"), "Should contain trimmed 'Alice'");
492 assert!(text.contains("Bob"), "Should contain trimmed 'Bob'");
493 assert!(
495 !text.contains(" Alice"),
496 "Should NOT contain ' Alice' with leading spaces"
497 );
498 }
499
500 #[test]
505 fn test_remove_duplicate_rows() {
506 let csv = "name,age\nAlice,30\nBob,25\nAlice,30\n";
508 let processor = CleanSpreadsheet::new();
509 let progress = ProgressReporter::new_noop();
510 let input = make_csv_input(csv);
511
512 let output = processor.process(input, &progress, &NoopContext).unwrap();
513 let text = output_csv_text(&output);
514 let rows = count_data_rows(&text);
515
516 assert_eq!(rows, 2, "Should remove 1 duplicate, keeping 2 rows");
518
519 let dupes = output
521 .metadata
522 .get("duplicatesRemoved")
523 .unwrap()
524 .as_u64()
525 .unwrap();
526 assert_eq!(dupes, 1, "Should report 1 duplicate removed");
527 }
528
529 #[test]
534 fn test_preserves_header_row() {
535 let csv = " name , age \nAlice,30\n";
537 let processor = CleanSpreadsheet::new();
538 let progress = ProgressReporter::new_noop();
539 let input = make_csv_input(csv);
540
541 let output = processor.process(input, &progress, &NoopContext).unwrap();
542 let text = output_csv_text(&output);
543
544 assert!(
546 text.starts_with("name,age"),
547 "Header should be trimmed: got '{}'",
548 text.lines().next().unwrap_or("")
549 );
550 }
551
552 #[test]
557 fn test_variable_length_rows_padded() {
558 let csv = "name,age,city\nAlice,30\nBob,25,LA\n";
561 let processor = CleanSpreadsheet::new();
562 let progress = ProgressReporter::new_noop();
563 let input = make_csv_input(csv);
564
565 let output = processor.process(input, &progress, &NoopContext).unwrap();
566 let text = output_csv_text(&output);
567
568 let rows = count_data_rows(&text);
570 assert_eq!(rows, 2, "Should keep both rows");
571
572 assert!(text.contains("Alice"), "Should contain Alice's row");
575 }
576
577 #[test]
582 fn test_headers_only_csv() {
583 let csv = "name,age,city\n";
585 let processor = CleanSpreadsheet::new();
586 let progress = ProgressReporter::new_noop();
587 let input = make_csv_input(csv);
588
589 let output = processor.process(input, &progress, &NoopContext).unwrap();
590 let text = output_csv_text(&output);
591
592 let rows = count_data_rows(&text);
594 assert_eq!(rows, 0, "Should have zero data rows");
595 assert!(text.contains("name"), "Should still contain header");
596
597 let original = output
599 .metadata
600 .get("originalRows")
601 .unwrap()
602 .as_u64()
603 .unwrap();
604 assert_eq!(original, 0, "Original row count should be 0");
605 }
606
607 #[test]
612 fn test_empty_input_returns_error() {
613 let csv = "";
615 let processor = CleanSpreadsheet::new();
616 let progress = ProgressReporter::new_noop();
617 let input = make_csv_input(csv);
618
619 let result = processor.process(input, &progress, &NoopContext);
620
621 assert!(result.is_err(), "Empty input should return an error");
622
623 if let Err(err) = result {
624 assert!(
625 err.to_string().contains("empty"),
626 "Error should mention 'empty': got '{}'",
627 err
628 );
629 }
630 }
631
632 #[test]
637 fn test_non_utf8_input_returns_error() {
638 let bad_bytes = vec![0xFF, 0xFE, 0x00, 0x41]; let processor = CleanSpreadsheet::new();
644 let progress = ProgressReporter::new_noop();
645 let input = NodeInput {
646 data: bad_bytes,
647 filename: "bad.csv".to_string(),
648 mime_type: None,
649 params: serde_json::Map::new(),
650 };
651
652 let result = processor.process(input, &progress, &NoopContext);
653
654 assert!(result.is_err(), "Non-UTF8 input should return an error");
655
656 if let Err(err) = result {
659 assert!(
660 err.to_string().contains("UTF-8"),
661 "Error should mention UTF-8: got '{}'",
662 err
663 );
664 }
665 }
666
667 #[test]
672 fn test_combined_trim_remove_empty_deduplicate() {
673 let csv = "name,age\n Alice , 30 \n,,\nBob,25\n Alice , 30 \n,,\n";
675 let processor = CleanSpreadsheet::new();
676 let progress = ProgressReporter::new_noop();
677 let input = make_csv_input(csv);
678
679 let output = processor.process(input, &progress, &NoopContext).unwrap();
680 let text = output_csv_text(&output);
681 let rows = count_data_rows(&text);
682
683 assert_eq!(
690 rows, 2,
691 "Should have 2 rows (Alice + Bob), got text:\n{}",
692 text
693 );
694
695 let removed = output
697 .metadata
698 .get("rowsRemoved")
699 .unwrap()
700 .as_u64()
701 .unwrap();
702 assert_eq!(
703 removed, 3,
704 "Should remove 3 rows total (2 empty + 1 duplicate)"
705 );
706 let dupes = output
707 .metadata
708 .get("duplicatesRemoved")
709 .unwrap()
710 .as_u64()
711 .unwrap();
712 assert_eq!(dupes, 1, "Should report 1 duplicate removed");
713 }
714
715 #[test]
720 fn test_remove_duplicates_false_preserves_duplicates() {
721 let csv = "name,age\nAlice,30\nBob,25\nAlice,30\n";
723 let processor = CleanSpreadsheet::new();
724 let progress = ProgressReporter::new_noop();
725
726 let mut params = serde_json::Map::new();
727 params.insert(
728 "removeDuplicates".to_string(),
729 serde_json::Value::Bool(false),
730 );
731 let input = make_csv_input_with_params(csv, params);
732
733 let output = processor.process(input, &progress, &NoopContext).unwrap();
734 let text = output_csv_text(&output);
735 let rows = count_data_rows(&text);
736
737 assert_eq!(
739 rows, 3,
740 "Should keep all 3 rows when removeDuplicates is false"
741 );
742
743 let dupes = output
745 .metadata
746 .get("duplicatesRemoved")
747 .unwrap()
748 .as_u64()
749 .unwrap();
750 assert_eq!(dupes, 0);
751 }
752
753 #[test]
754 fn test_trim_whitespace_false_preserves_whitespace() {
755 let csv = "name,age\n Alice , 30 \nBob,25\n";
757 let processor = CleanSpreadsheet::new();
758 let progress = ProgressReporter::new_noop();
759
760 let mut params = serde_json::Map::new();
761 params.insert("trimWhitespace".to_string(), serde_json::Value::Bool(false));
762 let input = make_csv_input_with_params(csv, params);
763
764 let output = processor.process(input, &progress, &NoopContext).unwrap();
765 let text = output_csv_text(&output);
766
767 assert!(
771 text.contains("Alice") && text.contains("Bob"),
772 "Should contain both names"
773 );
774 assert!(
777 text.contains(" Alice "),
778 "Should preserve whitespace around Alice: got:\n{}",
779 text
780 );
781 }
782
783 #[test]
784 fn test_remove_empty_rows_false_preserves_empty_rows() {
785 let csv = "name,age\nAlice,30\n,,\nBob,25\n";
787 let processor = CleanSpreadsheet::new();
788 let progress = ProgressReporter::new_noop();
789
790 let mut params = serde_json::Map::new();
791 params.insert(
792 "removeEmptyRows".to_string(),
793 serde_json::Value::Bool(false),
794 );
795 let input = make_csv_input_with_params(csv, params);
796
797 let output = processor.process(input, &progress, &NoopContext).unwrap();
798 let text = output_csv_text(&output);
799 let rows = count_data_rows(&text);
800
801 assert_eq!(
803 rows, 3,
804 "Should keep all 3 rows when removeEmptyRows is false"
805 );
806 }
807
808 #[test]
813 fn test_large_csv_1000_rows() {
814 let mut csv = String::from("id,name,value\n");
816 for i in 0..1200 {
817 csv.push_str(&format!("{i},item_{i},{}\n", i * 10));
818 }
819
820 let processor = CleanSpreadsheet::new();
821 let progress = ProgressReporter::new_noop();
822 let input = make_csv_input(&csv);
823
824 let output = processor.process(input, &progress, &NoopContext).unwrap();
825 let text = output_csv_text(&output);
826 let rows = count_data_rows(&text);
827
828 assert_eq!(rows, 1200, "Should process all 1200 rows");
830
831 let removed = output
833 .metadata
834 .get("rowsRemoved")
835 .unwrap()
836 .as_u64()
837 .unwrap();
838 assert_eq!(removed, 0, "No rows should be removed from clean data");
839 }
840
841 #[test]
846 fn test_output_filename_with_extension() {
847 let result = generate_output_filename("data.csv");
848 assert_eq!(result, "data-cleaned.csv");
849 }
850
851 #[test]
852 fn test_output_filename_without_extension() {
853 let result = generate_output_filename("data");
854 assert_eq!(result, "data-cleaned");
855 }
856
857 #[test]
858 fn test_output_filename_with_multiple_dots() {
859 let result = generate_output_filename("my.data.csv");
860 assert_eq!(result, "my.data-cleaned.csv");
861 }
862
863 #[test]
864 fn test_output_filename_in_result() {
865 let csv = "name\nAlice\n";
867 let processor = CleanSpreadsheet::new();
868 let progress = ProgressReporter::new_noop();
869 let input = NodeInput {
870 data: csv.as_bytes().to_vec(),
871 filename: "employees.csv".to_string(),
872 mime_type: None,
873 params: serde_json::Map::new(),
874 };
875
876 let output = processor.process(input, &progress, &NoopContext).unwrap();
877 assert_eq!(output.files[0].filename, "employees-cleaned.csv");
878 }
879
880 #[test]
885 fn test_metadata_contains_all_fields() {
886 let csv = "name,age\nAlice,30\nBob,25\n";
887 let processor = CleanSpreadsheet::new();
888 let progress = ProgressReporter::new_noop();
889 let input = make_csv_input(csv);
890
891 let output = processor.process(input, &progress, &NoopContext).unwrap();
892
893 assert!(output.metadata.contains_key("originalRows"));
895 assert!(output.metadata.contains_key("cleanedRows"));
896 assert!(output.metadata.contains_key("rowsRemoved"));
897 assert!(output.metadata.contains_key("duplicatesRemoved"));
898 }
899}