use std::io::Write;
use std::process::{Command, Stdio};
use std::str::FromStr;
use assert_cmd::cargo::CommandCargoExt;
use bitcoin::bip32::{DerivationPath, Fingerprint, Xpub};
use mk_codec::KeyCard;
use mk_codec::string_layer::bch::ALPHABET;
const V1_XPUB: &str = "xpub6Den8YwXbKQvkwukmx7Uukicw4qDgMEPuuUkhMp3Rn557YSN2uVQnCMQNSfgDtennU9nES3Wbbmz1LAPBydhNpED8NU4mf1SFF41hM7vFrc";
const V1_PATH: &str = "m/48'/0'/0'/2'";
fn generate_valid_mk1_chunks() -> Vec<String> {
let xpub = Xpub::from_str(V1_XPUB).unwrap();
let fp = Fingerprint::from([0xaa, 0xbb, 0xcc, 0xdd]);
let path = DerivationPath::from_str(V1_PATH).unwrap();
let stub = [0x11u8, 0x22, 0x33, 0x44];
let card = KeyCard::new(vec![stub], Some(fp), path, xpub);
mk_codec::encode(&card).expect("encode V1 KeyCard")
}
fn flip_at(chunk: &str, pos: usize) -> String {
let sep = chunk.rfind('1').unwrap();
let (prefix, rest) = chunk.split_at(sep + 1);
let mut chars: Vec<char> = rest.chars().collect();
let was = chars[pos];
let alphabet_str = std::str::from_utf8(ALPHABET).unwrap();
let was_idx = alphabet_str.find(was).unwrap();
let new_idx = (was_idx + 1) % 32;
chars[pos] = alphabet_str.chars().nth(new_idx).unwrap();
let mut out = String::from(prefix);
for c in chars {
out.push(c);
}
out
}
fn flip_many(chunk: &str, positions: &[usize]) -> String {
positions
.iter()
.fold(chunk.to_string(), |acc, &p| flip_at(&acc, p))
}
#[test]
fn repair_already_valid_input_exits_0() {
let chunks = generate_valid_mk1_chunks();
let valid = &chunks[0];
let mut cmd = Command::cargo_bin("mk").expect("mk binary");
let out = cmd
.args(["repair", valid])
.output()
.expect("invoke mk repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
0,
"expected exit 0 for clean input; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
!stdout.contains("# Repair report"),
"clean input must not emit a Repair report; got stdout={stdout:?}"
);
assert!(
stdout.lines().any(|line| line == valid),
"expected pass-through of valid input on stdout; got {stdout:?}"
);
}
#[test]
fn repair_one_substitution_exits_5() {
let chunks = generate_valid_mk1_chunks();
let valid = &chunks[1];
let corrupted = flip_at(valid, 5);
let mut cmd = Command::cargo_bin("mk").expect("mk binary");
let out = cmd
.args(["repair", &corrupted])
.output()
.expect("invoke mk repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 (REPAIR_APPLIED); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
stdout.contains("# Repair report"),
"expected `# Repair report` header; got {stdout:?}"
);
assert!(
stdout.contains("mk1 chunk 0: 1 correction at position 5"),
"expected per-chunk correction line at position 5; got {stdout:?}"
);
assert!(
stdout.lines().any(|line| line == valid.as_str()),
"expected corrected chunk to match the original valid mk1; got {stdout:?}"
);
}
#[test]
fn repair_beyond_t4_capacity_exits_2() {
let chunks = generate_valid_mk1_chunks();
let valid = &chunks[1];
let irreparable = flip_many(valid, &[3, 11, 19, 27, 35]);
let mut cmd = Command::cargo_bin("mk").expect("mk binary");
let out = cmd
.args(["repair", &irreparable])
.output()
.expect("invoke mk repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
2,
"expected exit 2 (CliError::Codec::BchUncorrectable); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("BCH uncorrectable") || stderr.contains("uncorrectable"),
"expected BCH-uncorrectable error message on stderr; got {stderr:?}"
);
}
#[test]
fn repair_hrp_mismatch_exits_2() {
let chunks = generate_valid_mk1_chunks();
let valid = &chunks[1];
let hrp_swapped = valid.replacen("mk1", "ms1", 1);
let mut cmd = Command::cargo_bin("mk").expect("mk binary");
let out = cmd
.args(["repair", &hrp_swapped])
.output()
.expect("invoke mk repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
2,
"expected exit 2 (InvalidHrp); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("invalid HRP") || stderr.contains("HRP"),
"expected HRP error message on stderr; got {stderr:?}"
);
assert!(
!stderr.contains("did you mean"),
"single-HRP mk-cli must not emit Levenshtein suggestion; got {stderr:?}"
);
}
#[test]
fn repair_long_code_happy_path() {
let chunks = generate_valid_mk1_chunks();
let valid_long = &chunks[0];
assert_eq!(
valid_long.len() - "mk1".len(),
108,
"fixture sanity: chunk 0 must be long-code (108 data-part chars); got len={}",
valid_long.len()
);
let corrupted = flip_at(valid_long, 50);
let mut cmd = Command::cargo_bin("mk").expect("mk binary");
let out = cmd
.args(["repair", &corrupted])
.output()
.expect("invoke mk repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 for long-code 1-substitution repair; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
stdout.contains("mk1 chunk 0: 1 correction at position 50"),
"expected long-code correction at position 50; got {stdout:?}"
);
assert!(
stdout.lines().any(|line| line == valid_long.as_str()),
"expected restored long-code chunk to match original; got {stdout:?}"
);
}
#[test]
fn repair_stdin_input_via_dash() {
let chunks = generate_valid_mk1_chunks();
let bad_a = flip_at(&chunks[0], 50);
let bad_b = flip_at(&chunks[1], 5);
let stdin_body = format!("{bad_a}\n{bad_b}\n");
let mut child = Command::cargo_bin("mk")
.expect("mk binary")
.args(["repair", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mk repair -");
child
.stdin
.as_mut()
.expect("stdin pipe")
.write_all(stdin_body.as_bytes())
.expect("write stdin");
let out = child.wait_with_output().expect("wait mk repair -");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 for stdin-with-corrupted-input; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
stdout.lines().any(|line| line == chunks[0].as_str()),
"expected restored chunk 0 on stdout; got {stdout:?}"
);
assert!(
stdout.lines().any(|line| line == chunks[1].as_str()),
"expected restored chunk 1 on stdout; got {stdout:?}"
);
}
#[test]
fn repair_json_envelope_shape() {
let chunks = generate_valid_mk1_chunks();
let valid = &chunks[1];
let corrupted = flip_at(valid, 5);
let mut cmd = Command::cargo_bin("mk").expect("mk binary");
let out = cmd
.args(["repair", "--json", &corrupted])
.output()
.expect("invoke mk repair --json");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 for JSON-mode repair; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
let envelope: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout parses as JSON");
assert_eq!(
envelope["schema_version"],
serde_json::Value::String("1".into()),
"schema_version must equal \"1\" (string)"
);
assert_eq!(
envelope["kind"],
serde_json::Value::String("mk1".into()),
"kind must equal \"mk1\""
);
let corrected_chunks = envelope["corrected_chunks"]
.as_array()
.expect("corrected_chunks must be a JSON array");
assert_eq!(corrected_chunks.len(), 1, "one input → one corrected_chunk");
assert_eq!(
corrected_chunks[0],
serde_json::Value::String(valid.clone()),
"corrected_chunk must equal the original valid mk1"
);
let repairs = envelope["repairs"]
.as_array()
.expect("repairs must be a JSON array");
assert_eq!(repairs.len(), 1, "one corrupted input → one repair entry");
let r0 = &repairs[0];
assert_eq!(r0["chunk_index"], serde_json::Value::from(0u32));
assert_eq!(
r0["original_chunk"],
serde_json::Value::String(corrupted.clone())
);
assert_eq!(
r0["corrected_chunk"],
serde_json::Value::String(valid.clone())
);
let positions = r0["corrected_positions"]
.as_array()
.expect("corrected_positions must be a JSON array");
assert_eq!(positions.len(), 1, "single-flip → one position entry");
let p0 = &positions[0];
assert_eq!(p0["position"], serde_json::Value::from(5u32));
assert!(p0["was"].is_string(), "was must be a string");
assert!(p0["now"].is_string(), "now must be a string");
assert_ne!(
p0["was"], p0["now"],
"was != now for a real correction"
);
}