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