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: {}. Use .yaml, .yml, or .json",
238                extension
239            ))
240            .with_path(path.display().to_string())),
241        }
242    }
243
244    /// Load all template files from a directory.
245    pub fn load_from_directory(dir: &Path) -> Result<TemplateData, TemplateError> {
246        if !dir.is_dir() {
247            return Err(
248                TemplateError::new("Path is not a directory").with_path(dir.display().to_string())
249            );
250        }
251
252        let mut merged = TemplateData::default();
253
254        let entries = std::fs::read_dir(dir).map_err(|e| {
255            TemplateError::new(format!("Failed to read directory: {}", e))
256                .with_path(dir.display().to_string())
257        })?;
258
259        for entry in entries {
260            let entry =
261                entry.map_err(|e| TemplateError::new(format!("Failed to read entry: {}", e)))?;
262            let path = entry.path();
263
264            if path.is_file() {
265                let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
266
267                if matches!(extension.to_lowercase().as_str(), "yaml" | "yml" | "json") {
268                    match Self::load_from_file(&path) {
269                        Ok(data) => {
270                            merged = Self::merge(merged, data, MergeStrategy::Extend);
271                        }
272                        Err(e) => {
273                            // Log but continue with other files
274                            eprintln!(
275                                "Warning: Failed to load template file {}: {}",
276                                path.display(),
277                                e
278                            );
279                        }
280                    }
281                }
282            }
283        }
284
285        Ok(merged)
286    }
287
288    /// Validate template data.
289    pub fn validate(data: &TemplateData) -> Vec<String> {
290        let mut errors = Vec::new();
291
292        // Check metadata
293        if data.metadata.name.is_empty() {
294            errors.push("Metadata: name is required".to_string());
295        }
296
297        // Check for empty template sections (warnings, not errors)
298        if data.person_names.cultures.is_empty() {
299            // This is OK - will fall back to embedded templates
300        }
301
302        // Validate culture names have required fields
303        for (culture, names) in &data.person_names.cultures {
304            if names.male_first_names.is_empty() && names.female_first_names.is_empty() {
305                errors.push(format!("Culture '{}': no first names defined", culture));
306            }
307            if names.last_names.is_empty() {
308                errors.push(format!("Culture '{}': no last names defined", culture));
309            }
310        }
311
312        errors
313    }
314
315    /// Merge two template data sets according to the specified strategy.
316    pub fn merge(
317        base: TemplateData,
318        overlay: TemplateData,
319        strategy: MergeStrategy,
320    ) -> TemplateData {
321        match strategy {
322            MergeStrategy::Replace => overlay,
323            MergeStrategy::Extend => Self::merge_extend(base, overlay),
324            MergeStrategy::MergePreferFile => Self::merge_prefer_overlay(base, overlay),
325        }
326    }
327
328    fn merge_extend(mut base: TemplateData, overlay: TemplateData) -> TemplateData {
329        // Extend cultures
330        for (culture, names) in overlay.person_names.cultures {
331            base.person_names
332                .cultures
333                .entry(culture)
334                .or_default()
335                .extend_from(&names);
336        }
337
338        // Extend vendor categories
339        for (category, names) in overlay.vendor_names.categories {
340            base.vendor_names
341                .categories
342                .entry(category)
343                .or_default()
344                .extend(names);
345        }
346
347        // Extend customer industries
348        for (industry, names) in overlay.customer_names.industries {
349            base.customer_names
350                .industries
351                .entry(industry)
352                .or_default()
353                .extend(names);
354        }
355
356        // Extend material descriptions
357        for (mat_type, descs) in overlay.material_descriptions.by_type {
358            base.material_descriptions
359                .by_type
360                .entry(mat_type)
361                .or_default()
362                .extend(descs);
363        }
364
365        // Extend asset descriptions
366        for (category, descs) in overlay.asset_descriptions.by_category {
367            base.asset_descriptions
368                .by_category
369                .entry(category)
370                .or_default()
371                .extend(descs);
372        }
373
374        // Extend line item descriptions
375        for (account_type, descs) in overlay.line_item_descriptions.p2p {
376            base.line_item_descriptions
377                .p2p
378                .entry(account_type)
379                .or_default()
380                .extend(descs);
381        }
382        for (account_type, descs) in overlay.line_item_descriptions.o2c {
383            base.line_item_descriptions
384                .o2c
385                .entry(account_type)
386                .or_default()
387                .extend(descs);
388        }
389
390        // Extend header templates
391        for (process, templates) in overlay.header_text_templates.by_process {
392            base.header_text_templates
393                .by_process
394                .entry(process)
395                .or_default()
396                .extend(templates);
397        }
398
399        base
400    }
401
402    fn merge_prefer_overlay(mut base: TemplateData, overlay: TemplateData) -> TemplateData {
403        // Use overlay metadata if present
404        if !overlay.metadata.name.is_empty() && overlay.metadata.name != "Default Templates" {
405            base.metadata = overlay.metadata;
406        }
407
408        // For prefer overlay, we replace entire categories if present in overlay
409        for (culture, names) in overlay.person_names.cultures {
410            base.person_names.cultures.insert(culture, names);
411        }
412
413        for (category, names) in overlay.vendor_names.categories {
414            if !names.is_empty() {
415                base.vendor_names.categories.insert(category, names);
416            }
417        }
418
419        for (industry, names) in overlay.customer_names.industries {
420            if !names.is_empty() {
421                base.customer_names.industries.insert(industry, names);
422            }
423        }
424
425        base
426    }
427}
428
429impl CultureNames {
430    fn extend_from(&mut self, other: &CultureNames) {
431        self.male_first_names
432            .extend(other.male_first_names.iter().cloned());
433        self.female_first_names
434            .extend(other.female_first_names.iter().cloned());
435        self.last_names.extend(other.last_names.iter().cloned());
436    }
437}
438
439#[cfg(test)]
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}