Skip to main content

veritas_core/
config.rs

1use std::{fs, path::Path};
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use veritas_plugin_api::{FailureSeverity, RiskLevel};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct VeritasConfig {
9    pub budget_seconds: u64,
10    pub write_generated_tests: bool,
11    pub fail_on_generated_test_failure: bool,
12    pub fail_on_findings: bool,
13    pub planner: PlannerConfig,
14    pub policy: PolicyConfig,
15    pub plugins: PluginConfigs,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct PlannerConfig {
20    pub mode: PlannerMode,
21    pub command: Option<String>,
22    pub fail_on_error: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum PlannerMode {
28    Deterministic,
29    ExternalLlm,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct PluginConfigs {
34    pub rust: RustPluginConfig,
35    pub go: GoPluginConfig,
36    pub python: PythonPluginConfig,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct RustPluginConfig {
41    pub property_framework: String,
42    pub command_timeout_seconds: u64,
43    pub coverage_enabled: bool,
44    pub coverage_timeout_seconds: u64,
45    pub cargo_jobs: usize,
46    pub test_threads: usize,
47    pub systemd_scope: bool,
48    pub memory_max: Option<String>,
49    pub cpu_quota: Option<String>,
50    pub mutation: MutationConfig,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct GoPluginConfig {
55    pub fuzz_seconds: u64,
56    pub fuzz_existing: bool,
57    pub fuzz_concurrency: usize,
58    pub coverage_enabled: bool,
59    pub reverse_dependency_depth: usize,
60    pub max_fuzz_targets: usize,
61    pub command_timeout_seconds: u64,
62    pub max_packages: usize,
63    pub max_mutants: usize,
64    pub build_tags: Vec<String>,
65    pub mutation: MutationConfig,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct PythonPluginConfig {
70    pub command_timeout_seconds: u64,
71    pub coverage_enabled: bool,
72    pub mutation: MutationConfig,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76pub struct MutationConfig {
77    pub enabled_domains: Vec<String>,
78    pub disabled_domains: Vec<String>,
79    pub enabled_operators: Vec<String>,
80    pub disabled_operators: Vec<String>,
81    pub include_paths: Vec<String>,
82    pub exclude_paths: Vec<String>,
83    pub include_symbols: Vec<String>,
84    pub exclude_symbols: Vec<String>,
85    pub include_target_ids: Vec<String>,
86    pub exclude_target_ids: Vec<String>,
87    pub include_mutant_ids: Vec<String>,
88    pub exclude_mutant_ids: Vec<String>,
89    pub report_filtered: bool,
90    pub dry_run: bool,
91    pub max_mutants: Option<usize>,
92    pub disable_test_selection: bool,
93    pub baseline_timing: bool,
94    pub workers: usize,
95    pub test_cpu: Option<usize>,
96    pub timeout_coefficient: u64,
97    pub timeout_min_seconds: Option<u64>,
98    pub timeout_max_seconds: Option<u64>,
99    pub shard_index: Option<usize>,
100    pub shard_count: Option<usize>,
101    pub output_statuses: Vec<String>,
102    pub isolation_exclude_paths: Vec<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct PolicyConfig {
107    pub fail_on_severity: FailureSeverity,
108    pub fail_on_languages: Vec<String>,
109    pub fail_on_artifact_kinds: Vec<String>,
110    pub fail_on_target_risks: Vec<RiskLevel>,
111    pub min_mutation_score: Option<u8>,
112    pub min_mutation_efficacy: Option<u8>,
113    pub min_mutant_coverage: Option<u8>,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117struct ConfigFile {
118    veritas: Option<VeritasSection>,
119    planner: Option<PlannerSection>,
120    policy: Option<PolicySection>,
121    mutation: Option<MutationConfigPartial>,
122    plugins: Option<PluginSection>,
123}
124
125#[derive(Debug, Clone, Deserialize)]
126struct VeritasSection {
127    budget_seconds: Option<u64>,
128    write_generated_tests: Option<bool>,
129    fail_on_generated_test_failure: Option<bool>,
130    fail_on_findings: Option<bool>,
131}
132
133#[derive(Debug, Clone, Deserialize)]
134struct PlannerSection {
135    mode: Option<PlannerMode>,
136    command: Option<String>,
137    fail_on_error: Option<bool>,
138}
139
140#[derive(Debug, Clone, Deserialize)]
141struct PolicySection {
142    fail_on_severity: Option<FailureSeverity>,
143    fail_on_languages: Option<Vec<String>>,
144    fail_on_artifact_kinds: Option<Vec<String>>,
145    fail_on_target_risks: Option<Vec<RiskLevel>>,
146    min_mutation_score: Option<u8>,
147    min_mutation_efficacy: Option<u8>,
148    min_mutant_coverage: Option<u8>,
149}
150
151#[derive(Debug, Clone, Deserialize)]
152struct PluginSection {
153    rust: Option<RustPluginConfigPartial>,
154    go: Option<GoPluginConfigPartial>,
155    python: Option<PythonPluginConfigPartial>,
156}
157
158#[derive(Debug, Clone, Deserialize)]
159struct RustPluginConfigPartial {
160    property_framework: Option<String>,
161    command_timeout_seconds: Option<u64>,
162    coverage_enabled: Option<bool>,
163    coverage_timeout_seconds: Option<u64>,
164    cargo_jobs: Option<usize>,
165    test_threads: Option<usize>,
166    systemd_scope: Option<bool>,
167    memory_max: Option<String>,
168    cpu_quota: Option<String>,
169    mutation: Option<MutationConfigPartial>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
173struct GoPluginConfigPartial {
174    fuzz_seconds: Option<u64>,
175    fuzz_existing: Option<bool>,
176    fuzz_concurrency: Option<usize>,
177    coverage_enabled: Option<bool>,
178    reverse_dependency_depth: Option<usize>,
179    max_fuzz_targets: Option<usize>,
180    command_timeout_seconds: Option<u64>,
181    max_packages: Option<usize>,
182    max_mutants: Option<usize>,
183    build_tags: Option<Vec<String>>,
184    mutation: Option<MutationConfigPartial>,
185}
186
187#[derive(Debug, Clone, Deserialize)]
188struct PythonPluginConfigPartial {
189    command_timeout_seconds: Option<u64>,
190    coverage_enabled: Option<bool>,
191    mutation: Option<MutationConfigPartial>,
192}
193
194#[derive(Debug, Clone, Deserialize)]
195struct MutationConfigPartial {
196    enabled_domains: Option<Vec<String>>,
197    disabled_domains: Option<Vec<String>>,
198    enabled_operators: Option<Vec<String>>,
199    disabled_operators: Option<Vec<String>>,
200    include_paths: Option<Vec<String>>,
201    exclude_paths: Option<Vec<String>>,
202    include_symbols: Option<Vec<String>>,
203    exclude_symbols: Option<Vec<String>>,
204    include_target_ids: Option<Vec<String>>,
205    exclude_target_ids: Option<Vec<String>>,
206    include_mutant_ids: Option<Vec<String>>,
207    exclude_mutant_ids: Option<Vec<String>>,
208    report_filtered: Option<bool>,
209    dry_run: Option<bool>,
210    max_mutants: Option<usize>,
211    disable_test_selection: Option<bool>,
212    baseline_timing: Option<bool>,
213    workers: Option<usize>,
214    test_cpu: Option<usize>,
215    timeout_coefficient: Option<u64>,
216    timeout_min_seconds: Option<u64>,
217    timeout_max_seconds: Option<u64>,
218    shard_index: Option<usize>,
219    shard_count: Option<usize>,
220    output_statuses: Option<Vec<String>>,
221    isolation_exclude_paths: Option<Vec<String>>,
222}
223
224impl Default for VeritasConfig {
225    fn default() -> Self {
226        Self {
227            budget_seconds: 120,
228            write_generated_tests: true,
229            fail_on_generated_test_failure: true,
230            fail_on_findings: false,
231            planner: PlannerConfig {
232                mode: PlannerMode::Deterministic,
233                command: None,
234                fail_on_error: false,
235            },
236            policy: PolicyConfig {
237                fail_on_severity: FailureSeverity::Error,
238                fail_on_languages: Vec::new(),
239                fail_on_artifact_kinds: Vec::new(),
240                fail_on_target_risks: Vec::new(),
241                min_mutation_score: None,
242                min_mutation_efficacy: None,
243                min_mutant_coverage: None,
244            },
245            plugins: PluginConfigs {
246                rust: RustPluginConfig {
247                    property_framework: "proptest".to_string(),
248                    command_timeout_seconds: 120,
249                    coverage_enabled: false,
250                    coverage_timeout_seconds: 120,
251                    cargo_jobs: 1,
252                    test_threads: 1,
253                    systemd_scope: false,
254                    memory_max: None,
255                    cpu_quota: None,
256                    mutation: MutationConfig::default(),
257                },
258                go: GoPluginConfig {
259                    fuzz_seconds: 10,
260                    fuzz_existing: true,
261                    fuzz_concurrency: 2,
262                    coverage_enabled: true,
263                    reverse_dependency_depth: 1,
264                    max_fuzz_targets: 20,
265                    command_timeout_seconds: 120,
266                    max_packages: 64,
267                    max_mutants: 8,
268                    build_tags: Vec::new(),
269                    mutation: MutationConfig::default(),
270                },
271                python: PythonPluginConfig {
272                    command_timeout_seconds: 120,
273                    coverage_enabled: false,
274                    mutation: MutationConfig::default(),
275                },
276            },
277        }
278    }
279}
280
281impl VeritasConfig {
282    pub fn load(root: &Path) -> Result<Self> {
283        let mut config = Self::default();
284        let Some(path) = config_path(root) else {
285            return Ok(config);
286        };
287
288        let contents = fs::read_to_string(&path)
289            .with_context(|| format!("failed to read config {}", path.display()))?;
290        let parsed: ConfigFile = toml::from_str(&contents)
291            .with_context(|| format!("failed to parse config {}", path.display()))?;
292
293        if let Some(veritas) = parsed.veritas {
294            if let Some(value) = veritas.budget_seconds {
295                config.budget_seconds = value;
296            }
297            if let Some(value) = veritas.write_generated_tests {
298                config.write_generated_tests = value;
299            }
300            if let Some(value) = veritas.fail_on_generated_test_failure {
301                config.fail_on_generated_test_failure = value;
302            }
303            if let Some(value) = veritas.fail_on_findings {
304                config.fail_on_findings = value;
305            }
306        }
307
308        if let Some(planner) = parsed.planner {
309            if let Some(value) = planner.mode {
310                config.planner.mode = value;
311            }
312            if let Some(value) = planner.command {
313                config.planner.command = Some(value);
314            }
315            if let Some(value) = planner.fail_on_error {
316                config.planner.fail_on_error = value;
317            }
318        }
319
320        if let Some(policy) = parsed.policy {
321            if let Some(value) = policy.fail_on_severity {
322                config.policy.fail_on_severity = value;
323            }
324            if let Some(value) = policy.fail_on_languages {
325                config.policy.fail_on_languages = value;
326            }
327            if let Some(value) = policy.fail_on_artifact_kinds {
328                config.policy.fail_on_artifact_kinds = value;
329            }
330            if let Some(value) = policy.fail_on_target_risks {
331                config.policy.fail_on_target_risks = value;
332            }
333            if let Some(value) = policy.min_mutation_score {
334                config.policy.min_mutation_score = Some(value.min(100));
335            }
336            if let Some(value) = policy.min_mutation_efficacy {
337                config.policy.min_mutation_efficacy = Some(value.min(100));
338            }
339            if let Some(value) = policy.min_mutant_coverage {
340                config.policy.min_mutant_coverage = Some(value.min(100));
341            }
342        }
343
344        if let Some(mutation) = parsed.mutation {
345            apply_mutation_config(&mut config.plugins.rust.mutation, &mutation);
346            apply_mutation_config(&mut config.plugins.go.mutation, &mutation);
347            apply_mutation_config(&mut config.plugins.python.mutation, &mutation);
348        }
349
350        if let Some(plugins) = parsed.plugins {
351            if let Some(rust) = plugins.rust {
352                if let Some(value) = rust.property_framework {
353                    config.plugins.rust.property_framework = value;
354                }
355                if let Some(value) = rust.command_timeout_seconds {
356                    config.plugins.rust.command_timeout_seconds = value;
357                }
358                if let Some(value) = rust.coverage_enabled {
359                    config.plugins.rust.coverage_enabled = value;
360                }
361                if let Some(value) = rust.coverage_timeout_seconds {
362                    config.plugins.rust.coverage_timeout_seconds = value;
363                }
364                if let Some(value) = rust.cargo_jobs {
365                    config.plugins.rust.cargo_jobs = value;
366                }
367                if let Some(value) = rust.test_threads {
368                    config.plugins.rust.test_threads = value;
369                }
370                if let Some(value) = rust.systemd_scope {
371                    config.plugins.rust.systemd_scope = value;
372                }
373                if let Some(value) = rust.memory_max {
374                    config.plugins.rust.memory_max = Some(value);
375                }
376                if let Some(value) = rust.cpu_quota {
377                    config.plugins.rust.cpu_quota = Some(value);
378                }
379                if let Some(value) = rust.mutation {
380                    apply_mutation_config(&mut config.plugins.rust.mutation, &value);
381                }
382            }
383            if let Some(go) = plugins.go {
384                if let Some(value) = go.fuzz_seconds {
385                    config.plugins.go.fuzz_seconds = value;
386                }
387                if let Some(value) = go.fuzz_existing {
388                    config.plugins.go.fuzz_existing = value;
389                }
390                if let Some(value) = go.fuzz_concurrency {
391                    config.plugins.go.fuzz_concurrency = value.max(1);
392                }
393                if let Some(value) = go.coverage_enabled {
394                    config.plugins.go.coverage_enabled = value;
395                }
396                if let Some(value) = go.reverse_dependency_depth {
397                    config.plugins.go.reverse_dependency_depth = value;
398                }
399                if let Some(value) = go.max_fuzz_targets {
400                    config.plugins.go.max_fuzz_targets = value;
401                }
402                if let Some(value) = go.command_timeout_seconds {
403                    config.plugins.go.command_timeout_seconds = value;
404                }
405                if let Some(value) = go.max_packages {
406                    config.plugins.go.max_packages = value;
407                }
408                if let Some(value) = go.max_mutants {
409                    config.plugins.go.max_mutants = value;
410                }
411                if let Some(value) = go.build_tags {
412                    config.plugins.go.build_tags = value;
413                }
414                if let Some(value) = go.mutation {
415                    apply_mutation_config(&mut config.plugins.go.mutation, &value);
416                }
417            }
418            if let Some(python) = plugins.python {
419                if let Some(value) = python.command_timeout_seconds {
420                    config.plugins.python.command_timeout_seconds = value;
421                }
422                if let Some(value) = python.coverage_enabled {
423                    config.plugins.python.coverage_enabled = value;
424                }
425                if let Some(value) = python.mutation {
426                    apply_mutation_config(&mut config.plugins.python.mutation, &value);
427                }
428            }
429        }
430
431        validate_shard_config("plugins.rust.mutation", &config.plugins.rust.mutation)?;
432        validate_shard_config("plugins.go.mutation", &config.plugins.go.mutation)?;
433        validate_shard_config("plugins.python.mutation", &config.plugins.python.mutation)?;
434
435        Ok(config)
436    }
437}
438
439fn validate_shard_config(label: &str, mutation: &MutationConfig) -> Result<()> {
440    let Some(shard_count) = mutation.shard_count else {
441        if mutation.shard_index.is_some() {
442            bail!("{label}.shard_index requires {label}.shard_count");
443        }
444        return Ok(());
445    };
446    if shard_count == 0 {
447        bail!("{label}.shard_count must be greater than zero");
448    }
449    if let Some(shard_index) = mutation.shard_index {
450        if shard_index >= shard_count {
451            bail!(
452                "{label}.shard_index {shard_index} must be less than {label}.shard_count {shard_count}"
453            );
454        }
455    }
456    Ok(())
457}
458
459fn apply_mutation_config(config: &mut MutationConfig, partial: &MutationConfigPartial) {
460    if let Some(value) = &partial.enabled_domains {
461        config.enabled_domains = value.clone();
462    }
463    if let Some(value) = &partial.disabled_domains {
464        config.disabled_domains = value.clone();
465    }
466    if let Some(value) = &partial.enabled_operators {
467        config.enabled_operators = value.clone();
468    }
469    if let Some(value) = &partial.disabled_operators {
470        config.disabled_operators = value.clone();
471    }
472    if let Some(value) = &partial.include_paths {
473        config.include_paths = value.clone();
474    }
475    if let Some(value) = &partial.exclude_paths {
476        config.exclude_paths = value.clone();
477    }
478    if let Some(value) = &partial.include_symbols {
479        config.include_symbols = value.clone();
480    }
481    if let Some(value) = &partial.exclude_symbols {
482        config.exclude_symbols = value.clone();
483    }
484    if let Some(value) = &partial.include_target_ids {
485        config.include_target_ids = value.clone();
486    }
487    if let Some(value) = &partial.exclude_target_ids {
488        config.exclude_target_ids = value.clone();
489    }
490    if let Some(value) = &partial.include_mutant_ids {
491        config.include_mutant_ids = value.clone();
492    }
493    if let Some(value) = &partial.exclude_mutant_ids {
494        config.exclude_mutant_ids = value.clone();
495    }
496    if let Some(value) = partial.report_filtered {
497        config.report_filtered = value;
498    }
499    if let Some(value) = partial.dry_run {
500        config.dry_run = value;
501    }
502    if let Some(value) = partial.max_mutants {
503        config.max_mutants = Some(value.max(1));
504    }
505    if let Some(value) = partial.disable_test_selection {
506        config.disable_test_selection = value;
507    }
508    if let Some(value) = partial.baseline_timing {
509        config.baseline_timing = value;
510    }
511    if let Some(value) = partial.workers {
512        config.workers = value;
513    }
514    if let Some(value) = partial.test_cpu {
515        config.test_cpu = Some(value.max(1));
516    }
517    if let Some(value) = partial.timeout_coefficient {
518        config.timeout_coefficient = value;
519    }
520    if let Some(value) = partial.timeout_min_seconds {
521        config.timeout_min_seconds = Some(value);
522    }
523    if let Some(value) = partial.timeout_max_seconds {
524        config.timeout_max_seconds = Some(value);
525    }
526    if let Some(value) = partial.shard_index {
527        config.shard_index = Some(value);
528    }
529    if let Some(value) = partial.shard_count {
530        config.shard_count = Some(value);
531    }
532    if let Some(value) = &partial.output_statuses {
533        config.output_statuses = value.clone();
534    }
535    if let Some(value) = &partial.isolation_exclude_paths {
536        config.isolation_exclude_paths = value.clone();
537    }
538}
539
540fn config_path(root: &Path) -> Option<std::path::PathBuf> {
541    let candidates = [root.join("veritas.toml"), root.join(".veritas.toml")];
542    candidates.into_iter().find(|candidate| candidate.exists())
543}
544
545#[cfg(test)]
546mod tests {
547    use std::{
548        fs,
549        path::{Path, PathBuf},
550        process,
551        time::{SystemTime, UNIX_EPOCH},
552    };
553
554    use veritas_plugin_api::{FailureSeverity, RiskLevel};
555
556    use super::VeritasConfig;
557
558    #[test]
559    fn loads_go_production_and_policy_config() {
560        let root = TempRoot::new();
561        fs::write(
562            root.path().join("veritas.toml"),
563            r#"
564[policy]
565fail_on_severity = "warning"
566fail_on_languages = ["go"]
567fail_on_artifact_kinds = ["mutation_check"]
568fail_on_target_risks = ["high"]
569min_mutation_score = 70
570min_mutation_efficacy = 75
571min_mutant_coverage = 80
572
573[mutation]
574disabled_operators = ["loop"]
575exclude_paths = ["vendor/", "_generated.go$"]
576include_target_ids = ["exact:rust:src/lib.rs:parse"]
577exclude_target_ids = ["regex:^rust:vendor/"]
578report_filtered = true
579dry_run = true
580max_mutants = 17
581disable_test_selection = true
582baseline_timing = true
583workers = 4
584test_cpu = 2
585timeout_coefficient = 3
586output_statuses = ["lived", "timed_out"]
587
588[plugins.go]
589fuzz_seconds = 3
590fuzz_existing = false
591fuzz_concurrency = 3
592coverage_enabled = false
593reverse_dependency_depth = 2
594max_fuzz_targets = 4
595command_timeout_seconds = 9
596max_packages = 12
597max_mutants = 5
598build_tags = ["integration", "sqlite"]
599
600[plugins.go.mutation]
601enabled_operators = ["arithmetic", "comparison"]
602dry_run = false
603
604[plugins.rust]
605property_framework = "proptest"
606command_timeout_seconds = 33
607coverage_enabled = true
608coverage_timeout_seconds = 44
609cargo_jobs = 2
610test_threads = 3
611systemd_scope = true
612memory_max = "4G"
613cpu_quota = "150%"
614"#,
615        )
616        .expect("write config");
617
618        let config = VeritasConfig::load(root.path()).expect("load config");
619
620        assert_eq!(config.policy.fail_on_severity, FailureSeverity::Warning);
621        assert_eq!(config.policy.fail_on_languages, vec!["go"]);
622        assert_eq!(config.policy.fail_on_artifact_kinds, vec!["mutation_check"]);
623        assert_eq!(config.policy.fail_on_target_risks, vec![RiskLevel::High]);
624        assert_eq!(config.policy.min_mutation_score, Some(70));
625        assert_eq!(config.policy.min_mutation_efficacy, Some(75));
626        assert_eq!(config.policy.min_mutant_coverage, Some(80));
627        assert_eq!(
628            config.plugins.rust.mutation.disabled_operators,
629            vec!["loop"]
630        );
631        assert!(config.plugins.rust.mutation.dry_run);
632        assert_eq!(config.plugins.rust.mutation.max_mutants, Some(17));
633        assert!(config.plugins.rust.mutation.disable_test_selection);
634        assert!(config.plugins.rust.mutation.baseline_timing);
635        assert_eq!(
636            config.plugins.rust.mutation.include_target_ids,
637            vec!["exact:rust:src/lib.rs:parse"]
638        );
639        assert_eq!(
640            config.plugins.rust.mutation.exclude_target_ids,
641            vec!["regex:^rust:vendor/"]
642        );
643        assert!(config.plugins.rust.mutation.report_filtered);
644        assert_eq!(config.plugins.rust.mutation.test_cpu, Some(2));
645        assert_eq!(config.plugins.rust.mutation.timeout_coefficient, 3);
646        assert_eq!(
647            config.plugins.go.mutation.enabled_operators,
648            vec!["arithmetic", "comparison"]
649        );
650        assert_eq!(
651            config.plugins.go.mutation.exclude_paths,
652            vec!["vendor/", "_generated.go$"]
653        );
654        assert_eq!(
655            config.plugins.python.mutation.exclude_paths,
656            vec!["vendor/", "_generated.go$"]
657        );
658        assert_eq!(config.plugins.python.mutation.max_mutants, Some(17));
659        assert!(!config.plugins.go.mutation.dry_run);
660        assert_eq!(config.plugins.go.fuzz_seconds, 3);
661        assert!(!config.plugins.go.fuzz_existing);
662        assert_eq!(config.plugins.go.fuzz_concurrency, 3);
663        assert!(!config.plugins.go.coverage_enabled);
664        assert_eq!(config.plugins.go.reverse_dependency_depth, 2);
665        assert_eq!(config.plugins.go.max_fuzz_targets, 4);
666        assert_eq!(config.plugins.go.command_timeout_seconds, 9);
667        assert_eq!(config.plugins.go.max_packages, 12);
668        assert_eq!(config.plugins.go.max_mutants, 5);
669        assert_eq!(config.plugins.go.build_tags, vec!["integration", "sqlite"]);
670        assert_eq!(config.plugins.rust.command_timeout_seconds, 33);
671        assert!(config.plugins.rust.coverage_enabled);
672        assert_eq!(config.plugins.rust.coverage_timeout_seconds, 44);
673        assert_eq!(config.plugins.rust.cargo_jobs, 2);
674        assert_eq!(config.plugins.rust.test_threads, 3);
675        assert!(config.plugins.rust.systemd_scope);
676        assert_eq!(config.plugins.rust.memory_max.as_deref(), Some("4G"));
677        assert_eq!(config.plugins.rust.cpu_quota.as_deref(), Some("150%"));
678    }
679
680    #[test]
681    fn rejects_misconfigured_mutation_shards() {
682        let root = TempRoot::new();
683        fs::write(
684            root.path().join("veritas.toml"),
685            r#"
686[plugins.rust.mutation]
687shard_index = 2
688shard_count = 2
689"#,
690        )
691        .expect("write config");
692
693        let error = VeritasConfig::load(root.path()).expect_err("invalid shard config");
694        assert!(error.to_string().contains(
695            "plugins.rust.mutation.shard_index 2 must be less than plugins.rust.mutation.shard_count 2"
696        ));
697    }
698
699    struct TempRoot {
700        path: PathBuf,
701    }
702
703    impl TempRoot {
704        fn new() -> Self {
705            let nanos = SystemTime::now()
706                .duration_since(UNIX_EPOCH)
707                .expect("system time should be after UNIX_EPOCH")
708                .as_nanos();
709            let path =
710                std::env::temp_dir().join(format!("veritas-config-test-{}-{nanos}", process::id()));
711            fs::create_dir_all(&path).expect("create temp root");
712            Self { path }
713        }
714
715        fn path(&self) -> &Path {
716            &self.path
717        }
718    }
719
720    impl Drop for TempRoot {
721        fn drop(&mut self) {
722            let _ = fs::remove_dir_all(&self.path);
723        }
724    }
725}