diff --git a/src/cli/convert.rs b/src/cli/convert.rs
new file mode 100644
index 0000000..2f71ff6
--- /dev/null
+++ b/src/cli/convert.rs
@@ -0,0 +1,120 @@
+/* OpenTally: Open-source election vote counting
+ * Copyright © 2021 Lee Yingtong Li (RunasSudo)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+use crate::election::Election;
+use crate::numbers::Rational;
+use crate::parser;
+use crate::writer;
+
+use clap::{AppSettings, Clap};
+
+use std::fs::File;
+
+/// Convert between different ballot data formats
+#[derive(Clap)]
+#[clap(setting=AppSettings::DeriveDisplayOrder)]
+pub struct SubcmdOptions {
+ /// Path to the input data file
+ #[clap(help_heading=Some("INPUT/OUTPUT"))]
+ infile: String,
+
+ /// Path to the output data file
+ #[clap(help_heading=Some("INPUT/OUTPUT"))]
+ outfile: String,
+
+ /// Format of input file
+ #[clap(help_heading=Some("INPUT/OUTPUT"), short, long, possible_values=&["blt", "csp"], value_name="format")]
+ r#in: Option,
+
+ /// Format of output file
+ #[clap(help_heading=Some("INPUT/OUTPUT"), short, long, possible_values=&["blt", "csp"], value_name="format")]
+ out: Option,
+
+ /// Number of seats
+ #[clap(help_heading=Some("ELECTION SPECIFICATION"), long)]
+ seats: Option,
+}
+
+/// Entrypoint for subcommand
+pub fn main(mut cmd_opts: SubcmdOptions) -> Result<(), i32> {
+ // Auto-detect input/output formats
+ if cmd_opts.r#in == None {
+ if cmd_opts.infile.ends_with(".blt") {
+ cmd_opts.r#in = Some("blt".to_string());
+ } else if cmd_opts.infile.ends_with(".csp") {
+ cmd_opts.r#in = Some("csp".to_string());
+ } else {
+ println!("Error: --in not specified and format cannot be determined from input filename");
+ return Err(1);
+ }
+ }
+ if cmd_opts.out == None {
+ if cmd_opts.outfile.ends_with(".blt") {
+ cmd_opts.out = Some("blt".to_string());
+ } else if cmd_opts.outfile.ends_with(".csp") {
+ cmd_opts.out = Some("csp".to_string());
+ } else {
+ println!("Error: --out not specified and format cannot be determined from output filename");
+ return Err(1);
+ }
+ }
+
+ // Read input file
+ let election: Election;
+
+ match cmd_opts.r#in.as_deref().unwrap() {
+ "blt" => {
+ match parser::blt::parse_path(cmd_opts.infile) {
+ Ok(e) => {
+ election = e;
+ }
+ Err(err) => {
+ println!("Syntax Error: {}", err);
+ return Err(1);
+ }
+ }
+ }
+ "csp" => {
+ match cmd_opts.seats {
+ Some(seats) => {
+ let file = File::open(cmd_opts.infile).expect("IO Error");
+ election = parser::csp::parse_reader(file, seats);
+ }
+ None => {
+ println!("Error: --seats must be specified with CSP input");
+ return Err(1);
+ }
+ }
+ }
+ _ => unreachable!()
+ };
+
+ // Write output file
+ let output = File::create(cmd_opts.outfile).expect("IO Error");
+
+ match cmd_opts.out.as_deref().unwrap() {
+ "blt" => {
+ writer::blt::write(election, output);
+ }
+ "csp" => {
+ writer::csp::write(election, output);
+ }
+ _ => unreachable!()
+ }
+
+ return Ok(());
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
new file mode 100644
index 0000000..a244dd2
--- /dev/null
+++ b/src/cli/mod.rs
@@ -0,0 +1,21 @@
+/* OpenTally: Open-source election vote counting
+ * Copyright © 2021 Lee Yingtong Li (RunasSudo)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/// Convert between different ballot data formats
+pub mod convert;
+/// Count a single transferable vote (STV) election
+pub mod stv;
diff --git a/src/cli/stv.rs b/src/cli/stv.rs
new file mode 100644
index 0000000..8a50f21
--- /dev/null
+++ b/src/cli/stv.rs
@@ -0,0 +1,369 @@
+/* OpenTally: Open-source election vote counting
+ * Copyright © 2021 Lee Yingtong Li (RunasSudo)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+use crate::constraints::Constraints;
+use crate::election::{CandidateState, CountState, Election};
+use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
+use crate::parser::blt;
+use crate::stv::{self, STVOptions};
+use crate::ties;
+
+use clap::{AppSettings, Clap};
+
+use std::cmp::max;
+use std::fs::File;
+use std::io::{self, BufRead};
+use std::ops;
+
+/// Count a single transferable vote (STV) election
+#[derive(Clap)]
+#[clap(setting=AppSettings::DeriveDisplayOrder)]
+pub struct SubcmdOptions {
+ // ----------------
+ // -- File input --
+
+ /// Path to the BLT file to be counted
+ filename: String,
+
+ // ----------------------
+ // -- Numbers settings --
+
+ /// Numbers mode
+ #[clap(help_heading=Some("NUMBERS"), short, long, possible_values=&["rational", "fixed", "gfixed", "float64"], default_value="rational", value_name="mode")]
+ numbers: String,
+
+ /// Decimal places if --numbers fixed
+ #[clap(help_heading=Some("NUMBERS"), long, default_value="5", value_name="dps")]
+ decimals: usize,
+
+ /// Convert ballots with value >1 to multiple ballots of value 1
+ #[clap(help_heading=Some("NUMBERS"), long)]
+ normalise_ballots: bool,
+
+ // -----------------------
+ // -- Rounding settings --
+
+ /// Round surplus fractions to specified decimal places
+ #[clap(help_heading=Some("ROUNDING"), long, alias="round-tvs", value_name="dps")]
+ round_surplus_fractions: Option,
+
+ /// Round ballot values to specified decimal places
+ #[clap(help_heading=Some("ROUNDING"), long, alias="round-weights", value_name="dps")]
+ round_values: Option,
+
+ /// Round votes to specified decimal places
+ #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
+ round_votes: Option,
+
+ /// Round quota to specified decimal places
+ #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
+ round_quota: Option,
+
+ /// (Gregory STV) How to calculate votes to credit to candidates in surplus transfers
+ #[clap(help_heading=Some("ROUNDING"), long, possible_values=&["by_value", "per_ballot"], default_value="by_value", value_name="mode")]
+ sum_surplus_transfers: String,
+
+ /// (Meek STV) Limit for stopping iteration of surplus distribution
+ #[clap(help_heading=Some("ROUNDING"), long, default_value="0.001%", value_name="tolerance")]
+ meek_surplus_tolerance: String,
+
+ // -----------
+ // -- Quota --
+
+ /// Quota type
+ #[clap(help_heading=Some("QUOTA"), short, long, possible_values=&["droop", "hare", "droop_exact", "hare_exact"], default_value="droop")]
+ quota: String,
+
+ /// Whether to elect candidates on meeting (geq) or strictly exceeding (gt) the quota
+ #[clap(help_heading=Some("QUOTA"), short='c', long, possible_values=&["geq", "gt"], default_value="gt", value_name="criterion")]
+ quota_criterion: String,
+
+ /// Whether to apply a form of progressive quota
+ #[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97", "ers76", "dynamic_by_total", "dynamic_by_active"], default_value="static", value_name="mode")]
+ quota_mode: String,
+
+ // ------------------
+ // -- STV variants --
+
+ /// Tie-breaking method
+ #[clap(help_heading=Some("STV VARIANTS"), short='t', long, possible_values=&["forwards", "backwards", "random", "prompt"], default_value="prompt", value_name="methods")]
+ ties: Vec,
+
+ /// Random seed to use with --ties random
+ #[clap(help_heading=Some("STV VARIANTS"), long, value_name="seed")]
+ random_seed: Option,
+
+ /// Method of surplus distributions
+ #[clap(help_heading=Some("STV VARIANTS"), short='s', long, possible_values=&["wig", "uig", "eg", "meek", "cincinnati", "hare"], default_value="wig", value_name="method")]
+ surplus: String,
+
+ /// (Gregory STV) Order to distribute surpluses
+ #[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["by_size", "by_order"], default_value="by_size", value_name="order")]
+ surplus_order: String,
+
+ /// (Gregory STV) Examine only transferable papers during surplus distributions
+ #[clap(help_heading=Some("STV VARIANTS"), long)]
+ transferable_only: bool,
+
+ /// (Gregory STV) Method of exclusions
+ #[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["single_stage", "by_value", "by_source", "parcels_by_order", "wright"], default_value="single_stage", value_name="method")]
+ exclusion: String,
+
+ /// (Meek STV) NZ Meek STV behaviour: Iterate keep values one round before candidate exclusion
+ #[clap(help_heading=Some("STV VARIANTS"), long)]
+ meek_nz_exclusion: bool,
+
+ /// (Cincinnati/Hare) Method of drawing a sample
+ #[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["stratified", "by_order", "nth_ballot"], default_value="stratified", value_name="method")]
+ sample: String,
+
+ /// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer
+ #[clap(help_heading=Some("STV VARIANTS"), long)]
+ sample_per_ballot: bool,
+
+ // -------------------------
+ // -- Count optimisations --
+
+ /// Continue count even if continuing candidates fill all remaining vacancies
+ #[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
+ no_early_bulk_elect: bool,
+
+ /// Use bulk exclusion
+ #[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
+ bulk_exclude: bool,
+
+ /// Defer surplus distributions if possible
+ #[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
+ defer_surpluses: bool,
+
+ /// Elect candidates only when their surpluses are distributed; (Meek STV) Wait for keep values to converge before electing candidates
+ #[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
+ no_immediate_elect: bool,
+
+ /// On exclusion, exclude any candidate with fewer than this many votes
+ #[clap(help_heading=Some("COUNT OPTIMISATIONS"), long, default_value="0", value_name="votes")]
+ min_threshold: String,
+
+ // -----------------
+ // -- Constraints --
+
+ /// Path to a CON file specifying constraints
+ #[clap(help_heading=Some("CONSTRAINTS"), long)]
+ constraints: Option,
+
+ /// Mode of handling constraints
+ #[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")]
+ constraint_mode: String,
+
+ // ----------------------
+ // -- Display settings --
+
+ /// Hide excluded candidates from results report
+ #[clap(help_heading=Some("DISPLAY"), long)]
+ hide_excluded: bool,
+
+ /// Sort candidates by votes in results report
+ #[clap(help_heading=Some("DISPLAY"), long)]
+ sort_votes: bool,
+
+ /// Print votes to specified decimal places in results report
+ #[clap(help_heading=Some("DISPLAY"), long, default_value="2", value_name="dps")]
+ pp_decimals: usize,
+}
+
+/// Entrypoint for subcommand
+pub fn main(cmd_opts: SubcmdOptions) -> Result<(), i32> {
+ // Read and count election according to --numbers
+ if cmd_opts.numbers == "rational" {
+ let mut election = election_from_file(&cmd_opts.filename)?;
+ maybe_load_constraints(&mut election, &cmd_opts.constraints);
+
+ // Must specify :: here and in a few other places because ndarray causes E0275 otherwise
+ count_election::(election, cmd_opts)?;
+ } else if cmd_opts.numbers == "float64" {
+ let mut election = election_from_file(&cmd_opts.filename)?;
+ maybe_load_constraints(&mut election, &cmd_opts.constraints);
+ count_election::(election, cmd_opts)?;
+ } else if cmd_opts.numbers == "fixed" {
+ Fixed::set_dps(cmd_opts.decimals);
+
+ let mut election = election_from_file(&cmd_opts.filename)?;
+ maybe_load_constraints(&mut election, &cmd_opts.constraints);
+ count_election::(election, cmd_opts)?;
+ } else if cmd_opts.numbers == "gfixed" {
+ GuardedFixed::set_dps(cmd_opts.decimals);
+
+ let mut election = election_from_file(&cmd_opts.filename)?;
+ maybe_load_constraints(&mut election, &cmd_opts.constraints);
+ count_election::(election, cmd_opts)?;
+ }
+
+ return Ok(());
+}
+
+fn election_from_file(path: &str) -> Result, i32> {
+ match blt::parse_path(path) {
+ Ok(e) => return Ok(e),
+ Err(err) => {
+ println!("Syntax Error: {}", err);
+ return Err(1);
+ }
+ }
+}
+
+fn maybe_load_constraints(election: &mut Election, constraints: &Option) {
+ if let Some(c) = constraints {
+ let file = File::open(c).expect("IO Error");
+ let lines = io::BufReader::new(file).lines();
+ election.constraints = Some(Constraints::from_con(lines.map(|r| r.expect("IO Error").to_string()).into_iter()));
+ }
+}
+
+fn count_election(mut election: Election, cmd_opts: SubcmdOptions) -> Result<(), i32>
+where
+ for<'r> &'r N: ops::Add<&'r N, Output=N>,
+ for<'r> &'r N: ops::Sub<&'r N, Output=N>,
+ for<'r> &'r N: ops::Mul<&'r N, Output=N>,
+ for<'r> &'r N: ops::Div<&'r N, Output=N>,
+ for<'r> &'r N: ops::Neg