use crate::country::is_country_code;
use crate::validate::{CharsetPolicy, ValidationError, check_text, max_text_len};
use crate::xml_util::write_escaped;
const ADDRESS_ELEMENTS: [(&str, &str); 8] = [
("Dept", "PstlAdr/Dept"),
("SubDept", "PstlAdr/SubDept"),
("StrtNm", "PstlAdr/StrtNm"),
("BldgNb", "PstlAdr/BldgNb"),
("PstCd", "PstlAdr/PstCd"),
("TwnNm", "PstlAdr/TwnNm"),
("CtrySubDvsn", "PstlAdr/CtrySubDvsn"),
("AdrLine", "PstlAdr/AdrLine"),
];
fn address_field(element: &str) -> &'static str {
ADDRESS_ELEMENTS
.iter()
.find(|(name, _)| *name == element)
.map_or("PstlAdr/AdrLine", |(_, path)| *path)
}
pub const MAX_ADDRESS_LINES: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AddressError {
#[error("PstlAdr/TwnNm must not be empty")]
EmptyTown,
#[error("PstlAdr/Ctry {code:?} is not an ISO 3166-1 alpha-2 country")]
UnknownCountry {
code: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum AddressFormat {
Structured,
Hybrid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PostalAddress {
department: Option<String>,
sub_department: Option<String>,
street: Option<String>,
building_number: Option<String>,
post_code: Option<String>,
town_name: String,
country_subdivision: Option<String>,
country: String,
lines: Vec<String>,
}
impl PostalAddress {
pub fn new(town: impl Into<String>, country: &str) -> Result<Self, AddressError> {
let town_name = town.into();
if town_name.trim().is_empty() {
return Err(AddressError::EmptyTown);
}
if !is_country_code(country) {
return Err(AddressError::UnknownCountry {
code: country.to_owned(),
});
}
Ok(Self {
department: None,
sub_department: None,
street: None,
building_number: None,
post_code: None,
town_name,
country_subdivision: None,
country: country.to_ascii_uppercase(),
lines: Vec::new(),
})
}
#[must_use]
pub fn street(mut self, street: impl Into<String>) -> Self {
self.street = Some(street.into());
self
}
#[must_use]
pub fn building_number(mut self, number: impl Into<String>) -> Self {
self.building_number = Some(number.into());
self
}
#[must_use]
pub fn post_code(mut self, code: impl Into<String>) -> Self {
self.post_code = Some(code.into());
self
}
#[must_use]
pub fn country_subdivision(mut self, subdivision: impl Into<String>) -> Self {
self.country_subdivision = Some(subdivision.into());
self
}
#[must_use]
pub fn department(mut self, department: impl Into<String>) -> Self {
self.department = Some(department.into());
self
}
#[must_use]
pub fn sub_department(mut self, sub_department: impl Into<String>) -> Self {
self.sub_department = Some(sub_department.into());
self
}
#[must_use]
pub fn line(mut self, line: impl Into<String>) -> Self {
self.lines.push(line.into());
self
}
#[must_use]
pub fn town_name(&self) -> &str {
&self.town_name
}
#[must_use]
pub fn country(&self) -> &str {
&self.country
}
#[must_use]
pub fn street_name(&self) -> Option<&str> {
self.street.as_deref()
}
#[must_use]
pub fn lines(&self) -> &[String] {
&self.lines
}
#[must_use]
pub fn format(&self) -> AddressFormat {
if self.lines.is_empty() {
AddressFormat::Structured
} else {
AddressFormat::Hybrid
}
}
pub fn validate(&self, charset: CharsetPolicy) -> Result<(), ValidationError> {
let check = |element: &'static str, value: &str| -> Result<(), ValidationError> {
let field = address_field(element);
let max = max_text_len(Some("PstlAdr"), element).unwrap_or(0);
check_text(field, &charset.apply(field, value)?, max)
};
check("TwnNm", &self.town_name)?;
for (element, value) in [
("Dept", &self.department),
("SubDept", &self.sub_department),
("StrtNm", &self.street),
("BldgNb", &self.building_number),
("PstCd", &self.post_code),
("CtrySubDvsn", &self.country_subdivision),
] {
if let Some(value) = value {
check(element, value)?;
}
}
if self.lines.len() > MAX_ADDRESS_LINES {
return Err(ValidationError::TooMany {
field: "PstlAdr/AdrLine",
max: MAX_ADDRESS_LINES,
actual: self.lines.len(),
});
}
for line in &self.lines {
check("AdrLine", line)?;
}
Ok(())
}
pub(crate) fn write_xml<W: std::fmt::Write>(
&self,
w: &mut W,
charset: CharsetPolicy,
) -> std::fmt::Result {
fn element<W: std::fmt::Write>(
w: &mut W,
tag: &'static str,
value: &str,
charset: CharsetPolicy,
) -> std::fmt::Result {
write!(w, "<{tag}>")?;
write_escaped(w, &charset.render(value))?;
write!(w, "</{tag}>")
}
w.write_str("<PstlAdr>")?;
for (tag, value) in [
("Dept", &self.department),
("SubDept", &self.sub_department),
("StrtNm", &self.street),
("BldgNb", &self.building_number),
("PstCd", &self.post_code),
] {
if let Some(value) = value {
element(w, tag, value, charset)?;
}
}
element(w, "TwnNm", &self.town_name, charset)?;
if let Some(subdivision) = &self.country_subdivision {
element(w, "CtrySubDvsn", subdivision, charset)?;
}
write!(w, "<Ctry>{}</Ctry>", self.country)?;
for line in &self.lines {
element(w, "AdrLine", line, charset)?;
}
w.write_str("</PstlAdr>")
}
}
#[cfg(test)]
mod tests {
#[test]
fn every_address_element_this_crate_emits_has_a_bound() {
for (element, field) in super::ADDRESS_ELEMENTS {
assert!(
crate::validate::max_text_len(Some("PstlAdr"), element).is_some(),
"{field}: no bound in validate::max_text_len"
);
assert_eq!(super::address_field(element), field);
}
}
use super::{AddressError, AddressFormat, PostalAddress};
use crate::validate::{CharsetPolicy, ValidationError};
fn render(a: &PostalAddress) -> String {
let mut out = String::new();
a.write_xml(&mut out, CharsetPolicy::default()).unwrap();
out
}
#[test]
fn a_town_and_a_country_are_required_at_construction() {
assert_eq!(PostalAddress::new("", "DE"), Err(AddressError::EmptyTown));
assert_eq!(
PostalAddress::new(" ", "DE"),
Err(AddressError::EmptyTown)
);
assert_eq!(
PostalAddress::new("Atlantis", "ZZ"),
Err(AddressError::UnknownCountry {
code: "ZZ".to_owned()
})
);
assert!(PostalAddress::new("Nowhere", "QQ").is_err());
assert!(PostalAddress::new("Berlin", "DEU").is_err());
}
#[test]
fn the_country_code_is_normalised() {
assert_eq!(PostalAddress::new("Wien", "at").unwrap().country(), "AT");
}
#[test]
fn format_reports_structured_versus_hybrid() {
let structured = PostalAddress::new("Berlin", "DE")
.unwrap()
.street("Unter den Linden")
.building_number("77")
.post_code("10117");
assert_eq!(structured.format(), AddressFormat::Structured);
assert_eq!(
structured.clone().line("2. OG").format(),
AddressFormat::Hybrid
);
}
#[test]
fn elements_are_written_in_xsd_sequence_order() {
let xml = render(
&PostalAddress::new("Berlin", "DE")
.unwrap()
.department("Buchhaltung")
.sub_department("Kreditoren")
.street("Unter den Linden")
.building_number("77")
.post_code("10117")
.country_subdivision("BE")
.line("Aufgang C"),
);
assert_eq!(
xml,
"<PstlAdr><Dept>Buchhaltung</Dept><SubDept>Kreditoren</SubDept>\
<StrtNm>Unter den Linden</StrtNm><BldgNb>77</BldgNb><PstCd>10117</PstCd>\
<TwnNm>Berlin</TwnNm><CtrySubDvsn>BE</CtrySubDvsn><Ctry>DE</Ctry>\
<AdrLine>Aufgang C</AdrLine></PstlAdr>"
);
}
#[test]
fn the_minimal_address_is_town_and_country() {
assert_eq!(
render(&PostalAddress::new("Berlin", "DE").unwrap()),
"<PstlAdr><TwnNm>Berlin</TwnNm><Ctry>DE</Ctry></PstlAdr>"
);
}
#[test]
fn text_is_transliterated_and_escaped() {
let xml = render(
&PostalAddress::new("München", "DE")
.unwrap()
.street("Straße & Weg"),
);
assert!(xml.contains("<StrtNm>Strasse + Weg</StrtNm>"));
assert!(xml.contains("<TwnNm>Muenchen</TwnNm>"));
}
#[test]
fn lengths_are_the_iso_maximums_and_are_measured_after_transliteration() {
let policy = CharsetPolicy::default();
let at = |a: PostalAddress| a.validate(policy);
assert!(at(PostalAddress::new("A".repeat(35), "DE").unwrap()).is_ok());
assert!(matches!(
at(PostalAddress::new("A".repeat(36), "DE").unwrap()),
Err(ValidationError::TooLong {
field: "PstlAdr/TwnNm",
max: 35,
..
})
));
assert!(matches!(
at(PostalAddress::new("Berlin", "DE")
.unwrap()
.street("A".repeat(71))),
Err(ValidationError::TooLong {
field: "PstlAdr/StrtNm",
max: 70,
..
})
));
assert!(matches!(
at(PostalAddress::new("Berlin", "DE")
.unwrap()
.building_number("7".repeat(17))),
Err(ValidationError::TooLong {
field: "PstlAdr/BldgNb",
max: 16,
..
})
));
assert!(matches!(
at(PostalAddress::new("ü".repeat(35), "DE").unwrap()),
Err(ValidationError::TooLong {
max: 35,
actual: 70,
..
})
));
assert!(at(PostalAddress::new("ü".repeat(17), "DE").unwrap()).is_ok());
}
#[test]
fn a_third_address_line_is_rejected_rather_than_dropped() {
let policy = CharsetPolicy::default();
let two = PostalAddress::new("Berlin", "DE")
.unwrap()
.line("one")
.line("two");
assert!(two.validate(policy).is_ok());
assert_eq!(
two.line("three").validate(policy),
Err(ValidationError::TooMany {
field: "PstlAdr/AdrLine",
max: 2,
actual: 3,
})
);
}
#[test]
fn a_blank_element_is_rejected() {
assert!(matches!(
PostalAddress::new("Berlin", "DE")
.unwrap()
.street(" ")
.validate(CharsetPolicy::default()),
Err(ValidationError::Empty {
field: "PstlAdr/StrtNm"
})
));
}
#[test]
fn strict_charset_policy_rejects_rather_than_rewrites() {
assert!(matches!(
PostalAddress::new("München", "DE")
.unwrap()
.validate(CharsetPolicy::Strict),
Err(ValidationError::InvalidCharacter {
field: "PstlAdr/TwnNm",
ch: 'ü'
})
));
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trips() {
let a = PostalAddress::new("Berlin", "DE")
.unwrap()
.street("Unter den Linden")
.line("Aufgang C");
let json = serde_json::to_string(&a).unwrap();
assert_eq!(serde_json::from_str::<PostalAddress>(&json).unwrap(), a);
}
}