#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(unsafe_code)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
InvalidPrefix,
MissingField,
ExtraFields,
InvalidFormat,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::InvalidPrefix => write!(f, "Invalid identifier prefix"),
ParseError::MissingField => write!(f, "Missing field in identifier"),
ParseError::ExtraFields => write!(f, "Unexpected extra fields"),
ParseError::InvalidFormat => write!(f, "Invalid identifier format"),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sgtin<'a> {
pub company_prefix: &'a str,
pub indicator: &'a str,
pub item_ref: &'a str,
pub serial_number: &'a str,
}
impl<'a> Sgtin<'a> {
pub fn from_urn(urn: &'a str) -> Result<Self, ParseError> {
let body = urn.strip_prefix("urn:epc:id:sgtin:")
.ok_or(ParseError::InvalidPrefix)?;
let mut parts = body.split('.');
let company_prefix = parts.next().ok_or(ParseError::MissingField)?;
let indicator_and_item_ref = parts.next().ok_or(ParseError::MissingField)?;
let serial_number = parts.next().ok_or(ParseError::MissingField)?;
if parts.next().is_some() {
return Err(ParseError::ExtraFields);
}
if indicator_and_item_ref.is_empty() {
return Err(ParseError::InvalidFormat);
}
let indicator = &indicator_and_item_ref[0..1];
let item_ref = &indicator_and_item_ref[1..];
Ok(Self {
company_prefix,
indicator,
item_ref,
serial_number,
})
}
#[must_use]
pub fn to_urn(&self) -> String {
format!(
"urn:epc:id:sgtin:{}.{}{}.{}",
self.company_prefix, self.indicator, self.item_ref, self.serial_number
)
}
pub fn from_digital_link(url: &'a str, prefix_len: usize) -> Result<Self, ParseError> {
let idx = url.find("/01/").ok_or(ParseError::InvalidFormat)?;
let path = &url[idx + 4..];
let mut parts = path.split('/');
let gtin = parts.next().ok_or(ParseError::MissingField)?;
let ai = parts.next().ok_or(ParseError::MissingField)?;
if ai != "21" {
return Err(ParseError::InvalidFormat);
}
let serial_number = parts.next().ok_or(ParseError::MissingField)?;
if gtin.len() != 14 {
return Err(ParseError::InvalidFormat);
}
if prefix_len >= 13 {
return Err(ParseError::InvalidFormat);
}
let indicator = >in[0..1];
let company_prefix = >in[1..=prefix_len];
let item_ref = >in[1 + prefix_len..13];
Ok(Self {
company_prefix,
indicator,
item_ref,
serial_number,
})
}
#[must_use]
pub fn to_digital_link(&self, base_url: &str) -> String {
let gtin_without_check = format!("{}{}{}", self.indicator, self.company_prefix, self.item_ref);
let check_digit = calculate_check_digit(>in_without_check);
format!(
"{}/01/{}{}/21/{}",
base_url.trim_end_matches('/'),
gtin_without_check,
check_digit,
self.serial_number
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sscc<'a> {
pub company_prefix: &'a str,
pub extension_digit: &'a str,
pub serial_ref: &'a str,
}
impl<'a> Sscc<'a> {
pub fn from_urn(urn: &'a str) -> Result<Self, ParseError> {
let body = urn.strip_prefix("urn:epc:id:sscc:")
.ok_or(ParseError::InvalidPrefix)?;
let mut parts = body.split('.');
let company_prefix = parts.next().ok_or(ParseError::MissingField)?;
let ext_and_serial = parts.next().ok_or(ParseError::MissingField)?;
if parts.next().is_some() {
return Err(ParseError::ExtraFields);
}
if ext_and_serial.is_empty() {
return Err(ParseError::InvalidFormat);
}
let extension_digit = &ext_and_serial[0..1];
let serial_ref = &ext_and_serial[1..];
Ok(Self {
company_prefix,
extension_digit,
serial_ref,
})
}
#[must_use]
pub fn to_urn(&self) -> String {
format!(
"urn:epc:id:sscc:{}.{}{}",
self.company_prefix, self.extension_digit, self.serial_ref
)
}
pub fn from_digital_link(url: &'a str, prefix_len: usize) -> Result<Self, ParseError> {
let idx = url.find("/00/").ok_or(ParseError::InvalidFormat)?;
let path = &url[idx + 4..];
let mut parts = path.split('/');
let sscc = parts.next().ok_or(ParseError::MissingField)?;
if sscc.len() != 18 {
return Err(ParseError::InvalidFormat);
}
if prefix_len >= 17 {
return Err(ParseError::InvalidFormat);
}
let extension_digit = &sscc[0..1];
let company_prefix = &sscc[1..=prefix_len];
let serial_ref = &sscc[1 + prefix_len..17];
Ok(Self {
company_prefix,
extension_digit,
serial_ref,
})
}
#[must_use]
pub fn to_digital_link(&self, base_url: &str) -> String {
let sscc_without_check = format!("{}{}{}", self.extension_digit, self.company_prefix, self.serial_ref);
let check_digit = calculate_check_digit(&sscc_without_check);
format!(
"{}/00/{}{}",
base_url.trim_end_matches('/'),
sscc_without_check,
check_digit
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sgln<'a> {
pub company_prefix: &'a str,
pub location_reference: &'a str,
pub extension: &'a str,
}
impl<'a> Sgln<'a> {
pub fn from_urn(urn: &'a str) -> Result<Self, ParseError> {
let body = urn.strip_prefix("urn:epc:id:sgln:")
.ok_or(ParseError::InvalidPrefix)?;
let mut parts = body.split('.');
let company_prefix = parts.next().ok_or(ParseError::MissingField)?;
let location_reference = parts.next().ok_or(ParseError::MissingField)?;
let extension = parts.next().ok_or(ParseError::MissingField)?;
if parts.next().is_some() {
return Err(ParseError::ExtraFields);
}
Ok(Self {
company_prefix,
location_reference,
extension,
})
}
#[must_use]
pub fn to_urn(&self) -> String {
format!(
"urn:epc:id:sgln:{}.{}.{}",
self.company_prefix, self.location_reference, self.extension
)
}
pub fn from_digital_link(url: &'a str, prefix_len: usize) -> Result<Self, ParseError> {
let idx = url.find("/414/").ok_or(ParseError::InvalidFormat)?;
let path = &url[idx + 5..];
let mut parts = path.split('/');
let gln = parts.next().ok_or(ParseError::MissingField)?;
let next_ai = parts.next().ok_or(ParseError::MissingField)?;
if next_ai != "254" {
return Err(ParseError::InvalidFormat);
}
let extension = parts.next().ok_or(ParseError::MissingField)?;
if gln.len() != 13 {
return Err(ParseError::InvalidFormat);
}
if prefix_len >= 12 {
return Err(ParseError::InvalidFormat);
}
let company_prefix = &gln[0..prefix_len];
let location_reference = &gln[prefix_len..12];
Ok(Self {
company_prefix,
location_reference,
extension,
})
}
#[must_use]
pub fn to_digital_link(&self, base_url: &str) -> String {
let gln_without_check = format!("{}{}", self.company_prefix, self.location_reference);
let check_digit = calculate_check_digit(&gln_without_check);
format!(
"{}/414/{}{}/254/{}",
base_url.trim_end_matches('/'),
gln_without_check,
check_digit,
self.extension
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grai<'a> {
pub company_prefix: &'a str,
pub asset_type: &'a str,
pub serial_number: &'a str,
}
impl<'a> Grai<'a> {
pub fn from_urn(urn: &'a str) -> Result<Self, ParseError> {
let body = urn.strip_prefix("urn:epc:id:grai:")
.ok_or(ParseError::InvalidPrefix)?;
let mut parts = body.split('.');
let company_prefix = parts.next().ok_or(ParseError::MissingField)?;
let asset_type = parts.next().ok_or(ParseError::MissingField)?;
let serial_number = parts.next().ok_or(ParseError::MissingField)?;
if parts.next().is_some() {
return Err(ParseError::ExtraFields);
}
Ok(Self {
company_prefix,
asset_type,
serial_number,
})
}
#[must_use]
pub fn to_urn(&self) -> String {
format!(
"urn:epc:id:grai:{}.{}.{}",
self.company_prefix, self.asset_type, self.serial_number
)
}
pub fn from_digital_link(url: &'a str, prefix_len: usize) -> Result<Self, ParseError> {
let idx = url.find("/8003/").ok_or(ParseError::InvalidFormat)?;
let path = &url[idx + 6..];
if path.len() < 14 {
return Err(ParseError::InvalidFormat);
}
let grai_id = &path[0..14];
let serial_number = &path[14..];
if prefix_len >= 12 {
return Err(ParseError::InvalidFormat);
}
let company_prefix = &grai_id[1..=prefix_len];
let asset_type = &grai_id[1 + prefix_len..13];
Ok(Self {
company_prefix,
asset_type,
serial_number,
})
}
#[must_use]
pub fn to_digital_link(&self, base_url: &str) -> String {
let grai_without_check = format!("0{}{}", self.company_prefix, self.asset_type);
let check_digit = calculate_check_digit(&grai_without_check);
format!(
"{}/8003/{}{}{}",
base_url.trim_end_matches('/'),
grai_without_check,
check_digit,
self.serial_number
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Giai<'a> {
pub company_prefix: &'a str,
pub individual_asset_reference: &'a str,
}
impl<'a> Giai<'a> {
pub fn from_urn(urn: &'a str) -> Result<Self, ParseError> {
let body = urn.strip_prefix("urn:epc:id:giai:")
.ok_or(ParseError::InvalidPrefix)?;
let mut parts = body.split('.');
let company_prefix = parts.next().ok_or(ParseError::MissingField)?;
let individual_asset_reference = parts.next().ok_or(ParseError::MissingField)?;
if parts.next().is_some() {
return Err(ParseError::ExtraFields);
}
Ok(Self {
company_prefix,
individual_asset_reference,
})
}
#[must_use]
pub fn to_urn(&self) -> String {
format!(
"urn:epc:id:giai:{}.{}",
self.company_prefix, self.individual_asset_reference
)
}
pub fn from_digital_link(url: &'a str, prefix_len: usize) -> Result<Self, ParseError> {
let idx = url.find("/8004/").ok_or(ParseError::InvalidFormat)?;
let path = &url[idx + 6..];
let mut parts = path.split('/');
let raw_giai = parts.next().ok_or(ParseError::MissingField)?;
if raw_giai.len() <= prefix_len {
return Err(ParseError::InvalidFormat);
}
let company_prefix = &raw_giai[0..prefix_len];
let individual_asset_reference = &raw_giai[prefix_len..];
Ok(Self {
company_prefix,
individual_asset_reference,
})
}
#[must_use]
pub fn to_digital_link(&self, base_url: &str) -> String {
format!(
"{}/8004/{}{}",
base_url.trim_end_matches('/'),
self.company_prefix,
self.individual_asset_reference
)
}
}
fn calculate_check_digit(digits: &str) -> u32 {
let mut sum = 0;
for (i, char) in digits.chars().rev().enumerate() {
if let Some(digit) = char.to_digit(10) {
if i % 2 == 0 {
sum += digit * 3;
} else {
sum += digit;
}
}
}
let remainder = sum % 10;
if remainder == 0 {
0
} else {
10 - remainder
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sgtin_parse_errors() {
assert_eq!(Sgtin::from_urn("urn:epc:id:sscc:4012345.098765.12345"), Err(ParseError::InvalidPrefix));
assert_eq!(Sgtin::from_urn("urn:epc:id:sgtin:4012345"), Err(ParseError::MissingField));
assert_eq!(Sgtin::from_urn("urn:epc:id:sgtin:4012345.098765.12345.extra"), Err(ParseError::ExtraFields));
assert_eq!(Sgtin::from_urn("urn:epc:id:sgtin:4012345..12345"), Err(ParseError::InvalidFormat));
assert_eq!(Sgtin::from_digital_link("https://id.gs1.org/00/340123450123456784", 7), Err(ParseError::InvalidFormat));
assert_eq!(Sgtin::from_digital_link("https://id.gs1.org/01/04012345987652/22/12345", 7), Err(ParseError::InvalidFormat));
assert_eq!(Sgtin::from_digital_link("https://id.gs1.org/01/0401234598765/21/12345", 7), Err(ParseError::InvalidFormat)); assert_eq!(Sgtin::from_digital_link("https://id.gs1.org/01/04012345987652/21/12345", 14), Err(ParseError::InvalidFormat)); }
#[test]
fn test_sscc_parse_errors() {
assert_eq!(Sscc::from_urn("urn:epc:id:sgtin:4012345.0123456789"), Err(ParseError::InvalidPrefix));
assert_eq!(Sscc::from_urn("urn:epc:id:sscc:4012345"), Err(ParseError::MissingField));
assert_eq!(Sscc::from_urn("urn:epc:id:sscc:4012345.0123456789.extra"), Err(ParseError::ExtraFields));
assert_eq!(Sscc::from_urn("urn:epc:id:sscc:"), Err(ParseError::MissingField));
assert_eq!(Sscc::from_digital_link("https://id.gs1.org/01/04012345987652/21/12345", 7), Err(ParseError::InvalidFormat));
assert_eq!(Sscc::from_digital_link("https://id.gs1.org/00/34012345012345678", 7), Err(ParseError::InvalidFormat)); assert_eq!(Sscc::from_digital_link("https://id.gs1.org/00/340123450123456784", 17), Err(ParseError::InvalidFormat)); }
#[test]
fn test_sgln_parse_errors() {
assert_eq!(Sgln::from_urn("urn:epc:id:sscc:4012345.00001.0"), Err(ParseError::InvalidPrefix));
assert_eq!(Sgln::from_urn("urn:epc:id:sgln:4012345.00001"), Err(ParseError::MissingField));
assert_eq!(Sgln::from_urn("urn:epc:id:sgln:4012345.00001.0.extra"), Err(ParseError::ExtraFields));
assert_eq!(Sgln::from_digital_link("https://id.gs1.org/415/4012345000016/254/0", 7), Err(ParseError::InvalidFormat));
assert_eq!(Sgln::from_digital_link("https://id.gs1.org/414/4012345000016/255/0", 7), Err(ParseError::InvalidFormat));
assert_eq!(Sgln::from_digital_link("https://id.gs1.org/414/401234500001/254/0", 7), Err(ParseError::InvalidFormat)); assert_eq!(Sgln::from_digital_link("https://id.gs1.org/414/4012345000016/254/0", 12), Err(ParseError::InvalidFormat)); }
#[test]
fn test_grai_parse_errors() {
assert_eq!(Grai::from_urn("urn:epc:id:sscc:4012345.00001.12345"), Err(ParseError::InvalidPrefix));
assert_eq!(Grai::from_urn("urn:epc:id:grai:4012345.00001"), Err(ParseError::MissingField));
assert_eq!(Grai::from_urn("urn:epc:id:grai:4012345.00001.12345.extra"), Err(ParseError::ExtraFields));
assert_eq!(Grai::from_digital_link("https://id.gs1.org/8004/0401234500001612345", 7), Err(ParseError::InvalidFormat));
assert_eq!(Grai::from_digital_link("https://id.gs1.org/8003/0401234500001", 7), Err(ParseError::InvalidFormat)); assert_eq!(Grai::from_digital_link("https://id.gs1.org/8003/0401234500001612345", 12), Err(ParseError::InvalidFormat)); }
#[test]
fn test_giai_parse_errors() {
assert_eq!(Giai::from_urn("urn:epc:id:sscc:4012345.12345"), Err(ParseError::InvalidPrefix));
assert_eq!(Giai::from_urn("urn:epc:id:giai:4012345"), Err(ParseError::MissingField));
assert_eq!(Giai::from_urn("urn:epc:id:giai:4012345.12345.extra"), Err(ParseError::ExtraFields));
assert_eq!(Giai::from_digital_link("https://id.gs1.org/8003/401234512345", 7), Err(ParseError::InvalidFormat));
assert_eq!(Giai::from_digital_link("https://id.gs1.org/8004/401234512345", 15), Err(ParseError::InvalidFormat)); }
#[test]
fn test_translation_performance_guard() {
use std::time::Instant;
let urn = "urn:epc:id:sgtin:4012345.098765.12345";
let dl = "https://id.gs1.org/01/04012345987652/21/12345";
let start = Instant::now();
for _ in 0..100_000 {
let sgtin = Sgtin::from_urn(urn).unwrap();
let _dl_out = sgtin.to_digital_link("https://id.gs1.org");
let sgtin_dl = Sgtin::from_digital_link(dl, 7).unwrap();
let _urn_out = sgtin_dl.to_urn();
}
let elapsed = start.elapsed();
println!("Translated 100k inputs in: {:?}", elapsed);
assert!(elapsed.as_millis() < 500, "Performance regression detected! 100k roundtrips took {:?}", elapsed);
}
}
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "wasm")]
#[wasm_bindgen]
pub fn translate_urn_to_dl_wasm(urn: &str, base_url: &str) -> Result<String, String> {
let parts: Vec<&str> = urn.split(':').collect();
if parts.len() < 5 {
return Err("Invalid URN format. Expected e.g. urn:epc:id:sgtin:...".to_string());
}
let scheme = parts[3];
match scheme {
"sgtin" => {
let sgtin = Sgtin::from_urn(urn).map_err(|e| format!("Failed to parse SGTIN: {:?}", e))?;
Ok(sgtin.to_digital_link(base_url))
}
"sscc" => {
let sscc = Sscc::from_urn(urn).map_err(|e| format!("Failed to parse SSCC: {:?}", e))?;
Ok(sscc.to_digital_link(base_url))
}
"sgln" => {
let sgln = Sgln::from_urn(urn).map_err(|e| format!("Failed to parse SGLN: {:?}", e))?;
Ok(sgln.to_digital_link(base_url))
}
"grai" => {
let grai = Grai::from_urn(urn).map_err(|e| format!("Failed to parse GRAI: {:?}", e))?;
Ok(grai.to_digital_link(base_url))
}
"giai" => {
let giai = Giai::from_urn(urn).map_err(|e| format!("Failed to parse GIAI: {:?}", e))?;
Ok(giai.to_digital_link(base_url))
}
other => Err(format!("Unsupported URN scheme: {}", other)),
}
}
#[cfg(feature = "wasm")]
#[wasm_bindgen]
pub fn translate_dl_to_urn_wasm(dl: &str, prefix_len: usize) -> Result<String, String> {
if dl.contains("/01/") {
let sgtin = Sgtin::from_digital_link(dl, prefix_len).map_err(|e| format!("Failed to parse SGTIN DL: {:?}", e))?;
Ok(sgtin.to_urn())
} else if dl.contains("/00/") {
let sscc = Sscc::from_digital_link(dl, prefix_len).map_err(|e| format!("Failed to parse SSCC DL: {:?}", e))?;
Ok(sscc.to_urn())
} else if dl.contains("/414/") {
let sgln = Sgln::from_digital_link(dl, prefix_len).map_err(|e| format!("Failed to parse SGLN DL: {:?}", e))?;
Ok(sgln.to_urn())
} else if dl.contains("/8003/") {
let grai = Grai::from_digital_link(dl, prefix_len).map_err(|e| format!("Failed to parse GRAI DL: {:?}", e))?;
Ok(grai.to_urn())
} else if dl.contains("/8004/") {
let giai = Giai::from_digital_link(dl, prefix_len).map_err(|e| format!("Failed to parse GIAI DL: {:?}", e))?;
Ok(giai.to_urn())
} else {
Err("Could not detect GS1 Application Identifier (AI) in Digital Link path (expected e.g. /01/, /00/, /414/, /8003/, /8004/)".to_string())
}
}