use std::time::Duration;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::error::AiError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Provider {
Anthropic,
AnthropicLocal,
OpenAi,
OpenAiCompatible,
Gemini,
Ollama,
}
impl Provider {
#[must_use]
pub const fn is_anthropic(self) -> bool {
matches!(self, Self::Anthropic | Self::AnthropicLocal)
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub provider: Provider,
pub model: String,
pub base_url: Option<Url>,
pub api_key: SecretString,
pub timeout: Duration,
pub allow_insecure_base_url: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
provider: Provider::Anthropic,
model: "claude-opus-4-7".into(),
base_url: None,
api_key: SecretString::from(String::new()),
timeout: Duration::from_secs(60),
allow_insecure_base_url: false,
}
}
}
pub fn validate_base_url(url: &Url, allow_insecure: bool) -> Result<(), AiError> {
match url.scheme() {
"https" => {}
"http" if allow_insecure => {}
other => {
return Err(AiError::InvalidConfig(format!(
"base_url scheme {other:?} not permitted (set allow_insecure_base_url for tests)"
)));
}
}
if url.has_authority() {
let has_userinfo = !url.username().is_empty() || url.password().is_some();
if has_userinfo {
return Err(AiError::InvalidConfig(
"base_url must not embed userinfo (`user:pass@host`)".into(),
));
}
}
if let Some(host) = url.host_str() {
let lower = host.to_ascii_lowercase();
if lower == "example.com"
|| lower == "example.org"
|| lower.ends_with(".example.com")
|| lower.ends_with(".example.org")
{
return Err(AiError::InvalidConfig(format!(
"base_url host {host:?} is a documentation placeholder",
)));
}
}
Ok(())
}