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)]
159pub struct TemplateData {
160 #[serde(default)]
162 pub metadata: TemplateMetadata,
163 #[serde(default)]
165 pub person_names: PersonNameTemplates,
166 #[serde(default)]
168 pub vendor_names: VendorNameTemplates,
169 #[serde(default)]
171 pub customer_names: CustomerNameTemplates,
172 #[serde(default)]
174 pub material_descriptions: MaterialDescriptionTemplates,
175 #[serde(default)]
177 pub asset_descriptions: AssetDescriptionTemplates,
178 #[serde(default)]
180 pub line_item_descriptions: LineItemDescriptionTemplates,
181 #[serde(default)]
183 pub header_text_templates: HeaderTextTemplates,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
188#[serde(rename_all = "snake_case")]
189pub enum MergeStrategy {
190 Replace,
192 #[default]
194 Extend,
195 MergePreferFile,
197}
198
199pub struct TemplateLoader;
201
202impl TemplateLoader {
203 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 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 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 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 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 pub fn validate(data: &TemplateData) -> Vec<String> {
290 let mut errors = Vec::new();
291
292 if data.metadata.name.is_empty() {
294 errors.push("Metadata: name is required".to_string());
295 }
296
297 if data.person_names.cultures.is_empty() {
299 }
301
302 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 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 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 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 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 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 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 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 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 if !overlay.metadata.name.is_empty() && overlay.metadata.name != "Default Templates" {
405 base.metadata = overlay.metadata;
406 }
407
408 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 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 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 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 assert!(
593 merged.person_names.cultures.len() >= 4,
594 "Expected at least 4 cultures, got {}",
595 merged.person_names.cultures.len()
596 );
597
598 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}