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");
31
32// ---------------------------------------------------------------------------
33// CountryCode
34// ---------------------------------------------------------------------------
35
36/// Validated ISO 3166-1 alpha-2 country code (or `_DEFAULT`).
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct CountryCode(String);
39
40impl CountryCode {
41    /// Create a `CountryCode`, validating that it is exactly 2 uppercase ASCII
42    /// letters or the special `_DEFAULT` sentinel.
43    pub fn new(code: &str) -> Result<Self, CountryPackError> {
44        let code = code.trim().to_uppercase();
45        if code == "_DEFAULT" {
46            return Ok(Self(code));
47        }
48        if code.len() == 2 && code.chars().all(|c| c.is_ascii_uppercase()) {
49            Ok(Self(code))
50        } else {
51            Err(CountryPackError::InvalidCountryCode(code))
52        }
53    }
54
55    pub fn as_str(&self) -> &str {
56        &self.0
57    }
58}
59
60impl fmt::Display for CountryCode {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(&self.0)
63    }
64}
65
66// ---------------------------------------------------------------------------
67// CountryPackRegistry
68// ---------------------------------------------------------------------------
69
70/// Central registry that owns all loaded country packs and provides merged
71/// access keyed by country code.
72pub struct CountryPackRegistry {
73    default_pack: CountryPack,
74    packs: HashMap<CountryCode, CountryPack>,
75}
76
77impl CountryPackRegistry {
78    /// Build a registry from embedded packs, optionally loading additional
79    /// packs from `external_dir` and applying per-country `overrides`.
80    pub fn new(
81        external_dir: Option<&Path>,
82        overrides: &HashMap<String, serde_json::Value>,
83    ) -> Result<Self, CountryPackError> {
84        let mut registry = Self::builtin_only()?;
85
86        // Load external packs if a directory is provided.
87        if let Some(dir) = external_dir {
88            registry.load_external_dir(dir)?;
89        }
90
91        // Apply per-country overrides.
92        for (code_str, value) in overrides {
93            let code = CountryCode::new(code_str)?;
94            if let Some(pack) = registry.packs.get_mut(&code) {
95                apply_override(pack, value)?;
96            } else {
97                // Create a new pack by merging default + override.
98                let mut pack = registry.default_pack.clone();
99                pack.country_code = code_str.to_uppercase();
100                apply_override(&mut pack, value)?;
101                registry.packs.insert(code, pack);
102            }
103        }
104
105        Ok(registry)
106    }
107
108    /// Build a registry from the embedded (built-in) packs only.
109    pub fn builtin_only() -> Result<Self, CountryPackError> {
110        let default_pack: CountryPack = serde_json::from_str(DEFAULT_PACK_JSON)
111            .map_err(|e| CountryPackError::parse(format!("_default.json: {e}")))?;
112
113        let mut packs = HashMap::new();
114
115        for (json, label) in [
116            (US_PACK_JSON, "US.json"),
117            (DE_PACK_JSON, "DE.json"),
118            (GB_PACK_JSON, "GB.json"),
119        ] {
120            let pack = Self::parse_and_merge(&default_pack, json, label)?;
121            let code = CountryCode::new(&pack.country_code)?;
122            packs.insert(code, pack);
123        }
124
125        Ok(Self {
126            default_pack,
127            packs,
128        })
129    }
130
131    /// Look up a pack by `CountryCode`. Falls back to the default pack for
132    /// unknown codes.
133    pub fn get(&self, code: &CountryCode) -> &CountryPack {
134        self.packs.get(code).unwrap_or(&self.default_pack)
135    }
136
137    /// Convenience: look up by a raw country-code string (case-insensitive).
138    /// Returns the default pack if the code is invalid or unknown.
139    pub fn get_by_str(&self, code: &str) -> &CountryPack {
140        match CountryCode::new(code) {
141            Ok(cc) => self.get(&cc),
142            Err(_) => &self.default_pack,
143        }
144    }
145
146    /// List all country codes that have explicit packs (excludes `_DEFAULT`).
147    pub fn available_countries(&self) -> Vec<&CountryCode> {
148        self.packs.keys().collect()
149    }
150
151    /// Access the default (fallback) pack.
152    pub fn default_pack(&self) -> &CountryPack {
153        &self.default_pack
154    }
155
156    // -- internal helpers ---------------------------------------------------
157
158    /// Parse a country JSON string and deep-merge it on top of the default.
159    fn parse_and_merge(
160        default: &CountryPack,
161        json: &str,
162        label: &str,
163    ) -> Result<CountryPack, CountryPackError> {
164        let country_value: serde_json::Value = serde_json::from_str(json)
165            .map_err(|e| CountryPackError::parse(format!("{label}: {e}")))?;
166
167        let mut base_value = serde_json::to_value(default)
168            .map_err(|e| CountryPackError::parse(format!("serialize default: {e}")))?;
169
170        deep_merge(&mut base_value, &country_value);
171
172        serde_json::from_value(base_value)
173            .map_err(|e| CountryPackError::parse(format!("{label} merge: {e}")))
174    }
175
176    /// Scan an external directory for `*.json` files and load each as a
177    /// country pack, merging on top of the default.
178    fn load_external_dir(&mut self, dir: &Path) -> Result<(), CountryPackError> {
179        let entries = std::fs::read_dir(dir)
180            .map_err(|e| CountryPackError::directory(format!("{}: {e}", dir.display())))?;
181
182        for entry in entries {
183            let entry = entry
184                .map_err(|e| CountryPackError::directory(format!("{}: {e}", dir.display())))?;
185            let path = entry.path();
186
187            if path.extension().and_then(|e| e.to_str()) != Some("json") {
188                continue;
189            }
190
191            // Skip _default.json in external dirs (only embedded default is used).
192            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
193                if stem == "_default" {
194                    continue;
195                }
196            }
197
198            let json = std::fs::read_to_string(&path)
199                .map_err(|e| CountryPackError::directory(format!("{}: {e}", path.display())))?;
200
201            let label = path
202                .file_name()
203                .and_then(|n| n.to_str())
204                .unwrap_or("unknown");
205
206            let pack = Self::parse_and_merge(&self.default_pack, &json, label)?;
207            let code = CountryCode::new(&pack.country_code)?;
208            self.packs.insert(code, pack);
209        }
210
211        Ok(())
212    }
213}
214
215impl fmt::Debug for CountryPackRegistry {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.debug_struct("CountryPackRegistry")
218            .field("default", &self.default_pack.country_code)
219            .field(
220                "countries",
221                &self.packs.keys().map(|c| c.as_str()).collect::<Vec<_>>(),
222            )
223            .finish()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_country_code_valid() {
233        assert!(CountryCode::new("US").is_ok());
234        assert!(CountryCode::new("de").is_ok()); // lowercased → DE
235        assert!(CountryCode::new("GB").is_ok());
236        assert!(CountryCode::new("_DEFAULT").is_ok());
237    }
238
239    #[test]
240    fn test_country_code_invalid() {
241        assert!(CountryCode::new("").is_err());
242        assert!(CountryCode::new("USA").is_err());
243        assert!(CountryCode::new("1A").is_err());
244        assert!(CountryCode::new("A").is_err());
245    }
246
247    #[test]
248    fn test_builtin_only() {
249        let reg = CountryPackRegistry::builtin_only().expect("should load");
250        assert!(reg.available_countries().len() >= 3);
251
252        let us = reg.get_by_str("US");
253        assert_eq!(us.country_code, "US");
254        assert!(!us.holidays.fixed.is_empty());
255
256        let de = reg.get_by_str("DE");
257        assert_eq!(de.country_code, "DE");
258
259        let gb = reg.get_by_str("GB");
260        assert_eq!(gb.country_code, "GB");
261    }
262
263    #[test]
264    fn test_fallback_to_default() {
265        let reg = CountryPackRegistry::builtin_only().expect("should load");
266        let unknown = reg.get_by_str("ZZ");
267        assert_eq!(unknown.country_code, "_DEFAULT");
268    }
269
270    #[test]
271    fn test_default_pack_parses() {
272        let pack: CountryPack =
273            serde_json::from_str(DEFAULT_PACK_JSON).expect("default pack should parse");
274        assert_eq!(pack.country_code, "_DEFAULT");
275        assert_eq!(pack.schema_version, "1.0");
276    }
277
278    #[test]
279    fn test_registry_with_overrides() {
280        let mut overrides = HashMap::new();
281        overrides.insert(
282            "US".to_string(),
283            serde_json::json!({"country_name": "USA Override"}),
284        );
285        let reg = CountryPackRegistry::new(None, &overrides).expect("should load");
286        let us = reg.get_by_str("US");
287        assert_eq!(us.country_name, "USA Override");
288    }
289}