#![allow(dead_code)]
pub const FNC1: u8 = 0x1D;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Element {
pub ai: String,
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
MissingOpenParen { position: usize },
UnclosedAi { position: usize },
InvalidAi { ai: String },
UnknownAi { ai: String },
BadLength {
ai: String,
expected: String,
got: usize,
},
BadCharacter { ai: String, ch: char },
Empty,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::MissingOpenParen { position } => {
write!(f, "GS1 parse: expected '(' at position {position}")
}
ParseError::UnclosedAi { position } => {
write!(f, "GS1 parse: unclosed AI starting at position {position}")
}
ParseError::InvalidAi { ai } => {
write!(f, "GS1 parse: invalid AI {ai:?} (must be 2-4 digits)")
}
ParseError::UnknownAi { ai } => {
write!(f, "GS1 parse: unknown AI {ai:?}")
}
ParseError::BadLength { ai, expected, got } => write!(
f,
"GS1 parse: AI ({ai}) requires data length {expected}, got {got}"
),
ParseError::BadCharacter { ai, ch } => {
write!(f, "GS1 parse: AI ({ai}) does not allow character {ch:?}")
}
ParseError::Empty => write!(f, "GS1 parse: input is empty"),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse(input: &str) -> Result<Vec<Element>, ParseError> {
if input.is_empty() {
return Err(ParseError::Empty);
}
let bytes = input.as_bytes();
let mut pos = 0;
let mut out: Vec<Element> = Vec::new();
while pos < bytes.len() {
if bytes[pos] != b'(' {
return Err(ParseError::MissingOpenParen { position: pos });
}
let ai_start = pos + 1;
let close = bytes[ai_start..]
.iter()
.position(|&b| b == b')')
.map(|i| ai_start + i)
.ok_or(ParseError::UnclosedAi { position: pos })?;
let ai = &input[ai_start..close];
if !(2..=4).contains(&ai.len()) || !ai.chars().all(|c| c.is_ascii_digit()) {
return Err(ParseError::InvalidAi { ai: ai.to_string() });
}
let data_start = close + 1;
let data_end = bytes[data_start..]
.iter()
.position(|&b| b == b'(')
.map(|i| data_start + i)
.unwrap_or(bytes.len());
let data = &input[data_start..data_end];
let spec = AI_TABLE
.iter()
.find(|s| s.ai == ai)
.ok_or_else(|| ParseError::UnknownAi { ai: ai.to_string() })?;
spec.validate_data(data)?;
out.push(Element {
ai: ai.to_string(),
data: data.to_string(),
});
pos = data_end;
}
Ok(out)
}
pub fn encode_with_fnc1(elements: &[Element]) -> Vec<u8> {
let mut out = Vec::with_capacity(
elements
.iter()
.map(|e| e.ai.len() + e.data.len())
.sum::<usize>()
+ elements.len()
+ 1,
);
out.push(FNC1);
for (i, e) in elements.iter().enumerate() {
out.extend_from_slice(e.ai.as_bytes());
out.extend_from_slice(e.data.as_bytes());
let spec = AI_TABLE
.iter()
.find(|s| s.ai == e.ai)
.expect("element AI not in table");
if spec.variable && i + 1 < elements.len() {
out.push(FNC1);
}
}
out
}
pub fn parse_and_encode(input: &str) -> Result<Vec<u8>, ParseError> {
Ok(encode_with_fnc1(&parse(input)?))
}
pub fn parse_dl_uri(uri: &str) -> Result<Vec<Element>, DlUriError> {
let rest = uri
.strip_prefix("https://")
.or_else(|| uri.strip_prefix("http://"))
.ok_or(DlUriError::BadScheme)?;
let path_and_query = rest
.split_once('/')
.map(|x| x.1)
.ok_or(DlUriError::NoPath)?;
let (path, query) = match path_and_query.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (path_and_query, None),
};
let mut elements: Vec<Element> = Vec::new();
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut i = 0;
while i + 1 < segments.len() {
let ai = segments[i];
if !is_valid_ai(ai) {
break;
}
let value = url_percent_decode(segments[i + 1]);
elements.push(Element {
ai: ai.to_string(),
data: value,
});
i += 2;
}
if elements.is_empty() {
return Err(DlUriError::NoAiInPath);
}
if let Some(q) = query {
for kv in q.split('&').filter(|s| !s.is_empty()) {
let (k, v) = kv
.split_once('=')
.ok_or_else(|| DlUriError::BadQueryParam {
param: kv.to_string(),
})?;
if !is_valid_ai(k) {
continue;
}
elements.push(Element {
ai: k.to_string(),
data: url_percent_decode(v),
});
}
}
Ok(elements)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DlUriError {
BadScheme,
NoPath,
NoAiInPath,
BadQueryParam { param: String },
}
impl std::fmt::Display for DlUriError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DlUriError::BadScheme => write!(f, "GS1 DL URI: must start with http:// or https://"),
DlUriError::NoPath => write!(f, "GS1 DL URI: missing path after authority"),
DlUriError::NoAiInPath => write!(f, "GS1 DL URI: no AI segments found in path"),
DlUriError::BadQueryParam { param } => {
write!(
f,
"GS1 DL URI: bad query param {param:?} (expected `key=value`)"
)
}
}
}
}
impl std::error::Error for DlUriError {}
fn is_valid_ai(s: &str) -> bool {
matches!(s.len(), 2..=4) && s.bytes().all(|b| b.is_ascii_digit())
}
fn url_percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub fn ai_is_variable_length(ai: &str) -> Option<bool> {
AI_TABLE.iter().find(|s| s.ai == ai).map(|s| s.variable)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum DataFormat {
Numeric,
Alphanumeric,
}
struct AiSpec {
ai: &'static str,
min_len: usize,
max_len: Option<usize>,
variable: bool,
format: DataFormat,
}
impl AiSpec {
fn validate_data(&self, data: &str) -> Result<(), ParseError> {
let n = data.chars().count();
let expected = match self.max_len {
Some(max) if max == self.min_len => format!("{}", self.min_len),
Some(max) => format!("{}-{}", self.min_len, max),
None => format!("{}+", self.min_len),
};
match self.max_len {
Some(max) if n < self.min_len || n > max => {
return Err(ParseError::BadLength {
ai: self.ai.to_string(),
expected,
got: n,
});
}
None if n < self.min_len => {
return Err(ParseError::BadLength {
ai: self.ai.to_string(),
expected,
got: n,
});
}
_ => {}
}
for ch in data.chars() {
match self.format {
DataFormat::Numeric => {
if !ch.is_ascii_digit() {
return Err(ParseError::BadCharacter {
ai: self.ai.to_string(),
ch,
});
}
}
DataFormat::Alphanumeric => {
if !ch.is_ascii_graphic() {
return Err(ParseError::BadCharacter {
ai: self.ai.to_string(),
ch,
});
}
}
}
}
Ok(())
}
}
const N_FIXED: &[AiSpec] = &[];
const AI_TABLE: &[AiSpec] = &[
AiSpec {
ai: "00",
min_len: 18,
max_len: Some(18),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "01",
min_len: 14,
max_len: Some(14),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "02",
min_len: 14,
max_len: Some(14),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "10",
min_len: 1,
max_len: Some(20),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "11",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "12",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "13",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "15",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "16",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "17",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "20",
min_len: 2,
max_len: Some(2),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "21",
min_len: 1,
max_len: Some(20),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "240",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "241",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "30",
min_len: 1,
max_len: Some(8),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3100",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3101",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3102",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3103",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3104",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3105",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3200",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3201",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3202",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3203",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3204",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3205",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3920",
min_len: 1,
max_len: Some(15),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3921",
min_len: 1,
max_len: Some(15),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3922",
min_len: 1,
max_len: Some(15),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3923",
min_len: 1,
max_len: Some(15),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3930",
min_len: 4,
max_len: Some(18),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3931",
min_len: 4,
max_len: Some(18),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3932",
min_len: 4,
max_len: Some(18),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "3933",
min_len: 4,
max_len: Some(18),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "400",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "410",
min_len: 13,
max_len: Some(13),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "411",
min_len: 13,
max_len: Some(13),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "412",
min_len: 13,
max_len: Some(13),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "413",
min_len: 13,
max_len: Some(13),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "414",
min_len: 13,
max_len: Some(13),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "415",
min_len: 13,
max_len: Some(13),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "420",
min_len: 1,
max_len: Some(20),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "421",
min_len: 4,
max_len: Some(12),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "8003",
min_len: 14,
max_len: Some(14),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "8004",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "8005",
min_len: 6,
max_len: Some(6),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "8006",
min_len: 18,
max_len: Some(18),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "8007",
min_len: 1,
max_len: Some(34),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "8008",
min_len: 8,
max_len: Some(12),
variable: true,
format: DataFormat::Numeric,
},
AiSpec {
ai: "8018",
min_len: 18,
max_len: Some(18),
variable: false,
format: DataFormat::Numeric,
},
AiSpec {
ai: "8110",
min_len: 1,
max_len: Some(70),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "90",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "91",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "92",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "93",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "94",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "95",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "96",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "97",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "98",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
AiSpec {
ai: "99",
min_len: 1,
max_len: Some(30),
variable: true,
format: DataFormat::Alphanumeric,
},
];
const _: &[AiSpec] = N_FIXED;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_single_fixed_length_ai() {
let v = parse("(01)04012345123456").unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].ai, "01");
assert_eq!(v[0].data, "04012345123456");
}
#[test]
fn parses_multiple_fixed_length_ais() {
let v = parse("(01)04012345123456(17)260101(10)A1B2").unwrap();
assert_eq!(v.len(), 3);
assert_eq!(v[2].ai, "10");
assert_eq!(v[2].data, "A1B2");
}
#[test]
fn encode_inserts_fnc1_after_variable_only_when_followed() {
let v = parse("(10)A1B2(01)04012345123456").unwrap();
let bytes = encode_with_fnc1(&v);
assert_eq!(bytes[0], FNC1);
let second_fnc1_pos = 1 + 2 + 4;
assert_eq!(bytes[second_fnc1_pos], FNC1);
assert_eq!(bytes.len(), 24);
}
#[test]
fn encode_omits_trailing_fnc1_after_variable_at_end() {
let v = parse("(01)04012345123456(10)A1B2").unwrap();
let bytes = encode_with_fnc1(&v);
assert_eq!(bytes.last().copied().unwrap(), b'2');
}
#[test]
fn rejects_missing_open_paren() {
match parse("01)1234567890123") {
Err(ParseError::MissingOpenParen { position }) => {
assert_eq!(
position, 0,
"missing-paren should report position 0 (input starts with '0')"
);
}
other => panic!("expected MissingOpenParen at position 0, got {other:?}"),
}
}
#[test]
fn rejects_unclosed_ai() {
match parse("(01") {
Err(ParseError::UnclosedAi { position }) => {
assert_eq!(
position, 0,
"unclosed-AI should report position 0 (the open-paren idx)"
);
}
other => panic!("expected UnclosedAi at position 0, got {other:?}"),
}
}
#[test]
fn rejects_unknown_ai() {
match parse("(99999)X") {
Err(ParseError::InvalidAi { ai }) => {
assert_eq!(
ai, "99999",
"5-digit AI must surface full \"99999\" value (kills `{{ai}}` interpolation drop)"
);
}
other => panic!("expected InvalidAi {{ai: \"99999\"}}, got {other:?}"),
}
match parse("(0)X") {
Err(ParseError::InvalidAi { ai }) => {
assert_eq!(
ai, "0",
"1-digit AI must surface \"0\" (kills under-length branch mutations)"
);
}
other => panic!("expected InvalidAi {{ai: \"0\"}}, got {other:?}"),
}
}
#[test]
fn rejects_bad_length() {
match parse("(01)0401234512345") {
Err(ParseError::BadLength { ai, expected, got }) => {
assert_eq!(ai, "01", "ai must be \"01\"");
assert_eq!(expected, "14", "expected length must be \"14\"");
assert_eq!(got, 13, "got length must be 13");
}
other => panic!("expected BadLength, got {other:?}"),
}
}
#[test]
fn rejects_non_digit_in_numeric_ai() {
match parse("(01)040123ABCD3456") {
Err(ParseError::BadCharacter { ai, ch }) => {
assert_eq!(ai, "01", "ai must be \"01\"");
assert_eq!(
ch, 'A',
"ch must be 'A' (first non-digit after the 6-char numeric prefix)"
);
}
other => panic!("expected BadCharacter {{ai: \"01\", ch: 'A'}}, got {other:?}"),
}
}
#[test]
fn parse_and_encode_round_trip() {
let bytes = parse_and_encode("(01)04012345123456(17)260101").unwrap();
assert_eq!(bytes[0], FNC1);
let occurrences = bytes.iter().filter(|&&b| b == FNC1).count();
assert_eq!(occurrences, 1);
}
#[test]
fn encode_with_fnc1_handles_variable_separator() {
let bytes = encode_with_fnc1(&[]);
assert_eq!(bytes, vec![FNC1], "empty elements list → only leading FNC1");
let fixed_only = vec![Element {
ai: "01".to_string(),
data: "04012345123456".to_string(),
}];
let bytes = encode_with_fnc1(&fixed_only);
assert_eq!(bytes[0], FNC1);
assert_eq!(
bytes.iter().filter(|&&b| b == FNC1).count(),
1,
"single fixed AI: only the leading FNC1"
);
assert_eq!(&bytes[1..3], b"01");
assert_eq!(&bytes[3..], b"04012345123456");
let var_only = vec![Element {
ai: "10".to_string(),
data: "ABC".to_string(),
}];
let bytes = encode_with_fnc1(&var_only);
assert_eq!(
bytes.iter().filter(|&&b| b == FNC1).count(),
1,
"single variable AI must NOT add a trailing FNC1 (no next element)"
);
assert_eq!(&bytes[1..], b"10ABC");
let two_fixed = vec![
Element {
ai: "01".to_string(),
data: "04012345123456".to_string(),
},
Element {
ai: "17".to_string(),
data: "260101".to_string(),
},
];
let bytes = encode_with_fnc1(&two_fixed);
assert_eq!(
bytes.iter().filter(|&&b| b == FNC1).count(),
1,
"two fixed AIs: still only the leading FNC1"
);
let var_then_fixed = vec![
Element {
ai: "10".to_string(),
data: "ABC".to_string(),
},
Element {
ai: "01".to_string(),
data: "04012345123456".to_string(),
},
];
let bytes = encode_with_fnc1(&var_then_fixed);
assert_eq!(
bytes.iter().filter(|&&b| b == FNC1).count(),
2,
"variable AI followed by another element MUST emit a separator"
);
assert_eq!(bytes[0], FNC1);
assert_eq!(&bytes[1..6], b"10ABC");
assert_eq!(bytes[6], FNC1);
assert_eq!(&bytes[7..], b"0104012345123456");
let fixed_then_var = vec![
Element {
ai: "01".to_string(),
data: "04012345123456".to_string(),
},
Element {
ai: "10".to_string(),
data: "ABC".to_string(),
},
];
let bytes = encode_with_fnc1(&fixed_then_var);
assert_eq!(
bytes.iter().filter(|&&b| b == FNC1).count(),
1,
"trailing variable AI must NOT emit a separator (no next element)"
);
let tail = &bytes[bytes.len() - 5..];
assert_eq!(tail, b"10ABC", "variable AI tail uninterrupted");
}
#[test]
fn parse_dl_uri_extracts_path_ais() {
let v = parse_dl_uri("https://id.gs1.org/01/04012345123456").unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].ai, "01");
assert_eq!(v[0].data, "04012345123456");
}
#[test]
fn parse_dl_uri_extracts_multiple_path_ais() {
let v = parse_dl_uri("https://id.gs1.org/01/04012345123456/21/SERIAL123").unwrap();
assert_eq!(v.len(), 2);
assert_eq!(v[0].ai, "01");
assert_eq!(v[1].ai, "21");
assert_eq!(v[1].data, "SERIAL123");
}
#[test]
fn parse_dl_uri_extracts_query_ais() {
let v = parse_dl_uri("https://x.example/01/04012345123456?17=251231&10=ABC").unwrap();
assert_eq!(v.len(), 3);
assert_eq!(v[1].ai, "17");
assert_eq!(v[1].data, "251231");
assert_eq!(v[2].ai, "10");
assert_eq!(v[2].data, "ABC");
}
#[test]
fn parse_dl_uri_decodes_percent_escapes() {
let v = parse_dl_uri("https://x.example/01/04012345123456?10=A%2FB").unwrap();
assert_eq!(v[1].data, "A/B");
}
#[test]
fn parse_dl_uri_rejects_bad_scheme() {
assert_eq!(
parse_dl_uri("ftp://x.example/01/04012345123456"),
Err(DlUriError::BadScheme),
"ftp:// scheme must reject as BadScheme — kills variant-swap mutations between BadScheme / NoPath / NoAiInPath"
);
}
#[test]
fn parse_dl_uri_rejects_no_ai() {
assert_eq!(
parse_dl_uri("https://example.com/foo"),
Err(DlUriError::NoAiInPath),
"URI with valid scheme but no AI digit segments must reject as NoAiInPath — kills variant-swap mutations"
);
}
#[test]
fn parse_dl_uri_stops_at_first_non_ai_segment() {
let v = parse_dl_uri("https://x.example/01/04012345123456/foo/bar").unwrap();
assert_eq!(v.len(), 1);
}
#[test]
fn parse_dl_uri_branch_coverage() {
let v = parse_dl_uri("http://x.example/01/04012345123456").unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].ai, "01");
match parse_dl_uri("https://example.com") {
Err(DlUriError::NoPath) => {}
other => panic!("expected NoPath, got {other:?}"),
}
match parse_dl_uri("https://x.example/01/04012345123456?foo") {
Err(DlUriError::BadQueryParam { param }) => {
assert_eq!(param, "foo");
}
other => panic!("expected BadQueryParam('foo'), got {other:?}"),
}
let v = parse_dl_uri("https://x.example/01/04012345123456?lang=en").unwrap();
assert_eq!(
v.len(),
1,
"non-AI query params must be skipped silently, not rejected"
);
assert_eq!(v[0].ai, "01");
}
#[test]
fn is_valid_ai_length_boundaries() {
assert!(is_valid_ai("01"));
assert!(is_valid_ai("123"));
assert!(is_valid_ai("1234"));
assert!(is_valid_ai("99"));
assert!(!is_valid_ai(""));
assert!(!is_valid_ai("0"));
assert!(!is_valid_ai("12345"));
assert!(!is_valid_ai("123456"));
assert!(!is_valid_ai("ab"));
assert!(!is_valid_ai("1A"));
assert!(!is_valid_ai("12a"));
assert!(!is_valid_ai("X234"));
}
#[test]
fn hex_digit_per_arm_and_boundaries() {
assert_eq!(hex_digit(b'0'), Some(0));
assert_eq!(hex_digit(b'9'), Some(9));
assert_eq!(hex_digit(b'5'), Some(5));
assert_eq!(hex_digit(b'a'), Some(10));
assert_eq!(hex_digit(b'f'), Some(15));
assert_eq!(hex_digit(b'c'), Some(12));
assert_eq!(hex_digit(b'A'), Some(10));
assert_eq!(hex_digit(b'F'), Some(15));
assert_eq!(hex_digit(b'C'), Some(12));
assert_eq!(hex_digit(b'/'), None, "below '0'");
assert_eq!(hex_digit(b':'), None, "above '9'");
assert_eq!(hex_digit(b'`'), None, "below 'a'");
assert_eq!(hex_digit(b'g'), None, "above 'f'");
assert_eq!(hex_digit(b'@'), None, "below 'A'");
assert_eq!(hex_digit(b'G'), None, "above 'F'");
assert_eq!(hex_digit(b' '), None);
assert_eq!(hex_digit(0), None);
assert_eq!(hex_digit(255), None);
}
#[test]
fn ai_is_variable_length_classifies_known_ais() {
assert_eq!(
ai_is_variable_length("01"),
Some(false),
"01 (GTIN-14) fixed"
);
assert_eq!(ai_is_variable_length("02"), Some(false));
assert_eq!(ai_is_variable_length("11"), Some(false), "11 (date) fixed");
assert_eq!(
ai_is_variable_length("17"),
Some(false),
"17 (expiry) fixed"
);
assert_eq!(ai_is_variable_length("20"), Some(false));
assert_eq!(ai_is_variable_length("10"), Some(true), "10 (lot) variable");
assert_eq!(
ai_is_variable_length("21"),
Some(true),
"21 (serial) variable"
);
assert_eq!(
ai_is_variable_length("99"),
Some(true),
"99 variable alphanumeric"
);
assert_eq!(ai_is_variable_length("99999"), None);
assert_eq!(ai_is_variable_length(""), None);
assert_eq!(
ai_is_variable_length("89"),
None,
"AI 89 not in BWIPP table"
);
}
#[test]
fn hex_digit_all_cases() {
assert_eq!(hex_digit(b'0'), Some(0));
assert_eq!(hex_digit(b'9'), Some(9));
assert_eq!(hex_digit(b'5'), Some(5));
assert_eq!(hex_digit(b'a'), Some(10));
assert_eq!(hex_digit(b'f'), Some(15));
assert_eq!(hex_digit(b'c'), Some(12));
assert_eq!(hex_digit(b'A'), Some(10));
assert_eq!(hex_digit(b'F'), Some(15));
assert_eq!(hex_digit(b'D'), Some(13));
assert_eq!(hex_digit(b'g'), None);
assert_eq!(hex_digit(b'G'), None);
assert_eq!(hex_digit(b' '), None);
assert_eq!(hex_digit(b':'), None); assert_eq!(hex_digit(b'/'), None); assert_eq!(hex_digit(b'@'), None); assert_eq!(hex_digit(b'['), None); assert_eq!(hex_digit(b'`'), None); }
#[test]
fn validate_data_per_branch() {
let fixed = AiSpec {
ai: "test",
min_len: 3,
max_len: Some(3),
variable: false,
format: DataFormat::Numeric,
};
assert!(
fixed.validate_data("123").is_ok(),
"AiSpec(min=3, max=3, fixed).validate_data(\"123\") (exact-length numeric) must accept — kills off-by-one length-guard mutants on min==max fixed-length specs"
);
match fixed.validate_data("12").unwrap_err() {
ParseError::BadLength { ai, expected, got } => {
assert_eq!(ai, "test", "ai field must echo AiSpec.ai");
assert_eq!(expected, "3", "expected = `{{min}}` when min==max");
assert_eq!(got, 2, "got = actual char count (2)");
}
other => panic!("\"12\" should be BadLength, got {other:?}"),
}
match fixed.validate_data("1234").unwrap_err() {
ParseError::BadLength { ai, expected, got } => {
assert_eq!(ai, "test");
assert_eq!(expected, "3");
assert_eq!(got, 4, "got = actual char count (4)");
}
other => panic!("\"1234\" should be BadLength, got {other:?}"),
}
match fixed.validate_data("12A").unwrap_err() {
ParseError::BadCharacter { ai, ch } => {
assert_eq!(ai, "test", "ai must echo the AiSpec's ai (\"test\")");
assert_eq!(ch, 'A', "ch must be 'A' (the first non-digit at idx 2)");
}
other => panic!("\"12A\" should be BadCharacter, got {other:?}"),
}
let range = AiSpec {
ai: "rng",
min_len: 2,
max_len: Some(5),
variable: true,
format: DataFormat::Numeric,
};
assert!(range.validate_data("12").is_ok(), "min boundary");
assert!(range.validate_data("12345").is_ok(), "max boundary");
match range.validate_data("1").unwrap_err() {
ParseError::BadLength { ai, expected, got } => {
assert_eq!(ai, "rng");
assert_eq!(expected, "2-5", "range AI expected = \"min-max\"");
assert_eq!(got, 1, "below-min count");
}
other => panic!("\"1\" should be BadLength, got {other:?}"),
}
match range.validate_data("123456").unwrap_err() {
ParseError::BadLength { ai, expected, got } => {
assert_eq!(ai, "rng");
assert_eq!(expected, "2-5");
assert_eq!(got, 6, "above-max count");
}
other => panic!("\"123456\" should be BadLength, got {other:?}"),
}
let var = AiSpec {
ai: "var",
min_len: 4,
max_len: None,
variable: true,
format: DataFormat::Alphanumeric,
};
assert!(var.validate_data("abcd").is_ok(), "min boundary");
assert!(var.validate_data("abcdefghij").is_ok(), "long ok");
match var.validate_data("abc").unwrap_err() {
ParseError::BadLength { ai, expected, got } => {
assert_eq!(ai, "var");
assert_eq!(expected, "4+", "open-max AI expected = \"min+\"");
assert_eq!(got, 3);
}
other => panic!("\"abc\" should be BadLength, got {other:?}"),
}
match var.validate_data("ab c").unwrap_err() {
ParseError::BadCharacter { ai, ch } => {
assert_eq!(ai, "var");
assert_eq!(ch, ' ', "BadCharacter must echo the offending char");
}
other => panic!("\"ab c\" should be BadCharacter, got {other:?}"),
}
match var.validate_data("abc\n").unwrap_err() {
ParseError::BadCharacter { ai, ch } => {
assert_eq!(ai, "var");
assert_eq!(ch, '\n', "BadCharacter must echo newline as '\\n'");
}
other => panic!("\"abc\\n\" should be BadCharacter, got {other:?}"),
}
assert!(
var.validate_data("ab12!@#$").is_ok(),
"var-length Alphanumeric AiSpec.validate_data(\"ab12!@#$\") (lowercase letters + digits + 4 distinct punctuation) must accept — covers the full Alphanumeric char-class boundary, kills overly-strict char-class mutants"
);
}
#[test]
fn url_percent_decode_known_sequences() {
assert_eq!(url_percent_decode("abc"), "abc");
assert_eq!(url_percent_decode(""), "");
assert_eq!(url_percent_decode("a%20b"), "a b");
assert_eq!(url_percent_decode("%41%42%43"), "ABC");
assert_eq!(url_percent_decode("%41bc"), "Abc");
assert_eq!(url_percent_decode("ab%43"), "abC");
assert_eq!(url_percent_decode("%2f"), "/");
assert_eq!(url_percent_decode("%2F%6e"), "/n");
assert_eq!(url_percent_decode("a%"), "a%");
assert_eq!(url_percent_decode("a%X"), "a%X");
assert_eq!(url_percent_decode("a%XY"), "a%XY");
}
}