Skip to main content

datasynth_core/templates/
provider.rs

1//! Template provider trait and implementations.
2//!
3//! This module defines the `TemplateProvider` trait for accessing template data,
4//! along with implementations that combine embedded and file-based templates.
5
6use rand::seq::IndexedRandom;
7use rand::RngCore;
8use std::sync::Arc;
9
10use super::loader::{MergeStrategy, TemplateData, TemplateLoader};
11use super::names::NameCulture;
12use crate::models::BusinessProcess;
13
14/// Trait for providing template data to generators.
15///
16/// This trait abstracts the source of template data, allowing generators
17/// to work with either embedded templates, file-based templates, or a
18/// combination of both.
19///
20/// Methods use `&mut dyn RngCore` to allow the trait to be dyn-compatible.
21pub trait TemplateProvider: Send + Sync {
22    /// Get a random person first name for the given culture and gender.
23    fn get_person_first_name(
24        &self,
25        culture: NameCulture,
26        is_male: bool,
27        rng: &mut dyn RngCore,
28    ) -> String;
29
30    /// Get a random person last name for the given culture.
31    fn get_person_last_name(&self, culture: NameCulture, rng: &mut dyn RngCore) -> String;
32
33    /// Get a random vendor name for the given category.
34    fn get_vendor_name(&self, category: &str, rng: &mut dyn RngCore) -> String;
35
36    /// Get a random customer name for the given industry.
37    fn get_customer_name(&self, industry: &str, rng: &mut dyn RngCore) -> String;
38
39    /// Get a random material description for the given type.
40    fn get_material_description(&self, material_type: &str, rng: &mut dyn RngCore) -> String;
41
42    /// Get a random asset description for the given category.
43    fn get_asset_description(&self, category: &str, rng: &mut dyn RngCore) -> String;
44
45    /// Get a random line text for the given process and account type.
46    fn get_line_text(
47        &self,
48        process: BusinessProcess,
49        account_type: &str,
50        rng: &mut dyn RngCore,
51    ) -> String;
52
53    /// Get a random header text template for the given process.
54    fn get_header_template(&self, process: BusinessProcess, rng: &mut dyn RngCore) -> String;
55}
56
57/// Default template provider using embedded templates with optional file overrides.
58pub struct DefaultTemplateProvider {
59    /// Loaded template data (file-based)
60    template_data: Option<TemplateData>,
61    /// Merge strategy for combining embedded and file templates
62    merge_strategy: MergeStrategy,
63}
64
65impl DefaultTemplateProvider {
66    /// Create a new provider with embedded templates only.
67    pub fn new() -> Self {
68        Self {
69            template_data: None,
70            merge_strategy: MergeStrategy::Extend,
71        }
72    }
73
74    /// Create a provider with file-based templates.
75    pub fn with_templates(template_data: TemplateData, strategy: MergeStrategy) -> Self {
76        Self {
77            template_data: Some(template_data),
78            merge_strategy: strategy,
79        }
80    }
81
82    /// Load templates from a file path.
83    pub fn from_file(path: &std::path::Path) -> Result<Self, super::loader::TemplateError> {
84        let data = TemplateLoader::load_from_file(path)?;
85        Ok(Self::with_templates(data, MergeStrategy::Extend))
86    }
87
88    /// Load templates from a directory.
89    pub fn from_directory(path: &std::path::Path) -> Result<Self, super::loader::TemplateError> {
90        let data = TemplateLoader::load_from_directory(path)?;
91        Ok(Self::with_templates(data, MergeStrategy::Extend))
92    }
93
94    /// Set the merge strategy.
95    pub fn with_merge_strategy(mut self, strategy: MergeStrategy) -> Self {
96        self.merge_strategy = strategy;
97        self
98    }
99
100    /// Get embedded German first names (sample).
101    fn embedded_german_first_names_male() -> Vec<&'static str> {
102        vec![
103            "Hans", "Klaus", "Wolfgang", "Dieter", "Michael", "Stefan", "Thomas", "Andreas",
104            "Peter", "Jürgen", "Matthias", "Frank", "Martin", "Bernd",
105        ]
106    }
107
108    fn embedded_german_first_names_female() -> Vec<&'static str> {
109        vec![
110            "Anna",
111            "Maria",
112            "Elisabeth",
113            "Ursula",
114            "Monika",
115            "Petra",
116            "Karin",
117            "Sabine",
118            "Andrea",
119            "Christine",
120            "Gabriele",
121            "Heike",
122            "Birgit",
123        ]
124    }
125
126    fn embedded_german_last_names() -> Vec<&'static str> {
127        vec![
128            "Müller",
129            "Schmidt",
130            "Schneider",
131            "Fischer",
132            "Weber",
133            "Meyer",
134            "Wagner",
135            "Becker",
136            "Schulz",
137            "Hoffmann",
138            "Schäfer",
139            "Koch",
140            "Bauer",
141            "Richter",
142        ]
143    }
144
145    fn embedded_us_first_names_male() -> Vec<&'static str> {
146        vec![
147            "James",
148            "John",
149            "Robert",
150            "Michael",
151            "William",
152            "David",
153            "Richard",
154            "Joseph",
155            "Thomas",
156            "Charles",
157            "Christopher",
158            "Daniel",
159            "Matthew",
160        ]
161    }
162
163    fn embedded_us_first_names_female() -> Vec<&'static str> {
164        vec![
165            "Mary",
166            "Patricia",
167            "Jennifer",
168            "Linda",
169            "Barbara",
170            "Elizabeth",
171            "Susan",
172            "Jessica",
173            "Sarah",
174            "Karen",
175            "Lisa",
176            "Nancy",
177            "Betty",
178            "Margaret",
179        ]
180    }
181
182    fn embedded_us_last_names() -> Vec<&'static str> {
183        vec![
184            "Smith",
185            "Johnson",
186            "Williams",
187            "Brown",
188            "Jones",
189            "Garcia",
190            "Miller",
191            "Davis",
192            "Rodriguez",
193            "Martinez",
194            "Hernandez",
195            "Lopez",
196            "Gonzalez",
197        ]
198    }
199
200    fn embedded_vendor_names_manufacturing() -> Vec<&'static str> {
201        vec![
202            "Precision Parts Inc.",
203            "Industrial Components LLC",
204            "Advanced Materials Corp.",
205            "Steel Solutions GmbH",
206            "Quality Fasteners Ltd.",
207            "Machining Excellence Inc.",
208        ]
209    }
210
211    fn embedded_vendor_names_services() -> Vec<&'static str> {
212        vec![
213            "Consulting Partners LLP",
214            "Technical Services Inc.",
215            "Professional Solutions LLC",
216            "Business Advisory Group",
217            "Strategic Consulting Co.",
218            "Expert Services Ltd.",
219        ]
220    }
221
222    fn embedded_customer_names_automotive() -> Vec<&'static str> {
223        vec![
224            "AutoWerke Industries",
225            "Vehicle Tech Solutions",
226            "Motor Parts Direct",
227            "Automotive Excellence Corp.",
228            "Drive Systems Inc.",
229            "Engine Components Ltd.",
230        ]
231    }
232
233    fn embedded_customer_names_retail() -> Vec<&'static str> {
234        vec![
235            "Retail Solutions Corp.",
236            "Consumer Goods Direct",
237            "Shop Smart Inc.",
238            "Merchandise Holdings LLC",
239            "Retail Distribution Co.",
240            "Store Systems Ltd.",
241        ]
242    }
243
244    fn culture_to_key(culture: NameCulture) -> &'static str {
245        match culture {
246            NameCulture::WesternUs => "us",
247            NameCulture::German => "german",
248            NameCulture::Hispanic => "hispanic",
249            NameCulture::French => "french",
250            NameCulture::Chinese => "chinese",
251            NameCulture::Japanese => "japanese",
252            NameCulture::Indian => "indian",
253        }
254    }
255
256    fn process_to_key(process: BusinessProcess) -> &'static str {
257        match process {
258            BusinessProcess::P2P => "p2p",
259            BusinessProcess::O2C => "o2c",
260            BusinessProcess::H2R => "h2r",
261            BusinessProcess::R2R => "r2r",
262            _ => "other",
263        }
264    }
265}
266
267impl Default for DefaultTemplateProvider {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273impl TemplateProvider for DefaultTemplateProvider {
274    fn get_person_first_name(
275        &self,
276        culture: NameCulture,
277        is_male: bool,
278        rng: &mut dyn RngCore,
279    ) -> String {
280        let key = Self::culture_to_key(culture);
281
282        // Try file templates first
283        if let Some(ref data) = self.template_data {
284            if let Some(culture_names) = data.person_names.cultures.get(key) {
285                let names = if is_male {
286                    &culture_names.male_first_names
287                } else {
288                    &culture_names.female_first_names
289                };
290                if !names.is_empty() {
291                    if let Some(name) = names.choose(rng) {
292                        return name.clone();
293                    }
294                }
295            }
296        }
297
298        // Fall back to embedded templates
299        let embedded = match culture {
300            NameCulture::German => {
301                if is_male {
302                    Self::embedded_german_first_names_male()
303                } else {
304                    Self::embedded_german_first_names_female()
305                }
306            }
307            _ => {
308                if is_male {
309                    Self::embedded_us_first_names_male()
310                } else {
311                    Self::embedded_us_first_names_female()
312                }
313            }
314        };
315
316        embedded.choose(rng).unwrap_or(&"Unknown").to_string()
317    }
318
319    fn get_person_last_name(&self, culture: NameCulture, rng: &mut dyn RngCore) -> String {
320        let key = Self::culture_to_key(culture);
321
322        // Try file templates first
323        if let Some(ref data) = self.template_data {
324            if let Some(culture_names) = data.person_names.cultures.get(key) {
325                if !culture_names.last_names.is_empty() {
326                    if let Some(name) = culture_names.last_names.choose(rng) {
327                        return name.clone();
328                    }
329                }
330            }
331        }
332
333        // Fall back to embedded templates
334        let embedded = match culture {
335            NameCulture::German => Self::embedded_german_last_names(),
336            _ => Self::embedded_us_last_names(),
337        };
338
339        embedded.choose(rng).unwrap_or(&"Unknown").to_string()
340    }
341
342    fn get_vendor_name(&self, category: &str, rng: &mut dyn RngCore) -> String {
343        // Try file templates first
344        if let Some(ref data) = self.template_data {
345            if let Some(names) = data.vendor_names.categories.get(category) {
346                if !names.is_empty() {
347                    if let Some(name) = names.choose(rng) {
348                        return name.clone();
349                    }
350                }
351            }
352        }
353
354        // Fall back to embedded templates
355        let embedded = match category {
356            "manufacturing" => Self::embedded_vendor_names_manufacturing(),
357            "services" => Self::embedded_vendor_names_services(),
358            _ => {
359                tracing::debug!(
360                    "Unknown vendor name category '{}', falling back to manufacturing",
361                    category
362                );
363                Self::embedded_vendor_names_manufacturing()
364            }
365        };
366
367        embedded
368            .choose(rng)
369            .unwrap_or(&"Unknown Vendor")
370            .to_string()
371    }
372
373    fn get_customer_name(&self, industry: &str, rng: &mut dyn RngCore) -> String {
374        // Try file templates first
375        if let Some(ref data) = self.template_data {
376            if let Some(names) = data.customer_names.industries.get(industry) {
377                if !names.is_empty() {
378                    if let Some(name) = names.choose(rng) {
379                        return name.clone();
380                    }
381                }
382            }
383        }
384
385        // Fall back to embedded templates
386        let embedded = match industry {
387            "automotive" => Self::embedded_customer_names_automotive(),
388            "retail" => Self::embedded_customer_names_retail(),
389            _ => {
390                tracing::debug!(
391                    "Unknown customer name industry '{}', falling back to retail",
392                    industry
393                );
394                Self::embedded_customer_names_retail()
395            }
396        };
397
398        embedded
399            .choose(rng)
400            .unwrap_or(&"Unknown Customer")
401            .to_string()
402    }
403
404    fn get_material_description(&self, material_type: &str, rng: &mut dyn RngCore) -> String {
405        // Try file templates first
406        if let Some(ref data) = self.template_data {
407            if let Some(descs) = data.material_descriptions.by_type.get(material_type) {
408                if !descs.is_empty() {
409                    if let Some(desc) = descs.choose(rng) {
410                        return desc.clone();
411                    }
412                }
413            }
414        }
415
416        // Fall back to generic
417        format!("{} material", material_type)
418    }
419
420    fn get_asset_description(&self, category: &str, rng: &mut dyn RngCore) -> String {
421        // Try file templates first
422        if let Some(ref data) = self.template_data {
423            if let Some(descs) = data.asset_descriptions.by_category.get(category) {
424                if !descs.is_empty() {
425                    if let Some(desc) = descs.choose(rng) {
426                        return desc.clone();
427                    }
428                }
429            }
430        }
431
432        // Fall back to generic
433        format!("{} asset", category)
434    }
435
436    fn get_line_text(
437        &self,
438        process: BusinessProcess,
439        account_type: &str,
440        rng: &mut dyn RngCore,
441    ) -> String {
442        let key = Self::process_to_key(process);
443
444        // Try file templates first
445        if let Some(ref data) = self.template_data {
446            let descs_map = match process {
447                BusinessProcess::P2P => &data.line_item_descriptions.p2p,
448                BusinessProcess::O2C => &data.line_item_descriptions.o2c,
449                BusinessProcess::H2R => &data.line_item_descriptions.h2r,
450                BusinessProcess::R2R => &data.line_item_descriptions.r2r,
451                _ => &data.line_item_descriptions.p2p,
452            };
453
454            if let Some(descs) = descs_map.get(account_type) {
455                if !descs.is_empty() {
456                    if let Some(desc) = descs.choose(rng) {
457                        return desc.clone();
458                    }
459                }
460            }
461        }
462
463        // Fall back to generic
464        format!("{} posting", key.to_uppercase())
465    }
466
467    fn get_header_template(&self, process: BusinessProcess, rng: &mut dyn RngCore) -> String {
468        let key = Self::process_to_key(process);
469
470        // Try file templates first
471        if let Some(ref data) = self.template_data {
472            if let Some(templates) = data.header_text_templates.by_process.get(key) {
473                if !templates.is_empty() {
474                    if let Some(template) = templates.choose(rng) {
475                        return template.clone();
476                    }
477                }
478            }
479        }
480
481        // Fall back to generic
482        format!("{} Transaction", key.to_uppercase())
483    }
484}
485
486/// A thread-safe wrapper around a template provider.
487pub type SharedTemplateProvider = Arc<dyn TemplateProvider>;
488
489/// Create a default shared template provider.
490pub fn default_provider() -> SharedTemplateProvider {
491    Arc::new(DefaultTemplateProvider::new())
492}
493
494/// Create a shared template provider from a file.
495pub fn provider_from_file(
496    path: &std::path::Path,
497) -> Result<SharedTemplateProvider, super::loader::TemplateError> {
498    Ok(Arc::new(DefaultTemplateProvider::from_file(path)?))
499}
500
501#[cfg(test)]
502#[allow(clippy::unwrap_used)]
503mod tests {
504    use super::*;
505    use rand::SeedableRng;
506    use rand_chacha::ChaCha8Rng;
507
508    #[test]
509    fn test_default_provider() {
510        let provider = DefaultTemplateProvider::new();
511        let mut rng = ChaCha8Rng::seed_from_u64(12345);
512
513        let name = provider.get_person_first_name(NameCulture::German, true, &mut rng);
514        assert!(!name.is_empty());
515
516        let last_name = provider.get_person_last_name(NameCulture::German, &mut rng);
517        assert!(!last_name.is_empty());
518    }
519
520    #[test]
521    fn test_vendor_names() {
522        let provider = DefaultTemplateProvider::new();
523        let mut rng = ChaCha8Rng::seed_from_u64(12345);
524
525        let name = provider.get_vendor_name("manufacturing", &mut rng);
526        assert!(!name.is_empty());
527        assert!(!name.contains("Unknown"));
528    }
529
530    #[test]
531    fn test_shared_provider() {
532        let provider = default_provider();
533        let mut rng = ChaCha8Rng::seed_from_u64(12345);
534
535        let name = provider.get_customer_name("retail", &mut rng);
536        assert!(!name.is_empty());
537    }
538}