//! Pure RFC 4180 CSV parsing and comparison. Preview never changes state. use crate::{ domain::{Holiday, valid_country}, error::{Result, ensure}, }; use chrono::{Datelike, NaiveDate}; use serde::Serialize; use std::collections::BTreeMap; #[derive(Debug, Serialize)] pub struct ImportPreview { pub additions: Vec, pub existing: usize, pub duplicates: usize, pub conflicts: Vec, pub errors: Vec, } pub fn compare(input: &str, saved: &[Holiday]) -> Result { ensure( input.len() <= 262_144, crate::i18n::text("err_csv_is_limited_to_256_kib_e30936"), )?; let mut preview = ImportPreview { additions: vec![], existing: 0, duplicates: 0, conflicts: vec![], errors: vec![], }; let mut reader = csv::ReaderBuilder::new() .trim(csv::Trim::All) .from_reader(input.trim_start_matches('\u{feff}').as_bytes()); let headers = match reader.headers() { Ok(h) => h.iter().map(str::to_ascii_lowercase).collect::>(), Err(_) => { preview .errors .push(crate::i18n::text("err_could_not_read_csv_headers_2bbd8d").into()); return Ok(preview); } }; let positions = ["country", "date", "name"].map(|key| headers.iter().position(|h| h == key)); let [Some(country), Some(date), Some(name)] = positions else { preview .errors .push(crate::i18n::text("err_use_the_csv_headers_country_date_name_7d9639").into()); return Ok(preview); }; if headers.len() != 3 { preview.errors.push( crate::i18n::text("err_csv_must_have_exactly_three_columns_country_date_723e07").into(), ); return Ok(preview); } let existing: BTreeMap<_, _> = saved .iter() .map(|h| ((h.country.clone(), h.date), h.name.clone())) .collect(); let mut seen = BTreeMap::new(); for (index, row) in reader.records().enumerate() { if index >= 2000 { preview .errors .push(crate::i18n::text("err_import_is_limited_to_2_000_rows_6e1c86").into()); break; } let line = index + 2; let row = match row { Ok(row) => row, Err(_) => { preview.errors.push(crate::i18n::message( "err_row_line_invalid_csv_or_column_count_be7745", &[("line", (line).to_string())], )); continue; } }; let country = row[country].to_ascii_uppercase(); let parsed = ["%Y-%m-%d", "%d-%m-%Y"].into_iter().find_map(|format| { NaiveDate::parse_from_str(&row[date], format) .ok() .filter(|d| { (2000..=2100).contains(&d.year()) && d.format(format).to_string() == row[date] }) }); if !valid_country(&country) || parsed.is_none() || row[name].is_empty() || row[name].len() > 120 { preview.errors.push(crate::i18n::message( "err_row_line_use_an_iso_country_code_dd_mm_yyyy_or_y_bf011e", &[("line", (line).to_string())], )); continue; } let holiday = Holiday { country, date: parsed.unwrap(), name: row[name].into(), }; let key = (holiday.country.clone(), holiday.date); if let Some(previous) = seen.insert(key.clone(), holiday.name.clone()) { if previous == holiday.name { preview.duplicates += 1; } else { preview.errors.push(crate::i18n::message( "err_row_line_two_different_names_for_p0_on_p1_4c127b", &[ ("line", (line).to_string()), ("p0", (holiday.country).to_string()), ("p1", (holiday.date.format("%d-%m-%Y")).to_string()), ], )); } continue; } if let Some(name) = existing.get(&key) { if name == &holiday.name { preview.existing += 1; } else { preview.conflicts.push(crate::i18n::message( "err__p0_p1_existing_p2_is_kept_p3_will_not_replace_i_053cc0", &[ ("p0", (holiday.country).to_string()), ("p1", (holiday.date.format("%d-%m-%Y")).to_string()), ("p2", (name).to_string()), ("p3", (holiday.name).to_string()), ], )); } } else { preview.additions.push(holiday); } } if saved.len() + preview.additions.len() > 2000 { preview .errors .push(crate::i18n::text("err_the_team_would_exceed_2_000_holidays_496996").into()); } preview .additions .sort_by(|a, b| (&a.country, a.date).cmp(&(&b.country, b.date))); Ok(preview) } #[cfg(test)] mod tests { use super::*; #[test] fn csv_handles_bom_quotes_countries_duplicates_and_preserves_existing_names() { let saved = vec![Holiday { country: "CY".into(), date: "2026-01-01".parse().unwrap(), name: "New Year".into(), }]; let p=compare("\u{feff}date,name,country\r\n2026-01-01,New Year,CY\r\n2026-01-01,\"New Year, holiday\",am\r\n2026-01-01,\"New Year, holiday\",AM\r\n2026-12-25,Christmas,CY",&saved).unwrap(); assert!(p.errors.is_empty()); assert_eq!(p.existing, 1); assert_eq!(p.duplicates, 1); assert_eq!(p.additions.len(), 2); let p = compare("country,date,name\nCY,2026-01-01,Changed", &saved).unwrap(); assert!(p.additions.is_empty()); assert_eq!(p.conflicts.len(), 1); assert_eq!(saved[0].name, "New Year"); } #[test] fn invalid_rows_and_conflicting_input_duplicates_block_import() { let p=compare("country,date,name\nXX,2026-01-01,Invalid country\nCY,2026-02-30,Invalid date\nAM,2026-01-01,One\nAM,2026-01-01,Two\nCY,2026-01-01",&[]).unwrap(); assert_eq!(p.errors.len(), 4); assert!( !compare("date,label\n2026-01-01,Holiday", &[]) .unwrap() .errors .is_empty() ); } }