Skip to main content

datasynth_config/
validation.rs

1//! Configuration validation.
2
3use crate::schema::GeneratorConfig;
4use datasynth_core::error::{SynthError, SynthResult};
5
6/// Maximum allowed period in months (10 years).
7const MAX_PERIOD_MONTHS: u32 = 120;
8
9/// Validate a generator configuration.
10pub fn validate_config(config: &GeneratorConfig) -> SynthResult<()> {
11    validate_global_settings(config)?;
12    validate_companies(config)?;
13    validate_transactions(config)?;
14    validate_output(config)?;
15    validate_fraud(config)?;
16    validate_internal_controls(config)?;
17    validate_approval(config)?;
18    validate_master_data(config)?;
19    validate_document_flows(config)?;
20    validate_intercompany(config)?;
21    validate_balance(config)?;
22    validate_accounting_standards(config)?;
23    validate_audit_standards(config)?;
24    Ok(())
25}
26
27/// Validate global settings.
28fn validate_global_settings(config: &GeneratorConfig) -> SynthResult<()> {
29    if config.global.period_months == 0 {
30        return Err(SynthError::validation(
31            "period_months must be greater than 0",
32        ));
33    }
34    if config.global.period_months > MAX_PERIOD_MONTHS {
35        return Err(SynthError::validation(format!(
36            "period_months must be at most {} (10 years), got {}",
37            MAX_PERIOD_MONTHS, config.global.period_months
38        )));
39    }
40    Ok(())
41}
42
43/// Validate company configuration.
44fn validate_companies(config: &GeneratorConfig) -> SynthResult<()> {
45    if config.companies.is_empty() {
46        return Err(SynthError::validation(
47            "At least one company must be configured",
48        ));
49    }
50
51    for company in &config.companies {
52        if company.code.is_empty() {
53            return Err(SynthError::validation("Company code cannot be empty"));
54        }
55        if company.currency.len() != 3 {
56            return Err(SynthError::validation(format!(
57                "Invalid currency code '{}' for company '{}'",
58                company.currency, company.code
59            )));
60        }
61        if company.volume_weight < 0.0 {
62            return Err(SynthError::validation(format!(
63                "volume_weight must be non-negative for company '{}'",
64                company.code
65            )));
66        }
67    }
68    Ok(())
69}
70
71/// Validate transaction configuration.
72fn validate_transactions(config: &GeneratorConfig) -> SynthResult<()> {
73    // Validate line item distribution
74    let line_dist = &config.transactions.line_item_distribution;
75    if let Err(e) = line_dist.validate() {
76        return Err(SynthError::validation(e));
77    }
78
79    // Validate source distribution sums to ~1.0
80    let source_sum = config.transactions.source_distribution.manual
81        + config.transactions.source_distribution.automated
82        + config.transactions.source_distribution.recurring
83        + config.transactions.source_distribution.adjustment;
84    if (source_sum - 1.0).abs() > 0.01 {
85        return Err(SynthError::validation(format!(
86            "Source distribution must sum to 1.0, got {}",
87            source_sum
88        )));
89    }
90
91    // Validate business process weights
92    let bp_sum = config.business_processes.o2c_weight
93        + config.business_processes.p2p_weight
94        + config.business_processes.r2r_weight
95        + config.business_processes.h2r_weight
96        + config.business_processes.a2r_weight;
97    if (bp_sum - 1.0).abs() > 0.01 {
98        return Err(SynthError::validation(format!(
99            "Business process weights must sum to 1.0, got {}",
100            bp_sum
101        )));
102    }
103
104    // Validate Benford tolerance
105    let tolerance = config.transactions.benford.tolerance;
106    if !(0.0..=1.0).contains(&tolerance) {
107        return Err(SynthError::validation(format!(
108            "benford.tolerance must be between 0.0 and 1.0, got {}",
109            tolerance
110        )));
111    }
112
113    Ok(())
114}
115
116/// Validate output configuration.
117fn validate_output(config: &GeneratorConfig) -> SynthResult<()> {
118    let level = config.output.compression.level;
119    if config.output.compression.enabled && !(1..=9).contains(&level) {
120        return Err(SynthError::validation(format!(
121            "compression.level must be between 1 and 9, got {}",
122            level
123        )));
124    }
125
126    if config.output.batch_size == 0 {
127        return Err(SynthError::validation("batch_size must be greater than 0"));
128    }
129
130    Ok(())
131}
132
133/// Validate fraud configuration.
134fn validate_fraud(config: &GeneratorConfig) -> SynthResult<()> {
135    if !config.fraud.enabled {
136        return Ok(());
137    }
138
139    if config.fraud.fraud_rate < 0.0 || config.fraud.fraud_rate > 1.0 {
140        return Err(SynthError::validation(
141            "fraud_rate must be between 0.0 and 1.0",
142        ));
143    }
144
145    if config.fraud.clustering_factor < 0.0 {
146        return Err(SynthError::validation(
147            "clustering_factor must be non-negative",
148        ));
149    }
150
151    // Validate approval thresholds are in ascending order
152    let thresholds = &config.fraud.approval_thresholds;
153    for i in 1..thresholds.len() {
154        if thresholds[i] <= thresholds[i - 1] {
155            return Err(SynthError::validation(format!(
156                "fraud.approval_thresholds must be in strictly ascending order: {} is not greater than {}",
157                thresholds[i], thresholds[i - 1]
158            )));
159        }
160    }
161
162    // Validate fraud type distribution sums to ~1.0
163    let dist = &config.fraud.fraud_type_distribution;
164    let sum = dist.suspense_account_abuse
165        + dist.fictitious_transaction
166        + dist.revenue_manipulation
167        + dist.expense_capitalization
168        + dist.split_transaction
169        + dist.timing_anomaly
170        + dist.unauthorized_access
171        + dist.duplicate_payment;
172    if (sum - 1.0).abs() > 0.01 {
173        return Err(SynthError::validation(format!(
174            "fraud_type_distribution must sum to 1.0, got {}",
175            sum
176        )));
177    }
178
179    Ok(())
180}
181
182/// Validate internal controls configuration.
183fn validate_internal_controls(config: &GeneratorConfig) -> SynthResult<()> {
184    if !config.internal_controls.enabled {
185        return Ok(());
186    }
187
188    let exception_rate = config.internal_controls.exception_rate;
189    if !(0.0..=1.0).contains(&exception_rate) {
190        return Err(SynthError::validation(format!(
191            "exception_rate must be between 0.0 and 1.0, got {}",
192            exception_rate
193        )));
194    }
195
196    let sod_rate = config.internal_controls.sod_violation_rate;
197    if !(0.0..=1.0).contains(&sod_rate) {
198        return Err(SynthError::validation(format!(
199            "sod_violation_rate must be between 0.0 and 1.0, got {}",
200            sod_rate
201        )));
202    }
203
204    if config.internal_controls.sox_materiality_threshold < 0.0 {
205        return Err(SynthError::validation(
206            "sox_materiality_threshold must be non-negative",
207        ));
208    }
209
210    Ok(())
211}
212
213/// Validate approval configuration.
214fn validate_approval(config: &GeneratorConfig) -> SynthResult<()> {
215    if !config.approval.enabled {
216        return Ok(());
217    }
218
219    if config.approval.auto_approve_threshold < 0.0 {
220        return Err(SynthError::validation(
221            "auto_approve_threshold must be non-negative",
222        ));
223    }
224
225    let rejection_rate = config.approval.rejection_rate;
226    if !(0.0..=1.0).contains(&rejection_rate) {
227        return Err(SynthError::validation(format!(
228            "rejection_rate must be between 0.0 and 1.0, got {}",
229            rejection_rate
230        )));
231    }
232
233    let revision_rate = config.approval.revision_rate;
234    if !(0.0..=1.0).contains(&revision_rate) {
235        return Err(SynthError::validation(format!(
236            "revision_rate must be between 0.0 and 1.0, got {}",
237            revision_rate
238        )));
239    }
240
241    // rejection + revision should not exceed 1.0
242    if rejection_rate + revision_rate > 1.0 {
243        return Err(SynthError::validation(format!(
244            "rejection_rate + revision_rate must not exceed 1.0, got {}",
245            rejection_rate + revision_rate
246        )));
247    }
248
249    // Validate approval thresholds are in ascending order by amount
250    let thresholds = &config.approval.thresholds;
251    for i in 1..thresholds.len() {
252        if thresholds[i].amount <= thresholds[i - 1].amount {
253            return Err(SynthError::validation(format!(
254                "approval.thresholds must have strictly ascending amounts: {} is not greater than {}",
255                thresholds[i].amount, thresholds[i - 1].amount
256            )));
257        }
258    }
259
260    Ok(())
261}
262
263/// Validate master data configuration.
264fn validate_master_data(config: &GeneratorConfig) -> SynthResult<()> {
265    // Vendor config
266    let vendor_ic = config.master_data.vendors.intercompany_percent;
267    if !(0.0..=1.0).contains(&vendor_ic) {
268        return Err(SynthError::validation(format!(
269            "vendors.intercompany_percent must be between 0.0 and 1.0, got {}",
270            vendor_ic
271        )));
272    }
273
274    // Customer config
275    let customer_ic = config.master_data.customers.intercompany_percent;
276    if !(0.0..=1.0).contains(&customer_ic) {
277        return Err(SynthError::validation(format!(
278            "customers.intercompany_percent must be between 0.0 and 1.0, got {}",
279            customer_ic
280        )));
281    }
282
283    // Material config
284    let bom_percent = config.master_data.materials.bom_percent;
285    if !(0.0..=1.0).contains(&bom_percent) {
286        return Err(SynthError::validation(format!(
287            "materials.bom_percent must be between 0.0 and 1.0, got {}",
288            bom_percent
289        )));
290    }
291
292    // Fixed asset config
293    let fully_dep = config.master_data.fixed_assets.fully_depreciated_percent;
294    if !(0.0..=1.0).contains(&fully_dep) {
295        return Err(SynthError::validation(format!(
296            "fixed_assets.fully_depreciated_percent must be between 0.0 and 1.0, got {}",
297            fully_dep
298        )));
299    }
300
301    Ok(())
302}
303
304/// Validate document flow configuration.
305fn validate_document_flows(config: &GeneratorConfig) -> SynthResult<()> {
306    // P2P config
307    let p2p = &config.document_flows.p2p;
308    if p2p.enabled {
309        validate_rate("p2p.three_way_match_rate", p2p.three_way_match_rate)?;
310        validate_rate("p2p.partial_delivery_rate", p2p.partial_delivery_rate)?;
311        validate_rate("p2p.price_variance_rate", p2p.price_variance_rate)?;
312        validate_rate("p2p.quantity_variance_rate", p2p.quantity_variance_rate)?;
313
314        if p2p.max_price_variance_percent < 0.0 {
315            return Err(SynthError::validation(
316                "p2p.max_price_variance_percent must be non-negative",
317            ));
318        }
319
320        // P2P payment behavior config
321        validate_p2p_payment_behavior(&p2p.payment_behavior)?;
322    }
323
324    // O2C config
325    let o2c = &config.document_flows.o2c;
326    if o2c.enabled {
327        validate_rate(
328            "o2c.credit_check_failure_rate",
329            o2c.credit_check_failure_rate,
330        )?;
331        validate_rate("o2c.partial_shipment_rate", o2c.partial_shipment_rate)?;
332        validate_rate("o2c.return_rate", o2c.return_rate)?;
333        validate_rate("o2c.bad_debt_rate", o2c.bad_debt_rate)?;
334
335        // Cash discount config
336        validate_rate(
337            "o2c.cash_discount.eligible_rate",
338            o2c.cash_discount.eligible_rate,
339        )?;
340        validate_rate("o2c.cash_discount.taken_rate", o2c.cash_discount.taken_rate)?;
341        validate_rate(
342            "o2c.cash_discount.discount_percent",
343            o2c.cash_discount.discount_percent,
344        )?;
345
346        // O2C payment behavior config
347        validate_o2c_payment_behavior(&o2c.payment_behavior)?;
348    }
349
350    Ok(())
351}
352
353/// Validate P2P payment behavior configuration.
354fn validate_p2p_payment_behavior(
355    config: &crate::schema::P2PPaymentBehaviorConfig,
356) -> SynthResult<()> {
357    validate_rate(
358        "p2p.payment_behavior.late_payment_rate",
359        config.late_payment_rate,
360    )?;
361    validate_rate(
362        "p2p.payment_behavior.partial_payment_rate",
363        config.partial_payment_rate,
364    )?;
365    validate_rate(
366        "p2p.payment_behavior.payment_correction_rate",
367        config.payment_correction_rate,
368    )?;
369
370    // Validate late payment days distribution sums to ~1.0
371    let late_dist = &config.late_payment_days_distribution;
372    let late_sum = late_dist.slightly_late_1_to_7
373        + late_dist.late_8_to_14
374        + late_dist.very_late_15_to_30
375        + late_dist.severely_late_31_to_60
376        + late_dist.extremely_late_over_60;
377    if (late_sum - 1.0).abs() > 0.01 {
378        return Err(SynthError::validation(format!(
379            "p2p.payment_behavior.late_payment_days_distribution must sum to 1.0, got {}",
380            late_sum
381        )));
382    }
383
384    Ok(())
385}
386
387/// Validate O2C payment behavior configuration.
388fn validate_o2c_payment_behavior(
389    config: &crate::schema::O2CPaymentBehaviorConfig,
390) -> SynthResult<()> {
391    // Validate dunning config
392    let dunning = &config.dunning;
393    if dunning.enabled {
394        validate_rate(
395            "o2c.payment_behavior.dunning.dunning_block_rate",
396            dunning.dunning_block_rate,
397        )?;
398
399        // Validate dunning level days are in ascending order
400        if dunning.level_2_days_overdue <= dunning.level_1_days_overdue {
401            return Err(SynthError::validation(
402                "dunning.level_2_days_overdue must be greater than level_1_days_overdue",
403            ));
404        }
405        if dunning.level_3_days_overdue <= dunning.level_2_days_overdue {
406            return Err(SynthError::validation(
407                "dunning.level_3_days_overdue must be greater than level_2_days_overdue",
408            ));
409        }
410        if dunning.collection_days_overdue <= dunning.level_3_days_overdue {
411            return Err(SynthError::validation(
412                "dunning.collection_days_overdue must be greater than level_3_days_overdue",
413            ));
414        }
415
416        // Validate dunning payment rates sum to ~1.0
417        let rates = &dunning.payment_after_dunning_rates;
418        let rates_sum = rates.after_level_1
419            + rates.after_level_2
420            + rates.after_level_3
421            + rates.during_collection
422            + rates.never_pay;
423        if (rates_sum - 1.0).abs() > 0.01 {
424            return Err(SynthError::validation(format!(
425                "dunning.payment_after_dunning_rates must sum to 1.0, got {}",
426                rates_sum
427            )));
428        }
429    }
430
431    // Validate partial payments config
432    let partial = &config.partial_payments;
433    validate_rate("o2c.payment_behavior.partial_payments.rate", partial.rate)?;
434    let partial_dist = &partial.percentage_distribution;
435    let partial_sum = partial_dist.pay_25_percent
436        + partial_dist.pay_50_percent
437        + partial_dist.pay_75_percent
438        + partial_dist.pay_random_percent;
439    if (partial_sum - 1.0).abs() > 0.01 {
440        return Err(SynthError::validation(format!(
441            "partial_payments.percentage_distribution must sum to 1.0, got {}",
442            partial_sum
443        )));
444    }
445
446    // Validate short payments config
447    let short = &config.short_payments;
448    validate_rate("o2c.payment_behavior.short_payments.rate", short.rate)?;
449    validate_rate(
450        "o2c.payment_behavior.short_payments.max_short_percent",
451        short.max_short_percent,
452    )?;
453    let short_dist = &short.reason_distribution;
454    let short_sum = short_dist.pricing_dispute
455        + short_dist.quality_issue
456        + short_dist.quantity_discrepancy
457        + short_dist.unauthorized_deduction
458        + short_dist.incorrect_discount;
459    if (short_sum - 1.0).abs() > 0.01 {
460        return Err(SynthError::validation(format!(
461            "short_payments.reason_distribution must sum to 1.0, got {}",
462            short_sum
463        )));
464    }
465
466    // Validate on-account payments config
467    validate_rate(
468        "o2c.payment_behavior.on_account_payments.rate",
469        config.on_account_payments.rate,
470    )?;
471
472    // Validate payment corrections config
473    let corrections = &config.payment_corrections;
474    validate_rate(
475        "o2c.payment_behavior.payment_corrections.rate",
476        corrections.rate,
477    )?;
478    let corr_dist = &corrections.type_distribution;
479    let corr_sum = corr_dist.nsf
480        + corr_dist.chargeback
481        + corr_dist.wrong_amount
482        + corr_dist.wrong_customer
483        + corr_dist.duplicate_payment;
484    if (corr_sum - 1.0).abs() > 0.01 {
485        return Err(SynthError::validation(format!(
486            "payment_corrections.type_distribution must sum to 1.0, got {}",
487            corr_sum
488        )));
489    }
490
491    Ok(())
492}
493
494/// Validate intercompany configuration.
495fn validate_intercompany(config: &GeneratorConfig) -> SynthResult<()> {
496    if !config.intercompany.enabled {
497        return Ok(());
498    }
499
500    validate_rate(
501        "intercompany.ic_transaction_rate",
502        config.intercompany.ic_transaction_rate,
503    )?;
504
505    if config.intercompany.markup_percent < 0.0 {
506        return Err(SynthError::validation(
507            "intercompany.markup_percent must be non-negative",
508        ));
509    }
510
511    // Validate IC transaction type distribution sums to ~1.0
512    let dist = &config.intercompany.transaction_type_distribution;
513    let sum = dist.goods_sale
514        + dist.service_provided
515        + dist.loan
516        + dist.dividend
517        + dist.management_fee
518        + dist.royalty
519        + dist.cost_sharing;
520    if (sum - 1.0).abs() > 0.01 {
521        return Err(SynthError::validation(format!(
522            "intercompany.transaction_type_distribution must sum to 1.0, got {}",
523            sum
524        )));
525    }
526
527    Ok(())
528}
529
530/// Validate balance configuration.
531fn validate_balance(config: &GeneratorConfig) -> SynthResult<()> {
532    let balance = &config.balance;
533
534    if balance.target_gross_margin < 0.0 || balance.target_gross_margin > 1.0 {
535        return Err(SynthError::validation(format!(
536            "target_gross_margin must be between 0.0 and 1.0, got {}",
537            balance.target_gross_margin
538        )));
539    }
540
541    if balance.target_current_ratio < 0.0 {
542        return Err(SynthError::validation(
543            "target_current_ratio must be non-negative",
544        ));
545    }
546
547    if balance.target_debt_to_equity < 0.0 {
548        return Err(SynthError::validation(
549            "target_debt_to_equity must be non-negative",
550        ));
551    }
552
553    Ok(())
554}
555
556/// Helper to validate a rate field is between 0.0 and 1.0.
557fn validate_rate(field_name: &str, value: f64) -> SynthResult<()> {
558    if !(0.0..=1.0).contains(&value) {
559        return Err(SynthError::validation(format!(
560            "{} must be between 0.0 and 1.0, got {}",
561            field_name, value
562        )));
563    }
564    Ok(())
565}
566
567/// Validate accounting standards configuration (IFRS, US GAAP).
568fn validate_accounting_standards(config: &GeneratorConfig) -> SynthResult<()> {
569    let standards = &config.accounting_standards;
570
571    if !standards.enabled {
572        return Ok(());
573    }
574
575    // Validate revenue recognition settings
576    if standards.revenue_recognition.enabled {
577        let rev = &standards.revenue_recognition;
578
579        if rev.avg_obligations_per_contract < 1.0 {
580            return Err(SynthError::validation(
581                "avg_obligations_per_contract must be >= 1.0",
582            ));
583        }
584
585        validate_rate(
586            "revenue_recognition.variable_consideration_rate",
587            rev.variable_consideration_rate,
588        )?;
589
590        validate_rate(
591            "revenue_recognition.over_time_recognition_rate",
592            rev.over_time_recognition_rate,
593        )?;
594    }
595
596    // Validate lease accounting settings
597    if standards.leases.enabled {
598        let lease = &standards.leases;
599
600        if lease.avg_lease_term_months == 0 {
601            return Err(SynthError::validation(
602                "lease.avg_lease_term_months must be > 0",
603            ));
604        }
605
606        validate_rate("lease.finance_lease_percent", lease.finance_lease_percent)?;
607        validate_rate("lease.real_estate_percent", lease.real_estate_percent)?;
608    }
609
610    // Validate fair value settings
611    if standards.fair_value.enabled {
612        let fv = &standards.fair_value;
613
614        // Level distributions should sum to approximately 1.0
615        let level_sum = fv.level1_percent + fv.level2_percent + fv.level3_percent;
616        if (level_sum - 1.0).abs() > 0.01 {
617            return Err(SynthError::validation(format!(
618                "fair_value level percentages must sum to 1.0, got {}",
619                level_sum
620            )));
621        }
622
623        validate_rate("fair_value.level1_percent", fv.level1_percent)?;
624        validate_rate("fair_value.level2_percent", fv.level2_percent)?;
625        validate_rate("fair_value.level3_percent", fv.level3_percent)?;
626    }
627
628    // Validate impairment settings
629    if standards.impairment.enabled {
630        let imp = &standards.impairment;
631
632        validate_rate("impairment.impairment_rate", imp.impairment_rate)?;
633    }
634
635    Ok(())
636}
637
638/// Validate audit standards configuration (ISA, PCAOB, SOX).
639fn validate_audit_standards(config: &GeneratorConfig) -> SynthResult<()> {
640    let standards = &config.audit_standards;
641
642    if !standards.enabled {
643        return Ok(());
644    }
645
646    // Validate ISA compliance settings
647    if standards.isa_compliance.enabled {
648        let valid_levels = ["basic", "standard", "comprehensive"];
649        if !valid_levels.contains(&standards.isa_compliance.compliance_level.as_str()) {
650            return Err(SynthError::validation(format!(
651                "isa_compliance.compliance_level must be one of {:?}, got '{}'",
652                valid_levels, standards.isa_compliance.compliance_level
653            )));
654        }
655
656        let valid_frameworks = ["isa", "pcaob", "dual"];
657        if !valid_frameworks.contains(&standards.isa_compliance.framework.as_str()) {
658            return Err(SynthError::validation(format!(
659                "isa_compliance.framework must be one of {:?}, got '{}'",
660                valid_frameworks, standards.isa_compliance.framework
661            )));
662        }
663    }
664
665    // Validate analytical procedures settings
666    if standards.analytical_procedures.enabled {
667        let ap = &standards.analytical_procedures;
668
669        if ap.procedures_per_account == 0 {
670            return Err(SynthError::validation(
671                "analytical_procedures.procedures_per_account must be > 0",
672            ));
673        }
674
675        validate_rate(
676            "analytical_procedures.variance_probability",
677            ap.variance_probability,
678        )?;
679    }
680
681    // Validate confirmations settings
682    if standards.confirmations.enabled {
683        let conf = &standards.confirmations;
684
685        validate_rate(
686            "confirmations.positive_response_rate",
687            conf.positive_response_rate,
688        )?;
689
690        validate_rate("confirmations.exception_rate", conf.exception_rate)?;
691
692        // Positive + non-response + exception should make sense
693        let total_rate = conf.positive_response_rate + conf.exception_rate;
694        if total_rate > 1.0 {
695            return Err(SynthError::validation(
696                "confirmations: positive_response_rate + exception_rate cannot exceed 1.0",
697            ));
698        }
699    }
700
701    // Validate opinion settings
702    if standards.opinion.enabled {
703        let op = &standards.opinion;
704
705        if op.generate_kam && op.average_kam_count == 0 {
706            return Err(SynthError::validation(
707                "opinion.average_kam_count must be > 0 when generate_kam is true",
708            ));
709        }
710    }
711
712    // Validate SOX settings
713    if standards.sox.enabled {
714        let sox = &standards.sox;
715
716        if sox.materiality_threshold < 0.0 {
717            return Err(SynthError::validation(
718                "sox.materiality_threshold must be >= 0",
719            ));
720        }
721    }
722
723    // Validate PCAOB settings
724    if standards.pcaob.enabled {
725        // PCAOB requires ISA dual framework or PCAOB-only
726        if standards.isa_compliance.enabled
727            && standards.isa_compliance.framework != "pcaob"
728            && standards.isa_compliance.framework != "dual"
729        {
730            return Err(SynthError::validation(
731                "When PCAOB is enabled, ISA framework must be 'pcaob' or 'dual'",
732            ));
733        }
734    }
735
736    Ok(())
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use crate::presets::{create_preset, demo_preset, stress_test_preset};
743    use crate::schema::*;
744    use datasynth_core::models::{CoAComplexity, IndustrySector};
745
746    /// Helper to create a minimal valid config for testing.
747    fn minimal_valid_config() -> GeneratorConfig {
748        GeneratorConfig {
749            global: GlobalConfig {
750                seed: Some(42),
751                industry: IndustrySector::Manufacturing,
752                start_date: "2024-01-01".to_string(),
753                period_months: 3,
754                group_currency: "USD".to_string(),
755                parallel: true,
756                worker_threads: 0,
757                memory_limit_mb: 0,
758            },
759            companies: vec![CompanyConfig {
760                code: "TEST".to_string(),
761                name: "Test Company".to_string(),
762                currency: "USD".to_string(),
763                country: "US".to_string(),
764                fiscal_year_variant: "K4".to_string(),
765                annual_transaction_volume: TransactionVolume::TenK,
766                volume_weight: 1.0,
767            }],
768            chart_of_accounts: ChartOfAccountsConfig {
769                complexity: CoAComplexity::Small,
770                industry_specific: true,
771                custom_accounts: None,
772                min_hierarchy_depth: 2,
773                max_hierarchy_depth: 5,
774            },
775            transactions: TransactionConfig::default(),
776            output: OutputConfig::default(),
777            fraud: FraudConfig::default(),
778            internal_controls: InternalControlsConfig::default(),
779            business_processes: BusinessProcessConfig::default(),
780            user_personas: UserPersonaConfig::default(),
781            templates: TemplateConfig::default(),
782            approval: ApprovalConfig::default(),
783            departments: DepartmentConfig::default(),
784            master_data: MasterDataConfig::default(),
785            document_flows: DocumentFlowConfig::default(),
786            intercompany: IntercompanyConfig::default(),
787            balance: BalanceConfig::default(),
788            ocpm: OcpmConfig::default(),
789            audit: AuditGenerationConfig::default(),
790            banking: datasynth_banking::BankingConfig::default(),
791            data_quality: DataQualitySchemaConfig::default(),
792            scenario: ScenarioConfig::default(),
793            temporal: TemporalDriftConfig::default(),
794            graph_export: GraphExportConfig::default(),
795            streaming: StreamingSchemaConfig::default(),
796            rate_limit: RateLimitSchemaConfig::default(),
797            temporal_attributes: TemporalAttributeSchemaConfig::default(),
798            relationships: RelationshipSchemaConfig::default(),
799            accounting_standards: AccountingStandardsConfig::default(),
800            audit_standards: AuditStandardsConfig::default(),
801        }
802    }
803
804    // ==========================================================================
805    // Period Months Validation Tests
806    // ==========================================================================
807
808    #[test]
809    fn test_valid_period_months() {
810        let config = minimal_valid_config();
811        assert!(validate_config(&config).is_ok());
812    }
813
814    #[test]
815    fn test_zero_period_months_rejected() {
816        let mut config = minimal_valid_config();
817        config.global.period_months = 0;
818        let result = validate_config(&config);
819        assert!(result.is_err());
820        assert!(result.unwrap_err().to_string().contains("period_months"));
821    }
822
823    #[test]
824    fn test_large_period_months_accepted() {
825        let mut config = minimal_valid_config();
826        config.global.period_months = 120; // 10 years - maximum allowed
827        assert!(validate_config(&config).is_ok());
828    }
829
830    #[test]
831    fn test_period_months_exceeds_max_rejected() {
832        let mut config = minimal_valid_config();
833        config.global.period_months = 121; // Exceeds 10 year max
834        let result = validate_config(&config);
835        assert!(result.is_err());
836        assert!(result.unwrap_err().to_string().contains("period_months"));
837    }
838
839    // ==========================================================================
840    // Company Validation Tests
841    // ==========================================================================
842
843    #[test]
844    fn test_empty_companies_rejected() {
845        let mut config = minimal_valid_config();
846        config.companies.clear();
847        let result = validate_config(&config);
848        assert!(result.is_err());
849        assert!(result.unwrap_err().to_string().contains("company"));
850    }
851
852    #[test]
853    fn test_empty_company_code_rejected() {
854        let mut config = minimal_valid_config();
855        config.companies[0].code = "".to_string();
856        let result = validate_config(&config);
857        assert!(result.is_err());
858        assert!(result.unwrap_err().to_string().contains("Company code"));
859    }
860
861    #[test]
862    fn test_invalid_currency_code_rejected() {
863        let mut config = minimal_valid_config();
864        config.companies[0].currency = "US".to_string(); // 2 chars, not 3
865        let result = validate_config(&config);
866        assert!(result.is_err());
867        assert!(result.unwrap_err().to_string().contains("currency"));
868    }
869
870    #[test]
871    fn test_long_currency_code_rejected() {
872        let mut config = minimal_valid_config();
873        config.companies[0].currency = "USDD".to_string(); // 4 chars
874        let result = validate_config(&config);
875        assert!(result.is_err());
876        assert!(result.unwrap_err().to_string().contains("currency"));
877    }
878
879    #[test]
880    fn test_multiple_companies_validated() {
881        let mut config = minimal_valid_config();
882        config.companies.push(CompanyConfig {
883            code: "SUB1".to_string(),
884            name: "Subsidiary 1".to_string(),
885            currency: "EUR".to_string(),
886            country: "DE".to_string(),
887            fiscal_year_variant: "K4".to_string(),
888            annual_transaction_volume: TransactionVolume::TenK,
889            volume_weight: 0.5,
890        });
891        assert!(validate_config(&config).is_ok());
892    }
893
894    #[test]
895    fn test_second_company_invalid_currency_rejected() {
896        let mut config = minimal_valid_config();
897        config.companies.push(CompanyConfig {
898            code: "SUB1".to_string(),
899            name: "Subsidiary 1".to_string(),
900            currency: "EU".to_string(), // Invalid
901            country: "DE".to_string(),
902            fiscal_year_variant: "K4".to_string(),
903            annual_transaction_volume: TransactionVolume::TenK,
904            volume_weight: 0.5,
905        });
906        let result = validate_config(&config);
907        assert!(result.is_err());
908    }
909
910    // ==========================================================================
911    // Source Distribution Validation Tests
912    // ==========================================================================
913
914    #[test]
915    fn test_valid_source_distribution() {
916        let config = minimal_valid_config();
917        // Default source distribution sums to 1.0
918        assert!(validate_config(&config).is_ok());
919    }
920
921    #[test]
922    fn test_source_distribution_not_summing_to_one_rejected() {
923        let mut config = minimal_valid_config();
924        config.transactions.source_distribution = SourceDistribution {
925            manual: 0.5,
926            automated: 0.5,
927            recurring: 0.5, // Sum = 1.6
928            adjustment: 0.1,
929        };
930        let result = validate_config(&config);
931        assert!(result.is_err());
932        assert!(result
933            .unwrap_err()
934            .to_string()
935            .contains("Source distribution"));
936    }
937
938    #[test]
939    fn test_source_distribution_slightly_off_accepted() {
940        let mut config = minimal_valid_config();
941        // Within 0.01 tolerance
942        config.transactions.source_distribution = SourceDistribution {
943            manual: 0.20,
944            automated: 0.70,
945            recurring: 0.07,
946            adjustment: 0.025, // Sum = 0.995, within tolerance
947        };
948        assert!(validate_config(&config).is_ok());
949    }
950
951    // ==========================================================================
952    // Business Process Weights Validation Tests
953    // ==========================================================================
954
955    #[test]
956    fn test_valid_business_process_weights() {
957        let config = minimal_valid_config();
958        // Default weights sum to 1.0
959        assert!(validate_config(&config).is_ok());
960    }
961
962    #[test]
963    fn test_business_process_weights_not_summing_to_one_rejected() {
964        let mut config = minimal_valid_config();
965        config.business_processes = BusinessProcessConfig {
966            o2c_weight: 0.5,
967            p2p_weight: 0.5,
968            r2r_weight: 0.5, // Sum > 1
969            h2r_weight: 0.1,
970            a2r_weight: 0.1,
971        };
972        let result = validate_config(&config);
973        assert!(result.is_err());
974        assert!(result
975            .unwrap_err()
976            .to_string()
977            .contains("Business process weights"));
978    }
979
980    // ==========================================================================
981    // Fraud Configuration Validation Tests
982    // ==========================================================================
983
984    #[test]
985    fn test_fraud_disabled_invalid_rate_accepted() {
986        let mut config = minimal_valid_config();
987        config.fraud.enabled = false;
988        config.fraud.fraud_rate = 5.0; // Invalid but ignored since disabled
989        assert!(validate_config(&config).is_ok());
990    }
991
992    #[test]
993    fn test_fraud_enabled_valid_rate_accepted() {
994        let mut config = minimal_valid_config();
995        config.fraud.enabled = true;
996        config.fraud.fraud_rate = 0.05;
997        assert!(validate_config(&config).is_ok());
998    }
999
1000    #[test]
1001    fn test_fraud_enabled_negative_rate_rejected() {
1002        let mut config = minimal_valid_config();
1003        config.fraud.enabled = true;
1004        config.fraud.fraud_rate = -0.1;
1005        let result = validate_config(&config);
1006        assert!(result.is_err());
1007        assert!(result.unwrap_err().to_string().contains("fraud_rate"));
1008    }
1009
1010    #[test]
1011    fn test_fraud_enabled_rate_above_one_rejected() {
1012        let mut config = minimal_valid_config();
1013        config.fraud.enabled = true;
1014        config.fraud.fraud_rate = 1.5;
1015        let result = validate_config(&config);
1016        assert!(result.is_err());
1017        assert!(result.unwrap_err().to_string().contains("fraud_rate"));
1018    }
1019
1020    #[test]
1021    fn test_fraud_rate_zero_accepted() {
1022        let mut config = minimal_valid_config();
1023        config.fraud.enabled = true;
1024        config.fraud.fraud_rate = 0.0;
1025        assert!(validate_config(&config).is_ok());
1026    }
1027
1028    #[test]
1029    fn test_fraud_rate_one_accepted() {
1030        let mut config = minimal_valid_config();
1031        config.fraud.enabled = true;
1032        config.fraud.fraud_rate = 1.0;
1033        assert!(validate_config(&config).is_ok());
1034    }
1035
1036    // ==========================================================================
1037    // Preset Validation Tests
1038    // ==========================================================================
1039
1040    #[test]
1041    fn test_demo_preset_valid() {
1042        let config = demo_preset();
1043        assert!(validate_config(&config).is_ok());
1044    }
1045
1046    #[test]
1047    fn test_stress_test_preset_valid() {
1048        let config = stress_test_preset();
1049        assert!(validate_config(&config).is_ok());
1050    }
1051
1052    #[test]
1053    fn test_manufacturing_preset_valid() {
1054        let config = create_preset(
1055            IndustrySector::Manufacturing,
1056            2,
1057            12,
1058            CoAComplexity::Medium,
1059            TransactionVolume::HundredK,
1060        );
1061        assert!(validate_config(&config).is_ok());
1062    }
1063
1064    #[test]
1065    fn test_retail_preset_valid() {
1066        let config = create_preset(
1067            IndustrySector::Retail,
1068            3,
1069            6,
1070            CoAComplexity::Large,
1071            TransactionVolume::OneM,
1072        );
1073        assert!(validate_config(&config).is_ok());
1074    }
1075
1076    #[test]
1077    fn test_financial_services_preset_valid() {
1078        let config = create_preset(
1079            IndustrySector::FinancialServices,
1080            2,
1081            12,
1082            CoAComplexity::Large,
1083            TransactionVolume::TenM,
1084        );
1085        assert!(validate_config(&config).is_ok());
1086    }
1087
1088    #[test]
1089    fn test_healthcare_preset_valid() {
1090        let config = create_preset(
1091            IndustrySector::Healthcare,
1092            2,
1093            6,
1094            CoAComplexity::Medium,
1095            TransactionVolume::HundredK,
1096        );
1097        assert!(validate_config(&config).is_ok());
1098    }
1099
1100    #[test]
1101    fn test_technology_preset_valid() {
1102        let config = create_preset(
1103            IndustrySector::Technology,
1104            3,
1105            12,
1106            CoAComplexity::Medium,
1107            TransactionVolume::HundredK,
1108        );
1109        assert!(validate_config(&config).is_ok());
1110    }
1111
1112    // ==========================================================================
1113    // Transaction Volume Tests
1114    // ==========================================================================
1115
1116    #[test]
1117    fn test_transaction_volume_counts() {
1118        assert_eq!(TransactionVolume::TenK.count(), 10_000);
1119        assert_eq!(TransactionVolume::HundredK.count(), 100_000);
1120        assert_eq!(TransactionVolume::OneM.count(), 1_000_000);
1121        assert_eq!(TransactionVolume::TenM.count(), 10_000_000);
1122        assert_eq!(TransactionVolume::HundredM.count(), 100_000_000);
1123        assert_eq!(TransactionVolume::Custom(50_000).count(), 50_000);
1124    }
1125
1126    // ==========================================================================
1127    // Default Value Tests
1128    // ==========================================================================
1129
1130    #[test]
1131    fn test_source_distribution_default_sums_to_one() {
1132        let dist = SourceDistribution::default();
1133        let sum = dist.manual + dist.automated + dist.recurring + dist.adjustment;
1134        assert!((sum - 1.0).abs() < 0.001);
1135    }
1136
1137    #[test]
1138    fn test_business_process_default_sums_to_one() {
1139        let bp = BusinessProcessConfig::default();
1140        let sum = bp.o2c_weight + bp.p2p_weight + bp.r2r_weight + bp.h2r_weight + bp.a2r_weight;
1141        assert!((sum - 1.0).abs() < 0.001);
1142    }
1143
1144    #[test]
1145    fn test_fraud_type_distribution_default_sums_to_one() {
1146        let dist = FraudTypeDistribution::default();
1147        let sum = dist.suspense_account_abuse
1148            + dist.fictitious_transaction
1149            + dist.revenue_manipulation
1150            + dist.expense_capitalization
1151            + dist.split_transaction
1152            + dist.timing_anomaly
1153            + dist.unauthorized_access
1154            + dist.duplicate_payment;
1155        assert!((sum - 1.0).abs() < 0.001);
1156    }
1157
1158    #[test]
1159    fn test_persona_distribution_default_sums_to_one() {
1160        let dist = PersonaDistribution::default();
1161        let sum = dist.junior_accountant
1162            + dist.senior_accountant
1163            + dist.controller
1164            + dist.manager
1165            + dist.automated_system;
1166        assert!((sum - 1.0).abs() < 0.001);
1167    }
1168
1169    #[test]
1170    fn test_payment_terms_distribution_default_sums_to_one() {
1171        let dist = PaymentTermsDistribution::default();
1172        let sum = dist.net_30
1173            + dist.net_60
1174            + dist.net_90
1175            + dist.two_ten_net_30
1176            + dist.due_on_receipt
1177            + dist.end_of_month;
1178        assert!((sum - 1.0).abs() < 0.001);
1179    }
1180
1181    #[test]
1182    fn test_vendor_behavior_distribution_default_sums_to_one() {
1183        let dist = VendorBehaviorDistribution::default();
1184        let sum = dist.reliable
1185            + dist.sometimes_late
1186            + dist.inconsistent_quality
1187            + dist.premium
1188            + dist.budget;
1189        assert!((sum - 1.0).abs() < 0.001);
1190    }
1191
1192    #[test]
1193    fn test_credit_rating_distribution_default_sums_to_one() {
1194        let dist = CreditRatingDistribution::default();
1195        let sum = dist.aaa + dist.aa + dist.a + dist.bbb + dist.bb + dist.b + dist.below_b;
1196        assert!((sum - 1.0).abs() < 0.001);
1197    }
1198
1199    #[test]
1200    fn test_payment_behavior_distribution_default_sums_to_one() {
1201        let dist = PaymentBehaviorDistribution::default();
1202        let sum = dist.early_payer
1203            + dist.on_time
1204            + dist.occasional_late
1205            + dist.frequent_late
1206            + dist.discount_taker;
1207        assert!((sum - 1.0).abs() < 0.001);
1208    }
1209
1210    #[test]
1211    fn test_material_type_distribution_default_sums_to_one() {
1212        let dist = MaterialTypeDistribution::default();
1213        let sum = dist.raw_material
1214            + dist.semi_finished
1215            + dist.finished_good
1216            + dist.trading_good
1217            + dist.operating_supply
1218            + dist.service;
1219        assert!((sum - 1.0).abs() < 0.001);
1220    }
1221
1222    #[test]
1223    fn test_valuation_method_distribution_default_sums_to_one() {
1224        let dist = ValuationMethodDistribution::default();
1225        let sum = dist.standard_cost + dist.moving_average + dist.fifo + dist.lifo;
1226        assert!((sum - 1.0).abs() < 0.001);
1227    }
1228
1229    #[test]
1230    fn test_asset_class_distribution_default_sums_to_one() {
1231        let dist = AssetClassDistribution::default();
1232        let sum = dist.buildings
1233            + dist.machinery
1234            + dist.vehicles
1235            + dist.it_equipment
1236            + dist.furniture
1237            + dist.land
1238            + dist.leasehold;
1239        assert!((sum - 1.0).abs() < 0.001);
1240    }
1241
1242    #[test]
1243    fn test_depreciation_method_distribution_default_sums_to_one() {
1244        let dist = DepreciationMethodDistribution::default();
1245        let sum = dist.straight_line
1246            + dist.declining_balance
1247            + dist.double_declining
1248            + dist.sum_of_years
1249            + dist.units_of_production;
1250        assert!((sum - 1.0).abs() < 0.001);
1251    }
1252
1253    #[test]
1254    fn test_employee_department_distribution_default_sums_to_one() {
1255        let dist = EmployeeDepartmentDistribution::default();
1256        let sum = dist.finance
1257            + dist.procurement
1258            + dist.sales
1259            + dist.warehouse
1260            + dist.it
1261            + dist.hr
1262            + dist.operations
1263            + dist.executive;
1264        assert!((sum - 1.0).abs() < 0.001);
1265    }
1266
1267    #[test]
1268    fn test_ic_transaction_type_distribution_default_sums_to_one() {
1269        let dist = ICTransactionTypeDistribution::default();
1270        let sum = dist.goods_sale
1271            + dist.service_provided
1272            + dist.loan
1273            + dist.dividend
1274            + dist.management_fee
1275            + dist.royalty
1276            + dist.cost_sharing;
1277        assert!((sum - 1.0).abs() < 0.001);
1278    }
1279
1280    // ==========================================================================
1281    // Compression Level Validation Tests
1282    // ==========================================================================
1283
1284    #[test]
1285    fn test_compression_level_valid() {
1286        let mut config = minimal_valid_config();
1287        config.output.compression.enabled = true;
1288        config.output.compression.level = 5;
1289        assert!(validate_config(&config).is_ok());
1290    }
1291
1292    #[test]
1293    fn test_compression_level_zero_rejected() {
1294        let mut config = minimal_valid_config();
1295        config.output.compression.enabled = true;
1296        config.output.compression.level = 0;
1297        let result = validate_config(&config);
1298        assert!(result.is_err());
1299        assert!(result
1300            .unwrap_err()
1301            .to_string()
1302            .contains("compression.level"));
1303    }
1304
1305    #[test]
1306    fn test_compression_level_ten_rejected() {
1307        let mut config = minimal_valid_config();
1308        config.output.compression.enabled = true;
1309        config.output.compression.level = 10;
1310        let result = validate_config(&config);
1311        assert!(result.is_err());
1312        assert!(result
1313            .unwrap_err()
1314            .to_string()
1315            .contains("compression.level"));
1316    }
1317
1318    #[test]
1319    fn test_compression_disabled_ignores_level() {
1320        let mut config = minimal_valid_config();
1321        config.output.compression.enabled = false;
1322        config.output.compression.level = 0; // Invalid but ignored
1323        assert!(validate_config(&config).is_ok());
1324    }
1325
1326    // ==========================================================================
1327    // Approval Threshold Ordering Tests
1328    // ==========================================================================
1329
1330    #[test]
1331    fn test_approval_thresholds_ascending_accepted() {
1332        let mut config = minimal_valid_config();
1333        config.approval.enabled = true;
1334        config.approval.thresholds = vec![
1335            ApprovalThresholdConfig {
1336                amount: 1000.0,
1337                level: 1,
1338                roles: vec!["accountant".to_string()],
1339            },
1340            ApprovalThresholdConfig {
1341                amount: 5000.0,
1342                level: 2,
1343                roles: vec!["manager".to_string()],
1344            },
1345            ApprovalThresholdConfig {
1346                amount: 10000.0,
1347                level: 3,
1348                roles: vec!["director".to_string()],
1349            },
1350        ];
1351        assert!(validate_config(&config).is_ok());
1352    }
1353
1354    #[test]
1355    fn test_approval_thresholds_not_ascending_rejected() {
1356        let mut config = minimal_valid_config();
1357        config.approval.enabled = true;
1358        config.approval.thresholds = vec![
1359            ApprovalThresholdConfig {
1360                amount: 5000.0,
1361                level: 1,
1362                roles: vec!["accountant".to_string()],
1363            },
1364            ApprovalThresholdConfig {
1365                amount: 1000.0, // Less than previous - invalid
1366                level: 2,
1367                roles: vec!["manager".to_string()],
1368            },
1369        ];
1370        let result = validate_config(&config);
1371        assert!(result.is_err());
1372        assert!(result.unwrap_err().to_string().contains("ascending"));
1373    }
1374
1375    #[test]
1376    fn test_fraud_approval_thresholds_ascending_accepted() {
1377        let mut config = minimal_valid_config();
1378        config.fraud.enabled = true;
1379        config.fraud.fraud_rate = 0.05;
1380        config.fraud.approval_thresholds = vec![1000.0, 5000.0, 10000.0];
1381        assert!(validate_config(&config).is_ok());
1382    }
1383
1384    #[test]
1385    fn test_fraud_approval_thresholds_not_ascending_rejected() {
1386        let mut config = minimal_valid_config();
1387        config.fraud.enabled = true;
1388        config.fraud.fraud_rate = 0.05;
1389        config.fraud.approval_thresholds = vec![5000.0, 1000.0, 10000.0]; // Not ascending
1390        let result = validate_config(&config);
1391        assert!(result.is_err());
1392        assert!(result.unwrap_err().to_string().contains("ascending"));
1393    }
1394
1395    // ==========================================================================
1396    // Rate/Percentage Validation Tests
1397    // ==========================================================================
1398
1399    #[test]
1400    fn test_internal_controls_rates_valid() {
1401        let mut config = minimal_valid_config();
1402        config.internal_controls.enabled = true;
1403        config.internal_controls.exception_rate = 0.05;
1404        config.internal_controls.sod_violation_rate = 0.02;
1405        assert!(validate_config(&config).is_ok());
1406    }
1407
1408    #[test]
1409    fn test_internal_controls_exception_rate_invalid() {
1410        let mut config = minimal_valid_config();
1411        config.internal_controls.enabled = true;
1412        config.internal_controls.exception_rate = 1.5; // > 1.0
1413        let result = validate_config(&config);
1414        assert!(result.is_err());
1415        assert!(result.unwrap_err().to_string().contains("exception_rate"));
1416    }
1417
1418    #[test]
1419    fn test_approval_rejection_plus_revision_exceeds_one_rejected() {
1420        let mut config = minimal_valid_config();
1421        config.approval.enabled = true;
1422        config.approval.rejection_rate = 0.6;
1423        config.approval.revision_rate = 0.6; // Sum > 1.0
1424        let result = validate_config(&config);
1425        assert!(result.is_err());
1426        assert!(result
1427            .unwrap_err()
1428            .to_string()
1429            .contains("must not exceed 1.0"));
1430    }
1431
1432    #[test]
1433    fn test_master_data_intercompany_percent_invalid() {
1434        let mut config = minimal_valid_config();
1435        config.master_data.vendors.intercompany_percent = 1.5; // > 1.0
1436        let result = validate_config(&config);
1437        assert!(result.is_err());
1438        assert!(result
1439            .unwrap_err()
1440            .to_string()
1441            .contains("intercompany_percent"));
1442    }
1443
1444    #[test]
1445    fn test_balance_gross_margin_invalid() {
1446        let mut config = minimal_valid_config();
1447        config.balance.target_gross_margin = 1.5; // > 1.0
1448        let result = validate_config(&config);
1449        assert!(result.is_err());
1450        assert!(result
1451            .unwrap_err()
1452            .to_string()
1453            .contains("target_gross_margin"));
1454    }
1455
1456    #[test]
1457    fn test_negative_volume_weight_rejected() {
1458        let mut config = minimal_valid_config();
1459        config.companies[0].volume_weight = -0.5;
1460        let result = validate_config(&config);
1461        assert!(result.is_err());
1462        assert!(result.unwrap_err().to_string().contains("volume_weight"));
1463    }
1464
1465    #[test]
1466    fn test_benford_tolerance_invalid() {
1467        let mut config = minimal_valid_config();
1468        config.transactions.benford.tolerance = 1.5; // > 1.0
1469        let result = validate_config(&config);
1470        assert!(result.is_err());
1471        assert!(result
1472            .unwrap_err()
1473            .to_string()
1474            .contains("benford.tolerance"));
1475    }
1476
1477    #[test]
1478    fn test_batch_size_zero_rejected() {
1479        let mut config = minimal_valid_config();
1480        config.output.batch_size = 0;
1481        let result = validate_config(&config);
1482        assert!(result.is_err());
1483        assert!(result.unwrap_err().to_string().contains("batch_size"));
1484    }
1485}