zone_detect/
lib.rs

1//! Example:
2//!
3//! ```
4//! let database = zone_detect::Database::open("data/timezone21.bin")
5//!     .expect("failed to open database");
6//! let s = database.simple_lookup(zone_detect::Location {
7//!     latitude: 35.0715,
8//!     longitude: -82.5216,
9//! }).unwrap();
10//! assert_eq!(s, "America/New_York");
11//! ```
12
13#![deny(missing_docs)]
14
15mod generated;
16
17use generated::{PointLookupResult, decode_variable_length_unsigned};
18use std::{
19    collections::HashMap, convert::TryInto, fs, io, path::Path,
20    string::FromUtf8Error,
21};
22
23/// Latitude and longitude.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct Location {
26    /// Latitude.
27    pub latitude: f32,
28    /// Longitude.
29    pub longitude: f32,
30}
31
32impl Location {
33    /// Create a new Location.
34    pub fn new(latitude: f32, longitude: f32) -> Location {
35        Location {
36            latitude,
37            longitude,
38        }
39    }
40}
41
42/// Zone retrieved from the database.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct Zone {
45    /// Polygon ID.
46    pub polygon_id: u32,
47    /// Metadata ID.
48    pub meta_id: u32,
49    /// Zone information. The keys will vary depending on the database.
50    pub fields: HashMap<String, String>,
51}
52
53#[allow(missing_docs)]
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum ZoneMatchKind {
56    InZone,
57    InExcludedZone,
58    OnBorderVertex,
59    OnBorderSegment,
60}
61
62impl ZoneMatchKind {
63    fn from_point_lookup(r: PointLookupResult) -> Option<ZoneMatchKind> {
64        match r {
65            PointLookupResult::InZone => Some(ZoneMatchKind::InZone),
66            PointLookupResult::InExcludedZone => {
67                Some(ZoneMatchKind::InExcludedZone)
68            }
69            PointLookupResult::OnBorderVertex => {
70                Some(ZoneMatchKind::OnBorderVertex)
71            }
72            PointLookupResult::OnBorderSegment => {
73                Some(ZoneMatchKind::OnBorderSegment)
74            }
75            _ => None,
76        }
77    }
78}
79
80/// Zone retrieved from the database, along with the type of result.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct ZoneMatch {
83    /// Type of match.
84    pub kind: ZoneMatchKind,
85    /// Zone information.
86    pub zone: Zone,
87}
88
89/// Matching zones and safezone from a database lookup.
90#[derive(Clone, Debug, PartialEq)]
91pub struct ZoneLookup {
92    /// List of matching zones.
93    pub matches: Vec<ZoneMatch>,
94    /// TODO: not sure what this value is
95    pub safezone: f32,
96}
97
98#[allow(missing_docs)]
99#[derive(Debug, thiserror::Error)]
100pub enum Error {
101    #[error("IO error")]
102    IoError(#[from] io::Error),
103    #[error("database header is truncated")]
104    TruncatedDatabase(usize),
105    #[error("invalid magic bytes")]
106    InvalidMagic([u8; 3]),
107    #[error("invalid version")]
108    InvalidVersion(u8),
109    #[error("invalid table type")]
110    InvalidTableType(u8),
111    #[error("invalid field name")]
112    InvalidFieldName(u8, StringParseError),
113    #[error("invalid notice")]
114    InvalidNotice(StringParseError),
115    #[error("invalid metadata offset")]
116    InvalidMetadataOffset,
117    #[error("invalid data offset")]
118    InvalidDataOffset,
119    // TODO: I'm not actually sure what this offset is supposed to be,
120    // calling it padding for now
121    #[error("invalid padding offset")]
122    InvalidPaddingOffset,
123    #[error("length mismatch")]
124    LengthMismatch(usize),
125}
126
127/// Database type.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum TableType {
130    /// Country-name database.
131    Country,
132    /// Timezone database.
133    Timezone,
134}
135
136#[allow(missing_docs)]
137pub type Result<T> = std::result::Result<T, Error>;
138
139#[allow(missing_docs)]
140#[derive(Debug, thiserror::Error)]
141pub enum StringParseError {
142    #[error("encoding error")]
143    EncodingError,
144    #[error("invalid UTF-8")]
145    InvalidUtf8(#[from] FromUtf8Error),
146}
147
148fn parse_string(
149    db: &Database,
150    index: &mut u32,
151) -> std::result::Result<String, StringParseError> {
152    if let Some(bytes) = generated::parse_string(db, index) {
153        let string = String::from_utf8(bytes)?;
154        Ok(string)
155    } else {
156        Err(StringParseError::EncodingError)
157    }
158}
159
160/// Zone database.
161pub struct Database {
162    bbox_offset: u32,
163    data_offset: u32,
164    mapping: Vec<u8>,
165    metadata_offset: u32,
166
167    /// Names of all the fields in the database.
168    pub field_names: Vec<String>,
169    /// Database notice text (e.g. for licensing information).
170    pub notice: String,
171    /// Precision of the data.
172    pub precision: u8,
173    /// Type of data (country or timezone).
174    pub table_type: crate::TableType,
175    /// Encoding version.
176    pub version: u8,
177}
178
179impl Database {
180    /// Open a zone database.
181    pub fn open<P: AsRef<Path>>(path: P) -> Result<Database> {
182        let mapping = fs::read(path)?;
183        Self::from_vec(mapping)
184    }
185
186    /// Load the database from a byte vector.
187    pub fn from_vec(mapping: Vec<u8>) -> Result<Database> {
188        let mut db = Database {
189            mapping,
190            notice: String::new(),
191            table_type: TableType::Country,
192            version: 0,
193            precision: 0,
194            field_names: Vec::new(),
195            bbox_offset: 0,
196            metadata_offset: 0,
197            data_offset: 0,
198        };
199        Self::parse_header(&mut db)?;
200        Ok(db)
201    }
202
203    fn parse_header(db: &mut Database) -> Result<()> {
204        if db.mapping.len() < 7 {
205            return Err(Error::TruncatedDatabase(db.mapping.len()));
206        }
207
208        let expected_magic = b"PLB";
209        let actual_magic = &db.mapping[0..3];
210        if actual_magic != expected_magic {
211            return Err(Error::InvalidMagic(
212                actual_magic.try_into().unwrap_or([0; 3]),
213            ));
214        }
215
216        let table_type = db.mapping[3];
217        db.version = db.mapping[4];
218        db.precision = db.mapping[5];
219        let num_fields = db.mapping[6];
220
221        if table_type == b'T' {
222            db.table_type = TableType::Timezone;
223        } else if table_type == b'C' {
224            db.table_type = TableType::Country;
225        } else {
226            return Err(Error::InvalidTableType(table_type));
227        }
228
229        if db.version >= 2 {
230            return Err(Error::InvalidVersion(db.version));
231        }
232
233        // Start reading at byte 7
234        let mut index = 7;
235
236        db.field_names.reserve(num_fields as usize);
237        for field_index in 0..num_fields {
238            let name = parse_string(db, &mut index)
239                .map_err(|err| Error::InvalidFieldName(field_index, err))?;
240            db.field_names.push(name);
241        }
242
243        db.notice =
244            parse_string(db, &mut index).map_err(Error::InvalidNotice)?;
245
246        // Read section sizes. Note that bboxOffset is already initialized to zero.
247        let mut tmp: u64 = 0;
248        if decode_variable_length_unsigned(db, &mut index, &mut tmp) == 0 {
249            return Err(Error::InvalidMetadataOffset);
250        }
251        db.metadata_offset = tmp as u32 + db.bbox_offset;
252
253        if decode_variable_length_unsigned(db, &mut index, &mut tmp) == 0 {
254            return Err(Error::InvalidDataOffset);
255        }
256        db.data_offset = tmp as u32 + db.metadata_offset;
257
258        if decode_variable_length_unsigned(db, &mut index, &mut tmp) == 0 {
259            return Err(Error::InvalidPaddingOffset);
260        }
261
262        // Add header size to everything
263        db.bbox_offset += index;
264        db.metadata_offset += index;
265        db.data_offset += index;
266
267        // Verify file length
268        let length = (tmp + db.data_offset as u64) as usize;
269        if length != db.mapping.len() {
270            return Err(Error::LengthMismatch(length));
271        }
272
273        Ok(())
274    }
275
276    /// Get a simple description of a location.
277    ///
278    /// For a country database this will be the country name, for the
279    /// timezone database it will be the timezone ID.
280    pub fn simple_lookup(&self, location: Location) -> Option<String> {
281        let results = generated::lookup(self, location, None);
282
283        if let Some(result) = results.first() {
284            match self.table_type {
285                TableType::Country => result.zone.fields.get("Name"),
286                TableType::Timezone => {
287                    if let Some(prefix) =
288                        result.zone.fields.get("TimezoneIdPrefix")
289                    {
290                        if let Some(id) = result.zone.fields.get("TimezoneId") {
291                            return Some(format!("{prefix}{id}"));
292                        }
293                    }
294                    None
295                }
296            }
297            .cloned()
298        } else {
299            None
300        }
301    }
302
303    /// Perform a full database lookup for a location.
304    pub fn lookup(&self, location: Location) -> ZoneLookup {
305        let mut safezone: f32 = 0.0;
306        let results = generated::lookup(self, location, Some(&mut safezone));
307        let matches = results
308            .iter()
309            .map(|r| {
310                ZoneMatch {
311                    // Unwrapping should be OK here since the lookup
312                    // function already filters out other kinds of results
313                    kind: ZoneMatchKind::from_point_lookup(r.result)
314                        .expect("invalid match kind"),
315                    zone: r.zone.clone(),
316                }
317            })
318            .collect::<Vec<_>>();
319        ZoneLookup { matches, safezone }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_open() {
329        let db = Database::open("data/timezone21.bin").unwrap();
330        assert_eq!(db.bbox_offset, 288);
331        assert_eq!(db.metadata_offset, 31803);
332        assert_eq!(db.data_offset, 40825);
333        assert_eq!(db.notice, "Contains data from Natural Earth, placed in the Public Domain. Contains information from https://github.com/evansiroky/timezone-boundary-builder, which is made available here under the Open Database License \\(ODbL\\).".to_string());
334        assert_eq!(db.table_type, TableType::Timezone);
335        assert_eq!(db.precision, 21);
336        assert_eq!(
337            db.field_names,
338            vec![
339                "TimezoneIdPrefix".to_string(),
340                "TimezoneId".to_string(),
341                "CountryAlpha2".to_string(),
342                "CountryName".to_string(),
343            ]
344        );
345    }
346
347    #[test]
348    fn test_simple_lookup() {
349        let db = Database::open("data/timezone21.bin").unwrap();
350        // Beijing
351        assert_eq!(
352            db.simple_lookup(Location::new(39.9042, 116.4074)).unwrap(),
353            "Asia/Shanghai"
354        );
355        // Buenos Aires
356        assert_eq!(
357            db.simple_lookup(Location::new(-34.6037, -58.3816)).unwrap(),
358            "America/Argentina/Buenos_Aires"
359        );
360        // Canberra
361        assert_eq!(
362            db.simple_lookup(Location::new(-35.2809, 149.13)).unwrap(),
363            "Australia/Sydney"
364        );
365        // New York City
366        assert_eq!(
367            db.simple_lookup(Location::new(40.7128, -74.0060)).unwrap(),
368            "America/New_York"
369        );
370    }
371}