Skip to main content

agentic_config/
validation.rs

1//! Advisory validation for `AgenticConfig`.
2//!
3//! Validation is advisory - it produces warnings but doesn't prevent
4//! the config from being used. This allows tools to work with imperfect
5//! configs while still surfacing potential issues.
6
7use crate::types::AgenticConfig;
8use std::collections::BTreeSet;
9
10/// An advisory warning about a configuration issue.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AdvisoryWarning {
13    /// Machine-readable warning code.
14    pub code: &'static str,
15
16    /// Human-readable warning message.
17    pub message: String,
18
19    /// Config path to the problematic field.
20    pub path: &'static str,
21}
22
23impl AdvisoryWarning {
24    /// Create a new advisory warning.
25    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
40/// Detect deprecated config keys in raw TOML before deserialization.
41///
42/// This inspects the merged TOML Value to detect keys that are no longer
43/// used and emit advisory warnings. The config will still load successfully,
44/// but users will be notified that they should update their configuration.
45pub 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
70// TODO(2): This list must be kept in sync with AgenticConfig fields in types.rs.
71// Consider generating dynamically via schemars introspection, or adding a compile-time
72// test that extracts field names from AgenticConfig's JsonSchema and verifies they
73// match this list. Currently requires manual updates when adding new config sections.
74// See research/pr127-group7-type-safety-external-type-dependencies.md for analysis.
75
76/// Known top-level keys for unknown key detection.
77/// Unknown keys at root level produce advisory warnings.
78const 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
94/// Detect unknown top-level keys in raw TOML before deserialization.
95///
96/// Unknown keys at the root are ignored by serde, so we emit an advisory warning
97/// to help users catch typos like `[servics]` instead of `[services]`.
98pub 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
117/// Validate a configuration and return advisory warnings.
118///
119/// This does NOT fail on issues - it only collects warnings that
120/// callers can choose to display or log.
121pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
122    let mut warnings = vec![];
123
124    // Validate service URLs
125    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    // Validate log level
152    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    // Validate subagents model values are not empty
166    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    // Validate reasoning model values are not empty
189    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    // Validate OpenRouter format for reasoning models (should contain '/')
205    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    // Validate reasoning_effort enum
238    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    // Validate orchestrator.compaction_threshold is in (0,1]
279    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    // Validate web_retrieval: default_search_results <= max_search_results
311    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    // Validate web_retrieval.summarizer.model is not empty
320    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    // Validate cli_tools.max_depth is reasonable
329    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(); // Missing provider prefix
687
688        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(); // Not GPT-5
700
701        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()); // Invalid value
713
714        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; // Invalid
739
740        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}