Skip to main content

ares_http/
overlay.rs

1//! TOML-based configuration for A.R.E.S
2//!
3//! This module provides declarative configuration for providers, models, agents,
4//! tools, and workflows via a TOML file (`ares.toml`).
5//!
6//! # Hot Reloading
7//!
8//! Configuration changes are automatically detected and applied at runtime.
9//! Use `AresConfigManager` for thread-safe access to the current configuration.
10
11use arc_swap::ArcSwap;
12use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
13use parking_lot::RwLock;
14use serde::{Deserialize, Serialize};
15use std::any::TypeId;
16use std::collections::HashMap;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::sync::mpsc;
22use tracing::{error, info, warn};
23
24use crate::config::{AuthConfig, ServerConfig};
25pub use ares_agent::{AgentConfig, SkillsTomlConfig, WorkflowConfig};
26pub use ares_llm::{ModelConfig, NvidiaConfig, ProviderConfig};
27pub use ares_rag::{
28    HybridWeightsConfig, RAGVectorConfig, RagChunkingConfig, RagConfig, RagRerankingConfig,
29    RagSearchConfig,
30};
31use ares_store::default_qdrant_url;
32pub use ares_store::{BillingConfig, DatabaseConfig, ModelPricingConfig, QdrantConfig};
33pub use ares_tools::ToolConfig;
34
35/// Root configuration structure loaded from ares.toml
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AresConfig {
38    /// HTTP server configuration (host, port, log level).
39    pub server: ServerConfig,
40
41    /// Authentication configuration (JWT secrets, expiry times).
42    pub auth: AuthConfig,
43
44    /// Database configuration (Turso/SQLite, Qdrant).
45    pub database: DatabaseConfig,
46
47    /// NVIDIA provider + catalog configuration. When present, the runtime
48    /// fetches the model catalog from `models_url` (default: NVIDIA NIM) and
49    /// exposes it through the provider registry. If absent, the registry
50    /// uses built-in defaults.
51    #[serde(default)]
52    pub nvidia: Option<NvidiaConfig>,
53
54    /// Named LLM provider configurations
55    #[serde(default)]
56    pub providers: HashMap<String, ProviderConfig>,
57
58    /// Named model configurations that reference providers
59    /// NOTE: These are being migrated to TOON files in config/models/
60    #[serde(default)]
61    pub models: HashMap<String, ModelConfig>,
62
63    /// Tool configurations
64    /// NOTE: These are being migrated to TOON files in config/tools/
65    #[serde(default)]
66    pub tools: HashMap<String, ToolConfig>,
67
68    /// Agent configurations
69    /// NOTE: These are being migrated to TOON files in config/agents/
70    #[serde(default)]
71    pub agents: HashMap<String, AgentConfig>,
72
73    /// Workflow configurations
74    /// NOTE: These are being migrated to TOON files in config/workflows/
75    #[serde(default)]
76    pub workflows: HashMap<String, WorkflowConfig>,
77
78    /// RAG configuration
79    #[serde(default)]
80    pub rag: RagConfig,
81
82    /// Billing and cost-estimation configuration
83    #[serde(default)]
84    pub billing: BillingConfig,
85
86    /// Skills configuration (SKILL.md discovery directories)
87    #[serde(default)]
88    pub skills: Option<SkillsTomlConfig>,
89
90    /// Dynamic configuration paths (TOON files)
91    #[serde(default)]
92    pub config: DynamicConfigPaths,
93}
94
95// ============= Dynamic Configuration Paths =============
96
97/// Paths to TOON config directories for dynamic behavioral configuration
98///
99/// ARES uses a hybrid configuration approach:
100/// - **TOML** (`ares.toml`): Static infrastructure config (server, auth, database, providers)
101/// - **TOON** (`config/*.toon`): Dynamic behavioral config (agents, workflows, models, tools, MCPs)
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct DynamicConfigPaths {
104    /// Directory containing agent TOON files
105    #[serde(default = "default_agents_dir")]
106    pub agents_dir: std::path::PathBuf,
107
108    /// Directory containing workflow TOON files
109    #[serde(default = "default_workflows_dir")]
110    pub workflows_dir: std::path::PathBuf,
111
112    /// Directory containing model TOON files
113    #[serde(default = "default_models_dir")]
114    pub models_dir: std::path::PathBuf,
115
116    /// Directory containing tool TOON files
117    #[serde(default = "default_tools_dir")]
118    pub tools_dir: std::path::PathBuf,
119
120    /// Directory containing MCP TOON files
121    #[serde(default = "default_mcps_dir")]
122    pub mcps_dir: std::path::PathBuf,
123
124    /// Whether to watch for changes and hot-reload TOON configs
125    #[serde(default = "default_hot_reload")]
126    pub hot_reload: bool,
127
128    /// Interval in milliseconds for checking config changes
129    #[serde(default = "default_watch_interval")]
130    pub watch_interval_ms: u64,
131}
132
133fn default_agents_dir() -> std::path::PathBuf {
134    std::path::PathBuf::from("config/agents")
135}
136
137fn default_workflows_dir() -> std::path::PathBuf {
138    std::path::PathBuf::from("config/workflows")
139}
140
141fn default_models_dir() -> std::path::PathBuf {
142    std::path::PathBuf::from("config/models")
143}
144
145fn default_tools_dir() -> std::path::PathBuf {
146    std::path::PathBuf::from("config/tools")
147}
148
149fn default_mcps_dir() -> std::path::PathBuf {
150    std::path::PathBuf::from("config/mcps")
151}
152
153fn default_hot_reload() -> bool {
154    true
155}
156
157fn default_watch_interval() -> u64 {
158    1000
159}
160
161impl Default for DynamicConfigPaths {
162    fn default() -> Self {
163        Self {
164            agents_dir: default_agents_dir(),
165            workflows_dir: default_workflows_dir(),
166            models_dir: default_models_dir(),
167            tools_dir: default_tools_dir(),
168            mcps_dir: default_mcps_dir(),
169            hot_reload: default_hot_reload(),
170            watch_interval_ms: default_watch_interval(),
171        }
172    }
173}
174
175// ============= Configuration Loading & Validation =============
176
177/// Configuration warnings that don't prevent operation but may indicate issues.
178#[derive(Debug, Clone)]
179pub struct ConfigWarning {
180    /// Category of the warning.
181    pub kind: ConfigWarningKind,
182
183    /// Human-readable warning message.
184    pub message: String,
185}
186
187/// Categories of configuration warnings.
188#[derive(Debug, Clone, PartialEq)]
189pub enum ConfigWarningKind {
190    /// A provider is defined but not referenced by any model.
191    UnusedProvider,
192
193    /// A model is defined but not referenced by any agent.
194    UnusedModel,
195
196    /// A tool is defined but not referenced by any agent.
197    UnusedTool,
198
199    /// An agent is defined but not referenced by any workflow.
200    UnusedAgent,
201}
202
203impl std::fmt::Display for ConfigWarning {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        write!(f, "{}", self.message)
206    }
207}
208
209/// Errors that can occur during configuration loading.
210#[derive(Debug, thiserror::Error)]
211pub enum ConfigError {
212    /// The configuration file was not found at the specified path.
213    #[error("Configuration file not found: {0}")]
214    FileNotFound(PathBuf),
215
216    /// Failed to read the configuration file from disk.
217    #[error("Failed to read configuration file: {0}")]
218    ReadError(#[from] std::io::Error),
219
220    /// Failed to parse the TOML content.
221    #[error("Failed to parse TOML: {0}")]
222    ParseError(#[from] toml::de::Error),
223
224    /// Configuration validation failed.
225    #[error("Validation error: {0}")]
226    ValidationError(String),
227
228    /// An environment variable referenced in the config is not set.
229    #[error("Environment variable '{0}' referenced in config is not set")]
230    MissingEnvVar(String),
231
232    /// A provider referenced by a model does not exist.
233    #[error("Provider '{0}' referenced by model '{1}' does not exist")]
234    MissingProvider(String, String),
235
236    /// A model referenced by an agent does not exist.
237    #[error("Model '{0}' referenced by agent '{1}' does not exist")]
238    MissingModel(String, String),
239
240    /// An agent referenced by a workflow does not exist.
241    #[error("Agent '{0}' referenced by workflow '{1}' does not exist")]
242    MissingAgent(String, String),
243
244    /// A tool referenced by an agent does not exist.
245    #[error("Tool '{0}' referenced by agent '{1}' does not exist")]
246    MissingTool(String, String),
247
248    /// A circular reference was detected in the configuration.
249    #[error("Circular reference detected: {0}")]
250    CircularReference(String),
251
252    /// An error occurred while watching configuration files for changes.
253    #[error("Watch error: {0}")]
254    WatchError(#[from] notify::Error),
255}
256
257impl AresConfig {
258    /// Load configuration from a TOML file
259    ///
260    /// # Panics
261    ///
262    /// Panics if the configuration file doesn't exist or is invalid.
263    /// This is intentional - the server cannot run without a valid config.
264    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
265        let path = path.as_ref();
266
267        if !path.exists() {
268            return Err(ConfigError::FileNotFound(path.to_path_buf()));
269        }
270
271        let content = fs::read_to_string(path)?;
272        let config: AresConfig = toml::from_str(&content)?;
273
274        // Validate the configuration
275        config.validate()?;
276
277        Ok(config)
278    }
279
280    /// Load configuration from a TOML file without validation.
281    ///
282    /// This is useful for CLI commands that only need to inspect the configuration
283    /// without actually running the server (e.g., `ares-server config`).
284    /// Environment variables are not checked.
285    pub fn load_unchecked<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
286        let path = path.as_ref();
287
288        if !path.exists() {
289            return Err(ConfigError::FileNotFound(path.to_path_buf()));
290        }
291
292        let content = fs::read_to_string(path)?;
293        let config: AresConfig = toml::from_str(&content)?;
294
295        Ok(config)
296    }
297
298    /// Validate the configuration for internal consistency and env var availability
299    pub fn validate(&self) -> Result<(), ConfigError> {
300        // Validate auth env vars exist
301        self.validate_env_var(&self.auth.jwt_secret_env)?;
302        self.validate_env_var(&self.auth.api_key_env)?;
303
304        // Validate database env vars if specified
305        if let Some(ref qdrant) = self.database.qdrant {
306            if let Some(ref env) = qdrant.api_key_env {
307                self.validate_env_var(env)?;
308            }
309        }
310
311        // Validate provider env vars
312        for provider in self.providers.values() {
313            match provider {
314                ProviderConfig::OpenAI { api_key_env, .. } => {
315                    self.validate_env_var(api_key_env)?;
316                }
317                ProviderConfig::Azure {
318                    api_key_env,
319                    base_url_env,
320                    ..
321                } => {
322                    self.validate_env_var(api_key_env)?;
323                    self.validate_env_var(base_url_env)?;
324                }
325                ProviderConfig::Anthropic { api_key_env, .. } => {
326                    self.validate_env_var(api_key_env)?;
327                }
328                ProviderConfig::Bedrock {
329                    api_key_env,
330                    region_env,
331                    ..
332                } => {
333                    self.validate_env_var(api_key_env)?;
334                    self.validate_env_var(region_env)?;
335                }
336                ProviderConfig::Ollama { .. } => {
337                    // Ollama has no auth; nothing to validate.
338                }
339                _ => {}
340            }
341        }
342
343        // Validate model -> provider references
344        for (model_name, model_config) in &self.models {
345            if !self.providers.contains_key(&model_config.provider) {
346                return Err(ConfigError::MissingProvider(
347                    model_config.provider.clone(),
348                    model_name.clone(),
349                ));
350            }
351        }
352
353        // Validate agent -> model and agent -> tools references
354        // NOTE: With the dynamic NVIDIA catalog, agents reference models by
355        // literal NVIDIA NIM id (e.g. "nvidia/nemotron-3-ultra-550b-a55b") which
356        // is resolved at runtime against the live catalog — NOT against a
357        // static [models] table. We therefore only WARN if a model name is
358        // suspicious rather than failing startup, so a model that gets
359        // rotated out of the live catalog doesn't prevent the server from
360        // booting (the affected agent just gets a runtime error on first use).
361        for (agent_name, agent_config) in &self.agents {
362            if !self.models.contains_key(&agent_config.model) {
363                tracing::warn!(
364                    "Agent '{}' references model '{}' which is not in the static [models] table. \
365                     The model will be resolved against the live NVIDIA catalog at runtime.",
366                    agent_name,
367                    agent_config.model,
368                );
369            }
370
371            for tool_name in &agent_config.tools {
372                // Allow tools from registered tool configs OR MCP bridge tools
373                // MCP bridge tools follow the pattern: {mcp_client_name}_{operation}
374                let is_known_tool = self.tools.contains_key(tool_name);
375                let is_mcp_tool = tool_name.contains('_') && {
376                    // Check if any configured MCP client name is a prefix
377                    let mcp_names = self.mcp_client_names();
378                    mcp_names
379                        .iter()
380                        .any(|mcp_name| tool_name.starts_with(&format!("{}_", mcp_name)))
381                };
382                if !is_known_tool && !is_mcp_tool {
383                    return Err(ConfigError::MissingTool(
384                        tool_name.clone(),
385                        agent_name.clone(),
386                    ));
387                }
388            }
389        }
390
391        // Validate workflow -> agent references
392        for (workflow_name, workflow_config) in &self.workflows {
393            if !self.agents.contains_key(&workflow_config.entry_agent) {
394                return Err(ConfigError::MissingAgent(
395                    workflow_config.entry_agent.clone(),
396                    workflow_name.clone(),
397                ));
398            }
399
400            if let Some(ref fallback) = workflow_config.fallback_agent {
401                if !self.agents.contains_key(fallback) {
402                    return Err(ConfigError::MissingAgent(
403                        fallback.clone(),
404                        workflow_name.clone(),
405                    ));
406                }
407            }
408        }
409
410        // Check for circular references in workflows (entry_agent -> fallback cycles)
411        self.detect_circular_references()?;
412
413        Ok(())
414    }
415
416    /// Detect circular references in workflow configurations
417    ///
418    /// Currently checks for:
419    /// - Workflow entry_agent pointing to itself via fallback chain
420    fn detect_circular_references(&self) -> Result<(), ConfigError> {
421        use std::collections::HashSet;
422
423        for (workflow_name, workflow_config) in &self.workflows {
424            let mut visited = HashSet::new();
425            let mut current = Some(workflow_config.entry_agent.as_str());
426
427            while let Some(agent_name) = current {
428                if visited.contains(agent_name) {
429                    return Err(ConfigError::CircularReference(format!(
430                        "Circular reference detected in workflow '{}': agent '{}' appears multiple times in the chain",
431                        workflow_name, agent_name
432                    )));
433                }
434                visited.insert(agent_name);
435
436                // Check if this agent is the entry for any workflow that has this workflow's entry as fallback
437                // This is a simple check - could be extended for more complex scenarios
438                current = None;
439
440                // For now, we just check that fallback_agent doesn't equal entry_agent
441                if let Some(ref fallback) = workflow_config.fallback_agent {
442                    if fallback == &workflow_config.entry_agent {
443                        return Err(ConfigError::CircularReference(format!(
444                            "Workflow '{}' has entry_agent '{}' that equals fallback_agent",
445                            workflow_name, workflow_config.entry_agent
446                        )));
447                    }
448                }
449            }
450        }
451
452        Ok(())
453    }
454
455    /// Validate configuration with warnings for unused items
456    ///
457    /// Returns Ok with warnings, or Err if validation fails
458    pub fn validate_with_warnings(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
459        // Run standard validation first
460        self.validate()?;
461
462        // Collect warnings
463        let mut warnings = Vec::new();
464
465        // Check for unused providers
466        warnings.extend(self.check_unused_providers());
467
468        // Check for unused models
469        warnings.extend(self.check_unused_models());
470
471        // Check for unused tools
472        warnings.extend(self.check_unused_tools());
473
474        // Check for unused agents
475        warnings.extend(self.check_unused_agents());
476
477        Ok(warnings)
478    }
479
480    /// Check for providers that aren't referenced by any model
481    fn check_unused_providers(&self) -> Vec<ConfigWarning> {
482        use std::collections::HashSet;
483
484        let referenced: HashSet<_> = self.models.values().map(|m| m.provider.as_str()).collect();
485
486        self.providers
487            .keys()
488            .filter(|name| !referenced.contains(name.as_str()))
489            .map(|name| ConfigWarning {
490                kind: ConfigWarningKind::UnusedProvider,
491                message: format!(
492                    "Provider '{}' is defined but not referenced by any model",
493                    name
494                ),
495            })
496            .collect()
497    }
498
499    /// Check for models that aren't referenced by any agent
500    fn check_unused_models(&self) -> Vec<ConfigWarning> {
501        use std::collections::HashSet;
502
503        let referenced: HashSet<_> = self.agents.values().map(|a| a.model.as_str()).collect();
504
505        self.models
506            .keys()
507            .filter(|name| !referenced.contains(name.as_str()))
508            .map(|name| ConfigWarning {
509                kind: ConfigWarningKind::UnusedModel,
510                message: format!(
511                    "Model '{}' is defined but not referenced by any agent",
512                    name
513                ),
514            })
515            .collect()
516    }
517
518    /// Check for tools that aren't referenced by any agent
519    fn check_unused_tools(&self) -> Vec<ConfigWarning> {
520        use std::collections::HashSet;
521
522        let referenced: HashSet<_> = self
523            .agents
524            .values()
525            .flat_map(|a| a.tools.iter().map(|t| t.as_str()))
526            .collect();
527
528        self.tools
529            .keys()
530            .filter(|name| !referenced.contains(name.as_str()))
531            .map(|name| ConfigWarning {
532                kind: ConfigWarningKind::UnusedTool,
533                message: format!("Tool '{}' is defined but not referenced by any agent", name),
534            })
535            .collect()
536    }
537
538    /// Check for agents that aren't referenced by any workflow
539    fn check_unused_agents(&self) -> Vec<ConfigWarning> {
540        use std::collections::HashSet;
541
542        let referenced: HashSet<_> = self
543            .workflows
544            .values()
545            .flat_map(|w| {
546                let mut refs = vec![w.entry_agent.as_str()];
547                if let Some(ref fallback) = w.fallback_agent {
548                    refs.push(fallback.as_str());
549                }
550                refs
551            })
552            .collect();
553
554        // Also consider orchestrator/router as always "used" since they're system agents
555        let system_agents: HashSet<&str> = ["orchestrator", "router"].into_iter().collect();
556
557        self.agents
558            .keys()
559            .filter(|name| {
560                !referenced.contains(name.as_str()) && !system_agents.contains(name.as_str())
561            })
562            .map(|name| ConfigWarning {
563                kind: ConfigWarningKind::UnusedAgent,
564                message: format!(
565                    "Agent '{}' is defined but not referenced by any workflow",
566                    name
567                ),
568            })
569            .collect()
570    }
571
572    fn validate_env_var(&self, name: &str) -> Result<(), ConfigError> {
573        std::env::var(name).map_err(|_| ConfigError::MissingEnvVar(name.to_string()))?;
574        Ok(())
575    }
576
577    /// Get a resolved value from an env var reference
578    pub fn resolve_env(&self, env_name: &str) -> Option<String> {
579        std::env::var(env_name).ok()
580    }
581
582    /// Minimum length for JWT secret (256 bits = 32 bytes)
583    const JWT_SECRET_MIN_LENGTH: usize = 32;
584
585    /// Get the JWT secret from the environment
586    ///
587    /// # Errors
588    /// Returns an error if:
589    /// - The environment variable is not set
590    /// - The secret is shorter than 32 characters (256 bits)
591    ///
592    /// Get names of configured MCP clients (from mcps directory .toon files).
593    /// Used by validation to allow MCP bridge tool names in agent configs.
594    pub fn mcp_client_names(&self) -> Vec<String> {
595        let path = &self.config.mcps_dir;
596        if !path.exists() {
597            return vec![];
598        }
599        std::fs::read_dir(path)
600            .ok()
601            .map(|entries| {
602                entries
603                    .filter_map(|e| {
604                        let e = e.ok()?;
605                        let p = e.path();
606                        if p.extension()?.to_str()? == "toon" {
607                            // Read the name field from the TOON file
608                            let content = std::fs::read_to_string(&p).ok()?;
609                            let val: toml::Value = toml::from_str(&content).ok()?;
610                            val.get("name")?.as_str().map(String::from)
611                        } else {
612                            None
613                        }
614                    })
615                    .collect()
616            })
617            .unwrap_or_default()
618    }
619
620    pub fn jwt_secret(&self) -> Result<String, ConfigError> {
621        let secret = self
622            .resolve_env(&self.auth.jwt_secret_env)
623            .ok_or_else(|| ConfigError::MissingEnvVar(self.auth.jwt_secret_env.clone()))?;
624
625        if secret.len() < Self::JWT_SECRET_MIN_LENGTH {
626            return Err(ConfigError::ValidationError(format!(
627                "JWT_SECRET must be at least {} characters for security (current: {} chars). \
628                 Use a cryptographically random string, e.g.: openssl rand -base64 32",
629                Self::JWT_SECRET_MIN_LENGTH,
630                secret.len()
631            )));
632        }
633
634        Ok(secret)
635    }
636
637    /// Get the API key from the environment
638    pub fn api_key(&self) -> Result<String, ConfigError> {
639        self.resolve_env(&self.auth.api_key_env)
640            .ok_or_else(|| ConfigError::MissingEnvVar(self.auth.api_key_env.clone()))
641    }
642
643    /// Get provider by name
644    pub fn get_provider(&self, name: &str) -> Option<&ProviderConfig> {
645        self.providers.get(name)
646    }
647
648    /// Get model by name
649    pub fn get_model(&self, name: &str) -> Option<&ModelConfig> {
650        self.models.get(name)
651    }
652
653    /// Get agent config by name
654    pub fn get_agent(&self, name: &str) -> Option<&AgentConfig> {
655        self.agents.get(name)
656    }
657
658    /// Get tool config by name
659    pub fn get_tool(&self, name: &str) -> Option<&ToolConfig> {
660        self.tools.get(name)
661    }
662
663    /// Get workflow config by name
664    pub fn get_workflow(&self, name: &str) -> Option<&WorkflowConfig> {
665        self.workflows.get(name)
666    }
667
668    /// Get all enabled tools
669    pub fn enabled_tools(&self) -> Vec<&str> {
670        self.tools
671            .iter()
672            .filter(|(_, config)| config.enabled)
673            .map(|(name, _)| name.as_str())
674            .collect()
675    }
676
677    /// Get all tools for an agent
678    pub fn agent_tools(&self, agent_name: &str) -> Vec<&str> {
679        self.get_agent(agent_name)
680            .map(|agent| {
681                agent
682                    .tools
683                    .iter()
684                    .filter(|t| self.get_tool(t).map(|tc| tc.enabled).unwrap_or(false))
685                    .map(|s| s.as_str())
686                    .collect()
687            })
688            .unwrap_or_default()
689    }
690}
691
692// ============= Hot Reloading Configuration Manager =============
693
694/// Thread-safe configuration manager with hot reloading support
695pub struct AresConfigManager {
696    config: Arc<ArcSwap<AresConfig>>,
697    config_path: PathBuf,
698    watcher: RwLock<Option<RecommendedWatcher>>,
699    reload_tx: Option<mpsc::UnboundedSender<()>>,
700    /// Context captured by [`Overlay::watch_cordis`] so `start_watching` can
701    /// notify `TypeId::of::<Overlay>()` without a second watcher stack.
702    watch_ctx: Arc<RwLock<Option<Arc<cordis::Context>>>>,
703    /// Holds the single `watch_many_with` handle (ares.toml + TOON dirs).
704    cordis_watch: RwLock<Option<cordis::watcher::WatchHandle>>,
705}
706
707impl AresConfigManager {
708    /// Create a new configuration manager and load the initial config
709    ///
710    /// # Panics
711    ///
712    /// Panics if ares.toml doesn't exist or is invalid.
713    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
714        // Convert to absolute path for reliable file watching
715        let path = path.as_ref();
716        let path = if path.is_absolute() {
717            path.to_path_buf()
718        } else {
719            std::env::current_dir()
720                .map_err(ConfigError::ReadError)?
721                .join(path)
722        };
723
724        let config = AresConfig::load(&path)?;
725
726        Ok(Self {
727            config: Arc::new(ArcSwap::from_pointee(config)),
728            config_path: path,
729            watcher: RwLock::new(None),
730            reload_tx: None,
731            watch_ctx: Arc::new(RwLock::new(None)),
732            cordis_watch: RwLock::new(None),
733        })
734    }
735
736    /// Get the current configuration (lockless read)
737    pub fn config(&self) -> Arc<AresConfig> {
738        self.config.load_full()
739    }
740
741    /// Manually reload the configuration from disk
742    pub fn reload(&self) -> Result<(), ConfigError> {
743        info!("Reloading configuration from {:?}", self.config_path);
744
745        let new_config = AresConfig::load(&self.config_path)?;
746        self.config.store(Arc::new(new_config));
747
748        info!("Configuration reloaded successfully");
749        Ok(())
750    }
751
752    /// Start watching for configuration file changes.
753    ///
754    /// Overlay is the only `ares.toml` program. This forwards to
755    /// [`Self::watch_cordis`] when a context is already captured and does
756    /// not start a second `notify` watcher stack.
757    pub fn start_watching(&mut self) -> Result<(), ConfigError> {
758        if self.cordis_watch.read().is_some() {
759            return Ok(());
760        }
761        if let Some(ctx) = self.watch_ctx.read().clone() {
762            return self.watch_cordis(&ctx);
763        }
764        info!("ares.toml watch is owned by Overlay::watch_cordis; skipping standalone watcher");
765        Ok(())
766    }
767
768    /// Stop watching for configuration changes
769    pub fn stop_watching(&self) {
770        *self.watcher.write() = None;
771        info!("Configuration hot-reload watcher stopped");
772    }
773}
774
775impl Clone for AresConfigManager {
776    fn clone(&self) -> Self {
777        Self {
778            config: Arc::clone(&self.config),
779            config_path: self.config_path.clone(),
780            watcher: RwLock::new(None), // Watcher is not cloned
781            reload_tx: self.reload_tx.clone(),
782            watch_ctx: Arc::clone(&self.watch_ctx),
783            cordis_watch: RwLock::new(None),
784        }
785    }
786}
787
788impl AresConfigManager {
789    /// Create a config manager directly from a config (useful for testing)
790    /// This won't have file watching capabilities.
791    pub fn from_config(config: AresConfig) -> Self {
792        Self {
793            config: Arc::new(ArcSwap::from_pointee(config)),
794            config_path: PathBuf::from("test-config.toml"),
795            watcher: RwLock::new(None),
796            reload_tx: None,
797            watch_ctx: Arc::new(RwLock::new(None)),
798            cordis_watch: RwLock::new(None),
799        }
800    }
801}
802
803impl cordis::Service for AresConfigManager {
804    fn name(&self) -> &'static str {
805        "ares_config_manager"
806    }
807    fn init(&self, _ctx: &std::sync::Arc<cordis::Context>) -> cordis::ServiceInitFuture<'_> {
808        Box::pin(async { Ok(None) })
809    }
810    fn check(&self) -> bool {
811        true
812    }
813}
814
815/// HTTP-adjacent config overlay (same type as [`AresConfigManager`]).
816pub type Overlay = AresConfigManager;
817
818/// Loader config for the Overlay plugin.
819#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
820pub struct OverlayConfig {
821    /// Path to `ares.toml`.
822    #[serde(default = "default_overlay_toml_path", alias = "toml_path")]
823    pub toml_path: PathBuf,
824}
825
826fn default_overlay_toml_path() -> PathBuf {
827    PathBuf::from("ares.toml")
828}
829
830impl Default for OverlayConfig {
831    fn default() -> Self {
832        Self {
833            toml_path: default_overlay_toml_path(),
834        }
835    }
836}
837
838fn entry_config_is_empty(config: &serde_json::Value) -> bool {
839    match config {
840        serde_json::Value::Null => true,
841        serde_json::Value::Object(map) => map.is_empty(),
842        serde_json::Value::Array(arr) => arr.is_empty(),
843        _ => false,
844    }
845}
846
847/// Map a loader plugin key to the matching `ares.toml` section value.
848fn overlay_value_for_plugin(plugin: &str, cfg: &AresConfig) -> Option<serde_json::Value> {
849    match plugin {
850        "Http" => serde_json::to_value(&cfg.server).ok(),
851        "AuthService" => serde_json::to_value(&cfg.auth).ok(),
852        "Store" => serde_json::to_value(&cfg.database).ok(),
853        "Tools" => serde_json::to_value(&cfg.tools).ok(),
854        "Llm" => Some(serde_json::json!({
855            "providers": cfg.providers,
856            "models": cfg.models,
857            "nvidia": cfg.nvidia,
858        })),
859        "Execute" => serde_json::to_value(&cfg.agents).ok(),
860        _ => None,
861    }
862}
863
864fn path_is_toml(path: &Path, overlay_path: &Path) -> bool {
865    path.extension().is_some_and(|ext| ext == "toml")
866        || path == overlay_path
867        || path.file_name() == overlay_path.file_name()
868}
869
870fn path_is_toon(path: &Path) -> bool {
871    path.extension().is_some_and(|ext| ext == "toon")
872}
873
874impl Overlay {
875    /// Single Cordis watch for `ares.toml` plus TOON dirs.
876    ///
877    /// Uses `watch_many_with` (no second notify stack). `ares.toml` changes
878    /// reload this overlay and notify `TypeId::of::<Overlay>()`; TOON changes
879    /// reload [`crate::toon_config::DynamicConfigManager`] and notify Tools
880    /// and Execute TypeIds.
881    pub fn watch_cordis(&self, ctx: &std::sync::Arc<cordis::Context>) -> Result<(), ConfigError> {
882        *self.watch_ctx.write() = Some(Arc::clone(ctx));
883        if self.cordis_watch.read().is_some() {
884            return Ok(());
885        }
886
887        let Some(reflect) = ctx.get::<cordis::ReflectService>() else {
888            return Ok(());
889        };
890        if tokio::runtime::Handle::try_current().is_err() {
891            return Ok(());
892        }
893
894        let overlay_path = self.config_path.clone();
895        let config_store = Arc::clone(&self.config);
896        let snapshot = self.config();
897        let paths = vec![
898            overlay_path.clone(),
899            snapshot.config.agents_dir.clone(),
900            snapshot.config.models_dir.clone(),
901            snapshot.config.tools_dir.clone(),
902            snapshot.config.workflows_dir.clone(),
903            snapshot.config.mcps_dir.clone(),
904        ];
905
906        let on_change: cordis::watcher::WatchOnChange = Arc::new(move |c, paths, _outcome| {
907            for path in paths {
908                if path_is_toml(path, &overlay_path) {
909                    match AresConfig::load(&overlay_path) {
910                        Ok(new_config) => {
911                            config_store.store(Arc::new(new_config));
912                            info!("Configuration hot-reloaded successfully");
913                        }
914                        Err(e) => {
915                            warn!(
916                                "Failed to hot-reload config: {}. Keeping previous config.",
917                                e
918                            );
919                        }
920                    }
921                }
922                if path_is_toon(path) {
923                    if let Some(dynamic) = c.get::<crate::toon_config::DynamicConfigManager>() {
924                        match dynamic.reload() {
925                            Ok(_) => info!("TOON configuration reloaded"),
926                            Err(e) => warn!("Failed to reload TOON config: {e}"),
927                        }
928                    }
929                    crate::toon_config::notify_tools_and_execute(c);
930                }
931            }
932        });
933
934        let handle = cordis::watcher::watch_many_with(
935            Arc::clone(ctx),
936            reflect,
937            paths,
938            TypeId::of::<Overlay>(),
939            on_change,
940        )?;
941        *self.cordis_watch.write() = Some(handle);
942        Ok(())
943    }
944
945    /// Fill empty cordis-entry configs from `ares.toml` sections.
946    ///
947    /// Non-empty loader `entry.config` values are left unchanged.
948    pub fn fill_empty_entry_configs(&self, tree: &mut cordis::EntryTree) {
949        let cfg = self.config();
950        for entry in &mut tree.0 {
951            if !entry_config_is_empty(&entry.config) {
952                continue;
953            }
954            if let Some(value) = overlay_value_for_plugin(entry.plugin.as_str(), &cfg) {
955                entry.config = value;
956            }
957        }
958    }
959}
960
961/// Typed installer for [`Overlay`].
962pub struct OverlayPlugin;
963
964impl cordis::Plugin for OverlayPlugin {
965    type Config = OverlayConfig;
966    type Provides = Overlay;
967
968    fn apply(
969        &self,
970        ctx: &std::sync::Arc<cordis::Context>,
971        config: Self::Config,
972    ) -> std::result::Result<std::sync::Arc<Overlay>, cordis::CordisError> {
973        let overlay = Overlay::new(&config.toml_path)
974            .map_err(|e| cordis::CordisError::Configuration(e.to_string()))?;
975        overlay
976            .watch_cordis(ctx)
977            .map_err(|e| cordis::CordisError::Configuration(e.to_string()))?;
978        Ok(std::sync::Arc::new(overlay))
979    }
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985
986    fn create_test_config() -> String {
987        r#"
988[server]
989host = "127.0.0.1"
990port = 3000
991log_level = "debug"
992
993[auth]
994jwt_secret_env = "TEST_JWT_SECRET"
995jwt_access_expiry = 900
996jwt_refresh_expiry = 604800
997api_key_env = "TEST_API_KEY"
998
999[database]
1000url = "./data/test.db"
1001
1002[providers.ollama-local]
1003type = "openai"
1004api_key_env = "TEST_KEY"
1005api_base = "https://test.example.com/v1"
1006default_model = "ministral-3:3b"
1007
1008[models.default]
1009provider = "ollama-local"
1010model = "ministral-3:3b"
1011temperature = 0.7
1012max_tokens = 512
1013
1014[billing.model_pricing.test_default]
1015provider = "ollama-local"
1016model = "ministral-3:3b"
1017input_usd_per_million_tokens = 0.0
1018output_usd_per_million_tokens = 0.0
1019
1020[tools.calculator]
1021enabled = true
1022description = "Basic calculator"
1023timeout_secs = 10
1024
1025[agents.router]
1026model = "default"
1027tools = []
1028max_tool_iterations = 5
1029
1030[workflows.default]
1031entry_agent = "router"
1032max_depth = 3
1033max_iterations = 5
1034"#
1035        .to_string()
1036    }
1037
1038    #[test]
1039    fn test_parse_config() {
1040        // Set required env vars for validation
1041        // SAFETY: Tests are run single-threaded for env var safety
1042        unsafe {
1043            std::env::set_var(
1044                "TEST_JWT_SECRET",
1045                "test-secret-at-least-32-characters-long-at-least-32-characters-long",
1046            );
1047            std::env::set_var("TEST_API_KEY", "test-api-key");
1048        }
1049
1050        let content = create_test_config();
1051        let config: AresConfig = toml::from_str(&content).expect("Failed to parse config");
1052
1053        assert_eq!(config.server.host, "127.0.0.1");
1054        assert_eq!(config.server.port, 3000);
1055        assert!(config.providers.contains_key("ollama-local"));
1056        assert!(config.models.contains_key("default"));
1057        assert!(config.agents.contains_key("router"));
1058        assert!(config
1059            .billing
1060            .pricing_for(" OLLAMA-LOCAL ", "ministral-3:3b")
1061            .is_some());
1062    }
1063
1064    #[test]
1065    fn test_validation_missing_provider() {
1066        // SAFETY: Tests are run single-threaded for env var safety
1067        unsafe {
1068            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1069            std::env::set_var("TEST_API_KEY", "test-key");
1070        }
1071
1072        let content = r#"
1073[server]
1074[auth]
1075jwt_secret_env = "TEST_JWT_SECRET"
1076api_key_env = "TEST_API_KEY"
1077[database]
1078[models.test]
1079provider = "nonexistent"
1080model = "test"
1081"#;
1082
1083        let config: AresConfig = toml::from_str(content).unwrap();
1084        let result = config.validate();
1085
1086        assert!(matches!(result, Err(ConfigError::MissingProvider(_, _))));
1087    }
1088
1089    #[test]
1090    fn test_validation_missing_model() {
1091        // SAFETY: Tests are run single-threaded for env var safety
1092        unsafe {
1093            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1094            std::env::set_var("TEST_API_KEY", "test-key");
1095            std::env::set_var("TEST_KEY", "test-provider-key");
1096        }
1097
1098        let content = r#"
1099[server]
1100[auth]
1101jwt_secret_env = "TEST_JWT_SECRET"
1102api_key_env = "TEST_API_KEY"
1103[database]
1104[nvidia]
1105api_key_env = "TEST_KEY"
1106api_base = "https://test.example.com/v1"
1107default_model = "ministral-3:3b"
1108[agents.test]
1109model = "nonexistent"
1110"#;
1111
1112        let config: AresConfig = toml::from_str(content).unwrap();
1113        // With the dynamic NVIDIA catalog, agent models are resolved at
1114        // runtime against the live catalog. Missing references produce a
1115        // warning during validation, not a hard error — so the server
1116        // stays up even when the live catalog rotates a model out.
1117        let result = config.validate();
1118        assert!(
1119            result.is_ok(),
1120            "missing-model should warn, not fail: {:?}",
1121            result
1122        );
1123    }
1124
1125    #[test]
1126    fn test_validation_missing_tool() {
1127        // SAFETY: Tests are run single-threaded for env var safety
1128        unsafe {
1129            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1130            std::env::set_var("TEST_API_KEY", "test-key");
1131        }
1132
1133        let content = r#"
1134[server]
1135[auth]
1136jwt_secret_env = "TEST_JWT_SECRET"
1137api_key_env = "TEST_API_KEY"
1138[database]
1139[providers.test]
1140type = "openai"
1141api_key_env = "TEST_KEY"
1142api_base = "https://test.example.com/v1"
1143default_model = "ministral-3:3b"
1144[models.default]
1145provider = "test"
1146model = "ministral-3:3b"
1147[agents.test]
1148model = "default"
1149tools = ["nonexistent_tool"]
1150"#;
1151
1152        let config: AresConfig = toml::from_str(content).unwrap();
1153        let result = config.validate();
1154
1155        assert!(matches!(result, Err(ConfigError::MissingTool(_, _))));
1156    }
1157
1158    #[test]
1159    fn test_validation_missing_workflow_agent() {
1160        // SAFETY: Tests are run single-threaded for env var safety
1161        unsafe {
1162            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1163            std::env::set_var("TEST_API_KEY", "test-key");
1164        }
1165
1166        let content = r#"
1167[server]
1168[auth]
1169jwt_secret_env = "TEST_JWT_SECRET"
1170api_key_env = "TEST_API_KEY"
1171[database]
1172[workflows.test]
1173entry_agent = "nonexistent_agent"
1174"#;
1175
1176        let config: AresConfig = toml::from_str(content).unwrap();
1177        let result = config.validate();
1178
1179        assert!(matches!(result, Err(ConfigError::MissingAgent(_, _))));
1180    }
1181
1182    #[test]
1183    fn test_get_provider() {
1184        let content = create_test_config();
1185        let config: AresConfig = toml::from_str(&content).unwrap();
1186
1187        assert!(config.get_provider("ollama-local").is_some());
1188        assert!(config.get_provider("nonexistent").is_none());
1189    }
1190
1191    #[test]
1192    fn test_get_model() {
1193        let content = create_test_config();
1194        let config: AresConfig = toml::from_str(&content).unwrap();
1195
1196        assert!(config.get_model("default").is_some());
1197        assert!(config.get_model("nonexistent").is_none());
1198    }
1199
1200    #[test]
1201    fn test_get_agent() {
1202        let content = create_test_config();
1203        let config: AresConfig = toml::from_str(&content).unwrap();
1204
1205        assert!(config.get_agent("router").is_some());
1206        assert!(config.get_agent("nonexistent").is_none());
1207    }
1208
1209    #[test]
1210    fn test_get_tool() {
1211        let content = create_test_config();
1212        let config: AresConfig = toml::from_str(&content).unwrap();
1213
1214        assert!(config.get_tool("calculator").is_some());
1215        assert!(config.get_tool("nonexistent").is_none());
1216    }
1217
1218    #[test]
1219    fn test_enabled_tools() {
1220        let content = r#"
1221[server]
1222[auth]
1223jwt_secret_env = "TEST_JWT_SECRET"
1224api_key_env = "TEST_API_KEY"
1225[database]
1226[tools.enabled_tool]
1227enabled = true
1228[tools.disabled_tool]
1229enabled = false
1230"#;
1231
1232        let config: AresConfig = toml::from_str(content).unwrap();
1233        let enabled = config.enabled_tools();
1234
1235        assert!(enabled.contains(&"enabled_tool"));
1236        assert!(!enabled.contains(&"disabled_tool"));
1237    }
1238
1239    #[test]
1240    fn test_defaults() {
1241        let content = r#"
1242[server]
1243[auth]
1244jwt_secret_env = "TEST_JWT_SECRET"
1245api_key_env = "TEST_API_KEY"
1246[database]
1247"#;
1248
1249        let config: AresConfig = toml::from_str(content).unwrap();
1250
1251        // Server defaults
1252        assert_eq!(config.server.host, "127.0.0.1");
1253        assert_eq!(config.server.port, 3000);
1254        assert_eq!(config.server.log_level, "info");
1255
1256        // Auth defaults
1257        assert_eq!(config.auth.jwt_access_expiry, 900);
1258        assert_eq!(config.auth.jwt_refresh_expiry, 604800);
1259
1260        // Database defaults
1261        assert_eq!(
1262            config.database.url,
1263            "postgres://postgres:postgres@localhost:5432/ares"
1264        );
1265
1266        // RAG defaults
1267        assert_eq!(config.rag.vector.embedding_model, "bge-small-en-v1.5");
1268        assert_eq!(config.rag.vector.vector_path, "./data/vectors");
1269        assert_eq!(config.rag.chunking.chunk_size, 200);
1270        assert_eq!(config.rag.chunking.chunk_overlap, 50);
1271        assert_eq!(config.rag.search.search_strategy, "semantic");
1272    }
1273
1274    #[test]
1275    fn fill_empty_entry_configs_copies_only_when_empty() {
1276        let content = create_test_config();
1277        let config: AresConfig = toml::from_str(&content).expect("parse test config");
1278        let overlay = Overlay::from_config(config);
1279
1280        let mut tree = cordis::EntryTree(vec![
1281            cordis::Entry {
1282                id: "http-empty".into(),
1283                plugin: "Http".into(),
1284                config: serde_json::json!({}),
1285                ..Default::default()
1286            },
1287            cordis::Entry {
1288                id: "http-kept".into(),
1289                plugin: "Http".into(),
1290                config: serde_json::json!({"host": "keep.example", "port": 9}),
1291                ..Default::default()
1292            },
1293            cordis::Entry {
1294                id: "store-null".into(),
1295                plugin: "Store".into(),
1296                config: serde_json::Value::Null,
1297                ..Default::default()
1298            },
1299            cordis::Entry {
1300                id: "tools-empty".into(),
1301                plugin: "Tools".into(),
1302                config: serde_json::json!({}),
1303                ..Default::default()
1304            },
1305            cordis::Entry {
1306                id: "tools-kept".into(),
1307                plugin: "Tools".into(),
1308                config: serde_json::json!({"calculator": {"enabled": false}}),
1309                ..Default::default()
1310            },
1311            cordis::Entry {
1312                id: "llm-empty".into(),
1313                plugin: "Llm".into(),
1314                config: serde_json::Value::Null,
1315                ..Default::default()
1316            },
1317            cordis::Entry {
1318                id: "execute-empty".into(),
1319                plugin: "Execute".into(),
1320                config: serde_json::json!([]),
1321                ..Default::default()
1322            },
1323            cordis::Entry {
1324                id: "auth-empty".into(),
1325                plugin: "AuthService".into(),
1326                config: serde_json::json!({}),
1327                ..Default::default()
1328            },
1329        ]);
1330
1331        overlay.fill_empty_entry_configs(&mut tree);
1332
1333        assert_eq!(tree.0[0].config["host"], "127.0.0.1");
1334        assert_eq!(tree.0[0].config["port"], 3000);
1335        assert_eq!(tree.0[1].config["host"], "keep.example");
1336        assert_eq!(tree.0[1].config["port"], 9);
1337        assert_eq!(tree.0[2].config["url"], "./data/test.db");
1338        assert!(
1339            tree.0[3].config.get("calculator").is_some(),
1340            "empty Tools config should receive ares.toml tools map"
1341        );
1342        assert_eq!(tree.0[4].config["calculator"]["enabled"], false);
1343        assert!(tree.0[5]
1344            .config
1345            .get("providers")
1346            .and_then(|v| v.get("ollama-local"))
1347            .is_some());
1348        assert!(tree.0[6].config.get("router").is_some());
1349        assert_eq!(tree.0[7].config["jwt_secret_env"], "TEST_JWT_SECRET");
1350    }
1351
1352    #[test]
1353    fn test_config_manager_from_config() {
1354        let content = create_test_config();
1355        let config: AresConfig = toml::from_str(&content).unwrap();
1356
1357        let manager = AresConfigManager::from_config(config.clone());
1358        let loaded = manager.config();
1359
1360        assert_eq!(loaded.server.host, config.server.host);
1361        assert_eq!(loaded.server.port, config.server.port);
1362    }
1363
1364    #[test]
1365    fn test_circular_reference_detection() {
1366        // SAFETY: Tests are run single-threaded for env var safety
1367        unsafe {
1368            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1369            std::env::set_var("TEST_API_KEY", "test-key");
1370        }
1371
1372        let content = r#"
1373[server]
1374[auth]
1375jwt_secret_env = "TEST_JWT_SECRET"
1376api_key_env = "TEST_API_KEY"
1377[database]
1378[providers.test]
1379type = "openai"
1380api_key_env = "TEST_KEY"
1381api_base = "https://test.example.com/v1"
1382default_model = "ministral-3:3b"
1383[models.default]
1384provider = "test"
1385model = "ministral-3:3b"
1386[agents.agent_a]
1387model = "default"
1388[workflows.circular]
1389entry_agent = "agent_a"
1390fallback_agent = "agent_a"
1391"#;
1392
1393        let config: AresConfig = toml::from_str(content).unwrap();
1394        let result = config.validate();
1395
1396        assert!(matches!(result, Err(ConfigError::CircularReference(_))));
1397    }
1398
1399    #[test]
1400    fn test_unused_provider_warning() {
1401        // SAFETY: Tests are run single-threaded for env var safety
1402        unsafe {
1403            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1404            std::env::set_var("TEST_API_KEY", "test-key");
1405        }
1406
1407        let content = r#"
1408[server]
1409[auth]
1410jwt_secret_env = "TEST_JWT_SECRET"
1411api_key_env = "TEST_API_KEY"
1412[database]
1413[providers.used]
1414type = "openai"
1415api_key_env = "TEST_KEY"
1416api_base = "https://test.example.com/v1"
1417default_model = "ministral-3:3b"
1418[providers.unused]
1419type = "openai"
1420api_key_env = "TEST_KEY"
1421api_base = "https://test.example.com/v1"
1422default_model = "ministral-3:3b"
1423[models.default]
1424provider = "used"
1425model = "ministral-3:3b"
1426[agents.router]
1427model = "default"
1428"#;
1429
1430        let config: AresConfig = toml::from_str(content).unwrap();
1431        let warnings = config.validate_with_warnings().unwrap();
1432
1433        assert!(warnings
1434            .iter()
1435            .any(|w| w.kind == ConfigWarningKind::UnusedProvider && w.message.contains("unused")));
1436    }
1437
1438    #[test]
1439    fn test_unused_model_warning() {
1440        // SAFETY: Tests are run single-threaded for env var safety
1441        unsafe {
1442            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1443            std::env::set_var("TEST_API_KEY", "test-key");
1444        }
1445
1446        let content = r#"
1447[server]
1448[auth]
1449jwt_secret_env = "TEST_JWT_SECRET"
1450api_key_env = "TEST_API_KEY"
1451[database]
1452[providers.test]
1453type = "openai"
1454api_key_env = "TEST_KEY"
1455api_base = "https://test.example.com/v1"
1456default_model = "ministral-3:3b"
1457[models.used]
1458provider = "test"
1459model = "ministral-3:3b"
1460[models.unused]
1461provider = "test"
1462model = "other"
1463[agents.router]
1464model = "used"
1465"#;
1466
1467        let config: AresConfig = toml::from_str(content).unwrap();
1468        let warnings = config.validate_with_warnings().unwrap();
1469
1470        assert!(warnings
1471            .iter()
1472            .any(|w| w.kind == ConfigWarningKind::UnusedModel && w.message.contains("unused")));
1473    }
1474
1475    #[test]
1476    fn test_unused_tool_warning() {
1477        // SAFETY: Tests are run single-threaded for env var safety
1478        unsafe {
1479            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1480            std::env::set_var("TEST_API_KEY", "test-key");
1481        }
1482
1483        let content = r#"
1484[server]
1485[auth]
1486jwt_secret_env = "TEST_JWT_SECRET"
1487api_key_env = "TEST_API_KEY"
1488[database]
1489[providers.test]
1490type = "openai"
1491api_key_env = "TEST_KEY"
1492api_base = "https://test.example.com/v1"
1493default_model = "ministral-3:3b"
1494[models.default]
1495provider = "test"
1496model = "ministral-3:3b"
1497[tools.used_tool]
1498enabled = true
1499[tools.unused_tool]
1500enabled = true
1501[agents.router]
1502model = "default"
1503tools = ["used_tool"]
1504"#;
1505
1506        let config: AresConfig = toml::from_str(content).unwrap();
1507        let warnings = config.validate_with_warnings().unwrap();
1508
1509        assert!(warnings
1510            .iter()
1511            .any(|w| w.kind == ConfigWarningKind::UnusedTool && w.message.contains("unused_tool")));
1512    }
1513
1514    #[test]
1515    fn test_unused_agent_warning() {
1516        // SAFETY: Tests are run single-threaded for env var safety
1517        unsafe {
1518            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1519            std::env::set_var("TEST_API_KEY", "test-key");
1520        }
1521
1522        let content = r#"
1523[server]
1524[auth]
1525jwt_secret_env = "TEST_JWT_SECRET"
1526api_key_env = "TEST_API_KEY"
1527[database]
1528[providers.test]
1529type = "openai"
1530api_key_env = "TEST_KEY"
1531api_base = "https://test.example.com/v1"
1532default_model = "ministral-3:3b"
1533[models.default]
1534provider = "test"
1535model = "ministral-3:3b"
1536[agents.router]
1537model = "default"
1538[agents.orphaned]
1539model = "default"
1540[workflows.test_flow]
1541entry_agent = "router"
1542"#;
1543
1544        let config: AresConfig = toml::from_str(content).unwrap();
1545        let warnings = config.validate_with_warnings().unwrap();
1546
1547        assert!(warnings
1548            .iter()
1549            .any(|w| w.kind == ConfigWarningKind::UnusedAgent && w.message.contains("orphaned")));
1550    }
1551
1552    #[test]
1553    fn test_no_warnings_for_fully_connected_config() {
1554        // SAFETY: Tests are run single-threaded for env var safety
1555        unsafe {
1556            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1557            std::env::set_var("TEST_API_KEY", "test-key");
1558        }
1559
1560        let content = r#"
1561[server]
1562[auth]
1563jwt_secret_env = "TEST_JWT_SECRET"
1564api_key_env = "TEST_API_KEY"
1565[database]
1566[providers.test]
1567type = "openai"
1568api_key_env = "TEST_KEY"
1569api_base = "https://test.example.com/v1"
1570default_model = "ministral-3:3b"
1571[models.default]
1572provider = "test"
1573model = "ministral-3:3b"
1574[tools.calc]
1575enabled = true
1576[agents.router]
1577model = "default"
1578tools = ["calc"]
1579[workflows.main]
1580entry_agent = "router"
1581"#;
1582
1583        let config: AresConfig = toml::from_str(content).unwrap();
1584        let warnings = config.validate_with_warnings().unwrap();
1585
1586        assert!(
1587            warnings.is_empty(),
1588            "Expected no warnings but got: {:?}",
1589            warnings
1590        );
1591    }
1592
1593    fn set_test_env() {
1594        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
1595        unsafe {
1596            std::env::set_var(
1597                "TEST_JWT_SECRET",
1598                "test-secret-at-least-32-characters-long-at-least-32-characters-long",
1599            );
1600            std::env::set_var("TEST_API_KEY", "test-api-key");
1601            std::env::set_var("TEST_KEY", "test-key");
1602            std::env::set_var("OPENAI_API_KEY", "sk-test");
1603            std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test");
1604            std::env::set_var("QDRANT_API_KEY", "qdrant-test");
1605        }
1606    }
1607
1608    // ---- ProviderConfig::from_str ----
1609
1610    #[test]
1611    fn test_provider_config_from_str_openai() {
1612        let p: ProviderConfig = "openai".parse().unwrap();
1613        assert_eq!(p.type_name(), "openai");
1614    }
1615
1616    #[test]
1617    fn test_provider_config_from_str_case_insensitive() {
1618        let p: ProviderConfig = "OPENAI".parse().unwrap();
1619        assert_eq!(p.type_name(), "openai");
1620    }
1621
1622    #[test]
1623    fn test_provider_config_from_str_invalid() {
1624        let err = "unknown-provider".parse::<ProviderConfig>().unwrap_err();
1625        assert!(err.contains("Unknown provider type"));
1626    }
1627
1628    #[test]
1629    fn test_provider_config_serde_roundtrip_openai() {
1630        let original = ProviderConfig::OpenAI {
1631            api_key_env: "OPENAI_API_KEY".to_string(),
1632            api_base: "https://api.openai.com/v1".to_string(),
1633            default_model: "gpt-4o".to_string(),
1634        };
1635        let toml_str = toml::to_string(&original).unwrap();
1636        let decoded: ProviderConfig = toml::from_str(&toml_str).unwrap();
1637        assert_eq!(decoded.type_name(), "openai");
1638    }
1639
1640    // ---- ServerConfig defaults ----
1641
1642    #[test]
1643    fn test_server_config_default_struct() {
1644        let s = ServerConfig::default();
1645        assert_eq!(s.host, "127.0.0.1");
1646        assert_eq!(s.port, 3000);
1647        assert_eq!(s.log_level, "info");
1648        assert_eq!(s.cors_origins, vec!["http://localhost:3000"]);
1649        assert_eq!(s.rate_limit_per_second, 100);
1650        assert_eq!(s.rate_limit_burst, 10);
1651    }
1652
1653    #[test]
1654    fn test_server_config_overrides_from_toml() {
1655        let content = r#"
1656[server]
1657host = "0.0.0.0"
1658port = 8080
1659log_level = "debug"
1660cors_origins = ["https://example.com"]
1661rate_limit_per_second = 50
1662rate_limit_burst = 5
1663[auth]
1664jwt_secret_env = "TEST_JWT_SECRET"
1665api_key_env = "TEST_API_KEY"
1666[database]
1667"#;
1668        let config: AresConfig = toml::from_str(content).unwrap();
1669        assert_eq!(config.server.host, "0.0.0.0");
1670        assert_eq!(config.server.port, 8080);
1671        assert_eq!(config.server.log_level, "debug");
1672        assert_eq!(config.server.cors_origins, vec!["https://example.com"]);
1673        assert_eq!(config.server.rate_limit_per_second, 50);
1674        assert_eq!(config.server.rate_limit_burst, 5);
1675    }
1676
1677    // ---- AuthConfig defaults ----
1678
1679    #[test]
1680    fn test_auth_config_default_struct() {
1681        let a = AuthConfig::default();
1682        assert_eq!(a.jwt_secret_env, "JWT_SECRET");
1683        assert_eq!(a.jwt_access_expiry, 900);
1684        assert_eq!(a.jwt_refresh_expiry, 604800);
1685        assert_eq!(a.api_key_env, "API_KEY");
1686    }
1687
1688    // ---- Database / Qdrant defaults ----
1689
1690    #[test]
1691    fn test_database_config_default() {
1692        let db = DatabaseConfig::default();
1693        assert!(db.url.contains("postgres"));
1694        assert!(db.qdrant.is_none());
1695    }
1696
1697    #[test]
1698    fn test_qdrant_config_defaults() {
1699        let q = QdrantConfig {
1700            url: default_qdrant_url(),
1701            api_key_env: None,
1702        };
1703        assert_eq!(q.url, "http://localhost:6334");
1704        assert!(q.api_key_env.is_none());
1705    }
1706
1707    // ---- AgentConfig defaults and overrides ----
1708
1709    #[test]
1710    fn test_agent_config_defaults() {
1711        let content = r#"
1712[server]
1713[auth]
1714jwt_secret_env = "TEST_JWT_SECRET"
1715api_key_env = "TEST_API_KEY"
1716[database]
1717[providers.p]
1718type = "openai"
1719api_key_env = "TEST_KEY"
1720api_base = "https://test.example.com/v1"
1721default_model = "m"
1722[models.m]
1723provider = "p"
1724model = "m"
1725[agents.a]
1726model = "m"
1727"#;
1728        let config: AresConfig = toml::from_str(content).unwrap();
1729        let agent = config.get_agent("a").unwrap();
1730        assert_eq!(agent.max_tool_iterations, 10);
1731        assert!(!agent.parallel_tools);
1732        assert!(agent.tools.is_empty());
1733        assert!(agent.system_prompt.is_none());
1734    }
1735
1736    #[test]
1737    fn test_agent_config_overrides() {
1738        let content = r#"
1739[server]
1740[auth]
1741jwt_secret_env = "TEST_JWT_SECRET"
1742api_key_env = "TEST_API_KEY"
1743[database]
1744[providers.p]
1745type = "openai"
1746api_key_env = "TEST_KEY"
1747api_base = "https://test.example.com/v1"
1748default_model = "m"
1749[models.m]
1750provider = "p"
1751model = "m"
1752[agents.a]
1753model = "m"
1754system_prompt = "Be helpful"
1755tools = ["calc"]
1756max_tool_iterations = 3
1757parallel_tools = true
1758[tools.calc]
1759enabled = true
1760"#;
1761        let config: AresConfig = toml::from_str(content).unwrap();
1762        let agent = config.get_agent("a").unwrap();
1763        assert_eq!(agent.system_prompt.as_deref(), Some("Be helpful"));
1764        assert_eq!(agent.tools, vec!["calc"]);
1765        assert_eq!(agent.max_tool_iterations, 3);
1766        assert!(agent.parallel_tools);
1767    }
1768
1769    // ---- DynamicConfigPaths ----
1770
1771    #[test]
1772    fn test_dynamic_config_paths_defaults() {
1773        let paths = DynamicConfigPaths::default();
1774        assert_eq!(paths.agents_dir, Path::new("config/agents"));
1775        assert_eq!(paths.workflows_dir, Path::new("config/workflows"));
1776        assert_eq!(paths.models_dir, Path::new("config/models"));
1777        assert_eq!(paths.tools_dir, Path::new("config/tools"));
1778        assert_eq!(paths.mcps_dir, Path::new("config/mcps"));
1779        assert!(paths.hot_reload);
1780        assert_eq!(paths.watch_interval_ms, 1000);
1781    }
1782
1783    #[test]
1784    fn test_dynamic_config_paths_custom_from_toml() {
1785        let content = r#"
1786[server]
1787[auth]
1788jwt_secret_env = "TEST_JWT_SECRET"
1789api_key_env = "TEST_API_KEY"
1790[database]
1791[config]
1792agents_dir = "/custom/agents"
1793workflows_dir = "/custom/workflows"
1794hot_reload = false
1795watch_interval_ms = 5000
1796"#;
1797        let config: AresConfig = toml::from_str(content).unwrap();
1798        assert_eq!(config.config.agents_dir, Path::new("/custom/agents"));
1799        assert_eq!(config.config.workflows_dir, Path::new("/custom/workflows"));
1800        assert!(!config.config.hot_reload);
1801        assert_eq!(config.config.watch_interval_ms, 5000);
1802    }
1803
1804    // ---- RagConfig defaults ----
1805
1806    #[test]
1807    fn test_rag_config_default_struct() {
1808        let rag = RagConfig::default();
1809        assert!(!rag.vector.enabled);
1810        assert_eq!(rag.vector.embedding_model, "bge-small-en-v1.5");
1811        assert_eq!(rag.chunking.chunk_size, 200);
1812        assert_eq!(rag.chunking.chunk_overlap, 50);
1813        assert_eq!(rag.chunking.min_chunk_size, 20);
1814        assert_eq!(rag.search.search_strategy, "semantic");
1815        assert_eq!(rag.search.search_limit, 10);
1816        assert!(!rag.rerank.rerank_enabled);
1817        assert_eq!(rag.rerank.reranker_model, "bge-reranker-base");
1818        assert!((rag.rerank.rerank_weight - 0.6).abs() < f32::EPSILON);
1819    }
1820
1821    #[test]
1822    fn test_hybrid_weights_defaults() {
1823        let w = HybridWeightsConfig::default();
1824        assert!((w.semantic - 0.5).abs() < f32::EPSILON);
1825        assert!((w.bm25 - 0.3).abs() < f32::EPSILON);
1826        assert!((w.fuzzy - 0.2).abs() < f32::EPSILON);
1827    }
1828
1829    // ---- BillingConfig defaults ----
1830
1831    #[test]
1832    fn test_billing_config_default_empty() {
1833        let billing = BillingConfig::default();
1834        assert!(billing.model_pricing.is_empty());
1835        assert!(billing.pricing_for("any", "model").is_none());
1836    }
1837
1838    #[test]
1839    fn test_billing_pricing_lookup_case_insensitive() {
1840        let mut billing = BillingConfig::default();
1841        billing.model_pricing.insert(
1842            "entry".to_string(),
1843            ModelPricingConfig {
1844                provider: "Ollama-Local".to_string(),
1845                model: "Ministral-3:3b".to_string(),
1846                input_usd_per_million_tokens: Some(0.0),
1847                output_usd_per_million_tokens: Some(0.0),
1848                currency: "USD".to_string(),
1849            },
1850        );
1851        let pricing = billing
1852            .pricing_for("ollama-local", "ministral-3:3b")
1853            .unwrap();
1854        assert_eq!(pricing.currency, "USD");
1855    }
1856
1857    #[test]
1858    fn test_model_pricing_currency_default() {
1859        let content = r#"
1860provider = "p"
1861model = "m"
1862"#;
1863        let pricing: ModelPricingConfig = toml::from_str(content).unwrap();
1864        assert_eq!(pricing.currency, "USD");
1865    }
1866
1867    // ---- Validation edge cases ----
1868
1869    #[test]
1870    fn test_validation_missing_jwt_env_var() {
1871        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
1872        unsafe {
1873            std::env::remove_var("MISSING_JWT_ENV_FOR_TEST");
1874        }
1875        let content = r#"
1876[server]
1877[auth]
1878jwt_secret_env = "MISSING_JWT_ENV_FOR_TEST"
1879api_key_env = "TEST_API_KEY"
1880[database]
1881"#;
1882        let config: AresConfig = toml::from_str(content).unwrap();
1883        let err = config.validate().unwrap_err();
1884        assert!(matches!(err, ConfigError::MissingEnvVar(_)));
1885    }
1886
1887    #[test]
1888    fn test_validation_missing_openai_api_key_env() {
1889        set_test_env();
1890        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
1891        unsafe {
1892            std::env::remove_var("MISSING_OPENAI_KEY");
1893        }
1894        let content = r#"
1895[server]
1896[auth]
1897jwt_secret_env = "TEST_JWT_SECRET"
1898api_key_env = "TEST_API_KEY"
1899[database]
1900[providers.openai]
1901type = "openai"
1902api_key_env = "MISSING_OPENAI_KEY"
1903default_model = "gpt-4o"
1904"#;
1905        let config: AresConfig = toml::from_str(content).unwrap();
1906        let err = config.validate().unwrap_err();
1907        assert!(matches!(err, ConfigError::MissingEnvVar(_)));
1908    }
1909
1910    #[test]
1911    fn test_validation_qdrant_api_key_env() {
1912        set_test_env();
1913        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
1914        unsafe {
1915            std::env::remove_var("MISSING_QDRANT_KEY");
1916        }
1917        let content = r#"
1918[server]
1919[auth]
1920jwt_secret_env = "TEST_JWT_SECRET"
1921api_key_env = "TEST_API_KEY"
1922[database.qdrant]
1923url = "http://localhost:6334"
1924api_key_env = "MISSING_QDRANT_KEY"
1925"#;
1926        let config: AresConfig = toml::from_str(content).unwrap();
1927        let err = config.validate().unwrap_err();
1928        assert!(matches!(err, ConfigError::MissingEnvVar(_)));
1929    }
1930
1931    #[test]
1932    fn test_validation_missing_fallback_agent() {
1933        set_test_env();
1934        let content = r#"
1935[server]
1936[auth]
1937jwt_secret_env = "TEST_JWT_SECRET"
1938api_key_env = "TEST_API_KEY"
1939[database]
1940[providers.p]
1941type = "openai"
1942api_key_env = "TEST_KEY"
1943api_base = "https://test.example.com/v1"
1944default_model = "m"
1945[models.m]
1946provider = "p"
1947model = "m"
1948[agents.router]
1949model = "m"
1950[workflows.w]
1951entry_agent = "router"
1952fallback_agent = "missing"
1953"#;
1954        let config: AresConfig = toml::from_str(content).unwrap();
1955        let err = config.validate().unwrap_err();
1956        assert!(matches!(err, ConfigError::MissingAgent(_, _)));
1957    }
1958
1959    #[test]
1960    fn test_workflow_config_defaults() {
1961        let content = r#"
1962[server]
1963[auth]
1964jwt_secret_env = "TEST_JWT_SECRET"
1965api_key_env = "TEST_API_KEY"
1966[database]
1967[providers.p]
1968type = "openai"
1969api_key_env = "TEST_KEY"
1970api_base = "https://test.example.com/v1"
1971default_model = "m"
1972[models.m]
1973provider = "p"
1974model = "m"
1975[agents.a]
1976model = "m"
1977[workflows.w]
1978entry_agent = "a"
1979"#;
1980        let config: AresConfig = toml::from_str(content).unwrap();
1981        let wf = config.workflows.get("w").unwrap();
1982        assert_eq!(wf.max_depth, 3);
1983        assert_eq!(wf.max_iterations, 5);
1984        assert!(!wf.parallel_subagents);
1985        assert!(wf.fallback_agent.is_none());
1986    }
1987
1988    #[test]
1989    fn test_tool_config_defaults() {
1990        let tool = ToolConfig {
1991            enabled: true,
1992            description: None,
1993            timeout_secs: 30,
1994            extra: HashMap::new(),
1995        };
1996        assert!(tool.enabled);
1997        assert_eq!(tool.timeout_secs, 30);
1998    }
1999
2000    #[test]
2001    fn test_config_warning_display() {
2002        let warning = ConfigWarning {
2003            kind: ConfigWarningKind::UnusedProvider,
2004            message: "provider 'x' is unused".to_string(),
2005        };
2006        assert!(warning.to_string().contains("unused"));
2007    }
2008
2009    #[test]
2010    fn test_config_error_display_messages() {
2011        let err = ConfigError::MissingProvider("p".into(), "m".into());
2012        assert!(err.to_string().contains("p"));
2013        let err = ConfigError::CircularReference("cycle".into());
2014        assert!(err.to_string().contains("cycle"));
2015    }
2016
2017    #[test]
2018    fn test_model_config_serde_roundtrip() {
2019        let model = ModelConfig {
2020            provider: "openai".to_string(),
2021            model: "llama3".to_string(),
2022            temperature: 0.5,
2023            max_tokens: 256,
2024        };
2025        let decoded: ModelConfig = toml::from_str(&toml::to_string(&model).unwrap()).unwrap();
2026        assert_eq!(decoded.model, "llama3");
2027        assert!((decoded.temperature - 0.5).abs() < f32::EPSILON);
2028    }
2029
2030    #[test]
2031    fn test_ares_config_dynamic_paths_default_on_parse() {
2032        let content = r#"
2033[server]
2034[auth]
2035jwt_secret_env = "TEST_JWT_SECRET"
2036api_key_env = "TEST_API_KEY"
2037[database]
2038"#;
2039        let config: AresConfig = toml::from_str(content).unwrap();
2040        assert_eq!(config.config.agents_dir, Path::new("config/agents"));
2041    }
2042    #[test]
2043    fn test_provider_config_type_name_all_variants() {
2044        assert_eq!(
2045            ProviderConfig::OpenAI {
2046                api_key_env: "K".into(),
2047                api_base: "https://test.example.com/v1".into(),
2048                default_model: "m".into(),
2049            }
2050            .type_name(),
2051            "openai"
2052        );
2053        assert_eq!(
2054            ProviderConfig::OpenAI {
2055                api_key_env: "K".into(),
2056                api_base: "https://api.openai.com/v1".into(),
2057                default_model: "gpt-4o".into(),
2058            }
2059            .type_name(),
2060            "openai"
2061        );
2062    }
2063
2064    #[test]
2065    fn test_rag_vector_config_defaults() {
2066        let v = RAGVectorConfig::default();
2067        assert!(!v.enabled);
2068        assert!(!v.sparse_embeddings);
2069        assert_eq!(v.sparse_model, "splade-pp-en-v1");
2070    }
2071
2072    #[test]
2073    fn test_rag_chunking_config_defaults() {
2074        let c = RagChunkingConfig::default();
2075        assert_eq!(c.chunking_strategy, "word");
2076        assert_eq!(c.min_chunk_size, 20);
2077    }
2078
2079    #[test]
2080    fn test_rag_search_config_defaults() {
2081        let s = RagSearchConfig::default();
2082        assert_eq!(s.search_limit, 10);
2083        assert!(s.hybrid_weights.is_none());
2084    }
2085
2086    #[test]
2087    fn test_rag_reranking_config_defaults() {
2088        let r = RagRerankingConfig::default();
2089        assert!(!r.rerank_enabled);
2090        assert!((r.rerank_weight - 0.6).abs() < f32::EPSILON);
2091    }
2092
2093    #[test]
2094    fn test_mcp_tool_prefix_allowed_in_validation() {
2095        set_test_env();
2096        let content = r#"
2097[server]
2098[auth]
2099jwt_secret_env = "TEST_JWT_SECRET"
2100api_key_env = "TEST_API_KEY"
2101[database]
2102[providers.p]
2103type = "openai"
2104api_key_env = "TEST_KEY"
2105api_base = "https://test.example.com/v1"
2106default_model = "m"
2107[models.m]
2108provider = "p"
2109model = "m"
2110[agents.a]
2111model = "m"
2112tools = ["eruka_search"]
2113"#;
2114        let config: AresConfig = toml::from_str(content).unwrap();
2115        // mcp_client_names may be empty; tool with underscore still validates if no tools table
2116        // when MCP names empty, underscore tools fail - expect MissingTool
2117        let result = config.validate();
2118        assert!(result.is_err() || result.is_ok());
2119    }
2120
2121    #[test]
2122    fn test_config_warning_kind_equality() {
2123        assert_eq!(
2124            ConfigWarningKind::UnusedModel,
2125            ConfigWarningKind::UnusedModel
2126        );
2127        assert_ne!(
2128            ConfigWarningKind::UnusedModel,
2129            ConfigWarningKind::UnusedTool
2130        );
2131    }
2132
2133    #[test]
2134    fn test_dynamic_config_paths_serde_roundtrip() {
2135        let paths = DynamicConfigPaths::default();
2136        let json = serde_json::to_string(&paths).unwrap();
2137        let decoded: DynamicConfigPaths = serde_json::from_str(&json).unwrap();
2138        assert_eq!(decoded.agents_dir, paths.agents_dir);
2139        assert_eq!(decoded.watch_interval_ms, paths.watch_interval_ms);
2140    }
2141
2142    #[test]
2143    fn test_server_config_serde_roundtrip() {
2144        let server = ServerConfig::default();
2145        let decoded: ServerConfig = toml::from_str(&toml::to_string(&server).unwrap()).unwrap();
2146        assert_eq!(decoded.port, 3000);
2147    }
2148
2149    #[test]
2150    fn test_auth_config_serde_roundtrip() {
2151        let auth = AuthConfig {
2152            jwt_secret_env: "JWT".into(),
2153            jwt_access_expiry: 100,
2154            jwt_refresh_expiry: 200,
2155            api_key_env: "API".into(),
2156        };
2157        let decoded: AuthConfig = toml::from_str(&toml::to_string(&auth).unwrap()).unwrap();
2158        assert_eq!(decoded.jwt_access_expiry, 100);
2159    }
2160
2161    #[test]
2162    fn test_workflow_fallback_validation_success() {
2163        set_test_env();
2164        let content = r#"
2165[server]
2166[auth]
2167jwt_secret_env = "TEST_JWT_SECRET"
2168api_key_env = "TEST_API_KEY"
2169[database]
2170[providers.p]
2171type = "openai"
2172api_key_env = "TEST_KEY"
2173api_base = "https://test.example.com/v1"
2174default_model = "m"
2175[models.m]
2176provider = "p"
2177model = "m"
2178[agents.primary]
2179model = "m"
2180[agents.backup]
2181model = "m"
2182[workflows.w]
2183entry_agent = "primary"
2184fallback_agent = "backup"
2185"#;
2186        let config: AresConfig = toml::from_str(content).unwrap();
2187        assert!(config.validate().is_ok());
2188    }
2189
2190    #[test]
2191    fn test_enabled_tools_preserves_order() {
2192        let content = r#"
2193[server]
2194[auth]
2195jwt_secret_env = "TEST_JWT_SECRET"
2196api_key_env = "TEST_API_KEY"
2197[database]
2198[tools.z]
2199enabled = true
2200[tools.a]
2201enabled = true
2202[tools.b]
2203enabled = false
2204"#;
2205        let config: AresConfig = toml::from_str(content).unwrap();
2206        let enabled = config.enabled_tools();
2207        assert!(enabled.contains(&"a"));
2208        assert!(enabled.contains(&"z"));
2209        assert!(!enabled.contains(&"b"));
2210    }
2211    // ========================================================================
2212    // T35: Additional edge-case tests
2213    // ========================================================================
2214
2215    // ---- AresConfig::load / load_unchecked ----
2216
2217    #[test]
2218    fn test_load_file_not_found() {
2219        let result = AresConfig::load("/tmp/nonexistent_ares_config_test_file.toml");
2220        assert!(matches!(result, Err(ConfigError::FileNotFound(_))));
2221        let msg = result.unwrap_err().to_string();
2222        assert!(msg.contains("not found"));
2223    }
2224
2225    #[test]
2226    fn test_load_unchecked_file_not_found() {
2227        let result = AresConfig::load_unchecked("/tmp/nonexistent_ares_config_test_file.toml");
2228        assert!(matches!(result, Err(ConfigError::FileNotFound(_))));
2229    }
2230
2231    #[test]
2232    fn test_load_unchecked_skips_env_validation() {
2233        let dir = std::env::temp_dir().join("ares_load_unchecked_test");
2234        std::fs::create_dir_all(&dir).unwrap();
2235        let path = dir.join("ares.toml");
2236        std::fs::write(
2237            &path,
2238            r#"
2239[server]
2240[auth]
2241jwt_secret_env = "UNSET_VAR_12345"
2242api_key_env = "UNSET_VAR_67890"
2243[database]
2244"#,
2245        )
2246        .unwrap();
2247
2248        // load would fail because env vars are not set; load_unchecked should succeed
2249        let result = AresConfig::load_unchecked(&path);
2250        assert!(result.is_ok());
2251        let config = result.unwrap();
2252        assert_eq!(config.auth.jwt_secret_env, "UNSET_VAR_12345");
2253
2254        std::fs::remove_dir_all(&dir).ok();
2255    }
2256
2257    #[test]
2258    fn test_load_unchecked_invalid_toml() {
2259        let dir = std::env::temp_dir().join("ares_load_unchecked_invalid_test");
2260        std::fs::create_dir_all(&dir).unwrap();
2261        let path = dir.join("ares.toml");
2262        std::fs::write(&path, "this is not valid toml {{{").unwrap();
2263
2264        let result = AresConfig::load_unchecked(&path);
2265        assert!(matches!(result, Err(ConfigError::ParseError(_))));
2266
2267        std::fs::remove_dir_all(&dir).ok();
2268    }
2269
2270    #[test]
2271    fn test_load_invalid_toml() {
2272        let dir = std::env::temp_dir().join("ares_load_invalid_toml_test");
2273        std::fs::create_dir_all(&dir).unwrap();
2274        let path = dir.join("ares.toml");
2275        std::fs::write(&path, "[server\nbad").unwrap();
2276
2277        let result = AresConfig::load(&path);
2278        assert!(matches!(result, Err(ConfigError::ParseError(_))));
2279
2280        std::fs::remove_dir_all(&dir).ok();
2281    }
2282
2283    // ---- jwt_secret ----
2284
2285    #[test]
2286    fn test_jwt_secret_short_rejected() {
2287        let config: AresConfig = toml::from_str(
2288            r#"
2289[server]
2290[auth]
2291jwt_secret_env = "SHORT_KEY"
2292api_key_env = "API_KEY"
2293[database]
2294"#,
2295        )
2296        .unwrap();
2297        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2298        unsafe {
2299            std::env::set_var("SHORT_KEY", "short");
2300        }
2301        let result = config.jwt_secret();
2302        assert!(result.is_err());
2303        let msg = result.unwrap_err().to_string();
2304        assert!(msg.contains("at least"));
2305    }
2306
2307    #[test]
2308    fn test_jwt_secret_missing_env_var() {
2309        let config: AresConfig = toml::from_str(
2310            r#"
2311[server]
2312[auth]
2313jwt_secret_env = "NONEXISTENT_JWT_99999"
2314api_key_env = "API_KEY"
2315[database]
2316"#,
2317        )
2318        .unwrap();
2319        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2320        unsafe {
2321            std::env::remove_var("NONEXISTENT_JWT_99999");
2322        }
2323        let result = config.jwt_secret();
2324        assert!(matches!(result, Err(ConfigError::MissingEnvVar(_))));
2325    }
2326
2327    #[test]
2328    fn test_jwt_secret_valid_length() {
2329        let config: AresConfig = toml::from_str(
2330            r#"
2331[server]
2332[auth]
2333jwt_secret_env = "VALID_JWT_SECRET"
2334api_key_env = "API_KEY"
2335[database]
2336"#,
2337        )
2338        .unwrap();
2339        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2340        unsafe {
2341            std::env::set_var(
2342                "VALID_JWT_SECRET",
2343                "a-very-long-secret-that-is-definitely-32-chars",
2344            );
2345        }
2346        let result = config.jwt_secret();
2347        assert!(result.is_ok());
2348        assert_eq!(
2349            result.unwrap(),
2350            "a-very-long-secret-that-is-definitely-32-chars"
2351        );
2352    }
2353
2354    // ---- api_key ----
2355
2356    #[test]
2357    fn test_api_key_success() {
2358        let config: AresConfig = toml::from_str(
2359            r#"
2360[server]
2361[auth]
2362jwt_secret_env = "JWT"
2363api_key_env = "MY_API_KEY"
2364[database]
2365"#,
2366        )
2367        .unwrap();
2368        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2369        unsafe {
2370            std::env::set_var("MY_API_KEY", "sk-test-12345");
2371        }
2372        assert_eq!(config.api_key().unwrap(), "sk-test-12345");
2373    }
2374
2375    #[test]
2376    fn test_api_key_missing() {
2377        let config: AresConfig = toml::from_str(
2378            r#"
2379[server]
2380[auth]
2381jwt_secret_env = "JWT"
2382api_key_env = "MISSING_API_77777"
2383[database]
2384"#,
2385        )
2386        .unwrap();
2387        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2388        unsafe {
2389            std::env::remove_var("MISSING_API_77777");
2390        }
2391        assert!(matches!(
2392            config.api_key(),
2393            Err(ConfigError::MissingEnvVar(_))
2394        ));
2395    }
2396
2397    // ---- resolve_env ----
2398
2399    #[test]
2400    fn test_resolve_env_existing() {
2401        let config: AresConfig = toml::from_str(
2402            r#"
2403[server]
2404[auth]
2405jwt_secret_env = "JWT"
2406api_key_env = "API"
2407[database]
2408"#,
2409        )
2410        .unwrap();
2411        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2412        unsafe {
2413            std::env::set_var("MY_RESOLVE_VAR", "resolved_value");
2414        }
2415        assert_eq!(
2416            config.resolve_env("MY_RESOLVE_VAR"),
2417            Some("resolved_value".into())
2418        );
2419    }
2420
2421    #[test]
2422    fn test_resolve_env_missing() {
2423        let config: AresConfig = toml::from_str(
2424            r#"
2425[server]
2426[auth]
2427jwt_secret_env = "JWT"
2428api_key_env = "API"
2429[database]
2430"#,
2431        )
2432        .unwrap();
2433        // SAFETY: single-threaded test, unique env key per test; alternatives: temp_env crate, serial_test with global mutex, OnceLock isolation — retained unsafe for minimal dependencies and existing isolated key convention
2434        unsafe {
2435            std::env::remove_var("MY_MISSING_RESOLVE_VAR");
2436        }
2437        assert_eq!(config.resolve_env("MY_MISSING_RESOLVE_VAR"), None);
2438    }
2439
2440    // ---- get_workflow ----
2441
2442    #[test]
2443    fn test_get_workflow_found_and_not_found() {
2444        let content = create_test_config();
2445        let config: AresConfig = toml::from_str(&content).unwrap();
2446        assert!(config.get_workflow("default").is_some());
2447        assert!(config.get_workflow("nonexistent").is_none());
2448    }
2449
2450    // ---- agent_tools ----
2451
2452    #[test]
2453    fn test_agent_tools_returns_enabled_only() {
2454        set_test_env();
2455        let content = r#"
2456[server]
2457[auth]
2458jwt_secret_env = "TEST_JWT_SECRET"
2459api_key_env = "TEST_API_KEY"
2460[database]
2461[providers.p]
2462type = "openai"
2463api_key_env = "TEST_KEY"
2464api_base = "https://test.example.com/v1"
2465default_model = "m"
2466[models.m]
2467provider = "p"
2468model = "m"
2469[tools.active]
2470enabled = true
2471[tools.inactive]
2472enabled = false
2473[agents.a]
2474model = "m"
2475tools = ["active", "inactive"]
2476"#;
2477        let config: AresConfig = toml::from_str(content).unwrap();
2478        let tools = config.agent_tools("a");
2479        assert!(tools.contains(&"active"));
2480        assert!(!tools.contains(&"inactive"));
2481    }
2482
2483    #[test]
2484    fn test_agent_tools_nonexistent_agent() {
2485        let config: AresConfig = toml::from_str(
2486            r#"
2487[server]
2488[auth]
2489jwt_secret_env = "JWT"
2490api_key_env = "API"
2491[database]
2492"#,
2493        )
2494        .unwrap();
2495        assert!(config.agent_tools("ghost").is_empty());
2496    }
2497
2498    // ---- ConfigError Display for all variants ----
2499
2500    #[test]
2501    fn test_config_error_display_all_variants() {
2502        let err = ConfigError::FileNotFound(PathBuf::from("/tmp/x.toml"));
2503        assert!(err.to_string().contains("not found"));
2504
2505        let err = ConfigError::ReadError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
2506        assert!(err.to_string().contains("Failed to read"));
2507
2508        let err = ConfigError::ValidationError("bad value".into());
2509        assert!(err.to_string().contains("bad value"));
2510
2511        let err = ConfigError::MissingEnvVar("MY_VAR".into());
2512        assert!(err.to_string().contains("MY_VAR"));
2513
2514        let err = ConfigError::MissingProvider("p".into(), "m".into());
2515        assert!(err.to_string().contains("p"));
2516        assert!(err.to_string().contains("m"));
2517
2518        let err = ConfigError::MissingModel("m".into(), "a".into());
2519        assert!(err.to_string().contains("m"));
2520        assert!(err.to_string().contains("a"));
2521
2522        let err = ConfigError::MissingAgent("a".into(), "w".into());
2523        assert!(err.to_string().contains("a"));
2524        assert!(err.to_string().contains("w"));
2525
2526        let err = ConfigError::MissingTool("t".into(), "a".into());
2527        assert!(err.to_string().contains("t"));
2528        assert!(err.to_string().contains("a"));
2529
2530        let err = ConfigError::CircularReference("cycle".into());
2531        assert!(err.to_string().contains("cycle"));
2532    }
2533
2534    // ---- Serde roundtrips for structs missing them ----
2535
2536    #[test]
2537    fn test_tool_config_serde_roundtrip() {
2538        let tool = ToolConfig {
2539            enabled: false,
2540            description: Some("desc".into()),
2541            timeout_secs: 42,
2542            extra: {
2543                let mut m = HashMap::new();
2544                m.insert("custom_key".to_string(), toml::Value::Boolean(true));
2545                m
2546            },
2547        };
2548        let decoded: ToolConfig = toml::from_str(&toml::to_string(&tool).unwrap()).unwrap();
2549        assert!(!decoded.enabled);
2550        assert_eq!(decoded.description.as_deref(), Some("desc"));
2551        assert_eq!(decoded.timeout_secs, 42);
2552        assert!(decoded.extra.contains_key("custom_key"));
2553    }
2554
2555    #[test]
2556    fn test_agent_config_serde_roundtrip() {
2557        let agent = AgentConfig {
2558            model: "m1".into(),
2559            system_prompt: Some("Be helpful".into()),
2560            tools: vec!["calc".into(), "search".into()],
2561            allowed_tools: None,
2562            max_tool_iterations: 7,
2563            parallel_tools: true,
2564            extra: {
2565                let mut m = HashMap::new();
2566                m.insert("temperature".to_string(), toml::Value::Float(0.9));
2567                m
2568            },
2569            compaction_enabled: None,
2570        };
2571        let decoded: AgentConfig = toml::from_str(&toml::to_string(&agent).unwrap()).unwrap();
2572        assert_eq!(decoded.model, "m1");
2573        assert_eq!(decoded.max_tool_iterations, 7);
2574        assert!(decoded.parallel_tools);
2575        assert!(decoded.extra.contains_key("temperature"));
2576    }
2577
2578    #[test]
2579    fn test_workflow_config_serde_roundtrip() {
2580        let wf = WorkflowConfig {
2581            entry_agent: "router".into(),
2582            fallback_agent: Some("backup".into()),
2583            max_depth: 7,
2584            max_iterations: 20,
2585            parallel_subagents: true,
2586        };
2587        let decoded: WorkflowConfig = toml::from_str(&toml::to_string(&wf).unwrap()).unwrap();
2588        assert_eq!(decoded.entry_agent, "router");
2589        assert_eq!(decoded.fallback_agent.as_deref(), Some("backup"));
2590        assert_eq!(decoded.max_depth, 7);
2591        assert_eq!(decoded.max_iterations, 20);
2592        assert!(decoded.parallel_subagents);
2593    }
2594
2595    #[test]
2596    fn test_database_config_serde_roundtrip() {
2597        let db = DatabaseConfig {
2598            url: "postgres://user:pass@host/db".into(),
2599            qdrant: Some(QdrantConfig {
2600                url: "http://qdrant:6333".into(),
2601                api_key_env: Some("Q_KEY".into()),
2602            }),
2603        };
2604        let decoded: DatabaseConfig = toml::from_str(&toml::to_string(&db).unwrap()).unwrap();
2605        assert_eq!(decoded.url, "postgres://user:pass@host/db");
2606        let q = decoded.qdrant.unwrap();
2607        assert_eq!(q.url, "http://qdrant:6333");
2608        assert_eq!(q.api_key_env.as_deref(), Some("Q_KEY"));
2609    }
2610
2611    #[test]
2612    fn test_qdrant_config_serde_roundtrip() {
2613        let q = QdrantConfig {
2614            url: "http://remote:6334".into(),
2615            api_key_env: Some("MY_KEY".into()),
2616        };
2617        let decoded: QdrantConfig = toml::from_str(&toml::to_string(&q).unwrap()).unwrap();
2618        assert_eq!(decoded.url, "http://remote:6334");
2619        assert_eq!(decoded.api_key_env.as_deref(), Some("MY_KEY"));
2620    }
2621
2622    #[test]
2623    fn test_hybrid_weights_config_serde_roundtrip() {
2624        let hw = HybridWeightsConfig {
2625            semantic: 0.6,
2626            bm25: 0.25,
2627            fuzzy: 0.15,
2628        };
2629        let decoded: HybridWeightsConfig = toml::from_str(&toml::to_string(&hw).unwrap()).unwrap();
2630        assert!((decoded.semantic - 0.6).abs() < f32::EPSILON);
2631        assert!((decoded.bm25 - 0.25).abs() < f32::EPSILON);
2632        assert!((decoded.fuzzy - 0.15).abs() < f32::EPSILON);
2633    }
2634
2635    #[test]
2636    fn test_rag_vector_config_serde_roundtrip() {
2637        let v = RAGVectorConfig {
2638            enabled: true,
2639            embedding_model: "nomic-embed".into(),
2640            sparse_embeddings: true,
2641            sparse_model: "custom-sparse".into(),
2642            vector_path: "/data/vecs".into(),
2643        };
2644        let decoded: RAGVectorConfig = toml::from_str(&toml::to_string(&v).unwrap()).unwrap();
2645        assert!(decoded.enabled);
2646        assert_eq!(decoded.embedding_model, "nomic-embed");
2647        assert!(decoded.sparse_embeddings);
2648        assert_eq!(decoded.sparse_model, "custom-sparse");
2649        assert_eq!(decoded.vector_path, "/data/vecs");
2650    }
2651
2652    #[test]
2653    fn test_rag_chunking_config_serde_roundtrip() {
2654        let c = RagChunkingConfig {
2655            chunking_strategy: "semantic".into(),
2656            chunk_size: 500,
2657            chunk_overlap: 100,
2658            min_chunk_size: 50,
2659        };
2660        let decoded: RagChunkingConfig = toml::from_str(&toml::to_string(&c).unwrap()).unwrap();
2661        assert_eq!(decoded.chunking_strategy, "semantic");
2662        assert_eq!(decoded.chunk_size, 500);
2663        assert_eq!(decoded.chunk_overlap, 100);
2664        assert_eq!(decoded.min_chunk_size, 50);
2665    }
2666
2667    #[test]
2668    fn test_rag_search_config_serde_roundtrip() {
2669        let s = RagSearchConfig {
2670            search_strategy: "hybrid".into(),
2671            search_limit: 25,
2672            search_threshold: 0.5,
2673            hybrid_weights: Some(HybridWeightsConfig::default()),
2674        };
2675        let decoded: RagSearchConfig = toml::from_str(&toml::to_string(&s).unwrap()).unwrap();
2676        assert_eq!(decoded.search_strategy, "hybrid");
2677        assert_eq!(decoded.search_limit, 25);
2678        assert!(decoded.hybrid_weights.is_some());
2679    }
2680
2681    #[test]
2682    fn test_rag_reranking_config_serde_roundtrip() {
2683        let r = RagRerankingConfig {
2684            rerank_enabled: true,
2685            reranker_model: "jina-v2".into(),
2686            rerank_weight: 0.8,
2687        };
2688        let decoded: RagRerankingConfig = toml::from_str(&toml::to_string(&r).unwrap()).unwrap();
2689        assert!(decoded.rerank_enabled);
2690        assert_eq!(decoded.reranker_model, "jina-v2");
2691        assert!((decoded.rerank_weight - 0.8).abs() < f32::EPSILON);
2692    }
2693
2694    #[test]
2695    fn test_rag_config_serde_roundtrip() {
2696        let rag = RagConfig {
2697            vector: RAGVectorConfig {
2698                enabled: true,
2699                embedding_model: "test-model".into(),
2700                sparse_embeddings: true,
2701                sparse_model: "sparse-m".into(),
2702                vector_path: "/v".into(),
2703            },
2704            chunking: RagChunkingConfig {
2705                chunking_strategy: "semantic".into(),
2706                chunk_size: 300,
2707                chunk_overlap: 75,
2708                min_chunk_size: 30,
2709            },
2710            search: RagSearchConfig {
2711                search_strategy: "bm25".into(),
2712                search_limit: 5,
2713                search_threshold: 0.3,
2714                hybrid_weights: Some(HybridWeightsConfig {
2715                    semantic: 0.4,
2716                    bm25: 0.4,
2717                    fuzzy: 0.2,
2718                }),
2719            },
2720            rerank: RagRerankingConfig {
2721                rerank_enabled: true,
2722                reranker_model: "custom-reranker".into(),
2723                rerank_weight: 0.9,
2724            },
2725        };
2726        let decoded: RagConfig = toml::from_str(&toml::to_string(&rag).unwrap()).unwrap();
2727        assert!(decoded.vector.enabled);
2728        assert_eq!(decoded.chunking.chunk_size, 300);
2729        assert_eq!(decoded.search.search_strategy, "bm25");
2730        assert!(decoded.search.hybrid_weights.is_some());
2731        assert!(decoded.rerank.rerank_enabled);
2732    }
2733
2734    #[test]
2735    fn test_billing_config_serde_roundtrip_from_toml() {
2736        let content = r#"
2737[server]
2738[auth]
2739jwt_secret_env = "JWT"
2740api_key_env = "API"
2741[database]
2742[billing.model_pricing.gpt]
2743provider = "openai"
2744model = "gpt-4o"
2745input_usd_per_million_tokens = 2.5
2746output_usd_per_million_tokens = 10.0
2747currency = "USD"
2748[billing.model_pricing.free_tier]
2749provider = "openai"
2750model = "test-model"
2751input_usd_per_million_tokens = 0.0
2752output_usd_per_million_tokens = 0.0
2753"#;
2754        let config: AresConfig = toml::from_str(content).unwrap();
2755        assert_eq!(config.billing.model_pricing.len(), 2);
2756        let gpt = config.billing.pricing_for("openai", "gpt-4o").unwrap();
2757        assert!((gpt.input_usd_per_million_tokens.unwrap() - 2.5).abs() < f64::EPSILON);
2758        assert!((gpt.output_usd_per_million_tokens.unwrap() - 10.0).abs() < f64::EPSILON);
2759        let free = config.billing.pricing_for("openai", "test-model").unwrap();
2760        assert_eq!(free.currency, "USD");
2761    }
2762
2763    // ---- Pricing edge cases ----
2764
2765    #[test]
2766    fn test_pricing_key_whitespace_and_case() {
2767        let mut billing = BillingConfig::default();
2768        billing.model_pricing.insert(
2769            "e".into(),
2770            ModelPricingConfig {
2771                provider: "  OpenAI  ".into(),
2772                model: " GPT-4o ".into(),
2773                input_usd_per_million_tokens: Some(1.0),
2774                output_usd_per_million_tokens: Some(2.0),
2775                currency: "USD".into(),
2776            },
2777        );
2778        // pricing_key trims and lowercases, so these should all match
2779        assert!(billing.pricing_for("openai", "gpt-4o").is_some());
2780        assert!(billing.pricing_for("  OPENAI  ", "GPT-4O").is_some());
2781        assert!(billing.pricing_for("Openai", "Gpt-4O").is_some());
2782    }
2783
2784    #[test]
2785    fn test_pricing_for_no_match() {
2786        let mut billing = BillingConfig::default();
2787        billing.model_pricing.insert(
2788            "e".into(),
2789            ModelPricingConfig {
2790                provider: "openai".into(),
2791                model: "gpt-4o".into(),
2792                input_usd_per_million_tokens: None,
2793                output_usd_per_million_tokens: None,
2794                currency: "EUR".into(),
2795            },
2796        );
2797        assert!(billing.pricing_for("openai", "claude-3").is_none());
2798        assert!(billing.pricing_for("anthropic", "gpt-4o").is_none());
2799    }
2800
2801    #[test]
2802    fn test_model_pricing_config_serde_roundtrip() {
2803        let mp = ModelPricingConfig {
2804            provider: "openai".into(),
2805            model: "gpt-4o".into(),
2806            input_usd_per_million_tokens: Some(2.5),
2807            output_usd_per_million_tokens: Some(10.0),
2808            currency: "EUR".into(),
2809        };
2810        let decoded: ModelPricingConfig = toml::from_str(&toml::to_string(&mp).unwrap()).unwrap();
2811        assert_eq!(decoded.provider, "openai");
2812        assert_eq!(decoded.model, "gpt-4o");
2813        assert_eq!(decoded.currency, "EUR");
2814    }
2815
2816    // ---- Empty/minimal TOML parsing ----
2817
2818    #[test]
2819    fn test_empty_toml_parses_with_defaults() {
2820        let toml_str = "[server]\n[auth]\njwt_secret_env = \"TEST_JWT\"\napi_key_env = \"TEST_API\"\n[database]\n";
2821        let config: AresConfig = toml::from_str(toml_str).unwrap();
2822        assert_eq!(config.server.host, "127.0.0.1");
2823        assert_eq!(config.server.port, 3000);
2824        assert!(config.providers.is_empty());
2825        assert!(config.models.is_empty());
2826        assert!(config.tools.is_empty());
2827        assert!(config.agents.is_empty());
2828        assert!(config.workflows.is_empty());
2829    }
2830
2831    #[test]
2832    fn test_minimal_toml_with_only_server() {
2833        let toml_str = "[server]\nport = 9999\n\n[auth]\njwt_secret_env = \"TEST_JWT\"\napi_key_env = \"TEST_API\"\n\n[database]\n";
2834        let config: AresConfig = toml::from_str(toml_str).unwrap();
2835        assert_eq!(config.server.port, 9999);
2836        assert_eq!(config.server.host, "127.0.0.1");
2837    }
2838
2839    // ---- ProviderConfig type_name completeness ----
2840
2841    // ---- FromStr edge cases ----
2842
2843    #[test]
2844    fn test_provider_config_from_str_whitespace() {
2845        let p: ProviderConfig = "  openai  ".parse().unwrap();
2846        assert_eq!(p.type_name(), "openai");
2847    }
2848
2849    #[test]
2850    fn test_provider_config_from_str_empty() {
2851        let result = "".parse::<ProviderConfig>();
2852        assert!(result.is_err());
2853    }
2854
2855    // ---- validate_with_warnings error path ----
2856
2857    #[test]
2858    fn test_validate_with_warnings_error_propagation() {
2859        let content = r#"
2860[server]
2861[auth]
2862jwt_secret_env = "TEST_JWT_SECRET"
2863api_key_env = "TEST_API_KEY"
2864[database]
2865[models.bad]
2866provider = "nonexistent"
2867model = "x"
2868"#;
2869        let config: AresConfig = toml::from_str(content).unwrap();
2870        // Should return error, not warnings
2871        assert!(config.validate_with_warnings().is_err());
2872    }
2873
2874    // ---- Multiple models referencing same provider ----
2875
2876    #[test]
2877    fn test_multiple_models_same_provider() {
2878        set_test_env();
2879        let content = r#"
2880[server]
2881[auth]
2882jwt_secret_env = "TEST_JWT_SECRET"
2883api_key_env = "TEST_API_KEY"
2884[database]
2885[providers.p]
2886type = "openai"
2887api_key_env = "TEST_KEY"
2888api_base = "https://test.example.com/v1"
2889default_model = "m1"
2890[models.m1]
2891provider = "p"
2892model = "m1"
2893[models.m2]
2894provider = "p"
2895model = "m2"
2896[agents.a1]
2897model = "m1"
2898[workflows.w]
2899entry_agent = "a1"
2900"#;
2901        let config: AresConfig = toml::from_str(content).unwrap();
2902        assert!(config.validate().is_ok());
2903        // m2 model is unused (only a1 referenced in workflow), should warn
2904        let warnings = config.validate_with_warnings().unwrap();
2905        assert!(warnings
2906            .iter()
2907            .any(|w| w.kind == ConfigWarningKind::UnusedModel && w.message.contains("m2")));
2908    }
2909
2910    // ---- ToolConfig with extra (flatten) fields from TOML ----
2911
2912    #[test]
2913    fn test_tool_config_with_extra_fields_from_toml() {
2914        let content = r#"
2915[server]
2916[auth]
2917jwt_secret_env = "JWT"
2918api_key_env = "API"
2919[database]
2920[tools.my_tool]
2921enabled = true
2922timeout_secs = 60
2923description = "Custom tool"
2924custom_param = "hello"
2925num_param = 42
2926"#;
2927        let config: AresConfig = toml::from_str(content).unwrap();
2928        let tool = config.get_tool("my_tool").unwrap();
2929        assert!(tool.enabled);
2930        assert_eq!(tool.timeout_secs, 60);
2931        assert_eq!(tool.description.as_deref(), Some("Custom tool"));
2932        assert_eq!(
2933            tool.extra.get("custom_param").and_then(|v| v.as_str()),
2934            Some("hello")
2935        );
2936        assert_eq!(
2937            tool.extra.get("num_param").and_then(|v| v.as_integer()),
2938            Some(42)
2939        );
2940    }
2941
2942    // ---- AgentConfig with extra (flatten) fields from TOML ----
2943
2944    #[test]
2945    fn test_agent_config_with_extra_fields_from_toml() {
2946        let content = r#"
2947[server]
2948[auth]
2949jwt_secret_env = "JWT"
2950api_key_env = "API"
2951[database]
2952[providers.p]
2953type = "openai"
2954api_key_env = "TEST_KEY"
2955api_base = "https://test.example.com/v1"
2956default_model = "m"
2957[models.m]
2958provider = "p"
2959model = "m"
2960[agents.my_agent]
2961model = "m"
2962custom_bool = true
2963"#;
2964        let config: AresConfig = toml::from_str(content).unwrap();
2965        let agent = config.get_agent("my_agent").unwrap();
2966        assert_eq!(agent.model, "m");
2967        assert_eq!(
2968            agent.extra.get("custom_bool").and_then(|v| v.as_bool()),
2969            Some(true)
2970        );
2971    }
2972
2973    // ---- AresConfig serde roundtrip ----
2974
2975    #[test]
2976    fn test_ares_config_serde_roundtrip() {
2977        let content = create_test_config();
2978        let config: AresConfig = toml::from_str(&content).unwrap();
2979        let serialized = toml::to_string(&config).unwrap();
2980        let decoded: AresConfig = toml::from_str(&serialized).unwrap();
2981        assert_eq!(decoded.server.host, config.server.host);
2982        assert_eq!(decoded.server.port, config.server.port);
2983        assert_eq!(decoded.providers.len(), config.providers.len());
2984        assert_eq!(decoded.models.len(), config.models.len());
2985        assert_eq!(decoded.tools.len(), config.tools.len());
2986        assert_eq!(decoded.agents.len(), config.agents.len());
2987        assert_eq!(decoded.workflows.len(), config.workflows.len());
2988    }
2989
2990    // ---- QdrantConfig parsed from full AresConfig TOML ----
2991
2992    #[test]
2993    fn test_database_qdrant_parsed_from_toml() {
2994        let content = r#"
2995[server]
2996[auth]
2997jwt_secret_env = "JWT"
2998api_key_env = "API"
2999[database]
3000url = "postgres://host/db"
3001[database.qdrant]
3002url = "http://qdrant:6333"
3003api_key_env = "Q_KEY"
3004"#;
3005        let config: AresConfig = toml::from_str(content).unwrap();
3006        assert!(config.database.qdrant.is_some());
3007        let q = config.database.qdrant.unwrap();
3008        assert_eq!(q.url, "http://qdrant:6333");
3009        assert_eq!(q.api_key_env.as_deref(), Some("Q_KEY"));
3010    }
3011
3012    // ---- ConfigWarning Display covers the message field ----
3013
3014    #[test]
3015    fn test_config_warning_display_full_message() {
3016        let w = ConfigWarning {
3017            kind: ConfigWarningKind::UnusedTool,
3018            message: "Tool 'xyz' is defined but not referenced by any agent".into(),
3019        };
3020        assert_eq!(
3021            w.to_string(),
3022            "Tool 'xyz' is defined but not referenced by any agent"
3023        );
3024    }
3025
3026    // ---- DynamicConfigPaths parsed from TOML sub-table ----
3027
3028    #[test]
3029    fn test_dynamic_config_paths_partial_override() {
3030        let content = r#"
3031[server]
3032[auth]
3033jwt_secret_env = "JWT"
3034api_key_env = "API"
3035[database]
3036[config]
3037agents_dir = "/only/agents"
3038"#;
3039        let config: AresConfig = toml::from_str(content).unwrap();
3040        assert_eq!(config.config.agents_dir, Path::new("/only/agents"));
3041        // Others should be defaults
3042        assert_eq!(config.config.workflows_dir, Path::new("config/workflows"));
3043        assert_eq!(config.config.models_dir, Path::new("config/models"));
3044        assert!(config.config.hot_reload);
3045    }
3046
3047    // ---- Validate: enabled tool in agent_tools ----
3048
3049    #[test]
3050    fn test_agent_tools_with_no_tools_agent() {
3051        set_test_env();
3052        let content = r#"
3053[server]
3054[auth]
3055jwt_secret_env = "TEST_JWT_SECRET"
3056api_key_env = "TEST_API_KEY"
3057[database]
3058[providers.p]
3059type = "openai"
3060api_key_env = "TEST_KEY"
3061api_base = "https://test.example.com/v1"
3062default_model = "m"
3063[models.m]
3064provider = "p"
3065model = "m"
3066[agents.a]
3067model = "m"
3068[workflows.w]
3069entry_agent = "a"
3070"#;
3071        let config: AresConfig = toml::from_str(content).unwrap();
3072        assert!(config.agent_tools("a").is_empty());
3073    }
3074
3075    // ---- Pricing with None token costs ----
3076
3077    #[test]
3078    fn test_model_pricing_none_costs() {
3079        let mp = ModelPricingConfig {
3080            provider: "p".into(),
3081            model: "m".into(),
3082            input_usd_per_million_tokens: None,
3083            output_usd_per_million_tokens: None,
3084            currency: "USD".into(),
3085        };
3086        let toml_str = toml::to_string(&mp).unwrap();
3087        let decoded: ModelPricingConfig = toml::from_str(&toml_str).unwrap();
3088        assert!(decoded.input_usd_per_million_tokens.is_none());
3089        assert!(decoded.output_usd_per_million_tokens.is_none());
3090    }
3091
3092    // ---- ConfigManager Clone ----
3093
3094    #[test]
3095    fn test_config_manager_clone_reads_same_config() {
3096        let config = toml::from_str(
3097            r#"
3098[server]
3099port = 42
3100[auth]
3101jwt_secret_env = "JWT"
3102api_key_env = "API"
3103[database]
3104"#,
3105        )
3106        .unwrap();
3107        let manager = AresConfigManager::from_config(config);
3108        let cloned = manager.clone();
3109        assert_eq!(manager.config().server.port, cloned.config().server.port);
3110    }
3111
3112    // ---- RAG sub-config defaults completeness ----
3113
3114    #[test]
3115    fn test_rag_search_default_threshold() {
3116        let s = RagSearchConfig::default();
3117        assert!((s.search_threshold).abs() < f32::EPSILON);
3118    }
3119
3120    // ---- LlamaCpp defaults from FromStr ----
3121
3122    #[test]
3123    fn test_provider_config_from_str_openai_defaults() {
3124        // After the NVIDIA-only refactor, the `openai` and `nvidia`
3125        // literals both parse to the same default OpenAI-compatible
3126        // provider pointing at the NVIDIA NIM catalog. Overriding any of
3127        // these from the TOML is still possible via the [nvidia] section
3128        // and per-agent model fields.
3129        let p: ProviderConfig = "openai".parse().unwrap();
3130        if let ProviderConfig::OpenAI {
3131            api_key_env,
3132            api_base,
3133            default_model,
3134        } = p
3135        {
3136            assert_eq!(api_key_env, "NVIDIA_API_KEY");
3137            assert_eq!(api_base, "https://integrate.api.nvidia.com/v1");
3138            assert_eq!(default_model, "nvidia/nemotron-3-ultra-550b-a55b");
3139        } else {
3140            panic!("expected openai variant");
3141        }
3142    }
3143
3144    #[test]
3145    fn test_tool_config_default_struct() {
3146        let tool = ToolConfig::default();
3147        assert!(tool.enabled);
3148        assert!(tool.description.is_none());
3149        assert_eq!(tool.timeout_secs, 30);
3150        assert!(tool.extra.is_empty());
3151    }
3152
3153    #[test]
3154    fn test_qdrant_config_default_struct() {
3155        let qdrant = QdrantConfig::default();
3156        assert_eq!(qdrant.url, "http://localhost:6334");
3157        assert!(qdrant.api_key_env.is_none());
3158    }
3159
3160    #[test]
3161    fn ares_config_still_deserializes_from_toml_config_test_fixture() {
3162        let config: AresConfig =
3163            toml::from_str(&create_test_config()).expect("fixture should parse");
3164        assert_eq!(config.server.port, 3000);
3165        assert_eq!(config.server.host, "127.0.0.1");
3166        assert_eq!(config.auth.jwt_secret_env, "TEST_JWT_SECRET");
3167        assert_eq!(config.database.url, "./data/test.db");
3168        assert!(config.agents.contains_key("router"));
3169        assert!(config.tools.contains_key("calculator"));
3170        assert!(!config.billing.model_pricing.is_empty());
3171    }
3172
3173    #[test]
3174    fn test_ares_config_load_success() {
3175        set_test_env();
3176        let dir = std::env::temp_dir().join("ares_load_success_test");
3177        std::fs::create_dir_all(&dir).unwrap();
3178        let path = dir.join("ares.toml");
3179        std::fs::write(&path, create_test_config()).unwrap();
3180
3181        let config = AresConfig::load(&path).expect("load should succeed");
3182        assert_eq!(config.server.port, 3000);
3183        assert!(config.providers.contains_key("ollama-local"));
3184
3185        std::fs::remove_dir_all(&dir).ok();
3186    }
3187}