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::{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 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
83// --- Configuration ---
84
85struct 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
124// --- Metadata Parameter Definitions ---
125
126/// Boolean cleaning parameter.
127fn 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
163// --- CSV Parsing ---
164
165/// Validate and convert raw bytes to a UTF-8 string, rejecting empty input.
166fn 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
182/// Read and optionally trim the header row. Returns (headers, column_count).
183fn 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
204// --- Row Processing ---
205
206/// Iterate data rows, applying trim and empty-row removal.
207/// Returns (cleaned_rows, original_row_count).
208fn 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, // skip malformed rows
227        };
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
241/// Clean a single record: optionally trim cells, then pad/truncate to match header width.
242fn 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
261/// Remove duplicate rows in-place using null-byte-joined keys. Returns count removed.
262fn 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    // Null byte separator avoids collisions between cell values.
271    rows.retain(|row| seen.insert(row.join("\0")));
272
273    before - rows.len()
274}
275
276// --- CSV Output ---
277
278/// Write cleaned headers and rows to a CSV byte buffer.
279fn 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
297// --- Result Metadata ---
298
299fn 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
325// --- Filename ---
326
327/// Add "-cleaned" before the file extension: "data.csv" -> "data-cleaned.csv"
328fn 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// =============================================================================
339// Tests — Every function must be tested!
340// =============================================================================
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use bnto_core::NoopContext;
346
347    // =========================================================================
348    // Test Helpers
349    // =========================================================================
350
351    /// Create a NodeInput from raw CSV text and optional params.
352    ///
353    /// This helper makes tests concise — instead of building a full
354    /// NodeInput struct every time, we just pass the CSV text.
355    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    /// Create a NodeInput with custom parameters.
365    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    /// Extract the output CSV text from a NodeOutput.
378    ///
379    /// The output is raw bytes — we convert back to a String for easy
380    /// assertion comparisons in tests.
381    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    /// Count the number of data rows (excluding the header) in CSV text.
386    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    // =========================================================================
395    // Basic Functionality Tests
396    // =========================================================================
397
398    #[test]
399    fn test_name_returns_clean_csv() {
400        // Verify the node processor reports the correct name.
401        let processor = CleanSpreadsheet::new();
402        assert_eq!(processor.name(), "spreadsheet-clean");
403    }
404
405    #[test]
406    fn test_default_creates_same_as_new() {
407        // Verify that Default trait works.
408        // Clippy warns against `.default()` on a unit struct, but we want
409        // to test that Default IS implemented, so we allow it.
410        #[allow(clippy::default_constructed_unit_structs)]
411        let _processor = CleanSpreadsheet::default();
412        // If we get here without panic, the test passes.
413        // CleanSpreadsheet is a unit struct, so there's nothing else to check.
414    }
415
416    #[test]
417    fn test_basic_csv_passthrough() {
418        // A clean CSV with no issues should pass through with all rows intact.
419        // Only formatting differences (like trailing newline) should change.
420        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        // Should have one output file.
428        assert_eq!(output.files.len(), 1);
429
430        // Should be a CSV file.
431        assert_eq!(output.files[0].mime_type, "text/csv");
432
433        // Should preserve both data rows.
434        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        // Should contain both names.
439        assert!(text.contains("Alice"), "Should contain Alice");
440        assert!(text.contains("Bob"), "Should contain Bob");
441    }
442
443    // =========================================================================
444    // Remove Empty Rows Tests
445    // =========================================================================
446
447    #[test]
448    fn test_remove_empty_rows() {
449        // CSV with empty rows (all cells blank) — they should be removed.
450        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        // Should keep only Alice and Bob (2 rows), removing 2 empty rows.
460        assert_eq!(rows, 2, "Should remove empty rows, keeping 2 data rows");
461        assert!(text.contains("Alice"));
462        assert!(text.contains("Bob"));
463
464        // Check metadata.
465        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    // =========================================================================
475    // Trim Whitespace Tests
476    // =========================================================================
477
478    #[test]
479    fn test_trim_whitespace_from_cells() {
480        // CSV with extra whitespace around cell values.
481        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        // After trimming, cells should have no leading/trailing whitespace.
490        // The csv crate quotes fields that don't need quoting only when necessary.
491        assert!(text.contains("Alice"), "Should contain trimmed 'Alice'");
492        assert!(text.contains("Bob"), "Should contain trimmed 'Bob'");
493        // Verify there's no "  Alice  " with spaces.
494        assert!(
495            !text.contains("  Alice"),
496            "Should NOT contain '  Alice' with leading spaces"
497        );
498    }
499
500    // =========================================================================
501    // Remove Duplicate Rows Tests
502    // =========================================================================
503
504    #[test]
505    fn test_remove_duplicate_rows() {
506        // CSV with exact duplicate rows — only the first occurrence should remain.
507        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        // Should keep Alice (first) and Bob, removing the duplicate Alice.
517        assert_eq!(rows, 2, "Should remove 1 duplicate, keeping 2 rows");
518
519        // Check metadata.
520        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    // =========================================================================
530    // Preserve Header Row Tests
531    // =========================================================================
532
533    #[test]
534    fn test_preserves_header_row() {
535        // The header row should always be present in the output, trimmed.
536        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        // The header should be trimmed.
545        assert!(
546            text.starts_with("name,age"),
547            "Header should be trimmed: got '{}'",
548            text.lines().next().unwrap_or("")
549        );
550    }
551
552    // =========================================================================
553    // Variable-Length Rows Tests
554    // =========================================================================
555
556    #[test]
557    fn test_variable_length_rows_padded() {
558        // Some rows have fewer columns than the header — they should be
559        // padded with empty strings to match the header width.
560        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        // Both rows should be present.
569        let rows = count_data_rows(&text);
570        assert_eq!(rows, 2, "Should keep both rows");
571
572        // Alice's row should have an empty city field.
573        // The csv writer will include the trailing comma for the empty field.
574        assert!(text.contains("Alice"), "Should contain Alice's row");
575    }
576
577    // =========================================================================
578    // Edge Case: Headers Only
579    // =========================================================================
580
581    #[test]
582    fn test_headers_only_csv() {
583        // A CSV with only headers and no data rows.
584        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        // The output should have the header but zero data rows.
593        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        // Metadata should reflect zero rows.
598        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    // =========================================================================
608    // Edge Case: Empty Input
609    // =========================================================================
610
611    #[test]
612    fn test_empty_input_returns_error() {
613        // Completely empty input (no bytes) should return an error.
614        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    // =========================================================================
633    // Edge Case: Non-UTF8 Input
634    // =========================================================================
635
636    #[test]
637    fn test_non_utf8_input_returns_error() {
638        // Invalid UTF-8 bytes should return a clear error.
639        //
640        // 0xFF 0xFE is a common byte sequence in files that aren't
641        // UTF-8 (it's a UTF-16 BOM). Our parser expects UTF-8 only.
642        let bad_bytes = vec![0xFF, 0xFE, 0x00, 0x41]; // Not valid UTF-8
643        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        // Use `if let` instead of `.unwrap_err()` because NodeOutput
657        // doesn't implement Debug (required by unwrap_err's panic message).
658        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    // =========================================================================
668    // Combined Operations Test
669    // =========================================================================
670
671    #[test]
672    fn test_combined_trim_remove_empty_deduplicate() {
673        // A messy CSV that needs all three cleaning operations.
674        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        // After trim + remove empty + deduplicate:
684        //   " Alice ", " 30 "  -> "Alice", "30" (kept, first occurrence)
685        //   ",,\"               -> removed (empty)
686        //   "Bob", "25"         -> kept
687        //   " Alice ", " 30 "  -> removed (duplicate after trim)
688        //   ",,"                -> removed (empty)
689        assert_eq!(
690            rows, 2,
691            "Should have 2 rows (Alice + Bob), got text:\n{}",
692            text
693        );
694
695        // Check metadata shows the cleaning results.
696        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    // =========================================================================
716    // Parameter Override Tests
717    // =========================================================================
718
719    #[test]
720    fn test_remove_duplicates_false_preserves_duplicates() {
721        // When removeDuplicates is false, duplicate rows should be kept.
722        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        // All 3 rows should be kept (including the duplicate).
738        assert_eq!(
739            rows, 3,
740            "Should keep all 3 rows when removeDuplicates is false"
741        );
742
743        // Metadata should show 0 duplicates removed.
744        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        // When trimWhitespace is false, whitespace should be preserved.
756        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        // The whitespace should still be there.
768        // The csv writer may quote fields with spaces, so check for the
769        // actual content rather than exact formatting.
770        assert!(
771            text.contains("Alice") && text.contains("Bob"),
772            "Should contain both names"
773        );
774        // With trimWhitespace=false, the spaces around Alice should persist.
775        // The csv crate will quote the field: "  Alice  "
776        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        // When removeEmptyRows is false, empty rows should be kept.
786        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        // All 3 rows (including the empty one) should be kept.
802        assert_eq!(
803            rows, 3,
804            "Should keep all 3 rows when removeEmptyRows is false"
805        );
806    }
807
808    // =========================================================================
809    // Large CSV Test
810    // =========================================================================
811
812    #[test]
813    fn test_large_csv_1000_rows() {
814        // Generate a CSV with 1000+ rows to verify performance.
815        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        // All 1200 rows should be present (no duplicates, no empty rows).
829        assert_eq!(rows, 1200, "Should process all 1200 rows");
830
831        // Metadata should show 0 rows removed.
832        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    // =========================================================================
842    // Output Filename Tests
843    // =========================================================================
844
845    #[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        // Verify the output file has the correct cleaned filename.
866        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    // =========================================================================
881    // Metadata Tests
882    // =========================================================================
883
884    #[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        // All metadata fields should be present.
894        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}