use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AiProvider {
#[default]
Anthropic,
OpenAI,
Ollama,
}
impl AiProvider {
pub fn as_str(&self) -> &'static str {
match self {
AiProvider::Anthropic => "anthropic",
AiProvider::OpenAI => "openai",
AiProvider::Ollama => "ollama",
}
}
pub fn default_model(&self) -> &'static str {
match self {
AiProvider::Anthropic => "claude-sonnet-4-20250514",
AiProvider::OpenAI => "gpt-4o",
AiProvider::Ollama => "llama3.2",
}
}
pub fn env_key_name(&self) -> &'static str {
match self {
AiProvider::Anthropic => "ANTHROPIC_API_KEY",
AiProvider::OpenAI => "OPENAI_API_KEY",
AiProvider::Ollama => "OLLAMA_BASE_URL",
}
}
}
impl std::fmt::Display for AiProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for AiProvider {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"anthropic" | "claude" => Ok(AiProvider::Anthropic),
"openai" | "gpt" => Ok(AiProvider::OpenAI),
"ollama" | "local" => Ok(AiProvider::Ollama),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AudienceLevel {
Beginner,
#[default]
Intermediate,
Expert,
}
impl AudienceLevel {
pub fn as_str(&self) -> &'static str {
match self {
AudienceLevel::Beginner => "beginner",
AudienceLevel::Intermediate => "intermediate",
AudienceLevel::Expert => "expert",
}
}
pub fn description(&self) -> &'static str {
match self {
AudienceLevel::Beginner => "someone new to security concepts",
AudienceLevel::Intermediate => "a developer with some security knowledge",
AudienceLevel::Expert => "an experienced security professional",
}
}
}
impl std::fmt::Display for AudienceLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for AudienceLevel {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"beginner" | "novice" => Ok(AudienceLevel::Beginner),
"intermediate" | "medium" => Ok(AudienceLevel::Intermediate),
"expert" | "advanced" => Ok(AudienceLevel::Expert),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiConfig {
pub provider: AiProvider,
pub model: String,
#[serde(skip_serializing)]
pub api_key: Option<String>,
pub ollama_url: String,
pub max_tokens: u32,
pub temperature: f32,
pub cache_ttl_secs: u64,
pub stream: bool,
pub timeout_secs: u64,
pub max_retries: u32,
pub rate_limit_rpm: u32,
pub rate_limit_tpm: u32,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
provider: AiProvider::default(),
model: AiProvider::default().default_model().to_string(),
api_key: None,
ollama_url: "http://localhost:11434".to_string(),
max_tokens: 16384,
temperature: 0.3,
cache_ttl_secs: 7 * 24 * 60 * 60, stream: false,
timeout_secs: 120,
max_retries: 3,
rate_limit_rpm: 50,
rate_limit_tpm: 100_000,
}
}
}
impl AiConfig {
pub fn anthropic() -> Self {
Self {
provider: AiProvider::Anthropic,
model: "claude-sonnet-4-20250514".to_string(),
..Default::default()
}
}
pub fn openai() -> Self {
Self {
provider: AiProvider::OpenAI,
model: "gpt-4o".to_string(),
rate_limit_rpm: 60,
rate_limit_tpm: 150_000,
..Default::default()
}
}
pub fn ollama() -> Self {
Self {
provider: AiProvider::Ollama,
model: "llama3.2".to_string(),
rate_limit_rpm: 1000, rate_limit_tpm: 1_000_000,
..Default::default()
}
}
pub fn with_provider(mut self, provider: AiProvider) -> Self {
self.provider = provider;
self.model = provider.default_model().to_string();
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn with_ollama_url(mut self, url: impl Into<String>) -> Self {
self.ollama_url = url.into();
self
}
pub fn with_max_tokens(mut self, tokens: u32) -> Self {
self.max_tokens = tokens;
self
}
pub fn with_temperature(mut self, temp: f32) -> Self {
self.temperature = temp.clamp(0.0, 1.0);
self
}
pub fn with_cache_ttl(mut self, duration: Duration) -> Self {
self.cache_ttl_secs = duration.as_secs();
self
}
pub fn without_cache(mut self) -> Self {
self.cache_ttl_secs = 0;
self
}
pub fn with_streaming(mut self) -> Self {
self.stream = true;
self
}
pub fn cache_ttl(&self) -> Duration {
Duration::from_secs(self.cache_ttl_secs)
}
pub fn timeout(&self) -> Duration {
Duration::from_secs(self.timeout_secs)
}
pub fn load_api_key_from_env(&mut self) {
if self.api_key.is_none() {
self.api_key = std::env::var(self.provider.env_key_name()).ok();
}
}
pub fn has_api_key(&self) -> bool {
self.api_key.is_some() || self.provider == AiProvider::Ollama
}
pub fn validate(&self) -> Result<(), String> {
if !self.has_api_key() && self.provider != AiProvider::Ollama {
return Err(format!(
"Missing API key. Set {} environment variable",
self.provider.env_key_name()
));
}
if self.max_tokens == 0 {
return Err("max_tokens must be greater than 0".to_string());
}
if self.timeout_secs == 0 {
return Err("timeout_secs must be greater than 0".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct AiConfigBuilder {
provider: Option<AiProvider>,
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
timeout: Option<u64>,
max_tokens: Option<u32>,
temperature: Option<f32>,
}
impl AiConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn provider(mut self, provider: AiProvider) -> Self {
self.provider = Some(provider);
self
}
pub fn model(mut self, model: &str) -> Self {
self.model = Some(model.to_string());
self
}
pub fn api_key(mut self, key: &str) -> Self {
self.api_key = Some(key.to_string());
self
}
pub fn base_url(mut self, url: &str) -> Self {
self.base_url = Some(url.to_string());
self
}
pub fn timeout(mut self, secs: u64) -> Self {
self.timeout = Some(secs);
self
}
pub fn max_tokens(mut self, tokens: u32) -> Self {
self.max_tokens = Some(tokens);
self
}
pub fn temperature(mut self, temp: f32) -> Self {
self.temperature = Some(temp);
self
}
pub fn build(self) -> AiConfig {
let provider = self.provider.unwrap_or_default();
let mut config = match provider {
AiProvider::Anthropic => AiConfig::anthropic(),
AiProvider::OpenAI => AiConfig::openai(),
AiProvider::Ollama => AiConfig::ollama(),
};
if let Some(model) = self.model {
config.model = model;
}
if let Some(key) = self.api_key {
config.api_key = Some(key);
}
if let Some(url) = self.base_url {
config.ollama_url = url;
}
if let Some(timeout) = self.timeout {
config.timeout_secs = timeout;
}
if let Some(tokens) = self.max_tokens {
config.max_tokens = tokens;
}
if let Some(temp) = self.temperature {
config.temperature = temp.clamp(0.0, 1.0);
}
config
}
}
impl AiConfig {
pub fn builder() -> AiConfigBuilder {
AiConfigBuilder::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplanationContext {
pub server_name: String,
pub tech_stack: Vec<String>,
pub audience: AudienceLevel,
pub code_language: Option<String>,
pub include_education: bool,
pub include_code_examples: bool,
}
impl Default for ExplanationContext {
fn default() -> Self {
Self {
server_name: "Unknown Server".to_string(),
tech_stack: Vec::new(),
audience: AudienceLevel::default(),
code_language: None,
include_education: true,
include_code_examples: true,
}
}
}
impl AiConfig {
pub fn load_from_file(path: Option<&str>) -> Result<Self, ConfigLoadError> {
let config_paths = if let Some(p) = path {
vec![std::path::PathBuf::from(p)]
} else {
let mut paths = vec![
std::path::PathBuf::from(".mcplint.toml"),
std::path::PathBuf::from("mcplint.toml"),
];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("mcplint").join("config.toml"));
}
paths
};
for config_path in &config_paths {
if config_path.exists() {
return Self::load_from_path(config_path);
}
}
Err(ConfigLoadError::NotFound)
}
fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
let content =
std::fs::read_to_string(path).map_err(|e| ConfigLoadError::ReadError(e.to_string()))?;
Self::parse_toml(&content)
}
fn parse_toml(content: &str) -> Result<Self, ConfigLoadError> {
let table: toml::Table = content
.parse()
.map_err(|e: toml::de::Error| ConfigLoadError::ParseError(e.to_string()))?;
let ai_section = table
.get("ai")
.and_then(|v| v.as_table())
.ok_or(ConfigLoadError::MissingSection("[ai]".to_string()))?;
let mut config = AiConfig::default();
if let Some(provider_str) = ai_section.get("provider").and_then(|v| v.as_str()) {
if let Ok(provider) = provider_str.parse::<AiProvider>() {
config.provider = provider;
config.model = provider.default_model().to_string();
}
}
if let Some(model) = ai_section.get("model").and_then(|v| v.as_str()) {
config.model = model.to_string();
}
if let Some(url) = ai_section.get("ollama_url").and_then(|v| v.as_str()) {
config.ollama_url = url.to_string();
}
if let Some(tokens) = ai_section.get("max_tokens").and_then(|v| v.as_integer()) {
config.max_tokens = tokens as u32;
}
if let Some(temp) = ai_section.get("temperature").and_then(|v| v.as_float()) {
config.temperature = (temp as f32).clamp(0.0, 1.0);
}
if let Some(ttl) = ai_section.get("cache_ttl").and_then(|v| v.as_integer()) {
config.cache_ttl_secs = ttl as u64;
}
if let Some(rpm) = ai_section
.get("rate_limit_rpm")
.and_then(|v| v.as_integer())
{
config.rate_limit_rpm = rpm as u32;
}
if let Some(tpm) = ai_section
.get("rate_limit_tpm")
.and_then(|v| v.as_integer())
{
config.rate_limit_tpm = tpm as u32;
}
config.load_api_key_from_env();
Ok(config)
}
pub fn load_or_default(path: Option<&str>) -> Self {
match Self::load_from_file(path) {
Ok(config) => {
tracing::debug!("Loaded AI config from file");
config
}
Err(ConfigLoadError::NotFound) => {
tracing::debug!("No config file found, using defaults");
let mut config = Self::default();
config.load_api_key_from_env();
config
}
Err(e) => {
tracing::warn!("Failed to load config: {}, using defaults", e);
let mut config = Self::default();
config.load_api_key_from_env();
config
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigLoadError {
#[error("Config file not found")]
NotFound,
#[error("Failed to read config file: {0}")]
ReadError(String),
#[error("Failed to parse config file: {0}")]
ParseError(String),
#[error("Missing required section: {0}")]
MissingSection(String),
}
impl ExplanationContext {
pub fn new(server_name: impl Into<String>) -> Self {
Self {
server_name: server_name.into(),
..Default::default()
}
}
pub fn with_audience(mut self, audience: AudienceLevel) -> Self {
self.audience = audience;
self
}
pub fn with_tech_stack(mut self, tech: Vec<String>) -> Self {
self.tech_stack = tech;
self
}
pub fn with_code_language(mut self, lang: impl Into<String>) -> Self {
self.code_language = Some(lang.into());
self
}
pub fn without_education(mut self) -> Self {
self.include_education = false;
self
}
pub fn without_code_examples(mut self) -> Self {
self.include_code_examples = false;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let config = AiConfig::default();
assert_eq!(config.provider, AiProvider::Anthropic);
assert!(config.max_tokens > 0);
assert!(config.temperature >= 0.0 && config.temperature <= 1.0);
}
#[test]
fn provider_parsing() {
assert_eq!("anthropic".parse::<AiProvider>(), Ok(AiProvider::Anthropic));
assert_eq!("OPENAI".parse::<AiProvider>(), Ok(AiProvider::OpenAI));
assert_eq!("local".parse::<AiProvider>(), Ok(AiProvider::Ollama));
assert!("invalid".parse::<AiProvider>().is_err());
}
#[test]
fn audience_parsing() {
assert_eq!(
"beginner".parse::<AudienceLevel>(),
Ok(AudienceLevel::Beginner)
);
assert_eq!("EXPERT".parse::<AudienceLevel>(), Ok(AudienceLevel::Expert));
assert!("invalid".parse::<AudienceLevel>().is_err());
}
#[test]
fn config_builder() {
let config = AiConfig::default()
.with_provider(AiProvider::OpenAI)
.with_model("gpt-4-turbo")
.with_max_tokens(2048)
.with_temperature(0.5);
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4-turbo");
assert_eq!(config.max_tokens, 2048);
assert_eq!(config.temperature, 0.5);
}
#[test]
fn config_validation() {
let ollama_config = AiConfig::ollama();
assert!(ollama_config.validate().is_ok());
let anthropic_config = AiConfig::anthropic();
assert!(anthropic_config.validate().is_err());
let with_key = AiConfig::anthropic().with_api_key("test-key");
assert!(with_key.validate().is_ok());
}
#[test]
fn parse_toml_basic() {
let toml_content = r#"
[ai]
provider = "ollama"
model = "llama3.2"
ollama_url = "http://localhost:11434"
max_tokens = 2048
temperature = 0.5
cache_ttl = 3600
rate_limit_rpm = 100
rate_limit_tpm = 50000
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.provider, AiProvider::Ollama);
assert_eq!(config.model, "llama3.2");
assert_eq!(config.ollama_url, "http://localhost:11434");
assert_eq!(config.max_tokens, 2048);
assert_eq!(config.temperature, 0.5);
assert_eq!(config.cache_ttl_secs, 3600);
assert_eq!(config.rate_limit_rpm, 100);
assert_eq!(config.rate_limit_tpm, 50000);
}
#[test]
fn parse_toml_anthropic() {
let toml_content = r#"
[ai]
provider = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.provider, AiProvider::Anthropic);
assert_eq!(config.model, "claude-sonnet-4-20250514");
assert_eq!(config.max_tokens, 16384);
}
#[test]
fn parse_toml_openai() {
let toml_content = r#"
[ai]
provider = "openai"
model = "gpt-4o"
temperature = 0.7
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4o");
assert_eq!(config.temperature, 0.7);
}
#[test]
fn parse_toml_missing_section() {
let toml_content = r#"
[general]
format = "text"
"#;
let result = AiConfig::parse_toml(toml_content);
assert!(result.is_err());
match result {
Err(ConfigLoadError::MissingSection(section)) => {
assert_eq!(section, "[ai]");
}
_ => panic!("Expected MissingSection error"),
}
}
#[test]
fn parse_toml_partial_config() {
let toml_content = r#"
[ai]
provider = "ollama"
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.provider, AiProvider::Ollama);
assert_eq!(config.model, "llama3.2");
assert_eq!(config.max_tokens, 16384);
assert!(config.temperature > 0.0);
}
#[test]
fn parse_toml_invalid_provider() {
let toml_content = r#"
[ai]
provider = "invalid_provider"
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.provider, AiProvider::Anthropic);
}
#[test]
fn parse_toml_temperature_clamp() {
let toml_content = r#"
[ai]
provider = "ollama"
temperature = 2.5
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.temperature, 1.0);
}
#[test]
fn load_or_default_nonexistent() {
let config = AiConfig::load_or_default(Some("/nonexistent/path/config.toml"));
assert_eq!(config.provider, AiProvider::Anthropic);
assert!(config.max_tokens > 0);
}
#[test]
fn config_load_error_display() {
let err = ConfigLoadError::NotFound;
assert!(err.to_string().contains("not found"));
let err = ConfigLoadError::ReadError("permission denied".to_string());
assert!(err.to_string().contains("permission denied"));
let err = ConfigLoadError::ParseError("invalid toml".to_string());
assert!(err.to_string().contains("invalid toml"));
let err = ConfigLoadError::MissingSection("[ai]".to_string());
assert!(err.to_string().contains("[ai]"));
}
#[test]
fn ai_provider_as_str() {
assert_eq!(AiProvider::Anthropic.as_str(), "anthropic");
assert_eq!(AiProvider::OpenAI.as_str(), "openai");
assert_eq!(AiProvider::Ollama.as_str(), "ollama");
}
#[test]
fn ai_provider_default_model() {
assert_eq!(
AiProvider::Anthropic.default_model(),
"claude-sonnet-4-20250514"
);
assert_eq!(AiProvider::OpenAI.default_model(), "gpt-4o");
assert_eq!(AiProvider::Ollama.default_model(), "llama3.2");
}
#[test]
fn ai_provider_env_key_name() {
assert_eq!(AiProvider::Anthropic.env_key_name(), "ANTHROPIC_API_KEY");
assert_eq!(AiProvider::OpenAI.env_key_name(), "OPENAI_API_KEY");
assert_eq!(AiProvider::Ollama.env_key_name(), "OLLAMA_BASE_URL");
}
#[test]
fn ai_provider_display() {
assert_eq!(format!("{}", AiProvider::Anthropic), "anthropic");
assert_eq!(format!("{}", AiProvider::OpenAI), "openai");
assert_eq!(format!("{}", AiProvider::Ollama), "ollama");
}
#[test]
fn ai_provider_from_str_aliases() {
assert_eq!("claude".parse::<AiProvider>(), Ok(AiProvider::Anthropic));
assert_eq!("gpt".parse::<AiProvider>(), Ok(AiProvider::OpenAI));
assert_eq!("ollama".parse::<AiProvider>(), Ok(AiProvider::Ollama));
}
#[test]
fn ai_provider_default() {
assert_eq!(AiProvider::default(), AiProvider::Anthropic);
}
#[test]
fn audience_level_as_str() {
assert_eq!(AudienceLevel::Beginner.as_str(), "beginner");
assert_eq!(AudienceLevel::Intermediate.as_str(), "intermediate");
assert_eq!(AudienceLevel::Expert.as_str(), "expert");
}
#[test]
fn audience_level_description() {
assert_eq!(
AudienceLevel::Beginner.description(),
"someone new to security concepts"
);
assert_eq!(
AudienceLevel::Intermediate.description(),
"a developer with some security knowledge"
);
assert_eq!(
AudienceLevel::Expert.description(),
"an experienced security professional"
);
}
#[test]
fn audience_level_display() {
assert_eq!(format!("{}", AudienceLevel::Beginner), "beginner");
assert_eq!(format!("{}", AudienceLevel::Intermediate), "intermediate");
assert_eq!(format!("{}", AudienceLevel::Expert), "expert");
}
#[test]
fn audience_level_from_str_aliases() {
assert_eq!(
"novice".parse::<AudienceLevel>(),
Ok(AudienceLevel::Beginner)
);
assert_eq!(
"medium".parse::<AudienceLevel>(),
Ok(AudienceLevel::Intermediate)
);
assert_eq!(
"advanced".parse::<AudienceLevel>(),
Ok(AudienceLevel::Expert)
);
}
#[test]
fn audience_level_default() {
assert_eq!(AudienceLevel::default(), AudienceLevel::Intermediate);
}
#[test]
fn config_with_api_key() {
let config = AiConfig::default().with_api_key("test-key-123");
assert_eq!(config.api_key, Some("test-key-123".to_string()));
}
#[test]
fn config_with_ollama_url() {
let config = AiConfig::default().with_ollama_url("http://custom:8080");
assert_eq!(config.ollama_url, "http://custom:8080");
}
#[test]
fn config_with_cache_ttl() {
let config = AiConfig::default().with_cache_ttl(Duration::from_secs(3600));
assert_eq!(config.cache_ttl_secs, 3600);
}
#[test]
fn config_without_cache() {
let config = AiConfig::default().without_cache();
assert_eq!(config.cache_ttl_secs, 0);
}
#[test]
fn config_with_streaming() {
let config = AiConfig::default().with_streaming();
assert!(config.stream);
}
#[test]
fn config_cache_ttl() {
let config = AiConfig::default();
let ttl = config.cache_ttl();
assert_eq!(ttl, Duration::from_secs(7 * 24 * 60 * 60));
}
#[test]
fn config_timeout() {
let config = AiConfig::default();
let timeout = config.timeout();
assert_eq!(timeout, Duration::from_secs(120));
}
#[test]
fn config_has_api_key_with_key() {
let config = AiConfig::default().with_api_key("test-key");
assert!(config.has_api_key());
}
#[test]
fn config_has_api_key_ollama_without_key() {
let config = AiConfig::ollama();
assert!(config.has_api_key()); }
#[test]
fn config_has_api_key_anthropic_without_key() {
let config = AiConfig::anthropic();
assert!(!config.has_api_key());
}
#[test]
fn config_temperature_clamp_high() {
let config = AiConfig::default().with_temperature(2.5);
assert_eq!(config.temperature, 1.0);
}
#[test]
fn config_temperature_clamp_low() {
let config = AiConfig::default().with_temperature(-0.5);
assert_eq!(config.temperature, 0.0);
}
#[test]
fn config_temperature_clamp_valid() {
let config = AiConfig::default().with_temperature(0.7);
assert_eq!(config.temperature, 0.7);
}
#[test]
fn config_validate_zero_max_tokens() {
let mut config = AiConfig::ollama();
config.max_tokens = 0;
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("max_tokens"));
}
#[test]
fn config_validate_zero_timeout() {
let mut config = AiConfig::ollama();
config.timeout_secs = 0;
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("timeout_secs"));
}
#[test]
fn config_validate_openai_no_key() {
let config = AiConfig::openai();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("OPENAI_API_KEY"));
}
#[test]
fn config_anthropic_preset() {
let config = AiConfig::anthropic();
assert_eq!(config.provider, AiProvider::Anthropic);
assert_eq!(config.model, "claude-sonnet-4-20250514");
assert_eq!(config.rate_limit_rpm, 50);
assert_eq!(config.rate_limit_tpm, 100_000);
}
#[test]
fn config_openai_preset() {
let config = AiConfig::openai();
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4o");
assert_eq!(config.rate_limit_rpm, 60);
assert_eq!(config.rate_limit_tpm, 150_000);
}
#[test]
fn config_ollama_preset() {
let config = AiConfig::ollama();
assert_eq!(config.provider, AiProvider::Ollama);
assert_eq!(config.model, "llama3.2");
assert_eq!(config.rate_limit_rpm, 1000);
assert_eq!(config.rate_limit_tpm, 1_000_000);
}
#[test]
fn builder_new() {
let builder = AiConfigBuilder::new();
assert!(builder.provider.is_none());
assert!(builder.model.is_none());
}
#[test]
fn builder_default() {
let builder = AiConfigBuilder::default();
assert!(builder.provider.is_none());
}
#[test]
fn builder_provider() {
let config = AiConfigBuilder::new().provider(AiProvider::OpenAI).build();
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4o");
}
#[test]
fn builder_model() {
let config = AiConfigBuilder::new()
.provider(AiProvider::Anthropic)
.model("claude-opus-4")
.build();
assert_eq!(config.model, "claude-opus-4");
}
#[test]
fn builder_api_key() {
let config = AiConfigBuilder::new()
.provider(AiProvider::Anthropic)
.api_key("builder-test-key")
.build();
assert_eq!(config.api_key, Some("builder-test-key".to_string()));
}
#[test]
fn builder_base_url() {
let config = AiConfigBuilder::new()
.provider(AiProvider::Ollama)
.base_url("http://custom:9000")
.build();
assert_eq!(config.ollama_url, "http://custom:9000");
}
#[test]
fn builder_timeout() {
let config = AiConfigBuilder::new()
.provider(AiProvider::Anthropic)
.timeout(300)
.build();
assert_eq!(config.timeout_secs, 300);
}
#[test]
fn builder_max_tokens() {
let config = AiConfigBuilder::new()
.provider(AiProvider::OpenAI)
.max_tokens(4096)
.build();
assert_eq!(config.max_tokens, 4096);
}
#[test]
fn builder_temperature() {
let config = AiConfigBuilder::new()
.provider(AiProvider::Anthropic)
.temperature(0.8)
.build();
assert_eq!(config.temperature, 0.8);
}
#[test]
fn builder_temperature_clamp() {
let config = AiConfigBuilder::new()
.provider(AiProvider::Anthropic)
.temperature(1.5)
.build();
assert_eq!(config.temperature, 1.0);
}
#[test]
fn builder_chain_all() {
let config = AiConfigBuilder::new()
.provider(AiProvider::OpenAI)
.model("gpt-4-turbo")
.api_key("test-key")
.base_url("http://custom:8080")
.timeout(180)
.max_tokens(8192)
.temperature(0.6)
.build();
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4-turbo");
assert_eq!(config.api_key, Some("test-key".to_string()));
assert_eq!(config.ollama_url, "http://custom:8080");
assert_eq!(config.timeout_secs, 180);
assert_eq!(config.max_tokens, 8192);
assert_eq!(config.temperature, 0.6);
}
#[test]
fn builder_from_config() {
let config = AiConfig::builder().provider(AiProvider::Ollama).build();
assert_eq!(config.provider, AiProvider::Ollama);
}
#[test]
fn builder_defaults_to_anthropic() {
let config = AiConfigBuilder::new().build();
assert_eq!(config.provider, AiProvider::Anthropic);
}
#[test]
fn explanation_context_default() {
let ctx = ExplanationContext::default();
assert_eq!(ctx.server_name, "Unknown Server");
assert!(ctx.tech_stack.is_empty());
assert_eq!(ctx.audience, AudienceLevel::Intermediate);
assert!(ctx.code_language.is_none());
assert!(ctx.include_education);
assert!(ctx.include_code_examples);
}
#[test]
fn explanation_context_new() {
let ctx = ExplanationContext::new("test-server");
assert_eq!(ctx.server_name, "test-server");
}
#[test]
fn explanation_context_with_audience() {
let ctx = ExplanationContext::new("server").with_audience(AudienceLevel::Expert);
assert_eq!(ctx.audience, AudienceLevel::Expert);
}
#[test]
fn explanation_context_with_tech_stack() {
let stack = vec!["Rust".to_string(), "Python".to_string()];
let ctx = ExplanationContext::new("server").with_tech_stack(stack.clone());
assert_eq!(ctx.tech_stack, stack);
}
#[test]
fn explanation_context_with_code_language() {
let ctx = ExplanationContext::new("server").with_code_language("rust");
assert_eq!(ctx.code_language, Some("rust".to_string()));
}
#[test]
fn explanation_context_without_education() {
let ctx = ExplanationContext::new("server").without_education();
assert!(!ctx.include_education);
}
#[test]
fn explanation_context_without_code_examples() {
let ctx = ExplanationContext::new("server").without_code_examples();
assert!(!ctx.include_code_examples);
}
#[test]
fn explanation_context_chain_all() {
let ctx = ExplanationContext::new("my-server")
.with_audience(AudienceLevel::Beginner)
.with_tech_stack(vec!["Rust".to_string()])
.with_code_language("python")
.without_education()
.without_code_examples();
assert_eq!(ctx.server_name, "my-server");
assert_eq!(ctx.audience, AudienceLevel::Beginner);
assert_eq!(ctx.tech_stack, vec!["Rust".to_string()]);
assert_eq!(ctx.code_language, Some("python".to_string()));
assert!(!ctx.include_education);
assert!(!ctx.include_code_examples);
}
#[test]
fn config_load_api_key_from_env_when_already_set() {
let mut config = AiConfig::anthropic().with_api_key("existing-key");
config.load_api_key_from_env();
assert_eq!(config.api_key, Some("existing-key".to_string()));
}
#[test]
fn parse_toml_provider_update_model() {
let toml_content = r#"
[ai]
provider = "openai"
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4o"); }
#[test]
fn parse_toml_model_override() {
let toml_content = r#"
[ai]
provider = "anthropic"
model = "custom-model"
"#;
let config = AiConfig::parse_toml(toml_content).unwrap();
assert_eq!(config.model, "custom-model");
}
#[test]
fn config_with_provider_updates_model() {
let config = AiConfig::default().with_provider(AiProvider::OpenAI);
assert_eq!(config.provider, AiProvider::OpenAI);
assert_eq!(config.model, "gpt-4o");
}
#[test]
fn serialization_skips_api_key() {
let config = AiConfig::anthropic().with_api_key("secret-key");
let json = serde_json::to_string(&config).unwrap();
assert!(!json.contains("secret-key"));
assert!(!json.contains("api_key"));
}
}