use std::path::PathBuf;
use clap::Subcommand;
use serde_json::Value;
use vta_cli_common::render::{
BOLD, DIM, GREEN, RESET, YELLOW, bin_name, is_json_output, print_json,
};
use vta_sdk::session::TransportChoice;
use vtc_client::VtcError;
use vtc_client::elections::{certify, create};
use crate::vtc::{self as vtc_target, Connected, VtcTarget};
type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
#[derive(Subcommand)]
pub enum ElectionsCommands {
Create {
manifest: PathBuf,
},
List {
#[arg(long)]
state: Option<String>,
},
Show { election_id: String },
Close {
election_id: String,
#[arg(long)]
reason: Option<String>,
},
Cancel {
election_id: String,
#[arg(long)]
reason: String,
},
Certify {
election_id: String,
#[arg(long = "attestation")]
attestations: Vec<PathBuf>,
},
}
pub async fn run(
command: ElectionsCommands,
keyring_key: &str,
target: &VtcTarget,
transport: TransportChoice,
) -> CliResult {
let vtc = vtc_target::connect_for_tasks(keyring_key, target, transport).await?;
let outcome = run_command(command, &vtc, keyring_key).await;
vtc.client.shutdown().await;
outcome
}
fn read_json(path: &PathBuf) -> CliResult<Value> {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
Ok(serde_json::from_str(&raw).map_err(|e| format!("{} is not JSON: {e}", path.display()))?)
}
async fn run_command(command: ElectionsCommands, vtc: &Connected, keyring_key: &str) -> CliResult {
let key = crate::access::signing_key(keyring_key)?;
let c = &vtc.client;
match command {
ElectionsCommands::Create { manifest } => {
let manifest = read_json(&manifest)?;
let id = manifest["electionId"]
.as_str()
.unwrap_or_default()
.to_string();
let box_did = manifest["ballotBox"]
.as_str()
.unwrap_or_default()
.to_string();
let created = c
.elections_create(manifest, &key)
.await
.map_err(|e| refusal(vtc, e, &id, Some(&box_did)))?;
if is_json_output() {
return Ok(print_json(&created)?);
}
let state = created.state.to_string();
println!(
"{GREEN}created{RESET} {BOLD}{id}{RESET} — {state} (manifest {})",
created.manifest_digest.as_str()
);
if state == "pendingRegistration" {
println!(
" {DIM}the ballot box has not answered yet; the community keeps delivering the \
registration, and no ticket is issued until it is registered{RESET}"
);
}
Ok(())
}
ElectionsCommands::List { state } => {
let mut all = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = c
.elections_list(state.as_deref(), cursor.as_deref(), Some(100), &key)
.await
.map_err(|e| refusal(vtc, e, "", None))?;
all.extend(page.elections);
match page.next {
Some(n) => cursor = Some(n.to_string()),
None => break,
}
}
if is_json_output() {
return Ok(print_json(&all)?);
}
if all.is_empty() {
println!("{DIM}no elections{RESET}");
}
for e in &all {
let title = e.title.as_ref().map(|t| t.to_string()).unwrap_or_default();
println!(
"{BOLD}{}{RESET} {} {} → {}{} {DIM}{title}{RESET}",
e.election_id.as_str(),
e.state,
e.voting_opens,
e.voting_closes,
if e.binding { " binding" } else { "" },
);
}
Ok(())
}
ElectionsCommands::Show { election_id } => {
let shown = c
.elections_show(&election_id, &key)
.await
.map_err(|e| refusal(vtc, e, &election_id, None))?;
if is_json_output() {
return Ok(print_json(&shown)?);
}
let v = serde_json::to_value(&shown)?;
println!(
"{BOLD}{election_id}{RESET} {}",
v["state"].as_str().unwrap_or_default()
);
println!(
" manifest {}",
v["manifestDigest"].as_str().unwrap_or_default()
);
println!(
" ballot box {} ({}{})",
v["disclosure"]["ballotBox"].as_str().unwrap_or_default(),
v["disclosure"]["ballotBoxOperator"]
.as_str()
.unwrap_or_default(),
if v["disclosure"]["sameOperator"] == true {
", run by this community's operator"
} else {
""
}
);
println!(
" voting {} → {}",
v["manifest"]["votingOpens"].as_str().unwrap_or_default(),
v["manifest"]["votingCloses"].as_str().unwrap_or_default()
);
if let Some(n) = v["ticketsIssued"].as_u64() {
println!(" tickets {n} issued");
}
println!(
" checkpoints {}",
v["checkpoints"].as_array().map_or(0, Vec::len)
);
if v.get("certified").is_some() {
println!(
" {GREEN}certified{RESET} {}",
v["certified"]["certifiedAt"].as_str().unwrap_or_default()
);
}
Ok(())
}
ElectionsCommands::Close {
election_id,
reason,
} => {
let closed = c
.elections_close(&election_id, reason.as_deref(), &key)
.await
.map_err(|e| refusal(vtc, e, &election_id, None))?;
if is_json_output() {
return Ok(print_json(&closed)?);
}
println!("{GREEN}closed{RESET} {election_id} — {}", closed.state);
Ok(())
}
ElectionsCommands::Cancel {
election_id,
reason,
} => {
let cancelled = c
.elections_cancel(&election_id, &reason, &key)
.await
.map_err(|e| refusal(vtc, e, &election_id, None))?;
if is_json_output() {
return Ok(print_json(&cancelled)?);
}
println!(
"{GREEN}cancelled{RESET} {election_id} — {}",
cancelled.state
);
Ok(())
}
ElectionsCommands::Certify {
election_id,
attestations,
} => {
let attestations = attestations
.iter()
.map(read_json)
.collect::<CliResult<Vec<Value>>>()?;
let certified = c
.elections_certify(&election_id, attestations, &key)
.await
.map_err(|e| refusal(vtc, e, &election_id, None))?;
if is_json_output() {
return Ok(print_json(&certified)?);
}
let v = serde_json::to_value(&certified)?;
println!(
"{GREEN}certified{RESET} {election_id}: {} ballots counted, board of {} entries",
v["result"]["ballotsCounted"], v["head"]["treeSize"]
);
for q in v["result"]["questions"].as_array().into_iter().flatten() {
println!(
" {BOLD}{}{RESET} {}",
q["question"].as_str().unwrap_or_default(),
q
);
}
Ok(())
}
}
}
fn declared_code(err: &VtcError) -> Option<&'static str> {
let text = match err {
VtcError::Http { body, .. } => body.as_str(),
VtcError::Refused { document } => document.as_str(),
VtcError::NotFound { code, .. } => code.as_str(),
_ => return None,
};
create::ERROR_CODES
.iter()
.chain(certify::ERROR_CODES)
.map(|d| d.code)
.find(|c| text.contains(&format!("\"{c}\"")) || text == *c)
}
fn suggested_fix(code: &str, election_id: &str, ballot_box: Option<&str>) -> Option<String> {
let bin = bin_name();
Some(match code {
c if c == create::error_codes::BALLOT_BOX_NOT_TRUSTED.code => format!(
"{} is not one of this community's trusted voting services. A host operator adds it \
to the VTC's config.toml and restarts the VTC:\n\n [[voting.trusted_ballot_boxes]]\n \
did = \"{}\"\n operator = \"<who runs it>\"\n same_operator = false",
ballot_box.unwrap_or("the ballot box"),
ballot_box.unwrap_or("<its DID>")
),
c if c == create::error_codes::ELECTION_EXISTS.code => format!(
"this community already holds an election called {election_id}. Pick another \
electionId, or see it with:\n {bin} elections show {election_id}"
),
c if c == certify::error_codes::NOT_TALLIED.code => format!(
"the ballot box has not published a final result yet. Check where it stands with:\n \
{bin} elections show {election_id}"
),
_ => return None,
})
}
fn refusal(
vtc: &Connected,
err: VtcError,
election_id: &str,
ballot_box: Option<&str>,
) -> Box<dyn std::error::Error> {
if let VtcError::Parked { action_id, message } = &err {
return format!(
"{message}\n A binding election takes another administrator's consent. Nothing more \
to send: it is created when the approval lands. Follow it with `{bin} actions show \
{action_id}`.",
bin = bin_name()
)
.into();
}
let text = err.to_string();
if let Some(fix) = declared_code(&err).and_then(|c| suggested_fix(c, election_id, ballot_box)) {
return format!("{fix}\n({text})").into();
}
if text.contains("stepUpRequest") || text.contains("passkey gesture") {
return format!(
"{YELLOW}the VTC needs a passkey gesture from {} first{RESET}: approve it in the admin \
console, then run the same command again.\n({text})",
vtc.client_did
)
.into();
}
format!("the VTC refused {}: {text}", vtc.client_did).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn refused(code: &str) -> VtcError {
VtcError::Refused {
document: serde_json::json!({ "payload": { "code": code, "message": "no" } })
.to_string(),
}
}
#[test]
fn an_untrusted_ballot_box_prints_the_config_to_add() {
let code =
declared_code(&refused(create::error_codes::BALLOT_BOX_NOT_TRUSTED.code)).unwrap();
let fix = suggested_fix(code, "agm", Some("did:web:ballots.example")).unwrap();
assert!(fix.contains("[[voting.trusted_ballot_boxes]]"), "{fix}");
assert!(fix.contains("did = \"did:web:ballots.example\""), "{fix}");
}
#[test]
fn a_taken_identifier_points_at_the_election() {
let code = declared_code(&refused(create::error_codes::ELECTION_EXISTS.code)).unwrap();
assert!(
suggested_fix(code, "agm", None)
.unwrap()
.contains("elections show agm")
);
}
}