Skip to main content

zeph_config/
loader.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10    /// Load configuration from a TOML file with env var overrides.
11    ///
12    /// Falls back to sensible defaults when the file does not exist.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the file exists but cannot be read or parsed.
17    pub fn load(path: &Path) -> Result<Self, ConfigError> {
18        let mut config = if path.exists() {
19            let content = std::fs::read_to_string(path)?;
20            toml::from_str::<Self>(&content)?
21        } else {
22            Self::default()
23        };
24
25        config.apply_env_overrides();
26        config.normalize_legacy_runtime_defaults();
27        Ok(config)
28    }
29
30    /// Serialize the default configuration to a TOML string.
31    ///
32    /// Produces a pretty-printed TOML representation of [`Config::default()`].
33    /// Useful for bootstrapping a new config file or documenting available options.
34    ///
35    /// The `secrets` field is always excluded from the output because it is
36    /// populated at runtime only and must never be written to disk.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if serialization fails (unlikely — the default value is
41    /// always structurally valid).
42    ///
43    /// # Examples
44    ///
45    /// ```no_run
46    /// use zeph_config::Config;
47    ///
48    /// let toml = Config::dump_defaults().expect("serialization failed");
49    /// assert!(toml.contains("[agent]"));
50    /// assert!(toml.contains("[memory]"));
51    /// ```
52    pub fn dump_defaults() -> Result<String, crate::error::ConfigError> {
53        let defaults = Self::default();
54        toml::to_string_pretty(&defaults).map_err(|e| {
55            crate::error::ConfigError::Validation(format!("failed to serialize defaults: {e}"))
56        })
57    }
58
59    /// Validate configuration values are within sane bounds.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if any value is out of range.
64    pub fn validate(&self) -> Result<(), ConfigError> {
65        self.validate_scalar_bounds()?;
66        self.validate_memory_compression()?;
67        self.validate_memory_probe_and_graph()?;
68        self.validate_mcp_servers()?;
69        self.experiments
70            .validate()
71            .map_err(ConfigError::Validation)?;
72        if self.orchestration.plan_cache.enabled {
73            self.orchestration
74                .plan_cache
75                .validate()
76                .map_err(ConfigError::Validation)?;
77        }
78        self.validate_orchestration()?;
79        self.validate_focus_and_sidequest()?;
80        self.validate_llm_and_skills()?;
81        self.validate_provider_names()?;
82        self.validate_mcp_misc()?;
83        Ok(())
84    }
85
86    /// Validate scalar bounds for memory, agent, a2a, and gateway fields.
87    fn validate_scalar_bounds(&self) -> Result<(), ConfigError> {
88        if self.memory.history_limit > 10_000 {
89            return Err(ConfigError::Validation(format!(
90                "history_limit must be <= 10000, got {}",
91                self.memory.history_limit
92            )));
93        }
94        if self.memory.context_budget_tokens > 1_000_000 {
95            return Err(ConfigError::Validation(format!(
96                "context_budget_tokens must be <= 1000000, got {}",
97                self.memory.context_budget_tokens
98            )));
99        }
100        if self.agent.max_tool_iterations > 100 {
101            return Err(ConfigError::Validation(format!(
102                "max_tool_iterations must be <= 100, got {}",
103                self.agent.max_tool_iterations
104            )));
105        }
106        if self.a2a.rate_limit == 0 {
107            return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
108        }
109        if self.gateway.rate_limit == 0 {
110            return Err(ConfigError::Validation(
111                "gateway.rate_limit must be > 0".into(),
112            ));
113        }
114        if self.gateway.max_body_size > 10_485_760 {
115            return Err(ConfigError::Validation(format!(
116                "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
117                self.gateway.max_body_size
118            )));
119        }
120        if self.memory.token_safety_margin <= 0.0 {
121            return Err(ConfigError::Validation(format!(
122                "token_safety_margin must be > 0.0, got {}",
123                self.memory.token_safety_margin
124            )));
125        }
126        if self.memory.tool_call_cutoff == 0 {
127            return Err(ConfigError::Validation(
128                "tool_call_cutoff must be >= 1".into(),
129            ));
130        }
131        Ok(())
132    }
133
134    /// Validate memory compression strategy bounds and compaction thresholds.
135    fn validate_memory_compression(&self) -> Result<(), ConfigError> {
136        if let crate::memory::CompressionStrategy::Proactive {
137            threshold_tokens,
138            max_summary_tokens,
139        } = &self.memory.compression.strategy
140        {
141            if *threshold_tokens < 1_000 {
142                return Err(ConfigError::Validation(format!(
143                    "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
144                )));
145            }
146            if *max_summary_tokens < 128 {
147                return Err(ConfigError::Validation(format!(
148                    "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
149                )));
150            }
151        }
152        if !self.memory.soft_compaction_threshold.is_finite()
153            || self.memory.soft_compaction_threshold <= 0.0
154            || self.memory.soft_compaction_threshold >= 1.0
155        {
156            return Err(ConfigError::Validation(format!(
157                "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
158                self.memory.soft_compaction_threshold
159            )));
160        }
161        if !self.memory.hard_compaction_threshold.is_finite()
162            || self.memory.hard_compaction_threshold <= 0.0
163            || self.memory.hard_compaction_threshold >= 1.0
164        {
165            return Err(ConfigError::Validation(format!(
166                "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
167                self.memory.hard_compaction_threshold
168            )));
169        }
170        if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
171            return Err(ConfigError::Validation(format!(
172                "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
173                self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
174            )));
175        }
176        Ok(())
177    }
178
179    /// Validate memory probe thresholds and graph temporal decay rate.
180    fn validate_memory_probe_and_graph(&self) -> Result<(), ConfigError> {
181        if self.memory.graph.temporal_decay_rate < 0.0
182            || self.memory.graph.temporal_decay_rate > 10.0
183        {
184            return Err(ConfigError::Validation(format!(
185                "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
186                self.memory.graph.temporal_decay_rate
187            )));
188        }
189        if self.memory.compression.probe.enabled {
190            let probe = &self.memory.compression.probe;
191            if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
192                return Err(ConfigError::Validation(format!(
193                    "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
194                    probe.threshold
195                )));
196            }
197            if !probe.hard_fail_threshold.is_finite()
198                || probe.hard_fail_threshold < 0.0
199                || probe.hard_fail_threshold >= 1.0
200            {
201                return Err(ConfigError::Validation(format!(
202                    "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
203                    probe.hard_fail_threshold
204                )));
205            }
206            if probe.hard_fail_threshold >= probe.threshold {
207                return Err(ConfigError::Validation(format!(
208                    "memory.compression.probe.hard_fail_threshold ({}) must be less than \
209                     memory.compression.probe.threshold ({})",
210                    probe.hard_fail_threshold, probe.threshold
211                )));
212            }
213            if probe.max_questions < 1 {
214                return Err(ConfigError::Validation(
215                    "memory.compression.probe.max_questions must be >= 1".into(),
216                ));
217            }
218            if probe.timeout_secs < 1 {
219                return Err(ConfigError::Validation(
220                    "memory.compression.probe.timeout_secs must be >= 1".into(),
221                ));
222            }
223        }
224        Ok(())
225    }
226
227    /// Validate MCP server entries for header/oauth exclusivity and vault key uniqueness.
228    fn validate_mcp_servers(&self) -> Result<(), ConfigError> {
229        use std::collections::HashSet;
230        let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
231        for s in &self.mcp.servers {
232            // headers and oauth are mutually exclusive
233            if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
234                return Err(ConfigError::Validation(format!(
235                    "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
236                    s.id
237                )));
238            }
239            // vault key collision detection
240            if s.oauth.as_ref().is_some_and(|o| o.enabled) {
241                let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
242                if !seen_oauth_vault_keys.insert(key.clone()) {
243                    return Err(ConfigError::Validation(format!(
244                        "MCP server '{}' has vault key collision ('{key}'): another server \
245                         with the same normalized ID already uses this key",
246                        s.id
247                    )));
248                }
249            }
250        }
251        Ok(())
252    }
253
254    /// Validate orchestration thresholds and cascade settings.
255    fn validate_orchestration(&self) -> Result<(), ConfigError> {
256        if self.orchestration.max_parallel == 0 {
257            return Err(ConfigError::Validation(
258                "orchestration.max_parallel must be > 0".into(),
259            ));
260        }
261        if self.orchestration.max_tasks == 0 {
262            return Err(ConfigError::Validation(
263                "orchestration.max_tasks must be > 0".into(),
264            ));
265        }
266        let ct = self.orchestration.completeness_threshold;
267        if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
268            return Err(ConfigError::Validation(format!(
269                "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
270            )));
271        }
272        // Cascade chain threshold must not be 1 — that would abort on every single failure.
273        if self.orchestration.cascade_chain_threshold == 1 {
274            return Err(ConfigError::Validation(
275                "orchestration.cascade_chain_threshold=1 aborts on every failure; \
276                 use 0 to disable linear-chain cascade abort instead"
277                    .into(),
278            ));
279        }
280        let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
281        if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
282            return Err(ConfigError::Validation(format!(
283                "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
284            )));
285        }
286        if self.orchestration.lineage_ttl_secs == 0 {
287            return Err(ConfigError::Validation(
288                "orchestration.lineage_ttl_secs must be > 0; \
289                 set cascade_chain_threshold=0 to disable lineage tracking instead"
290                    .into(),
291            ));
292        }
293        if self.orchestration.aggregator_timeout_secs == 0 {
294            return Err(ConfigError::Validation(
295                "orchestration.aggregator_timeout_secs must be > 0".into(),
296            ));
297        }
298        if self.orchestration.planner_timeout_secs == 0 {
299            return Err(ConfigError::Validation(
300                "orchestration.planner_timeout_secs must be > 0".into(),
301            ));
302        }
303        if self.orchestration.verifier_timeout_secs == 0 {
304            return Err(ConfigError::Validation(
305                "orchestration.verifier_timeout_secs must be > 0".into(),
306            ));
307        }
308        Ok(())
309    }
310
311    /// Validate focus and sidequest interval and ratio constraints.
312    fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
313        if self.agent.focus.compression_interval == 0 {
314            return Err(ConfigError::Validation(
315                "agent.focus.compression_interval must be >= 1".into(),
316            ));
317        }
318        if self.agent.focus.min_messages_per_focus == 0 {
319            return Err(ConfigError::Validation(
320                "agent.focus.min_messages_per_focus must be >= 1".into(),
321            ));
322        }
323        if self.agent.focus.auto_consolidate_min_window == 0 {
324            return Err(ConfigError::Validation(
325                "agent.focus.auto_consolidate_min_window must be >= 1 \
326                 (set focus.enabled = false to disable auto-consolidation)"
327                    .into(),
328            ));
329        }
330        if self.memory.sidequest.interval_turns == 0 {
331            return Err(ConfigError::Validation(
332                "memory.sidequest.interval_turns must be >= 1".into(),
333            ));
334        }
335        if !self.memory.sidequest.max_eviction_ratio.is_finite()
336            || self.memory.sidequest.max_eviction_ratio <= 0.0
337            || self.memory.sidequest.max_eviction_ratio > 1.0
338        {
339            return Err(ConfigError::Validation(format!(
340                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
341                self.memory.sidequest.max_eviction_ratio
342            )));
343        }
344        Ok(())
345    }
346
347    /// Validate LLM semantic cache threshold and skill evaluation weight sum.
348    fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
349        let sct = self.llm.semantic_cache_threshold;
350        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
351            return Err(ConfigError::Validation(format!(
352                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
353                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
354            )));
355        }
356        // MemCoT distill provider fast-tier soft-warn (#3574).
357        if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
358            self.llm.warn_non_fast_tier_provider(
359                &self.memory.memcot.distill_provider,
360                "memory.memcot.distill_provider",
361                &self.memory.memcot.fast_tier_models,
362            );
363        }
364        self.skills
365            .learning
366            .validate()
367            .map_err(ConfigError::Validation)?;
368        // Skill evaluation weight-sum validation (#3319).
369        if self.skills.evaluation.enabled {
370            let weight_sum = self.skills.evaluation.weight_correctness
371                + self.skills.evaluation.weight_reusability
372                + self.skills.evaluation.weight_specificity;
373            if (weight_sum - 1.0_f32).abs() > 1e-3 {
374                return Err(ConfigError::Validation(format!(
375                    "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
376                )));
377            }
378        }
379        Ok(())
380    }
381
382    /// Validate miscellaneous MCP output schema hint size.
383    fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
384        if self.mcp.output_schema_hint_bytes < 64 {
385            return Err(ConfigError::Validation(format!(
386                "mcp.output_schema_hint_bytes must be >= 64, got {}; \
387                 use forward_output_schema = false to disable forwarding",
388                self.mcp.output_schema_hint_bytes
389            )));
390        }
391        Ok(())
392    }
393
394    fn validate_provider_names(&self) -> Result<(), ConfigError> {
395        let known = self.known_provider_names();
396        self.validate_named_provider_refs(&known)?;
397        self.validate_optional_provider_refs(&known)?;
398        Ok(())
399    }
400
401    /// Build the set of declared provider names from all `[[llm.providers]]` entries.
402    fn known_provider_names(&self) -> std::collections::HashSet<String> {
403        self.llm
404            .providers
405            .iter()
406            .map(super::providers::ProviderEntry::effective_name)
407            .collect()
408    }
409
410    /// Validate every required `*_provider` field references a declared provider.
411    ///
412    /// The field table lists all subsystem provider references. Each non-empty value must
413    /// match a name in `known`.
414    fn validate_named_provider_refs(
415        &self,
416        known: &std::collections::HashSet<String>,
417    ) -> Result<(), ConfigError> {
418        self.validate_core_provider_refs(known)?;
419        self.validate_tool_and_quality_provider_refs(known)
420    }
421
422    fn validate_core_provider_refs(
423        &self,
424        known: &std::collections::HashSet<String>,
425    ) -> Result<(), ConfigError> {
426        let fields: &[(&str, &crate::providers::ProviderName)] = &[
427            (
428                "memory.tiers.scene_provider",
429                &self.memory.tiers.scene_provider,
430            ),
431            (
432                "memory.compression.compress_provider",
433                &self.memory.compression.compress_provider,
434            ),
435            (
436                "memory.consolidation.consolidation_provider",
437                &self.memory.consolidation.consolidation_provider,
438            ),
439            (
440                "memory.admission.admission_provider",
441                &self.memory.admission.admission_provider,
442            ),
443            (
444                "memory.admission.goal_utility_provider",
445                &self.memory.admission.goal_utility_provider,
446            ),
447            (
448                "memory.store_routing.routing_classifier_provider",
449                &self.memory.store_routing.routing_classifier_provider,
450            ),
451            (
452                "skills.learning.feedback_provider",
453                &self.skills.learning.feedback_provider,
454            ),
455            (
456                "skills.learning.arise_trace_provider",
457                &self.skills.learning.arise_trace_provider,
458            ),
459            (
460                "skills.learning.stem_provider",
461                &self.skills.learning.stem_provider,
462            ),
463            (
464                "skills.learning.erl_extract_provider",
465                &self.skills.learning.erl_extract_provider,
466            ),
467            (
468                "mcp.pruning.pruning_provider",
469                &self.mcp.pruning.pruning_provider,
470            ),
471            (
472                "mcp.tool_discovery.embedding_provider",
473                &self.mcp.tool_discovery.embedding_provider,
474            ),
475            (
476                "security.response_verification.verifier_provider",
477                &self.security.response_verification.verifier_provider,
478            ),
479            (
480                "orchestration.planner_provider",
481                &self.orchestration.planner_provider,
482            ),
483            (
484                "orchestration.verify_provider",
485                &self.orchestration.verify_provider,
486            ),
487            (
488                "orchestration.tool_provider",
489                &self.orchestration.tool_provider,
490            ),
491            (
492                "skills.evaluation.provider",
493                &self.skills.evaluation.provider,
494            ),
495            (
496                "skills.proactive_exploration.provider",
497                &self.skills.proactive_exploration.provider,
498            ),
499            (
500                "memory.compression_spectrum.promotion_provider",
501                &self.memory.compression_spectrum.promotion_provider,
502            ),
503        ];
504        Self::check_provider_refs(fields, known)
505    }
506
507    fn validate_tool_and_quality_provider_refs(
508        &self,
509        known: &std::collections::HashSet<String>,
510    ) -> Result<(), ConfigError> {
511        let fields: &[(&str, &crate::providers::ProviderName)] = &[
512            (
513                "security.shadow_sentinel.probe_provider",
514                &self.security.shadow_sentinel.probe_provider,
515            ),
516            (
517                "tools.retry.parameter_reformat_provider",
518                &self.tools.retry.parameter_reformat_provider,
519            ),
520            (
521                "tools.adversarial_policy.policy_provider",
522                &self.tools.adversarial_policy.policy_provider,
523            ),
524            (
525                "tools.speculative.pattern.rerank_provider",
526                &self.tools.speculative.pattern.rerank_provider,
527            ),
528            (
529                "tools.compression.evolution_provider",
530                &self.tools.compression.evolution_provider,
531            ),
532            ("quality.proposer_provider", &self.quality.proposer_provider),
533            ("quality.checker_provider", &self.quality.checker_provider),
534        ];
535        Self::check_provider_refs(fields, known)
536    }
537
538    fn check_provider_refs(
539        fields: &[(&str, &crate::providers::ProviderName)],
540        known: &std::collections::HashSet<String>,
541    ) -> Result<(), ConfigError> {
542        for (field, name) in fields {
543            if !name.is_empty() && !known.contains(name.as_str()) {
544                return Err(ConfigError::Validation(format!(
545                    "{field} = {:?} does not match any [[llm.providers]] entry",
546                    name.as_str()
547                )));
548            }
549        }
550        Ok(())
551    }
552
553    /// Validate optional provider references in complexity routing and router bandit config.
554    fn validate_optional_provider_refs(
555        &self,
556        known: &std::collections::HashSet<String>,
557    ) -> Result<(), ConfigError> {
558        if let Some(triage) = self
559            .llm
560            .complexity_routing
561            .as_ref()
562            .and_then(|cr| cr.triage_provider.as_ref())
563            .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
564        {
565            return Err(ConfigError::Validation(format!(
566                "llm.complexity_routing.triage_provider = {:?} does not match any \
567                 [[llm.providers]] entry",
568                triage.as_str()
569            )));
570        }
571
572        if let Some(embed) = self
573            .llm
574            .router
575            .as_ref()
576            .and_then(|r| r.bandit.as_ref())
577            .map(|b| &b.embedding_provider)
578            .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
579        {
580            return Err(ConfigError::Validation(format!(
581                "llm.router.bandit.embedding_provider = {:?} does not match any \
582                 [[llm.providers]] entry",
583                embed.as_str()
584            )));
585        }
586
587        Ok(())
588    }
589
590    fn normalize_legacy_runtime_defaults(&mut self) {
591        use crate::defaults::{
592            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
593            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
594            is_legacy_default_sqlite_path,
595        };
596
597        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
598            self.memory.sqlite_path = default_sqlite_path();
599        }
600
601        for skill_path in &mut self.skills.paths {
602            if is_legacy_default_skills_path(skill_path) {
603                *skill_path = default_skills_dir();
604            }
605        }
606
607        if is_legacy_default_debug_dir(&self.debug.output_dir) {
608            self.debug.output_dir = default_debug_dir();
609        }
610
611        if is_legacy_default_log_file(&self.logging.file) {
612            self.logging.file = default_log_file_path();
613        }
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    fn config_with_sct(threshold: f32) -> Config {
622        let mut cfg = Config::default();
623        cfg.llm.semantic_cache_threshold = threshold;
624        cfg
625    }
626
627    #[test]
628    fn semantic_cache_threshold_valid_zero() {
629        assert!(config_with_sct(0.0).validate().is_ok());
630    }
631
632    #[test]
633    fn semantic_cache_threshold_valid_mid() {
634        assert!(config_with_sct(0.5).validate().is_ok());
635    }
636
637    #[test]
638    fn semantic_cache_threshold_valid_one() {
639        assert!(config_with_sct(1.0).validate().is_ok());
640    }
641
642    #[test]
643    fn semantic_cache_threshold_invalid_negative() {
644        let err = config_with_sct(-0.1).validate().unwrap_err();
645        assert!(
646            err.to_string().contains("semantic_cache_threshold"),
647            "unexpected error: {err}"
648        );
649    }
650
651    #[test]
652    fn semantic_cache_threshold_invalid_above_one() {
653        let err = config_with_sct(1.1).validate().unwrap_err();
654        assert!(
655            err.to_string().contains("semantic_cache_threshold"),
656            "unexpected error: {err}"
657        );
658    }
659
660    #[test]
661    fn semantic_cache_threshold_invalid_nan() {
662        let err = config_with_sct(f32::NAN).validate().unwrap_err();
663        assert!(
664            err.to_string().contains("semantic_cache_threshold"),
665            "unexpected error: {err}"
666        );
667    }
668
669    #[test]
670    fn semantic_cache_threshold_invalid_infinity() {
671        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
672        assert!(
673            err.to_string().contains("semantic_cache_threshold"),
674            "unexpected error: {err}"
675        );
676    }
677
678    #[test]
679    fn semantic_cache_threshold_invalid_neg_infinity() {
680        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
681        assert!(
682            err.to_string().contains("semantic_cache_threshold"),
683            "unexpected error: {err}"
684        );
685    }
686
687    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
688        let mut cfg = Config::default();
689        cfg.memory.compression.probe.enabled = enabled;
690        cfg.memory.compression.probe.threshold = threshold;
691        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
692        cfg
693    }
694
695    #[test]
696    fn probe_disabled_skips_validation() {
697        // Invalid thresholds when probe is disabled must not cause errors.
698        let cfg = probe_config(false, 0.0, 1.0);
699        assert!(cfg.validate().is_ok());
700    }
701
702    #[test]
703    fn probe_valid_thresholds() {
704        let cfg = probe_config(true, 0.6, 0.35);
705        assert!(cfg.validate().is_ok());
706    }
707
708    #[test]
709    fn probe_threshold_zero_invalid() {
710        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
711        assert!(
712            err.to_string().contains("probe.threshold"),
713            "unexpected error: {err}"
714        );
715    }
716
717    #[test]
718    fn probe_hard_fail_threshold_above_one_invalid() {
719        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
720        assert!(
721            err.to_string().contains("probe.hard_fail_threshold"),
722            "unexpected error: {err}"
723        );
724    }
725
726    #[test]
727    fn probe_hard_fail_gte_threshold_invalid() {
728        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
729        assert!(
730            err.to_string().contains("probe.hard_fail_threshold"),
731            "unexpected error: {err}"
732        );
733    }
734
735    fn config_with_completeness_threshold(ct: f32) -> Config {
736        let mut cfg = Config::default();
737        cfg.orchestration.completeness_threshold = ct;
738        cfg
739    }
740
741    #[test]
742    fn completeness_threshold_valid_zero() {
743        assert!(config_with_completeness_threshold(0.0).validate().is_ok());
744    }
745
746    #[test]
747    fn completeness_threshold_valid_default() {
748        assert!(config_with_completeness_threshold(0.7).validate().is_ok());
749    }
750
751    #[test]
752    fn completeness_threshold_valid_one() {
753        assert!(config_with_completeness_threshold(1.0).validate().is_ok());
754    }
755
756    #[test]
757    fn completeness_threshold_invalid_negative() {
758        let err = config_with_completeness_threshold(-0.1)
759            .validate()
760            .unwrap_err();
761        assert!(
762            err.to_string().contains("completeness_threshold"),
763            "unexpected error: {err}"
764        );
765    }
766
767    #[test]
768    fn completeness_threshold_invalid_above_one() {
769        let err = config_with_completeness_threshold(1.1)
770            .validate()
771            .unwrap_err();
772        assert!(
773            err.to_string().contains("completeness_threshold"),
774            "unexpected error: {err}"
775        );
776    }
777
778    #[test]
779    fn completeness_threshold_invalid_nan() {
780        let err = config_with_completeness_threshold(f32::NAN)
781            .validate()
782            .unwrap_err();
783        assert!(
784            err.to_string().contains("completeness_threshold"),
785            "unexpected error: {err}"
786        );
787    }
788
789    #[test]
790    fn completeness_threshold_invalid_infinity() {
791        let err = config_with_completeness_threshold(f32::INFINITY)
792            .validate()
793            .unwrap_err();
794        assert!(
795            err.to_string().contains("completeness_threshold"),
796            "unexpected error: {err}"
797        );
798    }
799
800    fn config_with_provider(name: &str) -> Config {
801        let mut cfg = Config::default();
802        cfg.llm.providers.push(crate::providers::ProviderEntry {
803            provider_type: crate::providers::ProviderKind::Ollama,
804            name: Some(name.into()),
805            ..Default::default()
806        });
807        cfg
808    }
809
810    #[test]
811    fn validate_provider_names_all_empty_ok() {
812        let cfg = Config::default();
813        assert!(cfg.validate_provider_names().is_ok());
814    }
815
816    #[test]
817    fn validate_provider_names_matching_provider_ok() {
818        let mut cfg = config_with_provider("fast");
819        cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
820        assert!(cfg.validate_provider_names().is_ok());
821    }
822
823    #[test]
824    fn validate_provider_names_unknown_provider_err() {
825        let mut cfg = config_with_provider("fast");
826        cfg.memory.admission.admission_provider =
827            crate::providers::ProviderName::new("nonexistent");
828        let err = cfg.validate_provider_names().unwrap_err();
829        let msg = err.to_string();
830        assert!(
831            msg.contains("admission_provider") && msg.contains("nonexistent"),
832            "unexpected error: {msg}"
833        );
834    }
835
836    #[test]
837    fn validate_provider_names_triage_provider_none_ok() {
838        let mut cfg = config_with_provider("fast");
839        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
840            triage_provider: None,
841            ..Default::default()
842        });
843        assert!(cfg.validate_provider_names().is_ok());
844    }
845
846    #[test]
847    fn validate_provider_names_triage_provider_matching_ok() {
848        let mut cfg = config_with_provider("fast");
849        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
850            triage_provider: Some(crate::providers::ProviderName::new("fast")),
851            ..Default::default()
852        });
853        assert!(cfg.validate_provider_names().is_ok());
854    }
855
856    #[test]
857    fn validate_provider_names_triage_provider_unknown_err() {
858        let mut cfg = config_with_provider("fast");
859        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
860            triage_provider: Some(crate::providers::ProviderName::new("ghost")),
861            ..Default::default()
862        });
863        let err = cfg.validate_provider_names().unwrap_err();
864        let msg = err.to_string();
865        assert!(
866            msg.contains("triage_provider") && msg.contains("ghost"),
867            "unexpected error: {msg}"
868        );
869    }
870
871    // Regression test for issue #2599: TOML float values must deserialise without error
872    // across all config sections that contain f32/f64 fields.
873    #[test]
874    fn toml_float_fields_deserialise_correctly() {
875        let toml = r"
876[llm.router.reputation]
877enabled = true
878decay_factor = 0.95
879weight = 0.3
880
881[llm.router.bandit]
882enabled = false
883cost_weight = 0.3
884alpha = 1.0
885decay_factor = 0.99
886
887[skills]
888disambiguation_threshold = 0.25
889cosine_weight = 0.7
890";
891        // Wrap in a full Config to exercise the nested paths.
892        let wrapped = format!(
893            "{}\n{}",
894            toml,
895            r"[memory.semantic]
896mmr_lambda = 0.7
897"
898        );
899        // We only need the sub-structs to round-trip; build minimal wrappers.
900        let router: crate::providers::RouterConfig = toml::from_str(
901            r"[reputation]
902enabled = true
903decay_factor = 0.95
904weight = 0.3
905",
906        )
907        .expect("RouterConfig with float fields must deserialise");
908        assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
909
910        let bandit: crate::providers::BanditConfig =
911            toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
912                .expect("BanditConfig with float fields must deserialise");
913        assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
914
915        let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
916            .expect("SemanticConfig with float fields must deserialise");
917        assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
918
919        let skills: crate::features::SkillsConfig =
920            toml::from_str("disambiguation_threshold = 0.25\n")
921                .expect("SkillsConfig with float fields must deserialise");
922        assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
923
924        let _ = wrapped; // silence unused-variable lint
925    }
926
927    #[test]
928    fn validate_max_parallel_zero_rejected() {
929        let mut cfg = Config::default();
930        cfg.orchestration.max_parallel = 0;
931        let err = cfg.validate().unwrap_err().to_string();
932        assert!(
933            err.contains("max_parallel"),
934            "expected max_parallel in error, got: {err}"
935        );
936    }
937
938    #[test]
939    fn validate_max_parallel_one_accepted() {
940        let mut cfg = Config::default();
941        cfg.orchestration.max_parallel = 1;
942        assert!(cfg.validate().is_ok());
943    }
944
945    #[test]
946    fn validate_max_tasks_zero_rejected() {
947        let mut cfg = Config::default();
948        cfg.orchestration.max_tasks = 0;
949        let err = cfg.validate().unwrap_err().to_string();
950        assert!(
951            err.contains("max_tasks"),
952            "expected max_tasks in error, got: {err}"
953        );
954    }
955
956    #[test]
957    fn validate_max_tasks_one_accepted() {
958        let mut cfg = Config::default();
959        cfg.orchestration.max_tasks = 1;
960        assert!(cfg.validate().is_ok());
961    }
962
963    #[test]
964    fn validate_aggregator_timeout_zero_rejected() {
965        let mut cfg = Config::default();
966        cfg.orchestration.aggregator_timeout_secs = 0;
967        let err = cfg.validate().unwrap_err().to_string();
968        assert!(
969            err.contains("aggregator_timeout_secs"),
970            "expected aggregator_timeout_secs in error, got: {err}"
971        );
972    }
973
974    #[test]
975    fn validate_planner_timeout_zero_rejected() {
976        let mut cfg = Config::default();
977        cfg.orchestration.planner_timeout_secs = 0;
978        let err = cfg.validate().unwrap_err().to_string();
979        assert!(
980            err.contains("planner_timeout_secs"),
981            "expected planner_timeout_secs in error, got: {err}"
982        );
983    }
984
985    #[test]
986    fn validate_verifier_timeout_zero_rejected() {
987        let mut cfg = Config::default();
988        cfg.orchestration.verifier_timeout_secs = 0;
989        let err = cfg.validate().unwrap_err().to_string();
990        assert!(
991            err.contains("verifier_timeout_secs"),
992            "expected verifier_timeout_secs in error, got: {err}"
993        );
994    }
995
996    #[test]
997    fn focus_auto_consolidate_min_window_zero_rejected() {
998        let mut cfg = Config::default();
999        cfg.agent.focus.auto_consolidate_min_window = 0;
1000        let err = cfg.validate().unwrap_err().to_string();
1001        assert!(
1002            err.contains("auto_consolidate_min_window"),
1003            "expected auto_consolidate_min_window in error, got: {err}"
1004        );
1005    }
1006
1007    #[test]
1008    fn focus_auto_consolidate_min_window_one_accepted() {
1009        let mut cfg = Config::default();
1010        cfg.agent.focus.auto_consolidate_min_window = 1;
1011        assert!(cfg.validate().is_ok());
1012    }
1013}