Skip to main content

acorn/schema/
geonames.rs

1//! Module for working with GeoNames data
2use crate::prelude::*;
3use crate::util::{Constant, Searchable};
4use bon::Builder;
5use serde::{Deserialize, Serialize};
6use serde_with::skip_serializing_none;
7
8/// Trait for parsing GeoNames country data
9pub trait GeonamesParser {
10    /// Isolate country codes from GeoNames country data for use in other contexts
11    ///
12    /// Returns a sorted list of unique country code tags from ISO-3166-1
13    fn country_codes(format: CodeFormat) -> Vec<String>;
14    /// Parse GeoNames country TSV text response, removing comments and returning structured rows.
15    ///
16    /// Lines beginning with `#` are treated as comments and skipped. The first non-comment
17    /// line that starts with the `ISO` header row is treated as the header and skipped.
18    /// Remaining lines are split on tab characters and converted into [`Country`] values.
19    /// Lines that do not contain at least 19 tab-separated columns are ignored.
20    fn country_data() -> Countries;
21    /// Isolate languages data from GeoNames country data for use in other contexts
22    ///
23    /// Returns a sorted list of unique language tags from ISO-639-(1|2|3)
24    fn languages(format: CodeFormat) -> Vec<String>;
25}
26/// Wrapper type for a list of GeoNames countries
27#[derive(Clone, Debug, Default, Deserialize, Serialize)]
28#[serde(transparent)]
29pub struct Countries(pub Vec<Country>);
30/// Selects code format to extract (either alpha-2 or alpha-3).
31#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
32pub enum CodeFormat {
33    /// Two-letter code (e.g., `US` for countries, `en` for languages).
34    Alpha2,
35    /// Three-letter or longer code (e.g., `USA` for countries, `eng` or `en-US` for languages).
36    Alpha3,
37}
38/// Represents a single GeoNames country row parsed from a TSV file.
39///
40/// The fields map directly to the columns in the GeoNames `countryInfo.txt`/TSV export.
41#[skip_serializing_none]
42#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
43#[builder(start_fn = init, on(String, into))]
44pub struct Country {
45    /// Two-letter ISO 3166-1 alpha-2 country code (e.g., "US")
46    pub iso: String,
47    /// Three-letter ISO 3166-1 alpha-3 country code (e.g., "USA")
48    pub iso3: String,
49    /// Numeric ISO 3166-1 code as a string (e.g., "840")
50    pub iso_numeric: String,
51    /// FIPS 10-4 country code (deprecated in favor of ISO codes)
52    pub fips: String,
53    /// Country name
54    pub name: String,
55    /// Capital city name
56    pub capital: String,
57    /// Country area in square kilometers
58    pub area: Option<u64>,
59    /// Country population
60    pub population: Option<u64>,
61    /// Continent code (e.g., "EU", "NA")
62    pub continent: String,
63    /// Top-level domain (TLD) for the country (e.g., ".us")
64    pub top_level_domain: String,
65    /// Currency code (e.g., "USD")
66    pub currency_code: String,
67    /// Currency name (e.g., "Dollar")
68    pub currency_name: String,
69    /// Country calling code or pattern
70    pub country_code: String,
71    /// Postal code format string
72    pub postal_code_format: String,
73    /// Postal code regular expression
74    pub postal_code_regex: String,
75    /// List of language tags ordered by number of speakers
76    pub languages: Vec<String>,
77    /// GeoNames identifier for the country
78    pub identifier: Option<u64>,
79    /// List of neighboring country ISO codes
80    pub neighbours: Vec<String>,
81    /// Equivalent FIPS code, if any (deprecated in favor of ISO codes)
82    pub equivalent_fips_code: Option<String>,
83}
84impl Countries {
85    /// Return the number of countries in the collection.
86    pub fn len(&self) -> usize {
87        self.0.len()
88    }
89    /// Return true when the collection contains no countries.
90    pub fn is_empty(&self) -> bool {
91        self.0.is_empty()
92    }
93}
94impl Searchable<Country> for Countries {
95    fn contains(&self, value: &str) -> bool {
96        let trimmed = value.trim();
97        !trimmed.is_empty()
98            && self.0.iter().any(|country| {
99                let Country { iso, iso3, .. } = country;
100                iso.eq_ignore_ascii_case(trimmed) || iso3.eq_ignore_ascii_case(trimmed)
101            })
102    }
103    fn find_by_iso(&self, value: impl Into<String>) -> Option<Country> {
104        let trimmed = value.into().trim().to_string();
105        self.0
106            .iter()
107            .find(|country| {
108                let Country { iso, iso3, .. } = country;
109                iso.eq_ignore_ascii_case(&trimmed) || iso3.eq_ignore_ascii_case(&trimmed)
110            })
111            .cloned()
112    }
113    fn find_by_name(&self, value: impl Into<String>) -> Option<Country> {
114        let trimmed = value.into().trim().to_string();
115        self.0
116            .iter()
117            .find(|country| {
118                let Country { name, .. } = country;
119                name.eq_ignore_ascii_case(&trimmed)
120            })
121            .cloned()
122    }
123}
124impl From<Vec<Country>> for Countries {
125    fn from(value: Vec<Country>) -> Self {
126        Self(value)
127    }
128}
129impl From<&str> for Countries {
130    fn from(value: &str) -> Self {
131        parse(value)
132    }
133}
134impl From<String> for Countries {
135    fn from(value: String) -> Self {
136        parse(&value)
137    }
138}
139impl GeonamesParser for Constant {
140    fn country_codes(format: CodeFormat) -> Vec<String> {
141        let data = Self::country_data();
142        country_codes(data, format)
143    }
144    fn country_data() -> Countries {
145        let text = Self::from_asset("geonames.tsv").unwrap_or_default();
146        parse(&text)
147    }
148    fn languages(format: CodeFormat) -> Vec<String> {
149        let data = Self::country_data();
150        languages(data, format)
151    }
152}
153fn country_codes(data: Countries, format: CodeFormat) -> Vec<String> {
154    let mut codes = data
155        .0
156        .into_iter()
157        .map(|country: Country| {
158            let code = match format {
159                | CodeFormat::Alpha2 => country.iso,
160                | CodeFormat::Alpha3 => country.iso3,
161            };
162            code.trim().to_lowercase()
163        })
164        .filter(|value| !value.is_empty())
165        .collect::<Vec<String>>();
166    codes.sort();
167    codes.dedup();
168    codes
169}
170fn languages(data: Countries, format: CodeFormat) -> Vec<String> {
171    let mut languages = data
172        .0
173        .into_iter()
174        .flat_map(|country: Country| {
175            country
176                .languages
177                .into_iter()
178                .map(|value| value.trim().to_lowercase())
179                .filter(|value| !value.is_empty())
180                .collect::<Vec<String>>()
181        })
182        .filter(|value| match format {
183            | CodeFormat::Alpha2 => value.len() == 2,
184            | CodeFormat::Alpha3 => value.contains('-'),
185        })
186        .collect::<Vec<String>>();
187    languages.sort();
188    languages.dedup();
189    languages
190}
191fn parse(data: &str) -> Countries {
192    fn parse_optional_string_field(value: &str) -> Option<String> {
193        let trimmed = value.trim();
194        if trimmed.is_empty() {
195            None
196        } else {
197            Some(trimmed.to_string())
198        }
199    }
200    fn parse_u64_field(value: &str) -> Option<u64> {
201        let trimmed = value.trim();
202        if trimmed.is_empty() {
203            None
204        } else {
205            trimmed.parse::<u64>().ok()
206        }
207    }
208    fn list_field(value: &str) -> Vec<String> {
209        value
210            .split(',')
211            .map(|item| item.trim())
212            .filter(|item| !item.is_empty())
213            .map(|item| item.to_string())
214            .collect()
215    }
216    fn string_field(columns: &[&str], index: usize) -> String {
217        columns.get(index).map(|s| s.trim().to_string()).unwrap_or_default()
218    }
219    data.lines()
220        .filter(|line| {
221            let line = line.trim();
222            !line.is_empty() && !line.starts_with('#')
223        })
224        .filter(|line| !line.starts_with("ISO\t"))
225        .map(|line| {
226            let columns: Vec<&str> = line.split('\t').collect();
227            Country {
228                iso: string_field(&columns, 0),
229                iso3: string_field(&columns, 1),
230                iso_numeric: string_field(&columns, 2),
231                fips: string_field(&columns, 3),
232                name: string_field(&columns, 4),
233                capital: string_field(&columns, 5),
234                area: columns.get(6).and_then(|s| parse_u64_field(s)),
235                population: columns.get(7).and_then(|s| parse_u64_field(s)),
236                continent: string_field(&columns, 8),
237                top_level_domain: string_field(&columns, 9),
238                currency_code: string_field(&columns, 10),
239                currency_name: string_field(&columns, 11),
240                country_code: string_field(&columns, 12),
241                postal_code_format: string_field(&columns, 13),
242                postal_code_regex: string_field(&columns, 14),
243                languages: columns.get(15).map(|s| list_field(s)).unwrap_or_default(),
244                identifier: columns.get(16).and_then(|s| parse_u64_field(s)),
245                neighbours: columns.get(17).map(|s| list_field(s)).unwrap_or_default(),
246                equivalent_fips_code: columns.get(18).and_then(|s| parse_optional_string_field(s)),
247            }
248        })
249        .collect::<Vec<Country>>()
250        .into()
251}