1use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 let wrapped = format!(
921 "{}\n{}",
922 toml,
923 r"[memory.semantic]
924mmr_lambda = 0.7
925"
926 );
927 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; }
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}