From 4e00df4b2afc0705d259668e2e6628c74ceb6e19 Mon Sep 17 00:00:00 2001 From: Timothy Hunter Date: Mon, 24 Oct 2022 18:07:36 +0200 Subject: [PATCH] Adding manual and various other fixes for the 0.3 release (#36) * cleaning some assertions * changes * bumping the release version Co-authored-by: Tim Hunter --- .gitignore | 2 +- Cargo.toml | 4 +- ranked_voting/src/config.rs | 1 + ranked_voting/src/lib.rs | 137 ++++++++++++++++-- ranked_voting/src/manual.rs | 126 ++++++++++++++++ ranked_voting/src/quick_start.rs | 99 +++++++++++++ src/args.rs | 44 ++++++ src/main.rs | 52 ++----- src/rcv.rs | 25 +++- src/rcv/io_msforms.rs | 39 +++-- tests/msforms_1/msforms_1_config.json | 14 +- .../msforms_likert/msforms_likert_config.json | 21 ++- .../msforms_likert_transpose_config.json | 19 ++- 13 files changed, 502 insertions(+), 81 deletions(-) create mode 100644 ranked_voting/src/manual.rs create mode 100644 ranked_voting/src/quick_start.rs create mode 100644 src/args.rs diff --git a/.gitignore b/.gitignore index 7ea218d..49e1379 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Generated by Cargo # will have compiled files and executables /target/ - +/ranked_voting/target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index 75a77c4..4bef5b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "ranked-voting" -version = "0.2.0" +name = "timrcv" +version = "0.3.0" edition = "2021" # author = ["Tim Hunter "] diff --git a/ranked_voting/src/config.rs b/ranked_voting/src/config.rs index ba4190b..328bde1 100644 --- a/ranked_voting/src/config.rs +++ b/ranked_voting/src/config.rs @@ -91,6 +91,7 @@ pub enum VotingErrors { /// // TODO: explain when it may happen NoConvergence, + NoCandidateToEliminate, } impl Error for VotingErrors {} diff --git a/ranked_voting/src/lib.rs b/ranked_voting/src/lib.rs index 532cd18..3963340 100644 --- a/ranked_voting/src/lib.rs +++ b/ranked_voting/src/lib.rs @@ -1,6 +1,98 @@ +/*! +The `ranked_voting` crate provides a thoroughly tested implementation of the +[Instant-Runoff Voting algorithm](https://en.wikipedia.org/wiki/Instant-runoff_voting), +which is also called ranked-choice voting in the United States, preferential voting +in Australia or alternative vote in the United Kingdom. + +This library can be used in multiple flavours: +- as a simple library for most cases (see the [run_election1] function) + +- as a command-line utility that provides fast and easy election results that can then +be displayed or exported. The section [timrcv](#timrcv) provides a manual. + +- as a more complex library that can handle all the diversity of implementations. It provides +for example multiple ways to deal with blank or absentee ballots, undeclared candidates, etc. +If you are attempting to replicate the results of a specific elections, you should +carefully check the voting rules and use the configuration accordingly. If you are doing so, +you should check [run_election] and [VoteRules] + +# timrcv + +`timrcv` is a command-line program to run an instant runoff election. It can accomodate all common formats from vendors +or public offices. This document presents a tutorial on how to use it. + +## Installation + +Download the latest release from the [releases page](https://github.com/tjhunter/timrcv/releases). + Pre-compiled versions are available for Windows, MacOS and Linux. + + +## Quick start with existing data + +If you are running a poll and are collecting data using Microsoft Forms, +Google Form, Qualtrics, look at the [quick start using Google Forms](quick_start/index.html). + +If you have very simple needs and you can collect data in a +small text file, `timrcv` accepts a simple format of +comma-separated values. + + +To get started, let us say that you have a file with the following records of votes ([example.csv](https://github.com/tjhunter/timrcv/raw/main/tests/csv_simple_2/example.csv)). Each line corresponds to a vote, and A,B,C and D are the candidates: + +```text +A,B,,D +A,C,B, +B,A,D,C +B,C,A,D +C,A,B,D +D,B,A,C +``` +Each line is a recorded vote. The first line `A,B,,D` says that this voter preferred candidate A over everyone else (his/her first choice), followed by B as a second choice and finally D as a last choice. + +Running a vote with the default options is simply: + +```bash +timrcv --input example.csv +``` + +Output: + +```text +[ INFO ranked_voting] run_voting_stats: Processing 6 votes +[ INFO ranked_voting] Processing 6 aggregated votes +[ INFO ranked_voting] Candidate: 1: A +[ INFO ranked_voting] Candidate: 2: B +[ INFO ranked_voting] Candidate: 3: C +[ INFO ranked_voting] Candidate: 4: D +[ INFO ranked_voting] Round 1 (winning threshold: 4) +[ INFO ranked_voting] 2 B -> running +[ INFO ranked_voting] 2 A -> running +[ INFO ranked_voting] 1 C -> running +[ INFO ranked_voting] 1 D -> eliminated:1 -> B, +[ INFO ranked_voting] Round 2 (winning threshold: 4) +[ INFO ranked_voting] 3 B -> running +[ INFO ranked_voting] 2 A -> running +[ INFO ranked_voting] 1 C -> eliminated:1 -> A, +[ INFO ranked_voting] Round 3 (winning threshold: 4) +[ INFO ranked_voting] 3 A -> running +[ INFO ranked_voting] 3 B -> eliminated:3 -> A, +[ INFO ranked_voting] Round 4 (winning threshold: 4) +[ INFO ranked_voting] 6 A -> elected +``` + +`timrcv` supports many options (input and output formats, validation of the candidates, configuration of the tabulating process, ...). + Look at the [configuration section](manual/index.html#configuration) of the manual for more details. + + + + + */ + mod builder; mod config; pub use builder::Builder; +pub mod manual; +pub mod quick_start; use log::{debug, info}; use std::{ @@ -217,6 +309,28 @@ pub fn run_election1( run_election(&builder) } +fn candidates_from_ballots(ballots: &[Ballot]) -> Vec { + // Take everyone from the election as a valid candidate. + let mut cand_set: HashSet = HashSet::new(); + for ballot in ballots.iter() { + for choice in ballot.candidates.iter() { + if let BallotChoice::Candidate(name) = choice { + cand_set.insert(name.clone()); + } + } + } + let mut cand_vec: Vec = cand_set.iter().cloned().collect(); + cand_vec.sort(); + cand_vec + .iter() + .map(|n| config::Candidate { + name: n.clone(), + code: None, + excluded: false, + }) + .collect() +} + /// Runs the voting algorithm with the given rules for the given votes. /// /// Arguments: @@ -227,17 +341,20 @@ pub fn run_election1( fn run_voting_stats( coll: &Vec, rules: &config::VoteRules, - candidates: &Option>, + candidates_o: &Option>, ) -> Result { info!("run_voting_stats: Processing {:?} votes", coll.len()); + let candidates = candidates_o + .to_owned() + .unwrap_or_else(|| candidates_from_ballots(coll)); + debug!( "run_voting_stats: candidates: {:?}, rules: {:?}", coll.len(), candidates, ); - // TODO: ensure candidates - let cr: CheckResult = checks(coll, &candidates.clone().unwrap(), rules)?; + let cr: CheckResult = checks(coll, &candidates, rules)?; let checked_votes = cr.votes; debug!( "run_voting_stats: Checked votes: {:?}, detected UWIs {:?}", @@ -596,13 +713,15 @@ fn run_one_round( } // Find the candidates to eliminate - let p = find_eliminated_candidates(&tally, rules, candidate_names, num_round); + let p = find_eliminated_candidates(&tally, rules, candidate_names, num_round)?; let resolved_tiebreak: TiebreakSituation = p.1; let eliminated_candidates: HashSet = p.0.iter().cloned().collect(); // TODO strategy to pick the winning candidates - assert!(!eliminated_candidates.is_empty(), "No candidate eliminated"); + if eliminated_candidates.is_empty() { + return Err(VotingErrors::NoCandidateToEliminate); + } debug!("run_one_round: tiebreak situation: {:?}", resolved_tiebreak); debug!("run_one_round: eliminated_candidates: {:?}", p.0); @@ -728,22 +847,22 @@ fn find_eliminated_candidates( rules: &config::VoteRules, candidate_names: &[(String, CandidateId)], num_round: u32, -) -> (Vec, TiebreakSituation) { +) -> Result<(Vec, TiebreakSituation), VotingErrors> { // Try to eliminate candidates in batch if rules.elimination_algorithm == EliminationAlgorithm::Batch { if let Some(v) = find_eliminated_candidates_batch(tally) { - return (v, TiebreakSituation::Clean); + return Ok((v, TiebreakSituation::Clean)); } } if let Some((v, tb)) = find_eliminated_candidates_single(tally, rules.tiebreak_mode, candidate_names, num_round) { - return (v, tb); + return Ok((v, tb)); } // No candidate to eliminate. // TODO check the conditions for this to happen. - unimplemented!("find_eliminated_candidates: No candidate to eliminate"); + Err(VotingErrors::EmptyElection) } fn find_eliminated_candidates_batch( diff --git a/ranked_voting/src/manual.rs b/ranked_voting/src/manual.rs new file mode 100644 index 0000000..566b591 --- /dev/null +++ b/ranked_voting/src/manual.rs @@ -0,0 +1,126 @@ +/*! + +This is the long-form manual for `ranked_voting` and `timrcv`. + +## Input formats + +The following formats are supported: +* `ess` ES&S company +* `dominion` Dominion company +* `cdf` NIST CDF +* `csv`, `csv_likert` Comma Separated Values in various flavours +* `msforms`, `msforms_likert`, `msforms_likert_transpose` Input from Microsoft Forms and Google Forms products. + +### `ess` + +Votes recorded in the ES&S format (Excel spreadsheet). + +### `dominion` + +Votes recorded in the format from the Dominion company. + +### `cdf` + +Votes recorded in the Common Data Format from NIST. + +Notes: +- only the JSON notation is currently supported (not the XML) +- only one election is supported + +### `msforms` + +Results from Microsoft Forms when using the ranking widget. +The input file is expected to be in Excel (.xlsx) format. +See the example in the `tests` directory. + +### `msforms_likert` + +Results from Microsoft Forms when using the 'Likert' input. It is also compatible with +Google Forms when candidates are the rows and choices are the columns. +The input file is expected to be in Excel (.xlsx) format. + +See the example in the `tests` directory. Your form is expected to be formatted as followed: + + +| | choice 1 | choice 2 | ... | +|-------------|----------|----------|-----| +| candidate A | | x | | +| candidate B | x | | | +| ... | | | | + +In this example, this vote would mark `candidate B` as the first choice and then `candidate A` as a second choice. + +In this case, both the names of the choices and of the candidates are mandatory. See the example `msforms_likert` for an example of a configuration file. + +### `msforms_likert_transpose` + +Results from Microsoft Forms when using the 'Likert' input with the candidates in the first row. +It is also compatible with Google Forms when the rows are the choices and the columns are +the candidates. The input file is expected to be in Excel (.xlsx) format. +See the example in the `tests` directory. Your form is expected to be formatted as followed: + +| | candidate A | candidate B | ... | +|---------------|-------------|-------------|-----| +| first choice | | x | | +| second choice | x | | | +| ... | | | | + +In this example, this vote would mark `candidate B` as the first choice and then `candidate A` as a second choice. + +In this case, both the names of the choices and of the candidates are mandatory. See the example `msforms_likert_transpose` for an example of a configuration file. + +### csv + +Simple CSV reader. Each column (in order) is considered to be a choice. The name of the choice in the header is not significant. + +```text +id,count,choice 1,choice 2,choice 3,choice 4 +id1,20,A,B,C,D +id2,20,A,C,B,D +``` + +The `id` and `count` columns are optional. Headers in the first row is optional. +See the [Configuration section](#configuration) on controling the optional rows and columns. + +### csv_likert + +Simple CSV reader sorted by candidates. This format is also created by Qualtrics polls. The file is expected to look as follows: + +```text +id,count,A,B,C,D +id1,20,1,2,3, +id2,20,1,3,2,4 +``` + +The `id` and `count` columns are optional. The candidate names must all be a column and defined in the first row of the CSV file. The numbers below are the ranks of this candidate for each ballot (or empty if this candidate was not ranked). + +## Configuration + +`timrcv` comes with sensible defaults but users may want to apply specific rules +(for example, how to treat blank choices). The program accepts a configuration file in JSON that follows the specification of the [RCVTab program]() + +See the [complete documentation](https://github.com/BrightSpots/rcv/blob/develop/config_file_documentation.txt) for more details. + Note that not all options are supported and that some options have been added to better control the use of CSV. + Contributions are welcome in this area. + +The deviations from the specification of RCVTab are documented below. + +> Note: this documenation is incomplete for now. + +Deviations for FileSource: + - added `count_column_index` (string or number, optional): the location of the column that + indicates the counts. If not provided, every vote will be assigned a count of 1. + + - added `excel_worksheet_name` (string, optional): for Excel-based inputs, the name of + the worksheet in Excel. + + - added `choices` (array of strings, optional): The list of labels for the choices. For example, if + the list is `["First choice", "Second choice"]`, then seeing `First choice` will be + intepreted as choice #1, and so on. + + +Deviations for OutputSettings: +- removed `generateCdfJson`: feature not supported +- removed `tabulateByPrecinct`: feature not supported + + */ diff --git a/ranked_voting/src/quick_start.rs b/ranked_voting/src/quick_start.rs new file mode 100644 index 0000000..f21be45 --- /dev/null +++ b/ranked_voting/src/quick_start.rs @@ -0,0 +1,99 @@ +/*! + +# Quick start with Google Forms + +This example shows you how to run an example end to end, using an online tool to collect the votes. This example uses Google Forms because it +is free to use and has a large limit (millions of votes). Other providers (Microsoft, Qualtrics) provide similar systems for free. + +This section assumes that you have an account on Google Drive (either through the free GMail service or a professional Google Workspace). +We would like to decide on the election between 3 candidates: Alice, Bob and Charlie. To do that, we are going to create a new Form in Google +Drive: + +![screen1](https://user-images.githubusercontent.com/7456/197564418-5ac0d1c7-c1cc-467f-be92-a5bc08626cb2.png) + +**Creating a poll** We are going to populate this form with one voting question. Use the **Multiple Choice Grid** type of widget to add a voting question. +The rows are the ranking choices (`1`, `2`, `3`, etc.), the columns are the candidates (`Alice`, `Bob`, `Charlie`). +The name of the question is also important as it will be the reference to refer later to the results. Here it is `Who do you want to vote for?` +With this style of poll, the first row is the first (most prefered) choice of candidate, the second row is the second choice, and so on. + +![screen2](https://user-images.githubusercontent.com/7456/197564679-973dbdb3-dab7-483b-aafa-5588dee944a5.png) + +If you enable the option "Require a response in each row", the voters will be force to fill in all the ranks. + +**Voting process** You can then share the voting form with all the voters, for instance using the "Share" button. It is possible to add other elements to the forms, +such as other votes, other polls, etc. + +**Getting the results** After the poll is ended, all the results must be collected to the right format. In the `Responses` form, +use the `Create spreadsheet` option. + +![screen3](https://user-images.githubusercontent.com/7456/197564967-ee154c82-0e69-4167-887a-d4b76d53e434.png) + +This will lead you to the online spreadsheet that should look like the following: + +![screen4](https://user-images.githubusercontent.com/7456/197563637-7c121a4e-6675-48cf-bf88-e4d75c244e78.png) + +Download the spreadsheet on our computer in the **Excel format** (xlsx). + +Run `timrcv` with the following command (the name of the file may differ for you). + + +```bash +timrcv -i 'test voting.xlsx' --input-type msforms_likert_transpose \ +--choices "Who do you want to vote for? [First choice]" \ +--choices "Who do you want to vote for? [Choice #2]" \ +--choices "Who do you want to vote for? [Choice #3]" +``` + +The program needs to now which columns in the spreadsheet corresponds to choices. This is provided with the `--choices` flag. Each of the input is the name of +the column in the first row of the spreadsheet. The order of specifying the `--choices` flags is important. It will control which choice is the first then +the second then the third and so on. + +After running this command, you should see the outcome of the election; + +```text +[2022-10-21T09:55:59Z INFO ranked_voting] run_voting_stats: Processing 3 votes +[2022-10-21T09:55:59Z INFO ranked_voting] Processing 3 aggregated votes +[2022-10-21T09:55:59Z INFO ranked_voting] Candidate: 1: Alice +[2022-10-21T09:55:59Z INFO ranked_voting] Candidate: 2: Bob +[2022-10-21T09:55:59Z INFO ranked_voting] Candidate: 3: Charlie +[2022-10-21T09:55:59Z INFO ranked_voting] Round 1 (winning threshold: 2) +[2022-10-21T09:55:59Z INFO ranked_voting] 2 Alice -> elected +[2022-10-21T09:55:59Z INFO ranked_voting] 1 Bob -> eliminated:1 exhausted, +[2022-10-21T09:55:59Z INFO ranked_voting] 0 Charlie -> eliminated: +``` + +With these few example votes, `Alice` is declared the winner of this election +using the Instant-Runoff Voting scheme. Your results will vary depending on +the contents of the votes. + +**Display the output** The output can also be displayed in an interactive form, for example using the [RCVis website](https://rcvis.com/). + `timrcv` can generate +a file in the JSON format that summarizes the election results in a format + compatible with RCVis. This is controled with the `--out` flag. +Going back to our example, we create an output summary: + +```bash +timrcv -i 'test voting.xlsx' --input-type msforms_likert_transpose \ +--choices "Who do you want to vote for? [First choice]" \ +--choices "Who do you want to vote for? [Choice #2]" \ +--choices "Who do you want to vote for? [Choice #3]" +--out my_election_results.json +``` + +Create an account or log into your account on the [RCVis website](https://rcvis.com) + +Select "Upload election result". **Do not upload election results +to RCVis if the results are sensitive. The results on RCVis are publicly accessible** + +You should obtain a visualization similar to this one: + +![screen6](https://user-images.githubusercontent.com/7456/197565476-ad776726-c49d-4a29-8ab8-147c546e0f28.png) + +It is the end of this quick start. You can explore the following sections: +- if you are trying to recreate a specific election outcome from official tabulated data, + you should check the documentation of the `--config` flag. `timrcv` accepts many options in a + JSON format to control exactly how an election can be run. See the [configuration section](../manual/index.html#configuration). + - if your input is in a different format, check the input documentation page. + + +*/ diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..b15b2cf --- /dev/null +++ b/src/args.rs @@ -0,0 +1,44 @@ +use clap::Parser; + +/// This is a ranked voting tabulation program. +#[derive(Parser, Debug, Clone)] +#[clap(author, version, about, long_about = None)] +pub struct Args { + /// (file path, optional) The file containing the election data. (Only JSON election descriptions are currently supported) + /// For more information about the file format, read the documentation at + #[clap(short, long, value_parser)] + pub config: Option, + /// (file path) A reference file containing the outcome of an election in JSON format. If provided, timrcv will + /// check that the tabulated output matches the reference. + #[clap(short, long, value_parser)] + pub reference: Option, + + /// (file path, 'stdout' or empty) If specified, the summary of the election will be written in JSON format to the given + /// location. Setting this option overrides the path that may be specified with the --config option. + #[clap(short, long, value_parser)] + pub out: Option, + + /// (file path or empty) If specified, the summary of the election will be written in JSON format to the given + /// location. Setting this option overrides what may be specified with the --data option. + #[clap(short, long, value_parser)] + pub input: Option, + + /// (default csv) The type of the input. See documentation for all the input types. + #[clap(long, value_parser)] + pub input_type: Option, + + /// (list of comma-separated values or not specified) If specified, the list of labels for the ranks. This is useful for + /// Likert-like styles of inputs in which there is no natural order. It should correspond to the entries in the first row + /// of the input. + #[clap(long, value_parser)] + pub choices: Option>, + + /// (default Form1) When using an Excel file, indicates the name of the worksheet to use. + #[clap(long, value_parser)] + pub excel_worksheet_name: Option, + + // Other arguments + /// If passed as an argument, will turn on verbose logging to the standard output. + #[clap(long, takes_value = false)] + pub verbose: bool, +} diff --git a/src/main.rs b/src/main.rs index 4096bd8..0553d3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,11 @@ -pub mod rcv; - -use crate::rcv::run_election; -use crate::rcv::RcvResult; - use clap::Parser; - use env_logger::Env; -// https://github.com/tjhunter/timrcv#readme - -/// This is a ranked voting tabulation program. -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] -struct Args { - /// (file path, optional) The file containing the election data. (Only JSON election descriptions are currently supported) - /// For more information about the file format, read the documentation at - #[clap(short, long, value_parser)] - config: Option, - /// (file path) A reference file containing the outcome of an election in JSON format. If provided, timrcv will - /// check that the tabulated output matches the reference. - #[clap(short, long, value_parser)] - reference: Option, - - /// (file path, 'stdout' or empty) If specified, the summary of the election will be written in JSON format to the given - /// location. Setting this option overrides the path that may be specified with the --config option. - #[clap(short, long, value_parser)] - out: Option, - - /// (file path or empty) If specified, the summary of the election will be written in JSON format to the given - /// location. Setting this option overrides what may be specified with the --data option. - #[clap(short, long, value_parser)] - input: Option, - - /// If passed as an argument, will turn on verbose logging to the standard output. - #[clap(long, takes_value = false)] - verbose: bool, -} +mod args; +pub mod rcv; +use crate::args::Args; +use crate::rcv::run_election; +use crate::rcv::RcvResult; const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); @@ -54,5 +24,15 @@ fn main() -> RcvResult<()> { }); let _ = env_logger::try_init_from_env(env); - run_election(args.config, args.reference, args.input, args.out, false).map(|_| ()) + let args2 = args.clone(); + + run_election( + args.config, + args.reference, + args.input, + args.out, + false, + Some(args2), + ) + .map(|_| ()) } diff --git a/src/rcv.rs b/src/rcv.rs index cdc353b..b9862d2 100644 --- a/src/rcv.rs +++ b/src/rcv.rs @@ -15,7 +15,6 @@ use serde_json::Value as JSValue; use std::collections::HashSet; use text_diff::print_diff; -use crate::rcv::config_reader::*; mod config_reader; pub mod io_cdf; pub mod io_common; @@ -24,6 +23,9 @@ pub mod io_dominion; mod io_ess; mod io_msforms; +use crate::args::Args; +use crate::rcv::config_reader::*; + #[derive(Debug, Snafu)] pub enum RcvError { // General @@ -401,8 +403,9 @@ pub fn run_election( in_path: Option, out_path: Option, override_out_path: bool, + args_o: Option, ) -> RcvResult<()> { - let config: RcvConfig = { + let mut config: RcvConfig = { if let Some(config_path) = config_path_o.as_ref() { let config_p = Path::new(config_path.as_str()); debug!("Opening file {:?}", config_p); @@ -414,6 +417,23 @@ pub fn run_election( } }; + // Adding all the extra rules that may be required from the arguments + if let Some(args) = args_o { + for input in config.cvr_file_sources.iter_mut() { + if let Some(choices) = args.choices.as_ref() { + input.choices = Some(choices.clone()); + } + if let Some(input_type) = args.input_type.as_ref() { + input.provider = input_type.clone(); + } + + if let Some(name) = args.excel_worksheet_name.as_ref() { + input.excel_worksheet_name = Some(name.clone()); + } + } + } + + // Moved here because the borrow checker struggles inside the closure. let current_dir = std::env::current_dir() .ok() .context(MissingParentDirSnafu {})?; @@ -541,6 +561,7 @@ fn run_election_test(test_name: &str, config_lpath: &str, summary_lpath: &str, i None, None, true, + None, ); if let Err(e) = res { warn!("Error occured {:?}", e); diff --git a/src/rcv/io_msforms.rs b/src/rcv/io_msforms.rs index dabdc87..2d28100 100644 --- a/src/rcv/io_msforms.rs +++ b/src/rcv/io_msforms.rs @@ -284,22 +284,39 @@ fn get_ranked_choices(cfs: &FileSource) -> BRcvResult> { } fn get_range(path: &String, cfs: &FileSource) -> BRcvResult> { - let worksheet_name = cfs - .excel_worksheet_name - .clone() - .unwrap_or_else(|| "Form1".to_string()); + let worksheet_name_o = cfs.excel_worksheet_name.clone(); debug!( "read_excel_file: path: {:?} worksheet: {:?}", - &path, &worksheet_name + &path, &worksheet_name_o ); let p = path.clone(); let mut workbook: Xlsx<_> = open_workbook(p).context(OpeningExcelSnafu { path: path.clone() })?; - let wrange = workbook - .worksheet_range(&worksheet_name) - .context(EmptyExcelSnafu {})? - .context(OpeningExcelSnafu { path: path.clone() })?; - - Ok(wrange) + // A worksheet name was provided, use it. + if let Some(worksheet_name) = worksheet_name_o { + let wrange = workbook + .worksheet_range(&worksheet_name) + .context(EmptyExcelSnafu {})? + .context(OpeningExcelSnafu { path: path.clone() })?; + + Ok(wrange) + } else { + let all_worksheets = workbook.worksheets(); + match all_worksheets.as_slice() { + [] => unimplemented!("Empty worksheet"), + [(worksheet_name, wrange)] => { + debug!( + "read_excel_file: path: {:?} worksheet: {:?}", + &path, &worksheet_name + ); + Ok(wrange.clone()) + } + _ => { + unimplemented!( + "read_excel_file: too many worksheets, the worksheet name must be provided" + ); + } + } + } } diff --git a/tests/msforms_1/msforms_1_config.json b/tests/msforms_1/msforms_1_config.json index 143b140..8269e54 100644 --- a/tests/msforms_1/msforms_1_config.json +++ b/tests/msforms_1/msforms_1_config.json @@ -17,8 +17,9 @@ "overvoteLabel": "", "undervoteLabel": "", "undeclaredWriteInLabel": "", - "idColumnIndex" : "A", - "firstVoteColumnIndex" : "F" + "idColumnIndex": "A", + "firstVoteColumnIndex": "F", + "excelWorksheetName": "Form1" } ], "candidates": [ @@ -30,14 +31,15 @@ }, { "name": "Option 3" - }], - "rules" : { + } + ], + "rules": { "tiebreakMode": "useCandidateOrder", "overvoteRule": "exhaustImmediately", "winnerElectionMode": "singleWinnerMajority", "numberOfWinners": "1", "maxSkippedRanksAllowed": "1", "maxRankingsAllowed": "8", - "rulesDescription" : "Simple" + "rulesDescription": "Simple" } -} +} \ No newline at end of file diff --git a/tests/msforms_likert/msforms_likert_config.json b/tests/msforms_likert/msforms_likert_config.json index 0cddae9..0fb00ac 100644 --- a/tests/msforms_likert/msforms_likert_config.json +++ b/tests/msforms_likert/msforms_likert_config.json @@ -17,8 +17,15 @@ "overvoteLabel": "", "undervoteLabel": "", "undeclaredWriteInLabel": "", - "idColumnIndex" : "A", - "choices": ["Option 2-1","Option 2-2","Option 2-3","Option 2-4","Option 2-5"] + "idColumnIndex": "A", + "excelWorksheetName": "Form1", + "choices": [ + "Option 2-1", + "Option 2-2", + "Option 2-3", + "Option 2-4", + "Option 2-5" + ] } ], "candidates": [ @@ -37,15 +44,15 @@ { "name": "candidate 2-5" } - ], - "rules" : { + ], + "rules": { "tiebreakMode": "useCandidateOrder", "overvoteRule": "exhaustImmediately", "winnerElectionMode": "singleWinnerMajority", "numberOfWinners": "1", - "batchElimination" : true, + "batchElimination": true, "maxSkippedRanksAllowed": "1", "maxRankingsAllowed": "8", - "rulesDescription" : "Simple" + "rulesDescription": "Simple" } -} +} \ No newline at end of file diff --git a/tests/msforms_likert_transpose/msforms_likert_transpose_config.json b/tests/msforms_likert_transpose/msforms_likert_transpose_config.json index 65bb81a..2ad2bae 100644 --- a/tests/msforms_likert_transpose/msforms_likert_transpose_config.json +++ b/tests/msforms_likert_transpose/msforms_likert_transpose_config.json @@ -17,8 +17,13 @@ "overvoteLabel": "", "undervoteLabel": "", "undeclaredWriteInLabel": "", - "idColumnIndex" : "A", - "choices": ["choice 3-1","choice 3-2","choice 3-3"] + "idColumnIndex": "A", + "excelWorksheetName": "Form1", + "choices": [ + "choice 3-1", + "choice 3-2", + "choice 3-3" + ] } ], "candidates": [ @@ -37,15 +42,15 @@ { "name": "candidate 3-5" } - ], - "rules" : { + ], + "rules": { "tiebreakMode": "useCandidateOrder", "overvoteRule": "exhaustImmediately", "winnerElectionMode": "singleWinnerMajority", "numberOfWinners": "1", - "batchElimination" : true, + "batchElimination": true, "maxSkippedRanksAllowed": "1", "maxRankingsAllowed": "8", - "rulesDescription" : "Simple" + "rulesDescription": "Simple" } -} +} \ No newline at end of file