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    #[allow(clippy::too_many_lines)]
65    pub fn validate(&self) -> Result<(), ConfigError> {
66        if self.memory.history_limit > 10_000 {
67            return Err(ConfigError::Validation(format!(
68                "history_limit must be <= 10000, got {}",
69                self.memory.history_limit
70            )));
71        }
72        if self.memory.context_budget_tokens > 1_000_000 {
73            return Err(ConfigError::Validation(format!(
74                "context_budget_tokens must be <= 1000000, got {}",
75                self.memory.context_budget_tokens
76            )));
77        }
78        if self.agent.max_tool_iterations > 100 {
79            return Err(ConfigError::Validation(format!(
80                "max_tool_iterations must be <= 100, got {}",
81                self.agent.max_tool_iterations
82            )));
83        }
84        if self.a2a.rate_limit == 0 {
85            return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
86        }
87        if self.gateway.rate_limit == 0 {
88            return Err(ConfigError::Validation(
89                "gateway.rate_limit must be > 0".into(),
90            ));
91        }
92        if self.gateway.max_body_size > 10_485_760 {
93            return Err(ConfigError::Validation(format!(
94                "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
95                self.gateway.max_body_size
96            )));
97        }
98        if self.memory.token_safety_margin <= 0.0 {
99            return Err(ConfigError::Validation(format!(
100                "token_safety_margin must be > 0.0, got {}",
101                self.memory.token_safety_margin
102            )));
103        }
104        if self.memory.tool_call_cutoff == 0 {
105            return Err(ConfigError::Validation(
106                "tool_call_cutoff must be >= 1".into(),
107            ));
108        }
109        if let crate::memory::CompressionStrategy::Proactive {
110            threshold_tokens,
111            max_summary_tokens,
112        } = &self.memory.compression.strategy
113        {
114            if *threshold_tokens < 1_000 {
115                return Err(ConfigError::Validation(format!(
116                    "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
117                )));
118            }
119            if *max_summary_tokens < 128 {
120                return Err(ConfigError::Validation(format!(
121                    "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
122                )));
123            }
124        }
125        if !self.memory.soft_compaction_threshold.is_finite()
126            || self.memory.soft_compaction_threshold <= 0.0
127            || self.memory.soft_compaction_threshold >= 1.0
128        {
129            return Err(ConfigError::Validation(format!(
130                "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
131                self.memory.soft_compaction_threshold
132            )));
133        }
134        if !self.memory.hard_compaction_threshold.is_finite()
135            || self.memory.hard_compaction_threshold <= 0.0
136            || self.memory.hard_compaction_threshold >= 1.0
137        {
138            return Err(ConfigError::Validation(format!(
139                "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
140                self.memory.hard_compaction_threshold
141            )));
142        }
143        if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
144            return Err(ConfigError::Validation(format!(
145                "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
146                self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
147            )));
148        }
149        if self.memory.graph.temporal_decay_rate < 0.0
150            || self.memory.graph.temporal_decay_rate > 10.0
151        {
152            return Err(ConfigError::Validation(format!(
153                "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
154                self.memory.graph.temporal_decay_rate
155            )));
156        }
157        if self.memory.compression.probe.enabled {
158            let probe = &self.memory.compression.probe;
159            if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
160                return Err(ConfigError::Validation(format!(
161                    "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
162                    probe.threshold
163                )));
164            }
165            if !probe.hard_fail_threshold.is_finite()
166                || probe.hard_fail_threshold < 0.0
167                || probe.hard_fail_threshold >= 1.0
168            {
169                return Err(ConfigError::Validation(format!(
170                    "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
171                    probe.hard_fail_threshold
172                )));
173            }
174            if probe.hard_fail_threshold >= probe.threshold {
175                return Err(ConfigError::Validation(format!(
176                    "memory.compression.probe.hard_fail_threshold ({}) must be less than \
177                     memory.compression.probe.threshold ({})",
178                    probe.hard_fail_threshold, probe.threshold
179                )));
180            }
181            if probe.max_questions < 1 {
182                return Err(ConfigError::Validation(
183                    "memory.compression.probe.max_questions must be >= 1".into(),
184                ));
185            }
186            if probe.timeout_secs < 1 {
187                return Err(ConfigError::Validation(
188                    "memory.compression.probe.timeout_secs must be >= 1".into(),
189                ));
190            }
191        }
192        // MCP server validation
193        {
194            use std::collections::HashSet;
195            let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
196            for s in &self.mcp.servers {
197                // headers and oauth are mutually exclusive
198                if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
199                    return Err(ConfigError::Validation(format!(
200                        "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
201                        s.id
202                    )));
203                }
204                // vault key collision detection
205                if s.oauth.as_ref().is_some_and(|o| o.enabled) {
206                    let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
207                    if !seen_oauth_vault_keys.insert(key.clone()) {
208                        return Err(ConfigError::Validation(format!(
209                            "MCP server '{}' has vault key collision ('{key}'): another server \
210                             with the same normalized ID already uses this key",
211                            s.id
212                        )));
213                    }
214                }
215            }
216        }
217
218        self.experiments
219            .validate()
220            .map_err(ConfigError::Validation)?;
221
222        if self.orchestration.plan_cache.enabled {
223            self.orchestration
224                .plan_cache
225                .validate()
226                .map_err(ConfigError::Validation)?;
227        }
228
229        let ct = self.orchestration.completeness_threshold;
230        if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
231            return Err(ConfigError::Validation(format!(
232                "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
233            )));
234        }
235
236        // Cascade chain threshold must not be 1 — that would abort on every single failure.
237        if self.orchestration.cascade_chain_threshold == 1 {
238            return Err(ConfigError::Validation(
239                "orchestration.cascade_chain_threshold=1 aborts on every failure; \
240                 use 0 to disable linear-chain cascade abort instead"
241                    .into(),
242            ));
243        }
244
245        let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
246        if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
247            return Err(ConfigError::Validation(format!(
248                "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
249            )));
250        }
251
252        if self.orchestration.lineage_ttl_secs == 0 {
253            return Err(ConfigError::Validation(
254                "orchestration.lineage_ttl_secs must be > 0; \
255                 set cascade_chain_threshold=0 to disable lineage tracking instead"
256                    .into(),
257            ));
258        }
259
260        // Focus config validation
261        if self.agent.focus.compression_interval == 0 {
262            return Err(ConfigError::Validation(
263                "agent.focus.compression_interval must be >= 1".into(),
264            ));
265        }
266        if self.agent.focus.min_messages_per_focus == 0 {
267            return Err(ConfigError::Validation(
268                "agent.focus.min_messages_per_focus must be >= 1".into(),
269            ));
270        }
271        if self.agent.focus.auto_consolidate_min_window == 0 {
272            return Err(ConfigError::Validation(
273                "agent.focus.auto_consolidate_min_window must be >= 1 \
274                 (set focus.enabled = false to disable auto-consolidation)"
275                    .into(),
276            ));
277        }
278
279        // SideQuest config validation
280        if self.memory.sidequest.interval_turns == 0 {
281            return Err(ConfigError::Validation(
282                "memory.sidequest.interval_turns must be >= 1".into(),
283            ));
284        }
285        if !self.memory.sidequest.max_eviction_ratio.is_finite()
286            || self.memory.sidequest.max_eviction_ratio <= 0.0
287            || self.memory.sidequest.max_eviction_ratio > 1.0
288        {
289            return Err(ConfigError::Validation(format!(
290                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
291                self.memory.sidequest.max_eviction_ratio
292            )));
293        }
294
295        let sct = self.llm.semantic_cache_threshold;
296        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
297            return Err(ConfigError::Validation(format!(
298                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
299                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
300            )));
301        }
302
303        // Skill evaluation weight-sum validation (#3319).
304        if self.skills.evaluation.enabled {
305            let weight_sum = self.skills.evaluation.weight_correctness
306                + self.skills.evaluation.weight_reusability
307                + self.skills.evaluation.weight_specificity;
308            if (weight_sum - 1.0_f32).abs() > 1e-3 {
309                return Err(ConfigError::Validation(format!(
310                    "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
311                )));
312            }
313        }
314
315        self.validate_provider_names()?;
316
317        if self.mcp.output_schema_hint_bytes < 64 {
318            return Err(ConfigError::Validation(format!(
319                "mcp.output_schema_hint_bytes must be >= 64, got {}; \
320                 use forward_output_schema = false to disable forwarding",
321                self.mcp.output_schema_hint_bytes
322            )));
323        }
324
325        Ok(())
326    }
327
328    #[allow(clippy::too_many_lines)]
329    fn validate_provider_names(&self) -> Result<(), ConfigError> {
330        use std::collections::HashSet;
331        let known: HashSet<String> = self
332            .llm
333            .providers
334            .iter()
335            .map(super::providers::ProviderEntry::effective_name)
336            .collect();
337
338        let fields: &[(&str, &crate::providers::ProviderName)] = &[
339            (
340                "memory.tiers.scene_provider",
341                &self.memory.tiers.scene_provider,
342            ),
343            (
344                "memory.compression.compress_provider",
345                &self.memory.compression.compress_provider,
346            ),
347            (
348                "memory.consolidation.consolidation_provider",
349                &self.memory.consolidation.consolidation_provider,
350            ),
351            (
352                "memory.admission.admission_provider",
353                &self.memory.admission.admission_provider,
354            ),
355            (
356                "memory.admission.goal_utility_provider",
357                &self.memory.admission.goal_utility_provider,
358            ),
359            (
360                "memory.store_routing.routing_classifier_provider",
361                &self.memory.store_routing.routing_classifier_provider,
362            ),
363            (
364                "skills.learning.feedback_provider",
365                &self.skills.learning.feedback_provider,
366            ),
367            (
368                "skills.learning.arise_trace_provider",
369                &self.skills.learning.arise_trace_provider,
370            ),
371            (
372                "skills.learning.stem_provider",
373                &self.skills.learning.stem_provider,
374            ),
375            (
376                "skills.learning.erl_extract_provider",
377                &self.skills.learning.erl_extract_provider,
378            ),
379            (
380                "mcp.pruning.pruning_provider",
381                &self.mcp.pruning.pruning_provider,
382            ),
383            (
384                "mcp.tool_discovery.embedding_provider",
385                &self.mcp.tool_discovery.embedding_provider,
386            ),
387            (
388                "security.response_verification.verifier_provider",
389                &self.security.response_verification.verifier_provider,
390            ),
391            (
392                "orchestration.planner_provider",
393                &self.orchestration.planner_provider,
394            ),
395            (
396                "orchestration.verify_provider",
397                &self.orchestration.verify_provider,
398            ),
399            (
400                "orchestration.tool_provider",
401                &self.orchestration.tool_provider,
402            ),
403            (
404                "skills.evaluation.provider",
405                &self.skills.evaluation.provider,
406            ),
407            (
408                "skills.proactive_exploration.provider",
409                &self.skills.proactive_exploration.provider,
410            ),
411            (
412                "memory.compression_spectrum.promotion_provider",
413                &self.memory.compression_spectrum.promotion_provider,
414            ),
415        ];
416
417        for (field, name) in fields {
418            if !name.is_empty() && !known.contains(name.as_str()) {
419                return Err(ConfigError::Validation(format!(
420                    "{field} = {:?} does not match any [[llm.providers]] entry",
421                    name.as_str()
422                )));
423            }
424        }
425
426        if let Some(triage) = self
427            .llm
428            .complexity_routing
429            .as_ref()
430            .and_then(|cr| cr.triage_provider.as_ref())
431            .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
432        {
433            return Err(ConfigError::Validation(format!(
434                "llm.complexity_routing.triage_provider = {:?} does not match any \
435                 [[llm.providers]] entry",
436                triage.as_str()
437            )));
438        }
439
440        if let Some(embed) = self
441            .llm
442            .router
443            .as_ref()
444            .and_then(|r| r.bandit.as_ref())
445            .map(|b| &b.embedding_provider)
446            .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
447        {
448            return Err(ConfigError::Validation(format!(
449                "llm.router.bandit.embedding_provider = {:?} does not match any \
450                 [[llm.providers]] entry",
451                embed.as_str()
452            )));
453        }
454
455        Ok(())
456    }
457
458    fn normalize_legacy_runtime_defaults(&mut self) {
459        use crate::defaults::{
460            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
461            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
462            is_legacy_default_sqlite_path,
463        };
464
465        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
466            self.memory.sqlite_path = default_sqlite_path();
467        }
468
469        for skill_path in &mut self.skills.paths {
470            if is_legacy_default_skills_path(skill_path) {
471                *skill_path = default_skills_dir();
472            }
473        }
474
475        if is_legacy_default_debug_dir(&self.debug.output_dir) {
476            self.debug.output_dir = default_debug_dir();
477        }
478
479        if is_legacy_default_log_file(&self.logging.file) {
480            self.logging.file = default_log_file_path();
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn config_with_sct(threshold: f32) -> Config {
490        let mut cfg = Config::default();
491        cfg.llm.semantic_cache_threshold = threshold;
492        cfg
493    }
494
495    #[test]
496    fn semantic_cache_threshold_valid_zero() {
497        assert!(config_with_sct(0.0).validate().is_ok());
498    }
499
500    #[test]
501    fn semantic_cache_threshold_valid_mid() {
502        assert!(config_with_sct(0.5).validate().is_ok());
503    }
504
505    #[test]
506    fn semantic_cache_threshold_valid_one() {
507        assert!(config_with_sct(1.0).validate().is_ok());
508    }
509
510    #[test]
511    fn semantic_cache_threshold_invalid_negative() {
512        let err = config_with_sct(-0.1).validate().unwrap_err();
513        assert!(
514            err.to_string().contains("semantic_cache_threshold"),
515            "unexpected error: {err}"
516        );
517    }
518
519    #[test]
520    fn semantic_cache_threshold_invalid_above_one() {
521        let err = config_with_sct(1.1).validate().unwrap_err();
522        assert!(
523            err.to_string().contains("semantic_cache_threshold"),
524            "unexpected error: {err}"
525        );
526    }
527
528    #[test]
529    fn semantic_cache_threshold_invalid_nan() {
530        let err = config_with_sct(f32::NAN).validate().unwrap_err();
531        assert!(
532            err.to_string().contains("semantic_cache_threshold"),
533            "unexpected error: {err}"
534        );
535    }
536
537    #[test]
538    fn semantic_cache_threshold_invalid_infinity() {
539        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
540        assert!(
541            err.to_string().contains("semantic_cache_threshold"),
542            "unexpected error: {err}"
543        );
544    }
545
546    #[test]
547    fn semantic_cache_threshold_invalid_neg_infinity() {
548        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
549        assert!(
550            err.to_string().contains("semantic_cache_threshold"),
551            "unexpected error: {err}"
552        );
553    }
554
555    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
556        let mut cfg = Config::default();
557        cfg.memory.compression.probe.enabled = enabled;
558        cfg.memory.compression.probe.threshold = threshold;
559        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
560        cfg
561    }
562
563    #[test]
564    fn probe_disabled_skips_validation() {
565        // Invalid thresholds when probe is disabled must not cause errors.
566        let cfg = probe_config(false, 0.0, 1.0);
567        assert!(cfg.validate().is_ok());
568    }
569
570    #[test]
571    fn probe_valid_thresholds() {
572        let cfg = probe_config(true, 0.6, 0.35);
573        assert!(cfg.validate().is_ok());
574    }
575
576    #[test]
577    fn probe_threshold_zero_invalid() {
578        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
579        assert!(
580            err.to_string().contains("probe.threshold"),
581            "unexpected error: {err}"
582        );
583    }
584
585    #[test]
586    fn probe_hard_fail_threshold_above_one_invalid() {
587        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
588        assert!(
589            err.to_string().contains("probe.hard_fail_threshold"),
590            "unexpected error: {err}"
591        );
592    }
593
594    #[test]
595    fn probe_hard_fail_gte_threshold_invalid() {
596        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
597        assert!(
598            err.to_string().contains("probe.hard_fail_threshold"),
599            "unexpected error: {err}"
600        );
601    }
602
603    fn config_with_completeness_threshold(ct: f32) -> Config {
604        let mut cfg = Config::default();
605        cfg.orchestration.completeness_threshold = ct;
606        cfg
607    }
608
609    #[test]
610    fn completeness_threshold_valid_zero() {
611        assert!(config_with_completeness_threshold(0.0).validate().is_ok());
612    }
613
614    #[test]
615    fn completeness_threshold_valid_default() {
616        assert!(config_with_completeness_threshold(0.7).validate().is_ok());
617    }
618
619    #[test]
620    fn completeness_threshold_valid_one() {
621        assert!(config_with_completeness_threshold(1.0).validate().is_ok());
622    }
623
624    #[test]
625    fn completeness_threshold_invalid_negative() {
626        let err = config_with_completeness_threshold(-0.1)
627            .validate()
628            .unwrap_err();
629        assert!(
630            err.to_string().contains("completeness_threshold"),
631            "unexpected error: {err}"
632        );
633    }
634
635    #[test]
636    fn completeness_threshold_invalid_above_one() {
637        let err = config_with_completeness_threshold(1.1)
638            .validate()
639            .unwrap_err();
640        assert!(
641            err.to_string().contains("completeness_threshold"),
642            "unexpected error: {err}"
643        );
644    }
645
646    #[test]
647    fn completeness_threshold_invalid_nan() {
648        let err = config_with_completeness_threshold(f32::NAN)
649            .validate()
650            .unwrap_err();
651        assert!(
652            err.to_string().contains("completeness_threshold"),
653            "unexpected error: {err}"
654        );
655    }
656
657    #[test]
658    fn completeness_threshold_invalid_infinity() {
659        let err = config_with_completeness_threshold(f32::INFINITY)
660            .validate()
661            .unwrap_err();
662        assert!(
663            err.to_string().contains("completeness_threshold"),
664            "unexpected error: {err}"
665        );
666    }
667
668    fn config_with_provider(name: &str) -> Config {
669        let mut cfg = Config::default();
670        cfg.llm.providers.push(crate::providers::ProviderEntry {
671            provider_type: crate::providers::ProviderKind::Ollama,
672            name: Some(name.into()),
673            ..Default::default()
674        });
675        cfg
676    }
677
678    #[test]
679    fn validate_provider_names_all_empty_ok() {
680        let cfg = Config::default();
681        assert!(cfg.validate_provider_names().is_ok());
682    }
683
684    #[test]
685    fn validate_provider_names_matching_provider_ok() {
686        let mut cfg = config_with_provider("fast");
687        cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
688        assert!(cfg.validate_provider_names().is_ok());
689    }
690
691    #[test]
692    fn validate_provider_names_unknown_provider_err() {
693        let mut cfg = config_with_provider("fast");
694        cfg.memory.admission.admission_provider =
695            crate::providers::ProviderName::new("nonexistent");
696        let err = cfg.validate_provider_names().unwrap_err();
697        let msg = err.to_string();
698        assert!(
699            msg.contains("admission_provider") && msg.contains("nonexistent"),
700            "unexpected error: {msg}"
701        );
702    }
703
704    #[test]
705    fn validate_provider_names_triage_provider_none_ok() {
706        let mut cfg = config_with_provider("fast");
707        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
708            triage_provider: None,
709            ..Default::default()
710        });
711        assert!(cfg.validate_provider_names().is_ok());
712    }
713
714    #[test]
715    fn validate_provider_names_triage_provider_matching_ok() {
716        let mut cfg = config_with_provider("fast");
717        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
718            triage_provider: Some(crate::providers::ProviderName::new("fast")),
719            ..Default::default()
720        });
721        assert!(cfg.validate_provider_names().is_ok());
722    }
723
724    #[test]
725    fn validate_provider_names_triage_provider_unknown_err() {
726        let mut cfg = config_with_provider("fast");
727        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
728            triage_provider: Some(crate::providers::ProviderName::new("ghost")),
729            ..Default::default()
730        });
731        let err = cfg.validate_provider_names().unwrap_err();
732        let msg = err.to_string();
733        assert!(
734            msg.contains("triage_provider") && msg.contains("ghost"),
735            "unexpected error: {msg}"
736        );
737    }
738
739    // Regression test for issue #2599: TOML float values must deserialise without error
740    // across all config sections that contain f32/f64 fields.
741    #[test]
742    fn toml_float_fields_deserialise_correctly() {
743        let toml = r"
744[llm.router.reputation]
745enabled = true
746decay_factor = 0.95
747weight = 0.3
748
749[llm.router.bandit]
750enabled = false
751cost_weight = 0.3
752alpha = 1.0
753decay_factor = 0.99
754
755[skills]
756disambiguation_threshold = 0.25
757cosine_weight = 0.7
758";
759        // Wrap in a full Config to exercise the nested paths.
760        let wrapped = format!(
761            "{}\n{}",
762            toml,
763            r"[memory.semantic]
764mmr_lambda = 0.7
765"
766        );
767        // We only need the sub-structs to round-trip; build minimal wrappers.
768        let router: crate::providers::RouterConfig = toml::from_str(
769            r"[reputation]
770enabled = true
771decay_factor = 0.95
772weight = 0.3
773",
774        )
775        .expect("RouterConfig with float fields must deserialise");
776        assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
777
778        let bandit: crate::providers::BanditConfig =
779            toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
780                .expect("BanditConfig with float fields must deserialise");
781        assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
782
783        let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
784            .expect("SemanticConfig with float fields must deserialise");
785        assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
786
787        let skills: crate::features::SkillsConfig =
788            toml::from_str("disambiguation_threshold = 0.25\n")
789                .expect("SkillsConfig with float fields must deserialise");
790        assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
791
792        let _ = wrapped; // silence unused-variable lint
793    }
794
795    #[test]
796    fn focus_auto_consolidate_min_window_zero_rejected() {
797        let mut cfg = Config::default();
798        cfg.agent.focus.auto_consolidate_min_window = 0;
799        let err = cfg.validate().unwrap_err().to_string();
800        assert!(
801            err.contains("auto_consolidate_min_window"),
802            "expected auto_consolidate_min_window in error, got: {err}"
803        );
804    }
805
806    #[test]
807    fn focus_auto_consolidate_min_window_one_accepted() {
808        let mut cfg = Config::default();
809        cfg.agent.focus.auto_consolidate_min_window = 1;
810        assert!(cfg.validate().is_ok());
811    }
812}