Skip to main content

ravenclaws/
config.rs

1//! RavenClaws
2//!
3//! Secure by default: no credentials in config files, use environment variables.
4//! Supports multiple LLM providers: LiteLLM, OpenRouter, Ollama, OpenAI.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use zeroize::Zeroize;
9
10/// Configuration error type.
11///
12/// # Stability
13/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
14#[derive(Error, Debug)]
15#[non_exhaustive]
16pub enum ConfigError {
17    #[error("Failed to load config: {0}")]
18    LoadError(String),
19    #[error("Invalid configuration: {0}")]
20    ValidationError(String),
21    #[error("Missing required environment variable: {0}")]
22    #[allow(dead_code)]
23    MissingEnvVar(String),
24}
25
26/// LLM Provider type — determines which backend to use
27///
28/// # Stability
29/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
30/// Match with a wildcard arm to handle future variants.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
32#[serde(rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum LLMProvider {
35    #[default]
36    LiteLLM,
37    OpenRouter,
38    Ollama,
39    OpenAI,
40    Anthropic,
41    /// Generic OpenAI-compatible provider (vLLM, llama.cpp, LM Studio, TGI, Groq, Together AI, etc.)
42    #[serde(rename = "openai-compatible")]
43    OpenAICompatible,
44}
45
46/// Top-level configuration for RavenClaws.
47///
48/// # Stability
49/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
50/// Construct using `Config::load()` or use `..Default::default()` for updates.
51#[derive(Debug, Clone, Deserialize, Default)]
52#[non_exhaustive]
53pub struct Config {
54    /// LiteLLM configuration (single provider mode)
55    #[serde(default)]
56    pub llm: LLMConfig,
57
58    /// Multiple LLM configurations (multi-model mode)
59    #[serde(default)]
60    pub llms: Vec<LLMConfig>,
61
62    /// RavenFabric configuration
63    #[serde(default)]
64    pub ravenfabric: RavenFabricConfig,
65
66    /// Security settings
67    #[serde(default)]
68    pub security: SecurityConfig,
69
70    /// Runtime settings
71    #[serde(default)]
72    pub runtime: RuntimeConfig,
73
74    /// Telemetry / OpenTelemetry settings (v0.7.2)
75    #[serde(default)]
76    pub telemetry: TelemetryConfig,
77
78    /// Scheduler / triggers configuration (v0.8)
79    #[serde(default)]
80    pub scheduler: SchedulerConfig,
81
82    /// Web search configuration (v0.8)
83    #[serde(default)]
84    pub web_search: WebSearchConfig,
85
86    /// Heartbeat / autonomous agent configuration (v0.9)
87    #[serde(default)]
88    pub heartbeat: crate::heartbeat::HeartbeatConfig,
89
90    /// Swarm orchestration configuration (v0.9)
91    #[serde(default)]
92    pub swarm: crate::swarm::SwarmConfig,
93
94    /// MCP server connections configuration (v0.9.6)
95    #[serde(default)]
96    pub mcp: McpConfig,
97}
98
99/// MCP server connections configuration (v0.9.6)
100///
101/// Defines one or more MCP servers to connect to at startup.
102/// Each server is a subprocess that communicates via JSON-RPC 2.0 over stdio.
103///
104/// # Example (TOML)
105///
106/// ```toml
107/// [mcp]
108/// servers = [
109///   { name = "filesystem", command = "npx", args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] },
110///   { name = "database", command = "python", args = ["mcp-server.py"], env = { DB_URL = "postgres://..." } },
111/// ]
112/// ```
113///
114/// # Stability
115/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
116#[derive(Debug, Clone, Deserialize, Default)]
117#[non_exhaustive]
118pub struct McpConfig {
119    /// List of MCP servers to connect to
120    #[serde(default)]
121    pub servers: Vec<McpServerConfig>,
122}
123
124/// Configuration for a single MCP server connection (v0.9.6)
125///
126/// # Stability
127/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
128#[derive(Debug, Clone, Deserialize)]
129#[non_exhaustive]
130pub struct McpServerConfig {
131    /// Human-readable name for this MCP server (used in logs and tool namespacing)
132    pub name: String,
133    /// Command to launch the MCP server process
134    pub command: String,
135    /// Arguments to pass to the command
136    #[serde(default)]
137    pub args: Vec<String>,
138    /// Environment variables to set for the MCP server process
139    #[serde(default)]
140    pub env: std::collections::HashMap<String, String>,
141}
142
143/// Web search configuration (v0.8)
144///
145/// # Stability
146/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
147#[derive(Debug, Clone, Deserialize)]
148#[non_exhaustive]
149pub struct WebSearchConfig {
150    /// Search API endpoint (SearXNG or compatible)
151    #[serde(default = "default_search_endpoint")]
152    pub endpoint: String,
153
154    /// Search engine to use (e.g., "duckduckgo", "google", "brave")
155    #[serde(default = "default_search_engine")]
156    pub engine: String,
157
158    /// Maximum number of search results to return
159    #[serde(default = "default_search_max_results")]
160    pub max_results: usize,
161
162    /// Whether to fetch and extract content from each search result
163    #[serde(default = "default_true")]
164    pub fetch_content: bool,
165}
166
167impl Default for WebSearchConfig {
168    fn default() -> Self {
169        Self {
170            endpoint: default_search_endpoint(),
171            engine: default_search_engine(),
172            max_results: default_search_max_results(),
173            fetch_content: default_true(),
174        }
175    }
176}
177
178fn default_search_endpoint() -> String {
179    "https://searx.be".to_string()
180}
181
182fn default_search_engine() -> String {
183    "duckduckgo".to_string()
184}
185
186fn default_search_max_results() -> usize {
187    5
188}
189
190fn default_otel_disabled() -> bool {
191    true
192}
193
194/// Scheduler / triggers configuration (v0.8)
195///
196/// # Stability
197/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
198#[derive(Debug, Clone, Deserialize, Default)]
199#[non_exhaustive]
200pub struct SchedulerConfig {
201    /// List of trigger configurations
202    #[serde(default)]
203    pub triggers: Vec<crate::scheduler::TriggerConfig>,
204}
205
206/// Telemetry / OpenTelemetry configuration (v0.7.2)
207///
208/// # Stability
209/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
210#[derive(Debug, Clone, Deserialize, Default)]
211#[non_exhaustive]
212pub struct TelemetryConfig {
213    /// OTLP gRPC endpoint for OpenTelemetry (e.g., "http://jaeger:4317")
214    #[serde(default)]
215    pub otel_endpoint: Option<String>,
216
217    /// Service name for OpenTelemetry traces
218    #[serde(default)]
219    pub otel_service_name: Option<String>,
220
221    /// Disable OpenTelemetry tracing (default: true — opt-in)
222    #[serde(default = "default_otel_disabled")]
223    pub otel_disabled: bool,
224}
225
226/// LLM provider configuration.
227///
228/// # Stability
229/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
230/// Construct using `LLMConfig::default()` or use `..Default::default()` for updates.
231#[derive(Debug, Clone, Deserialize)]
232#[non_exhaustive]
233pub struct LLMConfig {
234    /// Provider type: litellm, openrouter, ollama, openai
235    #[serde(default)]
236    pub provider: LLMProvider,
237
238    /// Endpoint URL (e.g., http://litellm:4000, http://localhost:11434, https://api.openai.com)
239    #[serde(default)]
240    pub endpoint: String,
241
242    /// Default model to use
243    #[serde(default = "default_model")]
244    pub model: String,
245
246    /// API key (prefer env var)
247    #[serde(default)]
248    pub api_key: Option<String>,
249
250    /// Request timeout in seconds
251    #[serde(default = "default_timeout")]
252    pub timeout_secs: u64,
253
254    /// System prompt / persona for the agent
255    #[serde(default = "default_system_prompt")]
256    pub system_prompt: String,
257
258    /// Token budget (v0.5) — maximum tokens per run
259    #[serde(default)]
260    pub token_budget: Option<u32>,
261
262    /// Retry max attempts (v0.5) — default 3
263    #[serde(default = "default_retry_max")]
264    pub retry_max: u32,
265
266    /// Retry base delay in ms (v0.5) — default 100
267    #[serde(default = "default_retry_base_delay")]
268    pub retry_base_delay_ms: u64,
269
270    /// Retry max delay in ms (v0.5) — default 10000
271    #[serde(default = "default_retry_max_delay")]
272    pub retry_max_delay_ms: u64,
273}
274
275pub fn default_retry_max() -> u32 {
276    3
277}
278pub fn default_retry_base_delay() -> u64 {
279    100
280}
281pub fn default_retry_max_delay() -> u64 {
282    10000
283}
284
285pub fn default_system_prompt() -> String {
286    "You are RavenClaws, a lightweight autonomous agent. \
287        Be concise, efficient, and secure. Always validate inputs and outputs. \
288        When you have completed the task, prefix your final answer with FINAL: \
289        so the system knows the task is done."
290        .to_string()
291}
292
293/// RavenFabric mesh client configuration.
294///
295/// # Stability
296/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
297#[derive(Debug, Clone, Deserialize)]
298#[non_exhaustive]
299pub struct RavenFabricConfig {
300    /// RavenFabric endpoint
301    #[serde(default)]
302    pub endpoint: Option<String>,
303
304    /// Agent ID for identification
305    #[serde(default)]
306    pub agent_id: Option<String>,
307
308    /// Enable remote command execution
309    #[serde(default = "default_true")]
310    pub remote_exec: bool,
311
312    /// Allowed remote hosts (whitelist)
313    #[serde(default)]
314    #[allow(dead_code)]
315    pub allowed_hosts: Vec<String>,
316}
317
318impl Default for RavenFabricConfig {
319    fn default() -> Self {
320        Self {
321            endpoint: None,
322            agent_id: None,
323            remote_exec: default_true(),
324            allowed_hosts: Vec::new(),
325        }
326    }
327}
328
329/// Security configuration.
330///
331/// # Stability
332/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
333#[derive(Debug, Clone, Deserialize)]
334#[non_exhaustive]
335pub struct SecurityConfig {
336    /// Require TLS for all connections
337    #[serde(default = "default_true")]
338    pub require_tls: bool,
339
340    /// Maximum token lifetime in seconds (0 = unlimited)
341    /// When non-zero, agent sessions automatically terminate after this duration.
342    /// Enforced in the agent loop — checked before each iteration.
343    #[serde(default = "default_token_lifetime")]
344    pub token_lifetime_secs: u64,
345
346    /// Enable audit logging
347    #[serde(default = "default_true")]
348    #[allow(dead_code)]
349    pub audit_log: bool,
350
351    /// Enable prompt-injection defense
352    /// When true, LLM responses are scanned for injection patterns
353    /// and output schema violations before processing.
354    #[serde(default = "default_true")]
355    #[allow(dead_code)]
356    pub prompt_injection_protection: bool,
357}
358
359impl Default for SecurityConfig {
360    fn default() -> Self {
361        Self {
362            require_tls: default_true(),
363            token_lifetime_secs: default_token_lifetime(),
364            audit_log: default_true(),
365            prompt_injection_protection: default_true(),
366        }
367    }
368}
369
370/// Runtime configuration.
371///
372/// # Stability
373/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
374#[derive(Debug, Clone, Deserialize)]
375#[non_exhaustive]
376pub struct RuntimeConfig {
377    /// Working directory
378    #[serde(default = "default_workdir")]
379    #[allow(dead_code)]
380    pub workdir: String,
381
382    /// Maximum concurrent agents
383    #[serde(default = "default_max_agents")]
384    #[allow(dead_code)]
385    pub max_agents: usize,
386
387    /// Health check interval in seconds
388    #[serde(default = "default_health_interval")]
389    #[allow(dead_code)]
390    pub health_interval_secs: u64,
391
392    /// HTTP server host (v0.7)
393    #[serde(default)]
394    pub host: Option<String>,
395
396    /// HTTP server port (v0.7)
397    #[serde(default = "default_server_port")]
398    pub port: u16,
399}
400
401impl Default for RuntimeConfig {
402    fn default() -> Self {
403        Self {
404            workdir: default_workdir(),
405            max_agents: default_max_agents(),
406            health_interval_secs: default_health_interval(),
407            host: None,
408            port: default_server_port(),
409        }
410    }
411}
412
413fn default_model() -> String {
414    "gpt-4o-mini".to_string()
415}
416
417fn default_timeout() -> u64 {
418    30
419}
420
421fn default_true() -> bool {
422    true
423}
424
425fn default_token_lifetime() -> u64 {
426    3600
427}
428
429fn default_workdir() -> String {
430    "/tmp/ravenclaws-workdir".to_string()
431}
432
433fn default_max_agents() -> usize {
434    10
435}
436
437fn default_health_interval() -> u64 {
438    60
439}
440
441fn default_server_port() -> u16 {
442    8080
443}
444
445impl Default for LLMConfig {
446    fn default() -> Self {
447        Self {
448            provider: LLMProvider::LiteLLM,
449            endpoint: String::new(),
450            model: default_model(),
451            api_key: None,
452            timeout_secs: default_timeout(),
453            system_prompt: default_system_prompt(),
454            token_budget: None,
455            retry_max: default_retry_max(),
456            retry_base_delay_ms: default_retry_base_delay(),
457            retry_max_delay_ms: default_retry_max_delay(),
458        }
459    }
460}
461
462/// Zeroize sensitive fields on drop — API keys are cleared from memory
463impl Drop for LLMConfig {
464    fn drop(&mut self) {
465        if let Some(ref mut key) = self.api_key {
466            key.zeroize();
467        }
468    }
469}
470
471impl Config {
472    /// Load configuration from file and environment
473    pub fn load(config_path: Option<&str>) -> Result<Self, ConfigError> {
474        // Start with defaults from environment
475        dotenvy::dotenv().ok();
476
477        let mut config_builder = config::Config::builder();
478
479        // Load from file if provided
480        if let Some(path) = config_path {
481            config_builder =
482                config_builder.add_source(config::File::with_name(path).required(false));
483        }
484
485        // Load from environment (RAVENCLAW_* prefix)
486        // Save and remove RAVENCLAWS__LLMS before serde deserialization because
487        // config::Environment passes it as a raw string, which serde can't
488        // deserialize into Vec<LLMConfig>. We restore and parse it manually below.
489        let ravenclaws_llms = std::env::var("RAVENCLAWS__LLMS").ok();
490        if ravenclaws_llms.is_some() {
491            std::env::remove_var("RAVENCLAWS__LLMS");
492        }
493
494        config_builder = config_builder
495            .add_source(config::Environment::with_prefix("RAVENCLAW").separator("__"));
496
497        let config = config_builder
498            .build()
499            .map_err(|e| ConfigError::LoadError(e.to_string()))?;
500
501        let mut cfg: Config = config
502            .try_deserialize()
503            .map_err(|e| ConfigError::LoadError(e.to_string()))?;
504
505        // Restore RAVENCLAWS__LLMS if it was set
506        if let Some(ref val) = ravenclaws_llms {
507            std::env::set_var("RAVENCLAWS__LLMS", val);
508        }
509
510        // Override sensitive values from environment
511        // Single provider mode
512        if let Ok(key) = std::env::var("LITELLM_API_KEY") {
513            cfg.llm.api_key = Some(key);
514        }
515        if let Ok(provider) = std::env::var("RAVENCLAWS__LLM__PROVIDER") {
516            cfg.llm.provider = match provider.to_lowercase().as_str() {
517                "openrouter" => LLMProvider::OpenRouter,
518                "ollama" => LLMProvider::Ollama,
519                "openai" => LLMProvider::OpenAI,
520                "anthropic" => LLMProvider::Anthropic,
521                _ => LLMProvider::LiteLLM,
522            };
523        }
524        if let Ok(endpoint) = std::env::var("RAVENCLAWS__LLM__ENDPOINT") {
525            cfg.llm.endpoint = endpoint;
526        }
527        if let Ok(model) = std::env::var("RAVENCLAWS__LLM__MODEL") {
528            cfg.llm.model = model;
529        }
530
531        // Multi-provider mode
532        // Note: RAVENCLAWS__LLMS is handled manually (not via config::Environment)
533        // because it's a JSON string that serde can't deserialize into Vec<LLMConfig>.
534        if let Ok(keys) = std::env::var("RAVENCLAWS__LLMS") {
535            // Parse JSON array of LLM configs from env
536            if let Ok(llms) = serde_json::from_str::<Vec<LLMConfig>>(&keys) {
537                cfg.llms = llms;
538            }
539        }
540
541        if let Ok(endpoint) = std::env::var("RAVENFABRIC_ENDPOINT") {
542            cfg.ravenfabric.endpoint = Some(endpoint);
543        }
544
545        // Validate
546        cfg.validate()?;
547
548        Ok(cfg)
549    }
550
551    /// Validate configuration
552    fn validate(&self) -> Result<(), ConfigError> {
553        // Validate single provider config
554        if !self.llm.endpoint.is_empty() {
555            self.validate_llm_config(&self.llm)?;
556        }
557
558        // Validate multi-provider configs
559        for (i, llm) in self.llms.iter().enumerate() {
560            self.validate_llm_config(llm)
561                .map_err(|e| ConfigError::ValidationError(format!("LLM[{}]: {}", i, e)))?;
562        }
563
564        // At least one provider must be configured
565        if self.llm.endpoint.is_empty() && self.llms.is_empty() {
566            return Err(ConfigError::ValidationError(
567                "At least one LLM provider must be configured (llm or llms)".to_string(),
568            ));
569        }
570
571        Ok(())
572    }
573
574    fn validate_llm_config(&self, llm: &LLMConfig) -> Result<(), ConfigError> {
575        if llm.endpoint.is_empty()
576            && llm.provider != LLMProvider::OpenAI
577            && llm.provider != LLMProvider::OpenRouter
578            && llm.provider != LLMProvider::Anthropic
579        {
580            // OpenAI, OpenRouter, and Anthropic have fixed endpoints
581            return Err(ConfigError::ValidationError(
582                "LLM endpoint is required for this provider".to_string(),
583            ));
584        }
585
586        if self.security.require_tls
587            && !llm.endpoint.is_empty()
588            && !llm.endpoint.starts_with("https://")
589            && !llm.endpoint.contains("localhost")
590            && !llm.endpoint.contains("127.0.0.1")
591            && !llm.endpoint.contains("0.0.0.0")
592        {
593            return Err(ConfigError::ValidationError(
594                "TLS required but endpoint is not HTTPS".to_string(),
595            ));
596        }
597
598        Ok(())
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use serial_test::serial;
606
607    #[test]
608    #[serial(env_test)]
609    fn test_default_config() {
610        std::env::set_var("LITELLM_API_KEY", "test-key");
611        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
612
613        let config = Config::load(None).unwrap();
614        assert_eq!(config.llm.model, "gpt-4o-mini");
615        assert_eq!(config.llm.timeout_secs, 30);
616        // require_tls defaults to true via serde(default = "default_true")
617        // but only when deserialized, not via #[derive(Default)]
618        // Since we load via serde, it should be true
619        assert!(config.security.require_tls);
620
621        // Clean up env vars set by this test
622        std::env::remove_var("LITELLM_API_KEY");
623        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
624    }
625
626    #[test]
627    fn test_llm_provider_default() {
628        assert_eq!(LLMProvider::default(), LLMProvider::LiteLLM);
629    }
630
631    #[test]
632    fn test_llm_provider_serde() {
633        let json = r#""litellm""#;
634        let provider: LLMProvider = serde_json::from_str(json).unwrap();
635        assert_eq!(provider, LLMProvider::LiteLLM);
636
637        let json = r#""openai""#;
638        let provider: LLMProvider = serde_json::from_str(json).unwrap();
639        assert_eq!(provider, LLMProvider::OpenAI);
640
641        let json = r#""ollama""#;
642        let provider: LLMProvider = serde_json::from_str(json).unwrap();
643        assert_eq!(provider, LLMProvider::Ollama);
644
645        let json = r#""openrouter""#;
646        let provider: LLMProvider = serde_json::from_str(json).unwrap();
647        assert_eq!(provider, LLMProvider::OpenRouter);
648    }
649
650    #[test]
651    fn test_llm_config_default() {
652        let config = LLMConfig::default();
653        assert_eq!(config.provider, LLMProvider::LiteLLM);
654        assert_eq!(config.model, "gpt-4o-mini");
655        assert_eq!(config.timeout_secs, 30);
656        assert!(config.api_key.is_none());
657        assert!(config.endpoint.is_empty());
658        assert!(config.system_prompt.contains("RavenClaws"));
659    }
660
661    #[test]
662    fn test_system_prompt_custom() {
663        let mut config = LLMConfig::default();
664        config.system_prompt = "You are a helpful coding assistant.".to_string();
665        assert_eq!(config.system_prompt, "You are a helpful coding assistant.");
666    }
667
668    #[test]
669    fn test_validate_missing_endpoint() {
670        let config = Config {
671            llm: LLMConfig {
672                provider: LLMProvider::LiteLLM,
673                endpoint: String::new(),
674                model: "gpt-4o-mini".to_string(),
675                api_key: None,
676                timeout_secs: 30,
677                system_prompt: default_system_prompt(),
678                token_budget: None,
679                retry_max: 3,
680                retry_base_delay_ms: 100,
681                retry_max_delay_ms: 10000,
682            },
683            llms: vec![],
684            ravenfabric: RavenFabricConfig::default(),
685            security: SecurityConfig {
686                require_tls: false,
687                token_lifetime_secs: 3600,
688                audit_log: false,
689                prompt_injection_protection: false,
690            },
691            runtime: RuntimeConfig::default(),
692            telemetry: TelemetryConfig::default(),
693            scheduler: SchedulerConfig::default(),
694            web_search: WebSearchConfig::default(),
695            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
696            mcp: McpConfig::default(),
697            swarm: crate::swarm::SwarmConfig::default(),
698        };
699
700        let result = config.validate();
701        assert!(result.is_err());
702        assert!(result
703            .unwrap_err()
704            .to_string()
705            .contains("At least one LLM provider"));
706    }
707
708    #[test]
709    fn test_validate_tls_required() {
710        let config = Config {
711            llm: LLMConfig {
712                provider: LLMProvider::LiteLLM,
713                endpoint: "http://example.com:4000".to_string(),
714                model: "gpt-4o-mini".to_string(),
715                api_key: Some("key".to_string()),
716                timeout_secs: 30,
717                system_prompt: default_system_prompt(),
718                token_budget: None,
719                retry_max: 3,
720                retry_base_delay_ms: 100,
721                retry_max_delay_ms: 10000,
722            },
723            llms: vec![],
724            ravenfabric: RavenFabricConfig::default(),
725            security: SecurityConfig {
726                require_tls: true,
727                token_lifetime_secs: 3600,
728                audit_log: false,
729                prompt_injection_protection: false,
730            },
731            runtime: RuntimeConfig::default(),
732            telemetry: TelemetryConfig::default(),
733            scheduler: SchedulerConfig::default(),
734            web_search: WebSearchConfig::default(),
735            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
736            mcp: McpConfig::default(),
737            swarm: crate::swarm::SwarmConfig::default(),
738        };
739
740        let result = config.validate();
741        assert!(result.is_err());
742        let err = result.unwrap_err().to_string();
743        assert!(err.contains("TLS required"));
744    }
745
746    #[test]
747    fn test_validate_tls_localhost_allowed() {
748        let config = Config {
749            llm: LLMConfig {
750                provider: LLMProvider::LiteLLM,
751                endpoint: "http://localhost:4000".to_string(),
752                model: "gpt-4o-mini".to_string(),
753                api_key: Some("key".to_string()),
754                timeout_secs: 30,
755                system_prompt: default_system_prompt(),
756                token_budget: None,
757                retry_max: 3,
758                retry_base_delay_ms: 100,
759                retry_max_delay_ms: 10000,
760            },
761            llms: vec![],
762            ravenfabric: RavenFabricConfig::default(),
763            security: SecurityConfig {
764                require_tls: true,
765                token_lifetime_secs: 3600,
766                audit_log: false,
767                prompt_injection_protection: false,
768            },
769            runtime: RuntimeConfig::default(),
770            telemetry: TelemetryConfig::default(),
771            scheduler: SchedulerConfig::default(),
772            web_search: WebSearchConfig::default(),
773            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
774            mcp: McpConfig::default(),
775            swarm: crate::swarm::SwarmConfig::default(),
776        };
777
778        let result = config.validate();
779        assert!(result.is_ok());
780    }
781
782    #[test]
783    fn test_validate_openai_no_endpoint_needed() {
784        let config = Config {
785            llm: LLMConfig {
786                provider: LLMProvider::OpenAI,
787                endpoint: String::new(),
788                model: "gpt-4o".to_string(),
789                api_key: Some("sk-key".to_string()),
790                timeout_secs: 30,
791                system_prompt: default_system_prompt(),
792                token_budget: None,
793                retry_max: 3,
794                retry_base_delay_ms: 100,
795                retry_max_delay_ms: 10000,
796            },
797            llms: vec![],
798            ravenfabric: RavenFabricConfig::default(),
799            security: SecurityConfig {
800                require_tls: false,
801                token_lifetime_secs: 3600,
802                audit_log: false,
803                prompt_injection_protection: false,
804            },
805            runtime: RuntimeConfig::default(),
806            telemetry: TelemetryConfig::default(),
807            scheduler: SchedulerConfig::default(),
808            web_search: WebSearchConfig::default(),
809            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
810            mcp: McpConfig::default(),
811            swarm: crate::swarm::SwarmConfig::default(),
812        };
813
814        // OpenAI doesn't need an endpoint, but the llm.endpoint is empty
815        // and the llm section is checked. Since llm.endpoint is empty,
816        // validate() skips the llm check but then fails because no providers.
817        // Fix: set llm.endpoint to something or add llms
818        let result = config.validate();
819        assert!(result.is_err()); // No endpoint set for llm, and no llms
820    }
821
822    #[test]
823    fn test_validate_multi_provider() {
824        let config = Config {
825            llm: LLMConfig::default(),
826            llms: vec![LLMConfig {
827                provider: LLMProvider::Ollama,
828                endpoint: "http://localhost:11434".to_string(),
829                model: "llama3.1".to_string(),
830                api_key: None,
831                timeout_secs: 60,
832                system_prompt: default_system_prompt(),
833                token_budget: None,
834                retry_max: 3,
835                retry_base_delay_ms: 100,
836                retry_max_delay_ms: 10000,
837            }],
838            ravenfabric: RavenFabricConfig::default(),
839            security: SecurityConfig {
840                require_tls: false,
841                token_lifetime_secs: 3600,
842                audit_log: false,
843                prompt_injection_protection: false,
844            },
845            runtime: RuntimeConfig::default(),
846            telemetry: TelemetryConfig::default(),
847            scheduler: SchedulerConfig::default(),
848            web_search: WebSearchConfig::default(),
849            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
850            mcp: McpConfig::default(),
851            swarm: crate::swarm::SwarmConfig::default(),
852        };
853
854        let result = config.validate();
855        assert!(result.is_ok());
856    }
857
858    #[test]
859    fn test_ravenfabric_config_default() {
860        let config = RavenFabricConfig::default();
861        assert!(config.endpoint.is_none());
862        assert!(config.agent_id.is_none());
863        assert!(config.remote_exec);
864        assert!(config.allowed_hosts.is_empty());
865    }
866
867    #[test]
868    fn test_security_config_default() {
869        let config = SecurityConfig::default();
870        assert!(config.require_tls);
871        assert_eq!(config.token_lifetime_secs, 3600);
872        assert!(config.audit_log);
873    }
874
875    #[test]
876    fn test_runtime_config_default() {
877        let config = RuntimeConfig::default();
878        assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
879        assert_eq!(config.max_agents, 10);
880        assert_eq!(config.health_interval_secs, 60);
881    }
882
883    #[test]
884    fn test_config_error_display() {
885        let err = ConfigError::LoadError("file not found".to_string());
886        assert_eq!(format!("{}", err), "Failed to load config: file not found");
887
888        let err = ConfigError::ValidationError("bad field".to_string());
889        assert_eq!(format!("{}", err), "Invalid configuration: bad field");
890
891        let err = ConfigError::MissingEnvVar("API_KEY".to_string());
892        assert_eq!(
893            format!("{}", err),
894            "Missing required environment variable: API_KEY"
895        );
896    }
897
898    #[test]
899    fn test_validate_openrouter_no_endpoint_needed() {
900        let config = Config {
901            llm: LLMConfig {
902                provider: LLMProvider::OpenRouter,
903                endpoint: String::new(),
904                model: "anthropic/claude-sonnet-4-20250514".to_string(),
905                api_key: Some("or-key".to_string()),
906                timeout_secs: 30,
907                system_prompt: default_system_prompt(),
908                token_budget: None,
909                retry_max: 3,
910                retry_base_delay_ms: 100,
911                retry_max_delay_ms: 10000,
912            },
913            llms: vec![],
914            ravenfabric: RavenFabricConfig::default(),
915            security: SecurityConfig {
916                require_tls: false,
917                token_lifetime_secs: 3600,
918                audit_log: false,
919                prompt_injection_protection: false,
920            },
921            runtime: RuntimeConfig::default(),
922            telemetry: TelemetryConfig::default(),
923            scheduler: SchedulerConfig::default(),
924            web_search: WebSearchConfig::default(),
925            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
926            mcp: McpConfig::default(),
927            swarm: crate::swarm::SwarmConfig::default(),
928        };
929
930        // OpenRouter doesn't need an endpoint, but llm.endpoint is empty
931        // so validate() skips llm check and fails because no providers
932        let result = config.validate();
933        assert!(result.is_err());
934    }
935
936    #[test]
937    fn test_validate_ollama_needs_endpoint() {
938        let config = Config {
939            llm: LLMConfig {
940                provider: LLMProvider::Ollama,
941                endpoint: String::new(),
942                model: "llama3.1".to_string(),
943                api_key: None,
944                timeout_secs: 30,
945                system_prompt: default_system_prompt(),
946                token_budget: None,
947                retry_max: 3,
948                retry_base_delay_ms: 100,
949                retry_max_delay_ms: 10000,
950            },
951            llms: vec![],
952            ravenfabric: RavenFabricConfig::default(),
953            security: SecurityConfig {
954                require_tls: false,
955                token_lifetime_secs: 3600,
956                audit_log: false,
957                prompt_injection_protection: false,
958            },
959            runtime: RuntimeConfig::default(),
960            telemetry: TelemetryConfig::default(),
961            scheduler: SchedulerConfig::default(),
962            web_search: WebSearchConfig::default(),
963            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
964            mcp: McpConfig::default(),
965            swarm: crate::swarm::SwarmConfig::default(),
966        };
967
968        let result = config.validate();
969        assert!(result.is_err());
970        let err = result.unwrap_err().to_string();
971        assert!(err.contains("At least one LLM provider"));
972    }
973
974    #[test]
975    fn test_validate_tls_localhost_ip_allowed() {
976        let config = Config {
977            llm: LLMConfig {
978                provider: LLMProvider::LiteLLM,
979                endpoint: "http://127.0.0.1:4000".to_string(),
980                model: "gpt-4o-mini".to_string(),
981                api_key: Some("key".to_string()),
982                timeout_secs: 30,
983                system_prompt: default_system_prompt(),
984                token_budget: None,
985                retry_max: 3,
986                retry_base_delay_ms: 100,
987                retry_max_delay_ms: 10000,
988            },
989            llms: vec![],
990            ravenfabric: RavenFabricConfig::default(),
991            security: SecurityConfig {
992                require_tls: true,
993                token_lifetime_secs: 3600,
994                audit_log: false,
995                prompt_injection_protection: false,
996            },
997            runtime: RuntimeConfig::default(),
998            telemetry: TelemetryConfig::default(),
999            scheduler: SchedulerConfig::default(),
1000            web_search: WebSearchConfig::default(),
1001            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1002            mcp: McpConfig::default(),
1003            swarm: crate::swarm::SwarmConfig::default(),
1004        };
1005
1006        let result = config.validate();
1007        assert!(result.is_ok());
1008    }
1009
1010    #[test]
1011    fn test_validate_tls_wildcard_allowed() {
1012        let config = Config {
1013            llm: LLMConfig {
1014                provider: LLMProvider::LiteLLM,
1015                endpoint: "http://0.0.0.0:4000".to_string(),
1016                model: "gpt-4o-mini".to_string(),
1017                api_key: Some("key".to_string()),
1018                timeout_secs: 30,
1019                system_prompt: default_system_prompt(),
1020                token_budget: None,
1021                retry_max: 3,
1022                retry_base_delay_ms: 100,
1023                retry_max_delay_ms: 10000,
1024            },
1025            llms: vec![],
1026            ravenfabric: RavenFabricConfig::default(),
1027            security: SecurityConfig {
1028                require_tls: true,
1029                token_lifetime_secs: 3600,
1030                audit_log: false,
1031                prompt_injection_protection: false,
1032            },
1033            runtime: RuntimeConfig::default(),
1034            telemetry: TelemetryConfig::default(),
1035            scheduler: SchedulerConfig::default(),
1036            web_search: WebSearchConfig::default(),
1037            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1038            mcp: McpConfig::default(),
1039            swarm: crate::swarm::SwarmConfig::default(),
1040        };
1041
1042        let result = config.validate();
1043        assert!(result.is_ok());
1044    }
1045
1046    #[test]
1047    fn test_validate_multi_provider_with_tls() {
1048        let config = Config {
1049            llm: LLMConfig::default(),
1050            llms: vec![
1051                LLMConfig {
1052                    provider: LLMProvider::Ollama,
1053                    endpoint: "http://localhost:11434".to_string(),
1054                    model: "llama3.1".to_string(),
1055                    api_key: None,
1056                    timeout_secs: 60,
1057                    system_prompt: default_system_prompt(),
1058                    token_budget: None,
1059                    retry_max: 3,
1060                    retry_base_delay_ms: 100,
1061                    retry_max_delay_ms: 10000,
1062                },
1063                LLMConfig {
1064                    provider: LLMProvider::LiteLLM,
1065                    endpoint: "https://litellm.example.com:4000".to_string(),
1066                    model: "gpt-4o-mini".to_string(),
1067                    api_key: Some("key".to_string()),
1068                    timeout_secs: 30,
1069                    system_prompt: default_system_prompt(),
1070                    token_budget: None,
1071                    retry_max: 3,
1072                    retry_base_delay_ms: 100,
1073                    retry_max_delay_ms: 10000,
1074                },
1075            ],
1076            ravenfabric: RavenFabricConfig::default(),
1077            security: SecurityConfig {
1078                require_tls: true,
1079                token_lifetime_secs: 3600,
1080                audit_log: false,
1081                prompt_injection_protection: false,
1082            },
1083            runtime: RuntimeConfig::default(),
1084            telemetry: TelemetryConfig::default(),
1085            scheduler: SchedulerConfig::default(),
1086            web_search: WebSearchConfig::default(),
1087            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1088            mcp: McpConfig::default(),
1089            swarm: crate::swarm::SwarmConfig::default(),
1090        };
1091
1092        let result = config.validate();
1093        assert!(result.is_ok());
1094    }
1095
1096    #[test]
1097    fn test_validate_multi_provider_tls_failure() {
1098        let config = Config {
1099            llm: LLMConfig::default(),
1100            llms: vec![LLMConfig {
1101                provider: LLMProvider::LiteLLM,
1102                endpoint: "http://example.com:4000".to_string(),
1103                model: "gpt-4o-mini".to_string(),
1104                api_key: Some("key".to_string()),
1105                timeout_secs: 30,
1106                system_prompt: default_system_prompt(),
1107                token_budget: None,
1108                retry_max: 3,
1109                retry_base_delay_ms: 100,
1110                retry_max_delay_ms: 10000,
1111            }],
1112            ravenfabric: RavenFabricConfig::default(),
1113            security: SecurityConfig {
1114                require_tls: true,
1115                token_lifetime_secs: 3600,
1116                audit_log: false,
1117                prompt_injection_protection: false,
1118            },
1119            runtime: RuntimeConfig::default(),
1120            telemetry: TelemetryConfig::default(),
1121            scheduler: SchedulerConfig::default(),
1122            web_search: WebSearchConfig::default(),
1123            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1124            mcp: McpConfig::default(),
1125            swarm: crate::swarm::SwarmConfig::default(),
1126        };
1127
1128        let result = config.validate();
1129        assert!(result.is_err());
1130        let err = result.unwrap_err().to_string();
1131        assert!(err.contains("TLS required"));
1132    }
1133
1134    #[test]
1135    fn test_ravenfabric_config_custom() {
1136        let config = RavenFabricConfig {
1137            endpoint: Some("https://fabric.example.com:8443".to_string()),
1138            agent_id: Some("agent-01".to_string()),
1139            remote_exec: false,
1140            allowed_hosts: vec!["10.0.0.0/8".to_string()],
1141        };
1142        assert_eq!(config.endpoint.unwrap(), "https://fabric.example.com:8443");
1143        assert_eq!(config.agent_id.unwrap(), "agent-01");
1144        assert!(!config.remote_exec);
1145        assert_eq!(config.allowed_hosts.len(), 1);
1146    }
1147
1148    #[test]
1149    fn test_security_config_custom() {
1150        let config = SecurityConfig {
1151            require_tls: false,
1152            token_lifetime_secs: 7200,
1153            audit_log: false,
1154            prompt_injection_protection: false,
1155        };
1156        assert!(!config.require_tls);
1157        assert_eq!(config.token_lifetime_secs, 7200);
1158        assert!(!config.audit_log);
1159    }
1160
1161    #[test]
1162    fn test_runtime_config_custom() {
1163        let config = RuntimeConfig {
1164            workdir: "/data".to_string(),
1165            max_agents: 5,
1166            health_interval_secs: 120,
1167            host: Some("127.0.0.1".to_string()),
1168            port: 9090,
1169        };
1170        assert_eq!(config.workdir, "/data");
1171        assert_eq!(config.max_agents, 5);
1172        assert_eq!(config.health_interval_secs, 120);
1173        assert_eq!(config.host, Some("127.0.0.1".to_string()));
1174        assert_eq!(config.port, 9090);
1175    }
1176
1177    #[test]
1178    fn test_llm_config_custom() {
1179        let config = LLMConfig {
1180            provider: LLMProvider::OpenAI,
1181            endpoint: String::new(),
1182            model: "gpt-4o".to_string(),
1183            api_key: Some("sk-test".to_string()),
1184            timeout_secs: 120,
1185            system_prompt: default_system_prompt(),
1186            token_budget: None,
1187            retry_max: 3,
1188            retry_base_delay_ms: 100,
1189            retry_max_delay_ms: 10000,
1190        };
1191        assert_eq!(config.provider, LLMProvider::OpenAI);
1192        assert_eq!(config.model, "gpt-4o");
1193        assert_eq!(config.timeout_secs, 120);
1194        assert_eq!(config.api_key.clone().unwrap(), "sk-test");
1195    }
1196
1197    #[test]
1198    fn test_llm_provider_serde_invalid_fallback() {
1199        // Unknown provider values should fall back to default (LiteLLM)
1200        let json = r#""unknown_provider""#;
1201        let provider: LLMProvider = serde_json::from_str(json).unwrap_or_default();
1202        assert_eq!(provider, LLMProvider::LiteLLM);
1203    }
1204
1205    #[test]
1206    #[serial(env_test)]
1207    fn test_config_load_with_env_overrides() {
1208        // Set up env vars for single-provider mode
1209        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1210        std::env::set_var("RAVENCLAWS__LLM__MODEL", "gpt-4o");
1211        std::env::set_var("LITELLM_API_KEY", "env-key");
1212
1213        let config = Config::load(None).unwrap();
1214        assert_eq!(config.llm.endpoint, "http://localhost:4000");
1215        assert_eq!(config.llm.model, "gpt-4o");
1216        assert_eq!(config.llm.api_key.clone().unwrap(), "env-key");
1217
1218        // Clean up env vars set by this test
1219        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1220        std::env::remove_var("RAVENCLAWS__LLM__MODEL");
1221        std::env::remove_var("LITELLM_API_KEY");
1222    }
1223
1224    #[test]
1225    #[serial(env_test)]
1226    fn test_config_load_with_llms_json_env() {
1227        let llms_json = r#"[{"provider":"ollama","endpoint":"http://localhost:11434","model":"llama3.1","timeout_secs":60}]"#;
1228        std::env::set_var("RAVENCLAWS__LLMS", llms_json);
1229        std::env::set_var("LITELLM_API_KEY", "dummy");
1230        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1231
1232        let config = Config::load(None).unwrap();
1233        assert_eq!(config.llms.len(), 1);
1234        assert_eq!(config.llms[0].provider, LLMProvider::Ollama);
1235        assert_eq!(config.llms[0].endpoint, "http://localhost:11434");
1236        assert_eq!(config.llms[0].model, "llama3.1");
1237        assert_eq!(config.llms[0].timeout_secs, 60);
1238
1239        // Clean up env vars set by this test
1240        std::env::remove_var("RAVENCLAWS__LLMS");
1241        std::env::remove_var("LITELLM_API_KEY");
1242        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1243    }
1244
1245    #[test]
1246    #[serial(env_test)]
1247    fn test_config_load_with_ravenfabric_env() {
1248        std::env::set_var("RAVENFABRIC_ENDPOINT", "https://fabric.example.com:8443");
1249        std::env::set_var("LITELLM_API_KEY", "dummy");
1250        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1251
1252        let config = Config::load(None).unwrap();
1253        assert_eq!(
1254            config.ravenfabric.endpoint.unwrap(),
1255            "https://fabric.example.com:8443"
1256        );
1257
1258        // Clean up env vars set by this test
1259        std::env::remove_var("RAVENFABRIC_ENDPOINT");
1260        std::env::remove_var("LITELLM_API_KEY");
1261        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1262    }
1263
1264    #[test]
1265    #[serial(env_test)]
1266    fn test_config_load_with_provider_env() {
1267        // Test provider override via env var — use a valid serde value
1268        std::env::set_var("RAVENCLAWS__LLM__PROVIDER", "openai");
1269        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "https://api.openai.com");
1270        std::env::set_var("LITELLM_API_KEY", "dummy");
1271
1272        let config = Config::load(None).unwrap();
1273        assert_eq!(config.llm.provider, LLMProvider::OpenAI);
1274
1275        // Clean up env vars set by this test
1276        std::env::remove_var("RAVENCLAWS__LLM__PROVIDER");
1277        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1278        std::env::remove_var("LITELLM_API_KEY");
1279    }
1280
1281    #[test]
1282    fn test_config_load_with_provider_env_fallback() {
1283        // Test that the manual override code handles unknown providers
1284        // We test the override logic directly to avoid serde deserialization failure
1285        let mapped = match "unknown" {
1286            "openrouter" => LLMProvider::OpenRouter,
1287            "ollama" => LLMProvider::Ollama,
1288            "openai" => LLMProvider::OpenAI,
1289            _ => LLMProvider::LiteLLM,
1290        };
1291        assert_eq!(mapped, LLMProvider::LiteLLM);
1292
1293        // Test with empty string
1294        let mapped = match "" {
1295            "openrouter" => LLMProvider::OpenRouter,
1296            "ollama" => LLMProvider::Ollama,
1297            "openai" => LLMProvider::OpenAI,
1298            _ => LLMProvider::LiteLLM,
1299        };
1300        assert_eq!(mapped, LLMProvider::LiteLLM);
1301    }
1302
1303    #[test]
1304    fn test_validate_openai_with_endpoint() {
1305        let config = Config {
1306            llm: LLMConfig {
1307                provider: LLMProvider::OpenAI,
1308                endpoint: "https://api.openai.com".to_string(),
1309                model: "gpt-4o".to_string(),
1310                api_key: Some("sk-key".to_string()),
1311                timeout_secs: 30,
1312                system_prompt: default_system_prompt(),
1313                token_budget: None,
1314                retry_max: 3,
1315                retry_base_delay_ms: 100,
1316                retry_max_delay_ms: 10000,
1317            },
1318            llms: vec![],
1319            ravenfabric: RavenFabricConfig::default(),
1320            security: SecurityConfig {
1321                require_tls: false,
1322                token_lifetime_secs: 3600,
1323                audit_log: false,
1324                prompt_injection_protection: false,
1325            },
1326            runtime: RuntimeConfig::default(),
1327            telemetry: TelemetryConfig::default(),
1328            scheduler: SchedulerConfig::default(),
1329            web_search: WebSearchConfig::default(),
1330            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1331            mcp: McpConfig::default(),
1332            swarm: crate::swarm::SwarmConfig::default(),
1333        };
1334
1335        let result = config.validate();
1336        assert!(result.is_ok());
1337    }
1338
1339    #[test]
1340    fn test_validate_openrouter_with_endpoint() {
1341        let config = Config {
1342            llm: LLMConfig {
1343                provider: LLMProvider::OpenRouter,
1344                endpoint: "https://openrouter.ai/api".to_string(),
1345                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1346                api_key: Some("or-key".to_string()),
1347                timeout_secs: 30,
1348                system_prompt: default_system_prompt(),
1349                token_budget: None,
1350                retry_max: 3,
1351                retry_base_delay_ms: 100,
1352                retry_max_delay_ms: 10000,
1353            },
1354            llms: vec![],
1355            ravenfabric: RavenFabricConfig::default(),
1356            security: SecurityConfig {
1357                require_tls: false,
1358                token_lifetime_secs: 3600,
1359                audit_log: false,
1360                prompt_injection_protection: false,
1361            },
1362            runtime: RuntimeConfig::default(),
1363            telemetry: TelemetryConfig::default(),
1364            scheduler: SchedulerConfig::default(),
1365            web_search: WebSearchConfig::default(),
1366            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1367            mcp: McpConfig::default(),
1368            swarm: crate::swarm::SwarmConfig::default(),
1369        };
1370
1371        let result = config.validate();
1372        assert!(result.is_ok());
1373    }
1374
1375    #[test]
1376    fn test_validate_https_endpoint_with_tls() {
1377        let config = Config {
1378            llm: LLMConfig {
1379                provider: LLMProvider::LiteLLM,
1380                endpoint: "https://api.example.com:4000".to_string(),
1381                model: "gpt-4o-mini".to_string(),
1382                api_key: Some("key".to_string()),
1383                timeout_secs: 30,
1384                system_prompt: default_system_prompt(),
1385                token_budget: None,
1386                retry_max: 3,
1387                retry_base_delay_ms: 100,
1388                retry_max_delay_ms: 10000,
1389            },
1390            llms: vec![],
1391            ravenfabric: RavenFabricConfig::default(),
1392            security: SecurityConfig {
1393                require_tls: true,
1394                token_lifetime_secs: 3600,
1395                audit_log: false,
1396                prompt_injection_protection: false,
1397            },
1398            runtime: RuntimeConfig::default(),
1399            telemetry: TelemetryConfig::default(),
1400            scheduler: SchedulerConfig::default(),
1401            web_search: WebSearchConfig::default(),
1402            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1403            mcp: McpConfig::default(),
1404            swarm: crate::swarm::SwarmConfig::default(),
1405        };
1406
1407        let result = config.validate();
1408        assert!(result.is_ok());
1409    }
1410
1411    #[test]
1412    #[serial(env_test)]
1413    fn test_config_load_with_nonexistent_file() {
1414        // Loading with a non-existent file path should still succeed
1415        // (the File source is created with required(false))
1416        std::env::set_var("LITELLM_API_KEY", "test-key");
1417        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1418
1419        let result = Config::load(Some("/tmp/nonexistent/ravenclaws.toml"));
1420        assert!(result.is_ok());
1421
1422        std::env::remove_var("LITELLM_API_KEY");
1423        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1424    }
1425
1426    #[test]
1427    fn test_config_error_missing_env_var_display() {
1428        let err = ConfigError::MissingEnvVar("DATABASE_URL".to_string());
1429        assert_eq!(
1430            format!("{}", err),
1431            "Missing required environment variable: DATABASE_URL"
1432        );
1433    }
1434
1435    #[test]
1436    fn test_llm_config_deserialize() {
1437        let json = r#"{
1438            "provider": "openai",
1439            "endpoint": "https://api.openai.com",
1440            "model": "gpt-4o",
1441            "api_key": "sk-test",
1442            "timeout_secs": 120
1443        }"#;
1444        let config: LLMConfig = serde_json::from_str(json).unwrap();
1445
1446        assert_eq!(config.provider, LLMProvider::OpenAI);
1447        assert_eq!(config.endpoint, "https://api.openai.com");
1448        assert_eq!(config.model, "gpt-4o");
1449        assert_eq!(config.timeout_secs, 120);
1450    }
1451
1452    #[test]
1453    fn test_security_config_serde_defaults() {
1454        // When deserializing from an empty JSON object, serde should use defaults
1455        let json = r#"{}"#;
1456        let config: SecurityConfig = serde_json::from_str(json).unwrap();
1457        assert!(config.require_tls);
1458        assert_eq!(config.token_lifetime_secs, 3600);
1459        assert!(config.audit_log);
1460    }
1461
1462    #[test]
1463    fn test_runtime_config_serde_defaults() {
1464        let json = r#"{}"#;
1465        let config: RuntimeConfig = serde_json::from_str(json).unwrap();
1466        assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
1467        assert_eq!(config.max_agents, 10);
1468        assert_eq!(config.health_interval_secs, 60);
1469    }
1470
1471    #[test]
1472    fn test_ravenfabric_config_serde_defaults() {
1473        let json = r#"{}"#;
1474        let config: RavenFabricConfig = serde_json::from_str(json).unwrap();
1475        assert!(config.endpoint.is_none());
1476        assert!(config.agent_id.is_none());
1477        assert!(config.remote_exec);
1478        assert!(config.allowed_hosts.is_empty());
1479    }
1480
1481    #[test]
1482    fn test_validate_ollama_with_endpoint_succeeds() {
1483        let config = Config {
1484            llm: LLMConfig {
1485                provider: LLMProvider::Ollama,
1486                endpoint: "http://localhost:11434".to_string(),
1487                model: "llama3.1".to_string(),
1488                api_key: None,
1489                timeout_secs: 60,
1490                system_prompt: default_system_prompt(),
1491                token_budget: None,
1492                retry_max: 3,
1493                retry_base_delay_ms: 100,
1494                retry_max_delay_ms: 10000,
1495            },
1496            llms: vec![],
1497            ravenfabric: RavenFabricConfig::default(),
1498            security: SecurityConfig {
1499                require_tls: false,
1500                token_lifetime_secs: 3600,
1501                audit_log: false,
1502                prompt_injection_protection: false,
1503            },
1504            runtime: RuntimeConfig::default(),
1505            telemetry: TelemetryConfig::default(),
1506            scheduler: SchedulerConfig::default(),
1507            web_search: WebSearchConfig::default(),
1508            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1509            mcp: McpConfig::default(),
1510            swarm: crate::swarm::SwarmConfig::default(),
1511        };
1512
1513        let result = config.validate();
1514        assert!(result.is_ok());
1515    }
1516
1517    #[test]
1518    fn test_validate_openrouter_with_endpoint_succeeds() {
1519        let config = Config {
1520            llm: LLMConfig {
1521                provider: LLMProvider::OpenRouter,
1522                endpoint: "https://openrouter.ai/api".to_string(),
1523                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1524                api_key: Some("or-key".to_string()),
1525                timeout_secs: 30,
1526                system_prompt: default_system_prompt(),
1527                token_budget: None,
1528                retry_max: 3,
1529                retry_base_delay_ms: 100,
1530                retry_max_delay_ms: 10000,
1531            },
1532            llms: vec![],
1533            ravenfabric: RavenFabricConfig::default(),
1534            security: SecurityConfig {
1535                require_tls: false,
1536                token_lifetime_secs: 3600,
1537                audit_log: false,
1538                prompt_injection_protection: false,
1539            },
1540            runtime: RuntimeConfig::default(),
1541            telemetry: TelemetryConfig::default(),
1542            scheduler: SchedulerConfig::default(),
1543            web_search: WebSearchConfig::default(),
1544            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1545            mcp: McpConfig::default(),
1546            swarm: crate::swarm::SwarmConfig::default(),
1547        };
1548
1549        let result = config.validate();
1550        assert!(result.is_ok());
1551    }
1552
1553    #[test]
1554    fn test_validate_litellm_with_empty_endpoint_fails() {
1555        let config = Config {
1556            llm: LLMConfig {
1557                provider: LLMProvider::LiteLLM,
1558                endpoint: String::new(),
1559                model: "gpt-4o-mini".to_string(),
1560                api_key: Some("key".to_string()),
1561                timeout_secs: 30,
1562                system_prompt: default_system_prompt(),
1563                token_budget: None,
1564                retry_max: 3,
1565                retry_base_delay_ms: 100,
1566                retry_max_delay_ms: 10000,
1567            },
1568            llms: vec![],
1569            ravenfabric: RavenFabricConfig::default(),
1570            security: SecurityConfig {
1571                require_tls: false,
1572                token_lifetime_secs: 3600,
1573                audit_log: false,
1574                prompt_injection_protection: false,
1575            },
1576            runtime: RuntimeConfig::default(),
1577            telemetry: TelemetryConfig::default(),
1578            scheduler: SchedulerConfig::default(),
1579            web_search: WebSearchConfig::default(),
1580            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1581            mcp: McpConfig::default(),
1582            swarm: crate::swarm::SwarmConfig::default(),
1583        };
1584
1585        let result = config.validate();
1586        assert!(result.is_err());
1587        assert!(result
1588            .unwrap_err()
1589            .to_string()
1590            .contains("At least one LLM provider"));
1591    }
1592
1593    #[test]
1594    fn test_llm_provider_serde_serialize() {
1595        let provider = LLMProvider::OpenAI;
1596        let json = serde_json::to_string(&provider).unwrap();
1597        assert_eq!(json, r#""openai""#);
1598
1599        let provider = LLMProvider::Ollama;
1600        let json = serde_json::to_string(&provider).unwrap();
1601        assert_eq!(json, r#""ollama""#);
1602
1603        let provider = LLMProvider::OpenRouter;
1604        let json = serde_json::to_string(&provider).unwrap();
1605        assert_eq!(json, r#""openrouter""#);
1606
1607        let provider = LLMProvider::LiteLLM;
1608        let json = serde_json::to_string(&provider).unwrap();
1609        assert_eq!(json, r#""litellm""#);
1610    }
1611}