use crate::encoding::{Bar4State, Postal4Pattern};
use crate::error::Error;
use crate::options::Options;
const ENCS_36: [&str; 36] = [
"0033", "0123", "0132", "1023", "1032", "1122", "0213", "0303", "0312", "1203", "1212", "1302",
"0231", "0321", "0330", "1221", "1230", "1320", "2013", "2103", "2112", "3003", "3012", "3102",
"2031", "2121", "2130", "3021", "3030", "3120", "2211", "2301", "2310", "3201", "3210", "3300",
];
const KIX_ALPHA: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const RM4SCC_ALPHA: &str = "ZUVWXY501234B6789AHCDEFGNIJKLMTOPQRS";
const RM4SCC_START: &str = "2";
const RM4SCC_STOP: &str = "3";
fn pattern_to_bars(pat: &str, out: &mut Vec<Bar4State>) -> Result<(), Error> {
for c in pat.chars() {
let d = c.to_digit(10).ok_or_else(|| {
Error::InvalidData(format!("postal4 pattern contains non-digit {c:?}"))
})? as u8;
let bar = Bar4State::from_digit(d).ok_or_else(|| {
Error::InvalidData(format!("postal4 pattern digit must be 0..=3 (got {d})"))
})?;
out.push(bar);
}
Ok(())
}
pub fn encode_daft(data: &str, opts: &Options) -> Result<Postal4Pattern, Error> {
if data.is_empty() {
return Err(Error::InvalidData("DAFT payload must not be empty".into()));
}
let mut bars = Vec::with_capacity(data.len());
for c in data.chars() {
let bar = match c.to_ascii_uppercase() {
'D' => Bar4State::Descender,
'A' => Bar4State::Ascender,
'F' => Bar4State::Full,
'T' => Bar4State::Tracker,
other => {
return Err(Error::InvalidData(format!(
"DAFT accepts only D, A, F, T (got {other:?})"
)))
}
};
bars.push(bar);
}
Ok(Postal4Pattern {
bars,
text: if opts.include_text {
Some(data.to_string())
} else {
None
},
})
}
pub fn encode_kix(data: &str, opts: &Options) -> Result<Postal4Pattern, Error> {
let payload = data.to_uppercase();
if payload.is_empty() {
return Err(Error::InvalidData("KIX payload must not be empty".into()));
}
let mut bars = Vec::with_capacity(payload.len() * 4);
for c in payload.chars() {
let idx = KIX_ALPHA
.find(c)
.ok_or_else(|| Error::InvalidData(format!("KIX: invalid character {c:?}")))?;
pattern_to_bars(ENCS_36[idx], &mut bars)?;
}
Ok(Postal4Pattern {
bars,
text: if opts.include_text {
Some(payload)
} else {
None
},
})
}
pub fn encode_royalmail(data: &str, opts: &Options) -> Result<Postal4Pattern, Error> {
let payload = data.to_uppercase();
if payload.is_empty() {
return Err(Error::InvalidData(
"Royal Mail RM4SCC payload must not be empty".into(),
));
}
for c in payload.chars() {
if RM4SCC_ALPHA.find(c).is_none() {
return Err(Error::InvalidData(format!(
"Royal Mail RM4SCC: invalid character {c:?}"
)));
}
}
let validate = opts.get("validatecheck").is_some_and(|v| v == "true");
let (body, supplied_check) = if validate {
let mut chars: Vec<char> = payload.chars().collect();
if chars.len() < 2 {
return Err(Error::InvalidData(
"Royal Mail RM4SCC validatecheck: payload too short".into(),
));
}
let last = chars.pop().unwrap();
(chars.into_iter().collect::<String>(), Some(last))
} else {
(payload.clone(), None)
};
let computed = compute_rm4scc_check(&body);
if let Some(s) = supplied_check {
if s != computed {
return Err(Error::InvalidData(format!(
"Royal Mail RM4SCC: supplied check {s} does not match computed {computed}"
)));
}
}
let full_body = format!("{body}{computed}");
let mut bars = Vec::with_capacity(full_body.len() * 4 + 2);
pattern_to_bars(RM4SCC_START, &mut bars)?;
for c in full_body.chars() {
let idx = KIX_ALPHA.find(c).unwrap();
pattern_to_bars(ENCS_36[idx], &mut bars)?;
}
pattern_to_bars(RM4SCC_STOP, &mut bars)?;
Ok(Postal4Pattern {
bars,
text: if opts.include_text {
Some(full_body)
} else {
None
},
})
}
fn compute_rm4scc_check(body: &str) -> char {
let mut sum_top = 0u32;
let mut sum_bot = 0u32;
for c in body.chars() {
let idx = RM4SCC_ALPHA.find(c).unwrap() as u32;
sum_top += idx / 6;
sum_bot += idx % 6;
}
let check_idx = (sum_top % 6) * 6 + (sum_bot % 6);
RM4SCC_ALPHA.chars().nth(check_idx as usize).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
fn bar_string(p: &Postal4Pattern) -> String {
p.bars
.iter()
.map(|b| match b {
Bar4State::Tracker => 'T',
Bar4State::Descender => 'D',
Bar4State::Ascender => 'A',
Bar4State::Full => 'F',
})
.collect()
}
#[test]
fn daft_each_letter_maps_correctly() {
let p = encode_daft("DAFT", &Options::default()).expect(
"encode_daft(\"DAFT\", default) (DAFT 1:1 char→bar mapping smoke: D→Descender, A→Ascender, F→Full, T→Tracker) must succeed",
);
assert_eq!(bar_string(&p), "DAFT");
}
#[test]
fn daft_is_case_insensitive() {
let lower = encode_daft("daft", &Options::default()).expect(
"encode_daft(\"daft\", default) (DAFT lowercase case-fold path: must equal uppercase via to_ascii_uppercase()) must succeed",
);
let upper = encode_daft("DAFT", &Options::default()).expect(
"encode_daft(\"DAFT\", default) (DAFT uppercase baseline for case-fold cross-check) must succeed",
);
assert_eq!(lower.bars, upper.bars);
}
#[test]
fn daft_rejects_other_letters() {
match encode_daft("DAX", &Options::default()) {
Err(Error::InvalidData(msg)) => {
assert!(msg.contains("DAFT"), "missing DAFT prefix: {msg}");
assert!(
msg.contains("accepts only D, A, F, T"),
"missing `accepts only D, A, F, T` predicate: {msg}"
);
assert!(msg.contains("'X'"), "missing 'X' Debug echo: {msg}");
assert!(
!msg.contains("must not be empty"),
"wrong arm — empty-payload diagnostic leaked into letter reject: {msg}"
);
assert!(
!msg.contains("KIX"),
"wrong helper — KIX diagnostic leaked into DAFT reject: {msg}"
);
}
other => panic!("\"DAX\" should reject as InvalidData, got {other:?}"),
}
}
#[test]
fn daft_rejects_empty() {
match encode_daft("", &Options::default()) {
Err(Error::InvalidData(msg)) => {
assert!(msg.contains("DAFT"), "missing DAFT prefix: {msg}");
assert!(
msg.contains("payload must not be empty"),
"missing `payload must not be empty` predicate: {msg}"
);
assert!(
!msg.contains("accepts only D, A, F, T"),
"wrong arm — letter diagnostic leaked into empty reject: {msg}"
);
assert!(
!msg.contains("KIX"),
"wrong helper — KIX diagnostic leaked into DAFT empty reject: {msg}"
);
}
other => panic!("empty DAFT payload should reject as InvalidData, got {other:?}"),
}
}
#[test]
fn daft_matches_bwip_js() {
for text in ["DAFT", "DDDD", "AAAA", "FFFF", "TTTT", "DAFTDAFTDAFT"] {
let p = encode_daft(text, &Options::default()).unwrap_or_else(|e| {
panic!(
"encode_daft({text:?}, default) (DAFT bar-sequence corpus row, 4-char identity, single-letter run, or 3×repetition) must succeed: {e:?}",
)
});
assert_eq!(
bar_string(&p),
text,
"DAFT bar sequence mismatch for {text:?}"
);
}
}
#[test]
fn kix_known_pattern_for_zero() {
let p = encode_kix("0", &Options::default()).expect(
"encode_kix(\"0\", default) (KIX digit-0 path: KIX_ALPHA[0]='0' → ENCS_36[0]=\"0033\" → 4-bar TTFF) must succeed",
);
assert_eq!(bar_string(&p), "TTFF");
}
#[test]
fn kix_two_chars_concatenate() {
let p = encode_kix("01", &Options::default()).expect(
"encode_kix(\"01\", default) (KIX 2-digit concatenation; ENCS_36[0]+ENCS_36[1] → TTFFTDAF) must succeed",
);
assert_eq!(bar_string(&p), "TTFFTDAF");
}
#[test]
fn kix_rejects_lowercase_that_is_not_in_alpha() {
match encode_kix("AB#", &Options::default()) {
Err(Error::InvalidData(msg)) => {
assert!(msg.contains("KIX:"), "missing `KIX:` prefix: {msg}");
assert!(
msg.contains("invalid character"),
"missing `invalid character` predicate: {msg}"
);
assert!(msg.contains("'#'"), "missing '#' char Debug echo: {msg}");
assert!(
!msg.contains("must not be empty"),
"wrong arm — empty-payload diagnostic leaked: {msg}"
);
}
other => panic!("`AB#` should reject as InvalidData, got {other:?}"),
}
}
#[test]
fn rm4scc_canonical_payload_with_computed_check() {
let p = encode_royalmail("LE28HS9Z", &Options::default()).expect(
"encode_royalmail(\"LE28HS9Z\", default) (RM4SCC BWIPP canonical example; auto-check + start/stop sentinels → 38 bars) must succeed",
);
assert_eq!(p.bars.len(), 1 + 9 * 4 + 1);
assert_eq!(p.bars[0], Bar4State::Ascender); assert_eq!(p.bars[p.bars.len() - 1], Bar4State::Full); }
#[test]
fn pattern_to_bars_covers_all_arms() {
let mut out = Vec::new();
pattern_to_bars("0123", &mut out).unwrap();
assert_eq!(
out,
vec![
Bar4State::Tracker,
Bar4State::Descender,
Bar4State::Ascender,
Bar4State::Full,
],
"pattern '0123' must map to T,D,A,F in order"
);
pattern_to_bars("3", &mut out).unwrap();
assert_eq!(out.len(), 5, "pattern_to_bars must APPEND, not replace");
assert_eq!(out[4], Bar4State::Full);
let mut bad = Vec::new();
for d in '4'..='9' {
let pat = d.to_string();
match pattern_to_bars(&pat, &mut bad) {
Err(Error::InvalidData(msg)) => {
assert!(
msg.contains("postal4 pattern"),
"missing `postal4 pattern` prefix for '{d}': {msg:?}"
);
assert!(
msg.contains("digit must be 0..=3"),
"missing full predicate for '{d}': {msg:?}"
);
assert!(
msg.contains(&format!("got {d}")),
"missing per-iteration value echo `got {d}`: {msg:?}"
);
assert!(
!msg.contains("non-digit"),
"cross-arm contamination: out-of-range msg mentions `non-digit` for '{d}': {msg:?}"
);
}
other => panic!("expected InvalidData for '{d}', got {other:?}"),
}
}
let mut empty_out = Vec::new();
pattern_to_bars("", &mut empty_out).unwrap();
assert!(
empty_out.is_empty(),
"pattern_to_bars(\"\") must be a no-op (empty input → no bars pushed); got len={}",
empty_out.len()
);
let mut nondigit_out = Vec::new();
match pattern_to_bars("A", &mut nondigit_out) {
Err(Error::InvalidData(msg)) => {
assert!(
msg.contains("postal4 pattern"),
"missing `postal4 pattern` prefix: {msg:?}"
);
assert!(
msg.contains("contains non-digit"),
"missing full predicate `contains non-digit`: {msg:?}"
);
assert!(msg.contains("'A'"), "missing char Debug echo 'A': {msg:?}");
assert!(
!msg.contains("must be 0..=3"),
"cross-arm contamination: non-digit msg mentions `must be 0..=3`: {msg:?}"
);
}
other => panic!("expected InvalidData(non-digit), got {other:?}"),
}
}
#[test]
fn rm4scc_check_digit_known_vector() {
assert_eq!(compute_rm4scc_check("SN34RD1A"), 'K');
}
#[test]
fn rm4scc_validate_check_rejects_mismatch() {
let err = encode_royalmail(
"SN34RD1AX",
&Options::default().with("validatecheck", "true"),
)
.unwrap_err();
let Error::InvalidData(msg) = err else {
panic!("expected InvalidData for check mismatch; got {err:?}");
};
assert!(
msg.contains("Royal Mail RM4SCC:"),
"diagnostic must carry the symbology tag; got {msg:?}"
);
assert!(
msg.contains("supplied check X"),
"diagnostic must echo the supplied check 'X'; got {msg:?}"
);
assert!(
msg.contains("does not match computed K"),
"diagnostic must echo the computed check 'K'; got {msg:?}"
);
assert!(
!msg.contains("invalid character")
&& !msg.contains("must not be empty")
&& !msg.contains("too short"),
"mismatch diagnostic must not leak other arms' substrings; got {msg:?}"
);
}
#[test]
fn rm4scc_rejects_invalid_character() {
let err = encode_royalmail("HELLO!", &Options::default()).unwrap_err();
let Error::InvalidData(msg) = err else {
panic!("expected InvalidData for invalid char; got {err:?}");
};
assert!(
msg.contains("Royal Mail RM4SCC:"),
"diagnostic must carry the symbology tag; got {msg:?}"
);
assert!(
msg.contains("invalid character"),
"diagnostic must call out 'invalid character'; got {msg:?}"
);
assert!(
msg.contains("'!'"),
"diagnostic must echo the offending char via {{c:?}}; got {msg:?}"
);
assert!(
!msg.contains("supplied check") && !msg.contains("must not be empty"),
"invalid-char diagnostic must not leak other arms' substrings; got {msg:?}"
);
}
#[test]
fn rm4scc_rejects_empty() {
match encode_royalmail("", &Options::default()) {
Err(Error::InvalidData(msg)) => {
assert!(
msg.contains("Royal Mail RM4SCC"),
"missing `Royal Mail RM4SCC` prefix: {msg}"
);
assert!(
msg.contains("must not be empty"),
"missing `must not be empty` predicate: {msg}"
);
assert!(
!msg.contains("invalid character"),
"wrong arm — invalid-character diagnostic leaked: {msg}"
);
}
other => panic!("empty Royal Mail RM4SCC should reject as InvalidData, got {other:?}"),
}
}
#[test]
fn rm4scc_matches_bwip_js() {
let cases: &[(&str, &str)] = &[
("LE28HS9Z", "AFTTFTFFTTDFATFDADFATFTFTDATFFFTTDATFF"),
("SN12AA1A", "AFTFTFDTATDAFTDFADADADADATDAFDADAAFDTF"),
];
for &(text, want) in cases {
let p = encode_royalmail(text, &Options::default()).unwrap_or_else(|e| {
panic!("encode_royalmail({text:?}) (Royal Mail 4-State corpus item) must succeed; got Err: {e}")
});
assert_eq!(
bar_string(&p),
want,
"Royal Mail bar sequence mismatch for {text:?}"
);
}
}
#[test]
fn rm4scc_validatecheck_length_boundary_is_exactly_two() {
let opts = Options::default().with("validatecheck", "true");
encode_royalmail("AA", &opts)
.expect("validatecheck should accept length-2 payload (1 body + 1 check)");
match encode_royalmail("A", &opts) {
Err(Error::InvalidData(msg)) => {
assert!(
msg.contains("Royal Mail RM4SCC"),
"missing `Royal Mail RM4SCC` prefix: {msg}"
);
assert!(
msg.contains("validatecheck"),
"missing `validatecheck` mode qualifier: {msg}"
);
assert!(
msg.contains("payload too short"),
"missing `payload too short` predicate: {msg}"
);
}
other => panic!("expected InvalidData(too short), got {other:?}"),
}
assert_eq!(compute_rm4scc_check("AA"), 'L');
encode_royalmail("AAL", &opts)
.expect("validatecheck should accept length-3 payload (2 body + 1 check)");
}
#[test]
fn kix_matches_bwip_js() {
let cases: &[(&str, &str)] = &[
("1231GA1RS", "TDAFTDFADTAFTDAFDAFTDADATDAFFTADFTFT"),
("ABC123", "DADADFTATAFDTDAFTDFADTAF"),
];
for &(text, want) in cases {
let p = encode_kix(text, &Options::default()).unwrap_or_else(|e| {
panic!("encode_kix({text:?}) (KIX 4-State corpus item) must succeed; got Err: {e}")
});
assert_eq!(
bar_string(&p),
want,
"KIX bar sequence mismatch for {text:?}"
);
}
}
}