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 self.acp
86 .validate_auth_clients()
87 .map_err(ConfigError::Validation)?;
88 crate::providers::validate_pool(&self.llm.providers)?;
93 self.llm.validate_stt()?;
94 self.security
95 .trajectory
96 .validate()
97 .map_err(ConfigError::Validation)?;
98 self.gateway.validate().map_err(ConfigError::Validation)?;
99 self.tools
100 .utility
101 .validate()
102 .map_err(ConfigError::Validation)?;
103 if let Some(fidelity) = &self.memory.fidelity {
104 fidelity.validate().map_err(ConfigError::Validation)?;
105 }
106 self.memory
107 .compression
108 .acon
109 .validate()
110 .map_err(ConfigError::Validation)?;
111 if self.memory.shadow_memory.enabled {
112 self.memory
113 .shadow_memory
114 .validate()
115 .map_err(ConfigError::Validation)?;
116 }
117 Ok(())
118 }
119
120 fn validate_scalar_bounds(&self) -> Result<(), ConfigError> {
122 if self.memory.history_limit > 10_000 {
123 return Err(ConfigError::Validation(format!(
124 "history_limit must be <= 10000, got {}",
125 self.memory.history_limit
126 )));
127 }
128 if self.memory.context_budget_tokens > 1_000_000 {
129 return Err(ConfigError::Validation(format!(
130 "context_budget_tokens must be <= 1000000, got {}",
131 self.memory.context_budget_tokens
132 )));
133 }
134 if self.agent.max_tool_iterations > 100 {
135 return Err(ConfigError::Validation(format!(
136 "max_tool_iterations must be <= 100, got {}",
137 self.agent.max_tool_iterations
138 )));
139 }
140 if self.a2a.rate_limit == 0 {
141 return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
142 }
143 self.validate_a2a_client_trust()?;
144 if self.gateway.rate_limit == 0 {
145 return Err(ConfigError::Validation(
146 "gateway.rate_limit must be > 0".into(),
147 ));
148 }
149 if self.gateway.max_body_size > 10_485_760 {
150 return Err(ConfigError::Validation(format!(
151 "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
152 self.gateway.max_body_size
153 )));
154 }
155 if self.memory.token_safety_margin <= 0.0 {
156 return Err(ConfigError::Validation(format!(
157 "token_safety_margin must be > 0.0, got {}",
158 self.memory.token_safety_margin
159 )));
160 }
161 if self.memory.tool_call_cutoff == 0 {
162 return Err(ConfigError::Validation(
163 "tool_call_cutoff must be >= 1".into(),
164 ));
165 }
166 if self.worktree.max_worktrees == Some(0) {
167 return Err(ConfigError::Validation(
168 "worktree.max_worktrees must be > 0 or unset (unlimited); 0 would block all \
169 worktree creation"
170 .into(),
171 ));
172 }
173 if self.worktree.disk_quota_mb == Some(0) {
174 return Err(ConfigError::Validation(
175 "worktree.disk_quota_mb must be > 0 or unset (no accounting); 0 would leave \
176 every non-empty worktree permanently over quota"
177 .into(),
178 ));
179 }
180 if self.worktree.disk_quota_mb.is_some()
181 && self.worktree.auto_reconcile_secs == 0
182 && !self.worktree.reconcile_on_startup
183 {
184 return Err(ConfigError::Validation(
185 "worktree.disk_quota_mb is set but neither reconcile_on_startup nor \
186 auto_reconcile_secs is enabled — the quota will only be checked when you run \
187 `zeph worktree list` manually, never automatically"
188 .into(),
189 ));
190 }
191 if (1..60).contains(&self.worktree.auto_reconcile_secs) {
192 return Err(ConfigError::Validation(format!(
193 "worktree.auto_reconcile_secs must be 0 (disabled) or >= 60, got {}; a short \
194 interval runs a full filesystem walk in a tight loop",
195 self.worktree.auto_reconcile_secs
196 )));
197 }
198 Ok(())
199 }
200
201 #[cfg_attr(
211 feature = "card-signing",
212 allow(clippy::unused_self, clippy::unnecessary_wraps)
213 )]
214 fn validate_a2a_client_trust(&self) -> Result<(), ConfigError> {
215 #[cfg(not(feature = "card-signing"))]
216 if self.a2a_client.card_trust_policy == crate::channels::CardTrustPolicy::Require {
217 return Err(ConfigError::Validation(
218 "a2a_client.card_trust_policy = require requires the card-signing feature \
219 to be enabled at build time (see the `a2a` feature in the root Cargo.toml)"
220 .into(),
221 ));
222 }
223 Ok(())
224 }
225
226 fn validate_memory_compression(&self) -> Result<(), ConfigError> {
228 if let crate::memory::CompressionStrategy::Proactive {
229 threshold_tokens,
230 max_summary_tokens,
231 } = &self.memory.compression.strategy
232 {
233 if *threshold_tokens < 1_000 {
234 return Err(ConfigError::Validation(format!(
235 "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
236 )));
237 }
238 if *max_summary_tokens < 128 {
239 return Err(ConfigError::Validation(format!(
240 "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
241 )));
242 }
243 }
244 if !self.memory.soft_compaction_threshold.is_finite()
245 || self.memory.soft_compaction_threshold <= 0.0
246 || self.memory.soft_compaction_threshold >= 1.0
247 {
248 return Err(ConfigError::Validation(format!(
249 "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
250 self.memory.soft_compaction_threshold
251 )));
252 }
253 if !self.memory.hard_compaction_threshold.is_finite()
254 || self.memory.hard_compaction_threshold <= 0.0
255 || self.memory.hard_compaction_threshold >= 1.0
256 {
257 return Err(ConfigError::Validation(format!(
258 "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
259 self.memory.hard_compaction_threshold
260 )));
261 }
262 if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
263 return Err(ConfigError::Validation(format!(
264 "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
265 self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
266 )));
267 }
268 Ok(())
269 }
270
271 fn validate_memory_probe_and_graph(&self) -> Result<(), ConfigError> {
273 if self.memory.graph.temporal_decay_rate < 0.0
274 || self.memory.graph.temporal_decay_rate > 10.0
275 {
276 return Err(ConfigError::Validation(format!(
277 "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
278 self.memory.graph.temporal_decay_rate
279 )));
280 }
281 if self.memory.compression.probe.enabled {
282 let probe = &self.memory.compression.probe;
283 if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
284 return Err(ConfigError::Validation(format!(
285 "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
286 probe.threshold
287 )));
288 }
289 if !probe.hard_fail_threshold.is_finite()
290 || probe.hard_fail_threshold < 0.0
291 || probe.hard_fail_threshold >= 1.0
292 {
293 return Err(ConfigError::Validation(format!(
294 "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
295 probe.hard_fail_threshold
296 )));
297 }
298 if probe.hard_fail_threshold >= probe.threshold {
299 return Err(ConfigError::Validation(format!(
300 "memory.compression.probe.hard_fail_threshold ({}) must be less than \
301 memory.compression.probe.threshold ({})",
302 probe.hard_fail_threshold, probe.threshold
303 )));
304 }
305 if probe.max_questions < 1 {
306 return Err(ConfigError::Validation(
307 "memory.compression.probe.max_questions must be >= 1".into(),
308 ));
309 }
310 if probe.timeout_secs < 1 {
311 return Err(ConfigError::Validation(
312 "memory.compression.probe.timeout_secs must be >= 1".into(),
313 ));
314 }
315 }
316 Ok(())
317 }
318
319 fn validate_mcp_servers(&self) -> Result<(), ConfigError> {
321 use std::collections::HashSet;
322 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
323 for s in &self.mcp.servers {
324 if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
326 return Err(ConfigError::Validation(format!(
327 "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
328 s.id
329 )));
330 }
331 if s.oauth.as_ref().is_some_and(|o| o.enabled) {
333 let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
334 if !seen_oauth_vault_keys.insert(key.clone()) {
335 return Err(ConfigError::Validation(format!(
336 "MCP server '{}' has vault key collision ('{key}'): another server \
337 with the same normalized ID already uses this key",
338 s.id
339 )));
340 }
341 }
342 }
343 Ok(())
344 }
345
346 fn validate_orchestration(&self) -> Result<(), ConfigError> {
348 if self.orchestration.max_parallel == 0 {
349 return Err(ConfigError::Validation(
350 "orchestration.max_parallel must be > 0".into(),
351 ));
352 }
353 if self.orchestration.max_tasks == 0 {
354 return Err(ConfigError::Validation(
355 "orchestration.max_tasks must be > 0".into(),
356 ));
357 }
358 let ct = self.orchestration.completeness_threshold;
359 if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
360 return Err(ConfigError::Validation(format!(
361 "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
362 )));
363 }
364 let ensemble = &self.orchestration.ensemble;
368 if ensemble.enabled && ensemble.verify {
369 let n = ensemble.members.len();
370 if n.is_multiple_of(2) || n < 3 {
371 return Err(ConfigError::Validation(format!(
372 "orchestration.ensemble.members must be odd and >= 3, got {n}"
373 )));
374 }
375 let unique: std::collections::HashSet<&str> =
376 ensemble.members.iter().map(String::as_str).collect();
377 if unique.len() != ensemble.members.len() {
378 return Err(ConfigError::Validation(
379 "orchestration.ensemble.members contains a duplicate provider name".into(),
380 ));
381 }
382 let alpha = ensemble.ema_alpha;
387 if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
388 return Err(ConfigError::Validation(format!(
389 "orchestration.ensemble.ema_alpha must be in [0.0, 1.0], got {alpha}"
390 )));
391 }
392 let decay = ensemble.ema_decay;
393 if !decay.is_finite() || !(0.0..=1.0).contains(&decay) {
394 return Err(ConfigError::Validation(format!(
395 "orchestration.ensemble.ema_decay must be in [0.0, 1.0], got {decay}"
396 )));
397 }
398 }
399 if self.orchestration.cascade_chain_threshold == 1 {
401 return Err(ConfigError::Validation(
402 "orchestration.cascade_chain_threshold=1 aborts on every failure; \
403 use 0 to disable linear-chain cascade abort instead"
404 .into(),
405 ));
406 }
407 let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
408 if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
409 return Err(ConfigError::Validation(format!(
410 "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
411 )));
412 }
413 if self.orchestration.lineage_ttl_secs == 0 {
414 return Err(ConfigError::Validation(
415 "orchestration.lineage_ttl_secs must be > 0; \
416 set cascade_chain_threshold=0 to disable lineage tracking instead"
417 .into(),
418 ));
419 }
420 if self.orchestration.aggregator_timeout_secs == 0 {
421 return Err(ConfigError::Validation(
422 "orchestration.aggregator_timeout_secs must be > 0".into(),
423 ));
424 }
425 if self.orchestration.planner_timeout_secs == 0 {
426 return Err(ConfigError::Validation(
427 "orchestration.planner_timeout_secs must be > 0".into(),
428 ));
429 }
430 if self.orchestration.verifier_timeout_secs == 0 {
431 return Err(ConfigError::Validation(
432 "orchestration.verifier_timeout_secs must be > 0".into(),
433 ));
434 }
435 if self.orchestration.default_idle_timeout_secs == Some(0) {
436 return Err(ConfigError::Validation(
437 "orchestration.default_idle_timeout_secs must be > 0 or unset; 0 would mean \
438 an instant idle timeout"
439 .into(),
440 ));
441 }
442 self.validate_command_handoff()?;
443 Ok(())
444 }
445
446 fn validate_command_handoff(&self) -> Result<(), ConfigError> {
449 if self.orchestration.command.max_handoffs == 0 {
450 return Err(ConfigError::Validation(
451 "orchestration.command.max_handoffs must be > 0; set \
452 orchestration.command.enabled = false to disable Command handoff instead"
453 .into(),
454 ));
455 }
456 if self.orchestration.command.max_handoffs > 10_000 {
462 return Err(ConfigError::Validation(format!(
463 "orchestration.command.max_handoffs must be <= 10000, got {}",
464 self.orchestration.command.max_handoffs
465 )));
466 }
467 if !self.orchestration.command.enabled {
479 return Ok(());
480 }
481 if !self.memory.store.enabled {
482 return Err(ConfigError::Validation(
483 "orchestration.command.enabled = true requires memory.store.enabled = \
484 true — Command handoff has nowhere to persist its `update` payload \
485 without the cross-thread store"
486 .into(),
487 ));
488 }
489 if !self.security.content_isolation.enabled {
490 return Err(ConfigError::Validation(
491 "orchestration.command.enabled = true requires \
492 security.content_isolation.enabled = true — the FR-B-003 sanitizer \
493 scan that gates a Command handoff before it drives routing or a \
494 store write becomes a silent no-op otherwise"
495 .into(),
496 ));
497 }
498 if !self.security.content_isolation.flag_injection_patterns {
499 return Err(ConfigError::Validation(
500 "orchestration.command.enabled = true requires \
501 security.content_isolation.flag_injection_patterns = true — the \
502 FR-B-003 sanitizer scan never flags anything otherwise, silently \
503 bypassing the reject gate"
504 .into(),
505 ));
506 }
507 Ok(())
508 }
509
510 fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
512 if self.agent.focus.compression_interval == 0 {
513 return Err(ConfigError::Validation(
514 "agent.focus.compression_interval must be >= 1".into(),
515 ));
516 }
517 if self.agent.focus.min_messages_per_focus == 0 {
518 return Err(ConfigError::Validation(
519 "agent.focus.min_messages_per_focus must be >= 1".into(),
520 ));
521 }
522 if self.agent.focus.auto_consolidate_min_window == 0 {
523 return Err(ConfigError::Validation(
524 "agent.focus.auto_consolidate_min_window must be >= 1 \
525 (set focus.enabled = false to disable auto-consolidation)"
526 .into(),
527 ));
528 }
529 if self.memory.sidequest.interval_turns == 0 {
530 return Err(ConfigError::Validation(
531 "memory.sidequest.interval_turns must be >= 1".into(),
532 ));
533 }
534 if !self.memory.sidequest.max_eviction_ratio.is_finite()
535 || self.memory.sidequest.max_eviction_ratio <= 0.0
536 || self.memory.sidequest.max_eviction_ratio > 1.0
537 {
538 return Err(ConfigError::Validation(format!(
539 "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
540 self.memory.sidequest.max_eviction_ratio
541 )));
542 }
543 Ok(())
544 }
545
546 fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
548 let sct = self.llm.semantic_cache_threshold;
549 if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
550 return Err(ConfigError::Validation(format!(
551 "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
552 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
553 )));
554 }
555 if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
557 self.llm.warn_non_fast_tier_provider(
558 &self.memory.memcot.distill_provider,
559 "memory.memcot.distill_provider",
560 &self.memory.memcot.fast_tier_models,
561 );
562 }
563 self.skills
564 .learning
565 .validate()
566 .map_err(ConfigError::Validation)?;
567 if self.skills.evaluation.enabled {
569 let weight_sum = self.skills.evaluation.weight_correctness
570 + self.skills.evaluation.weight_reusability
571 + self.skills.evaluation.weight_specificity;
572 if (weight_sum - 1.0_f32).abs() > 1e-3 {
573 return Err(ConfigError::Validation(format!(
574 "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
575 )));
576 }
577 }
578 Ok(())
579 }
580
581 fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
583 if self.mcp.output_schema_hint_bytes < 64 {
584 return Err(ConfigError::Validation(format!(
585 "mcp.output_schema_hint_bytes must be >= 64, got {}; \
586 use forward_output_schema = false to disable forwarding",
587 self.mcp.output_schema_hint_bytes
588 )));
589 }
590 Ok(())
591 }
592
593 fn validate_scheduler(&self) -> Result<(), ConfigError> {
595 for task in &self.scheduler.tasks {
596 match (&task.cron, &task.run_at) {
597 (Some(_), Some(_)) => {
598 return Err(ConfigError::Validation(format!(
599 "scheduler task {:?}: only one of `cron` or `run_at` may be set, not both",
600 task.name
601 )));
602 }
603 (None, None) => {
604 return Err(ConfigError::Validation(format!(
605 "scheduler task {:?}: either `cron` or `run_at` must be set",
606 task.name
607 )));
608 }
609 _ => {}
610 }
611 }
612 Ok(())
613 }
614
615 fn validate_provider_names(&self) -> Result<(), ConfigError> {
616 let known = self.known_provider_names();
617 self.validate_named_provider_refs(&known)?;
618 self.validate_optional_provider_refs(&known)?;
619 Ok(())
620 }
621
622 fn known_provider_names(&self) -> std::collections::HashSet<String> {
624 self.llm
625 .providers
626 .iter()
627 .map(super::providers::ProviderEntry::effective_name)
628 .collect()
629 }
630
631 fn validate_named_provider_refs(
636 &self,
637 known: &std::collections::HashSet<String>,
638 ) -> Result<(), ConfigError> {
639 self.validate_core_provider_refs(known)?;
640 self.validate_tool_and_quality_provider_refs(known)
641 }
642
643 fn validate_core_provider_refs(
644 &self,
645 known: &std::collections::HashSet<String>,
646 ) -> Result<(), ConfigError> {
647 let fields: &[(&str, &crate::providers::ProviderName)] = &[
648 (
649 "memory.tiers.scene_provider",
650 &self.memory.tiers.scene_provider,
651 ),
652 (
653 "memory.compression.compress_provider",
654 &self.memory.compression.compress_provider,
655 ),
656 (
657 "memory.consolidation.consolidation_provider",
658 &self.memory.consolidation.consolidation_provider,
659 ),
660 (
661 "memory.admission.admission_provider",
662 &self.memory.admission.admission_provider,
663 ),
664 (
665 "memory.admission.goal_utility_provider",
666 &self.memory.admission.goal_utility_provider,
667 ),
668 (
669 "memory.store_routing.routing_classifier_provider",
670 &self.memory.store_routing.routing_classifier_provider,
671 ),
672 (
673 "skills.learning.feedback_provider",
674 &self.skills.learning.feedback_provider,
675 ),
676 (
677 "skills.learning.arise_trace_provider",
678 &self.skills.learning.arise_trace_provider,
679 ),
680 (
681 "skills.learning.stem_provider",
682 &self.skills.learning.stem_provider,
683 ),
684 (
685 "skills.learning.erl_extract_provider",
686 &self.skills.learning.erl_extract_provider,
687 ),
688 (
689 "mcp.pruning.pruning_provider",
690 &self.mcp.pruning.pruning_provider,
691 ),
692 (
693 "mcp.tool_discovery.embedding_provider",
694 &self.mcp.tool_discovery.embedding_provider,
695 ),
696 (
697 "security.response_verification.verifier_provider",
698 &self.security.response_verification.verifier_provider,
699 ),
700 (
701 "orchestration.planner_provider",
702 &self.orchestration.planner_provider,
703 ),
704 (
705 "orchestration.verify_provider",
706 &self.orchestration.verify_provider,
707 ),
708 (
709 "orchestration.tool_provider",
710 &self.orchestration.tool_provider,
711 ),
712 (
713 "skills.evaluation.provider",
714 &self.skills.evaluation.provider,
715 ),
716 (
717 "skills.proactive_exploration.provider",
718 &self.skills.proactive_exploration.provider,
719 ),
720 (
721 "memory.compression_spectrum.promotion_provider",
722 &self.memory.compression_spectrum.promotion_provider,
723 ),
724 ];
725 Self::check_provider_refs(fields, known)
726 }
727
728 fn validate_tool_and_quality_provider_refs(
729 &self,
730 known: &std::collections::HashSet<String>,
731 ) -> Result<(), ConfigError> {
732 let fields: &[(&str, &crate::providers::ProviderName)] = &[
733 (
734 "security.shadow_sentinel.probe_provider",
735 &self.security.shadow_sentinel.probe_provider,
736 ),
737 (
738 "tools.retry.parameter_reformat_provider",
739 &self.tools.retry.parameter_reformat_provider,
740 ),
741 (
742 "tools.policy.policy_provider",
743 &self.tools.policy.policy_provider,
744 ),
745 (
746 "tools.adversarial_policy.policy_provider",
747 &self.tools.adversarial_policy.policy_provider,
748 ),
749 (
750 "tools.speculative.pattern.rerank_provider",
751 &self.tools.speculative.pattern.rerank_provider,
752 ),
753 (
754 "tools.compression.evolution_provider",
755 &self.tools.compression.evolution_provider,
756 ),
757 ("quality.proposer_provider", &self.quality.proposer_provider),
758 ("quality.checker_provider", &self.quality.checker_provider),
759 ];
760 Self::check_provider_refs(fields, known)
761 }
762
763 fn check_provider_refs(
764 fields: &[(&str, &crate::providers::ProviderName)],
765 known: &std::collections::HashSet<String>,
766 ) -> Result<(), ConfigError> {
767 for (field, name) in fields {
768 if !name.is_empty() && !known.contains(name.as_str()) {
769 return Err(ConfigError::Validation(format!(
770 "{field} = {:?} does not match any [[llm.providers]] entry",
771 name.as_str()
772 )));
773 }
774 }
775 Ok(())
776 }
777
778 fn validate_optional_provider_refs(
780 &self,
781 known: &std::collections::HashSet<String>,
782 ) -> Result<(), ConfigError> {
783 if let Some(triage) = self
784 .llm
785 .complexity_routing
786 .as_ref()
787 .and_then(|cr| cr.triage_provider.as_ref())
788 .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
789 {
790 return Err(ConfigError::Validation(format!(
791 "llm.complexity_routing.triage_provider = {:?} does not match any \
792 [[llm.providers]] entry",
793 triage.as_str()
794 )));
795 }
796
797 if let Some(embed) = self
798 .llm
799 .router
800 .as_ref()
801 .and_then(|r| r.bandit.as_ref())
802 .map(|b| &b.embedding_provider)
803 .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
804 {
805 return Err(ConfigError::Validation(format!(
806 "llm.router.bandit.embedding_provider = {:?} does not match any \
807 [[llm.providers]] entry",
808 embed.as_str()
809 )));
810 }
811
812 Ok(())
813 }
814
815 fn normalize_legacy_runtime_defaults(&mut self) {
816 use crate::defaults::{
817 default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
818 is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
819 is_legacy_default_sqlite_path,
820 };
821
822 if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
823 self.memory.sqlite_path = default_sqlite_path();
824 }
825
826 for skill_path in &mut self.skills.paths {
827 if is_legacy_default_skills_path(skill_path) {
828 *skill_path = default_skills_dir();
829 }
830 }
831
832 if is_legacy_default_debug_dir(&self.debug.output_dir) {
833 self.debug.output_dir = default_debug_dir();
834 }
835
836 if is_legacy_default_log_file(&self.logging.file) {
837 self.logging.file = default_log_file_path();
838 }
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 fn config_with_sct(threshold: f32) -> Config {
847 let mut cfg = Config::default();
848 cfg.llm.semantic_cache_threshold = threshold;
849 cfg
850 }
851
852 #[test]
853 fn semantic_cache_threshold_valid_zero() {
854 assert!(config_with_sct(0.0).validate().is_ok());
855 }
856
857 #[test]
858 fn semantic_cache_threshold_valid_mid() {
859 assert!(config_with_sct(0.5).validate().is_ok());
860 }
861
862 #[test]
863 fn semantic_cache_threshold_valid_one() {
864 assert!(config_with_sct(1.0).validate().is_ok());
865 }
866
867 #[test]
868 fn semantic_cache_threshold_invalid_negative() {
869 let err = config_with_sct(-0.1).validate().unwrap_err();
870 assert!(
871 err.to_string().contains("semantic_cache_threshold"),
872 "unexpected error: {err}"
873 );
874 }
875
876 #[test]
877 fn semantic_cache_threshold_invalid_above_one() {
878 let err = config_with_sct(1.1).validate().unwrap_err();
879 assert!(
880 err.to_string().contains("semantic_cache_threshold"),
881 "unexpected error: {err}"
882 );
883 }
884
885 #[test]
886 fn semantic_cache_threshold_invalid_nan() {
887 let err = config_with_sct(f32::NAN).validate().unwrap_err();
888 assert!(
889 err.to_string().contains("semantic_cache_threshold"),
890 "unexpected error: {err}"
891 );
892 }
893
894 #[cfg(not(feature = "card-signing"))]
895 #[test]
896 fn card_trust_policy_require_without_feature_fails_validation() {
897 let mut cfg = Config::default();
898 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
899 let err = cfg.validate().unwrap_err();
900 assert!(
901 err.to_string().contains("card_trust_policy"),
902 "unexpected error: {err}"
903 );
904 }
905
906 #[test]
907 fn card_trust_policy_ignore_and_prefer_always_pass_validation() {
908 let mut cfg = Config::default();
909 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Ignore;
910 assert!(cfg.validate().is_ok());
911 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Prefer;
912 assert!(cfg.validate().is_ok());
913 }
914
915 #[cfg(feature = "card-signing")]
916 #[test]
917 fn card_trust_policy_require_with_feature_passes_validation() {
918 let mut cfg = Config::default();
919 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
920 assert!(cfg.validate().is_ok());
921 }
922
923 #[test]
924 fn semantic_cache_threshold_invalid_infinity() {
925 let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
926 assert!(
927 err.to_string().contains("semantic_cache_threshold"),
928 "unexpected error: {err}"
929 );
930 }
931
932 #[test]
933 fn semantic_cache_threshold_invalid_neg_infinity() {
934 let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
935 assert!(
936 err.to_string().contains("semantic_cache_threshold"),
937 "unexpected error: {err}"
938 );
939 }
940
941 fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
942 let mut cfg = Config::default();
943 cfg.memory.compression.probe.enabled = enabled;
944 cfg.memory.compression.probe.threshold = threshold;
945 cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
946 cfg
947 }
948
949 #[test]
950 fn probe_disabled_skips_validation() {
951 let cfg = probe_config(false, 0.0, 1.0);
953 assert!(cfg.validate().is_ok());
954 }
955
956 #[test]
957 fn probe_valid_thresholds() {
958 let cfg = probe_config(true, 0.6, 0.35);
959 assert!(cfg.validate().is_ok());
960 }
961
962 #[test]
963 fn probe_threshold_zero_invalid() {
964 let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
965 assert!(
966 err.to_string().contains("probe.threshold"),
967 "unexpected error: {err}"
968 );
969 }
970
971 #[test]
972 fn probe_hard_fail_threshold_above_one_invalid() {
973 let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
974 assert!(
975 err.to_string().contains("probe.hard_fail_threshold"),
976 "unexpected error: {err}"
977 );
978 }
979
980 #[test]
981 fn probe_hard_fail_gte_threshold_invalid() {
982 let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
983 assert!(
984 err.to_string().contains("probe.hard_fail_threshold"),
985 "unexpected error: {err}"
986 );
987 }
988
989 fn config_with_completeness_threshold(ct: f32) -> Config {
990 let mut cfg = Config::default();
991 cfg.orchestration.completeness_threshold = ct;
992 cfg
993 }
994
995 #[test]
996 fn completeness_threshold_valid_zero() {
997 assert!(config_with_completeness_threshold(0.0).validate().is_ok());
998 }
999
1000 #[test]
1001 fn completeness_threshold_valid_default() {
1002 assert!(config_with_completeness_threshold(0.7).validate().is_ok());
1003 }
1004
1005 #[test]
1006 fn completeness_threshold_valid_one() {
1007 assert!(config_with_completeness_threshold(1.0).validate().is_ok());
1008 }
1009
1010 #[test]
1011 fn completeness_threshold_invalid_negative() {
1012 let err = config_with_completeness_threshold(-0.1)
1013 .validate()
1014 .unwrap_err();
1015 assert!(
1016 err.to_string().contains("completeness_threshold"),
1017 "unexpected error: {err}"
1018 );
1019 }
1020
1021 #[test]
1022 fn completeness_threshold_invalid_above_one() {
1023 let err = config_with_completeness_threshold(1.1)
1024 .validate()
1025 .unwrap_err();
1026 assert!(
1027 err.to_string().contains("completeness_threshold"),
1028 "unexpected error: {err}"
1029 );
1030 }
1031
1032 #[test]
1033 fn completeness_threshold_invalid_nan() {
1034 let err = config_with_completeness_threshold(f32::NAN)
1035 .validate()
1036 .unwrap_err();
1037 assert!(
1038 err.to_string().contains("completeness_threshold"),
1039 "unexpected error: {err}"
1040 );
1041 }
1042
1043 #[test]
1044 fn completeness_threshold_invalid_infinity() {
1045 let err = config_with_completeness_threshold(f32::INFINITY)
1046 .validate()
1047 .unwrap_err();
1048 assert!(
1049 err.to_string().contains("completeness_threshold"),
1050 "unexpected error: {err}"
1051 );
1052 }
1053
1054 fn config_with_provider(name: &str) -> Config {
1055 let mut cfg = Config::default();
1056 cfg.llm.providers.push(crate::providers::ProviderEntry {
1057 provider_type: crate::providers::ProviderKind::Ollama,
1058 name: Some(name.into()),
1059 ..Default::default()
1060 });
1061 cfg
1062 }
1063
1064 #[test]
1065 fn validate_provider_names_all_empty_ok() {
1066 let cfg = Config::default();
1067 assert!(cfg.validate_provider_names().is_ok());
1068 }
1069
1070 #[test]
1071 fn validate_provider_names_matching_provider_ok() {
1072 let mut cfg = config_with_provider("fast");
1073 cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
1074 assert!(cfg.validate_provider_names().is_ok());
1075 }
1076
1077 #[test]
1078 fn validate_provider_names_unknown_provider_err() {
1079 let mut cfg = config_with_provider("fast");
1080 cfg.memory.admission.admission_provider =
1081 crate::providers::ProviderName::new("nonexistent");
1082 let err = cfg.validate_provider_names().unwrap_err();
1083 let msg = err.to_string();
1084 assert!(
1085 msg.contains("admission_provider") && msg.contains("nonexistent"),
1086 "unexpected error: {msg}"
1087 );
1088 }
1089
1090 #[test]
1091 fn validate_provider_names_triage_provider_none_ok() {
1092 let mut cfg = config_with_provider("fast");
1093 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1094 triage_provider: None,
1095 ..Default::default()
1096 });
1097 assert!(cfg.validate_provider_names().is_ok());
1098 }
1099
1100 #[test]
1101 fn validate_provider_names_triage_provider_matching_ok() {
1102 let mut cfg = config_with_provider("fast");
1103 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1104 triage_provider: Some(crate::providers::ProviderName::new("fast")),
1105 ..Default::default()
1106 });
1107 assert!(cfg.validate_provider_names().is_ok());
1108 }
1109
1110 #[test]
1111 fn validate_provider_names_triage_provider_unknown_err() {
1112 let mut cfg = config_with_provider("fast");
1113 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1114 triage_provider: Some(crate::providers::ProviderName::new("ghost")),
1115 ..Default::default()
1116 });
1117 let err = cfg.validate_provider_names().unwrap_err();
1118 let msg = err.to_string();
1119 assert!(
1120 msg.contains("triage_provider") && msg.contains("ghost"),
1121 "unexpected error: {msg}"
1122 );
1123 }
1124
1125 #[test]
1128 fn toml_float_fields_deserialise_correctly() {
1129 let toml = r"
1130[llm.router.reputation]
1131enabled = true
1132decay_factor = 0.95
1133weight = 0.3
1134
1135[llm.router.bandit]
1136enabled = false
1137cost_weight = 0.3
1138alpha = 1.0
1139decay_factor = 0.99
1140
1141[skills]
1142disambiguation_threshold = 0.25
1143cosine_weight = 0.7
1144";
1145 let wrapped = format!(
1147 "{}\n{}",
1148 toml,
1149 r"[memory.semantic]
1150mmr_lambda = 0.7
1151"
1152 );
1153 let router: crate::providers::RouterConfig = toml::from_str(
1155 r"[reputation]
1156enabled = true
1157decay_factor = 0.95
1158weight = 0.3
1159",
1160 )
1161 .expect("RouterConfig with float fields must deserialise");
1162 assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
1163
1164 let bandit: crate::providers::BanditConfig =
1165 toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
1166 .expect("BanditConfig with float fields must deserialise");
1167 assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
1168
1169 let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
1170 .expect("SemanticConfig with float fields must deserialise");
1171 assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
1172
1173 let skills: crate::features::SkillsConfig =
1174 toml::from_str("disambiguation_threshold = 0.25\n")
1175 .expect("SkillsConfig with float fields must deserialise");
1176 assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
1177
1178 let _ = wrapped; }
1180
1181 #[test]
1182 fn validate_max_parallel_zero_rejected() {
1183 let mut cfg = Config::default();
1184 cfg.orchestration.max_parallel = 0;
1185 let err = cfg.validate().unwrap_err().to_string();
1186 assert!(
1187 err.contains("max_parallel"),
1188 "expected max_parallel in error, got: {err}"
1189 );
1190 }
1191
1192 #[test]
1193 fn validate_max_parallel_one_accepted() {
1194 let mut cfg = Config::default();
1195 cfg.orchestration.max_parallel = 1;
1196 assert!(cfg.validate().is_ok());
1197 }
1198
1199 #[test]
1200 fn validate_max_tasks_zero_rejected() {
1201 let mut cfg = Config::default();
1202 cfg.orchestration.max_tasks = 0;
1203 let err = cfg.validate().unwrap_err().to_string();
1204 assert!(
1205 err.contains("max_tasks"),
1206 "expected max_tasks in error, got: {err}"
1207 );
1208 }
1209
1210 #[test]
1211 fn validate_max_tasks_one_accepted() {
1212 let mut cfg = Config::default();
1213 cfg.orchestration.max_tasks = 1;
1214 assert!(cfg.validate().is_ok());
1215 }
1216
1217 #[test]
1218 fn validate_aggregator_timeout_zero_rejected() {
1219 let mut cfg = Config::default();
1220 cfg.orchestration.aggregator_timeout_secs = 0;
1221 let err = cfg.validate().unwrap_err().to_string();
1222 assert!(
1223 err.contains("aggregator_timeout_secs"),
1224 "expected aggregator_timeout_secs in error, got: {err}"
1225 );
1226 }
1227
1228 #[test]
1229 fn validate_planner_timeout_zero_rejected() {
1230 let mut cfg = Config::default();
1231 cfg.orchestration.planner_timeout_secs = 0;
1232 let err = cfg.validate().unwrap_err().to_string();
1233 assert!(
1234 err.contains("planner_timeout_secs"),
1235 "expected planner_timeout_secs in error, got: {err}"
1236 );
1237 }
1238
1239 #[test]
1240 fn validate_verifier_timeout_zero_rejected() {
1241 let mut cfg = Config::default();
1242 cfg.orchestration.verifier_timeout_secs = 0;
1243 let err = cfg.validate().unwrap_err().to_string();
1244 assert!(
1245 err.contains("verifier_timeout_secs"),
1246 "expected verifier_timeout_secs in error, got: {err}"
1247 );
1248 }
1249
1250 #[test]
1251 fn validate_default_idle_timeout_zero_rejected() {
1252 let mut cfg = Config::default();
1253 cfg.orchestration.default_idle_timeout_secs = Some(0);
1254 let err = cfg.validate().unwrap_err().to_string();
1255 assert!(
1256 err.contains("default_idle_timeout_secs"),
1257 "expected default_idle_timeout_secs in error, got: {err}"
1258 );
1259 }
1260
1261 #[test]
1262 fn validate_default_idle_timeout_none_accepted() {
1263 let mut cfg = Config::default();
1264 cfg.orchestration.default_idle_timeout_secs = None;
1265 assert!(cfg.validate().is_ok());
1266 }
1267
1268 #[test]
1269 fn validate_default_idle_timeout_positive_accepted() {
1270 let mut cfg = Config::default();
1271 cfg.orchestration.default_idle_timeout_secs = Some(60);
1272 assert!(cfg.validate().is_ok());
1273 }
1274
1275 #[test]
1276 fn validate_command_max_handoffs_zero_rejected() {
1277 let mut cfg = Config::default();
1278 cfg.orchestration.command.max_handoffs = 0;
1279 let err = cfg.validate().unwrap_err().to_string();
1280 assert!(
1281 err.contains("max_handoffs"),
1282 "expected max_handoffs in error, got: {err}"
1283 );
1284 }
1285
1286 #[test]
1287 fn validate_command_max_handoffs_default_accepted() {
1288 let cfg = Config::default();
1289 assert_eq!(cfg.orchestration.command.max_handoffs, 16);
1290 assert!(!cfg.orchestration.command.enabled);
1291 assert!(cfg.validate().is_ok());
1292 }
1293
1294 #[test]
1297 fn validate_command_max_handoffs_over_10000_rejected() {
1298 let mut cfg = Config::default();
1299 cfg.orchestration.command.max_handoffs = 10_001;
1300 let err = cfg.validate().unwrap_err().to_string();
1301 assert!(
1302 err.contains("max_handoffs") && err.contains("<= 10000"),
1303 "expected max_handoffs upper-bound error, got: {err}"
1304 );
1305 }
1306
1307 #[test]
1308 fn validate_command_max_handoffs_exactly_10000_accepted() {
1309 let mut cfg = Config::default();
1310 cfg.orchestration.command.max_handoffs = 10_000;
1311 assert!(cfg.validate().is_ok());
1312 }
1313
1314 fn config_with_command_enabled() -> Config {
1317 let mut cfg = Config::default();
1318 cfg.orchestration.command.enabled = true;
1319 cfg.memory.store.enabled = true;
1320 cfg.security.content_isolation.enabled = true;
1321 cfg.security.content_isolation.flag_injection_patterns = true;
1322 cfg
1323 }
1324
1325 #[test]
1326 fn validate_command_enabled_with_all_prerequisites_accepted() {
1327 assert!(config_with_command_enabled().validate().is_ok());
1328 }
1329
1330 #[test]
1331 fn validate_command_enabled_without_store_enabled_rejected() {
1332 let mut cfg = config_with_command_enabled();
1333 cfg.memory.store.enabled = false;
1334 let err = cfg.validate().unwrap_err().to_string();
1335 assert!(
1336 err.contains("memory.store.enabled"),
1337 "expected store-prerequisite error, got: {err}"
1338 );
1339 }
1340
1341 #[test]
1342 fn validate_command_enabled_without_content_isolation_enabled_rejected() {
1343 let mut cfg = config_with_command_enabled();
1344 cfg.security.content_isolation.enabled = false;
1345 let err = cfg.validate().unwrap_err().to_string();
1346 assert!(
1347 err.contains("content_isolation.enabled"),
1348 "expected content_isolation-prerequisite error, got: {err}"
1349 );
1350 }
1351
1352 #[test]
1353 fn validate_command_enabled_without_flag_injection_patterns_rejected() {
1354 let mut cfg = config_with_command_enabled();
1355 cfg.security.content_isolation.flag_injection_patterns = false;
1356 let err = cfg.validate().unwrap_err().to_string();
1357 assert!(
1358 err.contains("flag_injection_patterns"),
1359 "expected flag_injection_patterns-prerequisite error, got: {err}"
1360 );
1361 }
1362
1363 #[test]
1364 fn validate_command_disabled_ignores_store_and_content_isolation_state() {
1365 let mut cfg = Config::default();
1368 cfg.memory.store.enabled = false;
1369 cfg.security.content_isolation.enabled = false;
1370 cfg.security.content_isolation.flag_injection_patterns = false;
1371 assert!(cfg.validate().is_ok());
1372 }
1373
1374 #[test]
1375 fn focus_auto_consolidate_min_window_zero_rejected() {
1376 let mut cfg = Config::default();
1377 cfg.agent.focus.auto_consolidate_min_window = 0;
1378 let err = cfg.validate().unwrap_err().to_string();
1379 assert!(
1380 err.contains("auto_consolidate_min_window"),
1381 "expected auto_consolidate_min_window in error, got: {err}"
1382 );
1383 }
1384
1385 #[test]
1386 fn focus_auto_consolidate_min_window_one_accepted() {
1387 let mut cfg = Config::default();
1388 cfg.agent.focus.auto_consolidate_min_window = 1;
1389 assert!(cfg.validate().is_ok());
1390 }
1391
1392 fn task_with(cron: Option<&str>, run_at: Option<&str>) -> crate::features::ScheduledTaskConfig {
1393 crate::features::ScheduledTaskConfig {
1394 name: "test-task".into(),
1395 cron: cron.map(Into::into),
1396 run_at: run_at.map(Into::into),
1397 kind: crate::features::ScheduledTaskKind::HealthCheck,
1398 config: serde_json::Value::Null,
1399 }
1400 }
1401
1402 #[test]
1403 fn scheduler_task_valid_cron() {
1404 let mut cfg = Config::default();
1405 cfg.scheduler.tasks.push(task_with(Some("0 9 * * *"), None));
1406 assert!(cfg.validate().is_ok());
1407 }
1408
1409 #[test]
1410 fn scheduler_task_valid_run_at() {
1411 let mut cfg = Config::default();
1412 cfg.scheduler
1413 .tasks
1414 .push(task_with(None, Some("2025-01-01T09:00:00Z")));
1415 assert!(cfg.validate().is_ok());
1416 }
1417
1418 #[test]
1419 fn scheduler_task_neither_cron_nor_run_at_rejected() {
1420 let mut cfg = Config::default();
1421 cfg.scheduler.tasks.push(task_with(None, None));
1422 let err = cfg.validate().unwrap_err().to_string();
1423 assert!(
1424 err.contains("either `cron` or `run_at` must be set"),
1425 "unexpected error: {err}"
1426 );
1427 }
1428
1429 #[test]
1430 fn scheduler_task_both_cron_and_run_at_rejected() {
1431 let mut cfg = Config::default();
1432 cfg.scheduler
1433 .tasks
1434 .push(task_with(Some("0 9 * * *"), Some("2025-01-01T09:00:00Z")));
1435 let err = cfg.validate().unwrap_err().to_string();
1436 assert!(
1437 err.contains("only one of `cron` or `run_at` may be set"),
1438 "unexpected error: {err}"
1439 );
1440 }
1441
1442 #[test]
1445 fn validate_rejects_empty_provider_pool() {
1446 let mut cfg = Config::default();
1452 cfg.llm.providers.clear();
1453 let err = cfg.validate().unwrap_err().to_string();
1454 assert!(
1455 err.contains("at least one LLM provider"),
1456 "expected empty-pool error, got: {err}"
1457 );
1458 }
1459
1460 #[test]
1461 fn validate_rejects_duplicate_provider_names() {
1462 let mut cfg = Config::default();
1463 cfg.llm.providers.push(crate::providers::ProviderEntry {
1464 provider_type: crate::providers::ProviderKind::Ollama,
1465 ..Default::default()
1466 });
1467 let err = cfg.validate().unwrap_err().to_string();
1468 assert!(
1469 err.contains("duplicate provider name"),
1470 "expected duplicate-name error, got: {err}"
1471 );
1472 }
1473
1474 #[test]
1475 fn validate_rejects_multiple_default_providers() {
1476 let mut cfg = Config::default();
1477 cfg.llm.providers[0].default = true;
1478 cfg.llm.providers.push(crate::providers::ProviderEntry {
1479 provider_type: crate::providers::ProviderKind::Ollama,
1480 name: Some("second".into()),
1481 default: true,
1482 ..Default::default()
1483 });
1484 let err = cfg.validate().unwrap_err().to_string();
1485 assert!(
1486 err.contains("default = true"),
1487 "expected multiple-default error, got: {err}"
1488 );
1489 }
1490
1491 #[test]
1492 fn validate_rejects_stt_provider_pointing_at_nonexistent_provider() {
1493 let mut cfg = Config::default();
1494 cfg.llm.stt = Some(crate::providers::SttConfig {
1495 provider: crate::providers::ProviderName::new("ghost"),
1496 language: crate::providers::default_stt_language(),
1497 });
1498 let err = cfg.validate().unwrap_err().to_string();
1499 assert!(
1500 err.contains("[llm.stt].provider") && err.contains("ghost"),
1501 "expected stt-provider-mismatch error, got: {err}"
1502 );
1503 }
1504
1505 #[test]
1506 fn validate_accepts_stt_provider_matching_existing_provider() {
1507 let mut cfg = Config::default();
1508 cfg.llm.stt = Some(crate::providers::SttConfig {
1509 provider: crate::providers::ProviderName::new("ollama"),
1510 language: crate::providers::default_stt_language(),
1511 });
1512 assert!(cfg.validate().is_ok());
1513 }
1514
1515 #[test]
1516 fn validate_rejects_trajectory_sentinel_inverted_thresholds() {
1517 let mut cfg = Config::default();
1518 cfg.security.trajectory.elevated_at = 0.9;
1519 cfg.security.trajectory.high_at = 0.5;
1520 let err = cfg.validate().unwrap_err().to_string();
1521 assert!(
1522 err.contains("elevated_at") && err.contains("high_at"),
1523 "expected trajectory threshold-ordering error, got: {err}"
1524 );
1525 }
1526
1527 #[test]
1528 fn validate_rejects_gateway_invalid_webhook_timeout() {
1529 let mut cfg = Config::default();
1535 cfg.gateway.webhook_send_timeout_secs = 0;
1536 let err = cfg.validate().unwrap_err().to_string();
1537 assert!(
1538 err.contains("webhook_send_timeout_secs"),
1539 "expected gateway webhook_send_timeout_secs error, got: {err}"
1540 );
1541 }
1542
1543 #[test]
1544 fn validate_rejects_negative_utility_scoring_weight() {
1545 let mut cfg = Config::default();
1546 cfg.tools.utility.gain_weight = -1.0;
1547 let err = cfg.validate().unwrap_err().to_string();
1548 assert!(
1549 err.contains("gain_weight"),
1550 "expected utility-scoring weight error, got: {err}"
1551 );
1552 }
1553
1554 #[test]
1555 fn validate_rejects_fidelity_threshold_ordering() {
1556 let mut cfg = Config::default();
1557 cfg.memory.fidelity = Some(crate::fidelity::FidelityConfig {
1558 full_threshold: 0.2,
1559 compressed_threshold: 0.5,
1560 ..Default::default()
1561 });
1562 let err = cfg.validate().unwrap_err().to_string();
1563 assert!(
1564 err.contains("full_threshold") && err.contains("compressed_threshold"),
1565 "expected fidelity threshold-ordering error, got: {err}"
1566 );
1567 }
1568
1569 #[test]
1570 fn validate_accepts_absent_fidelity_config() {
1571 let cfg = Config::default();
1572 assert!(cfg.memory.fidelity.is_none());
1573 assert!(cfg.validate().is_ok());
1574 }
1575
1576 #[test]
1577 fn validate_rejects_acon_inverted_thresholds() {
1578 let mut cfg = Config::default();
1579 cfg.memory.compression.acon.passthrough_threshold = 5000;
1580 cfg.memory.compression.acon.summarize_threshold = 1000;
1581 let err = cfg.validate().unwrap_err().to_string();
1582 assert!(
1583 err.contains("passthrough_threshold") && err.contains("summarize_threshold"),
1584 "expected acon threshold-ordering error, got: {err}"
1585 );
1586 }
1587
1588 #[test]
1589 fn validate_rejects_shadow_memory_inverted_thresholds() {
1590 let mut cfg = Config::default();
1591 cfg.memory.shadow_memory.enabled = true;
1592 cfg.memory.shadow_memory.escalation_threshold = 0.75;
1593 cfg.memory.shadow_memory.risk_threshold = 0.50;
1594 let err = cfg.validate().unwrap_err().to_string();
1595 assert!(
1596 err.contains("escalation_threshold") && err.contains("risk_threshold"),
1597 "expected shadow_memory threshold-ordering error, got: {err}"
1598 );
1599 }
1600
1601 #[test]
1602 fn validate_rejects_shadow_memory_equal_thresholds() {
1603 let mut cfg = Config::default();
1604 cfg.memory.shadow_memory.enabled = true;
1605 cfg.memory.shadow_memory.escalation_threshold = 0.6;
1606 cfg.memory.shadow_memory.risk_threshold = 0.6;
1607 assert!(
1608 cfg.validate().is_err(),
1609 "equal thresholds must be rejected — the escalation band would be empty"
1610 );
1611 }
1612
1613 #[test]
1614 fn validate_ignores_shadow_memory_thresholds_when_disabled() {
1615 let mut cfg = Config::default();
1616 cfg.memory.shadow_memory.enabled = false;
1617 cfg.memory.shadow_memory.escalation_threshold = 0.9;
1618 cfg.memory.shadow_memory.risk_threshold = 0.1;
1619 assert!(
1620 cfg.validate().is_ok(),
1621 "inverted thresholds on a disabled shadow_memory config must not fail validation"
1622 );
1623 }
1624
1625 #[test]
1626 fn validate_rejects_worktree_max_worktrees_zero() {
1627 let mut cfg = Config::default();
1628 cfg.worktree.max_worktrees = Some(0);
1629 let err = cfg.validate().unwrap_err().to_string();
1630 assert!(
1631 err.contains("max_worktrees"),
1632 "expected max_worktrees in error, got: {err}"
1633 );
1634 }
1635
1636 #[test]
1637 fn validate_accepts_worktree_max_worktrees_positive_or_unset() {
1638 let mut cfg = Config::default();
1639 cfg.worktree.max_worktrees = Some(1);
1640 assert!(cfg.validate().is_ok());
1641 cfg.worktree.max_worktrees = None;
1642 assert!(cfg.validate().is_ok());
1643 }
1644
1645 #[test]
1646 fn validate_rejects_worktree_disk_quota_mb_zero() {
1647 let mut cfg = Config::default();
1648 cfg.worktree.disk_quota_mb = Some(0);
1649 let err = cfg.validate().unwrap_err().to_string();
1650 assert!(
1651 err.contains("disk_quota_mb"),
1652 "expected disk_quota_mb in error, got: {err}"
1653 );
1654 }
1655
1656 #[test]
1657 fn validate_accepts_worktree_disk_quota_mb_positive_or_unset() {
1658 let mut cfg = Config::default();
1659 cfg.worktree.disk_quota_mb = Some(1);
1660 assert!(cfg.validate().is_ok());
1661 cfg.worktree.disk_quota_mb = None;
1662 assert!(cfg.validate().is_ok());
1663 }
1664
1665 #[test]
1669 fn validate_rejects_worktree_disk_quota_mb_with_no_evaluation_path_enabled() {
1670 let mut cfg = Config::default();
1671 cfg.worktree.disk_quota_mb = Some(100);
1672 cfg.worktree.auto_reconcile_secs = 0;
1673 cfg.worktree.reconcile_on_startup = false;
1674 let err = cfg.validate().unwrap_err().to_string();
1675 assert!(
1676 err.contains("disk_quota_mb") && err.contains("never automatically"),
1677 "expected inert-path error, got: {err}"
1678 );
1679 }
1680
1681 #[test]
1682 fn validate_accepts_worktree_disk_quota_mb_when_startup_sweep_enabled() {
1683 let mut cfg = Config::default();
1684 cfg.worktree.disk_quota_mb = Some(100);
1685 cfg.worktree.auto_reconcile_secs = 0;
1686 cfg.worktree.reconcile_on_startup = true;
1687 assert!(cfg.validate().is_ok());
1688 }
1689
1690 #[test]
1691 fn validate_accepts_worktree_disk_quota_mb_when_periodic_sweep_enabled() {
1692 let mut cfg = Config::default();
1693 cfg.worktree.disk_quota_mb = Some(100);
1694 cfg.worktree.auto_reconcile_secs = 3600;
1695 cfg.worktree.reconcile_on_startup = false;
1696 assert!(cfg.validate().is_ok());
1697 }
1698
1699 #[test]
1702 fn validate_rejects_worktree_auto_reconcile_secs_short_interval() {
1703 let mut cfg = Config::default();
1704 cfg.worktree.auto_reconcile_secs = 1;
1705 let err = cfg.validate().unwrap_err().to_string();
1706 assert!(
1707 err.contains("auto_reconcile_secs"),
1708 "expected auto_reconcile_secs in error, got: {err}"
1709 );
1710 }
1711
1712 #[test]
1713 fn validate_accepts_worktree_auto_reconcile_secs_zero_or_at_least_60() {
1714 let mut cfg = Config::default();
1715 cfg.worktree.auto_reconcile_secs = 0;
1716 assert!(cfg.validate().is_ok());
1717 cfg.worktree.auto_reconcile_secs = 60;
1718 assert!(cfg.validate().is_ok());
1719 cfg.worktree.auto_reconcile_secs = 3600;
1720 assert!(cfg.validate().is_ok());
1721 }
1722
1723 #[test]
1728 fn dump_defaults_output_is_self_consistent_and_validates() {
1729 assert!(Config::default().validate().is_ok());
1730
1731 let dumped = Config::dump_defaults().expect("dump defaults");
1732 assert!(
1733 dumped.contains("[[llm.providers]]"),
1734 "dumped defaults must include an active provider entry, got:\n{dumped}"
1735 );
1736 let reparsed: Config = toml::from_str(&dumped).expect("reparse dumped defaults");
1737 assert!(reparsed.validate().is_ok());
1738 }
1739
1740 fn config_with_ensemble(enabled: bool, verify: bool, members: Vec<&str>) -> Config {
1743 let mut cfg = Config::default();
1744 cfg.orchestration.ensemble.enabled = enabled;
1745 cfg.orchestration.ensemble.verify = verify;
1746 cfg.orchestration.ensemble.members = members.into_iter().map(String::from).collect();
1747 cfg
1748 }
1749
1750 #[test]
1751 fn ensemble_default_config_validates_trivially() {
1752 assert!(Config::default().validate().is_ok());
1753 }
1754
1755 #[test]
1756 fn ensemble_disabled_skips_member_list_validation() {
1757 let cfg = config_with_ensemble(false, false, vec!["a", "b"]);
1759 assert!(cfg.validate().is_ok());
1760 }
1761
1762 #[test]
1763 fn ensemble_enabled_but_not_verify_skips_member_list_validation() {
1764 let cfg = config_with_ensemble(true, false, vec!["a", "b"]);
1766 assert!(cfg.validate().is_ok());
1767 }
1768
1769 #[test]
1770 fn ensemble_active_even_length_members_rejected() {
1771 let cfg = config_with_ensemble(true, true, vec!["a", "b"]);
1772 let err = cfg.validate().unwrap_err();
1773 assert!(
1774 err.to_string().contains("must be odd and >= 3"),
1775 "unexpected error: {err}"
1776 );
1777 }
1778
1779 #[test]
1780 fn ensemble_active_short_members_rejected() {
1781 let cfg = config_with_ensemble(true, true, vec!["a"]);
1782 let err = cfg.validate().unwrap_err();
1783 assert!(
1784 err.to_string().contains("must be odd and >= 3"),
1785 "unexpected error: {err}"
1786 );
1787 }
1788
1789 #[test]
1790 fn ensemble_active_duplicate_members_rejected() {
1791 let cfg = config_with_ensemble(true, true, vec!["a", "b", "a"]);
1792 let err = cfg.validate().unwrap_err();
1793 assert!(
1794 err.to_string().contains("duplicate provider name"),
1795 "unexpected error: {err}"
1796 );
1797 }
1798
1799 #[test]
1800 fn ensemble_active_valid_odd_unique_members_accepted() {
1801 let cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1802 assert!(cfg.validate().is_ok());
1803 }
1804
1805 #[test]
1806 fn ensemble_active_valid_five_members_accepted() {
1807 let cfg = config_with_ensemble(true, true, vec!["a", "b", "c", "d", "e"]);
1808 assert!(cfg.validate().is_ok());
1809 }
1810
1811 #[test]
1814 fn ensemble_active_ema_alpha_above_one_rejected() {
1815 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1816 cfg.orchestration.ensemble.ema_alpha = 1.5;
1817 let err = cfg.validate().unwrap_err();
1818 assert!(
1819 err.to_string().contains("ema_alpha"),
1820 "unexpected error: {err}"
1821 );
1822 }
1823
1824 #[test]
1825 fn ensemble_active_ema_alpha_negative_rejected() {
1826 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1827 cfg.orchestration.ensemble.ema_alpha = -0.1;
1828 let err = cfg.validate().unwrap_err();
1829 assert!(
1830 err.to_string().contains("ema_alpha"),
1831 "unexpected error: {err}"
1832 );
1833 }
1834
1835 #[test]
1836 fn ensemble_active_ema_alpha_nan_rejected() {
1837 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1838 cfg.orchestration.ensemble.ema_alpha = f64::NAN;
1839 let err = cfg.validate().unwrap_err();
1840 assert!(
1841 err.to_string().contains("ema_alpha"),
1842 "unexpected error: {err}"
1843 );
1844 }
1845
1846 #[test]
1847 fn ensemble_active_ema_decay_above_one_rejected() {
1848 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1849 cfg.orchestration.ensemble.ema_decay = 1.1;
1850 let err = cfg.validate().unwrap_err();
1851 assert!(
1852 err.to_string().contains("ema_decay"),
1853 "unexpected error: {err}"
1854 );
1855 }
1856
1857 #[test]
1858 fn ensemble_active_ema_decay_negative_rejected() {
1859 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1860 cfg.orchestration.ensemble.ema_decay = -0.1;
1861 let err = cfg.validate().unwrap_err();
1862 assert!(
1863 err.to_string().contains("ema_decay"),
1864 "unexpected error: {err}"
1865 );
1866 }
1867
1868 #[test]
1869 fn ensemble_active_ema_boundaries_zero_and_one_accepted() {
1870 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1871 cfg.orchestration.ensemble.ema_alpha = 0.0;
1872 cfg.orchestration.ensemble.ema_decay = 1.0;
1873 assert!(cfg.validate().is_ok());
1874 }
1875
1876 #[test]
1877 fn ensemble_disabled_skips_ema_range_validation() {
1878 let mut cfg = config_with_ensemble(false, false, vec![]);
1880 cfg.orchestration.ensemble.ema_alpha = 5.0;
1881 assert!(cfg.validate().is_ok());
1882 }
1883}