Skip to main content

datasynth_core/templates/
loader.rs

1//! Template loader for external template files.
2//!
3//! This module provides functionality to load template data from YAML/JSON files,
4//! supporting regional and sector-specific customization.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Error type for template loading operations.
11#[derive(Debug, Clone)]
12pub struct TemplateError {
13    pub message: String,
14    pub path: Option<String>,
15}
16
17impl std::fmt::Display for TemplateError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        if let Some(ref path) = self.path {
20            write!(f, "{}: {}", path, self.message)
21        } else {
22            write!(f, "{}", self.message)
23        }
24    }
25}
26
27impl std::error::Error for TemplateError {}
28
29impl TemplateError {
30    pub fn new(message: impl Into<String>) -> Self {
31        Self {
32            message: message.into(),
33            path: None,
34        }
35    }
36
37    pub fn with_path(mut self, path: impl Into<String>) -> Self {
38        self.path = Some(path.into());
39        self
40    }
41}
42
43/// Metadata about a template file.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TemplateMetadata {
46    /// Template name
47    pub name: String,
48    /// Version string
49    #[serde(default = "default_version")]
50    pub version: String,
51    /// Region/locale (e.g., "de", "us", "gb")
52    pub region: Option<String>,
53    /// Industry sector (e.g., "manufacturing", "retail")
54    pub sector: Option<String>,
55    /// Template author
56    pub author: Option<String>,
57    /// Description
58    pub description: Option<String>,
59}
60
61fn default_version() -> String {
62    "1.0".to_string()
63}
64
65impl Default for TemplateMetadata {
66    fn default() -> Self {
67        Self {
68            name: "Default Templates".to_string(),
69            version: default_version(),
70            region: None,
71            sector: None,
72            author: None,
73            description: None,
74        }
75    }
76}
77
78/// Person name templates by culture.
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80pub struct PersonNameTemplates {
81    /// Names organized by culture
82    #[serde(default)]
83    pub cultures: HashMap<String, CultureNames>,
84}
85
86/// Names for a specific culture.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct CultureNames {
89    /// Male first names
90    #[serde(default)]
91    pub male_first_names: Vec<String>,
92    /// Female first names
93    #[serde(default)]
94    pub female_first_names: Vec<String>,
95    /// Last names / family names
96    #[serde(default)]
97    pub last_names: Vec<String>,
98}
99
100/// Vendor name templates by category.
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct VendorNameTemplates {
103    /// Vendor names by category (e.g., "manufacturing", "services")
104    #[serde(default)]
105    pub categories: HashMap<String, Vec<String>>,
106}
107
108/// Customer name templates by industry.
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct CustomerNameTemplates {
111    /// Customer names by industry (e.g., "automotive", "retail")
112    #[serde(default)]
113    pub industries: HashMap<String, Vec<String>>,
114}
115
116/// Material description templates.
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct MaterialDescriptionTemplates {
119    /// Descriptions by material type
120    #[serde(default)]
121    pub by_type: HashMap<String, Vec<String>>,
122}
123
124/// Asset description templates.
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct AssetDescriptionTemplates {
127    /// Descriptions by asset category
128    #[serde(default)]
129    pub by_category: HashMap<String, Vec<String>>,
130}
131
132/// Line item description templates by business process.
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
134pub struct LineItemDescriptionTemplates {
135    /// P2P line descriptions
136    #[serde(default)]
137    pub p2p: HashMap<String, Vec<String>>,
138    /// O2C line descriptions
139    #[serde(default)]
140    pub o2c: HashMap<String, Vec<String>>,
141    /// H2R line descriptions
142    #[serde(default)]
143    pub h2r: HashMap<String, Vec<String>>,
144    /// R2R line descriptions
145    #[serde(default)]
146    pub r2r: HashMap<String, Vec<String>>,
147}
148
149/// Header text templates by business process.
150#[derive(Debug, Clone, Serialize, Deserialize, Default)]
151pub struct HeaderTextTemplates {
152    /// Templates organized by business process
153    #[serde(default)]
154    pub by_process: HashMap<String, Vec<String>>,
155}
156
157/// Complete template data structure loaded from files.
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
159pub struct TemplateData {
160    /// Metadata about the template
161    #[serde(default)]
162    pub metadata: TemplateMetadata,
163    /// Person name templates
164    #[serde(default)]
165    pub person_names: PersonNameTemplates,
166    /// Vendor name templates
167    #[serde(default)]
168    pub vendor_names: VendorNameTemplates,
169    /// Customer name templates
170    #[serde(default)]
171    pub customer_names: CustomerNameTemplates,
172    /// Material description templates
173    #[serde(default)]
174    pub material_descriptions: MaterialDescriptionTemplates,
175    /// Asset description templates
176    #[serde(default)]
177    pub asset_descriptions: AssetDescriptionTemplates,
178    /// Line item description templates
179    #[serde(default)]
180    pub line_item_descriptions: LineItemDescriptionTemplates,
181    /// Header text templates
182    #[serde(default)]
183    pub header_text_templates: HeaderTextTemplates,
184}
185
186/// Strategy for merging template data.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
188#[serde(rename_all = "snake_case")]
189pub enum MergeStrategy {
190    /// Replace embedded templates entirely
191    Replace,
192    /// Extend embedded templates with file data
193    #[default]
194    Extend,
195    /// Merge, preferring file data for conflicts
196    MergePreferFile,
197}
198
199/// Template loader for reading and validating template files.
200pub struct TemplateLoader;
201
202impl TemplateLoader {
203    /// Load template data from a YAML file.
204    pub fn load_from_yaml(path: &Path) -> Result<TemplateData, TemplateError> {
205        let contents = std::fs::read_to_string(path).map_err(|e| {
206            TemplateError::new(format!("Failed to read file: {e}"))
207                .with_path(path.display().to_string())
208        })?;
209
210        serde_yaml::from_str(&contents).map_err(|e| {
211            TemplateError::new(format!("Failed to parse YAML: {e}"))
212                .with_path(path.display().to_string())
213        })
214    }
215
216    /// Load template data from a JSON file.
217    pub fn load_from_json(path: &Path) -> Result<TemplateData, TemplateError> {
218        let contents = std::fs::read_to_string(path).map_err(|e| {
219            TemplateError::new(format!("Failed to read file: {e}"))
220                .with_path(path.display().to_string())
221        })?;
222
223        serde_json::from_str(&contents).map_err(|e| {
224            TemplateError::new(format!("Failed to parse JSON: {e}"))
225                .with_path(path.display().to_string())
226        })
227    }
228
229    /// Load template data from a file (auto-detect format by extension).
230    pub fn load_from_file(path: &Path) -> Result<TemplateData, TemplateError> {
231        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
232
233        match extension.to_lowercase().as_str() {
234            "yaml" | "yml" => Self::load_from_yaml(path),
235            "json" => Self::load_from_json(path),
236            _ => Err(TemplateError::new(format!(
237                "Unsupported file extension: {extension}. Use .yaml, .yml, or .json"
238            ))
239            .with_path(path.display().to_string())),
240        }
241    }
242
243    /// Load all template files from a directory.
244    pub fn load_from_directory(dir: &Path) -> Result<TemplateData, TemplateError> {
245        if !dir.is_dir() {
246            return Err(
247                TemplateError::new("Path is not a directory").with_path(dir.display().to_string())
248            );
249        }
250
251        let mut merged = TemplateData::default();
252
253        let entries = std::fs::read_dir(dir).map_err(|e| {
254            TemplateError::new(format!("Failed to read directory: {e}"))
255                .with_path(dir.display().to_string())
256        })?;
257
258        for entry in entries {
259            let entry =
260                entry.map_err(|e| TemplateError::new(format!("Failed to read entry: {e}")))?;
261            let path = entry.path();
262
263            if path.is_file() {
264                let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
265
266                if matches!(extension.to_lowercase().as_str(), "yaml" | "yml" | "json") {
267                    match Self::load_from_file(&path) {
268                        Ok(data) => {
269                            merged = Self::merge(merged, data, MergeStrategy::Extend);
270                        }
271                        Err(e) => {
272                            // Log but continue with other files
273                            eprintln!(
274                                "Warning: Failed to load template file {}: {}",
275                                path.display(),
276                                e
277                            );
278                        }
279                    }
280                }
281            }
282        }
283
284        Ok(merged)
285    }
286
287    /// Validate template data.
288    pub fn validate(data: &TemplateData) -> Vec<String> {
289        let mut errors = Vec::new();
290
291        // Check metadata
292        if data.metadata.name.is_empty() {
293            errors.push("Metadata: name is required".to_string());
294        }
295
296        // Check for empty template sections (warnings, not errors)
297        if data.person_names.cultures.is_empty() {
298            // This is OK - will fall back to embedded templates
299        }
300
301        // Validate culture names have required fields
302        for (culture, names) in &data.person_names.cultures {
303            if names.male_first_names.is_empty() && names.female_first_names.is_empty() {
304                errors.push(format!("Culture '{culture}': no first names defined"));
305            }
306            if names.last_names.is_empty() {
307                errors.push(format!("Culture '{culture}': no last names defined"));
308            }
309        }
310
311        errors
312    }
313
314    /// Merge two template data sets according to the specified strategy.
315    pub fn merge(
316        base: TemplateData,
317        overlay: TemplateData,
318        strategy: MergeStrategy,
319    ) -> TemplateData {
320        match strategy {
321            MergeStrategy::Replace => overlay,
322            MergeStrategy::Extend => Self::merge_extend(base, overlay),
323            MergeStrategy::MergePreferFile => Self::merge_prefer_overlay(base, overlay),
324        }
325    }
326
327    fn merge_extend(mut base: TemplateData, overlay: TemplateData) -> TemplateData {
328        // Extend cultures
329        for (culture, names) in overlay.person_names.cultures {
330            base.person_names
331                .cultures
332                .entry(culture)
333                .or_default()
334                .extend_from(&names);
335        }
336
337        // Extend vendor categories
338        for (category, names) in overlay.vendor_names.categories {
339            base.vendor_names
340                .categories
341                .entry(category)
342                .or_default()
343                .extend(names);
344        }
345
346        // Extend customer industries
347        for (industry, names) in overlay.customer_names.industries {
348            base.customer_names
349                .industries
350                .entry(industry)
351                .or_default()
352                .extend(names);
353        }
354
355        // Extend material descriptions
356        for (mat_type, descs) in overlay.material_descriptions.by_type {
357            base.material_descriptions
358                .by_type
359                .entry(mat_type)
360                .or_default()
361                .extend(descs);
362        }
363
364        // Extend asset descriptions
365        for (category, descs) in overlay.asset_descriptions.by_category {
366            base.asset_descriptions
367                .by_category
368                .entry(category)
369                .or_default()
370                .extend(descs);
371        }
372
373        // Extend line item descriptions
374        for (account_type, descs) in overlay.line_item_descriptions.p2p {
375            base.line_item_descriptions
376                .p2p
377                .entry(account_type)
378                .or_default()
379                .extend(descs);
380        }
381        for (account_type, descs) in overlay.line_item_descriptions.o2c {
382            base.line_item_descriptions
383                .o2c
384                .entry(account_type)
385                .or_default()
386                .extend(descs);
387        }
388
389        // Extend header templates
390        for (process, templates) in overlay.header_text_templates.by_process {
391            base.header_text_templates
392                .by_process
393                .entry(process)
394                .or_default()
395                .extend(templates);
396        }
397
398        base
399    }
400
401    fn merge_prefer_overlay(mut base: TemplateData, overlay: TemplateData) -> TemplateData {
402        // Use overlay metadata if present
403        if !overlay.metadata.name.is_empty() && overlay.metadata.name != "Default Templates" {
404            base.metadata = overlay.metadata;
405        }
406
407        // For prefer overlay, we replace entire categories if present in overlay
408        for (culture, names) in overlay.person_names.cultures {
409            base.person_names.cultures.insert(culture, names);
410        }
411
412        for (category, names) in overlay.vendor_names.categories {
413            if !names.is_empty() {
414                base.vendor_names.categories.insert(category, names);
415            }
416        }
417
418        for (industry, names) in overlay.customer_names.industries {
419            if !names.is_empty() {
420                base.customer_names.industries.insert(industry, names);
421            }
422        }
423
424        base
425    }
426}
427
428impl CultureNames {
429    fn extend_from(&mut self, other: &CultureNames) {
430        self.male_first_names
431            .extend(other.male_first_names.iter().cloned());
432        self.female_first_names
433            .extend(other.female_first_names.iter().cloned());
434        self.last_names.extend(other.last_names.iter().cloned());
435    }
436}
437
438#[cfg(test)]
439#[allow(clippy::unwrap_used)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_default_template_data() {
445        let data = TemplateData::default();
446        assert_eq!(data.metadata.version, "1.0");
447        assert!(data.person_names.cultures.is_empty());
448    }
449
450    #[test]
451    fn test_validate_empty_culture() {
452        let mut data = TemplateData::default();
453        data.person_names.cultures.insert(
454            "test".to_string(),
455            CultureNames {
456                male_first_names: vec![],
457                female_first_names: vec![],
458                last_names: vec![],
459            },
460        );
461
462        let errors = TemplateLoader::validate(&data);
463        assert!(!errors.is_empty());
464    }
465
466    #[test]
467    fn test_merge_extend() {
468        let mut base = TemplateData::default();
469        base.vendor_names
470            .categories
471            .insert("services".to_string(), vec!["Company A".to_string()]);
472
473        let mut overlay = TemplateData::default();
474        overlay
475            .vendor_names
476            .categories
477            .insert("services".to_string(), vec!["Company B".to_string()]);
478
479        let merged = TemplateLoader::merge(base, overlay, MergeStrategy::Extend);
480        let services = merged.vendor_names.categories.get("services").unwrap();
481        assert_eq!(services.len(), 2);
482        assert!(services.contains(&"Company A".to_string()));
483        assert!(services.contains(&"Company B".to_string()));
484    }
485
486    #[test]
487    fn test_merge_replace() {
488        let mut base = TemplateData::default();
489        base.vendor_names
490            .categories
491            .insert("services".to_string(), vec!["Company A".to_string()]);
492
493        let mut overlay = TemplateData::default();
494        overlay
495            .vendor_names
496            .categories
497            .insert("manufacturing".to_string(), vec!["Company B".to_string()]);
498
499        let merged = TemplateLoader::merge(base, overlay, MergeStrategy::Replace);
500        assert!(!merged.vendor_names.categories.contains_key("services"));
501        assert!(merged.vendor_names.categories.contains_key("manufacturing"));
502    }
503
504    #[test]
505    fn test_load_example_templates() {
506        // This test verifies all example templates can be loaded
507        let examples_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
508            .parent()
509            .unwrap()
510            .parent()
511            .unwrap()
512            .join("examples")
513            .join("templates");
514
515        if !examples_dir.exists() {
516            // Skip if examples directory doesn't exist (e.g., in CI)
517            return;
518        }
519
520        let template_files = [
521            "german_manufacturing.yaml",
522            "japanese_technology.yaml",
523            "british_financial_services.yaml",
524            "brazilian_retail.yaml",
525            "indian_healthcare.yaml",
526        ];
527
528        for file in &template_files {
529            let path = examples_dir.join(file);
530            if path.exists() {
531                let result = TemplateLoader::load_from_file(&path);
532                assert!(
533                    result.is_ok(),
534                    "Failed to load {}: {:?}",
535                    file,
536                    result.err()
537                );
538
539                let data = result.unwrap();
540                assert!(
541                    !data.metadata.name.is_empty(),
542                    "{}: metadata.name is empty",
543                    file
544                );
545                assert!(
546                    data.metadata.region.is_some(),
547                    "{}: metadata.region is missing",
548                    file
549                );
550                assert!(
551                    data.metadata.sector.is_some(),
552                    "{}: metadata.sector is missing",
553                    file
554                );
555
556                // Validate the template
557                let errors = TemplateLoader::validate(&data);
558                assert!(
559                    errors.is_empty(),
560                    "{}: validation errors: {:?}",
561                    file,
562                    errors
563                );
564            }
565        }
566    }
567
568    #[test]
569    fn test_load_example_templates_directory() {
570        let examples_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
571            .parent()
572            .unwrap()
573            .parent()
574            .unwrap()
575            .join("examples")
576            .join("templates");
577
578        if !examples_dir.exists() {
579            return;
580        }
581
582        let result = TemplateLoader::load_from_directory(&examples_dir);
583        assert!(
584            result.is_ok(),
585            "Failed to load directory: {:?}",
586            result.err()
587        );
588
589        let merged = result.unwrap();
590
591        // Should have multiple cultures merged
592        assert!(
593            merged.person_names.cultures.len() >= 4,
594            "Expected at least 4 cultures, got {}",
595            merged.person_names.cultures.len()
596        );
597
598        // Check specific cultures exist
599        assert!(
600            merged.person_names.cultures.contains_key("german"),
601            "Missing german culture"
602        );
603        assert!(
604            merged.person_names.cultures.contains_key("japanese"),
605            "Missing japanese culture"
606        );
607        assert!(
608            merged.person_names.cultures.contains_key("british"),
609            "Missing british culture"
610        );
611        assert!(
612            merged.person_names.cultures.contains_key("brazilian"),
613            "Missing brazilian culture"
614        );
615        assert!(
616            merged.person_names.cultures.contains_key("indian"),
617            "Missing indian culture"
618        );
619    }
620}