Skip to main content

datasynth_core/country/
mod.rs

1//! Pluggable country-pack architecture.
2//!
3//! Loads country-specific configuration (holidays, names, tax rates, phone
4//! formats, address data, payroll rules, etc.) from JSON files.  Built-in
5//! packs are compiled in via `include_str!`; external packs can be loaded
6//! from a directory at runtime.
7
8pub mod easter;
9pub mod error;
10pub mod lunar;
11pub mod merge;
12pub mod schema;
13
14pub use error::CountryPackError;
15pub use schema::CountryPack;
16
17use std::collections::HashMap;
18use std::fmt;
19use std::path::Path;
20
21use merge::{apply_override, deep_merge};
22
23// ---------------------------------------------------------------------------
24// Embedded packs
25// ---------------------------------------------------------------------------
26
27const DEFAULT_PACK_JSON: &str = include_str!("../../country-packs/_default.json");
28const US_PACK_JSON: &str = include_str!("../../country-packs/US.json");
29const DE_PACK_JSON: &str = include_str!("../../country-packs/DE.json");
30const GB_PACK_JSON: &str = include_str!("../../country-packs/GB.json");
31const FR_PACK_JSON: &str = include_str!("../../country-packs/FR.json");
32const JP_PACK_JSON: &str = include_str!("../../country-packs/JP.json");
33const CN_PACK_JSON: &str = include_str!("../../country-packs/CN.json");
34const IN_PACK_JSON: &str = include_str!("../../country-packs/IN.json");
35const IT_PACK_JSON: &str = include_str!("../../country-packs/IT.json");
36const ES_PACK_JSON: &str = include_str!("../../country-packs/ES.json");
37const CA_PACK_JSON: &str = include_str!("../../country-packs/CA.json");
38
39// ---------------------------------------------------------------------------
40// CountryCode
41// ---------------------------------------------------------------------------
42
43/// Validated ISO 3166-1 alpha-2 country code (or `_DEFAULT`).
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct CountryCode(String);
46
47impl CountryCode {
48    /// Create a `CountryCode`, validating that it is exactly 2 uppercase ASCII
49    /// letters or the special `_DEFAULT` sentinel.
50    pub fn new(code: &str) -> Result<Self, CountryPackError> {
51        let code = code.trim().to_uppercase();
52        if code == "_DEFAULT" {
53            return Ok(Self(code));
54        }
55        if code.len() == 2 && code.chars().all(|c| c.is_ascii_uppercase()) {
56            Ok(Self(code))
57        } else {
58            Err(CountryPackError::InvalidCountryCode(code))
59        }
60    }
61
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65}
66
67impl fmt::Display for CountryCode {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(&self.0)
70    }
71}
72
73// ---------------------------------------------------------------------------
74// CountryPackRegistry
75// ---------------------------------------------------------------------------
76
77/// Central registry that owns all loaded country packs and provides merged
78/// access keyed by country code.
79pub struct CountryPackRegistry {
80    default_pack: CountryPack,
81    packs: HashMap<CountryCode, CountryPack>,
82}
83
84impl CountryPackRegistry {
85    /// Build a registry from embedded packs, optionally loading additional
86    /// packs from `external_dir` and applying per-country `overrides`.
87    pub fn new(
88        external_dir: Option<&Path>,
89        overrides: &HashMap<String, serde_json::Value>,
90    ) -> Result<Self, CountryPackError> {
91        let mut registry = Self::builtin_only()?;
92
93        // Load external packs if a directory is provided.
94        if let Some(dir) = external_dir {
95            registry.load_external_dir(dir)?;
96        }
97
98        // Apply per-country overrides.
99        for (code_str, value) in overrides {
100            let code = CountryCode::new(code_str)?;
101            if let Some(pack) = registry.packs.get_mut(&code) {
102                apply_override(pack, value)?;
103            } else {
104                // Create a new pack by merging default + override.
105                let mut pack = registry.default_pack.clone();
106                pack.country_code = code_str.to_uppercase();
107                apply_override(&mut pack, value)?;
108                registry.packs.insert(code, pack);
109            }
110        }
111
112        Ok(registry)
113    }
114
115    /// Build a registry from the embedded (built-in) packs only.
116    pub fn builtin_only() -> Result<Self, CountryPackError> {
117        let default_pack: CountryPack = serde_json::from_str(DEFAULT_PACK_JSON)
118            .map_err(|e| CountryPackError::parse(format!("_default.json: {e}")))?;
119
120        let mut packs = HashMap::new();
121
122        for (json, label) in [
123            (US_PACK_JSON, "US.json"),
124            (DE_PACK_JSON, "DE.json"),
125            (GB_PACK_JSON, "GB.json"),
126            (FR_PACK_JSON, "FR.json"),
127            (JP_PACK_JSON, "JP.json"),
128            (CN_PACK_JSON, "CN.json"),
129            (IN_PACK_JSON, "IN.json"),
130            (IT_PACK_JSON, "IT.json"),
131            (ES_PACK_JSON, "ES.json"),
132            (CA_PACK_JSON, "CA.json"),
133        ] {
134            let pack = Self::parse_and_merge(&default_pack, json, label)?;
135            let code = CountryCode::new(&pack.country_code)?;
136            packs.insert(code, pack);
137        }
138
139        Ok(Self {
140            default_pack,
141            packs,
142        })
143    }
144
145    /// Look up a pack by `CountryCode`. Falls back to the default pack for
146    /// unknown codes.
147    pub fn get(&self, code: &CountryCode) -> &CountryPack {
148        self.packs.get(code).unwrap_or(&self.default_pack)
149    }
150
151    /// Convenience: look up by a raw country-code string (case-insensitive).
152    /// Returns the default pack if the code is invalid or unknown.
153    pub fn get_by_str(&self, code: &str) -> &CountryPack {
154        match CountryCode::new(code) {
155            Ok(cc) => self.get(&cc),
156            Err(_) => &self.default_pack,
157        }
158    }
159
160    /// List all country codes that have explicit packs (excludes `_DEFAULT`).
161    pub fn available_countries(&self) -> Vec<&CountryCode> {
162        self.packs.keys().collect()
163    }
164
165    /// Access the default (fallback) pack.
166    pub fn default_pack(&self) -> &CountryPack {
167        &self.default_pack
168    }
169
170    // -- internal helpers ---------------------------------------------------
171
172    /// Parse a country JSON string and deep-merge it on top of the default.
173    fn parse_and_merge(
174        default: &CountryPack,
175        json: &str,
176        label: &str,
177    ) -> Result<CountryPack, CountryPackError> {
178        let country_value: serde_json::Value = serde_json::from_str(json)
179            .map_err(|e| CountryPackError::parse(format!("{label}: {e}")))?;
180
181        let mut base_value = serde_json::to_value(default)
182            .map_err(|e| CountryPackError::parse(format!("serialize default: {e}")))?;
183
184        deep_merge(&mut base_value, &country_value);
185
186        serde_json::from_value(base_value)
187            .map_err(|e| CountryPackError::parse(format!("{label} merge: {e}")))
188    }
189
190    /// Scan an external directory for `*.json` files and load each as a
191    /// country pack, merging on top of the default.
192    fn load_external_dir(&mut self, dir: &Path) -> Result<(), CountryPackError> {
193        let entries = std::fs::read_dir(dir)
194            .map_err(|e| CountryPackError::directory(format!("{}: {e}", dir.display())))?;
195
196        for entry in entries {
197            let entry = entry
198                .map_err(|e| CountryPackError::directory(format!("{}: {e}", dir.display())))?;
199            let path = entry.path();
200
201            if path.extension().and_then(|e| e.to_str()) != Some("json") {
202                continue;
203            }
204
205            // Skip _default.json in external dirs (only embedded default is used).
206            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
207                if stem == "_default" {
208                    continue;
209                }
210            }
211
212            let json = std::fs::read_to_string(&path)
213                .map_err(|e| CountryPackError::directory(format!("{}: {e}", path.display())))?;
214
215            let label = path
216                .file_name()
217                .and_then(|n| n.to_str())
218                .unwrap_or("unknown");
219
220            let pack = Self::parse_and_merge(&self.default_pack, &json, label)?;
221            let code = CountryCode::new(&pack.country_code)?;
222            self.packs.insert(code, pack);
223        }
224
225        Ok(())
226    }
227}
228
229impl fmt::Debug for CountryPackRegistry {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.debug_struct("CountryPackRegistry")
232            .field("default", &self.default_pack.country_code)
233            .field(
234                "countries",
235                &self
236                    .packs
237                    .keys()
238                    .map(CountryCode::as_str)
239                    .collect::<Vec<_>>(),
240            )
241            .finish()
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_country_code_valid() {
251        assert!(CountryCode::new("US").is_ok());
252        assert!(CountryCode::new("de").is_ok()); // lowercased → DE
253        assert!(CountryCode::new("GB").is_ok());
254        assert!(CountryCode::new("_DEFAULT").is_ok());
255    }
256
257    #[test]
258    fn test_country_code_invalid() {
259        assert!(CountryCode::new("").is_err());
260        assert!(CountryCode::new("USA").is_err());
261        assert!(CountryCode::new("1A").is_err());
262        assert!(CountryCode::new("A").is_err());
263    }
264
265    #[test]
266    fn test_builtin_only() {
267        let reg = CountryPackRegistry::builtin_only().expect("should load");
268        assert!(reg.available_countries().len() >= 3);
269
270        let us = reg.get_by_str("US");
271        assert_eq!(us.country_code, "US");
272        assert!(!us.holidays.fixed.is_empty());
273
274        let de = reg.get_by_str("DE");
275        assert_eq!(de.country_code, "DE");
276
277        let gb = reg.get_by_str("GB");
278        assert_eq!(gb.country_code, "GB");
279    }
280
281    #[test]
282    fn test_all_ten_packs_load() {
283        let reg = CountryPackRegistry::builtin_only().expect("should load");
284        assert_eq!(reg.available_countries().len(), 10);
285
286        for code in ["US", "DE", "GB", "FR", "JP", "CN", "IN", "IT", "ES", "CA"] {
287            let pack = reg.get_by_str(code);
288            assert_eq!(pack.country_code, code, "country_code mismatch for {code}");
289            assert!(
290                !pack.holidays.fixed.is_empty(),
291                "{code} has no fixed holidays"
292            );
293            assert!(
294                !pack.names.cultures.is_empty(),
295                "{code} has no name cultures"
296            );
297        }
298    }
299
300    #[test]
301    fn test_fr_pack_loads() {
302        let reg = CountryPackRegistry::builtin_only().expect("should load");
303        let fr = reg.get_by_str("FR");
304        assert_eq!(fr.country_code, "FR");
305        assert_eq!(fr.locale.default_currency, "EUR");
306        assert_eq!(fr.locale.default_timezone, "Europe/Paris");
307        assert!(fr.tax.vat.standard_rate > 0.19);
308    }
309
310    #[test]
311    fn test_jp_pack_loads() {
312        let reg = CountryPackRegistry::builtin_only().expect("should load");
313        let jp = reg.get_by_str("JP");
314        assert_eq!(jp.country_code, "JP");
315        assert_eq!(jp.locale.default_currency, "JPY");
316        assert_eq!(jp.locale.currency_decimal_places, 0);
317        assert_eq!(jp.locale.fiscal_year.start_month, 4);
318    }
319
320    #[test]
321    fn test_cn_pack_loads() {
322        let reg = CountryPackRegistry::builtin_only().expect("should load");
323        let cn = reg.get_by_str("CN");
324        assert_eq!(cn.country_code, "CN");
325        assert_eq!(cn.locale.default_currency, "CNY");
326        assert_eq!(cn.locale.default_timezone, "Asia/Shanghai");
327    }
328
329    #[test]
330    fn test_in_pack_loads() {
331        let reg = CountryPackRegistry::builtin_only().expect("should load");
332        let india = reg.get_by_str("IN");
333        assert_eq!(india.country_code, "IN");
334        assert_eq!(india.locale.default_currency, "INR");
335        assert_eq!(india.locale.number_format.grouping, vec![3, 2]);
336        assert_eq!(india.locale.fiscal_year.start_month, 4);
337    }
338
339    #[test]
340    fn test_it_pack_loads() {
341        let reg = CountryPackRegistry::builtin_only().expect("should load");
342        let it = reg.get_by_str("IT");
343        assert_eq!(it.country_code, "IT");
344        assert_eq!(it.locale.default_currency, "EUR");
345        assert!(it.payroll.thirteenth_month);
346    }
347
348    #[test]
349    fn test_es_pack_loads() {
350        let reg = CountryPackRegistry::builtin_only().expect("should load");
351        let es = reg.get_by_str("ES");
352        assert_eq!(es.country_code, "ES");
353        assert_eq!(es.locale.default_currency, "EUR");
354        assert_eq!(es.locale.default_timezone, "Europe/Madrid");
355    }
356
357    #[test]
358    fn test_ca_pack_loads() {
359        let reg = CountryPackRegistry::builtin_only().expect("should load");
360        let ca = reg.get_by_str("CA");
361        assert_eq!(ca.country_code, "CA");
362        assert_eq!(ca.locale.default_currency, "CAD");
363        assert!(!ca.tax.payroll_tax.income_tax_brackets.is_empty());
364    }
365
366    #[test]
367    fn test_fallback_to_default() {
368        let reg = CountryPackRegistry::builtin_only().expect("should load");
369        let unknown = reg.get_by_str("ZZ");
370        assert_eq!(unknown.country_code, "_DEFAULT");
371    }
372
373    #[test]
374    fn test_default_pack_parses() {
375        let pack: CountryPack =
376            serde_json::from_str(DEFAULT_PACK_JSON).expect("default pack should parse");
377        assert_eq!(pack.country_code, "_DEFAULT");
378        assert_eq!(pack.schema_version, "1.0");
379    }
380
381    #[test]
382    fn test_registry_with_overrides() {
383        let mut overrides = HashMap::new();
384        overrides.insert(
385            "US".to_string(),
386            serde_json::json!({"country_name": "USA Override"}),
387        );
388        let reg = CountryPackRegistry::new(None, &overrides).expect("should load");
389        let us = reg.get_by_str("US");
390        assert_eq!(us.country_name, "USA Override");
391    }
392}