Files
meguri/src/holidays.rs
T
ab a47c4a0398
Checks / test (push) Failing after 12m20s
Init
2026-09-24 14:35:05 +03:00

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()
);
}
}