Skip to main content

zipcodes/
lib.rs

1//! Query U.S. zipcodes without SQLite.
2//!
3//! The full zipcode dataset is embedded into the binary at compile time via
4//! [`include_bytes!`] and lazily decompressed/parsed on first access, making
5//! this crate suitable for constrained environments (AWS Lambda, containers)
6//! with no runtime file I/O.
7
8use std::io::prelude::*;
9use std::sync::LazyLock;
10
11use bzip2::read::BzDecoder;
12use serde::{Deserialize, Serialize};
13
14const ZIPCODE_LENGTH: usize = 5;
15
16static ZIPCODE_BYTES_BZIP: &[u8] = include_bytes!("zips.json.bz2");
17
18static ZIPCODES: LazyLock<Vec<Zipcode>> = LazyLock::new(|| {
19    let mut decompressor = BzDecoder::new(ZIPCODE_BYTES_BZIP);
20    let mut zipcode_json = String::new();
21    decompressor
22        .read_to_string(&mut zipcode_json)
23        .expect("failed to decompress embedded zipcode database");
24    serde_json::from_str::<Vec<Zipcode>>(&zipcode_json)
25        .unwrap_or_else(|e| panic!("failed to deserialize zipcode database: {}", e))
26});
27
28/// Describes different types of errors with supplied zipcodes during parsing.
29#[derive(thiserror::Error, Debug)]
30pub enum Error {
31    #[error("Invalid format, zipcode must be of the format: \"#####\" or \"#####-####\"")]
32    InvalidFormat,
33    #[error("Invalid characters, zipcode may only contain digits and \"-\".")]
34    InvalidCharacters,
35}
36
37/// A result type where the error is an `Error`.
38pub type Result<T> = std::result::Result<T, Error>;
39
40/// A record in the zipcode database.
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42pub struct Zipcode {
43    pub acceptable_cities: Vec<String>,
44    pub active: bool,
45    pub area_codes: Vec<String>,
46    pub city: String,
47    pub country: String,
48    pub county: String,
49    pub lat: String,
50    pub long: String,
51    pub state: String,
52    pub timezone: String,
53    pub unacceptable_cities: Vec<String>,
54    pub world_region: String,
55    pub zip_code: String,
56    pub zip_code_type: String,
57}
58
59impl Zipcode {
60    /// Compare a named field against a JSON value, mirroring the Python
61    /// package's `filter_by(**kwargs)` semantics: an unknown field name or a
62    /// type mismatch is simply not a match.
63    pub fn field_matches(&self, field: &str, value: &serde_json::Value) -> bool {
64        use serde_json::Value;
65        fn eq_list(items: &[String], value: &Value) -> bool {
66            match value {
67                Value::Array(arr) => {
68                    arr.len() == items.len()
69                        && arr.iter().zip(items).all(|(v, s)| v.as_str() == Some(s))
70                }
71                _ => false,
72            }
73        }
74        match field {
75            "zip_code" => value.as_str() == Some(&self.zip_code),
76            "zip_code_type" => value.as_str() == Some(&self.zip_code_type),
77            "active" => value.as_bool() == Some(self.active),
78            "city" => value.as_str() == Some(&self.city),
79            "acceptable_cities" => eq_list(&self.acceptable_cities, value),
80            "unacceptable_cities" => eq_list(&self.unacceptable_cities, value),
81            "state" => value.as_str() == Some(&self.state),
82            "county" => value.as_str() == Some(&self.county),
83            "timezone" => value.as_str() == Some(&self.timezone),
84            "area_codes" => eq_list(&self.area_codes, value),
85            "world_region" => value.as_str() == Some(&self.world_region),
86            "country" => value.as_str() == Some(&self.country),
87            "lat" => value.as_str() == Some(&self.lat),
88            "long" => value.as_str() == Some(&self.long),
89            _ => false,
90        }
91    }
92}
93
94/// Determine whether a supplied zipcode matches any existing zipcode. The supplied
95/// zipcode must be of the format: "#####", "#####-####", or "##### ####".
96pub fn matching(zipcode: &str, zipcodes: Option<Vec<Zipcode>>) -> Result<Vec<Zipcode>> {
97    let zipcode = clean_zipcode(zipcode)?;
98    let zipcodes = zipcodes.as_deref().unwrap_or(&ZIPCODES);
99    Ok(zipcodes
100        .iter()
101        .filter(|z| z.zip_code == zipcode)
102        .cloned()
103        .collect())
104}
105
106/// Returns true if the supplied zipcode exists in the database.
107pub fn is_real(zipcode: &str) -> Result<bool> {
108    Ok(!matching(zipcode, None)?.is_empty())
109}
110
111/// Return the zipcodes whose `zip_code` starts with the supplied prefix.
112pub fn similar_to(prefix: &str, zipcodes: Option<Vec<Zipcode>>) -> Vec<Zipcode> {
113    let zipcodes = zipcodes.as_deref().unwrap_or(&ZIPCODES);
114    zipcodes
115        .iter()
116        .filter(|z| z.zip_code.starts_with(prefix))
117        .cloned()
118        .collect()
119}
120
121/// Return the zipcodes whose `zip_code` contains the supplied fragment anywhere.
122pub fn contains(fragment: &str, zipcodes: Option<Vec<Zipcode>>) -> Vec<Zipcode> {
123    let zipcodes = zipcodes.as_deref().unwrap_or(&ZIPCODES);
124    zipcodes
125        .iter()
126        .filter(|z| z.zip_code.contains(fragment))
127        .cloned()
128        .collect()
129}
130
131/// Using a supplied list of filter-functions, return a filtered list of zipcodes.
132///
133/// By default, the supplied list of zipcodes is everything stored in the
134/// database. However, an optional list of override zipcodes can be supplied.
135pub fn filter_by<F>(filters: Vec<F>, zipcodes: Option<Vec<Zipcode>>) -> Result<Vec<Zipcode>>
136where
137    F: Fn(&Zipcode) -> bool,
138{
139    let zipcodes = zipcodes.as_deref().unwrap_or(&ZIPCODES);
140    Ok(zipcodes
141        .iter()
142        .filter(|z| filters.iter().all(|f| f(z)))
143        .cloned()
144        .collect())
145}
146
147/// Return the zipcodes whose named fields equal the supplied JSON values.
148///
149/// All `(field, value)` pairs must match. An unknown field name matches
150/// nothing, mirroring the Python package's `filter_by(**kwargs)`.
151pub fn filter_by_fields(
152    filters: &[(String, serde_json::Value)],
153    zipcodes: Option<Vec<Zipcode>>,
154) -> Vec<Zipcode> {
155    let zipcodes = zipcodes.as_deref().unwrap_or(&ZIPCODES);
156    zipcodes
157        .iter()
158        .filter(|z| {
159            filters
160                .iter()
161                .all(|(field, value)| z.field_matches(field, value))
162        })
163        .cloned()
164        .collect()
165}
166
167/// Calculate the great circle distance in miles between two points on the
168/// earth, specified in decimal degrees.
169pub fn haversine(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
170    let (lon1, lat1, lon2, lat2) = (
171        lon1.to_radians(),
172        lat1.to_radians(),
173        lon2.to_radians(),
174        lat2.to_radians(),
175    );
176    let dlon = lon2 - lon1;
177    let dlat = lat2 - lat1;
178    let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
179    let c = 2.0 * a.sqrt().asin();
180    let r = 3956.0; // Radius of earth in miles. Use 6371 for kilometers.
181    c * r
182}
183
184/// Return the zipcodes within `radius_in_miles` of the supplied coordinates.
185///
186/// Records whose stored coordinates fail to parse are excluded.
187pub fn filter_by_coordinates(
188    lat: f64,
189    long: f64,
190    radius_in_miles: f64,
191    zipcodes: Option<Vec<Zipcode>>,
192) -> Vec<Zipcode> {
193    let zipcodes = zipcodes.as_deref().unwrap_or(&ZIPCODES);
194    zipcodes
195        .iter()
196        .filter(|z| match (z.lat.parse::<f64>(), z.long.parse::<f64>()) {
197            (Ok(z_lat), Ok(z_long)) => haversine(z_long, z_lat, long, lat) <= radius_in_miles,
198            _ => false,
199        })
200        .cloned()
201        .collect()
202}
203
204/// Retrieve a list of all zipcodes in the database.
205pub fn list_all() -> Vec<Zipcode> {
206    ZIPCODES.clone()
207}
208
209/// Borrow the full embedded zipcode database without copying it.
210pub fn database() -> &'static [Zipcode] {
211    &ZIPCODES
212}
213
214fn clean_zipcode(zipcode: &str) -> Result<&str> {
215    let zipcode = zipcode.trim();
216    if zipcode.len() < ZIPCODE_LENGTH {
217        return Err(Error::InvalidFormat);
218    }
219    let prefix = &zipcode[..ZIPCODE_LENGTH];
220    if !prefix.chars().all(|c| c.is_ascii_digit()) {
221        return Err(Error::InvalidCharacters);
222    }
223    Ok(prefix)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use serde_json::json;
230
231    #[test]
232    fn should_find_real_zipcodes() {
233        assert!(is_real("06903").unwrap());
234        assert!(is_real("06905").unwrap());
235    }
236
237    #[test]
238    fn should_include_zips_from_issue_23() {
239        // https://github.com/seanpianka/Zipcodes/issues/23
240        assert!(is_real("85144").unwrap());
241    }
242
243    #[test]
244    fn database_invariants() {
245        let db = database();
246        assert!(
247            db.len() > 40_000 && db.len() < 50_000,
248            "unexpected database size: {}",
249            db.len()
250        );
251        let zips: Vec<&str> = db.iter().map(|z| z.zip_code.as_str()).collect();
252        let mut sorted = zips.clone();
253        sorted.sort_unstable();
254        assert_eq!(zips, sorted, "database must be sorted by zip_code");
255        let unique: std::collections::HashSet<_> = zips.iter().collect();
256        assert_eq!(
257            unique.len(),
258            zips.len(),
259            "database must not contain duplicate zip codes"
260        );
261    }
262
263    #[test]
264    fn should_not_find_fake_zipcodes() {
265        assert!(!is_real("91239").unwrap());
266    }
267
268    #[test]
269    fn should_return_no_zipcodes() {
270        for zc in &["00000", "00000-0000", "00000 0000"] {
271            assert!(matching(zc, None).unwrap().is_empty())
272        }
273    }
274
275    #[test]
276    fn should_match_zip_plus_four_to_base_zipcode() {
277        assert_eq!(matching("06903-1234", None).unwrap().len(), 1);
278    }
279
280    #[test]
281    fn should_fail_to_find_zipcodes_not_included_in_overrides() {
282        let zc = "06903";
283        matching(zc, None).unwrap();
284        assert!(matching(zc, Some(matching("06904", None).unwrap()))
285            .unwrap()
286            .is_empty());
287    }
288
289    #[test]
290    fn should_reject_invalid_zipcodes() {
291        assert!(matches!(matching("123", None), Err(Error::InvalidFormat)));
292        assert!(matches!(
293            matching("1234a", None),
294            Err(Error::InvalidCharacters)
295        ));
296    }
297
298    #[test]
299    fn should_include_county_field() {
300        let zips = matching("06475", None).unwrap();
301        assert_eq!(zips[0].county, "Middlesex County");
302        assert_eq!(zips[0].city, "Old Saybrook");
303    }
304
305    #[test]
306    fn should_find_similar_zipcodes_by_prefix() {
307        let zips = similar_to("1018", None);
308        assert_eq!(
309            zips.iter().map(|z| z.zip_code.as_str()).collect::<Vec<_>>(),
310            vec!["10184", "10185"]
311        );
312    }
313
314    #[test]
315    fn should_find_zipcodes_containing_fragment() {
316        assert!(contains("0185", None).iter().any(|z| z.zip_code == "10185"));
317    }
318
319    #[test]
320    fn should_filter_by_fields() {
321        let filters = vec![
322            ("active".to_string(), json!(true)),
323            ("city".to_string(), json!("Windsor")),
324        ];
325        let windsor = filter_by_fields(&filters, None);
326        assert!(!windsor.is_empty());
327        assert!(windsor.iter().all(|z| z.active && z.city == "Windsor"));
328        assert_eq!(similar_to("2", Some(windsor)).len(), 3);
329    }
330
331    #[test]
332    fn should_not_match_unknown_filter_fields() {
333        let filters = vec![("nonexistent".to_string(), json!("x"))];
334        assert!(filter_by_fields(&filters, None).is_empty());
335    }
336
337    #[test]
338    fn should_filter_by_coordinates() {
339        // Old Saybrook, CT (41.3015, -72.3879); a tight radius isolates it.
340        let nearby = filter_by_coordinates(41.3015, -72.3879, 1.0, None);
341        assert!(nearby.iter().any(|z| z.zip_code == "06475"));
342        // Wrong hemisphere (positive longitude) is nowhere near any US zipcode.
343        assert!(filter_by_coordinates(42.2529, 71.0023, 100.0, None).is_empty());
344    }
345
346    #[test]
347    fn haversine_known_distance() {
348        // NYC (40.7128, -74.0060) to LA (34.0522, -118.2437) is ~2445 miles.
349        let d = haversine(-74.0060, 40.7128, -118.2437, 34.0522);
350        assert!((d - 2445.0).abs() < 15.0, "distance was {}", d);
351    }
352}