Skip to main content

geo_kit/
address.rs

1//! Address newtype with validated components.
2
3extern crate alloc;
4
5use alloc::string::{String, ToString};
6
7use crate::coords::Coords;
8use crate::country::CountryCode;
9use crate::error::GeoError;
10use crate::postcode::{UkPostcode, UsZipCode};
11
12/// Which postcode type is used in the address.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "serde", serde(tag = "type", content = "value"))]
16pub enum PostcodeChoice {
17    /// UK postcode.
18    Uk(UkPostcode),
19    /// US ZIP code.
20    Us(UsZipCode),
21}
22
23impl core::fmt::Display for PostcodeChoice {
24    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
25        match self {
26            PostcodeChoice::Uk(x) => f.write_str(x.as_str()),
27            PostcodeChoice::Us(x) => f.write_str(x.as_str()),
28        }
29    }
30}
31
32/// A validated postal address.
33///
34/// Validation:
35/// - `line1` must be non-empty after trimming
36/// - `city` must be non-empty after trimming
37/// - `postcode` must be valid (already validated by its type)
38/// - `country` must be valid
39/// - `line2` and `county` if `Some` must be non-empty after trimming
40/// - optional `coords` if `Some` must be valid
41#[derive(Debug, Clone, PartialEq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct Address {
44    /// First address line (required, non-empty).
45    pub line1: String,
46    /// Optional second address line.
47    pub line2: Option<String>,
48    /// City / town (required, non-empty).
49    pub city: String,
50    /// Optional county / state / province.
51    pub county: Option<String>,
52    /// Postcode / ZIP.
53    pub postcode: UkPostcode,
54    /// ISO 3166-1 alpha-2 country code.
55    pub country: CountryCode,
56    /// Optional coordinates.
57    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
58    pub coords: Option<Coords>,
59}
60
61impl Address {
62    /// Create a validated address.
63    ///
64    /// Validates that required string fields are non-empty after trimming and that optional string
65    /// fields, if present, are non-empty after trimming.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`GeoError::InvalidAddress`] if validation fails.
70    pub fn new(
71        line1: String,
72        line2: Option<String>,
73        city: String,
74        county: Option<String>,
75        postcode: UkPostcode,
76        country: CountryCode,
77    ) -> Result<Self, GeoError> {
78        Self::new_with_coords(line1, line2, city, county, postcode, country, None)
79    }
80
81    /// Create a validated address with optional coordinates.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`GeoError::InvalidAddress`] if validation fails or [`GeoError::InvalidCoords`] via
86    /// address error conversion if coords are invalid (coords are already validated on construction).
87    pub fn new_with_coords(
88        line1: String,
89        line2: Option<String>,
90        city: String,
91        county: Option<String>,
92        postcode: UkPostcode,
93        country: CountryCode,
94        coords: Option<Coords>,
95    ) -> Result<Self, GeoError> {
96        validate_address_fields(&line1, &line2, &city, &county)?;
97        Ok(Address {
98            line1: line1.trim().to_string(),
99            line2: line2.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
100            city: city.trim().to_string(),
101            county: county.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
102            postcode,
103            country,
104            coords,
105        })
106    }
107
108    /// Return line1.
109    #[must_use]
110    pub fn line1(&self) -> &str {
111        &self.line1
112    }
113
114    /// Return city.
115    #[must_use]
116    pub fn city(&self) -> &str {
117        &self.city
118    }
119
120    /// Return postcode.
121    #[must_use]
122    pub fn postcode(&self) -> &UkPostcode {
123        &self.postcode
124    }
125
126    /// Return country.
127    #[must_use]
128    pub fn country(&self) -> &CountryCode {
129        &self.country
130    }
131}
132
133fn validate_address_fields(
134    line1: &str,
135    line2: &Option<String>,
136    city: &str,
137    county: &Option<String>,
138) -> Result<(), GeoError> {
139    if line1.trim().is_empty() {
140        return Err(GeoError::InvalidAddress(
141            "line1 must be non-empty".to_string(),
142        ));
143    }
144    if city.trim().is_empty() {
145        return Err(GeoError::InvalidAddress(
146            "city must be non-empty".to_string(),
147        ));
148    }
149    if line1.contains('\r') || line1.contains('\n') {
150        return Err(GeoError::InvalidAddress(
151            "line1 must not contain CR or LF".to_string(),
152        ));
153    }
154    if city.contains('\r') || city.contains('\n') {
155        return Err(GeoError::InvalidAddress(
156            "city must not contain CR or LF".to_string(),
157        ));
158    }
159    if let Some(l2) = line2 {
160        if l2.trim().is_empty() {
161            return Err(GeoError::InvalidAddress(
162                "line2 must be non-empty if provided".to_string(),
163            ));
164        }
165        if l2.contains('\r') || l2.contains('\n') {
166            return Err(GeoError::InvalidAddress(
167                "line2 must not contain CR or LF".to_string(),
168            ));
169        }
170    }
171    if let Some(c) = county {
172        if c.trim().is_empty() {
173            return Err(GeoError::InvalidAddress(
174                "county must be non-empty if provided".to_string(),
175            ));
176        }
177        if c.contains('\r') || c.contains('\n') {
178            return Err(GeoError::InvalidAddress(
179                "county must not contain CR or LF".to_string(),
180            ));
181        }
182    }
183    Ok(())
184}
185
186/// Address builder for ergonomic construction.
187///
188/// Validates on [`AddressBuilder::build`].
189#[derive(Debug, Clone, Default)]
190pub struct AddressBuilder {
191    line1: Option<String>,
192    line2: Option<String>,
193    city: Option<String>,
194    county: Option<String>,
195    postcode: Option<UkPostcode>,
196    country: Option<CountryCode>,
197    coords: Option<Coords>,
198}
199
200impl AddressBuilder {
201    /// Create a new empty builder.
202    #[must_use]
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// Set line1.
208    #[must_use]
209    pub fn line1(mut self, s: impl Into<String>) -> Self {
210        self.line1 = Some(s.into());
211        self
212    }
213
214    /// Set line2.
215    #[must_use]
216    pub fn line2(mut self, s: impl Into<String>) -> Self {
217        self.line2 = Some(s.into());
218        self
219    }
220
221    /// Set city.
222    #[must_use]
223    pub fn city(mut self, s: impl Into<String>) -> Self {
224        self.city = Some(s.into());
225        self
226    }
227
228    /// Set county.
229    #[must_use]
230    pub fn county(mut self, s: impl Into<String>) -> Self {
231        self.county = Some(s.into());
232        self
233    }
234
235    /// Set postcode.
236    #[must_use]
237    pub fn postcode(mut self, p: UkPostcode) -> Self {
238        self.postcode = Some(p);
239        self
240    }
241
242    /// Set country.
243    #[must_use]
244    pub fn country(mut self, c: CountryCode) -> Self {
245        self.country = Some(c);
246        self
247    }
248
249    /// Set coordinates.
250    #[must_use]
251    pub fn coords(mut self, c: Coords) -> Self {
252        self.coords = Some(c);
253        self
254    }
255
256    /// Build the address, validating required fields.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`GeoError::InvalidAddress`] if required fields are missing or invalid.
261    pub fn build(self) -> Result<Address, GeoError> {
262        let line1 = self.line1.ok_or_else(|| {
263            GeoError::InvalidAddress("line1 is required".to_string())
264        })?;
265        let city = self.city.ok_or_else(|| {
266            GeoError::InvalidAddress("city is required".to_string())
267        })?;
268        let postcode = self.postcode.ok_or_else(|| {
269            GeoError::InvalidAddress("postcode is required".to_string())
270        })?;
271        let country = self.country.ok_or_else(|| {
272            GeoError::InvalidAddress("country is required".to_string())
273        })?;
274        Address::new_with_coords(line1, self.line2, city, self.county, postcode, country, self.coords)
275    }
276}
277
278impl core::fmt::Display for Address {
279    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
280        write!(f, "{}, {}", self.line1, self.city)?;
281        if let Some(l2) = &self.line2 {
282            write!(f, " ({})", l2)?;
283        }
284        write!(f, ", {}", self.postcode)?;
285        write!(f, ", {}", self.country)?;
286        if let Some(coords) = &self.coords {
287            write!(f, " @ {}", coords)?;
288        }
289        Ok(())
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::country::CountryCode;
297    use crate::postcode::UkPostcode;
298
299    fn uk(s: &str) -> UkPostcode {
300        UkPostcode::parse(s).expect("valid uk postcode")
301    }
302    fn cc(s: &str) -> CountryCode {
303        CountryCode::parse(s).expect("valid country")
304    }
305
306    #[test]
307    fn valid_address() {
308        let a = Address::new(
309            "10 Downing St".to_string(),
310            None,
311            "London".to_string(),
312            None,
313            uk("SW1A 2AA"),
314            cc("GB"),
315        )
316        .expect("valid");
317        assert_eq!(a.line1(), "10 Downing St");
318        assert_eq!(a.city(), "London");
319    }
320
321    #[test]
322    fn valid_with_all_fields() {
323        let a = AddressBuilder::new()
324            .line1("221B Baker St")
325            .line2("Flat B")
326            .city("London")
327            .county("Greater London")
328            .postcode(uk("NW1 6XE"))
329            .country(cc("GB"))
330            .build()
331            .expect("valid");
332        assert_eq!(a.line2.as_deref(), Some("Flat B"));
333        assert_eq!(a.county.as_deref(), Some("Greater London"));
334    }
335
336    #[test]
337    fn valid_with_coords() {
338        let coords = Coords::new(51.5, -0.12).expect("valid coords");
339        let a = Address::new_with_coords(
340            "10 Downing St".to_string(),
341            None,
342            "London".to_string(),
343            None,
344            uk("SW1A 2AA"),
345            cc("GB"),
346            Some(coords),
347        )
348        .expect("valid");
349        assert_eq!(a.coords, Some(coords));
350    }
351
352    #[test]
353    fn invalid_empty_line1() {
354        let res = Address::new(
355            "   ".to_string(),
356            None,
357            "London".to_string(),
358            None,
359            uk("SW1A 2AA"),
360            cc("GB"),
361        );
362        assert!(res.is_err());
363    }
364
365    #[test]
366    fn invalid_empty_city() {
367        let res = Address::new(
368            "10 Downing St".to_string(),
369            None,
370            "".to_string(),
371            None,
372            uk("SW1A 2AA"),
373            cc("GB"),
374        );
375        assert!(res.is_err());
376    }
377
378    #[test]
379    fn invalid_line2_empty() {
380        let res = Address::new(
381            "10 Downing St".to_string(),
382            Some("   ".to_string()),
383            "London".to_string(),
384            None,
385            uk("SW1A 2AA"),
386            cc("GB"),
387        );
388        assert!(res.is_err());
389    }
390
391    #[test]
392    fn builder_missing_field() {
393        let res = AddressBuilder::new().line1("10 Downing St").city("London").build();
394        assert!(res.is_err());
395    }
396}