Skip to main content

bnto_spreadsheet/
clean.rs

1// Clean CSV Node — remove empty rows, trim whitespace, deduplicate.
2//
3// Each cleaning operation (trim, remove empties, deduplicate) is controlled
4// by a boolean parameter so users can enable/disable them individually.
5
6use bnto_core::context::ProcessContext;
7use bnto_core::errors::BntoError;
8use bnto_core::processor::{FileData, NodeInput, NodeOutput, NodeProcessor, OutputFile};
9use bnto_core::progress::ProgressReporter;
10
11/// The spreadsheet-clean node processor. Stateless — config comes from `NodeInput.params`.
12pub 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
26// --- NodeProcessor Implementation ---
27
28impl NodeProcessor for CleanSpreadsheet {
29    fn name(&self) -> &str {
30        "spreadsheet-clean"
31    }
32
33    /// Self-describing metadata: trimWhitespace, removeEmptyRows, removeDuplicates
34    /// (all booleans, default true). Accepts text/csv only.
35    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    /// Clean a CSV file: trim whitespace, remove empty rows, deduplicate.
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 = 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
87// --- Configuration ---
88
89struct 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
129// --- Metadata Parameter Definitions ---
130
131/// Boolean cleaning parameter.
132fn 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
168// --- CSV Parsing ---
169
170/// Validate and convert raw bytes to a UTF-8 string, rejecting empty input.
171fn 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
187/// Read and optionally trim the header row. Returns (headers, column_count).
188fn 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
209// --- Row Processing ---
210
211/// Iterate data rows, applying trim and empty-row removal.
212/// Returns (cleaned_rows, original_row_count).
213fn 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, // skip malformed rows
232        };
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
246/// Clean a single record: optionally trim cells, then pad/truncate to match header width.
247fn 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
266/// Remove duplicate rows in-place using null-byte-joined keys. Returns count removed.
267fn 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    // Null byte separator avoids collisions between cell values.
276    rows.retain(|row| seen.insert(row.join("\0")));
277
278    before - rows.len()
279}
280
281// --- CSV Output ---
282
283/// Write cleaned headers and rows to a CSV byte buffer.
284fn 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
302// --- Result Metadata ---
303
304fn 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
330// --- Filename ---
331
332/// Add "-cleaned" before the file extension: "data.csv" -> "data-cleaned.csv"
333fn 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// =============================================================================
344// Tests — Every function must be tested!
345// =============================================================================
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use bnto_core::NoopContext;
351
352    // =========================================================================
353    // Test Helpers
354    // =========================================================================
355
356    /// Create a NodeInput from raw CSV text and optional params.
357    ///
358    /// This helper makes tests concise — instead of building a full
359    /// NodeInput struct every time, we just pass the CSV text.
360    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    /// Create a NodeInput with custom parameters.
370    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    /// Extract the output CSV text from a NodeOutput.
383    ///
384    /// The output is raw bytes — we convert back to a String for easy
385    /// assertion comparisons in tests.
386    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    /// Count the number of data rows (excluding the header) in CSV text.
396    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    // =========================================================================
405    // Basic Functionality Tests
406    // =========================================================================
407
408    #[test]
409    fn test_name_returns_clean_csv() {
410        // Verify the node processor reports the correct name.
411        let processor = CleanSpreadsheet::new();
412        assert_eq!(processor.name(), "spreadsheet-clean");
413    }
414
415    #[test]
416    fn test_default_creates_same_as_new() {
417        // Verify that Default trait works.
418        // Clippy warns against `.default()` on a unit struct, but we want
419        // to test that Default IS implemented, so we allow it.
420        #[allow(clippy::default_constructed_unit_structs)]
421        let _processor = CleanSpreadsheet::default();
422        // If we get here without panic, the test passes.
423        // CleanSpreadsheet is a unit struct, so there's nothing else to check.
424    }
425
426    #[test]
427    fn test_basic_csv_passthrough() {
428        // A clean CSV with no issues should pass through with all rows intact.
429        // Only formatting differences (like trailing newline) should change.
430        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        // Should have one output file.
438        assert_eq!(output.files.len(), 1);
439
440        // Should be a CSV file.
441        assert_eq!(output.files[0].mime_type, "text/csv");
442
443        // Should preserve both data rows.
444        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        // Should contain both names.
449        assert!(text.contains("Alice"), "Should contain Alice");
450        assert!(text.contains("Bob"), "Should contain Bob");
451    }
452
453    // =========================================================================
454    // Remove Empty Rows Tests
455    // =========================================================================
456
457    #[test]
458    fn test_remove_empty_rows() {
459        // CSV with empty rows (all cells blank) — they should be removed.
460        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        // Should keep only Alice and Bob (2 rows), removing 2 empty rows.
470        assert_eq!(rows, 2, "Should remove empty rows, keeping 2 data rows");
471        assert!(text.contains("Alice"));
472        assert!(text.contains("Bob"));
473
474        // Check metadata.
475        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    // =========================================================================
485    // Trim Whitespace Tests
486    // =========================================================================
487
488    #[test]
489    fn test_trim_whitespace_from_cells() {
490        // CSV with extra whitespace around cell values.
491        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        // After trimming, cells should have no leading/trailing whitespace.
500        // The csv crate quotes fields that don't need quoting only when necessary.
501        assert!(text.contains("Alice"), "Should contain trimmed 'Alice'");
502        assert!(text.contains("Bob"), "Should contain trimmed 'Bob'");
503        // Verify there's no "  Alice  " with spaces.
504        assert!(
505            !text.contains("  Alice"),
506            "Should NOT contain '  Alice' with leading spaces"
507        );
508    }
509
510    // =========================================================================
511    // Remove Duplicate Rows Tests
512    // =========================================================================
513
514    #[test]
515    fn test_remove_duplicate_rows() {
516        // CSV with exact duplicate rows — only the first occurrence should remain.
517        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        // Should keep Alice (first) and Bob, removing the duplicate Alice.
527        assert_eq!(rows, 2, "Should remove 1 duplicate, keeping 2 rows");
528
529        // Check metadata.
530        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    // =========================================================================
540    // Preserve Header Row Tests
541    // =========================================================================
542
543    #[test]
544    fn test_preserves_header_row() {
545        // The header row should always be present in the output, trimmed.
546        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        // The header should be trimmed.
555        assert!(
556            text.starts_with("name,age"),
557            "Header should be trimmed: got '{}'",
558            text.lines().next().unwrap_or("")
559        );
560    }
561
562    // =========================================================================
563    // Variable-Length Rows Tests
564    // =========================================================================
565
566    #[test]
567    fn test_variable_length_rows_padded() {
568        // Some rows have fewer columns than the header — they should be
569        // padded with empty strings to match the header width.
570        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        // Both rows should be present.
579        let rows = count_data_rows(&text);
580        assert_eq!(rows, 2, "Should keep both rows");
581
582        // Alice's row should have an empty city field.
583        // The csv writer will include the trailing comma for the empty field.
584        assert!(text.contains("Alice"), "Should contain Alice's row");
585    }
586
587    // =========================================================================
588    // Edge Case: Headers Only
589    // =========================================================================
590
591    #[test]
592    fn test_headers_only_csv() {
593        // A CSV with only headers and no data rows.
594        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        // The output should have the header but zero data rows.
603        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        // Metadata should reflect zero rows.
608        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    // =========================================================================
618    // Edge Case: Empty Input
619    // =========================================================================
620
621    #[test]
622    fn test_empty_input_returns_error() {
623        // Completely empty input (no bytes) should return an error.
624        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    // =========================================================================
643    // Edge Case: Non-UTF8 Input
644    // =========================================================================
645
646    #[test]
647    fn test_non_utf8_input_returns_error() {
648        // Invalid UTF-8 bytes should return a clear error.
649        //
650        // 0xFF 0xFE is a common byte sequence in files that aren't
651        // UTF-8 (it's a UTF-16 BOM). Our parser expects UTF-8 only.
652        let bad_bytes = vec![0xFF, 0xFE, 0x00, 0x41]; // Not valid UTF-8
653        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        // Use `if let` instead of `.unwrap_err()` because NodeOutput
667        // doesn't implement Debug (required by unwrap_err's panic message).
668        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    // =========================================================================
678    // Combined Operations Test
679    // =========================================================================
680
681    #[test]
682    fn test_combined_trim_remove_empty_deduplicate() {
683        // A messy CSV that needs all three cleaning operations.
684        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        // After trim + remove empty + deduplicate:
694        //   " Alice ", " 30 "  -> "Alice", "30" (kept, first occurrence)
695        //   ",,\"               -> removed (empty)
696        //   "Bob", "25"         -> kept
697        //   " Alice ", " 30 "  -> removed (duplicate after trim)
698        //   ",,"                -> removed (empty)
699        assert_eq!(
700            rows, 2,
701            "Should have 2 rows (Alice + Bob), got text:\n{}",
702            text
703        );
704
705        // Check metadata shows the cleaning results.
706        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    // =========================================================================
726    // Parameter Override Tests
727    // =========================================================================
728
729    #[test]
730    fn test_remove_duplicates_false_preserves_duplicates() {
731        // When removeDuplicates is false, duplicate rows should be kept.
732        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        // All 3 rows should be kept (including the duplicate).
748        assert_eq!(
749            rows, 3,
750            "Should keep all 3 rows when removeDuplicates is false"
751        );
752
753        // Metadata should show 0 duplicates removed.
754        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        // When trimWhitespace is false, whitespace should be preserved.
766        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        // The whitespace should still be there.
778        // The csv writer may quote fields with spaces, so check for the
779        // actual content rather than exact formatting.
780        assert!(
781            text.contains("Alice") && text.contains("Bob"),
782            "Should contain both names"
783        );
784        // With trimWhitespace=false, the spaces around Alice should persist.
785        // The csv crate will quote the field: "  Alice  "
786        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        // When removeEmptyRows is false, empty rows should be kept.
796        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        // All 3 rows (including the empty one) should be kept.
812        assert_eq!(
813            rows, 3,
814            "Should keep all 3 rows when removeEmptyRows is false"
815        );
816    }
817
818    // =========================================================================
819    // Large CSV Test
820    // =========================================================================
821
822    #[test]
823    fn test_large_csv_1000_rows() {
824        // Generate a CSV with 1000+ rows to verify performance.
825        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        // All 1200 rows should be present (no duplicates, no empty rows).
839        assert_eq!(rows, 1200, "Should process all 1200 rows");
840
841        // Metadata should show 0 rows removed.
842        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    // =========================================================================
852    // Output Filename Tests
853    // =========================================================================
854
855    #[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        // Verify the output file has the correct cleaned filename.
876        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    // =========================================================================
891    // Metadata Tests
892    // =========================================================================
893
894    #[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        // All metadata fields should be present.
904        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}