180 lines
6.4 KiB
Rust
180 lines
6.4 KiB
Rust
//! 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<Holiday>,
|
|
pub existing: usize,
|
|
pub duplicates: usize,
|
|
pub conflicts: Vec<String>,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
pub fn compare(input: &str, saved: &[Holiday]) -> Result<ImportPreview> {
|
|
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::<Vec<_>>(),
|
|
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()
|
|
);
|
|
}
|
|
}
|