use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Parser, error::ErrorKind};
use eml_nl::{
csv::find_matching_documents,
documents::EML,
io::{EMLParsingMode, EMLRead as _},
};
use tracing::{debug, info, level_filters::LevelFilter};
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(version, about = "Convert EML election data to CSV format (osv4-3)")]
struct Cli {
counts_eml: PathBuf,
candidates_eml: PathBuf,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long = "no-bom", action = clap::ArgAction::SetFalse, default_value_t = true)]
bom: bool,
#[arg(long)]
trailing_newline: bool,
#[arg(long)]
quiet: bool,
#[arg(long)]
verbose: bool,
}
fn main() -> anyhow::Result<()> {
let args = match Cli::try_parse() {
Ok(args) => args,
Err(e) => {
if e.kind() == ErrorKind::DisplayHelp || e.kind() == ErrorKind::DisplayVersion {
eprintln!("{}", e);
std::process::exit(0);
} else {
return Err(e).context("Failed to parse command line arguments");
}
}
};
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_env_var("EML_LOG")
.with_default_directive(if args.quiet {
LevelFilter::OFF.into()
} else if args.verbose {
LevelFilter::DEBUG.into()
} else {
LevelFilter::INFO.into()
})
.from_env_lossy(),
)
.with_writer(std::io::stderr)
.init();
let (output, output_path) = process(
args.counts_eml,
args.candidates_eml,
args.output,
args.bom,
args.trailing_newline,
)?;
if output_path == Path::new("-") {
info!("Writing output to stdout");
println!("{}", output);
} else {
info!("Writing output to file: {}", output_path.display());
std::fs::write(&output_path, output.as_bytes())
.with_context(|| format!("Failed to write output: {}", output_path.display()))?;
}
info!("Output written");
Ok(())
}
fn process(
counts_eml: impl AsRef<Path>,
candidates_eml: impl AsRef<Path>,
output_path: Option<impl AsRef<Path>>,
include_bom: bool,
trailing_newline: bool,
) -> anyhow::Result<(String, PathBuf)> {
let first_xml = load_and_parse(counts_eml)?;
let second_xml = load_and_parse(candidates_eml)?;
info!("Starting conversion to OSV4-3 CSV file");
let (counts, candidates) = find_matching_documents(&first_xml, &second_xml)?;
let output_path = output_path.map_or_else(
|| counts.as_osv4_3_csv_filename(),
|v| Ok(v.as_ref().into()),
)?;
let output = counts.as_osv4_3_csv(candidates, include_bom, trailing_newline)?;
info!("Processing completed");
Ok((output, output_path))
}
fn load_and_parse(path: impl AsRef<Path>) -> Result<EML, anyhow::Error> {
info!("Loading EML file: {}", path.as_ref().display());
let xml = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read EML file: {}", path.as_ref().display()))?;
debug!("Successfully read EML file, size: {} bytes", xml.len());
debug!("Parsing EML file");
let eml = EML::parse_eml(&xml, EMLParsingMode::Strict)
.ok()
.with_context(|| format!("Failed to parse file as EML: {}", path.as_ref().display()))?;
info!(
"EML file was parsed succesfully, found document type: {}",
eml.to_eml_id()
);
Ok(eml)
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! assert_error_contains {
($err:expr, $expected:expr $(,)?) => {{
let error = &$err;
let expected = $expected;
let found = error.chain().any(|e| e.to_string().contains(expected));
if !found {
let chain_str: Vec<String> = error.chain().map(|e| format!(" - {}", e)).collect();
panic!(
"Assertion failed: error chain did not contain \"{}\"\nFull error chain:\n{}",
expected,
chain_str.join("\n")
);
}
}};
}
#[test]
fn test_only_provide_candidate_lists() {
let err = process(
"test-files/csv/Kandidatenlijsten_GR2022_Groningen.eml.xml",
"test-files/csv/Kandidatenlijsten_GR2022_Groningen.eml.xml",
None::<PathBuf>,
true,
false,
)
.unwrap_err();
assert_error_contains!(
err,
"EML-230b candidates document found, but missing EML-510 counts document"
);
}
#[test]
fn test_only_provide_counts() {
let err = process(
"test-files/csv/Telling_GR2022_Groningen.eml.xml",
"test-files/csv/Telling_GR2022_Groningen.eml.xml",
None::<PathBuf>,
true,
false,
)
.unwrap_err();
assert_error_contains!(
err,
"EML-510 counts document found, but missing EML-230b candidate lists document"
);
}
#[test]
fn test_provide_files_from_different_elections() {
let err = process(
"test-files/csv/Kandidatenlijsten_GR2022_WestMaasenWaal.eml.xml",
"test-files/csv/Telling_GR2022_Groningen.eml.xml",
None::<PathBuf>,
true,
false,
)
.unwrap_err();
assert_error_contains!(
err,
"Election id of counts file ('GR2022_Groningen') does not match candidate lists id ('GR2022_WestMaasenWaal')"
);
}
#[test]
fn test_provide_wrong_candidate_list() {
let err = process(
"test-files/csv/Kandidatenlijsten_TK2025_Haarlem.eml.xml",
"test-files/csv/Telling_TK2025_gemeente_West_Maas_en_Waal.eml.xml",
None::<PathBuf>,
true,
false,
)
.unwrap_err();
assert_error_contains!(
err,
"Contest id of counts file ('6') does not match candidate lists id ('10')"
);
}
#[test]
fn test_gr2022_groningen() {
let res = process(
"test-files/csv/Kandidatenlijsten_GR2022_Groningen.eml.xml",
"test-files/csv/Telling_GR2022_Groningen.eml.xml",
None::<PathBuf>,
true,
false,
)
.unwrap();
assert_eq!(
res.0,
include_str!("../test-files/csv/osv4-3_telling_gr2022_groningen.csv")
);
}
#[test]
fn test_gr2022_west_maas_en_waal_reverse() {
let res = process(
"test-files/csv/Telling_GR2022_WestMaasenWaal.eml.xml",
"test-files/csv/Kandidatenlijsten_GR2022_WestMaasenWaal.eml.xml",
None::<PathBuf>,
true,
false,
)
.unwrap();
assert_eq!(
res.0,
include_str!("../test-files/csv/osv4-3_telling_gr2022_westmaasenwaal.csv")
);
}
}