1use crate::types::AgenticConfig;
8use std::collections::BTreeSet;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AdvisoryWarning {
13 pub code: &'static str,
15
16 pub message: String,
18
19 pub path: &'static str,
21}
22
23impl AdvisoryWarning {
24 pub fn new(code: &'static str, path: &'static str, message: impl Into<String>) -> Self {
26 Self {
27 code,
28 path,
29 message: message.into(),
30 }
31 }
32}
33
34impl std::fmt::Display for AdvisoryWarning {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "[{}] {}: {}", self.code, self.path, self.message)
37 }
38}
39
40pub fn detect_deprecated_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
46 let mut warnings = Vec::new();
47
48 if let Some(tbl) = v.as_table() {
49 if let Some(thoughts) = tbl.get("thoughts").and_then(toml::Value::as_table)
50 && thoughts.contains_key("mount_dirs")
51 {
52 warnings.push(AdvisoryWarning::new(
53 "config.deprecated.thoughts.mount_dirs",
54 "thoughts.mount_dirs",
55 "The legacy thoughts.mount_dirs key is no longer supported. The agentic [thoughts] section now only models add_reference_timeout_secs.",
56 ));
57 }
58 if tbl.contains_key("models") {
59 warnings.push(AdvisoryWarning::new(
60 "config.deprecated.models",
61 "models",
62 "The 'models' section has been replaced by 'subagents' and 'reasoning'.",
63 ));
64 }
65 }
66
67 warnings
68}
69
70const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
79 "$schema",
80 "subagents",
81 "reasoning",
82 "services",
83 "orchestrator",
84 "web_retrieval",
85 "cli_tools",
86 "workspace_tools",
87 "review",
88 "thoughts",
89 "logging",
90];
91
92const GPT5_2_COMPLETION_TOKENS_DOC_MAX: u32 = 128_000;
93
94pub fn detect_unknown_top_level_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
99 let mut warnings = Vec::new();
100 let Some(tbl) = v.as_table() else {
101 return warnings;
102 };
103
104 for key in tbl.keys() {
105 if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
106 warnings.push(AdvisoryWarning::new(
107 "config.unknown_top_level_key",
108 "$",
109 format!("Unknown top-level key '{key}' will be ignored"),
110 ));
111 }
112 }
113
114 warnings
115}
116
117pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
122 let mut warnings = vec![];
123
124 validate_url(
126 &cfg.services.anthropic.base_url,
127 "services.anthropic.base_url",
128 "services.anthropic.base_url.invalid",
129 &mut warnings,
130 );
131
132 validate_url(
133 &cfg.services.exa.base_url,
134 "services.exa.base_url",
135 "services.exa.base_url.invalid",
136 &mut warnings,
137 );
138 validate_url(
139 &cfg.services.linear.base_url,
140 "services.linear.base_url",
141 "services.linear.base_url.invalid",
142 &mut warnings,
143 );
144 validate_url(
145 &cfg.services.github.base_url,
146 "services.github.base_url",
147 "services.github.base_url.invalid",
148 &mut warnings,
149 );
150
151 let valid_levels = ["trace", "debug", "info", "warn", "error"];
153 if !valid_levels.contains(&cfg.logging.level.to_lowercase().as_str()) {
154 warnings.push(AdvisoryWarning {
155 code: "logging.level.invalid",
156 path: "logging.level",
157 message: format!(
158 "Unknown log level '{}'. Expected one of: {}",
159 cfg.logging.level,
160 valid_levels.join(", ")
161 ),
162 });
163 }
164
165 if cfg.subagents.locator_model.trim().is_empty() {
167 warnings.push(AdvisoryWarning::new(
168 "subagents.locator_model.empty",
169 "subagents.locator_model",
170 "value is empty",
171 ));
172 }
173 if cfg.subagents.analyzer_model.trim().is_empty() {
174 warnings.push(AdvisoryWarning::new(
175 "subagents.analyzer_model.empty",
176 "subagents.analyzer_model",
177 "value is empty",
178 ));
179 }
180 validate_low_nonzero_timeout(
181 cfg.subagents.runtime_timeout_secs,
182 30,
183 "subagents.runtime_timeout_secs",
184 "subagents.runtime_timeout_secs.suspicious",
185 &mut warnings,
186 );
187
188 if cfg.reasoning.optimizer_model.trim().is_empty() {
190 warnings.push(AdvisoryWarning::new(
191 "reasoning.optimizer_model.empty",
192 "reasoning.optimizer_model",
193 "value is empty",
194 ));
195 }
196 if cfg.reasoning.executor_model.trim().is_empty() {
197 warnings.push(AdvisoryWarning::new(
198 "reasoning.executor_model.empty",
199 "reasoning.executor_model",
200 "value is empty",
201 ));
202 }
203
204 if !cfg.reasoning.optimizer_model.trim().is_empty()
206 && !cfg.reasoning.optimizer_model.contains('/')
207 {
208 warnings.push(AdvisoryWarning::new(
209 "reasoning.optimizer_model.format",
210 "reasoning.optimizer_model",
211 "expected OpenRouter format like `anthropic/claude-sonnet-4.6`",
212 ));
213 }
214
215 if !cfg.reasoning.executor_model.trim().is_empty()
216 && !cfg.reasoning.executor_model.contains('/')
217 {
218 warnings.push(AdvisoryWarning::new(
219 "reasoning.executor_model.format",
220 "reasoning.executor_model",
221 "expected OpenRouter format like `openai/gpt-5.2`",
222 ));
223 } else if !cfg.reasoning.executor_model.trim().is_empty()
224 && !cfg
225 .reasoning
226 .executor_model
227 .to_lowercase()
228 .contains("gpt-5")
229 {
230 warnings.push(AdvisoryWarning::new(
231 "reasoning.executor_model.suspicious",
232 "reasoning.executor_model",
233 "executor_model does not look like a GPT-5 model; reasoning_effort may not work",
234 ));
235 }
236
237 if let Some(eff) = cfg.reasoning.reasoning_effort.as_deref() {
239 let eff_lc = eff.trim().to_lowercase();
240 if !matches!(eff_lc.as_str(), "low" | "medium" | "high" | "xhigh") {
241 warnings.push(AdvisoryWarning::new(
242 "reasoning.reasoning_effort.invalid",
243 "reasoning.reasoning_effort",
244 "expected one of: low, medium, high, xhigh",
245 ));
246 }
247 }
248
249 if cfg
250 .reasoning
251 .executor_model
252 .to_lowercase()
253 .contains("gpt-5.2")
254 && let Some(n) = cfg.reasoning.max_completion_tokens
255 && n > GPT5_2_COMPLETION_TOKENS_DOC_MAX
256 {
257 warnings.push(AdvisoryWarning::new(
258 "reasoning.max_completion_tokens.exceeds_doc",
259 "reasoning.max_completion_tokens",
260 format!(
261 "max_completion_tokens={n} exceeds documented GPT-5.2 ceiling {GPT5_2_COMPLETION_TOKENS_DOC_MAX}; request may be rejected or truncate unexpectedly (warn-only; not clamped)."
262 ),
263 ));
264 }
265
266 if let Some(n) = cfg.reasoning.max_input_tokens
267 && n > 250_000
268 {
269 warnings.push(AdvisoryWarning::new(
270 "reasoning.max_input_tokens.suspicious",
271 "reasoning.max_input_tokens",
272 format!(
273 "max_input_tokens={n} exceeds the tool's default prompt cap (250000); ensure executor model supports this context size (warn-only)."
274 ),
275 ));
276 }
277
278 if !(0.0..=1.0).contains(&cfg.orchestrator.compaction_threshold) {
280 warnings.push(AdvisoryWarning::new(
281 "orchestrator.compaction_threshold.out_of_range",
282 "orchestrator.compaction_threshold",
283 "expected a value between 0.0 and 1.0",
284 ));
285 }
286
287 validate_command_entries(
288 &cfg.orchestrator.commands.allow,
289 "orchestrator.commands.allow",
290 &mut warnings,
291 );
292 validate_command_entries(
293 &cfg.orchestrator.commands.deny,
294 "orchestrator.commands.deny",
295 &mut warnings,
296 );
297 validate_command_overlap(cfg, &mut warnings);
298 validate_agent_entries(
299 &cfg.orchestrator.agents.allow,
300 "orchestrator.agents.allow",
301 &mut warnings,
302 );
303 validate_agent_entries(
304 &cfg.orchestrator.agents.deny,
305 "orchestrator.agents.deny",
306 &mut warnings,
307 );
308 validate_agent_overlap(cfg, &mut warnings);
309
310 if cfg.web_retrieval.default_search_results > cfg.web_retrieval.max_search_results {
312 warnings.push(AdvisoryWarning::new(
313 "web_retrieval.default_exceeds_max",
314 "web_retrieval.default_search_results",
315 "default_search_results exceeds max_search_results",
316 ));
317 }
318
319 if cfg.web_retrieval.summarizer.model.trim().is_empty() {
321 warnings.push(AdvisoryWarning::new(
322 "web_retrieval.summarizer.model.empty",
323 "web_retrieval.summarizer.model",
324 "value is empty",
325 ));
326 }
327
328 if cfg.cli_tools.max_depth == 0 {
330 warnings.push(AdvisoryWarning::new(
331 "cli_tools.max_depth.zero",
332 "cli_tools.max_depth",
333 "max_depth is 0, directory listing may be limited",
334 ));
335 }
336
337 validate_low_nonzero_timeout(
338 cfg.cli_tools.just_execute_timeout_secs,
339 5,
340 "cli_tools.just_execute_timeout_secs",
341 "cli_tools.just_execute_timeout_secs.suspicious",
342 &mut warnings,
343 );
344 validate_low_nonzero_timeout(
345 cfg.cli_tools.just_search_timeout_secs,
346 2,
347 "cli_tools.just_search_timeout_secs",
348 "cli_tools.just_search_timeout_secs.suspicious",
349 &mut warnings,
350 );
351 validate_low_nonzero_timeout(
352 cfg.services.linear.connect_timeout_secs,
353 1,
354 "services.linear.connect_timeout_secs",
355 "services.linear.connect_timeout_secs.suspicious",
356 &mut warnings,
357 );
358 validate_low_nonzero_timeout(
359 cfg.services.linear.request_timeout_secs,
360 5,
361 "services.linear.request_timeout_secs",
362 "services.linear.request_timeout_secs.suspicious",
363 &mut warnings,
364 );
365 validate_low_nonzero_timeout(
366 cfg.services.github.total_timeout_secs,
367 5,
368 "services.github.total_timeout_secs",
369 "services.github.total_timeout_secs.suspicious",
370 &mut warnings,
371 );
372 validate_low_nonzero_timeout(
373 cfg.review.run_timeout_secs,
374 30,
375 "review.run_timeout_secs",
376 "review.run_timeout_secs.suspicious",
377 &mut warnings,
378 );
379 validate_low_nonzero_timeout(
380 cfg.thoughts.add_reference_timeout_secs,
381 5,
382 "thoughts.add_reference_timeout_secs",
383 "thoughts.add_reference_timeout_secs.suspicious",
384 &mut warnings,
385 );
386
387 warnings
388}
389
390fn validate_low_nonzero_timeout(
391 value: u64,
392 minimum_recommended: u64,
393 path: &'static str,
394 code: &'static str,
395 warnings: &mut Vec<AdvisoryWarning>,
396) {
397 if value != 0 && value < minimum_recommended {
398 warnings.push(AdvisoryWarning::new(
399 code,
400 path,
401 format!(
402 "value {value}s is very low; {minimum_recommended}s or higher is usually safer, or use 0 to disable"
403 ),
404 ));
405 }
406}
407
408fn validate_command_entries(
409 entries: &[String],
410 path: &'static str,
411 warnings: &mut Vec<AdvisoryWarning>,
412) {
413 let mut seen = BTreeSet::new();
414 let mut duplicates = BTreeSet::new();
415
416 for entry in entries {
417 let trimmed = entry.trim();
418
419 if trimmed.is_empty() {
420 warnings.push(AdvisoryWarning::new(
421 if path.ends_with("allow") {
422 "orchestrator.commands.allow.empty_entry"
423 } else {
424 "orchestrator.commands.deny.empty_entry"
425 },
426 path,
427 format!("entry {entry:?} becomes empty after trimming"),
428 ));
429 continue;
430 }
431
432 if trimmed != entry {
433 warnings.push(AdvisoryWarning::new(
434 if path.ends_with("allow") {
435 "orchestrator.commands.allow.trimmed_entry"
436 } else {
437 "orchestrator.commands.deny.trimmed_entry"
438 },
439 path,
440 format!(
441 "entry {entry:?} has surrounding whitespace; effective value is {trimmed:?}"
442 ),
443 ));
444 }
445
446 if !seen.insert(trimmed.to_string()) {
447 duplicates.insert(trimmed.to_string());
448 }
449 }
450
451 if !duplicates.is_empty() {
452 let duplicates = duplicates.into_iter().collect::<Vec<_>>().join(", ");
453 warnings.push(AdvisoryWarning::new(
454 if path.ends_with("allow") {
455 "orchestrator.commands.allow.duplicate"
456 } else {
457 "orchestrator.commands.deny.duplicate"
458 },
459 path,
460 format!("duplicate command entries after trimming: {duplicates}"),
461 ));
462 }
463}
464
465fn validate_command_overlap(cfg: &AgenticConfig, warnings: &mut Vec<AdvisoryWarning>) {
466 let allow = cfg
467 .orchestrator
468 .commands
469 .allow
470 .iter()
471 .map(|entry| entry.trim())
472 .filter(|entry| !entry.is_empty())
473 .map(str::to_string)
474 .collect::<BTreeSet<_>>();
475 let deny = cfg
476 .orchestrator
477 .commands
478 .deny
479 .iter()
480 .map(|entry| entry.trim())
481 .filter(|entry| !entry.is_empty())
482 .map(str::to_string)
483 .collect::<BTreeSet<_>>();
484
485 let overlap = allow.intersection(&deny).cloned().collect::<Vec<_>>();
486 if overlap.is_empty() {
487 return;
488 }
489
490 warnings.push(AdvisoryWarning::new(
491 "orchestrator.commands.overlap",
492 "orchestrator.commands",
493 format!(
494 "commands appear in both allow and deny: {}. deny wins at runtime",
495 overlap.join(", ")
496 ),
497 ));
498}
499
500fn validate_agent_entries(
501 entries: &[String],
502 path: &'static str,
503 warnings: &mut Vec<AdvisoryWarning>,
504) {
505 let mut seen = BTreeSet::new();
506 let mut duplicates = BTreeSet::new();
507
508 for entry in entries {
509 let trimmed = entry.trim();
510
511 if trimmed.is_empty() {
512 warnings.push(AdvisoryWarning::new(
513 if path.ends_with("allow") {
514 "orchestrator.agents.allow.empty_entry"
515 } else {
516 "orchestrator.agents.deny.empty_entry"
517 },
518 path,
519 format!("entry {entry:?} becomes empty after trimming"),
520 ));
521 continue;
522 }
523
524 if trimmed != entry {
525 warnings.push(AdvisoryWarning::new(
526 if path.ends_with("allow") {
527 "orchestrator.agents.allow.trimmed_entry"
528 } else {
529 "orchestrator.agents.deny.trimmed_entry"
530 },
531 path,
532 format!(
533 "entry {entry:?} has surrounding whitespace; effective value is {trimmed:?}"
534 ),
535 ));
536 }
537
538 if !seen.insert(trimmed.to_string()) {
539 duplicates.insert(trimmed.to_string());
540 }
541 }
542
543 if !duplicates.is_empty() {
544 let duplicates = duplicates.into_iter().collect::<Vec<_>>().join(", ");
545 warnings.push(AdvisoryWarning::new(
546 if path.ends_with("allow") {
547 "orchestrator.agents.allow.duplicate"
548 } else {
549 "orchestrator.agents.deny.duplicate"
550 },
551 path,
552 format!("duplicate agent entries after trimming: {duplicates}"),
553 ));
554 }
555}
556
557fn validate_agent_overlap(cfg: &AgenticConfig, warnings: &mut Vec<AdvisoryWarning>) {
558 let allow = cfg
559 .orchestrator
560 .agents
561 .allow
562 .iter()
563 .map(|entry| entry.trim())
564 .filter(|entry| !entry.is_empty())
565 .map(str::to_string)
566 .collect::<BTreeSet<_>>();
567 let deny = cfg
568 .orchestrator
569 .agents
570 .deny
571 .iter()
572 .map(|entry| entry.trim())
573 .filter(|entry| !entry.is_empty())
574 .map(str::to_string)
575 .collect::<BTreeSet<_>>();
576
577 let overlap = allow.intersection(&deny).cloned().collect::<Vec<_>>();
578 if overlap.is_empty() {
579 return;
580 }
581
582 warnings.push(AdvisoryWarning::new(
583 "orchestrator.agents.overlap",
584 "orchestrator.agents",
585 format!(
586 "agents appear in both allow and deny: {}. deny wins at runtime",
587 overlap.join(", ")
588 ),
589 ));
590}
591
592fn validate_url(
593 url: &str,
594 path: &'static str,
595 code: &'static str,
596 warnings: &mut Vec<AdvisoryWarning>,
597) {
598 if !url.starts_with("http://") && !url.starts_with("https://") {
599 warnings.push(AdvisoryWarning {
600 code,
601 path,
602 message: format!("Expected an http(s) URL, got: '{url}'"),
603 });
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn test_default_config_has_no_warnings() {
613 let config = AgenticConfig::default();
614 let warnings = validate(&config);
615 assert!(
616 warnings.is_empty(),
617 "Default config should have no warnings: {warnings:?}"
618 );
619 }
620
621 #[test]
622 fn test_invalid_anthropic_url_warns() {
623 let mut config = AgenticConfig::default();
624 config.services.anthropic.base_url = "not-a-url".into();
625
626 let warnings = validate(&config);
627 assert_eq!(warnings.len(), 1);
628 assert_eq!(warnings[0].code, "services.anthropic.base_url.invalid");
629 }
630
631 #[test]
632 fn test_invalid_linear_and_github_urls_warn() {
633 let mut config = AgenticConfig::default();
634 config.services.linear.base_url = "linear".into();
635 config.services.github.base_url = "github".into();
636
637 let warnings = validate(&config);
638 assert!(
639 warnings
640 .iter()
641 .any(|w| w.code == "services.linear.base_url.invalid")
642 );
643 assert!(
644 warnings
645 .iter()
646 .any(|w| w.code == "services.github.base_url.invalid")
647 );
648 }
649
650 #[test]
651 fn test_invalid_log_level_warns() {
652 let mut config = AgenticConfig::default();
653 config.logging.level = "verbose".into();
654
655 let warnings = validate(&config);
656 assert!(warnings.iter().any(|w| w.code == "logging.level.invalid"));
657 }
658
659 #[test]
660 fn test_warning_display() {
661 let warning = AdvisoryWarning {
662 code: "test.code",
663 path: "test.path",
664 message: "Test message".into(),
665 };
666 let display = format!("{warning}");
667 assert_eq!(display, "[test.code] test.path: Test message");
668 }
669
670 #[test]
671 fn test_empty_subagent_model_warns() {
672 let mut config = AgenticConfig::default();
673 config.subagents.locator_model = String::new();
674
675 let warnings = validate(&config);
676 assert!(
677 warnings
678 .iter()
679 .any(|w| w.code == "subagents.locator_model.empty")
680 );
681 }
682
683 #[test]
684 fn test_reasoning_optimizer_model_format_warns() {
685 let mut config = AgenticConfig::default();
686 config.reasoning.optimizer_model = "claude-sonnet-4.6".into(); let warnings = validate(&config);
689 assert!(
690 warnings
691 .iter()
692 .any(|w| w.code == "reasoning.optimizer_model.format")
693 );
694 }
695
696 #[test]
697 fn test_reasoning_executor_model_suspicious_warns() {
698 let mut config = AgenticConfig::default();
699 config.reasoning.executor_model = "anthropic/claude-sonnet-4.6".into(); let warnings = validate(&config);
702 assert!(
703 warnings
704 .iter()
705 .any(|w| w.code == "reasoning.executor_model.suspicious")
706 );
707 }
708
709 #[test]
710 fn test_reasoning_effort_invalid_warns() {
711 let mut config = AgenticConfig::default();
712 config.reasoning.reasoning_effort = Some("extreme".into()); let warnings = validate(&config);
715 assert!(
716 warnings
717 .iter()
718 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
719 );
720 }
721
722 #[test]
723 fn test_reasoning_effort_valid_no_warning() {
724 let mut config = AgenticConfig::default();
725 config.reasoning.reasoning_effort = Some("high".into());
726
727 let warnings = validate(&config);
728 assert!(
729 !warnings
730 .iter()
731 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
732 );
733 }
734
735 #[test]
736 fn test_orchestrator_compaction_threshold_out_of_range() {
737 let mut config = AgenticConfig::default();
738 config.orchestrator.compaction_threshold = 1.5; let warnings = validate(&config);
741 assert!(
742 warnings
743 .iter()
744 .any(|w| w.code == "orchestrator.compaction_threshold.out_of_range")
745 );
746 }
747
748 #[test]
749 fn test_orchestrator_allow_empty_entry_warns() {
750 let mut config = AgenticConfig::default();
751 config.orchestrator.commands.allow = vec![" ".into()];
752
753 let warnings = validate(&config);
754 let warning = warnings
755 .iter()
756 .find(|w| w.code == "orchestrator.commands.allow.empty_entry")
757 .expect("empty allow warning expected");
758
759 assert_eq!(warning.path, "orchestrator.commands.allow");
760 assert!(warning.message.contains("becomes empty after trimming"));
761 }
762
763 #[test]
764 fn test_orchestrator_deny_trimmed_entry_warns() {
765 let mut config = AgenticConfig::default();
766 config.orchestrator.commands.deny = vec![" plan ".into()];
767
768 let warnings = validate(&config);
769 let warning = warnings
770 .iter()
771 .find(|w| w.code == "orchestrator.commands.deny.trimmed_entry")
772 .expect("trimmed deny warning expected");
773
774 assert_eq!(warning.path, "orchestrator.commands.deny");
775 assert!(warning.message.contains("effective value is \"plan\""));
776 }
777
778 #[test]
779 fn test_orchestrator_allow_duplicate_warns() {
780 let mut config = AgenticConfig::default();
781 config.orchestrator.commands.allow = vec!["plan".into(), " plan ".into()];
782
783 let warnings = validate(&config);
784 let warning = warnings
785 .iter()
786 .find(|w| w.code == "orchestrator.commands.allow.duplicate")
787 .expect("duplicate allow warning expected");
788
789 assert_eq!(warning.path, "orchestrator.commands.allow");
790 assert!(warning.message.contains("plan"));
791 }
792
793 #[test]
794 fn test_orchestrator_command_overlap_warns_with_deny_wins_message() {
795 let mut config = AgenticConfig::default();
796 config.orchestrator.commands.allow = vec!["plan".into()];
797 config.orchestrator.commands.deny = vec![" plan ".into()];
798
799 let warnings = validate(&config);
800 let warning = warnings
801 .iter()
802 .find(|w| w.code == "orchestrator.commands.overlap")
803 .expect("overlap warning expected");
804
805 assert_eq!(warning.path, "orchestrator.commands");
806 assert!(warning.message.contains("plan"));
807 assert!(warning.message.contains("deny wins at runtime"));
808 }
809
810 #[test]
811 fn test_orchestrator_agents_allow_empty_entry_warns() {
812 let mut config = AgenticConfig::default();
813 config.orchestrator.agents.allow = vec![" ".into()];
814
815 let warnings = validate(&config);
816 let warning = warnings
817 .iter()
818 .find(|w| w.code == "orchestrator.agents.allow.empty_entry")
819 .expect("empty allow warning expected");
820
821 assert_eq!(warning.path, "orchestrator.agents.allow");
822 assert!(warning.message.contains("becomes empty after trimming"));
823 }
824
825 #[test]
826 fn test_orchestrator_agents_deny_trimmed_entry_warns() {
827 let mut config = AgenticConfig::default();
828 config.orchestrator.agents.deny = vec![" Bash ".into()];
829
830 let warnings = validate(&config);
831 let warning = warnings
832 .iter()
833 .find(|w| w.code == "orchestrator.agents.deny.trimmed_entry")
834 .expect("trimmed deny warning expected");
835
836 assert_eq!(warning.path, "orchestrator.agents.deny");
837 assert!(warning.message.contains("effective value is \"Bash\""));
838 }
839
840 #[test]
841 fn test_orchestrator_agents_allow_duplicate_warns() {
842 let mut config = AgenticConfig::default();
843 config.orchestrator.agents.allow = vec!["Bash".into(), " Bash ".into()];
844
845 let warnings = validate(&config);
846 let warning = warnings
847 .iter()
848 .find(|w| w.code == "orchestrator.agents.allow.duplicate")
849 .expect("duplicate allow warning expected");
850
851 assert_eq!(warning.path, "orchestrator.agents.allow");
852 assert!(warning.message.contains("Bash"));
853 }
854
855 #[test]
856 fn test_orchestrator_agent_overlap_warns_with_deny_wins_message() {
857 let mut config = AgenticConfig::default();
858 config.orchestrator.agents.allow = vec!["Bash".into()];
859 config.orchestrator.agents.deny = vec![" Bash ".into()];
860
861 let warnings = validate(&config);
862 let warning = warnings
863 .iter()
864 .find(|w| w.code == "orchestrator.agents.overlap")
865 .expect("overlap warning expected");
866
867 assert_eq!(warning.path, "orchestrator.agents");
868 assert!(warning.message.contains("Bash"));
869 assert!(warning.message.contains("deny wins at runtime"));
870 }
871
872 #[test]
873 fn test_web_retrieval_default_exceeds_max() {
874 let mut config = AgenticConfig::default();
875 config.web_retrieval.default_search_results = 100;
876 config.web_retrieval.max_search_results = 20;
877
878 let warnings = validate(&config);
879 assert!(
880 warnings
881 .iter()
882 .any(|w| w.code == "web_retrieval.default_exceeds_max")
883 );
884 }
885
886 #[test]
887 fn test_detect_deprecated_thoughts_toml() {
888 let toml_val: toml::Value = toml::from_str(
889 r"
890[thoughts]
891mount_dirs = {}
892",
893 )
894 .unwrap();
895
896 let warnings = detect_deprecated_keys_toml(&toml_val);
897 assert!(
898 warnings
899 .iter()
900 .any(|w| w.code == "config.deprecated.thoughts.mount_dirs")
901 );
902 }
903
904 #[test]
905 fn test_supported_thoughts_section_is_not_deprecated() {
906 let toml_val: toml::Value = toml::from_str(
907 r"
908[thoughts]
909add_reference_timeout_secs = 600
910",
911 )
912 .unwrap();
913
914 let warnings = detect_deprecated_keys_toml(&toml_val);
915 assert!(warnings.is_empty());
916 }
917
918 #[test]
919 fn test_detect_deprecated_reasoning_token_limit_toml_is_silent() {
920 let toml_val: toml::Value = toml::from_str(
921 r"
922[reasoning]
923token_limit = 12345
924",
925 )
926 .unwrap();
927
928 let warnings = detect_deprecated_keys_toml(&toml_val);
929 assert!(warnings.is_empty());
930 }
931
932 #[test]
933 fn test_reasoning_max_completion_tokens_above_doc_max_warns() {
934 let mut config = AgenticConfig::default();
935 config.reasoning.max_completion_tokens = Some(128_001);
936
937 let warnings = validate(&config);
938 assert!(
939 warnings
940 .iter()
941 .any(|w| w.code == "reasoning.max_completion_tokens.exceeds_doc")
942 );
943 }
944
945 #[test]
946 fn test_reasoning_max_input_tokens_above_default_cap_warns() {
947 let mut config = AgenticConfig::default();
948 config.reasoning.max_input_tokens = Some(250_001);
949
950 let warnings = validate(&config);
951 assert!(
952 warnings
953 .iter()
954 .any(|w| w.code == "reasoning.max_input_tokens.suspicious")
955 );
956 }
957
958 #[test]
959 fn test_low_nonzero_timeout_values_warn() {
960 let mut config = AgenticConfig::default();
961 config.subagents.runtime_timeout_secs = 1;
962 config.cli_tools.just_execute_timeout_secs = 1;
963 config.cli_tools.just_search_timeout_secs = 1;
964 config.services.linear.request_timeout_secs = 1;
965 config.services.github.total_timeout_secs = 1;
966 config.review.run_timeout_secs = 1;
967 config.thoughts.add_reference_timeout_secs = 1;
968
969 let warnings = validate(&config);
970 for code in [
971 "subagents.runtime_timeout_secs.suspicious",
972 "cli_tools.just_execute_timeout_secs.suspicious",
973 "cli_tools.just_search_timeout_secs.suspicious",
974 "services.linear.request_timeout_secs.suspicious",
975 "services.github.total_timeout_secs.suspicious",
976 "review.run_timeout_secs.suspicious",
977 "thoughts.add_reference_timeout_secs.suspicious",
978 ] {
979 assert!(warnings.iter().any(|w| w.code == code), "missing {code}");
980 }
981 }
982}