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/// Flat pool of bank names (used for vendor-bank assignment and
158/// banking-customer name generation). Unstructured because the current
159/// generator picks uniformly without industry/region keys — see
160/// `vendor_generator.rs::BANK_NAMES`.
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct BankNameTemplates {
163    /// Bank names; picked uniformly at random.
164    #[serde(default)]
165    pub names: Vec<String>,
166}
167
168/// Audit finding title templates keyed by finding type.
169///
170/// Each entry is a (title, account-context) pair so the finding generator
171/// can pick a coherent title + account binding. Replaces the inline
172/// `const TITLES: &[(title, account)]` match arms in
173/// `audit/finding_generator.rs::generate_finding_title`.
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct FindingTitleTemplates {
176    /// Titles organised by finding-type key (e.g. "material_weakness",
177    /// "significant_deficiency", "control_deficiency", "material_misstatement",
178    /// "immaterial_misstatement", "compliance_exception", "it_deficiency",
179    /// "other_matter", "process_improvement").
180    #[serde(default)]
181    pub by_type: HashMap<String, Vec<FindingTitleEntry>>,
182}
183
184/// A single finding-title entry.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct FindingTitleEntry {
187    /// The finding title shown in workpapers.
188    pub title: String,
189    /// Account/area affected — paired with the title so the generator
190    /// doesn't accidentally tag a journal-entry finding to an unrelated
191    /// account like "Property, Plant & Equipment".
192    pub account: String,
193}
194
195/// Audit finding narrative templates keyed by finding type and section.
196///
197/// Each narrative has five sections (condition / criteria / cause /
198/// effect / recommendation). Templates may contain `{placeholder}`
199/// tokens that the generator substitutes at runtime (e.g. `{account}`,
200/// `{amount}`, `{period}`). Replaces the inline format!() macros in
201/// `audit/finding_generator.rs::generate_ccce` and
202/// `generate_recommendation`.
203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204pub struct FindingNarrativeTemplates {
205    /// Nested: by_type → by_section → list of candidate templates.
206    /// Section keys: "condition", "criteria", "cause", "effect", "recommendation".
207    #[serde(default)]
208    pub by_type: HashMap<String, HashMap<String, Vec<String>>>,
209}
210
211/// Department name templates keyed by department code.
212///
213/// Replaces the hardcoded strings in
214/// `master_data/employee_generator.rs::DepartmentDefinition::*`.
215/// Department codes: "finance", "procurement", "sales", "warehouse",
216/// "it".
217#[derive(Debug, Clone, Serialize, Deserialize, Default)]
218pub struct DepartmentNameTemplates {
219    /// Display name per department code.
220    #[serde(default)]
221    pub by_code: HashMap<String, String>,
222}
223
224/// Complete template data structure loaded from files.
225#[derive(Debug, Clone, Serialize, Deserialize, Default)]
226pub struct TemplateData {
227    /// Metadata about the template
228    #[serde(default)]
229    pub metadata: TemplateMetadata,
230    /// Person name templates
231    #[serde(default)]
232    pub person_names: PersonNameTemplates,
233    /// Vendor name templates
234    #[serde(default)]
235    pub vendor_names: VendorNameTemplates,
236    /// Customer name templates
237    #[serde(default)]
238    pub customer_names: CustomerNameTemplates,
239    /// Material description templates
240    #[serde(default)]
241    pub material_descriptions: MaterialDescriptionTemplates,
242    /// Asset description templates
243    #[serde(default)]
244    pub asset_descriptions: AssetDescriptionTemplates,
245    /// Line item description templates
246    #[serde(default)]
247    pub line_item_descriptions: LineItemDescriptionTemplates,
248    /// Header text templates
249    #[serde(default)]
250    pub header_text_templates: HeaderTextTemplates,
251    /// Bank name pool (v3.2.0+)
252    #[serde(default)]
253    pub bank_names: BankNameTemplates,
254    /// Audit finding titles by finding-type (v3.2.0+)
255    #[serde(default)]
256    pub finding_titles: FindingTitleTemplates,
257    /// Audit finding narratives by finding-type and section (v3.2.0+)
258    #[serde(default)]
259    pub finding_narratives: FindingNarrativeTemplates,
260    /// Department display names by code (v3.2.0+)
261    #[serde(default)]
262    pub department_names: DepartmentNameTemplates,
263}
264
265/// Strategy for merging template data.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum MergeStrategy {
269    /// Replace embedded templates entirely
270    Replace,
271    /// Extend embedded templates with file data
272    #[default]
273    Extend,
274    /// Merge, preferring file data for conflicts
275    MergePreferFile,
276}
277
278/// Template loader for reading and validating template files.
279pub struct TemplateLoader;
280
281impl TemplateLoader {
282    /// Load template data from a YAML file.
283    pub fn load_from_yaml(path: &Path) -> Result<TemplateData, TemplateError> {
284        let contents = std::fs::read_to_string(path).map_err(|e| {
285            TemplateError::new(format!("Failed to read file: {e}"))
286                .with_path(path.display().to_string())
287        })?;
288
289        serde_yaml::from_str(&contents).map_err(|e| {
290            TemplateError::new(format!("Failed to parse YAML: {e}"))
291                .with_path(path.display().to_string())
292        })
293    }
294
295    /// Load template data from an in-memory YAML string.  v4.0.0+.
296    ///
297    /// Intended for callers that embed template YAML at compile time
298    /// via `include_str!("…")`, which is the primary migration step
299    /// toward v4.1's planned YAML-as-source-of-truth for the default
300    /// name pools.
301    pub fn load_from_yaml_str(yaml: &str) -> Result<TemplateData, TemplateError> {
302        serde_yaml::from_str(yaml)
303            .map_err(|e| TemplateError::new(format!("Failed to parse YAML: {e}")))
304    }
305
306    /// Load template data from a JSON file.
307    pub fn load_from_json(path: &Path) -> Result<TemplateData, TemplateError> {
308        let contents = std::fs::read_to_string(path).map_err(|e| {
309            TemplateError::new(format!("Failed to read file: {e}"))
310                .with_path(path.display().to_string())
311        })?;
312
313        serde_json::from_str(&contents).map_err(|e| {
314            TemplateError::new(format!("Failed to parse JSON: {e}"))
315                .with_path(path.display().to_string())
316        })
317    }
318
319    /// Load template data from a file (auto-detect format by extension).
320    pub fn load_from_file(path: &Path) -> Result<TemplateData, TemplateError> {
321        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
322
323        match extension.to_lowercase().as_str() {
324            "yaml" | "yml" => Self::load_from_yaml(path),
325            "json" => Self::load_from_json(path),
326            _ => Err(TemplateError::new(format!(
327                "Unsupported file extension: {extension}. Use .yaml, .yml, or .json"
328            ))
329            .with_path(path.display().to_string())),
330        }
331    }
332
333    /// Save template data to a YAML file. v3.5.0+ — used by
334    /// `datasynth-data templates enrich` to persist LLM-enriched pools.
335    ///
336    /// Creates the parent directory if it doesn't exist.
337    pub fn save_to_yaml(data: &TemplateData, path: &Path) -> Result<(), TemplateError> {
338        if let Some(parent) = path.parent() {
339            if !parent.as_os_str().is_empty() && !parent.exists() {
340                std::fs::create_dir_all(parent).map_err(|e| {
341                    TemplateError::new(format!("Failed to create parent directory: {e}"))
342                        .with_path(parent.display().to_string())
343                })?;
344            }
345        }
346
347        let yaml = serde_yaml::to_string(data).map_err(|e| {
348            TemplateError::new(format!("Failed to serialize YAML: {e}"))
349                .with_path(path.display().to_string())
350        })?;
351
352        std::fs::write(path, yaml).map_err(|e| {
353            TemplateError::new(format!("Failed to write file: {e}"))
354                .with_path(path.display().to_string())
355        })
356    }
357
358    /// Save template data to a file (auto-detect format by extension).
359    /// v3.5.0+.
360    pub fn save_to_file(data: &TemplateData, path: &Path) -> Result<(), TemplateError> {
361        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
362        match extension.to_lowercase().as_str() {
363            "yaml" | "yml" => Self::save_to_yaml(data, path),
364            "json" => {
365                let json = serde_json::to_string_pretty(data).map_err(|e| {
366                    TemplateError::new(format!("Failed to serialize JSON: {e}"))
367                        .with_path(path.display().to_string())
368                })?;
369                if let Some(parent) = path.parent() {
370                    if !parent.as_os_str().is_empty() && !parent.exists() {
371                        std::fs::create_dir_all(parent).map_err(|e| {
372                            TemplateError::new(format!("Failed to create parent directory: {e}"))
373                                .with_path(parent.display().to_string())
374                        })?;
375                    }
376                }
377                std::fs::write(path, json).map_err(|e| {
378                    TemplateError::new(format!("Failed to write file: {e}"))
379                        .with_path(path.display().to_string())
380                })
381            }
382            _ => Err(TemplateError::new(format!(
383                "Unsupported file extension: {extension}. Use .yaml, .yml, or .json"
384            ))
385            .with_path(path.display().to_string())),
386        }
387    }
388
389    /// Load all template files from a directory.
390    pub fn load_from_directory(dir: &Path) -> Result<TemplateData, TemplateError> {
391        if !dir.is_dir() {
392            return Err(
393                TemplateError::new("Path is not a directory").with_path(dir.display().to_string())
394            );
395        }
396
397        let mut merged = TemplateData::default();
398
399        let entries = std::fs::read_dir(dir).map_err(|e| {
400            TemplateError::new(format!("Failed to read directory: {e}"))
401                .with_path(dir.display().to_string())
402        })?;
403
404        for entry in entries {
405            let entry =
406                entry.map_err(|e| TemplateError::new(format!("Failed to read entry: {e}")))?;
407            let path = entry.path();
408
409            if path.is_file() {
410                let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
411
412                if matches!(extension.to_lowercase().as_str(), "yaml" | "yml" | "json") {
413                    match Self::load_from_file(&path) {
414                        Ok(data) => {
415                            merged = Self::merge(merged, data, MergeStrategy::Extend);
416                        }
417                        Err(e) => {
418                            // Log but continue with other files
419                            eprintln!(
420                                "Warning: Failed to load template file {}: {}",
421                                path.display(),
422                                e
423                            );
424                        }
425                    }
426                }
427            }
428        }
429
430        Ok(merged)
431    }
432
433    /// Validate template data.
434    pub fn validate(data: &TemplateData) -> Vec<String> {
435        let mut errors = Vec::new();
436
437        // Check metadata
438        if data.metadata.name.is_empty() {
439            errors.push("Metadata: name is required".to_string());
440        }
441
442        // Check for empty template sections (warnings, not errors)
443        if data.person_names.cultures.is_empty() {
444            // This is OK - will fall back to embedded templates
445        }
446
447        // Validate culture names have required fields
448        for (culture, names) in &data.person_names.cultures {
449            if names.male_first_names.is_empty() && names.female_first_names.is_empty() {
450                errors.push(format!("Culture '{culture}': no first names defined"));
451            }
452            if names.last_names.is_empty() {
453                errors.push(format!("Culture '{culture}': no last names defined"));
454            }
455        }
456
457        errors
458    }
459
460    /// Merge two template data sets according to the specified strategy.
461    pub fn merge(
462        base: TemplateData,
463        overlay: TemplateData,
464        strategy: MergeStrategy,
465    ) -> TemplateData {
466        match strategy {
467            MergeStrategy::Replace => overlay,
468            MergeStrategy::Extend => Self::merge_extend(base, overlay),
469            MergeStrategy::MergePreferFile => Self::merge_prefer_overlay(base, overlay),
470        }
471    }
472
473    fn merge_extend(mut base: TemplateData, overlay: TemplateData) -> TemplateData {
474        // Extend cultures
475        for (culture, names) in overlay.person_names.cultures {
476            base.person_names
477                .cultures
478                .entry(culture)
479                .or_default()
480                .extend_from(&names);
481        }
482
483        // Extend vendor categories
484        for (category, names) in overlay.vendor_names.categories {
485            base.vendor_names
486                .categories
487                .entry(category)
488                .or_default()
489                .extend(names);
490        }
491
492        // Extend customer industries
493        for (industry, names) in overlay.customer_names.industries {
494            base.customer_names
495                .industries
496                .entry(industry)
497                .or_default()
498                .extend(names);
499        }
500
501        // Extend material descriptions
502        for (mat_type, descs) in overlay.material_descriptions.by_type {
503            base.material_descriptions
504                .by_type
505                .entry(mat_type)
506                .or_default()
507                .extend(descs);
508        }
509
510        // Extend asset descriptions
511        for (category, descs) in overlay.asset_descriptions.by_category {
512            base.asset_descriptions
513                .by_category
514                .entry(category)
515                .or_default()
516                .extend(descs);
517        }
518
519        // Extend line item descriptions
520        for (account_type, descs) in overlay.line_item_descriptions.p2p {
521            base.line_item_descriptions
522                .p2p
523                .entry(account_type)
524                .or_default()
525                .extend(descs);
526        }
527        for (account_type, descs) in overlay.line_item_descriptions.o2c {
528            base.line_item_descriptions
529                .o2c
530                .entry(account_type)
531                .or_default()
532                .extend(descs);
533        }
534
535        // Extend header templates
536        for (process, templates) in overlay.header_text_templates.by_process {
537            base.header_text_templates
538                .by_process
539                .entry(process)
540                .or_default()
541                .extend(templates);
542        }
543
544        // Extend h2r / r2r line descriptions (previously missing from merge)
545        for (account_type, descs) in overlay.line_item_descriptions.h2r {
546            base.line_item_descriptions
547                .h2r
548                .entry(account_type)
549                .or_default()
550                .extend(descs);
551        }
552        for (account_type, descs) in overlay.line_item_descriptions.r2r {
553            base.line_item_descriptions
554                .r2r
555                .entry(account_type)
556                .or_default()
557                .extend(descs);
558        }
559
560        // Extend bank names (flat pool).
561        base.bank_names.names.extend(overlay.bank_names.names);
562
563        // Extend finding titles by type.
564        for (ft, entries) in overlay.finding_titles.by_type {
565            base.finding_titles
566                .by_type
567                .entry(ft)
568                .or_default()
569                .extend(entries);
570        }
571
572        // Extend finding narratives by (type, section).
573        for (ft, sections) in overlay.finding_narratives.by_type {
574            let base_sections = base.finding_narratives.by_type.entry(ft).or_default();
575            for (section, templates) in sections {
576                base_sections.entry(section).or_default().extend(templates);
577            }
578        }
579
580        // Department names: keyed by code — overlay wins per code, since
581        // a department has one display name (not a pool).
582        for (code, name) in overlay.department_names.by_code {
583            base.department_names.by_code.insert(code, name);
584        }
585
586        base
587    }
588
589    fn merge_prefer_overlay(mut base: TemplateData, overlay: TemplateData) -> TemplateData {
590        // Use overlay metadata if present
591        if !overlay.metadata.name.is_empty() && overlay.metadata.name != "Default Templates" {
592            base.metadata = overlay.metadata;
593        }
594
595        // For prefer overlay, we replace entire categories if present in overlay
596        for (culture, names) in overlay.person_names.cultures {
597            base.person_names.cultures.insert(culture, names);
598        }
599
600        for (category, names) in overlay.vendor_names.categories {
601            if !names.is_empty() {
602                base.vendor_names.categories.insert(category, names);
603            }
604        }
605
606        for (industry, names) in overlay.customer_names.industries {
607            if !names.is_empty() {
608                base.customer_names.industries.insert(industry, names);
609            }
610        }
611
612        // Material descriptions — replace per material-type key
613        for (mat_type, descs) in overlay.material_descriptions.by_type {
614            if !descs.is_empty() {
615                base.material_descriptions.by_type.insert(mat_type, descs);
616            }
617        }
618
619        // Asset descriptions — replace per category key
620        for (category, descs) in overlay.asset_descriptions.by_category {
621            if !descs.is_empty() {
622                base.asset_descriptions.by_category.insert(category, descs);
623            }
624        }
625
626        // Header templates — replace per process key
627        for (process, templates) in overlay.header_text_templates.by_process {
628            if !templates.is_empty() {
629                base.header_text_templates
630                    .by_process
631                    .insert(process, templates);
632            }
633        }
634
635        // Bank names — if overlay has any, replace entirely
636        if !overlay.bank_names.names.is_empty() {
637            base.bank_names = overlay.bank_names;
638        }
639
640        // Finding titles — replace per finding type
641        for (ft, entries) in overlay.finding_titles.by_type {
642            if !entries.is_empty() {
643                base.finding_titles.by_type.insert(ft, entries);
644            }
645        }
646
647        // Finding narratives — replace per finding type (whole sections map)
648        for (ft, sections) in overlay.finding_narratives.by_type {
649            if !sections.is_empty() {
650                base.finding_narratives.by_type.insert(ft, sections);
651            }
652        }
653
654        // Department names — replace per code
655        for (code, name) in overlay.department_names.by_code {
656            base.department_names.by_code.insert(code, name);
657        }
658
659        base
660    }
661}
662
663impl CultureNames {
664    fn extend_from(&mut self, other: &CultureNames) {
665        self.male_first_names
666            .extend(other.male_first_names.iter().cloned());
667        self.female_first_names
668            .extend(other.female_first_names.iter().cloned());
669        self.last_names.extend(other.last_names.iter().cloned());
670    }
671}
672
673#[cfg(test)]
674#[allow(clippy::unwrap_used)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn test_default_template_data() {
680        let data = TemplateData::default();
681        assert_eq!(data.metadata.version, "1.0");
682        assert!(data.person_names.cultures.is_empty());
683    }
684
685    #[test]
686    fn test_validate_empty_culture() {
687        let mut data = TemplateData::default();
688        data.person_names.cultures.insert(
689            "test".to_string(),
690            CultureNames {
691                male_first_names: vec![],
692                female_first_names: vec![],
693                last_names: vec![],
694            },
695        );
696
697        let errors = TemplateLoader::validate(&data);
698        assert!(!errors.is_empty());
699    }
700
701    #[test]
702    fn test_merge_extend() {
703        let mut base = TemplateData::default();
704        base.vendor_names
705            .categories
706            .insert("services".to_string(), vec!["Company A".to_string()]);
707
708        let mut overlay = TemplateData::default();
709        overlay
710            .vendor_names
711            .categories
712            .insert("services".to_string(), vec!["Company B".to_string()]);
713
714        let merged = TemplateLoader::merge(base, overlay, MergeStrategy::Extend);
715        let services = merged.vendor_names.categories.get("services").unwrap();
716        assert_eq!(services.len(), 2);
717        assert!(services.contains(&"Company A".to_string()));
718        assert!(services.contains(&"Company B".to_string()));
719    }
720
721    #[test]
722    fn test_merge_replace() {
723        let mut base = TemplateData::default();
724        base.vendor_names
725            .categories
726            .insert("services".to_string(), vec!["Company A".to_string()]);
727
728        let mut overlay = TemplateData::default();
729        overlay
730            .vendor_names
731            .categories
732            .insert("manufacturing".to_string(), vec!["Company B".to_string()]);
733
734        let merged = TemplateLoader::merge(base, overlay, MergeStrategy::Replace);
735        assert!(!merged.vendor_names.categories.contains_key("services"));
736        assert!(merged.vendor_names.categories.contains_key("manufacturing"));
737    }
738
739    #[test]
740    fn test_load_example_templates() {
741        // This test verifies all example templates can be loaded
742        let examples_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
743            .parent()
744            .unwrap()
745            .parent()
746            .unwrap()
747            .join("examples")
748            .join("templates");
749
750        if !examples_dir.exists() {
751            // Skip if examples directory doesn't exist (e.g., in CI)
752            return;
753        }
754
755        let template_files = [
756            "german_manufacturing.yaml",
757            "japanese_technology.yaml",
758            "british_financial_services.yaml",
759            "brazilian_retail.yaml",
760            "indian_healthcare.yaml",
761        ];
762
763        for file in &template_files {
764            let path = examples_dir.join(file);
765            if path.exists() {
766                let result = TemplateLoader::load_from_file(&path);
767                assert!(
768                    result.is_ok(),
769                    "Failed to load {}: {:?}",
770                    file,
771                    result.err()
772                );
773
774                let data = result.unwrap();
775                assert!(
776                    !data.metadata.name.is_empty(),
777                    "{}: metadata.name is empty",
778                    file
779                );
780                assert!(
781                    data.metadata.region.is_some(),
782                    "{}: metadata.region is missing",
783                    file
784                );
785                assert!(
786                    data.metadata.sector.is_some(),
787                    "{}: metadata.sector is missing",
788                    file
789                );
790
791                // Validate the template
792                let errors = TemplateLoader::validate(&data);
793                assert!(
794                    errors.is_empty(),
795                    "{}: validation errors: {:?}",
796                    file,
797                    errors
798                );
799            }
800        }
801    }
802
803    #[test]
804    fn test_load_example_templates_directory() {
805        let examples_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
806            .parent()
807            .unwrap()
808            .parent()
809            .unwrap()
810            .join("examples")
811            .join("templates");
812
813        if !examples_dir.exists() {
814            return;
815        }
816
817        let result = TemplateLoader::load_from_directory(&examples_dir);
818        assert!(
819            result.is_ok(),
820            "Failed to load directory: {:?}",
821            result.err()
822        );
823
824        let merged = result.unwrap();
825
826        // Should have multiple cultures merged
827        assert!(
828            merged.person_names.cultures.len() >= 4,
829            "Expected at least 4 cultures, got {}",
830            merged.person_names.cultures.len()
831        );
832
833        // Check specific cultures exist
834        assert!(
835            merged.person_names.cultures.contains_key("german"),
836            "Missing german culture"
837        );
838        assert!(
839            merged.person_names.cultures.contains_key("japanese"),
840            "Missing japanese culture"
841        );
842        assert!(
843            merged.person_names.cultures.contains_key("british"),
844            "Missing british culture"
845        );
846        assert!(
847            merged.person_names.cultures.contains_key("brazilian"),
848            "Missing brazilian culture"
849        );
850        assert!(
851            merged.person_names.cultures.contains_key("indian"),
852            "Missing indian culture"
853        );
854    }
855}