diff --git a/packages/sequent-core/src/sqlite/results_area_contest.rs b/packages/sequent-core/src/sqlite/results_area_contest.rs index d35a77551d4..b0447ef73f5 100644 --- a/packages/sequent-core/src/sqlite/results_area_contest.rs +++ b/packages/sequent-core/src/sqlite/results_area_contest.rs @@ -9,7 +9,7 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_area_contests_sqlite( +pub fn create_results_area_contests_sqlite( sqlite_transaction: &Transaction<'_>, area_contests: Vec, ) -> Result> { diff --git a/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs b/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs index e797d054c74..a57e578ff1a 100644 --- a/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs +++ b/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs @@ -8,7 +8,7 @@ use rusqlite::{params, Transaction}; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_area_contest_candidates_sqlite( +pub fn create_results_area_contest_candidates_sqlite( sqlite_transaction: &Transaction<'_>, area_contest_candidates: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/results_contest.rs b/packages/sequent-core/src/sqlite/results_contest.rs index 37280cdd62c..f0a6251ab1f 100644 --- a/packages/sequent-core/src/sqlite/results_contest.rs +++ b/packages/sequent-core/src/sqlite/results_contest.rs @@ -10,7 +10,7 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_contest_sqlite( +pub fn create_results_contest_sqlite( sqlite_transaction: &Transaction<'_>, contests: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/results_contest_candidate.rs b/packages/sequent-core/src/sqlite/results_contest_candidate.rs index 3ff828add6b..9f3e5b5a92a 100644 --- a/packages/sequent-core/src/sqlite/results_contest_candidate.rs +++ b/packages/sequent-core/src/sqlite/results_contest_candidate.rs @@ -8,7 +8,7 @@ use rusqlite::{params, Transaction}; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_contest_candidates_sqlite( +pub fn create_results_contest_candidates_sqlite( sqlite_transaction: &Transaction<'_>, contest_candidates: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/results_election.rs b/packages/sequent-core/src/sqlite/results_election.rs index 982794ec5af..ab22b674d6f 100644 --- a/packages/sequent-core/src/sqlite/results_election.rs +++ b/packages/sequent-core/src/sqlite/results_election.rs @@ -9,7 +9,7 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_election_sqlite( +pub fn create_results_election_sqlite( sqlite_transaction: &Transaction<'_>, elections: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/results_event.rs b/packages/sequent-core/src/sqlite/results_event.rs index 0477105b45f..155dae03418 100644 --- a/packages/sequent-core/src/sqlite/results_event.rs +++ b/packages/sequent-core/src/sqlite/results_event.rs @@ -11,7 +11,7 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_event_sqlite( +pub fn create_results_event_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, election_event_id: &str, diff --git a/packages/velvet/Cargo.toml b/packages/velvet/Cargo.toml index 96a7d6da12a..9c8fe14007c 100644 --- a/packages/velvet/Cargo.toml +++ b/packages/velvet/Cargo.toml @@ -53,3 +53,35 @@ anyhow = "1" [[bench]] name = "pdf_generation" harness = false + +[lints.rustdoc] +missing_crate_level_docs = "deny" +broken_intra_doc_links = "deny" + +[lints.rust] +missing_docs = "deny" +unsafe_code = "forbid" +private_interfaces = "warn" +private_bounds = "warn" +unnameable_types = "warn" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } + +[lints.clippy] +missing_docs_in_private_items = "deny" +missing_errors_doc = "deny" +missing_panics_doc = "deny" +doc_markdown = "deny" +unwrap_used = "deny" +panic = "deny" +shadow_unrelated = "deny" +print_stdout = "deny" +print_stderr = "deny" +indexing_slicing = "deny" +missing_const_for_fn = "deny" +future_not_send = "deny" +arithmetic_side_effects = "deny" +suspicious = "deny" +complexity = "deny" +style = "deny" +perf = "deny" +pedantic = "deny" \ No newline at end of file diff --git a/packages/velvet/src/cli/cli.rs b/packages/velvet/src/cli/cli.rs index 998011882f3..271ff7a8cfb 100644 --- a/packages/velvet/src/cli/cli.rs +++ b/packages/velvet/src/cli/cli.rs @@ -8,40 +8,57 @@ use super::error::{Error, Result}; use clap::{Parser, Subcommand}; use std::{collections::HashSet, fs::File, path::PathBuf}; +/// Velvet command-line interface root. #[derive(Parser)] #[command(name = "Velvet")] pub struct Cli { + /// Subcommand to execute. #[command(subcommand)] pub command: Commands, } +/// Available CLI commands. #[derive(Subcommand)] pub enum Commands { + /// Run a pipeline stage. Run(CliRun), } +/// Configuration for the Run command. #[derive(Parser, Debug, Clone)] pub struct CliRun { + /// Pipeline stage to execute. pub stage: String, + /// Pipe identifier within the stage. pub pipe_id: String, + /// Path to the configuration file. #[arg(short, long)] pub config: PathBuf, + /// Input directory for ballots and data. #[arg(short, long)] pub input_dir: PathBuf, + /// Output directory for results. #[arg(short, long)] pub output_dir: PathBuf, } impl CliRun { + /// Validate the configuration for this CLI run. + /// + /// # Errors + /// Returns an error if the config file is missing, cannot be opened, or is invalid. pub fn validate(&self) -> Result { let config = self.parse_config()?; - Ok(config) } + /// Parse the configuration file for this CLI run. + /// + /// # Errors + /// Returns an error if the config file is missing, cannot be opened, or is invalid. fn parse_config(&self) -> Result { if !self.config.exists() { return Err(Error::ConfigNotFound); @@ -51,17 +68,15 @@ impl CliRun { let config: Config = parse_file(file)?; for stage in &config.stages.order { - if !config.stages.stages_def.contains_key(stage) { + let Some(stage_def) = config.stages.stages_def.get(stage) else { return Err(Error::StageDefinition(format!( "Stage '{stage}', defined in stages.order, is not defined in stages." ))); - } else { - let stage_def = config.stages.stages_def.get(stage).unwrap(); - let pipeline = &stage_def.pipeline; - let hash_set: HashSet<_> = pipeline.iter().map(|p| p.pipe.as_ref()).collect(); - if hash_set.len() != pipeline.len() { - return Err(Error::StageDefinition(format!("Pipeline, defined in stages[{stage}].pipeline, should have unique pipe definition"))); - } + }; + let pipeline = &stage_def.pipeline; + let hash_set: HashSet<_> = pipeline.iter().map(|p| p.pipe.as_ref()).collect(); + if hash_set.len() != pipeline.len() { + return Err(Error::StageDefinition(format!("Pipeline, defined in stages[{stage}].pipeline, should have unique pipe definition"))); } } @@ -138,6 +153,8 @@ mod tests { Ok(()) } + /// # Panics + /// This test will panic if the config file does not exist, as expected. #[test] #[should_panic] fn test_clirun_validate_not_found() { diff --git a/packages/velvet/src/cli/error.rs b/packages/velvet/src/cli/error.rs index 9979ed3b2a2..5b1c1ab5fbe 100644 --- a/packages/velvet/src/cli/error.rs +++ b/packages/velvet/src/cli/error.rs @@ -5,15 +5,23 @@ use crate::pipes; use pipes::error::Error as PipesError; +/// Result type alias for CLI operations. pub type Result = std::result::Result; +/// Error types for CLI operations. #[derive(Debug)] pub enum Error { + /// Configuration file not found. ConfigNotFound, + /// Cannot open configuration file. CannotOpenConfig, + /// JSON serialization/deserialization error. Json(serde_json::Error), + /// Invalid stage definition. StageDefinition(String), + /// Pipeline not found. PipeNotFound, + /// Error from pipeline operation. FromPipe(PipesError), } diff --git a/packages/velvet/src/cli/mod.rs b/packages/velvet/src/cli/mod.rs index dde8fad26bf..bf179a25c63 100644 --- a/packages/velvet/src/cli/mod.rs +++ b/packages/velvet/src/cli/mod.rs @@ -1,10 +1,16 @@ +//! Velvet CLI module: error handling, state, and test harness for CLI operations. // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +/// Error types for CLI operations. pub mod error; +/// CLI state and stage configuration. pub mod state; +/// Test harness for comprehensive validation. pub mod test_all; +/// Private CLI module containing command handling. +#[allow(clippy::module_inception)] mod cli; pub use cli::*; diff --git a/packages/velvet/src/cli/state.rs b/packages/velvet/src/cli/state.rs index 8b3ed7535f6..62d6cd3b723 100644 --- a/packages/velvet/src/cli/state.rs +++ b/packages/velvet/src/cli/state.rs @@ -13,21 +13,33 @@ use crate::pipes::PipeManager; use crate::{config::Config, pipes::pipe_name::PipeName}; use tracing::instrument; +/// CLI execution state and configuration. #[derive(Debug, Clone)] pub struct State { + /// Command-line arguments and configuration. pub cli: CliRun, + /// Execution stages in the pipeline. pub stages: Vec, } +/// A single pipeline stage configuration. #[derive(Debug, Clone)] pub struct Stage { + /// Stage name. pub name: String, + /// Pipeline components within this stage. pub pipeline: Vec, + /// Current active pipe in this stage. pub current_pipe: Option, + /// Previously executed pipe in this stage. pub previous_pipe: Option, } impl State { + /// Creates a new State from the CLI and config. + /// + /// # Errors + /// Returns an error if the state cannot be initialized from the given CLI or config. #[instrument(err, skip(config), name = "State::new")] pub fn new(cli: &CliRun, config: &Config) -> Result { let stages = @@ -50,8 +62,8 @@ impl State { )?; Ok(Stage { - name: stage_name.to_string(), - pipeline: pipeline.to_vec(), + name: stage_name.clone(), + pipeline: pipeline.clone(), previous_pipe: None, current_pipe: Some(current_pipe.pipe), }) @@ -65,19 +77,24 @@ impl State { } #[instrument(skip_all)] + /// Gets the next pipe to execute in the pipeline. + /// + /// Returns `None` if the current pipe is the same as the previous pipe. pub fn get_next(&self) -> Option { let stage_name = self.cli.stage.clone(); - self.get_stage(&stage_name) - .map(|stage| { - if stage.current_pipe == stage.previous_pipe { - None - } else { - stage.current_pipe - } - }) - .flatten() + self.get_stage(&stage_name).and_then(|stage| { + if stage.current_pipe == stage.previous_pipe { + None + } else { + stage.current_pipe + } + }) } + /// Executes the next pipe in the pipeline. + /// + /// # Errors + /// Returns an error if the next pipe cannot be executed or if a pipe fails. #[instrument(skip_all)] pub fn exec_next(&mut self) -> Result<()> { let stage_name = self.cli.stage.clone(); @@ -90,7 +107,7 @@ impl State { if let Err(e) = res { if let PipesError::FileAccess(file, _) = e { - println!("File not found: {} -- Not processed", file.display()) + tracing::info!("File not found: {} -- Not processed", file.display()); } else { return Err(Error::FromPipe(e)); } @@ -101,11 +118,16 @@ impl State { Ok(()) } + /// Returns a reference to the stage with the given name, if it exists. #[instrument(skip(self))] fn get_stage(&self, stage_name: &str) -> Option<&Stage> { self.stages.iter().find(|s| s.name == stage_name) } + /// Sets the current pipe for the given stage. + /// + /// # Errors + /// Returns `Error::PipeNotFound` if the stage is not found. #[instrument(skip(self))] fn set_current_pipe(&mut self, stage_name: &str, next_pipe: Option) -> Result<()> { let stage = self @@ -114,13 +136,16 @@ impl State { .find(|s| s.name == stage_name) .ok_or(Error::PipeNotFound)?; - stage.previous_pipe = stage.current_pipe.clone(); - stage.current_pipe = next_pipe; + stage.previous_pipe = stage.current_pipe; stage.current_pipe = next_pipe; Ok(()) } + /// Gets the computed results for the current stage. + /// + /// # Errors + /// Returns `Error::PipeNotFound` if not all pipelines have been executed and `force` is false, or if the stage is not found. #[instrument(skip_all, err)] pub fn get_results(&self, force: bool) -> Result> { let next_pipename = self.get_next(); @@ -144,29 +169,37 @@ impl State { } impl Stage { + /// Returns the previous pipe in the pipeline, if any. + /// + /// # Panics + /// Panics if the pipeline is empty. #[instrument(skip_all)] pub fn previous_pipe(&self) -> Option { if let Some(current_pipe) = self.current_pipe { let curr_index = self.pipeline.iter().position(|p| p.pipe == current_pipe); if let Some(curr_index) = curr_index { if curr_index > 0 { - return Some(self.pipeline[curr_index - 1].pipe); + return self + .pipeline + .get(curr_index.wrapping_sub(1)) + .map(|p| p.pipe); } } None } else { - Some(self.pipeline[self.pipeline.len() - 1].pipe) + self.pipeline.last().map(|p| p.pipe) } } + /// Returns the next pipe in the pipeline, if any. #[instrument(skip_all)] pub fn next_pipe(&self) -> Option { if let Some(current_pipe) = self.current_pipe { let curr_index = self.pipeline.iter().position(|p| p.pipe == current_pipe); if let Some(curr_index) = curr_index { - if curr_index + 1 < self.pipeline.len() { - return Some(self.pipeline[curr_index + 1].pipe); - } + // Avoid arithmetic side effects by checking bounds + let next_index = curr_index.checked_add(1)?; + return self.pipeline.get(next_index).map(|p| p.pipe); } None } else { @@ -174,6 +207,8 @@ impl Stage { } } + /// Returns the configuration for the given pipe, if it exists. + #[must_use] pub fn pipe_config(&self, pipe: Option) -> Option { if let Some(pipe) = pipe { self.pipeline.iter().find(|pc| pc.pipe == pipe).cloned() diff --git a/packages/velvet/src/cli/test_all.rs b/packages/velvet/src/cli/test_all.rs index 8a0a7d29936..da1028e840f 100644 --- a/packages/velvet/src/cli/test_all.rs +++ b/packages/velvet/src/cli/test_all.rs @@ -1,12 +1,11 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - use crate::fixtures::ballot_styles::generate_ballot_style; use crate::fixtures::TestFixture; use crate::pipes::pipe_inputs::BALLOTS_FILE; use anyhow::{Error, Result}; -use sequent_core::ballot::*; +use sequent_core::ballot::Contest; use sequent_core::ballot_codec::multi_ballot::{BallotChoices, ContestChoices}; use sequent_core::ballot_codec::BigUIntCodec; use sequent_core::plaintext::{DecodedVoteChoice, DecodedVoteContest}; @@ -22,7 +21,16 @@ use std::str::FromStr; use tracing::instrument; use uuid::Uuid; +/// Generate ballots for test fixtures. +/// +/// # Errors +/// Returns an error if any ballot or area config creation fails, or if +/// file operations fail. +/// +/// # Panics +/// Panics if `ballots_num` is between 1 and 19 (inclusive). #[instrument(skip_all)] +#[allow(clippy::too_many_lines)] pub fn generate_ballots( fixture: &TestFixture, election_num: u32, @@ -39,7 +47,7 @@ pub fn generate_ballots( (0..election_num).try_for_each(|_| { let areas: Vec = (0..area_num).map(|_| Uuid::new_v4()).collect(); - let mut election = fixture.create_election_config(&election_event_id, areas)?; + let mut election = fixture.create_election_config(&election_event_id, &areas)?; election.ballot_styles.clear(); (0..contest_num).try_for_each(|_| { @@ -53,7 +61,7 @@ pub fn generate_ballots( &election.tenant_id, &election_event_id, &election.id, - &Uuid::from_str(&contest.id).unwrap(), + &Uuid::from_str(&contest.id).expect("Invalid UUID in contest.id"), 100, 0, None, @@ -84,7 +92,6 @@ pub fn generate_ballots( } let mut file = fs::OpenOptions::new() - .write(true) .append(true) .create(true) .open(file.join(BALLOTS_FILE))?; @@ -127,35 +134,54 @@ pub fn generate_ballots( }; match i { - 1 => choices[0].selected = 0, - 2 => choices[1].selected = 0, - 3 => choices[2].selected = 0, - 4 => choices[3].selected = 0, - 5 => choices[4].selected = 0, - 6 => choices[0].selected = 0, - 7 => choices[0].selected = 0, - 8 => choices[3].selected = 0, - 9 => choices[3].selected = 0, - 10 => (), - 11 => (), - 12 => (), + 1 | 6 | 7 => { + if let Some(choice) = choices.get_mut(0) { + choice.selected = 0; + } + } + 3 => { + if let Some(choice) = choices.get_mut(2) { + choice.selected = 0; + } + } + 4 | 8 | 9 => { + if let Some(choice) = choices.get_mut(3) { + choice.selected = 0; + } + } + 5 => { + if let Some(choice) = choices.get_mut(4) { + choice.selected = 0; + } + } + 10..=12 => (), 14 => { - choices[2].selected = 0; - choices[3].selected = 42; + if let Some(choice2) = choices.get_mut(2) { + choice2.selected = 0; + } + if let Some(choice3) = choices.get_mut(3) { + choice3.selected = 42; + } } 15 => { - choices[3].selected = 42; + if let Some(choice3) = choices.get_mut(3) { + choice3.selected = 42; + } + } + _ => { + if let Some(choice) = choices.get_mut(1) { + choice.selected = 0; + } } - _ => choices[1].selected = 0, } plaintext_prepare.choices = choices; let plaintext = contest .encode_plaintext_contest_bigint(&plaintext_prepare) - .unwrap(); + .expect("Failed to encode plaintext contest"); - writeln!(file, "{}", plaintext)?; + writeln!(file, "{plaintext}")?; Ok::<(), Error>(()) })?; @@ -172,7 +198,16 @@ pub fn generate_ballots( Ok(()) } +/// Generate multi-contest ballots for test fixtures. +/// +/// # Errors +/// Returns an error if any ballot or area config creation fails, or if +/// file operations fail. +/// +/// # Panics +/// Panics if `ballots_num` is between 1 and 19 (inclusive), or if contest.id is not a valid UUID. #[instrument(skip_all)] +#[allow(clippy::too_many_lines)] pub fn generate_mcballots( fixture: &TestFixture, election_num: u32, @@ -189,7 +224,7 @@ pub fn generate_mcballots( (0..election_num).try_for_each(|_| { let areas: Vec = (0..area_num).map(|_| Uuid::new_v4()).collect(); - let mut election = fixture.create_election_config(&election_event_id, areas)?; + let mut election = fixture.create_election_config(&election_event_id, &areas)?; election.ballot_styles.clear(); let mut dvcs_by_area: HashMap<(String, u32), Vec> = HashMap::new(); @@ -208,7 +243,7 @@ pub fn generate_mcballots( &election.tenant_id, &election_event_id, &election.id, - &Uuid::from_str(&contest.id).unwrap(), + &Uuid::from_str(&contest.id).expect("Invalid UUID in contest.id"), 100, 0, None, @@ -220,11 +255,11 @@ pub fn generate_mcballots( )?; // create the directory for multi-ballots - let file = fixture + let file_path = fixture .input_dir_ballots .join(format!("election__{}", &election.id)) .join(format!("area__{}", area_config.id)); - fs::create_dir_all(&file)?; + fs::create_dir_all(&file_path)?; election.ballot_styles.push(generate_ballot_style( &election.tenant_id, @@ -246,7 +281,6 @@ pub fn generate_mcballots( } let mut file = fs::OpenOptions::new() - .write(true) .append(true) .create(true) .open(file.join(BALLOTS_FILE))?; @@ -290,27 +324,41 @@ pub fn generate_mcballots( }; match i { - 1 => choices[0].selected = 0, - 2 => choices[1].selected = 0, - 3 => choices[2].selected = 0, - 4 => choices[3].selected = 0, - 5 => choices[4].selected = 0, - 6 => choices[0].selected = 0, - 7 => choices[0].selected = 0, - 8 => choices[3].selected = 0, - 9 => choices[3].selected = 0, - 10 => (), - 11 => (), - 12 => (), - 14 => { - choices[2].selected = 0; - // We are not yet testing errors due to more than max allowed votes - // choices[3].selected = 42; + 1 | 6 | 7 => { + if let Some(choice) = choices.get_mut(0) { + choice.selected = 0; + } + } + 3 | 14 => { + if let Some(choice) = choices.get_mut(2) { + choice.selected = 0; + } + // For 14: We are not yet testing errors due to more than max allowed votes + // if let Some(choice) = choices.get_mut(3) { + // choice.selected = 42; + // } + } + 4 | 8 | 9 => { + if let Some(choice) = choices.get_mut(3) { + choice.selected = 0; + } } + 5 => { + if let Some(choice) = choices.get_mut(4) { + choice.selected = 0; + } + } + 10..=12 => (), 15 => { - choices[3].selected = 42; + if let Some(choice) = choices.get_mut(3) { + choice.selected = 42; + } + } + _ => { + if let Some(choice) = choices.get_mut(1) { + choice.selected = 0; + } } - _ => choices[1].selected = 0, } plaintext_prepare.choices = choices; @@ -325,9 +373,9 @@ pub fn generate_mcballots( let plaintext = contest .encode_plaintext_contest_bigint(&plaintext_prepare) - .unwrap(); + .expect("Failed to encode plaintext contest"); - writeln!(file, "{}", plaintext)?; + writeln!(file, "{plaintext}")?; Ok::<(), Error>(()) })?; @@ -351,11 +399,13 @@ pub fn generate_mcballots( &election.tenant_id, &election.election_event_id, &election.id, - &Uuid::from_str(&key.0).unwrap(), + &Uuid::from_str(&key.0).expect("Invalid UUID in ballot key"), contests.clone(), ); - let bigint = ballot.encode_to_bigint(&ballot_style).unwrap(); + let bigint = ballot + .encode_to_bigint(&ballot_style) + .expect("Failed to encode ballot to bigint"); ballots.push((key, bigint)); } @@ -371,12 +421,11 @@ pub fn generate_mcballots( .join(format!("area__{}", key.0)); let mut file = fs::OpenOptions::new() - .write(true) .append(true) .create(true) .open(file.join(BALLOTS_FILE))?; - writeln!(file, "{}", bigint)?; + writeln!(file, "{bigint}")?; } Ok::<(), Error>(()) @@ -400,6 +449,9 @@ mod tests { use crate::pipes::pipe_inputs::{PREFIX_AREA, PREFIX_CONTEST, PREFIX_ELECTION}; use crate::pipes::pipe_name::PipeNameOutputDir; use anyhow::{Error, Result}; + use sequent_core::ballot::EBlankVotePolicy; + use sequent_core::ballot::EOverVotePolicy; + use sequent_core::ballot::InvalidVotePolicy; use sequent_core::ballot_codec::BigUIntCodec; use sequent_core::plaintext::{DecodedVoteChoice, DecodedVoteContest}; use sequent_core::serialization::deserialize_with_path::deserialize_str; @@ -418,7 +470,7 @@ mod tests { let election_event_id = Uuid::new_v4(); let areas: Vec = vec![Uuid::new_v4()]; - let election = fixture.create_election_config(&election_event_id, areas)?; + let election = fixture.create_election_config(&election_event_id, &areas)?; let contest = fixture.create_contest_config(&election.tenant_id, &election_event_id, &election.id)?; @@ -780,7 +832,7 @@ mod tests { let election_event_id = Uuid::new_v4(); let areas: Vec = vec![Uuid::new_v4(), Uuid::new_v4()]; - let mut election = fixture.create_election_config(&election_event_id, areas)?; + let mut election = fixture.create_election_config(&election_event_id, &areas)?; election.ballot_styles.clear(); // First ballot style @@ -1225,7 +1277,7 @@ mod tests { let election_event_id = Uuid::new_v4(); let areas: Vec = vec![Uuid::new_v4()]; - let mut election = fixture.create_election_config(&election_event_id, areas)?; + let mut election = fixture.create_election_config(&election_event_id, &areas)?; election.ballot_styles.clear(); // First ballot style @@ -1331,7 +1383,7 @@ mod tests { let election_event_id = Uuid::new_v4(); let areas: Vec = vec![Uuid::new_v4()]; - let mut election = fixture.create_election_config(&election_event_id, areas)?; + let mut election = fixture.create_election_config(&election_event_id, &areas)?; election.ballot_styles.clear(); // First ballot style @@ -1487,7 +1539,7 @@ mod tests { let election_event_id = Uuid::new_v4(); let areas: Vec = vec![Uuid::new_v4()]; - let mut election = fixture.create_election_config(&election_event_id, areas)?; + let mut election = fixture.create_election_config(&election_event_id, &areas)?; election.ballot_styles.clear(); // First ballot style @@ -1654,7 +1706,7 @@ mod tests { let mut election = fixture.create_election_config_2( &election_event_id, - vec![ + &[ (child_area_id, Some(parent_area_id)), (parent_area_id, None), ], diff --git a/packages/velvet/src/config/ballot_images_config.rs b/packages/velvet/src/config/ballot_images_config.rs index 22983b69593..b836d1868e6 100644 --- a/packages/velvet/src/config/ballot_images_config.rs +++ b/packages/velvet/src/config/ballot_images_config.rs @@ -11,26 +11,38 @@ use serde_json::{json, Value}; use std::collections::HashMap; use tracing::instrument; +/// Configuration for ballot image generation pipeline. #[derive(Serialize, Deserialize, Debug)] pub struct PipeConfigBallotImages { + /// HTML template for ballot rendering. pub template: String, + /// System-level template for ballot processing. pub system_template: String, + /// Additional data passed to the template. pub extra_data: Value, + /// Whether to generate PDF outputs. pub enable_pdfs: bool, + /// PDF printing options configuration. pub pdf_options: Option, + /// Report output options. pub report_options: Option, + /// Execution metadata and annotations. pub execution_annotations: Option>, + /// ECIES encryption key pair. pub acm_key: Option, } +/// Default title for `MCBallot` ballot images. pub const DEFAULT_MCBALLOT_TITLE: &str = "Ballot images"; impl PipeConfigBallotImages { + /// Creates a new default ballot images configuration. #[instrument(skip_all, name = "PipeConfigBallotImages::new")] pub fn new() -> Self { Self::default() } + /// Creates a ballot images configuration for `MCBallot`. #[instrument(skip_all, name = "PipeConfigBallotImages::mcballot")] pub fn mcballot() -> Self { let html: &str = include_str!("../resources/ballot_images_user.hbs"); diff --git a/packages/velvet/src/config/config.rs b/packages/velvet/src/config/config.rs index 23a00b7d85e..5a28eeeb2f1 100644 --- a/packages/velvet/src/config/config.rs +++ b/packages/velvet/src/config/config.rs @@ -2,33 +2,46 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! Configuration types and helpers for Velvet pipelines. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::pipes::pipe_name::{deserialize_pipe, PipeName}; +/// Top-level configuration for a Velvet election pipeline. #[derive(Serialize, Deserialize, Debug)] pub struct Config { + /// Configuration version identifier. pub version: String, + /// Pipeline stages and their execution order. pub stages: Stages, } +/// Pipeline stages definition and execution order. #[derive(Serialize, Deserialize, Debug)] pub struct Stages { + /// Ordered list of stage names to execute. pub order: Vec, + /// Mapping of stage names to their pipeline configurations. #[serde(flatten)] pub stages_def: HashMap, } +/// A single pipeline stage containing multiple pipes. #[derive(Serialize, Deserialize, Debug)] pub struct Stage { + /// Ordered list of pipes to execute in this stage. pub pipeline: Vec, } +/// Configuration for a single pipeline component (pipe). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PipeConfig { + /// Unique identifier for this pipe instance. pub id: String, + /// Type of pipe to execute. #[serde(deserialize_with = "deserialize_pipe")] pub pipe: PipeName, + /// Pipe-specific configuration data. pub config: Option, } diff --git a/packages/velvet/src/config/generate_reports.rs b/packages/velvet/src/config/generate_reports.rs index f1fbc001dd9..85a77e4f30c 100644 --- a/packages/velvet/src/config/generate_reports.rs +++ b/packages/velvet/src/config/generate_reports.rs @@ -15,27 +15,41 @@ use serde_json::{json, Value}; use std::{collections::HashMap, str::FromStr}; use strum_macros::EnumString; +/// Configuration for the report generation pipeline. #[derive(Serialize, Deserialize, Debug, Default)] pub struct PipeConfigGenerateReports { + /// Whether to generate PDF outputs from reports. pub enable_pdfs: bool, + /// HTML template for report content rendering. pub report_content_template: Option, + /// PDF printing options configuration. pub pdf_options: Option, + /// Execution metadata and annotations. pub execution_annotations: HashMap, + /// System-level template for report processing. pub system_template: String, + /// Additional data passed to report templates. pub extra_data: Value, + /// Type of tally (electoral results or initialization). pub tally_type: TallyType, } +/// Policy for ordering candidates in report output. #[derive(Serialize, Deserialize, Debug, Default, EnumString)] pub enum CandidatesOrderPolicy { + /// Sort candidates by their winning position (default). #[default] SortByWinningPosition, + /// Keep candidates in the order they appear on the ballot. AsInBallot, } +/// Per-contest configuration for report generation. #[derive(Serialize, Deserialize, Debug, Default)] pub struct ContestReportConfig { + /// Order policy for candidates in this contest's report. pub candidates_order: CandidatesOrderPolicy, } -pub const CONTEST_REPORT_CONFIG: &'static str = "sequent:velvet:contest-report-config"; +/// Configuration key for storing per-contest report settings. +pub const CONTEST_REPORT_CONFIG: &str = "sequent:velvet:contest-report-config"; diff --git a/packages/velvet/src/config/mod.rs b/packages/velvet/src/config/mod.rs index 4ea3648622e..85809746433 100644 --- a/packages/velvet/src/config/mod.rs +++ b/packages/velvet/src/config/mod.rs @@ -1,9 +1,13 @@ +//! Velvet configuration module: re-exports config types and submodules for pipeline configuration. // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +/// Ballot image generation configuration. pub mod ballot_images_config; +/// Report generation configuration. pub mod generate_reports; +#[allow(clippy::module_inception)] mod config; pub use config::*; diff --git a/packages/velvet/src/fixtures/areas.rs b/packages/velvet/src/fixtures/areas.rs index be819794d25..1625173b05b 100644 --- a/packages/velvet/src/fixtures/areas.rs +++ b/packages/velvet/src/fixtures/areas.rs @@ -7,6 +7,19 @@ use uuid::Uuid; use crate::pipes::pipe_inputs::AreaConfig; #[allow(unused)] +/// Generate an `AreaConfig` for test fixtures. +/// +/// # Arguments +/// * `tenant_id` - Tenant UUID +/// * `election_event_id` - Election event UUID +/// * `election_id` - Election UUID +/// * `census` - Census value +/// * `auditable_votes` - Auditable votes value +/// * `parent_id` - Optional parent area UUID +/// * `area_id` - Optional area UUID as string +/// +/// # Panics +/// Panics if `area_id` is provided but is not a valid UUID string. pub fn get_area_config( tenant_id: &Uuid, election_event_id: &Uuid, @@ -16,12 +29,12 @@ pub fn get_area_config( parent_id: Option, area_id: Option, ) -> AreaConfig { - let area_uuid = area_id - .map(|val| Uuid::parse_str(&val).unwrap()) - .unwrap_or(Uuid::new_v4()); + let area_uuid = area_id.map_or_else(Uuid::new_v4, |val| { + Uuid::parse_str(&val).expect("Invalid UUID in area_id") + }); AreaConfig { id: area_uuid, - name: "".into(), + name: String::new(), tenant_id: *tenant_id, election_event_id: *election_event_id, election_id: *election_id, diff --git a/packages/velvet/src/fixtures/ballot_styles.rs b/packages/velvet/src/fixtures/ballot_styles.rs index 6f228151fcd..fd104f18252 100644 --- a/packages/velvet/src/fixtures/ballot_styles.rs +++ b/packages/velvet/src/fixtures/ballot_styles.rs @@ -7,6 +7,8 @@ use uuid::Uuid; use super::contests; +/// Creates a standard ballot style. +#[must_use] #[allow(unused)] pub fn get_ballot_style_1( tenant_id: &Uuid, @@ -32,8 +34,8 @@ pub fn get_ballot_style_1( election_event_id, election_id, )], - election_event_annotations: Default::default(), - election_annotations: Default::default(), + election_event_annotations: Option::default(), + election_annotations: Option::default(), election_event_presentation: None, election_presentation: None, election_dates: None, @@ -41,6 +43,8 @@ pub fn get_ballot_style_1( } } +/// Generates a custom ballot style with the provided contests. +#[must_use] #[allow(unused)] pub fn generate_ballot_style( tenant_id: &Uuid, @@ -66,8 +70,8 @@ pub fn generate_ballot_style( election_event_presentation: None, election_presentation: None, election_dates: None, - election_event_annotations: Default::default(), - election_annotations: Default::default(), + election_event_annotations: Option::default(), + election_annotations: Option::default(), area_annotations: None, } } diff --git a/packages/velvet/src/fixtures/candidates.rs b/packages/velvet/src/fixtures/candidates.rs index 92d75ce3950..ec3b3342b11 100644 --- a/packages/velvet/src/fixtures/candidates.rs +++ b/packages/velvet/src/fixtures/candidates.rs @@ -6,6 +6,7 @@ use sequent_core::ballot::{Candidate, CandidatePresentation}; use uuid::Uuid; #[allow(unused)] +/// Returns a test candidate with ID "0". pub fn get_candidate_0( tenant_id: &Uuid, election_event_id: &Uuid, @@ -18,7 +19,7 @@ pub fn get_candidate_0( election_event_id: (election_event_id.to_string()), election_id: (election_id.to_string()), contest_id: (contest_id.to_string()), - name: Some("José Rabano Pimiento".into()), + name: Some(String::from("José Rabano Pimiento")), name_i18n: None, description: None, description_i18n: None, @@ -42,6 +43,7 @@ pub fn get_candidate_0( } #[allow(unused)] +/// Return candidate with ID "1". pub fn get_candidate_1( tenant_id: &Uuid, election_event_id: &Uuid, @@ -54,7 +56,7 @@ pub fn get_candidate_1( election_event_id: (election_event_id.to_string()), election_id: (election_id.to_string()), contest_id: (contest_id.to_string()), - name: Some("Miguel Pimentel Inventado".into()), + name: Some(String::from("Miguel Pimentel Inventado")), name_i18n: None, description: None, description_i18n: None, @@ -78,6 +80,7 @@ pub fn get_candidate_1( } #[allow(unused)] +/// Return candidate with ID "2". pub fn get_candidate_2( tenant_id: &Uuid, election_event_id: &Uuid, @@ -90,7 +93,7 @@ pub fn get_candidate_2( election_event_id: (election_event_id.to_string()), election_id: (election_id.to_string()), contest_id: (contest_id.to_string()), - name: Some("Juan Iglesias Torquemada".into()), + name: Some(String::from("Juan Iglesias Torquemada")), name_i18n: None, description: None, description_i18n: None, @@ -114,6 +117,7 @@ pub fn get_candidate_2( } #[allow(unused)] +/// Return candidate with ID "3". pub fn get_candidate_3( tenant_id: &Uuid, election_event_id: &Uuid, @@ -126,7 +130,7 @@ pub fn get_candidate_3( election_event_id: (election_event_id.to_string()), election_id: (election_id.to_string()), contest_id: (contest_id.to_string()), - name: Some("Mari Pili Hernández Ordoñez".into()), + name: Some(String::from("Mari Pili Hernández Ordoñez")), name_i18n: None, description: None, description_i18n: None, @@ -150,6 +154,7 @@ pub fn get_candidate_3( } #[allow(unused)] +/// Return candidate with ID "4". pub fn get_candidate_4( tenant_id: &Uuid, election_event_id: &Uuid, @@ -162,7 +167,7 @@ pub fn get_candidate_4( election_event_id: (election_event_id.to_string()), election_id: (election_id.to_string()), contest_id: (contest_id.to_string()), - name: Some("Juan Y Medio".into()), + name: Some(String::from("Juan Y Medio")), name_i18n: None, description: None, description_i18n: None, @@ -186,6 +191,7 @@ pub fn get_candidate_4( } #[allow(unused)] +/// Return candidate with ID "5". pub fn get_candidate_5( tenant_id: &Uuid, election_event_id: &Uuid, @@ -198,7 +204,7 @@ pub fn get_candidate_5( election_event_id: election_event_id.to_string(), election_id: election_id.to_string(), contest_id: contest_id.to_string(), - name: Some("Spiderman".into()), + name: Some(String::from("Spiderman")), name_i18n: None, description: None, description_i18n: None, diff --git a/packages/velvet/src/fixtures/contests.rs b/packages/velvet/src/fixtures/contests.rs index d8d6e8d1be8..db2d0b131d8 100644 --- a/packages/velvet/src/fixtures/contests.rs +++ b/packages/velvet/src/fixtures/contests.rs @@ -11,6 +11,7 @@ use uuid::Uuid; use super::candidates; #[allow(unused)] +/// Generate a contest configuration for testing. pub fn get_contest_1(tenant_id: &Uuid, election_event_id: &Uuid, election_id: &Uuid) -> Contest { let contest_id = Uuid::new_v4(); Contest { @@ -71,6 +72,7 @@ pub fn get_contest_1(tenant_id: &Uuid, election_event_id: &Uuid, election_id: &U } #[allow(unused)] +/// Generate a contest with custom min/max votes. pub fn get_contest_min_max_votes( tenant_id: &Uuid, election_event_id: &Uuid, @@ -93,8 +95,12 @@ pub fn get_contest_min_max_votes( description_i18n: None, alias: None, alias_i18n: None, - max_votes: (max_votes as i64), - min_votes: (min_votes as i64), + max_votes: max_votes + .try_into() + .expect("max_votes must be a valid i64 value"), + min_votes: min_votes + .try_into() + .expect("min_votes must be a valid i64 value"), winning_candidates_num: (1), voting_type: Some("first-past-the-post".into()), counting_algorithm: Some(CountingAlgType::PluralityAtLarge), /* plurality-at-large|borda-nauru|borda|borda-mas-madrid|desborda3|desborda2|desborda|cumulative */ diff --git a/packages/velvet/src/fixtures/elections.rs b/packages/velvet/src/fixtures/elections.rs index a9caceeb96d..8c09dfc185c 100644 --- a/packages/velvet/src/fixtures/elections.rs +++ b/packages/velvet/src/fixtures/elections.rs @@ -2,17 +2,25 @@ // // SPDX-License-Identifier: AGPL-3.0-only +use std::collections::HashMap; + use super::ballot_styles; use crate::pipes::pipe_inputs::ElectionConfig; use sequent_core::{ballot::ElectionPresentation, services::area_tree::TreeNodeArea}; use uuid::Uuid; +/// Returns a sample `ElectionConfig` for testing. +/// +/// # Panics +/// Panics if `areas` is empty. +#[must_use] #[allow(unused)] -pub fn get_election_config_1(election_event_id: &Uuid, areas: Vec) -> ElectionConfig { +/// Generate an election configuration. +pub fn get_election_config_1(election_event_id: &Uuid, areas: &[Uuid]) -> ElectionConfig { let tenant_id = Uuid::new_v4(); let election_id = Uuid::new_v4(); - let first_area_id = areas.first().cloned().unwrap(); + let first_area_id = *areas.first().expect("areas cannot be empty"); let ballot_style = ballot_styles::get_ballot_style_1( &tenant_id, election_event_id, @@ -24,10 +32,10 @@ pub fn get_election_config_1(election_event_id: &Uuid, areas: Vec) -> Elec id: election_id, name: "Election 1".to_string(), alias: "Election 1 alias".to_string(), - description: "".to_string(), + description: String::new(), dates: None, - annotations: Default::default(), - election_event_annotations: Default::default(), + annotations: HashMap::default(), + election_event_annotations: HashMap::default(), tenant_id, election_event_id: *election_event_id, census: 0, @@ -38,7 +46,7 @@ pub fn get_election_config_1(election_event_id: &Uuid, areas: Vec) -> Elec .map(|area| TreeNodeArea { id: area.to_string(), tenant_id: tenant_id.to_string(), - annotations: Default::default(), + annotations: Option::default(), election_event_id: election_event_id.to_string(), parent_id: None, }) @@ -47,53 +55,69 @@ pub fn get_election_config_1(election_event_id: &Uuid, areas: Vec) -> Elec } } +/// Returns a second sample `ElectionConfig` for testing. +#[must_use] #[allow(unused)] pub fn get_election_config_2() -> ElectionConfig { let tenant_id = Uuid::new_v4(); let election_event_id = Uuid::new_v4(); let election_id = Uuid::new_v4(); - let area_id = Uuid::new_v4(); + let area_id1 = Uuid::new_v4(); let ballot_style1 = - ballot_styles::get_ballot_style_1(&tenant_id, &election_event_id, &election_id, &area_id); + ballot_styles::get_ballot_style_1(&tenant_id, &election_event_id, &election_id, &area_id1); - let area_id = Uuid::new_v4(); + let area_id2 = Uuid::new_v4(); let ballot_style2 = - ballot_styles::get_ballot_style_1(&tenant_id, &election_event_id, &election_id, &area_id); + ballot_styles::get_ballot_style_1(&tenant_id, &election_event_id, &election_id, &area_id2); ElectionConfig { id: election_id, name: "Election 2".to_string(), alias: "Election 2 alias".to_string(), - description: "".to_string(), - annotations: Default::default(), - election_event_annotations: Default::default(), + description: String::new(), + annotations: HashMap::default(), + election_event_annotations: HashMap::default(), dates: None, tenant_id, election_event_id, census: 0, total_votes: 0, ballot_styles: vec![ballot_style1, ballot_style2], - areas: vec![TreeNodeArea { - id: area_id.to_string(), - tenant_id: tenant_id.to_string(), - annotations: Default::default(), - election_event_id: election_event_id.to_string(), - parent_id: None, - }], + areas: vec![ + TreeNodeArea { + id: area_id1.to_string(), + tenant_id: tenant_id.to_string(), + annotations: Option::default(), + election_event_id: election_event_id.to_string(), + parent_id: None, + }, + TreeNodeArea { + id: area_id2.to_string(), + tenant_id: tenant_id.to_string(), + annotations: Option::default(), + election_event_id: election_event_id.to_string(), + parent_id: None, + }, + ], presentation: Some(ElectionPresentation::default()), } } +/// Returns a third sample `ElectionConfig` for testing. +/// +/// # Panics +/// Panics if `areas` is empty. +#[must_use] #[allow(unused)] pub fn get_election_config_3( election_event_id: &Uuid, - areas: Vec<(Uuid, Option)>, + areas: &[(Uuid, Option)], ) -> ElectionConfig { let tenant_id = Uuid::new_v4(); let election_id = Uuid::new_v4(); - let first_area_id = areas.first().cloned().unwrap().0; + let first_area_id = areas.first().expect("areas cannot be empty").0; let ballot_style = ballot_styles::get_ballot_style_1( &tenant_id, election_event_id, @@ -105,9 +129,9 @@ pub fn get_election_config_3( id: election_id, name: "Election 3".to_string(), alias: "Election 3 alias".to_string(), - description: "".to_string(), - annotations: Default::default(), - election_event_annotations: Default::default(), + description: String::new(), + annotations: HashMap::default(), + election_event_annotations: HashMap::default(), dates: None, tenant_id, election_event_id: *election_event_id, @@ -119,7 +143,7 @@ pub fn get_election_config_3( .map(|(area_id, parent_area_id)| TreeNodeArea { id: area_id.to_string(), tenant_id: tenant_id.to_string(), - annotations: Default::default(), + annotations: Option::default(), election_event_id: election_event_id.to_string(), parent_id: parent_area_id.map(|a| a.to_string()), }) diff --git a/packages/velvet/src/fixtures/fixtures.rs b/packages/velvet/src/fixtures/fixtures.rs index 56706c3c610..1eb0ef907ec 100644 --- a/packages/velvet/src/fixtures/fixtures.rs +++ b/packages/velvet/src/fixtures/fixtures.rs @@ -20,15 +20,24 @@ use crate::pipes::generate_db::DATABASE_FILENAME; use crate::pipes::pipe_inputs::{AreaConfig, ElectionConfig}; use crate::pipes::pipe_name::PipeName; +/// Test fixture for creating temporary election directories and configurations. #[derive(Debug)] pub struct TestFixture { + /// Path to the generated configuration file. pub config_path: PathBuf, + /// Root directory for all test data. pub root_dir: PathBuf, + /// Directory containing configuration inputs. pub input_dir_configs: PathBuf, + /// Directory containing ballot data. pub input_dir_ballots: PathBuf, } impl TestFixture { + /// Create a new `TestFixture`. + /// + /// # Errors + /// Returns an error if the temp directory or config file cannot be created. #[instrument] pub fn new() -> Result { let temp_folder = env::temp_dir(); @@ -45,6 +54,7 @@ impl TestFixture { temp_folder.join(format!("velvet/test-velvet-config-{}.json", Uuid::new_v4())); let mut file = fs::OpenOptions::new() .write(true) + .truncate(true) .create(true) .open(&config_path)?; @@ -58,6 +68,10 @@ impl TestFixture { }) } + /// Create a new `TestFixture` for multi-contest ballots. + /// + /// # Errors + /// Returns an error if the temp directory or config file cannot be created. #[instrument] pub fn new_mc() -> Result { let root_dir = PathBuf::from(format!("/tmp/velvet/tests-input__{}", Uuid::new_v4())); @@ -74,6 +88,7 @@ impl TestFixture { )); let mut file = fs::OpenOptions::new() .write(true) + .truncate(true) .create(true) .open(&config_path)?; @@ -88,10 +103,14 @@ impl TestFixture { } #[instrument] + /// Create an election configuration. + /// + /// # Errors + /// Returns an error if directory creation or file writing fails. pub fn create_election_config( &self, election_event_id: &Uuid, - areas: Vec, + areas: &[Uuid], ) -> Result { let election = super::elections::get_election_config_1(election_event_id, areas); @@ -108,10 +127,14 @@ impl TestFixture { } #[instrument] + /// Create an election configuration with parent areas. + /// + /// # Errors + /// Returns an error if directory creation or file writing fails. pub fn create_election_config_2( &self, election_event_id: &Uuid, - areas: Vec<(Uuid, Option)>, + areas: &[(Uuid, Option)], ) -> Result { let election = super::elections::get_election_config_3(election_event_id, areas); @@ -128,6 +151,10 @@ impl TestFixture { } #[instrument] + /// Create a contest configuration. + /// + /// # Errors + /// Returns an error if directory creation or file writing fails. pub fn create_contest_config( &self, tenant_id: &Uuid, @@ -149,6 +176,10 @@ impl TestFixture { } #[instrument] + /// Create a contest configuration with custom vote limits. + /// + /// # Errors + /// Returns an error if directory creation or file writing fails. pub fn create_contest_config_with_min_max_votes( &self, tenant_id: &Uuid, @@ -178,6 +209,11 @@ impl TestFixture { } #[instrument] + /// Create an area configuration. + /// + /// # Errors + /// Returns an error if directory creation or file writing fails. + #[allow(clippy::too_many_arguments)] pub fn create_area_config( &self, tenant_id: &Uuid, @@ -221,14 +257,22 @@ impl TestFixture { impl Drop for TestFixture { fn drop(&mut self) { - if env::var("CLEANUP_FILES").unwrap_or("true".to_string()) == "true" { - fs::remove_file(&self.config_path).unwrap(); - fs::remove_dir_all(&self.root_dir).unwrap(); + if env::var("CLEANUP_FILES").unwrap_or_else(|_| "true".to_string()) == "true" { + if let Err(e) = fs::remove_file(&self.config_path) { + tracing::warn!("Failed to remove config file: {}", e); + } + if let Err(e) = fs::remove_dir_all(&self.root_dir) { + tracing::warn!("Failed to remove root dir: {}", e); + } } } } #[instrument] +/// Generate a default Velvet configuration. +/// +/// # Errors +/// Returns an error if configuration serialization fails. pub fn get_config() -> Result { let ballot_images_pipe_config = PipeConfigBallotImages::new(); let database_pipe_config = PipeConfigGenerateDatabase { @@ -292,6 +336,10 @@ pub fn get_config() -> Result { } #[instrument] +/// Returns a test configuration for multi-contest ballot testing. +/// +/// # Errors +/// Returns an error if JSON serialization of pipe configurations fails. pub fn get_config_mcballots() -> Result { let mut ballot_images_pipe_config = PipeConfigBallotImages::new(); ballot_images_pipe_config.enable_pdfs = false; diff --git a/packages/velvet/src/fixtures/mod.rs b/packages/velvet/src/fixtures/mod.rs index 431057407f1..8bbb2b8d825 100644 --- a/packages/velvet/src/fixtures/mod.rs +++ b/packages/velvet/src/fixtures/mod.rs @@ -1,12 +1,20 @@ +//! Test fixtures for Velvet. // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +/// Area configuration fixtures. mod areas; +/// Ballot style configuration fixtures. pub mod ballot_styles; +/// Candidate configuration fixtures. mod candidates; +/// Contest configuration fixtures. mod contests; +/// Election configuration fixtures. pub mod elections; -pub mod fixtures; +/// Test fixture management. +#[allow(clippy::module_inception)] +mod fixtures; pub use fixtures::*; diff --git a/packages/velvet/src/lib.rs b/packages/velvet/src/lib.rs index 403d83469d4..caac3dd8f2b 100644 --- a/packages/velvet/src/lib.rs +++ b/packages/velvet/src/lib.rs @@ -1,9 +1,12 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - +//! Velvet is a crate for running election tally pipelines. +/// Command-line interface and configuration components. pub mod cli; pub mod config; pub mod fixtures; +/// Pipeline processing modules for election tallying. pub mod pipes; +/// Utility functions and helpers. pub mod utils; diff --git a/packages/velvet/src/main.rs b/packages/velvet/src/main.rs index 47bd23be0d7..5874335f686 100644 --- a/packages/velvet/src/main.rs +++ b/packages/velvet/src/main.rs @@ -2,10 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0-only +#![warn(missing_docs)] + +//! Election tallying and result processing system. + mod cli; mod config; +/// Test fixtures for Velvet. mod fixtures; +/// Vote tallying and processing pipelines. mod pipes; +/// Utility and helper functions. mod utils; use clap::Parser; diff --git a/packages/velvet/src/pipes/ballot_images/ballot_images.rs b/packages/velvet/src/pipes/ballot_images/ballot_images.rs index 27aa716f8c2..ac67ca5aa35 100644 --- a/packages/velvet/src/pipes/ballot_images/ballot_images.rs +++ b/packages/velvet/src/pipes/ballot_images/ballot_images.rs @@ -26,27 +26,42 @@ use tracing::info; use tracing::instrument; use uuid::Uuid; +/// Output filename for ballot images PDF file. pub const BALLOT_IMAGES_OUTPUT_FILE_PDF: &str = "ballot_images.pdf"; +/// Output filename for ballot images HTML file. pub const BALLOT_IMAGES_OUTPUT_FILE_HTML: &str = "ballot_images.html"; +/// Ballot images pipe implementation for generating ballot representations. pub struct BallotImages { + /// Pipeline input configuration. pub pipe_inputs: PipeInputs, } +/// Ballot images pipe data containing output filenames and paths. pub struct BallotImagesPipeData { + /// Output filename for PDF file. pub output_file_pdf: String, + /// Output filename for HTML file. pub output_file_html: String, + /// Pipeline name string. pub pipe_name: String, + /// Pipeline output directory name. pub pipe_name_output_dir: String, } impl BallotImages { + /// Creates a new ballot images pipe instance. #[instrument(skip_all, name = "BallotImages::new")] pub fn new(pipe_inputs: PipeInputs) -> Self { Self { pipe_inputs } } #[instrument(skip_all, err)] + /// Generates ballot images (PDF and HTML) for a contest. + /// + /// # Errors + /// Returns an error if tally creation, template rendering, or PDF generation fails. + #[allow(clippy::unused_self)] fn print_ballot_images( &self, path: &Path, @@ -64,7 +79,7 @@ impl BallotImages { vec![], vec![], ) - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let ballots = tally .ballots @@ -94,9 +109,8 @@ impl BallotImages { let rendered_user_template = reports::render_template_text(&pipe_config.template, map) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; @@ -114,23 +128,20 @@ impl BallotImages { let bytes_html = reports::render_template_text(&pipe_config.system_template, system_map) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; - let pdf_options = match pipe_config.pdf_options.clone() { - Some(options) => Some(options.to_print_to_pdf_options()), - None => None, - }; + let pdf_options = pipe_config + .pdf_options + .clone() + .map(|options| options.to_print_to_pdf_options()); let bytes_pdf = if pipe_config.enable_pdfs { let bytes_html = bytes_html.clone(); - let bytes_pdf = - pdf::sync::PdfRenderer::render_pdf(bytes_html, pdf_options).map_err(|e| { - Error::UnexpectedError(format!("Error during PDF rendering: {}", e)) - })?; + let bytes_pdf = pdf::sync::PdfRenderer::render_pdf(bytes_html, pdf_options) + .map_err(|e| Error::Unexpected(format!("Error during PDF rendering: {e}")))?; Some(bytes_pdf) } else { @@ -141,13 +152,17 @@ impl BallotImages { } #[instrument(err, skip_all)] + /// Gets the ballot images pipe configuration. + /// + /// # Errors + /// Returns an error if deserialization of the pipe config fails. pub fn get_config(&self) -> Result { let pipe_config: PipeConfigBallotImages = self .pipe_inputs .stage .pipe_config(self.pipe_inputs.stage.current_pipe) .and_then(|pc| pc.config) - .map(|value| serde_json::from_value(value)) + .map(serde_json::from_value) .transpose()? .unwrap_or_default(); Ok(pipe_config) @@ -155,6 +170,7 @@ impl BallotImages { } #[instrument(skip_all)] +/// Returns the ballot images pipe metadata. fn get_pipe_data() -> BallotImagesPipeData { BallotImagesPipeData { output_file_pdf: BALLOT_IMAGES_OUTPUT_FILE_PDF.to_string(), @@ -193,14 +209,13 @@ impl Pipe for BallotImages { let (bytes_pdf, bytes_html) = self.print_ballot_images( decoded_ballots_file.as_path(), &contest_input.contest, - &election_input, + election_input, &pipe_config, &area_input.area.name, )?; let path = PipeInputs::build_path( - &self - .pipe_inputs + self.pipe_inputs .cli .output_dir .join(&pipe_type_data.pipe_name_output_dir) @@ -219,7 +234,7 @@ impl Pipe for BallotImages { .truncate(true) .create(true) .open(file)?; - file.write_all(&some_bytes_pdf)?; + file.write_all(some_bytes_pdf)?; } let file = path.join(&pipe_type_data.output_file_html); @@ -230,11 +245,11 @@ impl Pipe for BallotImages { .open(file)?; file.write_all(&bytes_html)?; } else { - println!( + tracing::warn!( "[{}] File not found: {} -- Not processed", pipe_type_data.pipe_name, decoded_ballots_file.display() - ) + ); } } } @@ -245,51 +260,82 @@ impl Pipe for BallotImages { } #[derive(Serialize)] +/// Template data for rendering ballot images. struct TemplateData { + /// The contest configuration. pub contest: Contest, + /// The decoded ballot choices. pub ballots: Vec, + /// The election name. pub election_name: String, + /// The election alias. pub election_alias: String, + /// The area name. pub area: String, + /// The election dates. pub election_dates: Option, + /// The election annotations. pub election_annotations: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Ballot data for rendering includes decoded votes and invalid/blank status. pub struct BallotData { + /// Ballot identifier. pub id: String, + /// Encoded vote representation. pub encoded_vote: String, + /// Whether the ballot is marked as invalid. pub is_invalid: bool, + /// Whether the ballot is blank (no votes). pub is_blank: bool, + /// Contest choices for this ballot. pub contest_choices: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Contest data for rendering includes decoded choices and vote counts. pub struct ContestData { + /// Contest configuration. pub contest: Contest, + /// Decoded choices for this contest. pub decoded_choices: Vec, + /// Number of undervotes for this contest. pub undervotes: i64, + /// Number of overvotes for this contest. pub overvotes: i64, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Computed template data prepared for rendering ballot images. pub struct ComputedTemplateData { + /// All ballot data to render. pub ballot_data: Vec, + /// Name of the election. pub election_name: String, + /// Alias for the election. pub election_alias: String, + /// Area name for this ballot set. pub area: String, + /// Election start and end dates if specified. pub election_dates: Option, + /// Election annotations. pub election_annotations: HashMap, + /// Extra execution annotations such as date printed. pub execution_annotations: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Decoded choice data with candidate information. pub struct DecodedChoice { + /// The decoded vote choice. pub choice: DecodedVoteChoice, + /// The candidate if found, or None if not a valid choice. pub candidate: Option, } #[instrument(skip_all)] +/// Computes template data by processing ballot data for rendering. fn compute_data(data: TemplateData) -> ComputedTemplateData { let receipts = data .ballots @@ -315,17 +361,19 @@ fn compute_data(data: TemplateData) -> ComputedTemplateData { .filter(|can| can.is_selected()) .count(); - let is_blank = selected_candidates.len() == 0; - let undervotes = data.contest.max_votes - (num_selected as i64); - let mut overvotes = 0; - if (num_selected as i64) > data.contest.max_votes { - overvotes = (num_selected as i64) - data.contest.max_votes; + let is_blank = selected_candidates.is_empty(); + let num_selected_i64 = + i64::try_from(num_selected).expect("num_selected should fit in i64"); + let undervotes = data.contest.max_votes.saturating_sub(num_selected_i64); + let mut overvotes = 0i64; + if num_selected_i64 > data.contest.max_votes { + overvotes = num_selected_i64.saturating_sub(data.contest.max_votes); } let encoded_vote_contest = data .contest .encode_plaintext_contest_bigint(decoded_vote_contest) - .unwrap() + .expect("Failed to encode plaintext contest") .to_string(); let decoded_choices = decoded_vote_contest diff --git a/packages/velvet/src/pipes/ballot_images/mcballot_images.rs b/packages/velvet/src/pipes/ballot_images/mcballot_images.rs index 36766fb07d4..c8eb0aefe9a 100644 --- a/packages/velvet/src/pipes/ballot_images/mcballot_images.rs +++ b/packages/velvet/src/pipes/ballot_images/mcballot_images.rs @@ -33,24 +33,30 @@ use strand::hash::{hash_b64, hash_sha256}; use tokio::runtime::Runtime; use tracing::{info, instrument}; +/// Output filename for multi-contest ballot images. pub const BALLOT_IMAGES_OUTPUT_FILE: &str = "ballots"; +/// Multi-contest ballot images pipe implementation. pub struct MCBallotImages { + /// Pipeline input configuration. pub pipe_inputs: PipeInputs, } +/// Ballot images pipe data for multi-contest ballots. pub struct BallotImagesPipeData { + /// Output filename for ballot images. pub output_file: String, + /// Pipeline name string. pub pipe_name: String, + /// Pipeline output directory name. pub pipe_name_output_dir: String, } -// QR code = containing header of the report and voted candidates per position -// (if no votes, the content of QR code should be header of the report and "ABSTENTION") - +/// QR code = containing header of the report and voted candidates per position +/// (if no votes, the content of QR code should be header of the report and "ABSTENTION") #[instrument(skip_all)] -pub fn qr_encode_choices(contests: &Vec, title: &str) -> String { - let is_blank: bool = contests.iter().all(|contest| contest.is_blank()); +pub fn qr_encode_choices(contests: &[ContestData], title: &str) -> String { + let is_blank: bool = contests.iter().all(ContestData::is_blank); let mut data = vec![title.to_string()]; if is_blank { data.push("ABSTENTION".to_string()); @@ -64,8 +70,7 @@ pub fn qr_encode_choices(contests: &Vec, title: &str) -> String { let candidate_name = candidate .candidate .clone() - .map(|cand| cand.name) - .flatten() + .and_then(|cand| cand.name) .unwrap_or_default(); data.push(candidate_name); } @@ -75,7 +80,8 @@ pub fn qr_encode_choices(contests: &Vec, title: &str) -> String { } #[instrument(skip_all)] -fn sort_candidates(candidates: &mut Vec, order_field: CandidatesOrder) { +/// Sorts candidates according to the specified order. +fn sort_candidates(candidates: &mut [DecodedChoice], order_field: &CandidatesOrder) { match order_field { CandidatesOrder::Alphabetical => candidates.sort_by(|a, b| { let name_a = match &a.candidate { @@ -121,7 +127,7 @@ fn sort_candidates(candidates: &mut Vec, order_field: CandidatesO }; sort_order_a.cmp(&sort_order_b) - }) + }); } CandidatesOrder::Random => { @@ -131,16 +137,22 @@ fn sort_candidates(candidates: &mut Vec, order_field: CandidatesO } impl MCBallotImages { + /// Creates a new multi-contest ballot images pipe instance. #[instrument(skip_all, name = "MCBallotImages::new")] pub fn new(pipe_inputs: PipeInputs) -> Self { Self { pipe_inputs } } #[instrument(skip_all, err)] + /// Generates multi-contest ballot images (PDF and HTML). + /// + /// # Errors + /// Returns an error if template rendering or PDF generation fails. + #[allow(clippy::unused_self, clippy::too_many_lines)] fn print_ballot_images( &self, ballots: &[Bridge], - contests: &Vec, + contests: &[Contest], election_input: &InputElectionConfig, pipe_config: &PipeConfigBallotImages, area_name: &str, @@ -151,33 +163,34 @@ impl MCBallotImages { // We'll store some structures that map from (ballotIndex, contestIndex, pageNum) // to the sign_data string, so later we can fill in the signatures. struct ContestLocator { + /// The unique identifier for the signature request. sign_id: String, + /// The index of the ballot in the list. ballot_index: usize, + /// The index of the contest in the ballot. contest_index: usize, } let mut locators = Vec::new(); - let contest_map: HashMap = contests - .iter() - .map(|c| (c.id.to_string(), c.clone())) - .collect(); + let contest_map: HashMap = + contests.iter().map(|c| (c.id.clone(), c.clone())).collect(); let execution_annotations: Option> = pipe_config.execution_annotations.clone(); let precint_id = election_input .annotations - .get(&"miru:precinct-code".to_string()) - .map(|s| s.as_str()) + .get("miru:precinct-code") + .map(std::string::String::as_str) .unwrap_or_default(); let mut page_number = 1; let election_event_id = election_input .election_event_annotations .get("miru:election-event-id") - .map(|s| s.as_str()) + .map(std::string::String::as_str) .unwrap_or_default(); let election_id = election_input .annotations .get("miru:election-id") - .map(|s| s.as_str()) + .map(std::string::String::as_str) .unwrap_or_default(); let mut ballot_data = vec![]; @@ -186,7 +199,7 @@ impl MCBallotImages { for (c_idx, contest_choices) in ballot.choices.iter().enumerate() { let contest = contest_map .get(&contest_choices.contest_id) - .ok_or_else(|| Error::UnexpectedError("Can't get contest".into()))?; + .ok_or_else(|| Error::Unexpected("Can't get contest".into()))?; let mut choices = DecodedChoice::from_dvcs(contest_choices, contest); @@ -196,12 +209,14 @@ impl MCBallotImages { .unwrap_or_default() .candidates_order .unwrap_or_default(); - sort_candidates(&mut choices, candidates_order.clone()); + sort_candidates(&mut choices, &candidates_order); let num_selected = choices.iter().filter(|can| can.is_selected()).count(); - let undervotes = contest.max_votes - (num_selected as i64); - let overvotes = if (num_selected as i64) > contest.max_votes { - (num_selected as i64) - contest.max_votes + let num_selected_i64 = + i64::try_from(num_selected).expect("num_selected should fit in i64"); + let undervotes = contest.max_votes.saturating_sub(num_selected_i64); + let overvotes = if num_selected_i64 > contest.max_votes { + num_selected_i64.saturating_sub(contest.max_votes) } else { 0 }; @@ -214,11 +229,11 @@ impl MCBallotImages { precint_id, ballot.mcballot.serial_number.clone().unwrap_or_default(), election_id, - page_number.to_string() + page_number ); // We'll push this into our bulk_sign_requests // We also need a unique ID to correlate the signature - let sign_id = format!("b{}_c{}_p{}", b_idx, c_idx, page_number); + let sign_id = format!("b{b_idx}_c{c_idx}_p{page_number}"); bulk_sign_requests.push(SignRequest { id: sign_id.clone(), @@ -248,18 +263,19 @@ impl MCBallotImages { page_number: Some(page_number), }; - page_number += 1; + page_number = page_number.saturating_add(1); cds.push(cd); } cds.sort_by(|a, b| b.contest.name.cmp(&a.contest.name)); - let title = pipe_config.extra_data["title"] - .as_str() - .map(|val| val.to_string()) - .unwrap_or(DEFAULT_MCBALLOT_TITLE.to_string()); - let encoded_vote = qr_encode_choices(&cds, &title); - let is_blank = cds.iter().all(|choice| choice.is_blank()); + let title = pipe_config + .extra_data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(DEFAULT_MCBALLOT_TITLE); + let encoded_vote = qr_encode_choices(&cds, title); + let is_blank = cds.iter().all(ContestData::is_blank); let bd = BallotData { id: ballot.mcballot.serial_number.clone().unwrap_or_default(), @@ -270,7 +286,7 @@ impl MCBallotImages { }; ballot_data.push(bd); - page_number += 1; // inc by one for summary page + page_number = page_number.saturating_add(1); // inc by one for summary page } // 2. Now we do exactly one bulk sign if we have any sign_data @@ -278,7 +294,7 @@ impl MCBallotImages { if let Some(acm_key) = &pipe_config.acm_key { if !bulk_sign_requests.is_empty() { signatures_map = ecies_sign_data_bulk(acm_key, &bulk_sign_requests) - .map_err(|e| Error::UnexpectedError(format!("Error in bulk signing: {}", e)))?; + .map_err(|e| Error::Unexpected(format!("Error in bulk signing: {e}")))?; } } @@ -286,24 +302,23 @@ impl MCBallotImages { for locator in locators { // get the actual signature from the map if let Some(sig_base64) = signatures_map.get(&locator.sign_id) { - if ballot_data.len() <= locator.ballot_index { - return Err(Error::UnexpectedError(format!( + if let Some(bd) = ballot_data.get_mut(locator.ballot_index) { + if let Some(cd) = bd.contest_choices.get_mut(locator.contest_index) { + cd.digital_signature = Some(sig_base64.clone()); + } else { + return Err(Error::Unexpected(format!( + "index out of bounds for contest_index {} and length {}", + locator.contest_index, + bd.contest_choices.len() + ))); + } + } else { + return Err(Error::Unexpected(format!( "index out of bounds for ballot_index {} and length {}", locator.ballot_index, ballot_data.len() ))); } - let bd = &mut ballot_data[locator.ballot_index]; - if bd.contest_choices.len() <= locator.contest_index { - return Err(Error::UnexpectedError(format!( - "index out of bounds for contest_index {} and length {}", - locator.contest_index, - bd.contest_choices.len() - ))); - } - let cd = &mut bd.contest_choices[locator.contest_index]; - - cd.digital_signature = Some(sig_base64.clone()); } } @@ -326,9 +341,8 @@ impl MCBallotImages { let rendered_user_template = reports::render_template_text(&pipe_config.template, map) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; @@ -346,22 +360,20 @@ impl MCBallotImages { let bytes_html = reports::render_template_text(&pipe_config.system_template, system_map) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; - let pdf_options = match pipe_config.pdf_options.clone() { - Some(options) => Some(options.to_print_to_pdf_options()), - None => None, - }; + let pdf_options = pipe_config + .pdf_options + .clone() + .map(|options| options.to_print_to_pdf_options()); let bytes_pdf = if pipe_config.enable_pdfs { Some( - pdf::sync::PdfRenderer::render_pdf(bytes_html.clone(), pdf_options).map_err( - |e| Error::UnexpectedError(format!("Error during PDF rendering: {}", e)), - )?, + pdf::sync::PdfRenderer::render_pdf(bytes_html.clone(), pdf_options) + .map_err(|e| Error::Unexpected(format!("Error during PDF rendering: {e}")))?, ) } else { None @@ -371,13 +383,17 @@ impl MCBallotImages { } #[instrument(skip_all)] + /// Gets the `MCBallot` images pipe configuration. + /// + /// # Errors + /// Returns an error if deserialization of the pipe config fails. pub fn get_config(&self) -> Result { let pipe_config: PipeConfigBallotImages = self .pipe_inputs .stage .pipe_config(self.pipe_inputs.stage.current_pipe) .and_then(|pc| pc.config) - .map(|value| serde_json::from_value(value)) + .map(serde_json::from_value) .transpose()? .unwrap_or(PipeConfigBallotImages::mcballot()); Ok(pipe_config) @@ -385,6 +401,7 @@ impl MCBallotImages { } #[instrument(skip_all)] +/// Returns the `MCBallot` images pipe metadata. fn get_pipe_data() -> BallotImagesPipeData { BallotImagesPipeData { output_file: BALLOT_IMAGES_OUTPUT_FILE.to_string(), @@ -394,8 +411,12 @@ fn get_pipe_data() -> BallotImagesPipeData { } #[instrument(err, skip_all)] +/// Generates a hashed filename for ballot PDF reports. +/// +/// # Errors +/// Returns an error if file path operations fail. fn generate_hashed_filename( - path: &PathBuf, + path: &Path, name: &str, hash_bytes: &[u8], area_id: &str, @@ -403,11 +424,10 @@ fn generate_hashed_filename( from_ballot: Option<&Bridge>, to_ballot: Option<&Bridge>, ) -> Result { - let path = path.as_path(); let country_code = election_input .areas .iter() - .find(|area| area.id == area_id.to_string()) + .find(|area| area.id == area_id) .and_then(|area| { area.annotations .as_ref() @@ -418,13 +438,11 @@ fn generate_hashed_filename( let post_code = election_input .annotations .get("miru:precinct-code") - .map(|s| s.as_str()) - .unwrap_or(""); + .map_or("", std::string::String::as_str); let clustered_precint_id = election_input .annotations .get("clustered_precint_id") - .map(|s| s.as_str()) - .unwrap_or(""); + .map_or("", std::string::String::as_str); let from_ballot_id = match from_ballot { Some(from_ballot) => from_ballot.mcballot.serial_number.as_deref().unwrap_or(""), @@ -445,12 +463,16 @@ fn generate_hashed_filename( } #[derive(Serialize, Debug, Clone)] +/// Data for a ballot in CSV export format. struct BallotCsvData { + /// The filename of the ballot PDF. pub file_name: String, + /// The hash of the ballot content. pub hash: String, } impl Pipe for MCBallotImages { #[instrument(err, skip_all, name = "MultiBallotReceipts::exec")] + #[allow(clippy::too_many_lines)] fn exec(&self) -> Result<()> { let pipe_config: PipeConfigBallotImages = self.get_config()?; let pipe_data = get_pipe_data(); @@ -461,8 +483,7 @@ impl Pipe for MCBallotImages { for (area_id, area_contests) in area_contests_map { let path_ballots = PipeInputs::mcballots_path( - &self - .pipe_inputs + self.pipe_inputs .cli .output_dir .join(PipeNameOutputDir::DecodeMCBallots.as_ref()) @@ -479,20 +500,18 @@ impl Pipe for MCBallotImages { let ballots = convert_ballots(election_input, mcballots)?; let report_options = pipe_config.report_options.clone().unwrap_or_default(); - let max_threads = report_options.max_threads.unwrap_or_else(|| 3); + let max_threads = report_options.max_threads.unwrap_or(3); let pool = ThreadPoolBuilder::new() .num_threads(max_threads) .build() .map_err(|e| { - Error::UnexpectedError(format!("Error building thread pool: {}", e)) + Error::Unexpected(format!("Error building thread pool: {e}")) })?; - let max_items_per_report = - report_options.max_items_per_report.unwrap_or_else(|| 100); + let max_items_per_report = report_options.max_items_per_report.unwrap_or(100); let path = PipeInputs::mcballots_path( - &self - .pipe_inputs + self.pipe_inputs .cli .output_dir .join(&pipe_data.pipe_name_output_dir) @@ -501,12 +520,11 @@ impl Pipe for MCBallotImages { &area_id, ); - let chunks: Vec<&[Bridge]> = match ballots.is_empty() { - true => vec![&[] as &[Bridge]], - false => { - info!("ballots len = {len}", len = ballots.len()); - ballots.chunks(max_items_per_report).collect() - } + let chunks: Vec<&[Bridge]> = if ballots.is_empty() { + vec![&[] as &[Bridge]] + } else { + info!("ballots len = {len}", len = ballots.len()); + ballots.chunks(max_items_per_report).collect() }; let result: Result<(), Error> = pool.install(|| { @@ -519,7 +537,7 @@ impl Pipe for MCBallotImages { let (bytes_pdf, bytes_html) = self.print_ballot_images( chunk, &area_contests.contests, - &election_input, + election_input, &pipe_config, &area_contests.area_name, )?; @@ -530,25 +548,26 @@ impl Pipe for MCBallotImages { // pdf file creation let pdf_hash = hash_sha256(some_bytes_pdf.as_slice()).map_err(|e| { - Error::UnexpectedError(format!( - "Error during hash pdf bytes: {}", - e + Error::Unexpected(format!( + "Error during hash pdf bytes: {e}" )) })?; let base_file_name = pipe_data.output_file.clone(); - let from_ballot = match ballots.is_empty() { - true => None, - false => Some(chunk.first().ok_or( - Error::UnexpectedError("Can't get first chunk".into()), - )?), + let from_ballot = if ballots.is_empty() { + None + } else { + Some(chunk.first().ok_or(Error::Unexpected( + "Can't get first chunk".into(), + ))?) }; - let to_ballot = match ballots.is_empty() { - true => None, - false => Some(chunk.last().ok_or( - Error::UnexpectedError("Can't get last chunk".into()), - )?), + let to_ballot = if ballots.is_empty() { + None + } else { + Some(chunk.last().ok_or(Error::Unexpected( + "Can't get last chunk".into(), + ))?) }; let file = generate_hashed_filename( @@ -561,34 +580,26 @@ impl Pipe for MCBallotImages { to_ballot, ) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during hash pdf bytes: {}", - e + Error::Unexpected(format!( + "Error during hash pdf bytes: {e}" )) })?; let file_name = file .file_name() - .ok_or(Error::UnexpectedError( - "Can't get file name".into(), - ))? + .ok_or(Error::Unexpected("Can't get file name".into()))? .to_str() - .ok_or(Error::UnexpectedError( - "Can't get file name".into(), - ))?; + .ok_or(Error::Unexpected("Can't get file name".into()))?; let bytes_json = file_name.as_bytes().to_vec(); let file_hash = hash_b64(&bytes_json).map_err(|err| { - Error::UnexpectedError(format!( + Error::Unexpected(format!( "Error hashing the results file: {err:?}" )) })?; // Lock the mutex before modifying the vector let mut files_lock = files.lock().map_err(|e| { - Error::UnexpectedError(format!( - "Error locking files: {}", - e - )) + Error::Unexpected(format!("Error locking files: {e}")) })?; files_lock.push(BallotCsvData { file_name: file_name.to_string(), @@ -620,22 +631,17 @@ impl Pipe for MCBallotImages { // Write the CSV file of file names and hashes ONLY for `ballot` type if pipe_data.output_file.clone() == BALLOT_IMAGES_OUTPUT_FILE { - let csv_filename = format!("ballots_files.csv"); + let csv_filename = "ballots_files.csv".to_string(); let csv_path = path.join(csv_filename); let files_lock = files.lock().map_err(|e| { - Error::UnexpectedError(format!("Error locking files: {}", e)) + Error::Unexpected(format!("Error locking files: {e}")) })?; let rt = Runtime::new()?; rt.block_on(async { - write_file_hash_csv(files_lock.clone(), csv_path) - .await - .map_err(|e| { - Error::UnexpectedError(format!( - "Error writing file hash CSV: {}", - e - )) - }) + write_file_hash_csv(files_lock.clone(), csv_path).map_err(|e| { + Error::Unexpected(format!("Error writing file hash CSV: {e}")) + }) })?; } @@ -643,15 +649,15 @@ impl Pipe for MCBallotImages { }); if let Err(e) = result { - eprintln!("Error processing: {}", e); + tracing::warn!("Error processing: {e}"); } } else { - println!( + tracing::warn!( "[{}] File not found: {} -- Not processed", &pipe_data.pipe_name, path_ballots.display() ); - }; + } } } @@ -660,37 +666,61 @@ impl Pipe for MCBallotImages { } #[derive(Serialize, Debug)] +/// Template data for rendering multi-contest ballot images. pub struct TemplateData { + /// All ballot data to render. pub ballot_data: Vec, + /// Name of the election. pub election_name: String, + /// Alias for the election. pub election_alias: String, + /// Area name for this ballot set. pub area: String, + /// Election start and end dates if specified. pub election_dates: Option, + /// Election-wide annotations. pub election_annotations: HashMap, + /// Execution annotations for extra information. pub execution_annotations: HashMap, } #[derive(Serialize, Debug)] +/// Ballot data for rendering includes decoded votes and tracking information. pub struct BallotData { + /// Ballot identifier. pub id: String, + /// Encoded vote representation. pub encoded_vote: String, + /// Whether the ballot is marked as invalid. pub is_invalid: bool, + /// Whether the ballot is blank (no votes). pub is_blank: bool, + /// Contest choices for this ballot. pub contest_choices: Vec, } #[derive(Serialize, Debug)] +/// Contest data for rendering includes decoded choices and signature information. pub struct ContestData { + /// Contest configuration. pub contest: Contest, + /// Decoded choices for this contest. pub decoded_choices: Vec, + /// Number of undervotes for this contest. pub undervotes: i64, + /// Number of overvotes for this contest. pub overvotes: i64, + /// Digital signature for the ballot. pub digital_signature: Option, + /// Signing data for the ballot. pub sign_data: Option, + /// Page number for this contest. pub page_number: Option, } impl ContestData { + /// Checks if this contest has no selected choices (blank). + #[must_use] pub fn is_blank(&self) -> bool { self.decoded_choices .iter() @@ -699,14 +729,19 @@ impl ContestData { } #[derive(Serialize, Debug)] +/// A decoded choice for a ballot. struct DecodedChoice { + /// The decoded vote choice data. pub choice: DecodedVoteChoice, + /// Optional candidate information. pub candidate: Option, } impl DecodedChoice { + /// Checks if this choice is selected. pub fn is_selected(&self) -> bool { self.choice.is_selected() } + /// Converts decoded vote contest choices into `DecodedChoice` instances. fn from_dvcs(dvc: &DecodedVoteContest, contest: &Contest) -> Vec { dvc.choices .iter() @@ -723,18 +758,25 @@ impl DecodedChoice { } #[derive(Serialize, Debug)] +/// A bridge between multi-contest ballots and decoded vote contests. struct Bridge { + /// The multi-contest ballot data. pub mcballot: DecodedBallotChoices, + /// The decoded vote contests for this ballot. pub choices: Vec, } impl Bridge { - fn new(mcballot: DecodedBallotChoices, choices: Vec) -> Self { + /// Creates a new `Bridge` from ballot and contest data. + const fn new(mcballot: DecodedBallotChoices, choices: Vec) -> Self { Bridge { mcballot, choices } } } -// We are reusing some functionality from the standard receipts pipe/template, -// so it helps to convert mcballots to dcv format +/// We are reusing some functionality from the standard receipts pipe/template, +/// so it helps to convert mcballots to dcv format +/// +/// # Errors +/// Returns an error if contest or choice data cannot be found or processed. #[instrument(err, skip_all)] fn convert_ballots( election_input: &InputElectionConfig, @@ -749,18 +791,18 @@ fn convert_ballots( for contest in &dbc.choices { let blank: Option<&HashMap> = contest_dvc_map.get(&contest.contest_id); - if let Some(blank) = blank { - let mut next = blank.clone(); + if let Some(blank_data) = blank { + let mut next = blank_data.clone(); for choice in &contest.choices { - let blank = next.get(&choice.0); - if let Some(blank) = blank { - let mut marked = blank.clone(); + let blank_choice = next.get(&choice.0); + if let Some(blank_choice_val) = blank_choice { + let mut marked = blank_choice_val.clone(); marked.selected = 1; next.insert(choice.0.clone(), marked); } else { - return Err(Error::UnexpectedError(format!( - "could not find candidate for choice" - ))); + return Err(Error::Unexpected( + "could not find candidate for choice".to_string(), + )); } } let mut values: Vec = next.into_values().collect(); @@ -777,9 +819,9 @@ fn convert_ballots( }; ballot_dvcs.push(marked_contest); } else { - return Err(Error::UnexpectedError(format!( - "could not find choices for contest" - ))); + return Err(Error::Unexpected( + "could not find choices for contest".to_string(), + )); } } ret.push(Bridge::new(dbc, ballot_dvcs)); @@ -788,24 +830,28 @@ fn convert_ballots( Ok(ret) } -pub async fn write_file_hash_csv(data: Vec, path: PathBuf) -> Result<()> { +/// Writes ballot data to a CSV file. +/// +/// # Errors +/// Returns an error if file operations or CSV writing fails. +pub fn write_file_hash_csv(data: Vec, path: PathBuf) -> Result<()> { let headers = vec!["file_name".to_string(), "hash".to_string()]; let mut writer = Writer::from_writer(vec![]); - writer.write_record(&headers).map_err(|e| { - Error::UnexpectedError(format!("Failed to write headers to CSV file: {}", e)) - })?; + writer + .write_record(&headers) + .map_err(|e| Error::Unexpected(format!("Failed to write headers to CSV file: {e}")))?; for entry in data { writer .write_record(&[entry.file_name, entry.hash]) - .map_err(|e| Error::UnexpectedError(format!("Failed to write record: {}", e)))?; + .map_err(|e| Error::Unexpected(format!("Failed to write record: {e}")))?; } let data_bytes = writer .into_inner() - .map_err(|e| Error::UnexpectedError(format!("Failed to flush CSV writer: {}", e)))?; + .map_err(|e| Error::Unexpected(format!("Failed to flush CSV writer: {e}")))?; let mut file = OpenOptions::new() .write(true) diff --git a/packages/velvet/src/pipes/ballot_images/mod.rs b/packages/velvet/src/pipes/ballot_images/mod.rs index f5274fe5282..b87ec259023 100644 --- a/packages/velvet/src/pipes/ballot_images/mod.rs +++ b/packages/velvet/src/pipes/ballot_images/mod.rs @@ -2,7 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Ballot images pipe for generating PDF and HTML ballot representations. +#[allow(clippy::module_inception)] mod ballot_images; +/// Multi-contest ballot images pipe module. pub mod mcballot_images; pub use ballot_images::*; diff --git a/packages/velvet/src/pipes/decode_ballots/decode_ballots.rs b/packages/velvet/src/pipes/decode_ballots/decode_ballots.rs index 034d982ef46..8dd306b6405 100644 --- a/packages/velvet/src/pipes/decode_ballots/decode_ballots.rs +++ b/packages/velvet/src/pipes/decode_ballots/decode_ballots.rs @@ -21,13 +21,17 @@ use rayon::prelude::*; use crate::pipes::pipe_name::{PipeName, PipeNameOutputDir}; +/// Output filename for decoded ballots JSON. pub const OUTPUT_DECODED_BALLOTS_FILE: &str = "decoded_ballots.json"; +/// Ballot decoding pipe implementation. pub struct DecodeBallots { + /// Pipeline input configuration. pub pipe_inputs: PipeInputs, } impl DecodeBallots { + /// Creates a new ballot decoding pipe instance. #[instrument(skip_all, name = "DecodeBallots::new")] pub fn new(pipe_inputs: PipeInputs) -> Self { Self { pipe_inputs } @@ -36,6 +40,11 @@ impl DecodeBallots { impl DecodeBallots { #[instrument(err, skip(contest))] + /// Decodes ballots from a file using the specified contest configuration. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read or if ballot format is invalid. fn decode_ballots(path: &Path, contest: &Contest) -> Result> { let file = fs::File::open(path).map_err(|e| Error::FileAccess(path.to_path_buf(), e))?; let reader = std::io::BufReader::new(file); @@ -53,11 +62,11 @@ impl DecodeBallots { } let plaintext = - plaintext.map_err(|_| Error::UnexpectedError("Wrong ballot format".into()))?; + plaintext.map_err(|_| Error::Unexpected("Wrong ballot format".into()))?; let decoded_vote = contest .decode_plaintext_contest_bigint(&plaintext) - .map_err(|_| Error::UnexpectedError("Wrong ballot format".into()))?; + .map_err(|_| Error::Unexpected("Wrong ballot format".into()))?; decoded_ballots.push(decoded_vote); } diff --git a/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs b/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs index e399328dcb1..9930782fe26 100644 --- a/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs +++ b/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs @@ -23,15 +23,20 @@ use tracing::instrument; use crate::pipes::pipe_name::{PipeName, PipeNameOutputDir}; +/// Output filename for decoded mcballots. pub const OUTPUT_DECODED_BALLOTS_FILE: &str = "decoded_mcballots.json"; +/// Output filename for decoded contest ballots. pub const OUTPUT_DECODED_CONTEST_BALLOTS_FILE: &str = "decoded_ballots.json"; +/// `MCBallot` decoding pipe for processing multi-ballot files. pub struct DecodeMCBallots { + /// Pipe input configuration. pub pipe_inputs: PipeInputs, } impl DecodeMCBallots { #[instrument(skip_all, name = "DecodeMCBallots::new")] + /// Creates a new `MCBallot` decoder with the given pipe inputs. pub fn new(pipe_inputs: PipeInputs) -> Self { Self { pipe_inputs } } @@ -39,6 +44,11 @@ impl DecodeMCBallots { impl DecodeMCBallots { #[instrument(err, skip(contests))] + /// Decodes `MCballots` from a file using the specified contest configurations and serial numbering. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read or if ballot format is invalid. fn decode_ballots( path: &Path, contests: &Vec, @@ -60,14 +70,14 @@ impl DecodeMCBallots { } let plaintext = - plaintext.map_err(|_| Error::UnexpectedError("Wrong ballot format".into()))?; + plaintext.map_err(|_| Error::Unexpected("Wrong ballot format".into()))?; let decoded = BallotChoices::decode_from_bigint( &plaintext, contests, Some(serial_number_counter), ) - .map_err(|_| Error::UnexpectedError("Wrong ballot format".into()))?; + .map_err(|_| Error::Unexpected("Wrong ballot format".into()))?; decoded_ballots.push(decoded); } @@ -77,6 +87,9 @@ impl DecodeMCBallots { // contest_id -> (area_id -> dvc) #[instrument(skip_all)] + /// Builds a map of decoded vote choices from the election configuration. + /// + /// Maps contest IDs to area IDs to their decoded vote choices. fn get_contest_dvc_map( election_input: &InputElectionConfig, ) -> HashMap> { @@ -158,7 +171,7 @@ impl Pipe for DecodeMCBallots { dbc.clone(), &contests, ) - .map_err(|err| Error::UnexpectedError(err))?; + .map_err(Error::Unexpected)?; for decoded_contest in decoded_contests { if !output_map.contains_key(&decoded_contest.contest_id) { @@ -169,9 +182,7 @@ impl Pipe for DecodeMCBallots { .get_mut(&decoded_contest.contest_id) .expect("impossible"); - if !area_dvc_map.contains_key(&area_id) { - area_dvc_map.insert(area_id.clone(), vec![]); - } + area_dvc_map.entry(area_id).or_default(); let values = area_dvc_map.get_mut(&area_id).expect("impossible"); values.push(decoded_contest); } @@ -179,11 +190,11 @@ impl Pipe for DecodeMCBallots { } Err(e) => { if let Error::FileAccess(file, _) = &e { - println!( + tracing::warn!( "[{}] File not found: {} -- Not processed", PipeName::DecodeMCBallots.as_ref(), file.display() - ) + ); } else { return Err(e); } @@ -196,9 +207,8 @@ impl Pipe for DecodeMCBallots { for (contest_id, area_dcv_map) in output_map { for (area_id, dvcs) in area_dcv_map { let contest_uuid = Uuid::from_str(&contest_id).map_err(|e| { - Error::UnexpectedError(format!( - "Could not parse uuid for contest {}, {}", - contest_id, e + Error::Unexpected(format!( + "Could not parse uuid for contest {contest_id}, {e}" )) })?; diff --git a/packages/velvet/src/pipes/decode_ballots/mod.rs b/packages/velvet/src/pipes/decode_ballots/mod.rs index 7c7dbf9f116..d0e913eac79 100644 --- a/packages/velvet/src/pipes/decode_ballots/mod.rs +++ b/packages/velvet/src/pipes/decode_ballots/mod.rs @@ -2,6 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! Ballot decoding pipes for processing encoded ballots. +#[allow(clippy::module_inception)] +/// Single ballot decoding operations. mod decode_ballots; +/// Multi-ballot decoding operations. pub(crate) mod decode_mcballots; pub use decode_ballots::*; diff --git a/packages/velvet/src/pipes/do_tally/counting_algorithm/counting_algorithm.rs b/packages/velvet/src/pipes/do_tally/counting_algorithm/counting_algorithm.rs index ed6af35704f..ed6682ec4f5 100644 --- a/packages/velvet/src/pipes/do_tally/counting_algorithm/counting_algorithm.rs +++ b/packages/velvet/src/pipes/do_tally/counting_algorithm/counting_algorithm.rs @@ -5,6 +5,14 @@ pub use super::error::{Error, Result}; use crate::pipes::do_tally::ContestResult; +/// Trait for implementing counting algorithms that perform tally operations. +/// +/// Implementations calculate contest results based on ballots and contest configuration. pub trait CountingAlgorithm { + /// Performs the tally operation for a contest. + /// + /// # Errors + /// + /// Returns an error if the tally operation fails. fn tally(&self) -> Result; } diff --git a/packages/velvet/src/pipes/do_tally/counting_algorithm/error.rs b/packages/velvet/src/pipes/do_tally/counting_algorithm/error.rs index e220e1056a5..2696afdb178 100644 --- a/packages/velvet/src/pipes/do_tally/counting_algorithm/error.rs +++ b/packages/velvet/src/pipes/do_tally/counting_algorithm/error.rs @@ -2,14 +2,20 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Result type alias for counting algorithm operations. pub type Result = std::result::Result; +/// Errors that can occur during counting algorithm operations. #[derive(Debug)] pub enum Error { + /// Tally results are empty, cannot count votes. EmptyTallyResults, + /// Invalid tally operation with error message. InvalidTallyOperation(String), + /// Candidate was not found in the results. CandidateNotFound(String), - UnexpectedError(String), + /// Unexpected error during counting with error message. + Unexpected(String), } impl core::fmt::Display for Error { diff --git a/packages/velvet/src/pipes/do_tally/counting_algorithm/instant_runoff.rs b/packages/velvet/src/pipes/do_tally/counting_algorithm/instant_runoff.rs index 6cc3d25f89a..cf4e44b147b 100644 --- a/packages/velvet/src/pipes/do_tally/counting_algorithm/instant_runoff.rs +++ b/packages/velvet/src/pipes/do_tally/counting_algorithm/instant_runoff.rs @@ -5,8 +5,8 @@ use super::Result; use super::{CountingAlgorithm, Error}; use crate::pipes::do_tally::{ - counting_algorithm::utils::*, tally::Tally, CandidateResult, ContestResult, - ExtendedMetricsContest, InvalidVotes, + counting_algorithm::utils::update_extended_metrics, tally::Tally, CandidateResult, + ContestResult, ExtendedMetricsContest, InvalidVotes, }; use rand::prelude::IndexedRandom; use rand::seq::SliceRandom; @@ -25,37 +25,55 @@ use std::ops::{Deref, DerefMut}; use tracing::{info, instrument}; #[derive(Debug, Clone, Deserialize, Serialize)] +/// Reference to a candidate with ID and name. pub struct CandidateReference { + /// Unique candidate identifier. pub id: String, + /// Name of the candidate. pub name: String, } #[derive(PartialEq, Debug, Copy, Clone, Deserialize, Serialize)] +/// Status of a candidate during instant runoff counting. pub enum ECandidateStatus { + /// Candidate is still active in the counting process. Active, + /// Candidate has been eliminated from consideration. Eliminated, } impl ECandidateStatus { - fn is_active(&self) -> bool { - self == &ECandidateStatus::Active + /// Checks if this candidate status is active. + fn is_active(self) -> bool { + self == ECandidateStatus::Active } } #[derive(PartialEq, Debug, Copy, Clone)] +/// Status classification for a ballot during counting. enum BallotStatus { + /// Ballot is valid and contains valid votes. Valid, + /// Ballot is exhausted (no more valid choices available). Exhausted, + /// Ballot contains invalid votes. Invalid, + /// Ballot is blank (no votes selected). Blank, } #[derive(Debug)] +/// Status of ballots during instant runoff counting including valid, invalid, and blank counts. pub struct BallotsStatus<'a> { + /// List of ballots with their status, decoded votes, and weight. ballots: Vec<(BallotStatus, &'a DecodedVoteContest, Weight)>, + /// Count of valid ballots. count_valid: u64, + /// Count of ballots with invalid votes. count_invalid_votes: InvalidVotes, + /// Count of blank ballots. count_blank: u64, + /// Extended metrics for ballot analysis. extended_metrics: ExtendedMetricsContest, } @@ -79,48 +97,56 @@ impl BallotsStatus<'_> { let status = match (vote.is_invalid(), vote.is_blank()) { (true, _) => { if vote.is_explicit_invalid { - count_invalid_votes.explicit += 1; + count_invalid_votes.explicit = + count_invalid_votes.explicit.saturating_add(1); } else { - count_invalid_votes.implicit += 1; + count_invalid_votes.implicit = + count_invalid_votes.implicit.saturating_add(1); } BallotStatus::Invalid } (false, true) => { - count_blank += 1; + count_blank = count_blank.saturating_add(1); BallotStatus::Blank } (false, false) => BallotStatus::Valid, }; extended_metrics = update_extended_metrics(vote, &extended_metrics, contest); - ballots.push((status, vote, weight.clone())); + ballots.push((status, vote, *weight)); } let total_ballots = votes.len() as u64; extended_metrics.total_ballots = total_ballots; let count_valid = total_ballots - - count_invalid_votes.explicit - - count_invalid_votes.implicit - - count_blank; + .saturating_sub(count_invalid_votes.explicit) + .saturating_sub(count_invalid_votes.implicit) + .saturating_sub(count_blank); BallotsStatus { ballots, count_valid, count_invalid_votes, - extended_metrics, count_blank, + extended_metrics, } } } -/// Outcome for each candidate in a round +/// Outcome for each candidate in a counting round. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CandidateOutcome { + /// Candidate name. pub name: String, + /// Number of wins in this round (first-place votes). pub wins: u64, + /// Transference value for this candidate. pub transference: i64, + /// Percentage of votes received. pub percentage: f64, } +/// Mapping of candidate IDs to their outcomes in a counting round. type CandidatesOutcomes = HashMap; +/// Current status of all candidates in the runoff voting process. #[derive(Debug, Default, Serialize, Deserialize)] pub struct CandidatesStatus(pub HashMap); @@ -139,14 +165,15 @@ impl DerefMut for CandidatesStatus { impl CandidatesStatus { #[instrument(skip_all)] + /// Initializes candidate outcomes with zero wins for all active candidates. fn initialize_candidates_wins(&self) -> CandidatesOutcomes { let mut candidates_wins: CandidatesOutcomes = HashMap::new(); - for (candidate_id, status) in self.0.iter() { + for (candidate_id, status) in &self.0 { if status.is_active() { candidates_wins.insert( candidate_id.clone(), CandidateOutcome { - name: "".to_string(), + name: String::new(), wins: 0, transference: 0, percentage: 0.0, @@ -158,6 +185,7 @@ impl CandidatesStatus { } #[instrument(skip_all)] + /// Gets the list of candidate IDs that are currently active. fn get_active_candidate_ids(&self) -> Vec { self.0 .iter() @@ -172,37 +200,57 @@ impl CandidatesStatus { } #[instrument(skip_all)] + /// Marks a candidate as eliminated. fn set_candidate_to_eliminated(&mut self, candidate_id: &str) { self.insert(candidate_id.to_string(), ECandidateStatus::Eliminated); } } +/// Results from a single round of instant runoff voting. #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct Round { + /// Candidate elected in this round if any. pub winner: Option, + /// Vote counts and statistics for each candidate in this round. pub candidates_wins: CandidatesOutcomes, + /// Candidates eliminated in this round if any. pub eliminated_candidates: Option>, - pub active_candidates_count: u64, // Number of active candidates when starting this round - pub active_ballots_count: u64, // Number of active ballots when starting this round - pub exhausted_ballots_count: u64, // Number of exhausted ballots in this round + /// Number of active candidates when starting this round + pub active_candidates_count: u64, + /// Number of active ballots when starting this round + pub active_ballots_count: u64, + /// Number of exhausted ballots in this round + pub exhausted_ballots_count: u64, } #[derive(Default, Debug, Serialize, Deserialize)] +/// Status tracking instant runoff voting process through rounds of elimination. pub struct RunoffStatus { + /// Current status of each candidate (active or eliminated). pub candidates_status: CandidatesStatus, - pub name_references: Vec, // Maps candidate ID to name and serves as an ordered by results list in the end. + /// Maps candidate ID to name and serves as an ordered by results list in the end. + pub name_references: Vec, + /// Number of rounds completed. pub round_count: u64, + /// All completed rounds. pub rounds: Vec, + /// Maximum number of rounds expected. pub max_rounds: u64, + /// Policy for breaking ties when candidates have equal votes. pub tie_breaking_policy: TieBreakingPolicy, + /// Records of resolved tie situations. pub tie_resolutions: Vec, + /// Tie resolution awaiting user input. pub pending_tie_resolution: Option, } impl RunoffStatus { + /// Initializes the runoff voting status from a contest configuration. + /// + /// Sets up all candidates as active and initializes the tracking structures for the counting process. #[instrument(skip_all)] pub fn initialize_runoff(contest: &Contest) -> RunoffStatus { - let max_rounds = contest.candidates.len() as u64 + 1; // At least 1 candidate is eliminated per round + let max_rounds = (contest.candidates.len() as u64).saturating_add(1); // At least 1 candidate is eliminated per round let mut candidates_status = CandidatesStatus(HashMap::new()); let mut name_references = vec![]; for candidate in &contest.candidates { @@ -222,6 +270,9 @@ impl RunoffStatus { } } + /// Retrieves the name of a candidate by their ID. + /// + /// Returns `Some(name)` if the candidate is found, otherwise `None`. #[instrument(skip_all)] pub fn get_candidate_name(&self, candidate_id: &str) -> Option { self.name_references @@ -230,6 +281,9 @@ impl RunoffStatus { .map(|c| c.name.clone()) } + /// Fills in candidate names for all outcomes in a round. + /// + /// Replaces placeholder names with actual candidate names from the name references. #[instrument(skip_all)] pub fn fill_candidate_wins_names(&self, round: &Round) -> Round { let candidates_wins = round @@ -252,11 +306,17 @@ impl RunoffStatus { } } + /// Gets the last completed round. + /// + /// Returns `Some(round)` if rounds exist, otherwise `None`. #[instrument(skip_all)] pub fn get_last_round(&self) -> Option { self.rounds.last().cloned() } + /// Filters candidates by the number of wins they received. + /// + /// Returns a list of candidate IDs that received exactly `n` votes. #[instrument] pub fn filter_candidates_by_number_of_wins( &self, @@ -281,13 +341,13 @@ impl RunoffStatus { let previous_round = self.get_last_round(); let mut new_current_wins = current_wins.clone(); if let Some(prev_round) = previous_round { - for (candidate_id, outcome) in new_current_wins.iter_mut() { + for (candidate_id, outcome) in &mut new_current_wins { let prev_wins = prev_round .candidates_wins .get(candidate_id) - .map(|o| o.wins) - .unwrap_or(0); - outcome.transference = outcome.wins as i64 - prev_wins as i64; + .map_or(0, |o| o.wins); + outcome.transference = + (outcome.wins.cast_signed()).saturating_sub(prev_wins.cast_signed()); } } // If no previous round, transference stays at 0 (initial values) @@ -319,14 +379,17 @@ impl RunoffStatus { let losers = self.filter_candidates_by_number_of_wins(&candidates_to_untie, min_wins); if losers.len() == 1 { return losers; - } else { - // Continue the loop back until the tie is broken - round_possible_losers = losers; } + // Continue the loop back until the tie is broken + round_possible_losers = losers; } round_possible_losers } + /// Determines the winner or eliminated candidates using lot drawing (random selection). + /// + /// When candidates are tied, uses random chance to select winners/losers based on tie-breaking policy. + /// Returns the winning candidate and list of eliminated candidates if successful. pub fn determine_winner_by_lot( &mut self, candidates_to_eliminate: &Vec, @@ -335,9 +398,7 @@ impl RunoffStatus { // FULL TIE: All active candidates have the same (lowest) number of votes // No meaningful elimination possible → winner decided by tiebreak policy let mut rng = thread_rng(); - let Some(winner_id) = candidates_to_eliminate.choose(&mut rng) else { - return None; - }; + let winner_id = candidates_to_eliminate.choose(&mut rng)?; let winner_name = self.get_candidate_name(winner_id).unwrap_or_default(); info!( "IRV full tie detected among {} candidates. Selecting winner by lot: {} ({})", @@ -347,7 +408,7 @@ impl RunoffStatus { ); let winner = CandidateReference { - id: winner_id.to_string(), + id: winner_id.clone(), name: winner_name.clone(), }; // Mark all others as eliminated, keep only the random winner active @@ -359,7 +420,7 @@ impl RunoffStatus { self.candidates_status .set_candidate_to_eliminated(candidate_id); eliminated.push(CandidateReference { - id: candidate_id.to_string(), + id: candidate_id.clone(), name: self.get_candidate_name(candidate_id).unwrap_or_default(), }); } @@ -368,7 +429,7 @@ impl RunoffStatus { let winner_votes = candidates_wins.get(winner_id).map_or(0, |o| o.wins); let resolution_data = TallySessionResolutionData { - round_number: Some(self.round_count + 1), + round_number: Some(self.round_count.saturating_add(1)), tied_candidate_ids: candidates_to_eliminate.clone(), vote_count: winner_votes, method_used: TieBreakingMethod::Random, @@ -377,15 +438,18 @@ impl RunoffStatus { self.tie_resolutions.push(resolution_data); - return Some((winner, eliminated)); + Some((winner, eliminated)) } + /// Determines the winner or eliminated candidates using external procedure. + /// + /// When candidates are tied, defers to an external resolution procedure (user input or audit). pub fn determine_winner_by_external_procedure( &mut self, candidates_to_eliminate: &Vec, candidates_wins: &CandidatesOutcomes, ) -> Option<(CandidateReference, Vec)> { - let current_round = self.round_count + 1; + let current_round = self.round_count.saturating_add(1); // Check if there's a resolution that matches the tie. let existing_resolution = self.tie_resolutions.iter().find(|data| { @@ -404,7 +468,7 @@ impl RunoffStatus { let winner_name = self.get_candidate_name(winner_id).unwrap_or_default(); let winner = CandidateReference { - id: winner_id.to_string(), + id: winner_id.clone(), name: winner_name, }; @@ -416,7 +480,7 @@ impl RunoffStatus { self.candidates_status .set_candidate_to_eliminated(candidate_id); eliminated.push(CandidateReference { - id: candidate_id.to_string(), + id: candidate_id.clone(), name: self.get_candidate_name(candidate_id).unwrap_or_default(), }); } @@ -464,33 +528,31 @@ impl RunoffStatus { if active_count == reduced_list.len() { // if all active candidates have the same wins (all to be eliminated) then there is a winner tie, so end the election and the winner will be decided by tie breaking policy. return None; - } else { - // Simultaneous Elimination can create corner cases where a winner is decided unfairly. - // So many electoral systems pick a random candidate from the reduced list instead. - // Note: Some systems can do simultaneous elimination when it is mathematically safe, - // this is if the distance to the next more voted candidate is big enough. - let mut eliminated = vec![]; - for candidate_id in &reduced_list { - self.candidates_status - .set_candidate_to_eliminated(candidate_id); - eliminated.push(CandidateReference { - id: candidate_id.clone(), - name: self.get_candidate_name(candidate_id).unwrap_or_default(), - }); - } - return Some(eliminated); } + // Simultaneous Elimination can create corner cases where a winner is decided unfairly. + // So many electoral systems pick a random candidate from the reduced list instead. + // Note: Some systems can do simultaneous elimination when it is mathematically safe, + // this is if the distance to the next more voted candidate is big enough. + let mut eliminated = vec![]; + for candidate_id in &reduced_list { + self.candidates_status + .set_candidate_to_eliminated(candidate_id); + eliminated.push(CandidateReference { + id: candidate_id.clone(), + name: self.get_candidate_name(candidate_id).unwrap_or_default(), + }); + } + Some(eliminated) } /// Returns None if the ballot is Exhausted. /// We take into account the redristribution of votes here... - /// The first choice is the first not eliminated candidate_id in order of preference. + /// The first choice is the first not eliminated `candidate_id` in order of preference. /// This avoids having to modify the ballots list in memory. #[instrument(skip_all)] pub fn find_first_active_choice( - &self, - choices: &Vec, - active_candidate_ids: &Vec, + choices: &[DecodedVoteChoice], + active_candidate_ids: &[String], ) -> Option { let mut choices: Vec = choices .iter() @@ -515,84 +577,85 @@ impl RunoffStatus { let mut candidates_wins = self.candidates_status.initialize_candidates_wins(); let act_candidate_ids = self.candidates_status.get_active_candidate_ids(); let act_candidates_count = act_candidate_ids.len() as u64; - let mut act_ballots = 0; + let mut act_ballots: u64 = 0; let mut exhausted_ballots = self .get_last_round() .unwrap_or_default() .exhausted_ballots_count; - for (ballot_st, ballot, weight) in ballots_status.ballots.iter_mut() { + for (ballot_st, ballot, weight) in &mut ballots_status.ballots { if *ballot_st != BallotStatus::Valid { continue; } - let candidate_id = self.find_first_active_choice(&ballot.choices, &act_candidate_ids); + let candidate_id = + RunoffStatus::find_first_active_choice(&ballot.choices, &act_candidate_ids); let w = weight.unwrap_or_default(); if let Some(candidate_id) = candidate_id { if let Some(outcome) = candidates_wins.get_mut(&candidate_id) { - outcome.wins += w; + outcome.wins = outcome.wins.saturating_add(w); } - act_ballots += 1; + act_ballots = act_ballots.saturating_add(1); } else { *ballot_st = BallotStatus::Exhausted; - exhausted_ballots += 1; + exhausted_ballots = exhausted_ballots.saturating_add(1); } } candidates_wins = self.calculate_transferences(&candidates_wins); // Calculate percentages using act_ballots as denominator + #[allow(clippy::cast_precision_loss)] let act_ballots_f64 = cmp::max(1, act_ballots) as f64; for outcome in candidates_wins.values_mut() { - outcome.percentage = ((outcome.wins as f64) / act_ballots_f64).clamp(0.0, 1.0); + #[allow(clippy::cast_precision_loss)] + let wins_f64 = outcome.wins as f64; + outcome.percentage = (wins_f64 / act_ballots_f64).clamp(0.0, 1.0); } // Check if there is a winner let max_wins = candidates_wins.values().map(|o| o.wins).max().unwrap_or(0); - if 2 * max_wins > act_ballots { + if max_wins.saturating_mul(2) > act_ballots { let winner_id = self .filter_candidates_by_number_of_wins(&candidates_wins, max_wins) .first() .cloned(); - round.winner = winner_id.and_then(|id| { - Some(CandidateReference { - id: id.clone(), - name: self.get_candidate_name(&id).unwrap_or_default(), - }) + round.winner = winner_id.map(|id| CandidateReference { + id: id.clone(), + name: self.get_candidate_name(&id).unwrap_or_default(), }); } // Eliminate candidates for the next round - let continue_next_round = match round.winner.is_some() { - true => false, - false => { - // Find the Active candidate(s) with the fewest votes - let least_wins = candidates_wins.values().map(|o| o.wins).min().unwrap_or(0); - let candidates_to_eliminate: Vec = - self.filter_candidates_by_number_of_wins(&candidates_wins, least_wins); - let eliminated_candidates = - self.do_round_eliminations(&candidates_wins, &candidates_to_eliminate); - let continue_next_round = eliminated_candidates.is_some(); - if let Some(eliminated_candidates) = eliminated_candidates { - round.eliminated_candidates = Some(eliminated_candidates); - } else { - let tie_resolution = match self.tie_breaking_policy { - TieBreakingPolicy::RANDOM => { - self.determine_winner_by_lot(&candidates_to_eliminate, &candidates_wins) - } - TieBreakingPolicy::EXTERNAL_PROCEDURE => self - .determine_winner_by_external_procedure( - &candidates_to_eliminate, - &candidates_wins, - ), - }; - - if let Some((winner, eliminated_candidates)) = tie_resolution { - round.winner = Some(winner); - round.eliminated_candidates = Some(eliminated_candidates); - }; + let continue_next_round = if round.winner.is_some() { + false + } else { + // Find the Active candidate(s) with the fewest votes + let least_wins = candidates_wins.values().map(|o| o.wins).min().unwrap_or(0); + let candidates_to_eliminate: Vec = + self.filter_candidates_by_number_of_wins(&candidates_wins, least_wins); + let eliminated_candidates = + self.do_round_eliminations(&candidates_wins, &candidates_to_eliminate); + let continue_next_round = eliminated_candidates.is_some(); + if let Some(elim_candidates) = eliminated_candidates { + round.eliminated_candidates = Some(elim_candidates); + } else { + let tie_resolution = match self.tie_breaking_policy { + TieBreakingPolicy::RANDOM => { + self.determine_winner_by_lot(&candidates_to_eliminate, &candidates_wins) + } + TieBreakingPolicy::EXTERNAL_PROCEDURE => self + .determine_winner_by_external_procedure( + &candidates_to_eliminate, + &candidates_wins, + ), }; - continue_next_round + + if let Some((winner, elim_from_tie)) = tie_resolution { + round.winner = Some(winner); + round.eliminated_candidates = Some(elim_from_tie); + } } + continue_next_round }; round.active_ballots_count = act_ballots; round.active_candidates_count = act_candidates_count; @@ -600,12 +663,12 @@ impl RunoffStatus { round.candidates_wins = candidates_wins; round = self.fill_candidate_wins_names(&round); self.rounds.push(round); - self.round_count += 1; + self.round_count = self.round_count.saturating_add(1); - return continue_next_round; + continue_next_round } - /// Order name_references to have the best results at the beginning + /// Order `name_references` to have the best results at the beginning #[instrument(skip_all)] pub fn order_name_references_by_result(&self) -> Vec { let mut new_name_references: Vec = vec![]; @@ -614,44 +677,51 @@ impl RunoffStatus { } for round in self.rounds.iter().rev() { for (candidate_id, candidate_outcome) in &round.candidates_wins { - if new_name_references - .iter() - .find(|c| &c.id == candidate_id) - .is_none() - { + if !new_name_references.iter().any(|c| &c.id == candidate_id) { new_name_references.push(CandidateReference { id: candidate_id.clone(), name: candidate_outcome.name.clone(), - }) + }); } } } new_name_references } + /// Runs the instant runoff voting counting process. + /// + /// Iteratively eliminates candidates with the fewest votes until a winner is determined. #[instrument(skip_all)] pub fn run(&mut self, ballots_status: &mut BallotsStatus) { self.pending_tie_resolution = None; let mut iterations = 0; while self.run_next_round(ballots_status) && iterations < self.max_rounds { - iterations += 1; + iterations = iterations.saturating_add(1); } self.name_references = self.order_name_references_by_result(); } } +/// Instant runoff voting algorithm implementation. pub struct InstantRunoff { + /// Tally containing ballots and contest information. pub tally: Tally, } impl InstantRunoff { + /// Creates a new instant runoff voting algorithm with a tally. #[instrument(skip_all)] pub fn new(tally: Tally) -> Self { Self { tally } } #[instrument(err, skip_all)] + /// Processes ballots using instant runoff voting. + /// + /// # Errors + /// + /// Returns an error if the ballot processing fails. pub fn process_ballots(&self, op: TallyOperation) -> Result { let contest = &self.tally.contest; let votes: &Vec<(DecodedVoteContest, Weight)> = &self.tally.ballots; @@ -660,40 +730,41 @@ impl InstantRunoff { let count_blank = ballots_status.count_blank; let count_valid = ballots_status.count_valid; let count_invalid_votes = ballots_status.count_invalid_votes; - let count_invalid = count_invalid_votes.explicit + count_invalid_votes.implicit; + let count_invalid = count_invalid_votes + .explicit + .saturating_add(count_invalid_votes.implicit); let extended_metrics = ballots_status.extended_metrics; - let percentage_votes_denominator = count_valid - count_blank; - - let (candidate_result, process_results) = match op { - TallyOperation::SkipCandidateResults => (vec![], None), - _ => { - let mut runoff = RunoffStatus::initialize_runoff(&contest); - runoff.run(&mut ballots_status); - - let mut vote_count: HashMap = HashMap::new(); // vote_count has only the last round results or it could be left empty because the full results are in runoff_value - if let Some(results) = runoff.get_last_round() { - vote_count = results - .candidates_wins - .into_iter() - .map(|(candidate_id, outcome)| (candidate_id, outcome.wins)) - .collect(); - } + let percentage_votes_denominator = count_valid.saturating_sub(count_blank); - // Create a json value from runoff object. - let runoff_value = serde_json::to_value(runoff) - .map_err(|e| Error::UnexpectedError(e.to_string()))?; - - let candidate_result = self.tally.create_candidate_results( - vote_count, - count_blank, - count_invalid_votes.clone(), - extended_metrics.clone(), - count_valid, - count_invalid, - percentage_votes_denominator, - )?; - (candidate_result, Some(runoff_value)) + let (candidate_result, process_results) = if op == TallyOperation::SkipCandidateResults { + (vec![], None) + } else { + let mut runoff = RunoffStatus::initialize_runoff(contest); + runoff.run(&mut ballots_status); + + let mut vote_count: HashMap = HashMap::new(); // vote_count has only the last round results or it could be left empty because the full results are in runoff_value + if let Some(results) = runoff.get_last_round() { + vote_count = results + .candidates_wins + .into_iter() + .map(|(candidate_id, outcome)| (candidate_id, outcome.wins)) + .collect(); } + + // Create a json value from runoff object. + let runoff_value = + serde_json::to_value(runoff).map_err(|e| Error::Unexpected(e.to_string()))?; + + let candidate_result = self.tally.create_candidate_results( + vote_count, + count_blank, + count_invalid_votes, + extended_metrics, + count_valid, + count_invalid, + percentage_votes_denominator, + )?; + (candidate_result, Some(runoff_value)) }; self.tally.create_contest_result( @@ -713,7 +784,7 @@ impl CountingAlgorithm for InstantRunoff { #[instrument(err, skip_all)] fn tally(&self) -> Result { let contest_result = match self.tally.scope_operation { - ScopeOperation::Contest(op) if op == TallyOperation::AggregateResults => { + ScopeOperation::Contest(TallyOperation::AggregateResults) => { self.tally.aggregate_results()? } ScopeOperation::Contest(op) => self.process_ballots(op)?, diff --git a/packages/velvet/src/pipes/do_tally/counting_algorithm/mod.rs b/packages/velvet/src/pipes/do_tally/counting_algorithm/mod.rs index 4628c620d09..01e39de0800 100644 --- a/packages/velvet/src/pipes/do_tally/counting_algorithm/mod.rs +++ b/packages/velvet/src/pipes/do_tally/counting_algorithm/mod.rs @@ -2,10 +2,16 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Counting algorithm error handling. mod error; +/// Instant runoff voting counting algorithm. pub mod instant_runoff; +/// Plurality at large counting algorithm. pub mod plurality_at_large; +/// Utility functions for counting algorithms. pub mod utils; +/// Tally counting algorithm implementation. +#[allow(clippy::module_inception)] mod counting_algorithm; pub use counting_algorithm::*; diff --git a/packages/velvet/src/pipes/do_tally/counting_algorithm/plurality_at_large.rs b/packages/velvet/src/pipes/do_tally/counting_algorithm/plurality_at_large.rs index 908fa827b2d..090547ff1f1 100644 --- a/packages/velvet/src/pipes/do_tally/counting_algorithm/plurality_at_large.rs +++ b/packages/velvet/src/pipes/do_tally/counting_algorithm/plurality_at_large.rs @@ -4,8 +4,8 @@ use super::{CountingAlgorithm, Error}; use crate::pipes::do_tally::{ - counting_algorithm::utils::*, tally::Tally, CandidateResult, ContestResult, - ExtendedMetricsContest, InvalidVotes, + counting_algorithm::utils::update_extended_metrics, tally::Tally, CandidateResult, + ContestResult, ExtendedMetricsContest, InvalidVotes, }; use sequent_core::types::ceremonies::{ScopeOperation, TallyOperation}; use std::cmp; @@ -14,16 +14,24 @@ use tracing::{info, instrument}; use super::Result; +/// Plurality at large voting algorithm implementation. pub struct PluralityAtLarge { + /// Tally containing ballots and contest information. pub tally: Tally, } impl PluralityAtLarge { + /// Creates a new plurality at large voting algorithm with a tally. #[instrument(skip_all)] pub fn new(tally: Tally) -> Self { Self { tally } } #[instrument(err, skip_all)] + /// Processes ballots using plurality at large voting. + /// + /// # Errors + /// + /// Returns an error if the ballot processing fails. pub fn process_ballots(&self, op: TallyOperation) -> Result { let contest = &self.tally.contest; let votes = &self.tally.ballots; @@ -38,28 +46,29 @@ impl PluralityAtLarge { let mut count_blank: u64 = 0; let mut extended_metrics = ExtendedMetricsContest::default(); - let mut total_ballots = 0; - let mut total_weight = 0; + let mut total_ballots: u64 = 0; + let mut total_weight: u64 = 0; for (vote, weight_opt) in votes { let weight = weight_opt.clone().unwrap_or_default(); - total_ballots += 1; + total_ballots = total_ballots.saturating_add(1); - extended_metrics = update_extended_metrics(vote, &extended_metrics, &contest); + extended_metrics = update_extended_metrics(vote, &extended_metrics, contest); if vote.is_invalid() { if vote.is_explicit_invalid { - count_invalid_votes.explicit += 1; + count_invalid_votes.explicit = count_invalid_votes.explicit.saturating_add(1); } else { - count_invalid_votes.implicit += 1; + count_invalid_votes.implicit = count_invalid_votes.implicit.saturating_add(1); } - count_invalid += 1; + count_invalid = count_invalid.saturating_add(1); } else { let mut is_blank = true; for choice in &vote.choices { if choice.selected >= 0 { - *vote_count.entry(choice.id.clone()).or_insert(0) += weight; - total_weight += weight; + let entry = vote_count.entry(choice.id.clone()).or_insert(0); + *entry = entry.saturating_add(weight); + total_weight = total_weight.saturating_add(weight); if is_blank { is_blank = false; } @@ -67,10 +76,10 @@ impl PluralityAtLarge { } if is_blank { - count_blank += 1; + count_blank = count_blank.saturating_add(1); } - count_valid += 1; + count_valid = count_valid.saturating_add(1); } } @@ -83,8 +92,8 @@ impl PluralityAtLarge { _ => self.tally.create_candidate_results( vote_count, count_blank, - count_invalid_votes.clone(), - extended_metrics.clone(), + count_invalid_votes, + extended_metrics, count_valid, count_invalid, percentage_votes_denominator, @@ -108,7 +117,7 @@ impl CountingAlgorithm for PluralityAtLarge { #[instrument(err, skip_all)] fn tally(&self) -> Result { let contest_result = match self.tally.scope_operation { - ScopeOperation::Contest(op) if op == TallyOperation::AggregateResults => { + ScopeOperation::Contest(TallyOperation::AggregateResults) => { self.tally.aggregate_results()? } ScopeOperation::Contest(op) => self.process_ballots(op)?, diff --git a/packages/velvet/src/pipes/do_tally/counting_algorithm/utils.rs b/packages/velvet/src/pipes/do_tally/counting_algorithm/utils.rs index 1521a1dba8a..28f2129d4df 100644 --- a/packages/velvet/src/pipes/do_tally/counting_algorithm/utils.rs +++ b/packages/velvet/src/pipes/do_tally/counting_algorithm/utils.rs @@ -13,99 +13,91 @@ use std::str::FromStr; use tracing::{info, instrument}; use uuid::Uuid; +/// Calculates the number of undervotes in a ballot. fn calculate_undervotes(vote: &DecodedVoteContest, contest: &Contest) -> u64 { // Count actual votes (selected > -1) - let actual_votes: u64 = - vote.choices.iter().fold( - 0u64, - |acc, choice| { - if choice.selected > -1 { - acc + 1 - } else { - acc - } - }, - ); + let actual_votes: u64 = vote.choices.iter().fold(0u64, |acc, choice| { + if choice.selected > -1 { + acc.saturating_add(1) + } else { + acc + } + }); // Calculate undervotes based on max_votes - let max_votes = contest.max_votes as u64; - if actual_votes < max_votes { - max_votes - actual_votes - } else { - 0 - } + let max_votes = u64::try_from(contest.max_votes).expect("max_votes should be non-negative"); + max_votes.saturating_sub(actual_votes) } +/// Calculates the number of valid votes in a ballot. fn calculate_valid_votes(vote: &DecodedVoteContest, contest: &Contest) -> u64 { // Count actual votes (selected > -1) - let actual_votes: u64 = - vote.choices.iter().fold( - 0u64, - |acc, choice| { - if choice.selected > -1 { - acc + 1 - } else { - acc - } - }, - ); + let actual_votes: u64 = vote.choices.iter().fold(0u64, |acc, choice| { + if choice.selected > -1 { + acc.saturating_add(1) + } else { + acc + } + }); // Check if votes are within valid range - if actual_votes >= (contest.min_votes as u64) && actual_votes <= (contest.max_votes as u64) { + let min_votes_u64 = contest.min_votes.cast_unsigned(); + let max_votes_u64 = contest.max_votes.cast_unsigned(); + if actual_votes >= min_votes_u64 && actual_votes <= max_votes_u64 { actual_votes } else { 0 } } +/// Calculates the number of overvotes in a ballot. fn calculate_overvotes(vote: &DecodedVoteContest, contest: &Contest) -> u64 { // Count actual votes (selected > -1) - let actual_votes: u64 = - vote.choices.iter().fold( - 0u64, - |acc, choice| { - if choice.selected > -1 { - acc + 1 - } else { - acc - } - }, - ); + let actual_votes: u64 = vote.choices.iter().fold(0u64, |acc, choice| { + if choice.selected > -1 { + acc.saturating_add(1) + } else { + acc + } + }); // Calculate overvotes if actual votes exceed max_votes - if actual_votes > (contest.max_votes as u64) { - actual_votes - (contest.max_votes as u64) - } else { - 0 - } + let max_votes_u64 = contest.max_votes.cast_unsigned(); + actual_votes.saturating_sub(max_votes_u64) } +/// Updates extended metrics for a contest based on a decoded vote. +/// +/// Calculates valid votes, undervotes, and overvotes and updates the metrics accordingly. #[instrument(skip_all)] pub fn update_extended_metrics( vote: &DecodedVoteContest, current_metrics: &ExtendedMetricsContest, contest: &Contest, ) -> ExtendedMetricsContest { - let mut metrics = current_metrics.clone(); + let metrics = *current_metrics; + let mut result = metrics; // Calculate valid votes first let valid_votes = calculate_valid_votes(vote, contest); - metrics.votes_actually += valid_votes; + result.votes_actually = result.votes_actually.saturating_add(valid_votes); // Calculate undervotes let undervotes = calculate_undervotes(vote, contest); - metrics.under_votes += undervotes; + result.under_votes = result.under_votes.saturating_add(undervotes); // Calculate overvotes let overvotes = calculate_overvotes(vote, contest); - metrics.over_votes += overvotes; + result.over_votes = result.over_votes.saturating_add(overvotes); // Expected votes is always max_votes per ballot - metrics.expected_votes += contest.max_votes as u64; + let max_votes_u64 = contest.max_votes.cast_unsigned(); + result.expected_votes = result.expected_votes.saturating_add(max_votes_u64); - metrics + result } +/// Gets the tally operation for a contest from its annotations. #[instrument(skip_all)] pub fn get_contest_tally_operation(contest: &Contest) -> TallyOperation { let default_tally_op = contest @@ -114,14 +106,15 @@ pub fn get_contest_tally_operation(contest: &Contest) -> TallyOperation { let annotations = contest.annotations.clone().unwrap_or_default(); let operation = annotations .get("tally_operation") - .map(|val| val.clone()) + .cloned() .unwrap_or_default(); TallyOperation::from_str(&operation).unwrap_or(default_tally_op) } +/// Gets the tally operation for an area based on ballot styles and counting algorithm. #[instrument(skip_all)] pub fn get_area_tally_operation( - ballot_styles: &Vec, + ballot_styles: &[BallotStyle], counting_alg: CountingAlgType, area_id: &Uuid, ) -> TallyOperation { @@ -138,18 +131,18 @@ pub fn get_area_tally_operation( } } +/// Gets the weight for an area from ballot styles. #[instrument(skip_all)] -pub fn get_area_weight(ballot_styles: &Vec, area_id: &Uuid) -> Weight { +pub fn get_area_weight(ballot_styles: &[BallotStyle], area_id: &Uuid) -> Weight { let area_ballot_style: Option<&BallotStyle> = ballot_styles .iter() .find(|bs| bs.area_id == area_id.to_string()); area_ballot_style - .map(|bs| { + .and_then(|bs| { bs.area_annotations .as_ref() - .map(|area_annotations| area_annotations.get_weight()) + .map(sequent_core::ballot::AreaAnnotations::get_weight) }) - .flatten() .unwrap_or_default() } diff --git a/packages/velvet/src/pipes/do_tally/do_tally.rs b/packages/velvet/src/pipes/do_tally/do_tally.rs index fef9bfbeba0..523cfd761c7 100644 --- a/packages/velvet/src/pipes/do_tally/do_tally.rs +++ b/packages/velvet/src/pipes/do_tally/do_tally.rs @@ -43,29 +43,39 @@ use std::{ use tracing::{event, info, instrument, Level, Value as TracingValue}; use uuid::Uuid; +/// Output filename for contest results JSON. pub const OUTPUT_CONTEST_RESULT_FILE: &str = "contest_result.json"; +/// Output folder for aggregated child area results. pub const OUTPUT_CONTEST_RESULT_AREA_CHILDREN_AGGREGATE_FOLDER: &str = "aggregate"; +/// Input filename for tally sheet data. pub const INPUT_TALLY_SHEET_FILE: &str = "tally-sheet.json"; +/// Output folder for vote breakdowns. pub const OUTPUT_BREAKDOWNS_FOLDER: &str = "breakdowns"; +/// Tally calculation pipe implementation. pub struct DoTally { + /// Pipeline input configuration. pub pipe_inputs: PipeInputs, } impl DoTally { + /// Creates a new tally calculation pipe instance. #[instrument(skip_all, name = "DoTally::new")] pub fn new(pipe_inputs: PipeInputs) -> Self { Self { pipe_inputs } } } +/// Lists all tally sheet subfolders at the given path. +/// +/// Filters directories by checking if their names start with the tally sheet prefix. #[instrument] pub fn list_tally_sheet_subfolders(path: &Path) -> Vec { - let subfolders = list_subfolders(&path); + let subfolders = list_subfolders(path); let tally_sheet_folders: Vec = subfolders .into_iter() - .filter(|path| { - let Some(folder_name) = get_folder_name(path) else { + .filter(|subfolder| { + let Some(folder_name) = get_folder_name(subfolder) else { return false; }; folder_name.starts_with(PREFIX_TALLY_SHEET) @@ -75,11 +85,15 @@ pub fn list_tally_sheet_subfolders(path: &Path) -> Vec { } impl DoTally { + /// Saves tally sheet breakdown results to files. + /// + /// # Errors + /// + /// Returns an error if file operations fail. #[instrument(err, skip_all)] fn save_tally_sheets_breakdown( - &self, - tally_sheet_results: &Vec<(ContestResult, TallySheet)>, - base_file_path: &PathBuf, + tally_sheet_results: &[(ContestResult, TallySheet)], + base_file_path: &Path, ) -> Result<()> { let base_breakdown_path = base_file_path.join(OUTPUT_BREAKDOWNS_FOLDER); let mut breakdown_map: HashMap = HashMap::new(); @@ -90,13 +104,13 @@ impl DoTally { breakdown_map .entry(channel) .and_modify(|current_result| { - current_result.aggregate(&contest_result, true); + current_result.aggregate(contest_result, true); }) .or_insert_with(|| contest_result.clone()); } for (channel, contest_result) in breakdown_map { - let breakdown_folder_path = base_breakdown_path.join(&channel.to_string()); + let breakdown_folder_path = base_breakdown_path.join(channel.to_string()); fs::create_dir_all(&breakdown_folder_path)?; let breakdown_file_path = breakdown_folder_path.join(OUTPUT_CONTEST_RESULT_FILE); let contest_result_file = fs::File::create(&breakdown_file_path)?; @@ -108,7 +122,13 @@ impl DoTally { } impl Pipe for DoTally { + /// Processes tally operations and aggregates results. + /// + /// # Errors + /// + /// Returns an error if tally processing fails. #[instrument(err, skip_all, name = "DoTally::exec")] + #[allow(clippy::too_many_lines)] fn exec(&self) -> Result<()> { let input_dir_base = self .pipe_inputs @@ -135,28 +155,27 @@ impl Pipe for DoTally { let tally_sheets_dir = tally_sheets_dir_base.clone(); // These are specific to the contest and need to be cloned for use in area processing. - let election_id_for_contest = contest_input.election_id.clone(); - let contest_id_for_contest = contest_input.id.clone(); + let election_id_for_contest = contest_input.election_id; + let contest_id_for_contest = contest_input.id; let contest_object = contest_input.contest.clone(); let contest_op = get_contest_tally_operation(&contest_object); // --- Start of logic for a single contest --- - let _areas_info: Vec = contest_input // Renamed, original `areas` was unused after info + let areas_info: Vec = contest_input // Renamed, original `areas` was unused after info .area_list .iter() .map(|area| (&area.area).into()) .collect(); info!( "areas for contest {}: {:?}", - contest_id_for_contest, _areas_info + contest_id_for_contest, areas_info ); let areas_tree = Arc::new( TreeNode::<()>::from_areas(election_input.areas.clone()).map_err( |err| { - Error::UnexpectedError(format!( - "Error building area tree for contest {}: {:?}", - contest_id_for_contest, err + Error::Unexpected(format!( + "Error building area tree for contest {contest_id_for_contest}: {err:?}" )) }, )?, @@ -181,9 +200,9 @@ impl Pipe for DoTally { .par_iter() .map(|area_input| { // Clone data needed per area task. - let area_id = area_input.id.clone(); - let election_id = election_id_for_contest.clone(); - let contest_id = contest_id_for_contest.clone(); + let area_id = area_input.id; + let election_id = election_id_for_contest; + let contest_id = contest_id_for_contest; let base_input_path = PipeInputs::build_path( &input_dir, @@ -206,7 +225,7 @@ impl Pipe for DoTally { let Some(area_tree_node) = areas_tree.as_ref().find_area(&area_input.id.to_string()) else { - return Err(Error::UnexpectedError(format!( + return Err(Error::Unexpected(format!( "Error finding area {} in areas tree for contest {}", area_input.id, contest_id ))); @@ -246,7 +265,7 @@ impl Pipe for DoTally { .map(|child_area| -> Result<(PathBuf, Weight), Error> { let child_area_id = Uuid::parse_str(&child_area.id) .map_err(|err| { - Error::UnexpectedError(format!( + Error::Unexpected(format!( "Uuid parse error: {err:?}" )) })?; @@ -278,10 +297,10 @@ impl Pipe for DoTally { vec![], vec![], ) - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let res: ContestResult = counting_algorithm .tally() - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let file_path = base_aggregate_path.join(OUTPUT_CONTEST_RESULT_FILE); let file = fs::File::create(file_path)?; @@ -301,10 +320,10 @@ impl Pipe for DoTally { vec![], vec![], ) - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let mut area_tally_results = counting_algorithm_area .tally() - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; if let Some(extended_metrics) = area_tally_results.extended_metrics.as_mut() @@ -341,7 +360,7 @@ impl Pipe for DoTally { &tally_sheets_file_path, ) .map_err(|e| { - Error::FileAccess(tally_sheets_file_path.to_path_buf(), e) + Error::FileAccess(tally_sheets_file_path.clone(), e) })?; let tally_sheet: TallySheet = serde_json::from_str(&tally_sheet_str)?; @@ -353,7 +372,7 @@ impl Pipe for DoTally { fs::create_dir_all(&output_tally_sheets_folder_path)?; let contest_result_sheet = tally::process_tally_sheet(&tally_sheet, &contest_object) - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let output_tally_sheets_file_path = output_tally_sheets_folder_path @@ -398,8 +417,8 @@ impl Pipe for DoTally { ) in collected_area_outputs { contest_ballot_files.push(ballot_file); - sum_census += census; - sum_auditable_votes += auditable_votes_val; + sum_census = sum_census.saturating_add(census); + sum_auditable_votes = sum_auditable_votes.saturating_add(auditable_votes_val); tally_sheet_results_for_contest.extend(sheet_results); area_tally_results_for_contest.push(area_tally_results); } @@ -413,7 +432,7 @@ impl Pipe for DoTally { ); fs::create_dir_all(&contest_output_dir_path)?; // Ensure contest directory exists - self.save_tally_sheets_breakdown( + DoTally::save_tally_sheets_breakdown( &tally_sheet_results_for_contest, &contest_output_dir_path, )?; @@ -434,10 +453,10 @@ impl Pipe for DoTally { final_only_sheet_results, area_tally_results_for_contest, ) - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let final_res = final_counting_algorithm .tally() - .map_err(|e| Error::UnexpectedError(e.to_string()))?; + .map_err(|e| Error::Unexpected(e.to_string()))?; let final_contest_result_file_path = contest_output_dir_path.join(OUTPUT_CONTEST_RESULT_FILE); @@ -453,92 +472,127 @@ impl Pipe for DoTally { } #[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)] +/// Count of explicit and implicit invalid votes. pub struct InvalidVotes { + /// Number of explicitly marked invalid votes. pub explicit: u64, + /// Number of implicitly invalid votes (e.g., overvotes, rule violations). pub implicit: u64, } impl InvalidVotes { + /// Aggregates two Invalid Votes counts. + #[must_use] #[instrument] pub fn aggregate(&self, other: &InvalidVotes) -> InvalidVotes { - let mut sum = self.clone(); + let mut sum = *self; - sum.explicit += other.explicit; - sum.implicit += other.implicit; + sum.explicit = sum.explicit.saturating_add(other.explicit); + sum.implicit = sum.implicit.saturating_add(other.implicit); sum } } #[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)] +/// Extended metrics for contest voting analysis. pub struct ExtendedMetricsContest { - // Voted more candidates than the allowed amount per contest + /// Voted more candidates than the allowed amount per contest pub over_votes: u64, - // Voted less than the number of votes allowed for each contest. + /// Voted less than the number of votes allowed for each contest. pub under_votes: u64, - // Total actual marks count of candidates in the contest. Only counted UV and fully votes. + /// Total actual marks count of candidates in the contest. Only counted UV and fully votes. pub votes_actually: u64, - // Total expected marks for candidates if all votes were normal - // (no under-votes, no over-votes) (valid-ballots X number of - // votes possible in the contest) + /// Total expected marks for candidates if all votes were normal + /// (no under-votes, no over-votes) (valid-ballots X number of + /// votes possible in the contest) pub expected_votes: u64, - //Total counted ballots + /// Total counted ballots pub total_ballots: u64, - pub weight: Weight, // Used to store the actual weight used to tally an specific area. - pub total_weight: u64, // Used to calculate the right percentage_votes in aggregate + /// Used to store the actual weight used to tally an specific area. + pub weight: Weight, + /// Used to calculate the right `percentage_votes` in aggregate + pub total_weight: u64, } impl ExtendedMetricsContest { + /// Aggregates extended metrics from another contest result. + /// + /// Sums all vote counts and weights for comparison across multiple contests. + #[must_use] #[instrument(skip_all)] pub fn aggregate(&self, other: &ExtendedMetricsContest) -> ExtendedMetricsContest { - let mut result = self.clone(); - result.over_votes += other.over_votes; - result.under_votes += other.under_votes; - result.votes_actually += other.votes_actually; - result.expected_votes += other.expected_votes; - result.total_ballots += other.total_ballots; - result.total_weight += other.total_weight; + let mut result = *self; + result.over_votes = result.over_votes.saturating_add(other.over_votes); + result.under_votes = result.under_votes.saturating_add(other.under_votes); + result.votes_actually = result.votes_actually.saturating_add(other.votes_actually); + result.expected_votes = result.expected_votes.saturating_add(other.expected_votes); + result.total_ballots = result.total_ballots.saturating_add(other.total_ballots); + result.total_weight = result.total_weight.saturating_add(other.total_weight); result } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +/// Extended metrics for the entire election event across all contests. pub struct ExtendedMetricsElection { - // Number of valid ballots processed by the ACM without any - // single mark on all contests. + /// Number of valid ballots processed by the ACM without any + /// single mark on all contests. pub abstentions: u64, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +/// Complete tally results for a contest including vote counts and percentages. pub struct ContestResult { + /// Contest configuration and details. pub contest: Contest, + /// Total number of registered voters for this contest. pub census: u64, + /// Percentage of census votes. pub percentage_census: f64, + /// Number of auditable votes cast. pub auditable_votes: u64, + /// Percentage of auditable votes relative to census. pub percentage_auditable_votes: f64, + /// Total votes cast. pub total_votes: u64, + /// Percentage of total votes relative to census. pub percentage_total_votes: f64, + /// Total valid votes cast. pub total_valid_votes: u64, + /// Percentage of valid votes. pub percentage_total_valid_votes: f64, + /// Total invalid votes cast. pub total_invalid_votes: u64, + /// Percentage of invalid votes. pub percentage_total_invalid_votes: f64, + /// Total blank votes cast (no selection). pub total_blank_votes: u64, + /// Percentage of blank votes. pub percentage_total_blank_votes: f64, + /// Breakdown of explicit and implicit invalid votes. pub invalid_votes: InvalidVotes, + /// Percentage of explicitly invalid votes. pub percentage_invalid_votes_explicit: f64, + /// Percentage of implicitly invalid votes. pub percentage_invalid_votes_implicit: f64, + /// Vote counts and percentages for each candidate. pub candidate_result: Vec, + /// Extended metrics for detailed vote analysis. pub extended_metrics: Option, - pub process_results: Option, // The results from the counting algorithm process + /// Results from the counting algorithm process. + pub process_results: Option, } impl ContestResult { + /// Calculates percentages for all vote counts in the contest result. + /// + /// Computes vote percentages based on census and total vote counts, + /// and applies them to contest results. + #[must_use] #[instrument(skip_all)] + #[allow(clippy::cast_precision_loss)] pub fn calculate_percentages(&self) -> ContestResult { - let total_weight = self - .extended_metrics - .clone() - .unwrap_or_default() - .total_weight; + let total_weight = self.extended_metrics.unwrap_or_default().total_weight; let candidate_result: Vec = self .candidate_result .clone() @@ -593,19 +647,30 @@ impl ContestResult { contest_result } + /// Aggregates another contest result into this one. + /// + /// Combines vote counts and metrics from another contest result, + #[must_use] #[instrument(skip_all)] + #[allow(clippy::cast_precision_loss, clippy::arithmetic_side_effects)] pub fn aggregate(&self, other: &ContestResult, add_census: bool) -> ContestResult { let mut aggregate = self.clone(); if add_census { - aggregate.census += other.census; + aggregate.census = aggregate.census.saturating_add(other.census); } let aggregate_metrics = aggregate.extended_metrics.unwrap_or_default(); aggregate.extended_metrics = - Some(aggregate_metrics.aggregate(&other.extended_metrics.clone().unwrap_or_default())); - aggregate.total_votes += other.total_votes; - aggregate.total_valid_votes += other.total_valid_votes; - aggregate.total_invalid_votes += other.total_invalid_votes; - aggregate.total_blank_votes += other.total_blank_votes; + Some(aggregate_metrics.aggregate(&other.extended_metrics.unwrap_or_default())); + aggregate.total_votes = aggregate.total_votes.saturating_add(other.total_votes); + aggregate.total_valid_votes = aggregate + .total_valid_votes + .saturating_add(other.total_valid_votes); + aggregate.total_invalid_votes = aggregate + .total_invalid_votes + .saturating_add(other.total_invalid_votes); + aggregate.total_blank_votes = aggregate + .total_blank_votes + .saturating_add(other.total_blank_votes); aggregate.invalid_votes = aggregate.invalid_votes.aggregate(&other.invalid_votes); let mut candidate_map: HashMap = HashMap::new(); @@ -620,7 +685,11 @@ impl ContestResult { for candidate_result in &other.candidate_result { candidate_map .entry(candidate_result.candidate.id.clone()) - .and_modify(|entry| entry.total_count += candidate_result.total_count) + .and_modify(|entry| { + entry.total_count = entry + .total_count + .saturating_add(candidate_result.total_count); + }) .or_insert_with(|| candidate_result.clone()); } @@ -631,9 +700,13 @@ impl ContestResult { } #[derive(Debug, Clone, Serialize, Deserialize)] +/// Result for a single candidate containing vote counts and percentages. pub struct CandidateResult { + /// Candidate information including name and ID. pub candidate: Candidate, + /// Percentage of votes received by this candidate. pub percentage_votes: f64, + /// Total vote count for this candidate. pub total_count: u64, } diff --git a/packages/velvet/src/pipes/do_tally/error.rs b/packages/velvet/src/pipes/do_tally/error.rs index 4ac98fcb31c..1c0f89131ab 100644 --- a/packages/velvet/src/pipes/do_tally/error.rs +++ b/packages/velvet/src/pipes/do_tally/error.rs @@ -4,11 +4,15 @@ use std::error::Error as StdError; +/// Result type for tally operations. pub type Result> = std::result::Result; +/// Errors that can occur during tally operations. #[derive(Debug)] pub enum Error { + /// The tally type was not found for the contest. TallyTypeNotFound, + /// The tally type is not implemented. TallyTypeNotImplemented(String), } diff --git a/packages/velvet/src/pipes/do_tally/mod.rs b/packages/velvet/src/pipes/do_tally/mod.rs index d689c54a388..809533d20bc 100644 --- a/packages/velvet/src/pipes/do_tally/mod.rs +++ b/packages/velvet/src/pipes/do_tally/mod.rs @@ -2,9 +2,15 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Counting algorithms for tally operations. pub mod counting_algorithm; +/// Tally operation error types. mod error; +/// Tally operations and result aggregation. pub mod tally; +/// Tally processing and result aggregation. +#[allow(clippy::module_inception)] mod do_tally; + pub use do_tally::*; diff --git a/packages/velvet/src/pipes/do_tally/tally.rs b/packages/velvet/src/pipes/do_tally/tally.rs index 5108190d84d..fdb41dcfad1 100644 --- a/packages/velvet/src/pipes/do_tally/tally.rs +++ b/packages/velvet/src/pipes/do_tally/tally.rs @@ -24,18 +24,33 @@ use std::{fs, path::PathBuf}; use strum_macros::{Display, EnumString}; use tracing::instrument; +/// Tally data structure containing contest information and ballots for counting. +#[allow(clippy::struct_field_names)] pub struct Tally { + /// The counting algorithm type used for this tally. pub id: CountingAlgType, + /// The scope and operation being performed on this tally. pub scope_operation: ScopeOperation, + /// The contest being tallied. pub contest: Contest, + /// Decoded ballots with their weights. pub ballots: Vec<(DecodedVoteContest, Weight)>, + /// Total number of registered voters. pub census: u64, + /// Number of auditable votes. pub auditable_votes: u64, + /// Results from tally sheet processing. pub tally_sheet_results: Vec, + /// Final tally results. pub tally_results: Vec, } impl Tally { + /// Creates a new Tally instance for a contest. + /// + /// # Errors + /// + /// Returns an error if ballot loading or tally type retrieval fails. #[instrument(err, skip(contest, tally_results), name = "Tally::new")] pub fn new( contest: &Contest, @@ -63,6 +78,11 @@ impl Tally { }) } + /// Retrieves the counting algorithm type for a contest. + /// + /// # Errors + /// + /// Returns an error if the tally type is not found. #[instrument(err, skip_all)] fn get_tally_type(contest: &Contest) -> Result { contest @@ -70,6 +90,11 @@ impl Tally { .ok_or_else(|| Box::new(Error::TallyTypeNotFound) as Box) } + /// Loads and decodes ballots from the provided files. + /// + /// # Errors + /// + /// Returns an error if file reading or parsing fails. #[instrument(err, skip_all)] fn get_ballots(files: Vec<(PathBuf, Weight)>) -> Result> { let mut res = vec![]; @@ -88,13 +113,20 @@ impl Tally { .collect::>()) } + /// Aggregates tally result data from multiple tally operations. + /// + /// # Errors + /// + /// Returns an error if the tally results are empty. #[instrument(skip_all)] pub fn aggregate_results(&self) -> Result { if self.tally_results.is_empty() { return Err(CntAlgError::EmptyTallyResults); } - let mut contest_result = ContestResult::default(); - contest_result.contest = self.contest.clone(); + let mut contest_result = ContestResult { + contest: self.contest.clone(), + ..Default::default() + }; let aggregated = self .tally_results .iter() @@ -102,7 +134,13 @@ impl Tally { Ok(aggregated) } + /// Creates candidate result data structures from vote counts. + /// + /// # Errors + /// + /// Returns an error if candidate result creation fails. #[instrument(err, skip_all)] + #[allow(clippy::too_many_arguments, clippy::cast_precision_loss)] pub fn create_candidate_results( &self, vote_count: HashMap, @@ -213,7 +251,13 @@ impl Tally { Ok(candidate_result) } + /// Creates the final contest result from counts and metrics. + /// + /// # Errors + /// + /// Returns an error if contest result creation fails. #[instrument(err, skip_all)] + #[allow(clippy::too_many_arguments, clippy::cast_precision_loss)] pub fn create_contest_result( &self, process_results: Option, @@ -228,7 +272,7 @@ impl Tally { let contest = &self.contest; // Calculate percentages - let total_votes = count_valid + count_invalid; + let total_votes = count_valid.saturating_add(count_invalid); let total_votes_base = cmp::max(1, total_votes) as f64; let census_base = cmp::max(1, self.census) as f64; @@ -249,7 +293,7 @@ impl Tally { percentage_census: 100.0, auditable_votes: self.auditable_votes, percentage_auditable_votes: percentage_auditable_votes.clamp(0.0, 100.0), - total_votes: total_votes, + total_votes, percentage_total_votes: percentage_total_votes.clamp(0.0, 100.0), total_valid_votes: count_valid, percentage_total_valid_votes: percentage_total_valid_votes.clamp(0.0, 100.0), @@ -268,21 +312,28 @@ impl Tally { } } +/// Processes a tally sheet and creates a contest result. +/// +/// # Errors +/// +/// Returns an error if tally sheet processing fails. #[instrument(err, skip_all)] pub fn process_tally_sheet(tally_sheet: &TallySheet, contest: &Contest) -> Result { - let Some(content) = tally_sheet.content.clone() else { + let Some(tally_sheet_content) = tally_sheet.content.clone() else { return Err("missing tally sheet content".into()); }; - let invalid_votes = content.invalid_votes.unwrap_or(Default::default()); + let invalid_votes = tally_sheet_content.invalid_votes.unwrap_or_default(); let count_invalid_votes = InvalidVotes { explicit: invalid_votes.explicit_invalid.unwrap_or(0), implicit: invalid_votes.implicit_invalid.unwrap_or(0), }; - let count_invalid: u64 = count_invalid_votes.explicit + count_invalid_votes.implicit; - let count_blank: u64 = content.total_blank_votes.unwrap_or(0); + let count_invalid: u64 = count_invalid_votes + .explicit + .saturating_add(count_invalid_votes.implicit); + let count_blank: u64 = tally_sheet_content.total_blank_votes.unwrap_or(0); - let candidate_results = content + let candidate_results = tally_sheet_content .candidate_results .values() .map(|candidate| -> Result { @@ -307,15 +358,15 @@ pub fn process_tally_sheet(tally_sheet: &TallySheet, contest: &Contest) -> Resul .map(|candidate_result| candidate_result.total_count) .sum(); - let total_votes = count_valid + count_invalid; + let total_votes = count_valid.saturating_add(count_invalid); let contest_result = ContestResult { contest: contest.clone(), - census: content.census.unwrap_or(0), + census: tally_sheet_content.census.unwrap_or(0), percentage_census: 100.0, auditable_votes: 0, percentage_auditable_votes: 0.0, - total_votes: total_votes, + total_votes, percentage_total_votes: 0.0, total_valid_votes: count_valid, percentage_total_valid_votes: 0.0, @@ -334,6 +385,11 @@ pub fn process_tally_sheet(tally_sheet: &TallySheet, contest: &Contest) -> Resul } #[instrument(err, skip_all)] +/// Creates a tally instance for a contest and the associated counting algorithm. +/// +/// # Errors +/// +/// Returns an error if the tally cannot be created. pub fn create_tally( contest: &Contest, scope_operation: ScopeOperation, @@ -348,15 +404,15 @@ pub fn create_tally( .filter(|(f, _weight)| { let exist = f.exists(); if !exist { - println!( + tracing::warn!( "[{}] File not found: {} -- Not processed", PipeName::DoTally.as_ref(), f.display() - ) + ); } exist }) - .map(|(p, weight)| (PathBuf::from(p.as_path()), weight.clone())) + .map(|(p, weight)| (PathBuf::from(p.as_path()), *weight)) .collect(); let tally = Tally::new( diff --git a/packages/velvet/src/pipes/error.rs b/packages/velvet/src/pipes/error.rs index e79f6618528..04e06312fab 100644 --- a/packages/velvet/src/pipes/error.rs +++ b/packages/velvet/src/pipes/error.rs @@ -4,18 +4,29 @@ use uuid::Uuid; +/// Result type alias for pipe operations. pub type Result = std::result::Result; +/// Errors that can occur during pipe operations. #[derive(Debug)] pub enum Error { + /// An element ID was not found. IDNotFound, + /// Election configuration could not be found for the given election ID. ElectionConfigNotFound(Uuid), + /// Contest configuration could not be found for the given contest ID. ContestConfigNotFound(Uuid), + /// Area configuration could not be found for the given area ID. AreaConfigNotFound(Uuid), + /// File system access error with path and underlying I/O error. FileAccess(std::path::PathBuf, std::io::Error), + /// Generic I/O error. IO(std::io::Error), + /// JSON deserialization or parsing error. JsonParse(serde_json::Error), - UnexpectedError(String), + /// Unexpected error with a custom message. + Unexpected(String), + /// Wrapper for anyhow errors. Anyhow(anyhow::Error), } diff --git a/packages/velvet/src/pipes/generate_db/generate_db.rs b/packages/velvet/src/pipes/generate_db/generate_db.rs index c73c008efb8..1474ff9c6b9 100644 --- a/packages/velvet/src/pipes/generate_db/generate_db.rs +++ b/packages/velvet/src/pipes/generate_db/generate_db.rs @@ -42,31 +42,43 @@ use rusqlite::Transaction as SqliteTransaction; use serde::{Deserialize, Serialize}; +/// Configuration for `SQLite` database generation pipeline. #[derive(Serialize, Deserialize, Debug, Default)] pub struct PipeConfigGenerateDatabase { + /// Whether to include decoded ballots in the database. pub include_decoded_ballots: bool, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Output filename for the database. pub database_filename: String, } impl PipeConfigGenerateDatabase { + /// Creates a new database generation configuration with default values. #[instrument(skip_all, name = "PipeConfigGenerateDatabase::new")] pub fn new() -> Self { Self::default() } } +/// Default filename for the generated election database. pub const DATABASE_FILENAME: &str = "results.db"; +/// Database generation pipe implementation. #[derive(Debug)] pub struct GenerateDatabase { + /// Pipeline input configuration. pub pipe_inputs: PipeInputs, + /// Base input directory for results. pub input_dir: PathBuf, + /// Base output directory for the generated database. pub output_dir: PathBuf, } impl GenerateDatabase { + /// Creates a new database generation pipe instance. #[instrument(skip_all, name = "GenerateDatabase::new")] pub fn new(pipe_inputs: PipeInputs) -> Self { let input_dir = pipe_inputs @@ -87,6 +99,11 @@ impl GenerateDatabase { } } + /// Retrieves the pipe configuration for database generation. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be parsed. #[instrument(skip_all)] pub fn get_config(&self) -> Result { let pipe_config: PipeConfigGenerateDatabase = self @@ -94,7 +111,7 @@ impl GenerateDatabase { .stage .pipe_config(self.pipe_inputs.stage.current_pipe) .and_then(|pc| pc.config) - .map(|value| serde_json::from_value(value)) + .map(serde_json::from_value) .transpose()? .unwrap_or_default(); Ok(pipe_config) @@ -126,6 +143,17 @@ impl Pipe for GenerateDatabase { } } +/// Converts unsigned result totals to `i64` for SQLite; saturates at `i64::MAX` if out of range. +#[inline] +fn result_count_u64_as_i64(n: u64) -> i64 { + i64::try_from(n).unwrap_or(i64::MAX) +} + +/// Populates the database result tables from elections report data. +/// +/// # Errors +/// +/// Returns an error if database operations fail. #[instrument(skip(state_opt, config))] pub fn populate_results_tables( input_database_path: &Path, @@ -141,12 +169,12 @@ pub fn populate_results_tables( let _ = tokio::task::block_in_place(|| -> anyhow::Result { let process_result = rt.block_on(async { // Make sure the directory exists - fs::create_dir_all(&output_database_path)?; + fs::create_dir_all(output_database_path)?; if fs::exists(&input_database_path)? { fs::copy(input_database_path, &database_path).map_err(|error| anyhow!("Could not copy file: {error}"))?; } else { - warn!("No input database found. A new database will be created only with result tables.") + warn!("No input database found. A new database will be created only with result tables."); } let mut sqlite_connection = Connection::open(&database_path) @@ -162,7 +190,7 @@ pub fn populate_results_tables( process_decoded_ballots( &sqlite_transaction, &decoded_ballots_path, - ).await?; + )?; } let result = process_results_tables( @@ -170,8 +198,7 @@ pub fn populate_results_tables( &config.tenant_id, &config.election_event_id, &sqlite_transaction, - ) - .await; + ); sqlite_transaction .commit() @@ -194,7 +221,7 @@ pub fn populate_results_tables( /// (e.g., `election__/contest__/area__/decoded_ballots.json`). /// /// The content of each `decoded_ballots.json` file is then read and inserted -/// into the `ballot` table in the provided SQLite transaction. +/// into the `ballot` table in the provided `SQLite` transaction. /// /// The `ballot` table is created if it does not already exist with the schema: /// `(election_id TEXT NOT NULL, contest_id TEXT NOT NULL, area_id TEXT NOT NULL, decoded_ballot_json BLOB, PRIMARY KEY (election_id, contest_id, area_id))` @@ -208,8 +235,12 @@ pub fn populate_results_tables( /// /// # Returns /// `Result<()>` - Returns `Ok(())` on success, or an `anyhow::Error` if any operation fails. +/// +/// # Errors +/// +/// Returns an error if file reading or database operations fail. #[instrument(skip(sqlite_transaction))] -pub async fn process_decoded_ballots( +pub fn process_decoded_ballots( sqlite_transaction: &SqliteTransaction<'_>, decoded_ballots_path: &Path, ) -> anyhow::Result<()> { @@ -233,7 +264,7 @@ pub async fn process_decoded_ballots( // WalkDir provides an iterator that recursively goes through directories. for entry in WalkDir::new(decoded_ballots_path) .into_iter() - .filter_map(|e| e.ok()) + .filter_map(Result::ok) // Filter out any errors during directory traversal { let path = entry.path(); @@ -242,20 +273,20 @@ pub async fn process_decoded_ballots( if path.is_file() && path .file_name() - .map_or(false, |name| name == "decoded_ballots.json") + .is_some_and(|name| name == "decoded_ballots.json") { // A 'decoded_ballots.json' file has been found. - tracing::info!("Found decoded_ballots.json at: {:?}", path); + tracing::info!("Found decoded_ballots.json at: {path:?}"); // Extract the election, contest, and area IDs from the file's path. // This helper function parses the directory names to get the required IDs. let (election_id, contest_id, area_id) = extract_ids_from_path(path) - .with_context(|| format!("Failed to extract IDs from path: {:?}", path))?; + .with_context(|| format!("Failed to extract IDs from path: {}", path.display()))?; // Read the entire content of the decoded_ballots.json file asynchronously. // The content will be stored as a byte vector (Vec), suitable for BLOB. let decoded_ballot_json_content = fs::read(path) - .with_context(|| format!("Failed to read file content: {:?}", path))?; + .with_context(|| format!("Failed to read file content: {}", path.display()))?; // Insert or replace the data into the 'ballot' table. // `INSERT OR REPLACE` is used to handle cases where the same ballot might be @@ -269,15 +300,11 @@ pub async fn process_decoded_ballots( decoded_ballot_json_content // Parameter for decoded_ballot_json BLOB ], ).with_context(|| format!( - "Failed to insert/replace ballot data for election: {}, contest: {}, area: {}", - election_id, contest_id, area_id + "Failed to insert/replace ballot data for election: {election_id}, contest: {contest_id}, area: {area_id}" ))?; tracing::info!( - "Successfully processed ballot for election: {}, contest: {}, area: {}", - election_id, - contest_id, - area_id + "Successfully processed ballot for election: {election_id}, contest: {contest_id}, area: {area_id}" ); } } @@ -295,7 +322,7 @@ pub async fn process_decoded_ballots( /// * `path` - A reference to the `Path` from which to extract IDs. /// /// # Returns -/// `Result<(String, String, String)>` - A tuple containing (election_id, contest_id, area_id) +/// `Result<(String, String, String)>` - A tuple containing (`election_id`, `contest_id`, `area_id`) /// as Strings on success, or an `anyhow::Error` if any /// of the required IDs cannot be found. #[instrument(skip_all)] @@ -319,21 +346,27 @@ fn extract_ids_from_path(path: &Path) -> anyhow::Result<(String, String, String) match (election_id, contest_id, area_id) { (Some(e), Some(c), Some(a)) => Ok((e, c, a)), _ => Err(anyhow::anyhow!( - "Could not extract all required IDs (election, contest, area) from path: {:?}", - path + "Could not extract all required IDs (election, contest, area) from path: {}", + path.display() )), } } #[instrument(skip_all)] -pub async fn process_results_tables( +/// Processes the election event result tables and inserts them into the database. +/// +/// # Errors +/// +/// Returns an error if database operations fail. +#[instrument(err, skip_all)] +pub fn process_results_tables( results: Vec, tenant_id: &str, election_event_id: &str, sqlite_transaction: &SqliteTransaction<'_>, ) -> Result { let results_event_id = - generate_results_id_if_necessary(sqlite_transaction, tenant_id, election_event_id).await?; + generate_results_id_if_necessary(sqlite_transaction, tenant_id, election_event_id)?; save_results( sqlite_transaction, @@ -341,14 +374,19 @@ pub async fn process_results_tables( tenant_id, election_event_id, &results_event_id, - ) - .await?; + )?; Ok(results_event_id) } #[instrument(skip_all)] -pub async fn generate_results_id_if_necessary( +/// Generates or retrieves the results ID for the election event. +/// +/// # Errors +/// +/// Returns an error if database operations fail. +#[instrument(err, skip_all)] +pub fn generate_results_id_if_necessary( sqlite_transaction: &SqliteTransaction<'_>, tenant_id: &str, election_event_id: &str, @@ -361,13 +399,24 @@ pub async fn generate_results_id_if_necessary( election_event_id, &results_event_id, ) - .await .context("Failed to create results event table")?; Ok(results_event_id) } #[instrument(skip_all)] -pub async fn save_results( +/// Saves the finalized election results to the database. +/// +/// # Errors +/// +/// Returns an error if database operations fail. +/// +/// # Panics +/// +/// May panic if `contest_result` is None for a contest in the results. +#[instrument(err, skip_all)] +#[allow(clippy::too_many_lines)] +#[allow(clippy::cast_precision_loss)] +pub fn save_results( sqlite_transaction: &SqliteTransaction<'_>, results: Vec, tenant_id: &str, @@ -380,7 +429,7 @@ pub async fn save_results( let mut results_contest_candidates: Vec = Vec::new(); let mut results_area_contest_candidates: Vec = Vec::new(); for election in &results { - let total_voters_percent: f64 = + let election_total_voters_percent: f64 = (election.total_votes as f64) / (cmp::max(election.census, 1) as f64); results_elections.push(ResultsElection { id: Uuid::new_v4().into(), @@ -389,13 +438,13 @@ pub async fn save_results( election_id: election.election_id.clone(), results_event_id: results_event_id.into(), name: None, - elegible_census: Some(election.census as i64), - total_voters: Some(election.total_votes as i64), + elegible_census: Some(election.census.cast_signed()), + total_voters: Some(election.total_votes.cast_signed()), created_at: None, last_updated_at: None, labels: None, annotations: None, - total_voters_percent: Some(total_voters_percent.clamp(0.0, 1.0).try_into()?), + total_voters_percent: Some(election_total_voters_percent.clamp(0.0, 1.0).try_into()?), documents: None, }); @@ -403,8 +452,11 @@ pub async fn save_results( if contest.contest_result.is_none() || contest.contest.is_none() { continue; } - let contest_result = contest.contest_result.clone().unwrap(); - let current_contest = contest.contest.clone().unwrap(); + let contest_result = contest + .contest_result + .clone() + .expect("contest_result should be present"); + let current_contest = contest.contest.clone().expect("contest should be present"); let total_votes_percent: f64 = contest_result.percentage_total_votes / 100.0; let auditable_votes_percent: f64 = contest_result.percentage_auditable_votes / 100.0; @@ -419,17 +471,22 @@ pub async fn save_results( let total_blank_votes_percent: f64 = contest_result.percentage_total_blank_votes / 100.0; - let contest_result_ext_metrics = - contest_result.extended_metrics.clone().unwrap_or_default(); - let extended_metrics_value = serde_json::to_value(contest_result_ext_metrics.clone()) + let contest_result_ext_metrics = contest_result.extended_metrics.unwrap_or_default(); + let extended_metrics_value = serde_json::to_value(contest_result_ext_metrics) .expect("Failed to convert to JSON"); let votes_base: f64 = cmp::max(contest_result_ext_metrics.total_weight, 1) as f64; let mut annotations = json!({}); - annotations[EXTENDED_METRICS] = extended_metrics_value; + annotations + .as_object_mut() + .expect("annotations must be an object") + .insert(EXTENDED_METRICS.to_string(), extended_metrics_value); + if let Some(process_results) = contest_result.process_results.clone() { - annotations[PROCESS_RESULTS] = process_results; + annotations + .as_object_mut() + .expect("annotations must be an object") + .insert(PROCESS_RESULTS.to_string(), process_results); } - if let Some(area) = &contest.area { results_area_contests.push(ResultsAreaContest { id: Uuid::new_v4().into(), @@ -439,30 +496,40 @@ pub async fn save_results( contest_id: current_contest.id.clone(), area_id: area.id.clone(), results_event_id: results_event_id.into(), - elegible_census: Some(contest_result.census as i64), - total_votes: Some(contest_result.total_votes as i64), + elegible_census: Some(result_count_u64_as_i64(contest_result.census)), + total_votes: Some(result_count_u64_as_i64(contest_result.total_votes)), total_votes_percent: Some(total_votes_percent.clamp(0.0, 1.0).try_into()?), - total_auditable_votes: Some(contest_result.auditable_votes as i64), + total_auditable_votes: Some(result_count_u64_as_i64( + contest_result.auditable_votes, + )), total_auditable_votes_percent: Some( auditable_votes_percent.clamp(0.0, 1.0).try_into()?, ), - total_valid_votes: Some(contest_result.total_valid_votes as i64), + total_valid_votes: Some(result_count_u64_as_i64( + contest_result.total_valid_votes, + )), total_valid_votes_percent: Some( total_valid_votes_percent.clamp(0.0, 1.0).try_into()?, ), - total_invalid_votes: Some(contest_result.total_invalid_votes as i64), + total_invalid_votes: Some(result_count_u64_as_i64( + contest_result.total_invalid_votes, + )), total_invalid_votes_percent: Some( total_invalid_votes_percent.clamp(0.0, 1.0).try_into()?, ), - explicit_invalid_votes: Some(contest_result.invalid_votes.explicit as i64), + explicit_invalid_votes: Some(result_count_u64_as_i64( + contest_result.invalid_votes.explicit, + )), explicit_invalid_votes_percent: Some( explicit_invalid_votes_percent.clamp(0.0, 1.0).try_into()?, ), - implicit_invalid_votes: Some(contest_result.invalid_votes.implicit as i64), + implicit_invalid_votes: Some(result_count_u64_as_i64( + contest_result.invalid_votes.implicit, + )), implicit_invalid_votes_percent: Some( implicit_invalid_votes_percent.clamp(0.0, 1.0).try_into()?, ), - blank_votes: Some(contest_result.total_blank_votes as i64), + blank_votes: Some(result_count_u64_as_i64(contest_result.total_blank_votes)), blank_votes_percent: Some( total_blank_votes_percent.clamp(0.0, 1.0).try_into()?, ), @@ -484,9 +551,11 @@ pub async fn save_results( candidate_id: candidate.candidate.id.clone(), results_event_id: results_event_id.into(), area_id: area.id.clone(), - cast_votes: Some(candidate.total_count as i64), + cast_votes: Some(result_count_u64_as_i64(candidate.total_count)), cast_votes_percent: Some(cast_votes_percent.clamp(0.0, 1.0).try_into()?), - winning_position: candidate.winning_position.map(|val| val as i64), + winning_position: candidate + .winning_position + .map(|val| result_count_u64_as_i64(val as u64)), points: None, created_at: None, last_updated_at: None, @@ -503,11 +572,17 @@ pub async fn save_results( election_id: election.election_id.clone(), contest_id: current_contest.id.clone(), results_event_id: results_event_id.into(), - elegible_census: Some(contest_result.census as i64), - total_valid_votes: Some(contest_result.total_valid_votes as i64), - explicit_invalid_votes: Some(contest_result.invalid_votes.explicit as i64), - implicit_invalid_votes: Some(contest_result.invalid_votes.implicit as i64), - blank_votes: Some(contest_result.total_blank_votes as i64), + elegible_census: Some(result_count_u64_as_i64(contest_result.census)), + total_valid_votes: Some(result_count_u64_as_i64( + contest_result.total_valid_votes, + )), + explicit_invalid_votes: Some(result_count_u64_as_i64( + contest_result.invalid_votes.explicit, + )), + implicit_invalid_votes: Some(result_count_u64_as_i64( + contest_result.invalid_votes.implicit, + )), + blank_votes: Some(result_count_u64_as_i64(contest_result.total_blank_votes)), voting_type: current_contest.voting_type.clone(), counting_algorithm: Some( current_contest @@ -520,7 +595,9 @@ pub async fn save_results( last_updated_at: None, labels: None, annotations: Some(annotations), - total_invalid_votes: Some(contest_result.total_invalid_votes as i64), + total_invalid_votes: Some(result_count_u64_as_i64( + contest_result.total_invalid_votes, + )), total_invalid_votes_percent: Some( total_invalid_votes_percent.clamp(0.0, 1.0).try_into()?, ), @@ -536,10 +613,12 @@ pub async fn save_results( blank_votes_percent: Some( total_blank_votes_percent.clamp(0.0, 1.0).try_into()?, ), - total_votes: Some(contest_result.total_votes as i64), + total_votes: Some(result_count_u64_as_i64(contest_result.total_votes)), total_votes_percent: Some(total_votes_percent.clamp(0.0, 1.0).try_into()?), documents: None, - total_auditable_votes: Some(contest_result.auditable_votes as i64), + total_auditable_votes: Some(result_count_u64_as_i64( + contest_result.auditable_votes, + )), total_auditable_votes_percent: Some( auditable_votes_percent.clamp(0.0, 1.0).try_into()?, ), @@ -555,8 +634,10 @@ pub async fn save_results( contest_id: current_contest.id.clone(), candidate_id: candidate.candidate.id.clone(), results_event_id: results_event_id.into(), - cast_votes: Some(candidate.total_count as i64), - winning_position: candidate.winning_position.map(|val| val as i64), + cast_votes: Some(result_count_u64_as_i64(candidate.total_count)), + winning_position: candidate + .winning_position + .map(|val| result_count_u64_as_i64(val as u64)), points: None, created_at: None, last_updated_at: None, @@ -570,20 +651,18 @@ pub async fn save_results( } } - create_results_contest_sqlite(sqlite_transaction, results_contests).await?; + create_results_contest_sqlite(sqlite_transaction, results_contests)?; - create_results_area_contests_sqlite(sqlite_transaction, results_area_contests).await?; + create_results_area_contests_sqlite(sqlite_transaction, results_area_contests)?; - create_results_election_sqlite(sqlite_transaction, results_elections).await?; + create_results_election_sqlite(sqlite_transaction, results_elections)?; - create_results_contest_candidates_sqlite(sqlite_transaction, results_contest_candidates) - .await?; + create_results_contest_candidates_sqlite(sqlite_transaction, results_contest_candidates)?; create_results_area_contest_candidates_sqlite( sqlite_transaction, results_area_contest_candidates, - ) - .await?; + )?; Ok(()) } diff --git a/packages/velvet/src/pipes/generate_db/mod.rs b/packages/velvet/src/pipes/generate_db/mod.rs index 7139bb39d73..efeec52d6d5 100644 --- a/packages/velvet/src/pipes/generate_db/mod.rs +++ b/packages/velvet/src/pipes/generate_db/mod.rs @@ -2,5 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// `SQLite` Database generation pipe for processing election results. +#[allow(clippy::module_inception)] mod generate_db; pub use generate_db::*; diff --git a/packages/velvet/src/pipes/generate_reports/generate_reports.rs b/packages/velvet/src/pipes/generate_reports/generate_reports.rs index 57856225420..074dc196b40 100644 --- a/packages/velvet/src/pipes/generate_reports/generate_reports.rs +++ b/packages/velvet/src/pipes/generate_reports/generate_reports.rs @@ -49,34 +49,52 @@ use crate::{ utils::parse_file, }; +/// Output filename for PDF reports. pub const OUTPUT_PDF: &str = "report.pdf"; +/// Output filename for HTML reports. pub const OUTPUT_HTML: &str = "report.html"; +/// Output filename for JSON reports. pub const OUTPUT_JSON: &str = "report.json"; +/// Output filename for all areas JSON results. pub const OUTPUT_ALL_AREAS_JSON: &str = "all_areas_results.json"; +/// Output filename for all areas HTML results. pub const OUTPUT_ALL_AREAS_HTML: &str = "all_areas_results.html"; +/// Chunk size for parallel processing of reports. pub const PARALLEL_CHUNK_SIZE: usize = 8; +/// Pipe for generating election reports in multiple formats. #[derive(Debug)] pub struct GenerateReports { + /// Pipe input configuration containing election and file paths. pub pipe_inputs: PipeInputs, + /// Input directory containing report data. pub input_dir: PathBuf, + /// Output directory for generated reports. pub output_dir: PathBuf, } +/// Contains the byte contents of generated reports in different formats. pub struct GeneratedReportsBytes { - bytes_pdf: Option>, - bytes_html: Vec, - bytes_json: Vec, + /// PDF report content (optional). + pub pdf: Option>, + /// HTML report content. + pub html: Vec, + /// JSON report content. + pub json: Vec, } +/// Data structure containing template variables for report generation. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TemplateData { + /// Annotations from extra data to be used in the report template. pub execution_annotations: HashMap, + /// List of computed reports to include in the template. pub reports: Vec, } impl GenerateReports { #[instrument(skip_all, name = "GenerateReports::new")] + /// Creates a new report generator with the given pipe inputs. pub fn new(pipe_inputs: PipeInputs) -> Self { let input_dir = pipe_inputs .cli @@ -96,21 +114,31 @@ impl GenerateReports { } } #[instrument(skip_all)] + /// Retrieves the report generation configuration. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be parsed. pub fn get_config(&self) -> Result { let pipe_config: PipeConfigGenerateReports = self .pipe_inputs .stage .pipe_config(self.pipe_inputs.stage.current_pipe) .and_then(|pc| pc.config) - .map(|value| serde_json::from_value(value)) + .map(serde_json::from_value) .transpose()? .unwrap_or_default(); Ok(pipe_config) } + /// Computes and formats reports for rendering. + /// + /// # Errors + /// + /// Returns an error if report computation fails. #[instrument(err, skip_all)] + #[allow(clippy::too_many_lines)] pub fn compute_reports( - &self, reports: Vec, areas_map: &HashMap, is_consolidated: bool, @@ -123,15 +151,14 @@ impl GenerateReports { let area_annotations: HashMap = report .area .clone() - .map(|area| { + .and_then(|area| { areas_map .get(&area.id) .cloned() .map(|area| area.annotations) }) .flatten() - .flatten() - .map(|annotations| deserialize_value::>(annotations)) + .map(deserialize_value::>) .transpose() .unwrap_or(Some(default_area_annotations.clone())) .unwrap_or(default_area_annotations.clone()); @@ -151,9 +178,7 @@ impl GenerateReports { let mut contest_result_opt = report.contest_result.clone(); let mut candidate_result = vec![]; - if (contest_result_opt.is_some()) { - let mut contest_result = contest_result_opt.clone().unwrap(); - + if let Some(mut contest_result) = contest_result_opt.take() { contest_result.contest.name = contest_result.contest.name.as_ref().map(|name| { name.split('/') @@ -163,29 +188,26 @@ impl GenerateReports { .to_string() }); - sort_candidates( - &mut contest_result.candidate_result, - contest_result - .contest - .presentation - .clone() - .unwrap_or_default() - .candidates_order - .unwrap_or_default(), - ); + let candidates_order = contest_result + .contest + .presentation + .clone() + .unwrap_or_default() + .candidates_order + .unwrap_or_default(); + + sort_candidates(&mut contest_result.candidate_result, &candidates_order); // And we will sort the candidates in candidate_result by // winning position candidate_result = contest_result .candidate_result .iter() - .map(|candidate_result| CandidateResultForReport { - candidate: candidate_result.candidate.clone(), - total_count: candidate_result.total_count, - percentage_votes: candidate_result.percentage_votes, - winning_position: map_winners - .get(&candidate_result.candidate.id) - .cloned(), + .map(|cand_result| CandidateResultForReport { + candidate: cand_result.candidate.clone(), + total_count: cand_result.total_count, + percentage_votes: cand_result.percentage_votes, + winning_position: map_winners.get(&cand_result.candidate.id).copied(), }) .collect(); @@ -198,7 +220,7 @@ impl GenerateReports { .map(|contest_report_config| { deserialize_str(contest_report_config) .map_err(|err| { - warn!("Error deserializing contest_report_config: {err:?}") + warn!("Error deserializing contest_report_config: {err:?}"); }) .unwrap_or_default() }) @@ -215,7 +237,7 @@ impl GenerateReports { .then_with(|| a.candidate.name.cmp(&b.candidate.name)) }); } - }; + } contest_result_opt = Some(contest_result); } @@ -275,6 +297,15 @@ impl GenerateReports { } #[instrument(err, skip_all)] + /// Generates reports in PDF, HTML, and JSON formats. + /// + /// # Errors + /// + /// Returns an error if report generation fails. + /// + /// # Panics + /// + /// May panic if Tokio runtime creation fails. pub fn generate_report( &self, reports: Vec, @@ -287,7 +318,7 @@ impl GenerateReports { let mut execution_annotations = config.execution_annotations; let computed_reports = - self.compute_reports(reports.clone(), areas_map, is_consolidated.clone())?; + GenerateReports::compute_reports(reports.clone(), areas_map, is_consolidated)?; let template_data = TemplateData { execution_annotations: execution_annotations.clone(), reports: computed_reports.clone(), @@ -301,22 +332,22 @@ impl GenerateReports { election_hash } else { hash_b64(&bytes_json).map_err(|err| { - Error::UnexpectedError(format!("Error hashing the results file: {err:?}")) + Error::Unexpected(format!("Error hashing the results file: {err:?}")) })? }; // Insert the results_hash into the execution_annotations and re-render the template for both PDF and HTML execution_annotations.insert("results_hash".to_string(), results_hash.clone()); - let template_data = TemplateData { + let template_data_with_hash = TemplateData { execution_annotations, reports: computed_reports, }; - let template_vars = template_data + let template_vars = template_data_with_hash .clone() .to_map() // TODO: Fix neededing to do a Map Err - .map_err(|err| Error::UnexpectedError(format!("serialization error: {err:?}")))?; + .map_err(|err| Error::Unexpected(format!("serialization error: {err:?}")))?; let mut template_map = HashMap::new(); let report_base_html = include_str!("../../resources/report_base_html.hbs"); @@ -334,9 +365,8 @@ impl GenerateReports { template_vars.clone(), ) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; @@ -355,9 +385,8 @@ impl GenerateReports { let render_html = reports::render_template_text(&config.system_template, template_system_vars.clone()) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; @@ -365,9 +394,8 @@ impl GenerateReports { let render_pdf_user: String = reports::render_template("report_base_pdf", template_map, template_vars.clone()) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; @@ -379,9 +407,8 @@ impl GenerateReports { let render_pdf = reports::render_template_text(&config.system_template, template_system_vars) .map_err(|e| { - Error::UnexpectedError(format!( - "Error during render_template_text from report.hbs template file: {}", - e + Error::Unexpected(format!( + "Error during render_template_text from report.hbs template file: {e}" )) })?; @@ -390,10 +417,11 @@ impl GenerateReports { .map(|val| Some(val.to_print_to_pdf_options())) .unwrap_or_default(); - let rt = tokio::runtime::Runtime::new().unwrap(); + let _rt = tokio::runtime::Runtime::new() + .map_err(|e| Error::Unexpected(format!("Failed to create Tokio runtime: {e}")))?; let bytes_pdf = pdf::sync::PdfRenderer::render_pdf(render_pdf, pdf_options).map_err(|e| { - Error::UnexpectedError(format!("Error during html_to_pdf conversion: {}", e)) + Error::Unexpected(format!("Error during html_to_pdf conversion: {e}")) })?; Some(bytes_pdf) @@ -402,14 +430,17 @@ impl GenerateReports { }; let generated_report_bytes = GeneratedReportsBytes { - bytes_pdf: bytes_pdf, - bytes_html: render_html.as_bytes().to_vec(), - bytes_json: bytes_json, + pdf: bytes_pdf, + html: render_html.as_bytes().to_vec(), + json: bytes_json, }; Ok((generated_report_bytes, results_hash)) } + /// Checks if an aggregate result exists for the given `election/contest/area path`. + /// + /// Returns true if the aggregate folder exists and contains a contest result file. #[instrument(skip(self))] pub fn has_aggregate( &self, @@ -426,13 +457,18 @@ impl GenerateReports { .join(PipeNameOutputDir::DoTally.as_ref()), election_id, contest_id, - Some(area_id.clone()).as_ref(), + Some(*area_id).as_ref(), ); let aggregate_path = base_path.join(OUTPUT_CONTEST_RESULT_AREA_CHILDREN_AGGREGATE_FOLDER); aggregate_path.exists() && aggregate_path.is_dir() } #[instrument(err, skip(self))] + /// Reads the contest result for an election and contest. + /// + /// # Errors + /// + /// Returns an error if the contest result cannot be read. fn read_contest_result( &self, election_id: &Uuid, @@ -469,6 +505,11 @@ impl GenerateReports { Ok(contest_result) } + /// Reads the winners for a contest in an election. + /// + /// # Errors + /// + /// Returns an error if the winners file cannot be read or parsed. #[instrument(err, skip(self))] fn read_winners( &self, @@ -507,6 +548,11 @@ impl GenerateReports { Ok(res) } + /// Reads and parses all computed reports from storage. + /// + /// # Errors + /// + /// Returns an error if reports cannot be read or parsed. #[instrument(err, skip(self))] pub fn read_reports(&self) -> Result> { let mut election_reports: Vec = vec![]; @@ -554,7 +600,7 @@ impl GenerateReports { }); for area in &contest_input.area_list { - let contest_result = self.read_contest_result( + let area_contest_result = self.read_contest_result( &election_input.id, Some(&contest_input.id), Some(&area.id), @@ -562,7 +608,7 @@ impl GenerateReports { None, )?; - let winners = self.read_winners( + let area_winners = self.read_winners( &election_input.id, Some(&contest_input.id), Some(&area.id), @@ -583,19 +629,19 @@ impl GenerateReports { .election_event_annotations .clone(), contest: Some(contest_input.contest.clone()), - contest_result: Some(contest_result), + contest_result: Some(area_contest_result), area: Some(BasicArea { id: area.id.to_string(), name: area.area.name.clone(), }), - winners, + winners: area_winners, channel_type: None, election_results: None, }); } } - let computed_reports = self.compute_reports(reports, &areas_map, false)?; + let computed_reports = GenerateReports::compute_reports(reports, &areas_map, false)?; election_reports.push(ElectionReportDataComputed { election_id: election_input.id.clone().to_string(), @@ -608,7 +654,13 @@ impl GenerateReports { Ok(election_reports) } + /// Reads report breakdowns for an election and contest by channel type. + /// + /// # Errors + /// + /// Returns an error if report files cannot be read or parsed. #[instrument(err, skip_all)] + #[allow(clippy::too_many_arguments, clippy::ref_option)] fn read_breakdowns( &self, election_id: &Uuid, @@ -681,7 +733,7 @@ impl GenerateReports { .map_err(|e| Error::FileAccess(contest_results_file_path.clone(), e))?; let contest_result: ContestResult = parse_file(contest_results_file)?; - let subfolder_name = subfolder.file_name().unwrap(); + let subfolder_name = subfolder.file_name().expect("subfolder name should exist"); let winners_subfolder = winners_base_breakdown_path.join(subfolder_name); let winners_file_path = winners_subfolder.join(OUTPUT_WINNERS); let winners_file = fs::File::open(&winners_file_path) @@ -710,6 +762,15 @@ impl GenerateReports { Ok(reports) } + /// Generates a report for a contest in an election. + /// + /// # Errors + /// + /// Returns an error if reports cannot be generated or read. + /// + /// # Panics + /// + /// May panic during UUID parsing. #[instrument( skip( self, @@ -720,7 +781,7 @@ impl GenerateReports { ), err )] - #[instrument(err, skip_all)] + #[allow(clippy::too_many_arguments, clippy::ref_option)] fn make_report( &self, election_id: &Uuid, @@ -744,7 +805,7 @@ impl GenerateReports { .clone() .map(|value| Uuid::parse_str(&value.id)) .transpose() - .map_err(|err| Error::UnexpectedError(format!("{}", err)))?; + .map_err(|err| Error::Unexpected(format!("{err}")))?; let contest_result = self.read_contest_result( election_id, contest_id, @@ -767,8 +828,8 @@ impl GenerateReports { election_alias, election_description, election_dates, - &election_annotations, - &election_event_annotations, + election_annotations, + election_event_annotations, contest_id, &contest, area_id.as_ref(), @@ -818,7 +879,17 @@ impl GenerateReports { Ok(report) } + /// Writes generated reports to storage. + /// + /// # Errors + /// + /// Returns an error if reports cannot be written to storage. + /// + /// # Panics + /// + /// May panic if cloning `is_consolidated` on Copy types. #[instrument(err, skip(self, reports, areas_map), err)] + #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] fn write_report( &self, election_id: &Uuid, @@ -838,17 +909,15 @@ impl GenerateReports { enable_pdfs, election_hash, areas_map, - is_consolidated.clone(), + is_consolidated, )?; - let mut base_path = match area_based { - true => { - PipeInputs::build_path_by_area(&self.output_dir, election_id, contest_id, area_id) - } - false => match is_consolidated { - true => PipeInputs::build_consolidated_report_path(&self.output_dir, election_id), - false => PipeInputs::build_path(&self.output_dir, election_id, contest_id, area_id), - }, + let mut base_path = if area_based { + PipeInputs::build_path_by_area(&self.output_dir, election_id, contest_id, area_id) + } else if is_consolidated { + PipeInputs::build_consolidated_report_path(&self.output_dir, election_id) + } else { + PipeInputs::build_path(&self.output_dir, election_id, contest_id, area_id) }; if let Some(tally_sheet) = tally_sheet_id.clone() { @@ -861,7 +930,7 @@ impl GenerateReports { fs::create_dir_all(&base_path)?; - if let Some(bytes_pdf) = reports.bytes_pdf.clone() { + if let Some(bytes_pdf) = reports.pdf.clone() { let pdf_path = base_path.join(OUTPUT_PDF); let mut pdf_file = OpenOptions::new() .write(true) @@ -869,11 +938,12 @@ impl GenerateReports { .create(true) .open(pdf_path)?; pdf_file.write_all(&bytes_pdf)?; - }; + } - let (html_path, json_path) = match is_consolidated { - true => (OUTPUT_ALL_AREAS_HTML, OUTPUT_ALL_AREAS_JSON), - false => (OUTPUT_HTML, OUTPUT_JSON), + let (html_path, json_path) = if is_consolidated { + (OUTPUT_ALL_AREAS_HTML, OUTPUT_ALL_AREAS_JSON) + } else { + (OUTPUT_HTML, OUTPUT_JSON) }; let html_path = base_path.join(html_path); @@ -882,7 +952,7 @@ impl GenerateReports { .truncate(true) .create(true) .open(html_path)?; - html_file.write_all(&reports.bytes_html)?; + html_file.write_all(&reports.html)?; let json_path = base_path.join(json_path); let mut json_file = OpenOptions::new() @@ -890,27 +960,35 @@ impl GenerateReports { .truncate(true) .create(true) .open(json_path)?; - json_file.write_all(&reports.bytes_json)?; + json_file.write_all(&reports.json)?; Ok(result_hash) } } +/// Configuration for an area with its associated contests. #[derive(Debug, Clone)] struct InputConfigAreaContest<'a> { + /// The area configuration. area: &'a InputAreaConfig, + /// The list of contest configurations for this area. contests: Vec<&'a InputContestConfig>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +/// Results and statistics for an election. pub struct ElectionResultReport { + /// Total registered voters (census). pub census: u64, + /// Total number of votes cast. pub total_votes: u64, + /// Percentage of census that voted. pub percentage_total_votes: f64, } impl Pipe for GenerateReports { #[instrument(err, skip_all, name = "GenerateReports::exec")] + #[allow(clippy::too_many_lines)] fn exec(&self) -> Result<()> { let mark_winners_dir = self .pipe_inputs @@ -969,8 +1047,8 @@ impl Pipe for GenerateReports { let tally_sheet_ids = tally_sheet_paths .iter() .map(|tally_sheet_path| -> Result { - PipeInputs::get_tally_sheet_id_from_path(&tally_sheet_path) - .ok_or(Error::UnexpectedError( + PipeInputs::get_tally_sheet_id_from_path(tally_sheet_path) + .ok_or(Error::Unexpected( "Can't read tally sheet id from path".into(), )) }) @@ -1096,7 +1174,7 @@ impl Pipe for GenerateReports { .entry(area.id.to_string()) .and_modify(|entry| entry.contests.push(contest)) // Ensure contest is cloneable or references are fine .or_insert_with(|| InputConfigAreaContest { - area: area, // Ensure lifetime of area is suitable or it's cloned + area, // Ensure lifetime of area is suitable or it's cloned contests: vec![contest], }); }); @@ -1166,20 +1244,26 @@ impl Pipe for GenerateReports { .flatten() .collect(); // End of par_iter().try_for_each over area_contests_map - if (is_consolidated_report && area_contests_reports.len() > 0) { - let election_census = election_input.clone().census; - let total_votes = election_input.total_votes.clone(); + if is_consolidated_report && !area_contests_reports.is_empty() { + let election_census = election_input.census; + let total_votes = election_input.total_votes; + #[allow(clippy::cast_precision_loss)] let census_base = cmp::max(1, election_census) as f64; let election_results = ElectionResultReport { - census: election_census.clone(), - total_votes: total_votes.clone(), - percentage_total_votes: (total_votes as f64) * 100.0 / census_base, + census: election_census, + total_votes, + percentage_total_votes: { + #[allow(clippy::cast_precision_loss)] + { + (total_votes as f64) * 100.0 / census_base + } + }, }; let contest = area_contests_reports .first() - .map(|r| r.contest.clone().unwrap()) + .map(|r| r.contest.clone().expect("contest should be present")) .expect("area_contests_reports is empty"); let summary_election_report = ReportData { @@ -1188,7 +1272,7 @@ impl Pipe for GenerateReports { election_id: election_input.id.to_string(), tenant_id: contest.tenant_id.clone(), election_event_id: contest.election_event_id.clone(), - election_description: election_input.description.to_string(), + election_description: election_input.description.clone(), election_dates: election_input.dates.clone(), election_annotations: election_input.annotations.clone(), election_event_annotations: election_input @@ -1204,7 +1288,7 @@ impl Pipe for GenerateReports { area_contests_reports.push(summary_election_report); area_contests_reports.extend(contest_reports); - let result_hash = self.write_report( + self.write_report( &election_input.id, None, None, @@ -1225,8 +1309,11 @@ impl Pipe for GenerateReports { } #[derive(Debug, Clone, Serialize, Deserialize)] +/// Basic information about an area. pub struct BasicArea { + /// Unique identifier for the area. pub id: String, + /// Display name of the area. pub name: String, } @@ -1240,52 +1327,93 @@ impl From for BasicArea { } #[derive(Debug, Clone)] +/// Complete data for generating a single report. pub struct ReportData { + /// Name of the election. pub election_name: String, + /// Alias/shortname of the election. pub election_alias: String, + /// Unique election identifier. pub election_id: String, + /// Election event identifier. pub election_event_id: String, + /// Tenant/organization identifier. pub tenant_id: String, + /// Detailed description of the election. pub election_description: String, + /// Start and end dates of the election period. pub election_dates: Option, + /// Annotations for the election. pub election_annotations: HashMap, + /// Annotations for the election event. pub election_event_annotations: HashMap, + /// Contest being reported on. pub contest: Option, + /// Area for the report if applicable. pub area: Option, + /// Tally results for the contest. pub contest_result: Option, + /// List of winning candidates. pub winners: Vec, + /// Type of communication channel used. pub channel_type: Option, + /// Overall election results if applicable. pub election_results: Option, } #[derive(Debug, Serialize, Clone)] +/// Computed election report data with aggregated statistics. pub struct ElectionReportDataComputed { + /// Unique election identifier. pub election_id: String, + /// Area covered by the report. pub area: Option, + /// Total registered voters. pub census: u64, + /// Total votes cast. pub total_votes: u64, + /// List of computed reports for contests. pub reports: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] +/// Fully computed report data ready for template rendering. pub struct ReportDataComputed { + /// Name of the election. pub election_name: String, + /// Alias/shortname of the election. pub election_alias: String, + /// Unique election identifier. pub election_id: String, + /// Election event identifier. pub election_event_id: String, + /// Tenant/organization identifier. pub tenant_id: String, + /// Detailed description of the election. pub election_description: String, + /// Start and end dates of the election period. pub election_dates: Option, + /// Annotations for the election. pub election_annotations: HashMap, + /// Annotations for the election event. pub election_event_annotations: HashMap, + /// Contest being reported on. pub contest: Option, + /// Area for the report if applicable. pub area: Option, + /// Annotations for the area. pub area_annotations: HashMap, + /// Whether this is an aggregate report. pub is_aggregate: bool, + /// Identifier for the tally sheet. pub tally_sheet_id: Option, + /// Tally results for the contest. pub contest_result: Option, + /// Results for each candidate. pub candidate_result: Vec, + /// Type of communication channel used. pub channel_type: Option, + /// Overall election results if applicable. pub election_results: Option, } @@ -1305,7 +1433,7 @@ impl From for ReportData { winners: item .candidate_result .into_iter() - .filter_map(|winner| winner.into()) + .filter_map(std::convert::Into::into) .collect(), channel_type: item.channel_type.clone(), election_results: item.election_results.clone(), @@ -1316,18 +1444,21 @@ impl From for ReportData { } #[derive(Debug, Serialize, Deserialize, Clone)] +/// Report data for a single candidate's results. pub struct CandidateResultForReport { + /// Information about the candidate. pub candidate: Candidate, + /// Total votes the candidate received. pub total_count: u64, + /// Percentage of votes received (0.0 to 100.0). pub percentage_votes: f64, + /// Position in the winning list. pub winning_position: Option, } impl From for Option { fn from(item: CandidateResultForReport) -> Self { - let Some(winning_position) = item.winning_position.clone() else { - return None; - }; + let winning_position = item.winning_position?; Some(WinnerResult { candidate: item.candidate, total_count: item.total_count, @@ -1336,8 +1467,9 @@ impl From for Option { } } +/// Sorts candidates according to the specified order. #[instrument(skip_all)] -fn sort_candidates(candidates: &mut Vec, order_field: CandidatesOrder) { +fn sort_candidates(candidates: &mut [CandidateResult], order_field: &CandidatesOrder) { match order_field { CandidatesOrder::Alphabetical => { candidates.sort_by(|a, b| { diff --git a/packages/velvet/src/pipes/generate_reports/mod.rs b/packages/velvet/src/pipes/generate_reports/mod.rs index 39c65d01e84..ec9e8725730 100644 --- a/packages/velvet/src/pipes/generate_reports/mod.rs +++ b/packages/velvet/src/pipes/generate_reports/mod.rs @@ -2,5 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Report generation pipe for creating election reports. +#[allow(clippy::module_inception)] mod generate_reports; pub use generate_reports::*; diff --git a/packages/velvet/src/pipes/mark_winners/mark_winners.rs b/packages/velvet/src/pipes/mark_winners/mark_winners.rs index 50c49c584b2..c4317f4a038 100644 --- a/packages/velvet/src/pipes/mark_winners/mark_winners.rs +++ b/packages/velvet/src/pipes/mark_winners/mark_winners.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::{cmp::Ordering, fs}; use sequent_core::ballot::Candidate; @@ -24,19 +24,28 @@ use crate::pipes::{ }; use crate::utils::parse_file; +/// Output filename for the winners JSON file. pub const OUTPUT_WINNERS: &str = "winners.json"; +/// Pipe for identifying and marking winning candidates in a contest. pub struct MarkWinners { + /// Pipe input configuration containing election and file paths. pub pipe_inputs: PipeInputs, } impl MarkWinners { #[instrument(skip_all, name = "MarkWinners::new")] + /// Creates a new winner marker with the given pipe inputs. pub fn new(pipe_inputs: PipeInputs) -> Self { Self { pipe_inputs } } #[instrument(skip_all)] + /// Extracts and orders winners from contest results. + /// + /// # Panics + /// + /// Panics if the winning candidates number cannot be converted to usize. pub fn get_winners(contest_result: &ContestResult) -> Vec { let mut winners = contest_result.candidate_result.clone(); @@ -52,21 +61,30 @@ impl MarkWinners { winners .into_iter() - .take(contest_result.contest.winning_candidates_num as usize) + .take( + usize::try_from(contest_result.contest.winning_candidates_num) + .expect("winning candidates number must be convertible to usize"), + ) .enumerate() .map(|(index, w)| WinnerResult { candidate: w.candidate.clone(), total_count: w.total_count, - winning_position: index + 1, + winning_position: index.saturating_add(1), }) .collect() } + /// Creates breakdown winner reports for all contests and areas. + /// + /// # Errors + /// + /// Returns an error if files cannot be read or written. + /// + /// # Panics + /// + /// Panics if subfolder name cannot be determined. #[instrument(err, skip_all)] - pub fn create_breakdown_winners( - base_input_path: &PathBuf, - base_output_path: &PathBuf, - ) -> Result<()> { + pub fn create_breakdown_winners(base_input_path: &Path, base_output_path: &Path) -> Result<()> { let base_input_breakdown_path = base_input_path.join(OUTPUT_BREAKDOWNS_FOLDER); let base_output_breakdown_path = base_output_path.join(OUTPUT_BREAKDOWNS_FOLDER); let subfolders = list_subfolders(&base_input_breakdown_path); @@ -78,7 +96,7 @@ impl MarkWinners { let winners = MarkWinners::get_winners(&contest_result); - let subfolder_name = subfolder.file_name().unwrap(); + let subfolder_name = subfolder.file_name().expect("subfolder name must exist"); let output_subfolder = base_output_breakdown_path.join(subfolder_name); fs::create_dir_all(&output_subfolder)?; let winners_file_path = output_subfolder.join(OUTPUT_WINNERS); @@ -91,6 +109,7 @@ impl MarkWinners { impl Pipe for MarkWinners { #[instrument(err, skip_all, name = "MarkWinners::new")] + #[allow(clippy::too_many_lines)] fn exec(&self) -> Result<()> { let input_dir = self .pipe_inputs @@ -128,11 +147,12 @@ impl Pipe for MarkWinners { let contest_result_file = base_input_aggregate_path.join(OUTPUT_CONTEST_RESULT_FILE); - let contest_results_file = fs::File::open(&contest_result_file) + let contest_results_file_agg = fs::File::open(&contest_result_file) .map_err(|e| Error::FileAccess(contest_result_file.clone(), e))?; - let contest_result: ContestResult = parse_file(contest_results_file)?; + let contest_result_agg: ContestResult = + parse_file(contest_results_file_agg)?; - let winners = MarkWinners::get_winners(&contest_result); + let winners_agg = MarkWinners::get_winners(&contest_result_agg); let aggregate_output_path = base_output_path .join(OUTPUT_CONTEST_RESULT_AREA_CHILDREN_AGGREGATE_FOLDER); @@ -141,7 +161,7 @@ impl Pipe for MarkWinners { let winners_file_path = aggregate_output_path.join(OUTPUT_WINNERS); let winners_file = fs::File::create(winners_file_path)?; - serde_json::to_writer(winners_file, &winners)?; + serde_json::to_writer(winners_file, &winners_agg)?; } // do tally sheet winners @@ -150,44 +170,44 @@ impl Pipe for MarkWinners { let contest_result_file = tally_sheet_folder.join(OUTPUT_CONTEST_RESULT_FILE); - let contest_results_file = fs::File::open(&contest_result_file) + let contest_results_file_tally_sheet = fs::File::open(&contest_result_file) .map_err(|e| Error::FileAccess(contest_result_file.clone(), e))?; - let contest_result: ContestResult = parse_file(contest_results_file)?; + let contest_result_tally: ContestResult = + parse_file(contest_results_file_tally_sheet)?; - let winners = MarkWinners::get_winners(&contest_result); + let winners_tally = MarkWinners::get_winners(&contest_result_tally); let Some(tally_sheet_id) = PipeInputs::get_tally_sheet_id_from_path(&tally_sheet_folder) else { - return Err(Error::UnexpectedError( + return Err(Error::Unexpected( "Can't read tally sheet id from path".into(), )); }; - let tally_sheet_folder = + let tally_sheet_output = PipeInputs::build_tally_sheet_path(&base_output_path, &tally_sheet_id); - fs::create_dir_all(&tally_sheet_folder)?; + fs::create_dir_all(&tally_sheet_output)?; - let winners_file_path = tally_sheet_folder.join(OUTPUT_WINNERS); + let winners_file_path = tally_sheet_output.join(OUTPUT_WINNERS); let winners_file = fs::File::create(winners_file_path)?; - serde_json::to_writer(winners_file, &winners)?; + serde_json::to_writer(winners_file, &winners_tally)?; } // do area winners let contest_result_file = base_input_path.join(OUTPUT_CONTEST_RESULT_FILE); - - let contest_results_file = fs::File::open(&contest_result_file) + let contest_results_file_area = fs::File::open(&contest_result_file) .map_err(|e| Error::FileAccess(contest_result_file.clone(), e))?; - let contest_result: ContestResult = parse_file(contest_results_file)?; + let contest_result_area: ContestResult = parse_file(contest_results_file_area)?; - let winners = MarkWinners::get_winners(&contest_result); + let winners_area = MarkWinners::get_winners(&contest_result_area); fs::create_dir_all(&base_output_path)?; let winners_file_path = base_output_path.join(OUTPUT_WINNERS); let winners_file = fs::File::create(winners_file_path)?; - serde_json::to_writer(winners_file, &winners)?; + serde_json::to_writer(winners_file, &winners_area)?; } let contest_result_path = PipeInputs::build_path( @@ -227,8 +247,12 @@ impl Pipe for MarkWinners { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +/// Result data for a winning candidate. pub struct WinnerResult { + /// Information about the candidate. pub candidate: Candidate, + /// Total votes the candidate received. pub total_count: u64, + /// Position in the winners list. pub winning_position: usize, } diff --git a/packages/velvet/src/pipes/mark_winners/mod.rs b/packages/velvet/src/pipes/mark_winners/mod.rs index 58154c4a8f6..fe53f539402 100644 --- a/packages/velvet/src/pipes/mark_winners/mod.rs +++ b/packages/velvet/src/pipes/mark_winners/mod.rs @@ -2,5 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Mark winners pipe for identifying and tracking winning candidates. +#[allow(clippy::module_inception)] mod mark_winners; pub use mark_winners::*; diff --git a/packages/velvet/src/pipes/mod.rs b/packages/velvet/src/pipes/mod.rs index d347be413ef..4bf46de874f 100644 --- a/packages/velvet/src/pipes/mod.rs +++ b/packages/velvet/src/pipes/mod.rs @@ -2,17 +2,28 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Error handling for pipe operations. pub mod error; +/// Pipe input configuration and data structures. pub mod pipe_inputs; +/// Pipeline stage names and identifiers. pub mod pipe_name; // Pipes +/// Ballot image generation from vote records. pub mod ballot_images; +/// Ballot decoding and vote extraction. pub mod decode_ballots; +/// Tally computation and vote counting. pub mod do_tally; +/// `SQLite` Database generation from election results. pub mod generate_db; +/// Report generation for election results. pub mod generate_reports; +/// Winner identification and tracking. pub mod mark_winners; +/// Pipeline processing and routing. +#[allow(clippy::module_inception)] mod pipes; pub use pipes::*; diff --git a/packages/velvet/src/pipes/pipe_inputs.rs b/packages/velvet/src/pipes/pipe_inputs.rs index c3a4058c94a..e8d0e0d9059 100644 --- a/packages/velvet/src/pipes/pipe_inputs.rs +++ b/packages/velvet/src/pipes/pipe_inputs.rs @@ -21,54 +21,83 @@ use std::{ use tracing::{info, instrument}; use uuid::Uuid; +/// Prefix for election folder names. pub const PREFIX_ELECTION: &str = "election__"; +/// Prefix for contest folder names. pub const PREFIX_CONTEST: &str = "contest__"; +/// Prefix for area folder names. pub const PREFIX_AREA: &str = "area__"; +/// Prefix for tally sheet folder names. pub const PREFIX_TALLY_SHEET: &str = "tally_sheet__"; +/// Prefix for all areas folder name. pub const PREFIX_ALL_AREAS: &str = "all_areas"; +/// Default directory for configuration files. pub const DEFAULT_DIR_CONFIGS: &str = "default/configs"; +/// Default directory for ballot files. pub const DEFAULT_DIR_BALLOTS: &str = "default/ballots"; +/// Default directory for tally sheets. pub const DEFAULT_DIR_TALLY_SHEETS: &str = "default/tally_sheets"; +/// Default directory for database files. pub const DEFAULT_DIR_DATABASE: &str = "default/database"; +/// Filename of election configuration file. pub const ELECTION_CONFIG_FILE: &str = "election-config.json"; +/// Filename of contest configuration file. pub const CONTEST_CONFIG_FILE: &str = "contest-config.json"; +/// Filename of area configuration file. pub const AREA_CONFIG_FILE: &str = "area-config.json"; +/// Filename of ballots file. pub const BALLOTS_FILE: &str = "ballots.csv"; +/// Length of a UUID string representation. const UUID_LEN: usize = 36; +/// Pipeline input configuration holding paths and election data. #[derive(Debug)] pub struct PipeInputs { + /// CLI runtime arguments. pub cli: CliRun, + /// Root path to configuration files. pub root_path_config: PathBuf, + /// Root path to ballot files. pub root_path_ballots: PathBuf, + /// Root path to tally sheet files. pub root_path_tally_sheets: PathBuf, + /// Root path to database files. pub root_path_database: PathBuf, + /// Current processing stage. pub stage: Stage, + /// List of elections to process. pub election_list: Vec, } impl PipeInputs { #[instrument(err, skip_all, name = "PipeInputs::new")] + /// Creates a new `PipeInputs` instance from CLI arguments and stage configuration. + /// + /// # Errors + /// Returns an error if input directory reading or election configuration parsing fails. pub fn new(cli: CliRun, stage: Stage) -> Result { - let root_path_config = &cli.input_dir.join(DEFAULT_DIR_CONFIGS); - let root_path_ballots = &cli.input_dir.join(DEFAULT_DIR_BALLOTS); - let root_path_tally_sheets = &cli.input_dir.join(DEFAULT_DIR_TALLY_SHEETS); - let root_path_database = &cli.input_dir.join(DEFAULT_DIR_DATABASE); + let root_path_config = cli.input_dir.join(DEFAULT_DIR_CONFIGS); + let root_path_ballots = cli.input_dir.join(DEFAULT_DIR_BALLOTS); + let root_path_tally_sheets = cli.input_dir.join(DEFAULT_DIR_TALLY_SHEETS); + let root_path_database = cli.input_dir.join(DEFAULT_DIR_DATABASE); let election_list = Self::read_input_dir_config(root_path_config.as_path())?; Ok(Self { cli, - root_path_config: root_path_config.to_path_buf(), - root_path_ballots: root_path_ballots.to_path_buf(), - root_path_tally_sheets: root_path_tally_sheets.to_path_buf(), - root_path_database: root_path_database.to_path_buf(), + root_path_config: root_path_config.clone(), + root_path_ballots: root_path_ballots.clone(), + root_path_tally_sheets: root_path_tally_sheets.clone(), + root_path_database: root_path_database.clone(), stage, election_list, }) } + /// Builds a path for a specific election, contest, and area combination. + /// + /// The path hierarchy is: `root/election_id/contest_id/area_id` #[instrument(skip_all)] pub fn build_path( root: &Path, @@ -79,30 +108,36 @@ impl PipeInputs { let mut path = PathBuf::new(); path.push(root); - path.push(format!("{}{}", PREFIX_ELECTION, election_id)); + path.push(format!("{PREFIX_ELECTION}{election_id}")); if let Some(contest_id) = contest_id { - path.push(format!("{}{}", PREFIX_CONTEST, contest_id)); + path.push(format!("{PREFIX_CONTEST}{contest_id}")); if let Some(area_id) = area_id { - path.push(format!("{}{}", PREFIX_AREA, area_id)); + path.push(format!("{PREFIX_AREA}{area_id}")); } } path } + /// Builds a consolidated report path for all areas of a given election. + /// + /// The path hierarchy is: `root/election_id/all_areas` #[instrument(skip_all)] pub fn build_consolidated_report_path(root: &Path, election_id: &Uuid) -> PathBuf { let mut path = PathBuf::new(); path.push(root); - path.push(format!("{}{}", PREFIX_ELECTION, election_id)); - path.push(format!("{}", PREFIX_ALL_AREAS)); + path.push(format!("{PREFIX_ELECTION}{election_id}")); + path.push(PREFIX_ALL_AREAS); path } + /// Builds a path organized by area, then contest within that area. + /// + /// The path hierarchy is: `root/election_id/area_id/contest_id` #[instrument(skip_all)] pub fn build_path_by_area( root: &Path, @@ -113,14 +148,14 @@ impl PipeInputs { let mut path = PathBuf::new(); path.push(root); - path.push(format!("{}{}", PREFIX_ELECTION, election_id)); + path.push(format!("{PREFIX_ELECTION}{election_id}")); if let Some(area_id) = area_id { - path.push(format!("{}{}", PREFIX_AREA, area_id)); + path.push(format!("{PREFIX_AREA}{area_id}")); } if let Some(contest_id) = contest_id { - path.push(format!("{}{}", PREFIX_CONTEST, contest_id)); + path.push(format!("{PREFIX_CONTEST}{contest_id}")); } path @@ -137,36 +172,45 @@ impl PipeInputs { let mut path = PathBuf::new(); path.push(root); - path.push(format!("{}{}", PREFIX_ELECTION, election_id)); - path.push(format!("{}{}", PREFIX_AREA, area_id)); + path.push(format!("{PREFIX_ELECTION}{election_id}")); + path.push(format!("{PREFIX_AREA}{area_id}")); path } + /// Builds a path for a specific tally sheet. + /// + /// The path hierarchy is: `root/tally_sheet_id` #[instrument(skip_all)] pub fn build_tally_sheet_path(root: &Path, tally_sheet_id: &str) -> PathBuf { let mut path = PathBuf::new(); path.push(root); - path.push(format!("{}{}", PREFIX_TALLY_SHEET, tally_sheet_id)); + path.push(format!("{PREFIX_TALLY_SHEET}{tally_sheet_id}")); path } + /// Extracts the tally sheet ID from a file path. + /// + /// Returns `Some(id)` if the path ends with a folder prefixed with `PREFIX_TALLY_SHEET`, + /// otherwise returns `None`. #[instrument(skip_all)] pub fn get_tally_sheet_id_from_path(path: &Path) -> Option { - let Some(folder_name) = get_folder_name(path) else { - return None; - }; + let folder_name = get_folder_name(path)?; if folder_name.starts_with(PREFIX_TALLY_SHEET) { folder_name .strip_prefix(PREFIX_TALLY_SHEET) - .map(|val| val.to_string()) + .map(std::string::ToString::to_string) } else { None } } #[instrument(err)] + /// Reads and parses all election configurations from the input directory. + /// + /// # Errors + /// Returns an error if directory reading or election config parsing fails. fn read_input_dir_config(input_dir: &Path) -> Result> { let entries = fs::read_dir(input_dir)?; @@ -180,6 +224,10 @@ impl PipeInputs { } #[instrument(err)] + /// Reads and parses election configuration from a directory. + /// + /// # Errors + /// Returns an error if file access, config parsing, or contest reading fails. fn read_election_list_config(path: &Path) -> Result { let entries = fs::read_dir(path)?; @@ -196,9 +244,9 @@ impl PipeInputs { let mut configs = vec![]; for entry in entries { - let path = entry?.path(); - if path.is_dir() { - let config = Self::read_contest_list_config(&path, election_id)?; + let entry_path = entry?.path(); + if entry_path.is_dir() { + let config = Self::read_contest_list_config(&entry_path, election_id)?; configs.push(config); } } @@ -222,6 +270,10 @@ impl PipeInputs { } #[instrument(err)] + /// Reads and parses contest configuration from a directory. + /// + /// # Errors + /// Returns an error if file access, config parsing, or area reading fails. fn read_contest_list_config(path: &Path, election_id: Uuid) -> Result { let contest_id = Self::parse_path_components(path, PREFIX_CONTEST).ok_or(Error::IDNotFound)?; @@ -249,9 +301,9 @@ impl PipeInputs { return Err(Error::AreaConfigNotFound(area_id)); } - let config_file = fs::File::open(&config_path_area) + let config_file_area = fs::File::open(&config_path_area) .map_err(|e| Error::FileAccess(config_path_area.clone(), e))?; - let area_config: AreaConfig = parse_file(config_file)?; + let area_config: AreaConfig = parse_file(config_file_area)?; configs.push(InputAreaConfig { id: area_id, @@ -275,12 +327,17 @@ impl PipeInputs { } #[instrument] + /// Parses UUID from path components with a given prefix. + /// + /// # Errors (implicit - returns None) + /// Returns `None` if the prefix is not found in the path or UUID parsing fails. fn parse_path_components(path: &Path, prefix: &str) -> Option { for component in path.components() { let part = component.as_os_str().to_string_lossy(); if let Some(res) = part.strip_prefix(prefix) { - let slice = &res[res.len() - UUID_LEN..]; + let start = res.len().saturating_sub(UUID_LEN); + let slice = &res[start..]; // Check if the string length is at least 36 if res.len() >= UUID_LEN { // Use the last 36 characters for UUID parsing @@ -293,32 +350,51 @@ impl PipeInputs { } } +/// Election configuration data loaded from input files. #[derive(Debug)] pub struct InputElectionConfig { + /// Unique election identifier. pub id: Uuid, + /// Name of the election. pub name: String, + /// Alias for the election. pub alias: String, + /// Description of the election. pub description: String, + /// Start and end dates of the election if specified. pub dates: Option, + /// Annotations associated with the election. pub annotations: HashMap, + /// Annotations from the election event. pub election_event_annotations: HashMap, + /// Ballot styles used in the election. pub ballot_styles: Vec, + /// Contest configurations for this election. pub contest_list: Vec, + /// File path to the election configuration. pub path: PathBuf, + /// Total number of registered voters (census). pub census: u64, + /// Total votes cast in the election. pub total_votes: u64, + /// Areas involved in the election. pub areas: Vec, + /// Display presentation settings for the election. pub presentation: Option, } #[derive(Debug, Clone)] +/// Contest associated with a specific area. pub struct AreaContest { + /// Name of the area. pub area_name: String, + /// Contests in this area. pub contests: Vec, } impl InputElectionConfig { #[instrument(skip_all)] + /// Creates a map of area IDs to their associated contest information. pub(crate) fn get_area_contest_map(&self) -> HashMap { let mut ret: HashMap = HashMap::new(); @@ -345,64 +421,102 @@ impl InputElectionConfig { } } +/// Contest configuration data. #[derive(Debug)] pub struct InputContestConfig { + /// Unique contest identifier. pub id: Uuid, + /// Election ID this contest belongs to. pub election_id: Uuid, + /// Contest details. pub contest: Contest, + /// Areas where this contest is held. pub area_list: Vec, + /// File path to the contest configuration. pub path: PathBuf, } +/// Area configuration data. #[derive(Debug)] pub struct InputAreaConfig { + /// Unique area identifier. pub id: Uuid, + /// Election ID this area is part of. pub election_id: Uuid, + /// Contest ID for this area configuration. pub contest_id: Uuid, + /// Number of registered voters in this area. pub census: u64, + /// Number of auditable votes cast in this area. pub auditable_votes: u64, + /// File path to the area configuration. pub path: PathBuf, + /// Area configuration details. pub area: AreaConfig, } +/// Election configuration data deserialized from election-config.json. #[derive(Serialize, Deserialize, Clone)] pub struct ElectionConfig { + /// Unique election identifier. pub id: Uuid, + /// Name of the election. pub name: String, + /// Alias for the election. pub alias: String, + /// Description of the election. pub description: String, + /// Custom annotations associated with the election. pub annotations: HashMap, + /// Annotations from the election event. pub election_event_annotations: HashMap, + /// Tenant identifier. pub tenant_id: Uuid, + /// Election event identifier. pub election_event_id: Uuid, + /// Total number of registered voters (census). pub census: u64, + /// Total votes cast in the election. pub total_votes: u64, + /// Ballot styles used in the election. pub ballot_styles: Vec, + /// Areas involved in the election. pub areas: Vec, + /// Start and end dates of the election if specified. pub dates: Option, + /// Display presentation settings for the election. pub presentation: Option, } +/// Area configuration data deserialized from area-config.json. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AreaConfig { + /// Unique area identifier. pub id: Uuid, + /// Name of the area. pub name: String, + /// Tenant identifier. pub tenant_id: Uuid, + /// Election event identifier. pub election_event_id: Uuid, + /// Election identifier this area belongs to. pub election_id: Uuid, + /// Number of registered voters in this area. pub census: u64, + /// Parent area identifier if this is a sub-area. pub parent_id: Option, + /// Number of auditable votes cast in this area. pub auditable_votes: u64, } -impl Into for &AreaConfig { - fn into(self) -> TreeNodeArea { +impl From<&AreaConfig> for TreeNodeArea { + fn from(area: &AreaConfig) -> Self { TreeNodeArea { - id: self.id.to_string(), - tenant_id: self.tenant_id.to_string(), - annotations: Default::default(), - election_event_id: self.election_event_id.to_string(), - parent_id: self.parent_id.clone().map(|val| val.to_string()), + id: area.id.to_string(), + tenant_id: area.tenant_id.to_string(), + annotations: None, + election_event_id: area.election_event_id.to_string(), + parent_id: area.parent_id.map(|val| val.to_string()), } } } diff --git a/packages/velvet/src/pipes/pipe_name.rs b/packages/velvet/src/pipes/pipe_name.rs index 1853b91fc99..f4278aca2ae 100644 --- a/packages/velvet/src/pipes/pipe_name.rs +++ b/packages/velvet/src/pipes/pipe_name.rs @@ -8,22 +8,33 @@ use std::fmt; use std::str::FromStr; use strum_macros::{AsRefStr, Display, EnumString}; +/// Names of the different election processing pipelines. #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, EnumString, Display, AsRefStr)] pub enum PipeName { + /// Decode standard ballots pipeline. DecodeBallots, + /// Decode multi-contest ballots pipeline. DecodeMCBallots, + /// Generate ballot images pipeline. BallotImages, + /// Generate multi-contest ballot receipts pipeline. MCBallotReceipts, + /// Generate multi-contest ballot images pipeline. MCBallotImages, + /// Tally votes pipeline. DoTally, + /// Mark election winners pipeline. MarkWinners, + /// Generate election reports pipeline. GenerateReports, + /// Generate election database pipeline. GenerateDatabase, } +/// Visitor for deserializing `PipeName` from strings. struct PipeNameVisitor; -impl<'de> Visitor<'de> for PipeNameVisitor { +impl Visitor<'_> for PipeNameVisitor { type Value = PipeName; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -35,28 +46,42 @@ impl<'de> Visitor<'de> for PipeNameVisitor { } } +/// Deserializes a `PipeName` from a string. +/// +/// # Errors +/// Returns a deserialization error if the string is not a valid `PipeName` variant. pub fn deserialize_pipe<'de, D: Deserializer<'de>>(deserializer: D) -> Result { deserializer.deserialize_str(PipeNameVisitor) } #[derive(Debug, AsRefStr)] +/// Output directory names for each pipeline's results. pub enum PipeNameOutputDir { + /// Output directory for decoded ballots pipeline. #[strum(serialize = "velvet-decode-ballots")] DecodeBallots, + /// Output directory for decoded multi-contest ballots pipeline. #[strum(serialize = "velvet-decode-mcballots")] DecodeMCBallots, + /// Output directory for multi-contest ballot receipts pipeline. #[strum(serialize = "velvet-mcballot-receipts")] MCBallotReceipts, + /// Output directory for tally pipeline. #[strum(serialize = "velvet-do-tally")] DoTally, + /// Output directory for mark winners pipeline. #[strum(serialize = "velvet-mark-winners")] MarkWinners, + /// Output directory for generate reports pipeline. #[strum(serialize = "velvet-generate-reports")] GenerateReports, + /// Output directory for generate database pipeline. #[strum(serialize = "velvet-generate-database")] GenerateDatabase, + /// Output directory for ballot images pipeline. #[strum(serialize = "velvet-ballot-images")] BallotImages, + /// Output directory for multi-contest ballot images pipeline. #[strum(serialize = "velvet-mcballot-images")] MCBallotImages, } diff --git a/packages/velvet/src/pipes/pipes.rs b/packages/velvet/src/pipes/pipes.rs index 38939a3b82f..6a71cecfe79 100644 --- a/packages/velvet/src/pipes/pipes.rs +++ b/packages/velvet/src/pipes/pipes.rs @@ -17,14 +17,26 @@ use crate::pipes::do_tally::DoTally; use crate::pipes::generate_db::GenerateDatabase; use tracing::instrument; +/// Trait for implementing election processing pipeline stages. pub trait Pipe { + /// Executes the pipe's processing logic. + /// + /// # Errors + /// + /// Returns an error if execution fails at any stage. fn exec(&self) -> Result<()>; } +/// Manager for creating and routing pipeline instances. pub struct PipeManager; impl PipeManager { #[instrument(err, skip_all, name = "PipeManager::get_pipe")] + /// Retrieves the appropriate pipe implementation for the specified stage. + /// + /// # Errors + /// + /// Returns an error if the stage or pipe configuration is invalid. pub fn get_pipe(cli: CliRun, stage: Stage) -> Result>> { let pipe_inputs = PipeInputs::new(cli, stage)?; @@ -33,8 +45,9 @@ impl PipeManager { PipeName::DecodeBallots => Some(Box::new(DecodeBallots::new(pipe_inputs))), PipeName::BallotImages => Some(Box::new(BallotImages::new(pipe_inputs))), PipeName::DecodeMCBallots => Some(Box::new(DecodeMCBallots::new(pipe_inputs))), - PipeName::MCBallotReceipts => Some(Box::new(MCBallotImages::new(pipe_inputs))), - PipeName::MCBallotImages => Some(Box::new(MCBallotImages::new(pipe_inputs))), + PipeName::MCBallotReceipts | PipeName::MCBallotImages => { + Some(Box::new(MCBallotImages::new(pipe_inputs))) + } PipeName::DoTally => Some(Box::new(DoTally::new(pipe_inputs))), PipeName::MarkWinners => Some(Box::new(MarkWinners::new(pipe_inputs))), PipeName::GenerateReports => Some(Box::new(GenerateReports::new(pipe_inputs))), diff --git a/packages/velvet/src/utils.rs b/packages/velvet/src/utils.rs index e69962eef84..5bf27f49ef6 100644 --- a/packages/velvet/src/utils.rs +++ b/packages/velvet/src/utils.rs @@ -11,21 +11,26 @@ use std::io::Read; use crate::pipes::pipe_inputs::InputElectionConfig; use sequent_core::plaintext::DecodedVoteChoice; +/// Trait for types that have a unique identifier. pub trait HasId { + /// Returns the unique identifier as a string slice. fn id(&self) -> &str; } +/// Parses the contents of a file into the specified type. +/// +/// # Errors +/// +/// Returns an error if file cannot be read or deserialized. pub fn parse_file Deserialize<'a>>(mut file: File) -> Result { let mut contents = String::new(); file.read_to_string(&mut contents)?; - deserialize_str(&contents).map_err(|err| { - Error::UnexpectedError(format!("Parse error: {:?} . Contents {contents}", err)) - }) + deserialize_str(&contents) + .map_err(|err| Error::Unexpected(format!("Parse error: {err:?} . Contents {contents}"))) } - -// unmarked choices -// contest_id -> (candidate_id -> dcv) +/// Unmarked choices +/// Creates a map of decoded vote choices indexed by contest and candidate IDs. pub(crate) fn get_contest_dvc_map( election_input: &InputElectionConfig, ) -> HashMap> { diff --git a/packages/velvet/tests/instant_runoff/irv_unit_tests.rs b/packages/velvet/tests/instant_runoff/irv_unit_tests.rs index 147289517ee..1c32d265538 100644 --- a/packages/velvet/tests/instant_runoff/irv_unit_tests.rs +++ b/packages/velvet/tests/instant_runoff/irv_unit_tests.rs @@ -120,7 +120,7 @@ fn test_first_preference_is_active() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", 2), // Third preference ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d".to_string()) @@ -144,7 +144,7 @@ fn test_first_preference_eliminated_returns_second() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", 2), // Third preference (active) ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("b2c3d4e5-f6a7-4b5c-8d9e-1f2a3b4c5d6e".to_string()) @@ -162,7 +162,7 @@ fn test_multiple_eliminated_skips_to_first_active() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", 2), // Third preference (active) ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f".to_string()) @@ -180,7 +180,7 @@ fn test_no_active_candidates_returns_none() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", 2), ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!(result, None); } @@ -195,7 +195,7 @@ fn test_all_choices_eliminated_returns_none() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", 2), ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!(result, None); } @@ -206,7 +206,7 @@ fn test_empty_choices_returns_none() { let choices = vec![]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!(result, None); } @@ -229,7 +229,7 @@ fn test_all_choices_unselected_returns_none() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", -1), // Not selected ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!(result, None); } @@ -252,7 +252,7 @@ fn test_mixed_selected_and_unselected() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", -1), // Not selected ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("b2c3d4e5-f6a7-4b5c-8d9e-1f2a3b4c5d6e".to_string()) @@ -279,7 +279,7 @@ fn test_unordered_selected_values_sorted_correctly() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", 1), // Second preference ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d".to_string()) @@ -305,7 +305,7 @@ fn test_gap_in_selected_values() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", -1), // Not selected ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d".to_string()) @@ -323,7 +323,7 @@ fn test_only_second_preference_active() { create_choice("c3d4e5f6-a7b8-4c5d-8e9f-2a3b4c5d6e7f", -1), // Not selected ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("b2c3d4e5-f6a7-4b5c-8d9e-1f2a3b4c5d6e".to_string()) @@ -337,7 +337,7 @@ fn test_single_choice_active() { let choices = vec![create_choice("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", 0)]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d".to_string()) @@ -351,7 +351,7 @@ fn test_single_choice_not_active() { let choices = vec![create_choice("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", 0)]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!(result, None); } @@ -362,7 +362,7 @@ fn test_single_choice_unselected() { let choices = vec![create_choice("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", -1)]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!(result, None); } @@ -390,7 +390,7 @@ fn test_many_candidates_first_active_with_highest_preference() { create_choice("f6a7b8c9-d0e1-4f5a-8b9c-5d6e7f8a9b0c", 9), // Second active candidate ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); assert_eq!( result, Some("e5f6a7b8-c9d0-4e5f-8a9b-4c5d6e7f8a9b".to_string()) @@ -415,7 +415,7 @@ fn test_duplicate_selected_values() { create_choice("b2c3d4e5-f6a7-4b5c-8d9e-1f2a3b4c5d6e", 0), // Same selected value ]; - let result = runoff.find_first_active_choice(&choices, &active_candidates); + let result = RunoffStatus::find_first_active_choice(&choices, &active_candidates); // Should return one of them (order depends on sort stability) assert!( result == Some("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d".to_string()) diff --git a/packages/velvet/tests/instant_runoff/mod.rs b/packages/velvet/tests/instant_runoff/mod.rs index 2a272e3dd79..0174af95aa0 100644 --- a/packages/velvet/tests/instant_runoff/mod.rs +++ b/packages/velvet/tests/instant_runoff/mod.rs @@ -2,6 +2,11 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! Tests for instant runoff voting algorithm implementation. + +/// Integration tests for instant runoff voting. pub mod irv_integration_tests; +/// Tie-breaking tests for instant runoff voting. pub mod irv_tie_breaking_tests; +/// Unit tests for instant runoff voting. pub mod irv_unit_tests; diff --git a/packages/velvet/tests/mod.rs b/packages/velvet/tests/mod.rs index 1eb526ddc8b..41ce9e06421 100644 --- a/packages/velvet/tests/mod.rs +++ b/packages/velvet/tests/mod.rs @@ -2,5 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! Integration and unit tests for the velvet election system. + +/// Instant runoff voting algorithm tests. pub mod instant_runoff; +/// Pipeline processing tests. pub mod pipes; diff --git a/packages/velvet/tests/pipes/mod.rs b/packages/velvet/tests/pipes/mod.rs index fc978932e14..9ed7dc5d8b7 100644 --- a/packages/velvet/tests/pipes/mod.rs +++ b/packages/velvet/tests/pipes/mod.rs @@ -2,4 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! Tests for pipeline processing. + +/// Tests for mark winners pipe. pub mod mark_winners;