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