1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10#[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#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TemplateMetadata {
46 pub name: String,
48 #[serde(default = "default_version")]
50 pub version: String,
51 pub region: Option<String>,
53 pub sector: Option<String>,
55 pub author: Option<String>,
57 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80pub struct PersonNameTemplates {
81 #[serde(default)]
83 pub cultures: HashMap<String, CultureNames>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct CultureNames {
89 #[serde(default)]
91 pub male_first_names: Vec<String>,
92 #[serde(default)]
94 pub female_first_names: Vec<String>,
95 #[serde(default)]
97 pub last_names: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct VendorNameTemplates {
103 #[serde(default)]
105 pub categories: HashMap<String, Vec<String>>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct CustomerNameTemplates {
111 #[serde(default)]
113 pub industries: HashMap<String, Vec<String>>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct MaterialDescriptionTemplates {
119 #[serde(default)]
121 pub by_type: HashMap<String, Vec<String>>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct AssetDescriptionTemplates {
127 #[serde(default)]
129 pub by_category: HashMap<String, Vec<String>>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, Default)]
134pub struct LineItemDescriptionTemplates {
135 #[serde(default)]
137 pub p2p: HashMap<String, Vec<String>>,
138 #[serde(default)]
140 pub o2c: HashMap<String, Vec<String>>,
141 #[serde(default)]
143 pub h2r: HashMap<String, Vec<String>>,
144 #[serde(default)]
146 pub r2r: HashMap<String, Vec<String>>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default)]
151pub struct HeaderTextTemplates {
152 #[serde(default)]
154 pub by_process: HashMap<String, Vec<String>>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct BankNameTemplates {
163 #[serde(default)]
165 pub names: Vec<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct FindingTitleTemplates {
176 #[serde(default)]
181 pub by_type: HashMap<String, Vec<FindingTitleEntry>>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct FindingTitleEntry {
187 pub title: String,
189 pub account: String,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204pub struct FindingNarrativeTemplates {
205 #[serde(default)]
208 pub by_type: HashMap<String, HashMap<String, Vec<String>>>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, Default)]
218pub struct DepartmentNameTemplates {
219 #[serde(default)]
221 pub by_code: HashMap<String, String>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, Default)]
226pub struct TemplateData {
227 #[serde(default)]
229 pub metadata: TemplateMetadata,
230 #[serde(default)]
232 pub person_names: PersonNameTemplates,
233 #[serde(default)]
235 pub vendor_names: VendorNameTemplates,
236 #[serde(default)]
238 pub customer_names: CustomerNameTemplates,
239 #[serde(default)]
241 pub material_descriptions: MaterialDescriptionTemplates,
242 #[serde(default)]
244 pub asset_descriptions: AssetDescriptionTemplates,
245 #[serde(default)]
247 pub line_item_descriptions: LineItemDescriptionTemplates,
248 #[serde(default)]
250 pub header_text_templates: HeaderTextTemplates,
251 #[serde(default)]
253 pub bank_names: BankNameTemplates,
254 #[serde(default)]
256 pub finding_titles: FindingTitleTemplates,
257 #[serde(default)]
259 pub finding_narratives: FindingNarrativeTemplates,
260 #[serde(default)]
262 pub department_names: DepartmentNameTemplates,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum MergeStrategy {
269 Replace,
271 #[default]
273 Extend,
274 MergePreferFile,
276}
277
278pub struct TemplateLoader;
280
281impl TemplateLoader {
282 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 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 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 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 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 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 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 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 pub fn validate(data: &TemplateData) -> Vec<String> {
435 let mut errors = Vec::new();
436
437 if data.metadata.name.is_empty() {
439 errors.push("Metadata: name is required".to_string());
440 }
441
442 if data.person_names.cultures.is_empty() {
444 }
446
447 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 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 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 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 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 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 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 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 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 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 base.bank_names.names.extend(overlay.bank_names.names);
562
563 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 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 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 if !overlay.metadata.name.is_empty() && overlay.metadata.name != "Default Templates" {
592 base.metadata = overlay.metadata;
593 }
594
595 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 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 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 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 if !overlay.bank_names.names.is_empty() {
637 base.bank_names = overlay.bank_names;
638 }
639
640 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 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 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 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 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 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 assert!(
828 merged.person_names.cultures.len() >= 4,
829 "Expected at least 4 cultures, got {}",
830 merged.person_names.cultures.len()
831 );
832
833 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}