Skip to main content

whycodes_config/
validate.rs

1//! Config validation.
2
3use crate::types::{Config, NotifyEvent, is_discord_webhook_url};
4use whycodes_core::{Error, Result};
5
6impl Config {
7    /// Validate the configuration and return any issues.
8    ///
9    /// Checks required fields and emits warnings for common misconfigurations.
10    pub fn validate(&self) -> Result<()> {
11        report_issues(self.collect_issues())
12    }
13
14    /// The issues [`Self::validate`] fails on (localhost `base_url` notes
15    /// are only warnings), without logging them.
16    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        // Check that at least one provider is configured or default model exists
35        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        // Check default_model has a provider_id if set
44        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        // Check providers for common issues
59        for (name, provider) in &self.providers {
60            if provider.api_key.is_none() {
61                // Check env var: <NAME>_API_KEY or WHYCODES_<NAME>_API_KEY
62                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            // Warn if base_url is set to a known developer/local endpoint
78            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        // Check session config
153        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        // Check agents
191        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        // Check that default_agent resolves to an existing agent
199        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    // Report issues
216    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        // Return the first real error if any; otherwise it's just warnings
228        let errors: Vec<&String> = issues.iter().filter(|i| !is_warning_only(i)).collect();
229
230        if errors.is_empty() {
231            // Only localhost warnings — still ok
232            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}