1use crate::types::AgenticConfig;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct AdvisoryWarning {
12 pub code: &'static str,
14
15 pub message: String,
17
18 pub path: &'static str,
20}
21
22impl AdvisoryWarning {
23 pub fn new(code: &'static str, path: &'static str, message: impl Into<String>) -> Self {
25 Self {
26 code,
27 path,
28 message: message.into(),
29 }
30 }
31}
32
33impl std::fmt::Display for AdvisoryWarning {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 write!(f, "[{}] {}: {}", self.code, self.path, self.message)
36 }
37}
38
39pub fn detect_deprecated_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
45 let mut warnings = Vec::new();
46
47 if let Some(tbl) = v.as_table() {
49 if tbl.contains_key("thoughts") {
50 warnings.push(AdvisoryWarning::new(
51 "config.deprecated.thoughts",
52 "thoughts",
53 "The 'thoughts' section has been removed. thoughts-core now has its own config.",
54 ));
55 }
56 if tbl.contains_key("models") {
57 warnings.push(AdvisoryWarning::new(
58 "config.deprecated.models",
59 "models",
60 "The 'models' section has been replaced by 'subagents' and 'reasoning'.",
61 ));
62 }
63 }
64
65 warnings
66}
67
68const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
77 "$schema",
78 "subagents",
79 "reasoning",
80 "services",
81 "orchestrator",
82 "web_retrieval",
83 "cli_tools",
84 "logging",
85];
86
87pub fn detect_unknown_top_level_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
92 let mut warnings = Vec::new();
93 let Some(tbl) = v.as_table() else {
94 return warnings;
95 };
96
97 for key in tbl.keys() {
98 if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
99 warnings.push(AdvisoryWarning::new(
100 "config.unknown_top_level_key",
101 "$",
102 format!("Unknown top-level key '{key}' will be ignored"),
103 ));
104 }
105 }
106
107 warnings
108}
109
110pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
115 let mut warnings = vec![];
116
117 validate_url(
119 &cfg.services.anthropic.base_url,
120 "services.anthropic.base_url",
121 "services.anthropic.base_url.invalid",
122 &mut warnings,
123 );
124
125 validate_url(
126 &cfg.services.exa.base_url,
127 "services.exa.base_url",
128 "services.exa.base_url.invalid",
129 &mut warnings,
130 );
131
132 let valid_levels = ["trace", "debug", "info", "warn", "error"];
134 if !valid_levels.contains(&cfg.logging.level.to_lowercase().as_str()) {
135 warnings.push(AdvisoryWarning {
136 code: "logging.level.invalid",
137 path: "logging.level",
138 message: format!(
139 "Unknown log level '{}'. Expected one of: {}",
140 cfg.logging.level,
141 valid_levels.join(", ")
142 ),
143 });
144 }
145
146 if cfg.subagents.locator_model.trim().is_empty() {
148 warnings.push(AdvisoryWarning::new(
149 "subagents.locator_model.empty",
150 "subagents.locator_model",
151 "value is empty",
152 ));
153 }
154 if cfg.subagents.analyzer_model.trim().is_empty() {
155 warnings.push(AdvisoryWarning::new(
156 "subagents.analyzer_model.empty",
157 "subagents.analyzer_model",
158 "value is empty",
159 ));
160 }
161
162 if cfg.reasoning.optimizer_model.trim().is_empty() {
164 warnings.push(AdvisoryWarning::new(
165 "reasoning.optimizer_model.empty",
166 "reasoning.optimizer_model",
167 "value is empty",
168 ));
169 }
170 if cfg.reasoning.executor_model.trim().is_empty() {
171 warnings.push(AdvisoryWarning::new(
172 "reasoning.executor_model.empty",
173 "reasoning.executor_model",
174 "value is empty",
175 ));
176 }
177
178 if !cfg.reasoning.optimizer_model.trim().is_empty()
180 && !cfg.reasoning.optimizer_model.contains('/')
181 {
182 warnings.push(AdvisoryWarning::new(
183 "reasoning.optimizer_model.format",
184 "reasoning.optimizer_model",
185 "expected OpenRouter format like `anthropic/claude-sonnet-4.6`",
186 ));
187 }
188
189 if !cfg.reasoning.executor_model.trim().is_empty()
190 && !cfg.reasoning.executor_model.contains('/')
191 {
192 warnings.push(AdvisoryWarning::new(
193 "reasoning.executor_model.format",
194 "reasoning.executor_model",
195 "expected OpenRouter format like `openai/gpt-5.2`",
196 ));
197 } else if !cfg.reasoning.executor_model.trim().is_empty()
198 && !cfg
199 .reasoning
200 .executor_model
201 .to_lowercase()
202 .contains("gpt-5")
203 {
204 warnings.push(AdvisoryWarning::new(
205 "reasoning.executor_model.suspicious",
206 "reasoning.executor_model",
207 "executor_model does not look like a GPT-5 model; reasoning_effort may not work",
208 ));
209 }
210
211 if let Some(eff) = cfg.reasoning.reasoning_effort.as_deref() {
213 let eff_lc = eff.trim().to_lowercase();
214 if !matches!(eff_lc.as_str(), "low" | "medium" | "high" | "xhigh") {
215 warnings.push(AdvisoryWarning::new(
216 "reasoning.reasoning_effort.invalid",
217 "reasoning.reasoning_effort",
218 "expected one of: low, medium, high, xhigh",
219 ));
220 }
221 }
222
223 if !(0.0..=1.0).contains(&cfg.orchestrator.compaction_threshold) {
225 warnings.push(AdvisoryWarning::new(
226 "orchestrator.compaction_threshold.out_of_range",
227 "orchestrator.compaction_threshold",
228 "expected a value between 0.0 and 1.0",
229 ));
230 }
231
232 if cfg.web_retrieval.default_search_results > cfg.web_retrieval.max_search_results {
234 warnings.push(AdvisoryWarning::new(
235 "web_retrieval.default_exceeds_max",
236 "web_retrieval.default_search_results",
237 "default_search_results exceeds max_search_results",
238 ));
239 }
240
241 if cfg.web_retrieval.summarizer.model.trim().is_empty() {
243 warnings.push(AdvisoryWarning::new(
244 "web_retrieval.summarizer.model.empty",
245 "web_retrieval.summarizer.model",
246 "value is empty",
247 ));
248 }
249
250 if cfg.cli_tools.max_depth == 0 {
252 warnings.push(AdvisoryWarning::new(
253 "cli_tools.max_depth.zero",
254 "cli_tools.max_depth",
255 "max_depth is 0, directory listing may be limited",
256 ));
257 }
258
259 warnings
260}
261
262fn validate_url(
263 url: &str,
264 path: &'static str,
265 code: &'static str,
266 warnings: &mut Vec<AdvisoryWarning>,
267) {
268 if !url.starts_with("http://") && !url.starts_with("https://") {
269 warnings.push(AdvisoryWarning {
270 code,
271 path,
272 message: format!("Expected an http(s) URL, got: '{url}'"),
273 });
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn test_default_config_has_no_warnings() {
283 let config = AgenticConfig::default();
284 let warnings = validate(&config);
285 assert!(
286 warnings.is_empty(),
287 "Default config should have no warnings: {warnings:?}"
288 );
289 }
290
291 #[test]
292 fn test_invalid_anthropic_url_warns() {
293 let mut config = AgenticConfig::default();
294 config.services.anthropic.base_url = "not-a-url".into();
295
296 let warnings = validate(&config);
297 assert_eq!(warnings.len(), 1);
298 assert_eq!(warnings[0].code, "services.anthropic.base_url.invalid");
299 }
300
301 #[test]
302 fn test_invalid_log_level_warns() {
303 let mut config = AgenticConfig::default();
304 config.logging.level = "verbose".into();
305
306 let warnings = validate(&config);
307 assert!(warnings.iter().any(|w| w.code == "logging.level.invalid"));
308 }
309
310 #[test]
311 fn test_warning_display() {
312 let warning = AdvisoryWarning {
313 code: "test.code",
314 path: "test.path",
315 message: "Test message".into(),
316 };
317 let display = format!("{warning}");
318 assert_eq!(display, "[test.code] test.path: Test message");
319 }
320
321 #[test]
322 fn test_empty_subagent_model_warns() {
323 let mut config = AgenticConfig::default();
324 config.subagents.locator_model = String::new();
325
326 let warnings = validate(&config);
327 assert!(
328 warnings
329 .iter()
330 .any(|w| w.code == "subagents.locator_model.empty")
331 );
332 }
333
334 #[test]
335 fn test_reasoning_optimizer_model_format_warns() {
336 let mut config = AgenticConfig::default();
337 config.reasoning.optimizer_model = "claude-sonnet-4.6".into(); let warnings = validate(&config);
340 assert!(
341 warnings
342 .iter()
343 .any(|w| w.code == "reasoning.optimizer_model.format")
344 );
345 }
346
347 #[test]
348 fn test_reasoning_executor_model_suspicious_warns() {
349 let mut config = AgenticConfig::default();
350 config.reasoning.executor_model = "anthropic/claude-sonnet-4.6".into(); let warnings = validate(&config);
353 assert!(
354 warnings
355 .iter()
356 .any(|w| w.code == "reasoning.executor_model.suspicious")
357 );
358 }
359
360 #[test]
361 fn test_reasoning_effort_invalid_warns() {
362 let mut config = AgenticConfig::default();
363 config.reasoning.reasoning_effort = Some("extreme".into()); let warnings = validate(&config);
366 assert!(
367 warnings
368 .iter()
369 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
370 );
371 }
372
373 #[test]
374 fn test_reasoning_effort_valid_no_warning() {
375 let mut config = AgenticConfig::default();
376 config.reasoning.reasoning_effort = Some("high".into());
377
378 let warnings = validate(&config);
379 assert!(
380 !warnings
381 .iter()
382 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
383 );
384 }
385
386 #[test]
387 fn test_orchestrator_compaction_threshold_out_of_range() {
388 let mut config = AgenticConfig::default();
389 config.orchestrator.compaction_threshold = 1.5; let warnings = validate(&config);
392 assert!(
393 warnings
394 .iter()
395 .any(|w| w.code == "orchestrator.compaction_threshold.out_of_range")
396 );
397 }
398
399 #[test]
400 fn test_web_retrieval_default_exceeds_max() {
401 let mut config = AgenticConfig::default();
402 config.web_retrieval.default_search_results = 100;
403 config.web_retrieval.max_search_results = 20;
404
405 let warnings = validate(&config);
406 assert!(
407 warnings
408 .iter()
409 .any(|w| w.code == "web_retrieval.default_exceeds_max")
410 );
411 }
412
413 #[test]
414 fn test_detect_deprecated_thoughts_toml() {
415 let toml_val: toml::Value = toml::from_str(
416 r"
417[thoughts]
418mount_dirs = {}
419",
420 )
421 .unwrap();
422
423 let warnings = detect_deprecated_keys_toml(&toml_val);
424 assert!(
425 warnings
426 .iter()
427 .any(|w| w.code == "config.deprecated.thoughts")
428 );
429 }
430}