use std::collections::{HashMap, HashSet};
use clap::Args;
use mk_codec::string_layer::StringLayerHeader;
use mk_codec::string_layer::bch::{ALPHABET, BchCode, DecodedString};
use serde::Serialize;
use crate::cmd::read_mk1_strings;
use crate::error::{CliError, Result};
#[derive(Args, Debug)]
pub struct RepairArgs {
pub mk1_strings: Vec<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Clone)]
struct RepairDetail {
chunk_index: usize,
original_chunk: String,
corrected_chunk: String,
corrected_positions: Vec<(usize, char, char)>,
}
pub fn run(args: RepairArgs) -> Result<u8> {
let strings = read_mk1_strings(&args.mk1_strings)?;
let mut reports: Vec<RepairDetail> = Vec::with_capacity(strings.len());
let mut corrected_chunks: Vec<String> = Vec::with_capacity(strings.len());
let mut header_results: Vec<std::result::Result<StringLayerHeader, String>> =
Vec::with_capacity(strings.len());
for (idx, original) in strings.iter().enumerate() {
let decoded = mk_codec::string_layer::decode_string(original)?;
let (corrected_chunk, corrected_positions) = reconstruct_corrected(original, &decoded);
header_results.push(
StringLayerHeader::from_5bit_symbols(decoded.data())
.map(|(header, _consumed)| header)
.map_err(|e| e.to_string()),
);
reports.push(RepairDetail {
chunk_index: idx,
original_chunk: original.clone(),
corrected_chunk: corrected_chunk.clone(),
corrected_positions,
});
corrected_chunks.push(corrected_chunk);
}
let any_correction = reports.iter().any(|r| !r.corrected_positions.is_empty());
let mut unverified_advisory: Option<&'static str> = None;
if any_correction {
match classify_mk1_set(&reports, &header_results, &corrected_chunks)? {
SetVerify::Blessed => {}
SetVerify::Unverified => {
unverified_advisory = Some(
"correction UNVERIFIED — reassemble the full card (`mk decode`) to confirm; \
a >4-error correction can alias to a different card; BIP-93 recommends \
confirming a corrected codex32 string",
);
}
}
}
if args.json {
emit_json(&corrected_chunks, &reports)?;
} else {
emit_text(&corrected_chunks, &reports)?;
}
if let Some(advisory) = unverified_advisory {
eprintln!("warning: {advisory}");
}
crate::output_advisory::emit_output_class_advisory(
crate::output_advisory::OutputClass::WatchOnly,
&mut std::io::stderr(),
);
Ok(if any_correction { 5 } else { 0 })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupVerdict {
Bless,
Reject,
Candidate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetVerify {
Blessed,
Unverified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum GroupKey {
Chunked(u32),
SingleString(usize),
}
fn describe_group_key(key: GroupKey) -> String {
match key {
GroupKey::Chunked(csid) => format!("chunk_set_id 0x{csid:05x}"),
GroupKey::SingleString(idx) => format!("single-string chunk {idx}"),
}
}
fn group_is_complete_and_consistent(headers: &[&StringLayerHeader]) -> bool {
match headers.first() {
Some(StringLayerHeader::SingleString { .. }) => headers.len() == 1,
Some(StringLayerHeader::Chunked { total_chunks, .. }) => {
let total = *total_chunks as usize;
if headers.len() > total {
return false; }
let mut seen = vec![false; total];
for h in headers {
match h {
StringLayerHeader::Chunked {
total_chunks: t,
chunk_index,
..
} => {
if *t as usize != total {
return false; }
let idx = *chunk_index as usize;
if idx >= total || seen[idx] {
return false; }
seen[idx] = true;
}
_ => return false,
}
}
seen.iter().all(|&done| done)
}
_ => false,
}
}
fn fold_verdict(acc: Option<GroupVerdict>, v: GroupVerdict) -> GroupVerdict {
match (acc, v) {
(Some(GroupVerdict::Reject), _) | (_, GroupVerdict::Reject) => GroupVerdict::Reject,
(Some(GroupVerdict::Candidate), _) | (_, GroupVerdict::Candidate) => {
GroupVerdict::Candidate
}
_ => GroupVerdict::Bless,
}
}
fn classify_mk1_set(
reports: &[RepairDetail],
header_results: &[std::result::Result<StringLayerHeader, String>],
corrected_chunks: &[String],
) -> Result<SetVerify> {
let touched: HashSet<usize> = reports
.iter()
.filter(|r| !r.corrected_positions.is_empty())
.map(|r| r.chunk_index)
.collect();
let mut order: Vec<GroupKey> = Vec::new();
let mut members: HashMap<GroupKey, Vec<usize>> = HashMap::new();
let mut dominant: Option<GroupVerdict> = None;
let mut first_reject: Option<(String, String)> = None;
for (i, res) in header_results.iter().enumerate() {
match res {
Ok(header) => {
let key = match header {
StringLayerHeader::Chunked { chunk_set_id, .. } => {
GroupKey::Chunked(*chunk_set_id)
}
StringLayerHeader::SingleString { .. } => GroupKey::SingleString(i),
_ => GroupKey::SingleString(i),
};
members
.entry(key)
.or_insert_with(|| {
order.push(key);
Vec::new()
})
.push(i);
}
Err(e) => {
if touched.contains(&i) {
if first_reject.is_none() {
first_reject =
Some((format!("chunk {i} (post-correction header)"), e.clone()));
}
dominant = Some(fold_verdict(dominant, GroupVerdict::Reject));
}
}
}
}
for key in &order {
let idxs = &members[key];
if !idxs.iter().any(|i| touched.contains(i)) {
continue;
}
let headers: Vec<&StringLayerHeader> = idxs
.iter()
.map(|&i| {
header_results[i]
.as_ref()
.expect("grouped only from Ok(..) parses")
})
.collect();
let verdict = if !group_is_complete_and_consistent(&headers) {
GroupVerdict::Candidate
} else {
let refs: Vec<&str> = idxs.iter().map(|&i| corrected_chunks[i].as_str()).collect();
match mk_codec::decode(&refs) {
Ok(_) => GroupVerdict::Bless,
Err(e) => {
if first_reject.is_none() {
first_reject = Some((describe_group_key(*key), e.to_string()));
}
GroupVerdict::Reject
}
}
};
dominant = Some(fold_verdict(dominant, verdict));
}
match dominant.unwrap_or(GroupVerdict::Bless) {
GroupVerdict::Bless => Ok(SetVerify::Blessed),
GroupVerdict::Candidate => Ok(SetVerify::Unverified),
GroupVerdict::Reject => {
let (group, detail) =
first_reject.expect("dominant Reject implies a recorded reject detail");
Err(CliError::SetReassemblyMismatch { group, detail })
}
}
}
fn reconstruct_corrected(
original: &str,
decoded: &DecodedString,
) -> (String, Vec<(usize, char, char)>) {
let sep_pos = original
.rfind('1')
.expect("mk1 input passed BCH decode; must contain bech32 separator '1'");
let (prefix, rest) = original.split_at(sep_pos);
let data_part_raw: Vec<char> = rest[1..].chars().collect();
let mut corrected_positions: Vec<(usize, char, char)> =
Vec::with_capacity(decoded.corrected_positions.len());
let mut data_chars = data_part_raw.clone();
for &pos in &decoded.corrected_positions {
let was = data_chars
.get(pos)
.copied()
.unwrap_or('?');
let now = decoded.corrected_char_at(pos);
if pos < data_chars.len() {
data_chars[pos] = now;
}
corrected_positions.push((pos, was, now));
}
let mut out = String::with_capacity(prefix.len() + 1 + decoded.data_with_checksum.len());
out.push_str(&prefix.to_lowercase());
out.push('1');
for &v in &decoded.data_with_checksum {
out.push(ALPHABET[v as usize] as char);
}
let _ = match decoded.code {
BchCode::Regular => "regular",
BchCode::Long => "long",
};
(out, corrected_positions)
}
fn emit_text(corrected_chunks: &[String], reports: &[RepairDetail]) -> Result<()> {
let any_correction = reports.iter().any(|r| !r.corrected_positions.is_empty());
if any_correction {
println!("# Repair report");
for r in reports {
if r.corrected_positions.is_empty() {
continue;
}
let n = r.corrected_positions.len();
let plural = if n == 1 { "correction" } else { "corrections" };
let mut line = format!("# mk1 chunk {}: {} {} at ", r.chunk_index, n, plural);
for (i, (pos, was, now)) in r.corrected_positions.iter().enumerate() {
if i > 0 {
line.push_str(", ");
}
line.push_str(&format!("position {pos}: '{was}' -> '{now}'"));
}
println!("{line}");
}
}
for chunk in corrected_chunks {
println!("{chunk}");
}
Ok(())
}
#[derive(Serialize)]
struct RepairJson<'a> {
schema_version: &'static str,
kind: &'static str,
corrected_chunks: &'a [String],
repairs: Vec<RepairJsonDetail<'a>>,
}
#[derive(Serialize)]
struct RepairJsonDetail<'a> {
chunk_index: usize,
original_chunk: &'a str,
corrected_chunk: &'a str,
corrected_positions: Vec<RepairJsonPosition>,
}
#[derive(Serialize)]
struct RepairJsonPosition {
position: usize,
was: String,
now: String,
}
fn emit_json(corrected_chunks: &[String], reports: &[RepairDetail]) -> Result<()> {
let envelope = RepairJson {
schema_version: "1",
kind: "mk1",
corrected_chunks,
repairs: reports
.iter()
.filter(|r| !r.corrected_positions.is_empty())
.map(|r| RepairJsonDetail {
chunk_index: r.chunk_index,
original_chunk: &r.original_chunk,
corrected_chunk: &r.corrected_chunk,
corrected_positions: r
.corrected_positions
.iter()
.map(|(p, w, n)| RepairJsonPosition {
position: *p,
was: w.to_string(),
now: n.to_string(),
})
.collect(),
})
.collect(),
};
let body = serde_json::to_string(&envelope)
.map_err(|e| CliError::UsageError(format!("repair JSON serialize: {e}")))?;
println!("{body}");
Ok(())
}