mod matcher;
mod name_resolver;
mod utils;
mod writer;
use std::{borrow::Cow, path::PathBuf};
pub use matcher::{EMLMatchError, find_matching_documents};
pub use name_resolver::NameResolver;
use tracing::{debug, info};
pub use writer::CsvWriter;
use crate::{
EMLError, EMLErrorKind,
csv::utils::{AffiliationWithVotes, extract_vote_counts},
documents::election_count::{
CountType, ElectionCount, RejectedVotesReason, TotalVotes, UncountedVotesReason,
},
utils::{ElectionCategory, StringValueData},
};
static SPECIAL_MUNICIPALITIES: &[&str] = &["Bonaire", "Saba", "Sint Eustatius"];
fn normalise(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
impl ElectionCount {
pub fn as_osv4_3_csv_filename(&self) -> Result<PathBuf, EMLError> {
let election_identifier = &self.count.election.identifier;
let election_id = election_identifier.id.cloned_value()?;
let election_category = election_identifier.category.copied_value()?;
let election_date = election_identifier.election_date.copied_value()?;
let authority_identifier = &self.managing_authority.authority_identifier;
let authority_name = authority_identifier.name.as_deref().unwrap_or("");
let authority_type = if self.count_type == CountType::Municipal {
if SPECIAL_MUNICIPALITIES.contains(&authority_name) {
"Openbaar lichaam"
} else {
"Gemeente"
}
} else {
""
};
debug!(
"Inputs for filename: authority name: '{}', election id: '{:?}', election category: '{:?}', election date: '{:?}', authority type: '{}'",
authority_name, election_id, election_category, election_date, authority_type
);
let norm_authority_name = normalise(authority_name);
let base_election_id_value = election_id.value();
let norm_election_id = if base_election_id_value.len() >= 6 {
normalise(&base_election_id_value[..6])
} else {
use chrono::Datelike as _;
normalise(&format!(
"{}{}",
election_category.to_eml_value(),
election_date.date.year(),
))
};
if election_category == ElectionCategory::GR {
debug!("Election category is GR, omitting authority type from filename");
Ok(PathBuf::from(format!(
"osv4-3_telling_{}_{}.csv",
norm_election_id, norm_authority_name
)))
} else {
debug!("Election category is not GR, including authority type in filename");
Ok(PathBuf::from(format!(
"osv4-3_telling_{}_{}_{}.csv",
norm_election_id,
authority_type.to_lowercase().replace(' ', "_"),
norm_authority_name
)))
}
}
pub fn as_osv4_3_csv(
&self,
name_resolver: &impl NameResolver,
include_bom: bool,
include_final_newline: bool,
) -> Result<String, EMLError> {
let count_contest = self
.count
.election
.contests
.first()
.ok_or_else(|| EMLErrorKind::MissingContest.without_span())?;
let election_identifier = &self.count.election.identifier;
let authority_identifier = &self.managing_authority.authority_identifier;
let election_id = election_identifier.id.cloned_value()?;
let election_name = election_identifier.name.as_deref().unwrap_or("");
let election_date = election_identifier.election_date.copied_value()?;
let authority_name = authority_identifier.name.as_deref().unwrap_or("");
let authority_id = authority_identifier.id.cloned_value()?;
let authority_type = if self.count_type == CountType::Municipal {
if SPECIAL_MUNICIPALITIES.contains(&authority_name) {
"Openbaar lichaam"
} else {
"Gemeente"
}
} else {
""
};
info!("Generating OSV4-3 CSV for election {:?}", election_id);
let stations = utils::extract_polling_stations(self.count_type, count_contest)?;
let totals = count_contest
.total_votes
.as_ref()
.ok_or_else(|| EMLErrorKind::MissingTotalVotes.without_span())?;
let mut output = CsvWriter::new(include_bom);
output.row(["Verkiezing", "", election_name]);
output.row(["Datum", "", &election_date.to_raw_value()]);
output.row([
"Gebied",
"",
format!("{} {}", authority_type, authority_name).trim(),
]);
output.row(["Nummer", "", authority_id.value()]);
output.empty_row();
output.row(
[
"Lijstnummer",
"Aanduiding",
"Volgnummer",
"Naam kandidaat",
"Totaal",
]
.into_iter()
.chain(stations.iter().map(|s| s.cleaned_name.as_str())),
);
output.row(
["Gebiednummer", "", "", "", ""]
.into_iter()
.chain(stations.iter().map(|s| s.plain_ps_id.as_str())),
);
if self.count_type == CountType::Municipal {
output.row(
["Postcode", "", "", "", ""]
.into_iter()
.chain(stations.iter().map(|s| s.postal_code.as_str())),
);
}
emit_stat_rows(&mut output, totals, &stations)?;
let counts = extract_vote_counts(name_resolver, totals, &stations)?;
for (_, affiliation) in counts.into_iter() {
emit_affiliation_rows(&mut output, affiliation);
}
Ok(output.into_string(include_final_newline))
}
}
fn stat_row_start<'a>(
label: &'static str,
total_value: impl Into<Cow<'a, str>>,
) -> impl Iterator<Item = Cow<'a, str>> {
vec![
Cow::Borrowed(""),
Cow::Borrowed(label),
Cow::Borrowed(""),
Cow::Borrowed(""),
total_value.into(),
]
.into_iter()
}
fn emit_stat_rows(
output: &mut CsvWriter,
totals: &TotalVotes,
stations: &[utils::CsvPollingStation],
) -> Result<(), EMLError> {
macro_rules! stat_row {
($label:expr, $votes_type:ident, $filter_type:expr) => {
if let Some(total) = totals.$votes_type.get(&$filter_type) {
output.row(
stat_row_start($label, total.raw()).chain(stations.iter().map(|s| {
s.reporting_unit
.$votes_type
.get(&$filter_type)
.map(|v| v.raw())
.unwrap_or(Cow::Borrowed("0"))
})),
);
}
};
}
output.row(
stat_row_start("opgeroepenen", totals.eligible_voter_count.raw()).chain(
stations
.iter()
.map(|s| s.reporting_unit.eligible_voter_count.raw()),
),
);
stat_row!(
"geldige stempas",
uncounted_votes,
UncountedVotesReason::ValidPollCards
);
stat_row!(
"geldig volmachtbewijs",
uncounted_votes,
UncountedVotesReason::ValidProxyCertificates
);
stat_row!(
"geldige kiezerspas",
uncounted_votes,
UncountedVotesReason::ValidVoterCards
);
stat_row!(
"toegelaten kiezers",
uncounted_votes,
UncountedVotesReason::AdmittedVoters
);
output.row(
stat_row_start("geldige stembiljetten", totals.candidate_votes_count.raw()).chain(
stations
.iter()
.map(|s| s.reporting_unit.candidate_votes_count.raw()),
),
);
stat_row!(
"blanco stembiljetten",
rejected_votes,
RejectedVotesReason::Blank
);
stat_row!(
"ongeldige stembiljetten",
rejected_votes,
RejectedVotesReason::Invalid
);
let counted_ballots = (totals.blank_votes()?.copied_value()?
+ totals.invalid_votes()?.copied_value()?
+ totals.candidate_votes_count.copied_value()?)
.to_string();
let reporting_unit_counted_ballots = stations
.iter()
.map(|s| -> Result<Cow<str>, _> {
Ok(Cow::Owned(
(s.reporting_unit.blank_votes()?.copied_value()?
+ s.reporting_unit.invalid_votes()?.copied_value()?
+ s.reporting_unit.candidate_votes_count.copied_value()?)
.to_string(),
))
})
.collect::<Result<Vec<_>, EMLError>>()?;
output.row(
stat_row_start("aangetroffen stembiljetten", counted_ballots)
.chain(reporting_unit_counted_ballots),
);
stat_row!(
"meer stembiljetten dan toegelaten kiezers",
uncounted_votes,
UncountedVotesReason::MoreBallotsCounted
);
stat_row!(
"minder stembiljetten dan toegelaten kiezers",
uncounted_votes,
UncountedVotesReason::FewerBallotsCounted
);
stat_row!(
"kiezers met stembiljet hebben niet gestemd",
uncounted_votes,
UncountedVotesReason::BallotsTaken
);
stat_row!(
"er zijn te weinig stembiljetten uitgereikt",
uncounted_votes,
UncountedVotesReason::TooFewBallotsIssued
);
stat_row!(
"er zijn te veel stembiljetten uitgereikt",
uncounted_votes,
UncountedVotesReason::TooManyBallotsIssued
);
stat_row!(
"geen verklaring",
uncounted_votes,
UncountedVotesReason::NoExplanation
);
stat_row!(
"andere verklaring",
uncounted_votes,
UncountedVotesReason::OtherExplanation
);
Ok(())
}
fn emit_affiliation_rows(output: &mut CsvWriter, affiliation: AffiliationWithVotes) {
output.row(
vec![
affiliation.id.to_string().into(),
affiliation.name.into(),
Cow::Borrowed(""),
Cow::Borrowed(""),
affiliation.total_votes.to_string().into(),
]
.into_iter()
.chain(
affiliation
.reporting_unit_votes
.iter()
.map(ToString::to_string)
.map(Cow::Owned),
),
);
for (_, candidate) in affiliation.candidates {
output.row(
vec![
Cow::Borrowed(""),
Cow::Borrowed(""),
candidate.id.to_string().into(),
candidate.name.into(),
candidate.total_votes.to_string().into(),
]
.into_iter()
.chain(
candidate
.reporting_unit_votes
.iter()
.map(ToString::to_string)
.map(Cow::Owned),
),
);
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::{
documents::candidate_lists::CandidateLists,
io::{EMLParsingMode, EMLRead as _},
};
use super::*;
#[test]
fn test_gr2022_groningen() {
let cl = CandidateLists::parse_eml(
include_str!("../../test-files/csv/Kandidatenlijsten_GR2022_Groningen.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let count = ElectionCount::parse_eml(
include_str!("../../test-files/csv/Telling_GR2022_Groningen.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let result = count.as_osv4_3_csv(&cl, true, false).unwrap();
assert_eq!(
result,
include_str!("../../test-files/csv/osv4-3_telling_gr2022_groningen.csv")
);
}
#[test]
fn test_gr2022_west_maas_en_waal() {
let cl = CandidateLists::parse_eml(
include_str!("../../test-files/csv/Kandidatenlijsten_GR2022_WestMaasenWaal.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let count = ElectionCount::parse_eml(
include_str!("../../test-files/csv/Telling_GR2022_WestMaasenWaal.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let result = count.as_osv4_3_csv(&cl, true, false).unwrap();
assert_eq!(
result,
include_str!("../../test-files/csv/osv4-3_telling_gr2022_westmaasenwaal.csv")
);
}
#[test]
fn test_gr2026_assen() {
let cl = CandidateLists::parse_eml(
include_str!("../../test-files/csv/Kandidatenlijsten_GR2026_Assen.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let count = ElectionCount::parse_eml(
include_str!("../../test-files/csv/Telling_GR2026_Assen.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let result = count.as_osv4_3_csv(&cl, true, false).unwrap();
assert_eq!(
result,
include_str!("../../test-files/csv/osv4-3_telling_gr2026_assen.csv")
);
}
#[test]
fn test_tk2025_west_maas_en_waal() {
let cl = CandidateLists::parse_eml(
include_str!("../../test-files/csv/Kandidatenlijsten_TK2025_Nijmegen.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let count = ElectionCount::parse_eml(
include_str!("../../test-files/csv/Telling_TK2025_gemeente_West_Maas_en_Waal.eml.xml"),
EMLParsingMode::Strict,
)
.unwrap();
let result = count.as_osv4_3_csv(&cl, true, false).unwrap();
assert_eq!(
result,
include_str!("../../test-files/csv/osv4-3_telling_tk2025_gemeente_westmaasenwaal.csv")
);
}
}