whycodes_config/
validate.rs1use crate::types::{Config, NotifyEvent, is_discord_webhook_url};
4use whycodes_core::{Error, Result};
5
6impl Config {
7 pub fn validate(&self) -> Result<()> {
11 report_issues(self.collect_issues())
12 }
13
14 pub fn validation_errors(&self) -> Vec<String> {
17 self.collect_issues()
18 .into_iter()
19 .filter(|i| !is_warning_only(i))
20 .collect()
21 }
22
23 fn collect_issues(&self) -> Vec<String> {
24 let mut issues: Vec<String> = Vec::new();
25 self.collect_provider_issues(&mut issues);
26 self.collect_notify_issues(&mut issues);
27 self.collect_slop_issues(&mut issues);
28 self.collect_session_issues(&mut issues);
29 self.collect_agent_issues(&mut issues);
30 issues
31 }
32
33 fn collect_provider_issues(&self, issues: &mut Vec<String>) {
34 if self.providers.is_empty() && self.default_model.is_none() {
36 issues.push(
37 "No providers configured and no default model set. \
38 Configure at least one provider or set WHYCODES_PROVIDER / WHYCODES_MODEL."
39 .to_string(),
40 );
41 }
42
43 if let Some(ref dm) = self.default_model {
45 if dm.provider_id.is_empty() && !self.providers.is_empty() {
46 issues.push(format!(
47 "Default model '{}' has no provider_id but {} provider(s) are configured. \
48 Specify a provider_id for the default model.",
49 dm.model_id,
50 self.providers.len()
51 ));
52 }
53 if dm.model_id.is_empty() {
54 issues.push("Default model has an empty model_id.".to_string());
55 }
56 }
57
58 for (name, provider) in &self.providers {
60 if provider.api_key.is_none() {
61 let key_from_env = std::env::var(format!("{}_API_KEY", name.to_uppercase()))
63 .or_else(|_| std::env::var(format!("WHYCODES_{}_API_KEY", name.to_uppercase())))
64 .or_else(|_| std::env::var("OPENAI_API_KEY"))
65 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"));
66
67 if key_from_env.is_err() {
68 issues.push(format!(
69 "Provider '{}' has no api_key configured and no matching \
70 environment variable found ({}_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY).",
71 name,
72 name.to_uppercase()
73 ));
74 }
75 }
76
77 if let Some(ref url) = provider.base_url
79 && (url.contains("localhost") || url.contains("127.0.0.1"))
80 {
81 issues.push(format!(
82 "Provider '{}' base_url points to localhost ({}). \
83 This is fine for local development but will not work in production.",
84 name, url
85 ));
86 }
87 }
88 }
89
90 fn collect_notify_issues(&self, issues: &mut Vec<String>) {
91 if let Some(url) = self.notify.discord_webhook.as_deref()
92 && !url.trim().is_empty()
93 && !is_discord_webhook_url(url)
94 {
95 issues.push(
96 "notify.discord_webhook is set but is not a Discord Incoming Webhook URL \
97 (https://discord.com/api/webhooks/… or https://discordapp.com/api/webhooks/…)."
98 .to_string(),
99 );
100 }
101 if self
102 .notify
103 .telegram_bot_token
104 .as_deref()
105 .is_some_and(|t| !t.trim().is_empty())
106 != self
107 .notify
108 .telegram_chat_id
109 .as_deref()
110 .is_some_and(|c| !c.trim().is_empty())
111 {
112 issues.push(
113 "Telegram notify needs both notify.telegram_bot_token and notify.telegram_chat_id \
114 (or WHYCODES_NOTIFY_TELEGRAM_BOT_TOKEN / WHYCODES_NOTIFY_TELEGRAM_CHAT_ID)."
115 .to_string(),
116 );
117 }
118 if self.notify.timeout_secs == 0 || self.notify.timeout_secs > 60 {
119 issues.push(format!(
120 "notify.timeout_secs is {} (expected 1–60).",
121 self.notify.timeout_secs
122 ));
123 }
124 for event in &self.notify.on {
125 if NotifyEvent::parse(event).is_none() {
126 issues.push(format!(
127 "notify.on contains unknown event '{event}'. Expected turn_done and/or need_input."
128 ));
129 }
130 }
131 }
132
133 fn collect_slop_issues(&self, issues: &mut Vec<String>) {
134 if self.slop.verbosity < 0.0 || self.slop.verbosity > 1.0 {
135 issues.push(format!(
136 "slop.verbosity is {} (expected 0–1).",
137 self.slop.verbosity
138 ));
139 }
140 if self.slop.erosion < 0.0 || self.slop.erosion > 1.0 {
141 issues.push(format!(
142 "slop.erosion is {} (expected 0–1).",
143 self.slop.erosion
144 ));
145 }
146 if self.slop.hotspots == 0 {
147 issues.push("slop.hotspots is 0 (expected ≥ 1).".to_string());
148 }
149 }
150
151 fn collect_session_issues(&self, issues: &mut Vec<String>) {
152 if self.session.max_context_tokens == 0 {
154 issues.push(
155 "session.max_context_tokens is set to 0. This will disable context.".to_string(),
156 );
157 }
158 if let Some(p) = self.session.compaction_buffer_percent
159 && !(1..=90).contains(&p)
160 {
161 issues.push(format!(
162 "session.compaction_buffer_percent is {p} (expected 1–90)."
163 ));
164 }
165 if self.session.race_after_ms > 30_000 {
166 issues.push(format!(
167 "session.race_after_ms is {} (>30s). First-token race will wait a long time.",
168 self.session.race_after_ms
169 ));
170 }
171 if self.session.stream_retry_secs > 600 {
172 issues.push(format!(
173 "session.stream_retry_secs is {} (>600s). A dropped stream will be retried for a long time.",
174 self.session.stream_retry_secs
175 ));
176 }
177 let rc = self.session.response_cache.trim().to_ascii_lowercase();
178 if !matches!(
179 rc.as_str(),
180 "auto" | "on" | "true" | "1" | "off" | "false" | "0" | "none"
181 ) {
182 issues.push(format!(
183 "session.response_cache is '{}'; expected auto or off.",
184 self.session.response_cache
185 ));
186 }
187 }
188
189 fn collect_agent_issues(&self, issues: &mut Vec<String>) {
190 if self.agents.is_empty() {
192 issues.push(
193 "No agents configured. At least one agent is recommended for proper operation."
194 .to_string(),
195 );
196 }
197
198 if !self.default_agent.is_empty()
200 && !self.agents.iter().any(|a| a.name == self.default_agent)
201 {
202 issues.push(format!(
203 "Default agent '{}' not found in the agents list.",
204 self.default_agent
205 ));
206 }
207 }
208}
209
210fn is_warning_only(issue: &str) -> bool {
211 issue.contains("localhost") || issue.contains("127.0.0.1")
212}
213
214fn report_issues(issues: Vec<String>) -> Result<()> {
215 if issues.is_empty() {
217 tracing::info!("Configuration validated successfully.");
218 Ok(())
219 } else {
220 for issue in &issues {
221 if is_warning_only(issue) {
222 tracing::warn!("{}", issue);
223 } else {
224 tracing::warn!("Config issue: {}", issue);
225 }
226 }
227 let errors: Vec<&String> = issues.iter().filter(|i| !is_warning_only(i)).collect();
229
230 if errors.is_empty() {
231 Ok(())
233 } else if errors.len() == 1 {
234 Err(Error::Config(errors[0].clone()))
235 } else {
236 Err(Error::Config(
237 errors
238 .iter()
239 .map(|s| s.as_str())
240 .collect::<Vec<_>>()
241 .join("; "),
242 ))
243 }
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use crate::types::Config;
250 use std::collections::HashMap;
251 use whycodes_core::types::{ModelConfig, ProviderConfig};
252
253 fn provider(name: &str) -> ProviderConfig {
254 ProviderConfig {
255 name: name.into(),
256 api_key: None,
257 api_base: None,
258 base_url: None,
259 headers: None,
260 models: vec![],
261 tool_arguments: None,
262 extra: HashMap::new(),
263 credentials: Default::default(),
264 }
265 }
266
267 #[test]
268 fn validate_reports_missing_provider_and_model_fields() {
269 let empty = Config::default();
270 let err = empty.validate().unwrap_err().to_string();
271 assert!(err.contains("No providers configured"));
272
273 let mut cfg = Config {
274 default_model: Some(ModelConfig {
275 model_id: String::new(),
276 provider_id: String::new(),
277 max_tokens: None,
278 context_window: None,
279 temperature: None,
280 top_p: None,
281 thinking: None,
282 supports_tools: None,
283 supports_images: None,
284 }),
285 ..Config::default()
286 };
287 cfg.providers.insert("openai".into(), provider("openai"));
288 let err = cfg.validate().unwrap_err().to_string();
289 assert!(err.contains("empty model_id"));
290
291 let mut p = provider("openai");
292 p.api_key = Some("k".into());
293 let mut ok = Config {
294 default_model: Some(ModelConfig {
295 model_id: "gpt".into(),
296 provider_id: "openai".into(),
297 max_tokens: Some(16),
298 context_window: None,
299 temperature: None,
300 top_p: None,
301 thinking: None,
302 supports_tools: None,
303 supports_images: None,
304 }),
305 ..Config::default()
306 };
307 ok.providers.insert("openai".into(), p);
308 ok.validate().unwrap();
309 }
310}