Skip to main content

dinio_sdk/ops/
geo.rs

1//! Geo areas for geo bans: human input (`DE`, `DEU`, `Germany`, `Europe`, `Southern Europe`,
2//! `country:276`) ⇄ Core's (level, numeric code).
3//!
4//! Names resolve through a [`GeoCatalog`] read from Core
5//! ([`Geo::catalog`](crate::api::Geo::catalog)), the same table Core checks ban codes
6//! against and Relay matches queries with. Intermediate regions (e.g. Caribbean) are not
7//! a level.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Error, Result};
15use crate::models::GeoCatalog;
16
17/// How large a banned area is (`GeoBan.level`, `../core/src/api/enum_labels.ts`).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum GeoLevel {
20    /// A continent-sized UN M49 region (`Europe`, code 150).
21    Region,
22    /// A UN M49 sub-region (`Southern Europe`, code 39).
23    Subregion,
24    /// A country (ISO 3166-1 numeric, `Croatia`, code 191).
25    Country,
26}
27
28impl GeoLevel {
29    /// The label Core uses (`Region`, `Subregion`, `Country`).
30    pub fn as_wire(self) -> &'static str {
31        match self {
32            Self::Region => "Region",
33            Self::Subregion => "Subregion",
34            Self::Country => "Country",
35        }
36    }
37}
38
39impl fmt::Display for GeoLevel {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(self.as_wire())
42    }
43}
44
45impl FromStr for GeoLevel {
46    type Err = Error;
47
48    fn from_str(text: &str) -> Result<Self> {
49        match text.trim().to_ascii_lowercase().as_str() {
50            "region" => Ok(Self::Region),
51            "subregion" | "sub-region" => Ok(Self::Subregion),
52            "country" => Ok(Self::Country),
53            _ => Err(Error::InvalidInput(format!(
54                "unknown geo level {text:?} (Region, Subregion or Country)"
55            ))),
56        }
57    }
58}
59
60/// A resolved area, as Core and Relay identify it. Usually made by
61/// [`GeoCatalog::parse_area`].
62///
63/// Displays as `Country 191`; [`GeoCatalog::describe`] adds the name.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct GeoArea {
66    /// Region, sub-region or country.
67    pub level: GeoLevel,
68    /// UN M49 / ISO 3166-1 numeric code at that level.
69    pub code: u16,
70}
71
72impl fmt::Display for GeoArea {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "{} {}", self.level, self.code)
75    }
76}
77
78/// One entry of the catalog, for listing ([`GeoCatalog::known_areas`]).
79#[derive(Debug, Clone, Serialize)]
80pub struct KnownArea<'a> {
81    /// Region, sub-region or country.
82    pub level: GeoLevel,
83    /// Numeric code at that level.
84    pub code: u16,
85    /// English name (`Croatia`, `Southern Europe`).
86    pub name: &'a str,
87    /// Countries only.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub alpha2: Option<&'a str>,
90}
91
92impl GeoCatalog {
93    /// Resolves what a user typed. Accepted, case-insensitive: `level:code` or
94    /// `level:name` (`country:276`, `region:Europe`), a country alpha-2 or alpha-3 code
95    /// (`DE`, `DEU`), a country, region or sub-region name. A bare number is ambiguous
96    /// (region and country codes overlap) and needs a level prefix.
97    ///
98    /// # Errors
99    ///
100    /// [`Error::InvalidInput`] for an unknown or ambiguous area, or a code that is not in
101    /// the catalog at its level (Core would refuse it); the message says what is accepted.
102    /// [`GeoCatalog::known_areas`] lists every area.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use dinio_sdk::models::GeoCatalog;
108    /// use dinio_sdk::ops::GeoLevel;
109    ///
110    /// # let catalog: GeoCatalog = serde_json::from_value(serde_json::json!({
111    /// #     "regions": [{ "code": 150, "name": "Europe" }],
112    /// #     "subregions": [{ "code": 39, "name": "Southern Europe", "region_code": 150 }],
113    /// #     "countries": [{ "code": 191, "alpha2": "HR", "alpha3": "HRV", "name": "Croatia",
114    /// #                     "region_code": 150, "subregion_code": 39 }]
115    /// # })).unwrap();
116    /// // let catalog = client.geo().catalog().await?;
117    /// let hr = catalog.parse_area("HR")?;
118    /// assert_eq!((hr.level, hr.code), (GeoLevel::Country, 191));
119    /// assert_eq!(catalog.parse_area("croatia")?, hr);
120    /// assert_eq!(catalog.parse_area("Europe")?.level, GeoLevel::Region);
121    /// assert!(catalog.parse_area("150").is_err()); // region or country? say `region:150`
122    /// assert!(catalog.parse_area("country:999").is_err()); // not in the catalog
123    /// # Ok::<(), dinio_sdk::Error>(())
124    /// ```
125    pub fn parse_area(&self, input: &str) -> Result<GeoArea> {
126        let input = input.trim();
127        if let Some((level, rest)) = input.split_once(':') {
128            let level: GeoLevel = level.parse()?;
129            let rest = rest.trim();
130            if let Ok(code) = rest.parse::<u16>() {
131                let area = GeoArea { level, code };
132                return match self.name(area) {
133                    Some(_) => Ok(area),
134                    None => Err(Error::InvalidInput(format!(
135                        "unknown {level} code {code} (see the list of known areas)"
136                    ))),
137                };
138            }
139            return self
140                .find(rest, Some(level))
141                .ok_or_else(|| Error::InvalidInput(format!("unknown {level} {rest:?}")));
142        }
143        if input.parse::<u16>().is_ok() {
144            return Err(Error::InvalidInput(format!(
145                "{input:?} is ambiguous; write country:{input}, region:{input} or subregion:{input}"
146            )));
147        }
148        self.find(input, None).ok_or_else(|| {
149            Error::InvalidInput(format!(
150                "unknown area {input:?} (a country code like DE, a country, region or sub-region name, or level:code)"
151            ))
152        })
153    }
154
155    /// The area's English name (`Germany`, `Europe`), if the catalog has it.
156    pub fn name(&self, area: GeoArea) -> Option<&str> {
157        match area.level {
158            GeoLevel::Country => self
159                .countries
160                .iter()
161                .find(|c| c.code == area.code)
162                .map(|c| c.name.as_str()),
163            GeoLevel::Region => self
164                .regions
165                .iter()
166                .find(|r| r.code == area.code)
167                .map(|r| r.name.as_str()),
168            GeoLevel::Subregion => self
169                .subregions
170                .iter()
171                .find(|s| s.code == area.code)
172                .map(|s| s.name.as_str()),
173        }
174    }
175
176    /// `Country Germany (276)`; an area the catalog does not have shows without a name
177    /// (`Country 999`).
178    pub fn describe(&self, area: GeoArea) -> String {
179        match self.name(area) {
180            Some(name) => format!("{} {name} ({})", area.level, area.code),
181            None => area.to_string(),
182        }
183    }
184
185    /// Every area: regions, then sub-regions, then countries.
186    pub fn known_areas(&self) -> Vec<KnownArea<'_>> {
187        let regions = self.regions.iter().map(|r| KnownArea {
188            level: GeoLevel::Region,
189            code: r.code,
190            name: &r.name,
191            alpha2: None,
192        });
193        let subregions = self.subregions.iter().map(|s| KnownArea {
194            level: GeoLevel::Subregion,
195            code: s.code,
196            name: &s.name,
197            alpha2: None,
198        });
199        let countries = self.countries.iter().map(|c| KnownArea {
200            level: GeoLevel::Country,
201            code: c.code,
202            name: &c.name,
203            alpha2: Some(&c.alpha2),
204        });
205        regions.chain(subregions).chain(countries).collect()
206    }
207
208    fn find(&self, text: &str, only: Option<GeoLevel>) -> Option<GeoArea> {
209        let wanted = |level| only.is_none_or(|l| l == level);
210        let area = |level, code| GeoArea { level, code };
211
212        if wanted(GeoLevel::Country)
213            && let Some(c) = self.countries.iter().find(|c| {
214                c.alpha2.eq_ignore_ascii_case(text)
215                    || c.alpha3.eq_ignore_ascii_case(text)
216                    || c.name.eq_ignore_ascii_case(text)
217            })
218        {
219            return Some(area(GeoLevel::Country, c.code));
220        }
221        if wanted(GeoLevel::Region)
222            && let Some(r) = self
223                .regions
224                .iter()
225                .find(|r| r.name.eq_ignore_ascii_case(text))
226        {
227            return Some(area(GeoLevel::Region, r.code));
228        }
229        if wanted(GeoLevel::Subregion)
230            && let Some(s) = self
231                .subregions
232                .iter()
233                .find(|s| s.name.eq_ignore_ascii_case(text))
234        {
235            return Some(area(GeoLevel::Subregion, s.code));
236        }
237        None
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    /// A small catalog in Core's wire shape (`GET /api/v1/geo`).
246    fn catalog() -> GeoCatalog {
247        serde_json::from_value(serde_json::json!({
248            "regions": [
249                { "code": 150, "name": "Europe" },
250                { "code": 19, "name": "Americas" }
251            ],
252            "subregions": [
253                { "code": 39, "name": "Southern Europe", "region_code": 150 },
254                { "code": 155, "name": "Western Europe", "region_code": 150 }
255            ],
256            "countries": [
257                { "code": 191, "alpha2": "HR", "alpha3": "HRV", "name": "Croatia",
258                  "region_code": 150, "subregion_code": 39 },
259                { "code": 276, "alpha2": "DE", "alpha3": "DEU", "name": "Germany",
260                  "region_code": 150, "subregion_code": 155 },
261                { "code": 10, "alpha2": "AQ", "alpha3": "ATA", "name": "Antarctica",
262                  "region_code": null, "subregion_code": null }
263            ]
264        }))
265        .unwrap()
266    }
267
268    fn area(level: GeoLevel, code: u16) -> GeoArea {
269        GeoArea { level, code }
270    }
271
272    #[test]
273    fn country_without_region_deserializes() {
274        let aq = &catalog().countries[2];
275        assert_eq!((aq.region_code, aq.subregion_code), (None, None));
276    }
277
278    #[test]
279    fn countries() {
280        let c = catalog();
281        let de = area(GeoLevel::Country, 276);
282        assert_eq!(c.parse_area("DE").unwrap(), de);
283        assert_eq!(c.parse_area("deu").unwrap(), de);
284        assert_eq!(c.parse_area("germany").unwrap(), de);
285        assert_eq!(c.parse_area("country:276").unwrap(), de);
286        assert_eq!(c.parse_area("Country:DE").unwrap(), de);
287        assert_eq!(c.parse_area("HR").unwrap(), area(GeoLevel::Country, 191));
288    }
289
290    #[test]
291    fn regions() {
292        let c = catalog();
293        assert_eq!(c.parse_area("Europe").unwrap(), area(GeoLevel::Region, 150));
294        assert_eq!(
295            c.parse_area("southern europe").unwrap(),
296            area(GeoLevel::Subregion, 39)
297        );
298        assert_eq!(
299            c.parse_area("subregion:39").unwrap(),
300            area(GeoLevel::Subregion, 39)
301        );
302    }
303
304    #[test]
305    fn rejects_ambiguous_and_unknown() {
306        let c = catalog();
307        assert!(c.parse_area("150").is_err());
308        assert!(c.parse_area("Atlantis").is_err());
309        assert!(c.parse_area("region:Germany").is_err());
310        assert!(c.parse_area("planet:3").is_err());
311        assert!(c.parse_area("country:999").is_err());
312        assert!(c.parse_area("region:191").is_err());
313    }
314
315    #[test]
316    fn describe() {
317        let c = catalog();
318        assert_eq!(
319            c.describe(area(GeoLevel::Country, 191)),
320            "Country Croatia (191)"
321        );
322        assert_eq!(
323            c.describe(area(GeoLevel::Region, 150)),
324            "Region Europe (150)"
325        );
326        assert_eq!(c.describe(area(GeoLevel::Country, 999)), "Country 999");
327    }
328
329    #[test]
330    fn known_areas_in_order() {
331        let c = catalog();
332        let levels: Vec<GeoLevel> = c.known_areas().iter().map(|a| a.level).collect();
333        assert_eq!(levels.len(), 7);
334        assert_eq!(levels[0], GeoLevel::Region);
335        assert_eq!(levels[2], GeoLevel::Subregion);
336        assert_eq!(levels[6], GeoLevel::Country);
337    }
338}