use crate::types::{Config, NotifyEvent, is_discord_webhook_url};
use whycodes_core::{Error, Result};
impl Config {
pub fn validate(&self) -> Result<()> {
report_issues(self.collect_issues())
}
pub fn validation_errors(&self) -> Vec<String> {
self.collect_issues()
.into_iter()
.filter(|i| !is_warning_only(i))
.collect()
}
fn collect_issues(&self) -> Vec<String> {
let mut issues: Vec<String> = Vec::new();
self.collect_provider_issues(&mut issues);
self.collect_notify_issues(&mut issues);
self.collect_slop_issues(&mut issues);
self.collect_session_issues(&mut issues);
self.collect_agent_issues(&mut issues);
issues
}
fn collect_provider_issues(&self, issues: &mut Vec<String>) {
if self.providers.is_empty() && self.default_model.is_none() {
issues.push(
"No providers configured and no default model set. \
Configure at least one provider or set WHYCODES_PROVIDER / WHYCODES_MODEL."
.to_string(),
);
}
if let Some(ref dm) = self.default_model {
if dm.provider_id.is_empty() && !self.providers.is_empty() {
issues.push(format!(
"Default model '{}' has no provider_id but {} provider(s) are configured. \
Specify a provider_id for the default model.",
dm.model_id,
self.providers.len()
));
}
if dm.model_id.is_empty() {
issues.push("Default model has an empty model_id.".to_string());
}
}
for (name, provider) in &self.providers {
if provider.api_key.is_none() {
let key_from_env = std::env::var(format!("{}_API_KEY", name.to_uppercase()))
.or_else(|_| std::env::var(format!("WHYCODES_{}_API_KEY", name.to_uppercase())))
.or_else(|_| std::env::var("OPENAI_API_KEY"))
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"));
if key_from_env.is_err() {
issues.push(format!(
"Provider '{}' has no api_key configured and no matching \
environment variable found ({}_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY).",
name,
name.to_uppercase()
));
}
}
if let Some(ref url) = provider.base_url
&& (url.contains("localhost") || url.contains("127.0.0.1"))
{
issues.push(format!(
"Provider '{}' base_url points to localhost ({}). \
This is fine for local development but will not work in production.",
name, url
));
}
}
}
fn collect_notify_issues(&self, issues: &mut Vec<String>) {
if let Some(url) = self.notify.discord_webhook.as_deref()
&& !url.trim().is_empty()
&& !is_discord_webhook_url(url)
{
issues.push(
"notify.discord_webhook is set but is not a Discord Incoming Webhook URL \
(https://discord.com/api/webhooks/… or https://discordapp.com/api/webhooks/…)."
.to_string(),
);
}
if self
.notify
.telegram_bot_token
.as_deref()
.is_some_and(|t| !t.trim().is_empty())
!= self
.notify
.telegram_chat_id
.as_deref()
.is_some_and(|c| !c.trim().is_empty())
{
issues.push(
"Telegram notify needs both notify.telegram_bot_token and notify.telegram_chat_id \
(or WHYCODES_NOTIFY_TELEGRAM_BOT_TOKEN / WHYCODES_NOTIFY_TELEGRAM_CHAT_ID)."
.to_string(),
);
}
if self.notify.timeout_secs == 0 || self.notify.timeout_secs > 60 {
issues.push(format!(
"notify.timeout_secs is {} (expected 1–60).",
self.notify.timeout_secs
));
}
for event in &self.notify.on {
if NotifyEvent::parse(event).is_none() {
issues.push(format!(
"notify.on contains unknown event '{event}'. Expected turn_done and/or need_input."
));
}
}
}
fn collect_slop_issues(&self, issues: &mut Vec<String>) {
if self.slop.verbosity < 0.0 || self.slop.verbosity > 1.0 {
issues.push(format!(
"slop.verbosity is {} (expected 0–1).",
self.slop.verbosity
));
}
if self.slop.erosion < 0.0 || self.slop.erosion > 1.0 {
issues.push(format!(
"slop.erosion is {} (expected 0–1).",
self.slop.erosion
));
}
if self.slop.hotspots == 0 {
issues.push("slop.hotspots is 0 (expected ≥ 1).".to_string());
}
}
fn collect_session_issues(&self, issues: &mut Vec<String>) {
if self.session.max_context_tokens == 0 {
issues.push(
"session.max_context_tokens is set to 0. This will disable context.".to_string(),
);
}
if let Some(p) = self.session.compaction_buffer_percent
&& !(1..=90).contains(&p)
{
issues.push(format!(
"session.compaction_buffer_percent is {p} (expected 1–90)."
));
}
if self.session.race_after_ms > 30_000 {
issues.push(format!(
"session.race_after_ms is {} (>30s). First-token race will wait a long time.",
self.session.race_after_ms
));
}
if self.session.stream_retry_secs > 600 {
issues.push(format!(
"session.stream_retry_secs is {} (>600s). A dropped stream will be retried for a long time.",
self.session.stream_retry_secs
));
}
let rc = self.session.response_cache.trim().to_ascii_lowercase();
if !matches!(
rc.as_str(),
"auto" | "on" | "true" | "1" | "off" | "false" | "0" | "none"
) {
issues.push(format!(
"session.response_cache is '{}'; expected auto or off.",
self.session.response_cache
));
}
}
fn collect_agent_issues(&self, issues: &mut Vec<String>) {
if self.agents.is_empty() {
issues.push(
"No agents configured. At least one agent is recommended for proper operation."
.to_string(),
);
}
if !self.default_agent.is_empty()
&& !self.agents.iter().any(|a| a.name == self.default_agent)
{
issues.push(format!(
"Default agent '{}' not found in the agents list.",
self.default_agent
));
}
}
}
fn is_warning_only(issue: &str) -> bool {
issue.contains("localhost") || issue.contains("127.0.0.1")
}
fn report_issues(issues: Vec<String>) -> Result<()> {
if issues.is_empty() {
tracing::info!("Configuration validated successfully.");
Ok(())
} else {
for issue in &issues {
if is_warning_only(issue) {
tracing::warn!("{}", issue);
} else {
tracing::warn!("Config issue: {}", issue);
}
}
let errors: Vec<&String> = issues.iter().filter(|i| !is_warning_only(i)).collect();
if errors.is_empty() {
Ok(())
} else if errors.len() == 1 {
Err(Error::Config(errors[0].clone()))
} else {
Err(Error::Config(
errors
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("; "),
))
}
}
}
#[cfg(test)]
mod tests {
use crate::types::Config;
use std::collections::HashMap;
use whycodes_core::types::{ModelConfig, ProviderConfig};
fn provider(name: &str) -> ProviderConfig {
ProviderConfig {
name: name.into(),
api_key: None,
api_base: None,
base_url: None,
headers: None,
models: vec![],
tool_arguments: None,
extra: HashMap::new(),
credentials: Default::default(),
}
}
#[test]
fn validate_reports_missing_provider_and_model_fields() {
let empty = Config::default();
let err = empty.validate().unwrap_err().to_string();
assert!(err.contains("No providers configured"));
let mut cfg = Config {
default_model: Some(ModelConfig {
model_id: String::new(),
provider_id: String::new(),
max_tokens: None,
context_window: None,
temperature: None,
top_p: None,
thinking: None,
supports_tools: None,
supports_images: None,
}),
..Config::default()
};
cfg.providers.insert("openai".into(), provider("openai"));
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("empty model_id"));
let mut p = provider("openai");
p.api_key = Some("k".into());
let mut ok = Config {
default_model: Some(ModelConfig {
model_id: "gpt".into(),
provider_id: "openai".into(),
max_tokens: Some(16),
context_window: None,
temperature: None,
top_p: None,
thinking: None,
supports_tools: None,
supports_images: None,
}),
..Config::default()
};
ok.providers.insert("openai".into(), p);
ok.validate().unwrap();
}
}