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, RagChunkingConfig, RagConfig, RagRerankingConfig, RagSearchConfig,
29    RAGVectorConfig,
30};
31pub use ares_store::{BillingConfig, DatabaseConfig, ModelPricingConfig, QdrantConfig};
32pub use ares_tools::ToolConfig;
33use ares_store::default_qdrant_url;
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 { "ares_config_manager" }
805    fn init(&self, _ctx: &std::sync::Arc<cordis::Context>) -> cordis::ServiceInitFuture<'_> {
806        Box::pin(async { Ok(None) })
807    }
808    fn check(&self) -> bool { true }
809}
810
811/// HTTP-adjacent config overlay (same type as [`AresConfigManager`]).
812pub type Overlay = AresConfigManager;
813
814/// Loader config for the Overlay plugin.
815#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
816pub struct OverlayConfig {
817    /// Path to `ares.toml`.
818    #[serde(default = "default_overlay_toml_path", alias = "toml_path")]
819    pub toml_path: PathBuf,
820}
821
822
823fn default_overlay_toml_path() -> PathBuf {
824    PathBuf::from("ares.toml")
825}
826
827impl Default for OverlayConfig {
828    fn default() -> Self {
829        Self {
830            toml_path: default_overlay_toml_path(),
831        }
832    }
833}
834
835fn entry_config_is_empty(config: &serde_json::Value) -> bool {
836    match config {
837        serde_json::Value::Null => true,
838        serde_json::Value::Object(map) => map.is_empty(),
839        serde_json::Value::Array(arr) => arr.is_empty(),
840        _ => false,
841    }
842}
843
844/// Map a loader plugin key to the matching `ares.toml` section value.
845fn overlay_value_for_plugin(plugin: &str, cfg: &AresConfig) -> Option<serde_json::Value> {
846    match plugin {
847        "Http" => serde_json::to_value(&cfg.server).ok(),
848        "AuthService" => serde_json::to_value(&cfg.auth).ok(),
849        "Store" => serde_json::to_value(&cfg.database).ok(),
850        "Tools" => serde_json::to_value(&cfg.tools).ok(),
851        "Llm" => Some(serde_json::json!({
852            "providers": cfg.providers,
853            "models": cfg.models,
854            "nvidia": cfg.nvidia,
855        })),
856        "Execute" => serde_json::to_value(&cfg.agents).ok(),
857        _ => None,
858    }
859}
860
861fn path_is_toml(path: &Path, overlay_path: &Path) -> bool {
862    path.extension().is_some_and(|ext| ext == "toml")
863        || path == overlay_path
864        || path.file_name() == overlay_path.file_name()
865}
866
867fn path_is_toon(path: &Path) -> bool {
868    path.extension().is_some_and(|ext| ext == "toon")
869}
870
871impl Overlay {
872    /// Single Cordis watch for `ares.toml` plus TOON dirs.
873    ///
874    /// Uses `watch_many_with` (no second notify stack). `ares.toml` changes
875    /// reload this overlay and notify `TypeId::of::<Overlay>()`; TOON changes
876    /// reload [`crate::toon_config::DynamicConfigManager`] and notify Tools
877    /// and Execute TypeIds.
878    pub fn watch_cordis(&self, ctx: &std::sync::Arc<cordis::Context>) -> Result<(), ConfigError> {
879        *self.watch_ctx.write() = Some(Arc::clone(ctx));
880        if self.cordis_watch.read().is_some() {
881            return Ok(());
882        }
883
884        let Some(reflect) = ctx.get::<cordis::ReflectService>() else {
885            return Ok(());
886        };
887        if tokio::runtime::Handle::try_current().is_err() {
888            return Ok(());
889        }
890
891        let overlay_path = self.config_path.clone();
892        let config_store = Arc::clone(&self.config);
893        let snapshot = self.config();
894        let paths = vec![
895            overlay_path.clone(),
896            snapshot.config.agents_dir.clone(),
897            snapshot.config.models_dir.clone(),
898            snapshot.config.tools_dir.clone(),
899            snapshot.config.workflows_dir.clone(),
900            snapshot.config.mcps_dir.clone(),
901        ];
902
903        let on_change: cordis::watcher::WatchOnChange = Arc::new(move |c, path| {
904            if path_is_toml(path, &overlay_path) {
905                match AresConfig::load(&overlay_path) {
906                    Ok(new_config) => {
907                        config_store.store(Arc::new(new_config));
908                        info!("Configuration hot-reloaded successfully");
909                    }
910                    Err(e) => {
911                        warn!(
912                            "Failed to hot-reload config: {}. Keeping previous config.",
913                            e
914                        );
915                    }
916                }
917            }
918            if path_is_toon(path) {
919                if let Some(dynamic) = c.get::<crate::toon_config::DynamicConfigManager>() {
920                    match dynamic.reload() {
921                        Ok(_) => info!("TOON configuration reloaded"),
922                        Err(e) => warn!("Failed to reload TOON config: {e}"),
923                    }
924                }
925                crate::toon_config::notify_tools_and_execute(c);
926            }
927        });
928
929        let handle = cordis::watcher::watch_many_with(
930            Arc::clone(ctx),
931            reflect,
932            paths,
933            TypeId::of::<Overlay>(),
934            on_change,
935        )?;
936        *self.cordis_watch.write() = Some(handle);
937        Ok(())
938    }
939
940    /// Fill empty cordis-entry configs from `ares.toml` sections.
941    ///
942    /// Non-empty loader `entry.config` values are left unchanged.
943    pub fn fill_empty_entry_configs(&self, tree: &mut cordis::EntryTree) {
944        let cfg = self.config();
945        for entry in &mut tree.0 {
946            if !entry_config_is_empty(&entry.config) {
947                continue;
948            }
949            if let Some(value) = overlay_value_for_plugin(entry.plugin.as_str(), &cfg) {
950                entry.config = value;
951            }
952        }
953    }
954
955}
956
957/// Typed installer for [`Overlay`].
958pub struct OverlayPlugin;
959
960
961impl cordis::Plugin for OverlayPlugin {
962    type Config = OverlayConfig;
963    type Provides = Overlay;
964
965    fn apply(
966        &self,
967        ctx: &std::sync::Arc<cordis::Context>,
968        config: Self::Config,
969    ) -> std::result::Result<std::sync::Arc<Overlay>, cordis::CordisError> {
970        let overlay = Overlay::new(&config.toml_path)
971            .map_err(|e| cordis::CordisError::Configuration(e.to_string()))?;
972        overlay
973            .watch_cordis(ctx)
974            .map_err(|e| cordis::CordisError::Configuration(e.to_string()))?;
975        Ok(std::sync::Arc::new(overlay))
976    }
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    fn create_test_config() -> String {
984        r#"
985[server]
986host = "127.0.0.1"
987port = 3000
988log_level = "debug"
989
990[auth]
991jwt_secret_env = "TEST_JWT_SECRET"
992jwt_access_expiry = 900
993jwt_refresh_expiry = 604800
994api_key_env = "TEST_API_KEY"
995
996[database]
997url = "./data/test.db"
998
999[providers.ollama-local]
1000type = "openai"
1001api_key_env = "TEST_KEY"
1002api_base = "https://test.example.com/v1"
1003default_model = "ministral-3:3b"
1004
1005[models.default]
1006provider = "ollama-local"
1007model = "ministral-3:3b"
1008temperature = 0.7
1009max_tokens = 512
1010
1011[billing.model_pricing.test_default]
1012provider = "ollama-local"
1013model = "ministral-3:3b"
1014input_usd_per_million_tokens = 0.0
1015output_usd_per_million_tokens = 0.0
1016
1017[tools.calculator]
1018enabled = true
1019description = "Basic calculator"
1020timeout_secs = 10
1021
1022[agents.router]
1023model = "default"
1024tools = []
1025max_tool_iterations = 5
1026
1027[workflows.default]
1028entry_agent = "router"
1029max_depth = 3
1030max_iterations = 5
1031"#
1032        .to_string()
1033    }
1034
1035    #[test]
1036    fn test_parse_config() {
1037        // Set required env vars for validation
1038        // SAFETY: Tests are run single-threaded for env var safety
1039        unsafe {
1040            std::env::set_var(
1041                "TEST_JWT_SECRET",
1042                "test-secret-at-least-32-characters-long-at-least-32-characters-long",
1043            );
1044            std::env::set_var("TEST_API_KEY", "test-api-key");
1045        }
1046
1047        let content = create_test_config();
1048        let config: AresConfig = toml::from_str(&content).expect("Failed to parse config");
1049
1050        assert_eq!(config.server.host, "127.0.0.1");
1051        assert_eq!(config.server.port, 3000);
1052        assert!(config.providers.contains_key("ollama-local"));
1053        assert!(config.models.contains_key("default"));
1054        assert!(config.agents.contains_key("router"));
1055        assert!(config
1056            .billing
1057            .pricing_for(" OLLAMA-LOCAL ", "ministral-3:3b")
1058            .is_some());
1059    }
1060
1061    #[test]
1062    fn test_validation_missing_provider() {
1063        // SAFETY: Tests are run single-threaded for env var safety
1064        unsafe {
1065            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1066            std::env::set_var("TEST_API_KEY", "test-key");
1067        }
1068
1069        let content = r#"
1070[server]
1071[auth]
1072jwt_secret_env = "TEST_JWT_SECRET"
1073api_key_env = "TEST_API_KEY"
1074[database]
1075[models.test]
1076provider = "nonexistent"
1077model = "test"
1078"#;
1079
1080        let config: AresConfig = toml::from_str(content).unwrap();
1081        let result = config.validate();
1082
1083        assert!(matches!(result, Err(ConfigError::MissingProvider(_, _))));
1084    }
1085
1086    #[test]
1087    fn test_validation_missing_model() {
1088        // SAFETY: Tests are run single-threaded for env var safety
1089        unsafe {
1090            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1091            std::env::set_var("TEST_API_KEY", "test-key");
1092            std::env::set_var("TEST_KEY", "test-provider-key");
1093        }
1094
1095        let content = r#"
1096[server]
1097[auth]
1098jwt_secret_env = "TEST_JWT_SECRET"
1099api_key_env = "TEST_API_KEY"
1100[database]
1101[nvidia]
1102api_key_env = "TEST_KEY"
1103api_base = "https://test.example.com/v1"
1104default_model = "ministral-3:3b"
1105[agents.test]
1106model = "nonexistent"
1107"#;
1108
1109        let config: AresConfig = toml::from_str(content).unwrap();
1110        // With the dynamic NVIDIA catalog, agent models are resolved at
1111        // runtime against the live catalog. Missing references produce a
1112        // warning during validation, not a hard error — so the server
1113        // stays up even when the live catalog rotates a model out.
1114        let result = config.validate();
1115        assert!(result.is_ok(), "missing-model should warn, not fail: {:?}", result);
1116    }
1117
1118    #[test]
1119    fn test_validation_missing_tool() {
1120        // SAFETY: Tests are run single-threaded for env var safety
1121        unsafe {
1122            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1123            std::env::set_var("TEST_API_KEY", "test-key");
1124        }
1125
1126        let content = r#"
1127[server]
1128[auth]
1129jwt_secret_env = "TEST_JWT_SECRET"
1130api_key_env = "TEST_API_KEY"
1131[database]
1132[providers.test]
1133type = "openai"
1134api_key_env = "TEST_KEY"
1135api_base = "https://test.example.com/v1"
1136default_model = "ministral-3:3b"
1137[models.default]
1138provider = "test"
1139model = "ministral-3:3b"
1140[agents.test]
1141model = "default"
1142tools = ["nonexistent_tool"]
1143"#;
1144
1145        let config: AresConfig = toml::from_str(content).unwrap();
1146        let result = config.validate();
1147
1148        assert!(matches!(result, Err(ConfigError::MissingTool(_, _))));
1149    }
1150
1151    #[test]
1152    fn test_validation_missing_workflow_agent() {
1153        // SAFETY: Tests are run single-threaded for env var safety
1154        unsafe {
1155            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1156            std::env::set_var("TEST_API_KEY", "test-key");
1157        }
1158
1159        let content = r#"
1160[server]
1161[auth]
1162jwt_secret_env = "TEST_JWT_SECRET"
1163api_key_env = "TEST_API_KEY"
1164[database]
1165[workflows.test]
1166entry_agent = "nonexistent_agent"
1167"#;
1168
1169        let config: AresConfig = toml::from_str(content).unwrap();
1170        let result = config.validate();
1171
1172        assert!(matches!(result, Err(ConfigError::MissingAgent(_, _))));
1173    }
1174
1175    #[test]
1176    fn test_get_provider() {
1177        let content = create_test_config();
1178        let config: AresConfig = toml::from_str(&content).unwrap();
1179
1180        assert!(config.get_provider("ollama-local").is_some());
1181        assert!(config.get_provider("nonexistent").is_none());
1182    }
1183
1184    #[test]
1185    fn test_get_model() {
1186        let content = create_test_config();
1187        let config: AresConfig = toml::from_str(&content).unwrap();
1188
1189        assert!(config.get_model("default").is_some());
1190        assert!(config.get_model("nonexistent").is_none());
1191    }
1192
1193    #[test]
1194    fn test_get_agent() {
1195        let content = create_test_config();
1196        let config: AresConfig = toml::from_str(&content).unwrap();
1197
1198        assert!(config.get_agent("router").is_some());
1199        assert!(config.get_agent("nonexistent").is_none());
1200    }
1201
1202    #[test]
1203    fn test_get_tool() {
1204        let content = create_test_config();
1205        let config: AresConfig = toml::from_str(&content).unwrap();
1206
1207        assert!(config.get_tool("calculator").is_some());
1208        assert!(config.get_tool("nonexistent").is_none());
1209    }
1210
1211    #[test]
1212    fn test_enabled_tools() {
1213        let content = r#"
1214[server]
1215[auth]
1216jwt_secret_env = "TEST_JWT_SECRET"
1217api_key_env = "TEST_API_KEY"
1218[database]
1219[tools.enabled_tool]
1220enabled = true
1221[tools.disabled_tool]
1222enabled = false
1223"#;
1224
1225        let config: AresConfig = toml::from_str(content).unwrap();
1226        let enabled = config.enabled_tools();
1227
1228        assert!(enabled.contains(&"enabled_tool"));
1229        assert!(!enabled.contains(&"disabled_tool"));
1230    }
1231
1232    #[test]
1233    fn test_defaults() {
1234        let content = r#"
1235[server]
1236[auth]
1237jwt_secret_env = "TEST_JWT_SECRET"
1238api_key_env = "TEST_API_KEY"
1239[database]
1240"#;
1241
1242        let config: AresConfig = toml::from_str(content).unwrap();
1243
1244        // Server defaults
1245        assert_eq!(config.server.host, "127.0.0.1");
1246        assert_eq!(config.server.port, 3000);
1247        assert_eq!(config.server.log_level, "info");
1248
1249        // Auth defaults
1250        assert_eq!(config.auth.jwt_access_expiry, 900);
1251        assert_eq!(config.auth.jwt_refresh_expiry, 604800);
1252
1253        // Database defaults
1254        assert_eq!(
1255            config.database.url,
1256            "postgres://postgres:postgres@localhost:5432/ares"
1257        );
1258
1259        // RAG defaults
1260        assert_eq!(config.rag.vector.embedding_model, "bge-small-en-v1.5");
1261        assert_eq!(config.rag.vector.vector_path, "./data/vectors");
1262        assert_eq!(config.rag.chunking.chunk_size, 200);
1263        assert_eq!(config.rag.chunking.chunk_overlap, 50);
1264        assert_eq!(config.rag.search.search_strategy, "semantic");
1265    }
1266
1267    #[test]
1268    fn fill_empty_entry_configs_copies_only_when_empty() {
1269        let content = create_test_config();
1270        let config: AresConfig = toml::from_str(&content).expect("parse test config");
1271        let overlay = Overlay::from_config(config);
1272
1273        let mut tree = cordis::EntryTree(vec![
1274            cordis::Entry {
1275                id: "http-empty".into(),
1276                plugin: "Http".into(),
1277                config: serde_json::json!({}),
1278                ..Default::default()
1279            },
1280            cordis::Entry {
1281                id: "http-kept".into(),
1282                plugin: "Http".into(),
1283                config: serde_json::json!({"host": "keep.example", "port": 9}),
1284                ..Default::default()
1285            },
1286            cordis::Entry {
1287                id: "store-null".into(),
1288                plugin: "Store".into(),
1289                config: serde_json::Value::Null,
1290                ..Default::default()
1291            },
1292            cordis::Entry {
1293                id: "tools-empty".into(),
1294                plugin: "Tools".into(),
1295                config: serde_json::json!({}),
1296                ..Default::default()
1297            },
1298            cordis::Entry {
1299                id: "tools-kept".into(),
1300                plugin: "Tools".into(),
1301                config: serde_json::json!({"calculator": {"enabled": false}}),
1302                ..Default::default()
1303            },
1304            cordis::Entry {
1305                id: "llm-empty".into(),
1306                plugin: "Llm".into(),
1307                config: serde_json::Value::Null,
1308                ..Default::default()
1309            },
1310            cordis::Entry {
1311                id: "execute-empty".into(),
1312                plugin: "Execute".into(),
1313                config: serde_json::json!([]),
1314                ..Default::default()
1315            },
1316            cordis::Entry {
1317                id: "auth-empty".into(),
1318                plugin: "AuthService".into(),
1319                config: serde_json::json!({}),
1320                ..Default::default()
1321            },
1322        ]);
1323
1324        overlay.fill_empty_entry_configs(&mut tree);
1325
1326        assert_eq!(tree.0[0].config["host"], "127.0.0.1");
1327        assert_eq!(tree.0[0].config["port"], 3000);
1328        assert_eq!(tree.0[1].config["host"], "keep.example");
1329        assert_eq!(tree.0[1].config["port"], 9);
1330        assert_eq!(tree.0[2].config["url"], "./data/test.db");
1331        assert!(
1332            tree.0[3].config.get("calculator").is_some(),
1333            "empty Tools config should receive ares.toml tools map"
1334        );
1335        assert_eq!(tree.0[4].config["calculator"]["enabled"], false);
1336        assert!(tree.0[5].config.get("providers").and_then(|v| v.get("ollama-local")).is_some());
1337        assert!(tree.0[6].config.get("router").is_some());
1338        assert_eq!(tree.0[7].config["jwt_secret_env"], "TEST_JWT_SECRET");
1339    }
1340
1341    #[test]
1342    fn test_config_manager_from_config() {
1343        let content = create_test_config();
1344        let config: AresConfig = toml::from_str(&content).unwrap();
1345
1346        let manager = AresConfigManager::from_config(config.clone());
1347        let loaded = manager.config();
1348
1349        assert_eq!(loaded.server.host, config.server.host);
1350        assert_eq!(loaded.server.port, config.server.port);
1351    }
1352
1353    #[test]
1354    fn test_circular_reference_detection() {
1355        // SAFETY: Tests are run single-threaded for env var safety
1356        unsafe {
1357            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1358            std::env::set_var("TEST_API_KEY", "test-key");
1359        }
1360
1361        let content = r#"
1362[server]
1363[auth]
1364jwt_secret_env = "TEST_JWT_SECRET"
1365api_key_env = "TEST_API_KEY"
1366[database]
1367[providers.test]
1368type = "openai"
1369api_key_env = "TEST_KEY"
1370api_base = "https://test.example.com/v1"
1371default_model = "ministral-3:3b"
1372[models.default]
1373provider = "test"
1374model = "ministral-3:3b"
1375[agents.agent_a]
1376model = "default"
1377[workflows.circular]
1378entry_agent = "agent_a"
1379fallback_agent = "agent_a"
1380"#;
1381
1382        let config: AresConfig = toml::from_str(content).unwrap();
1383        let result = config.validate();
1384
1385        assert!(matches!(result, Err(ConfigError::CircularReference(_))));
1386    }
1387
1388    #[test]
1389    fn test_unused_provider_warning() {
1390        // SAFETY: Tests are run single-threaded for env var safety
1391        unsafe {
1392            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1393            std::env::set_var("TEST_API_KEY", "test-key");
1394        }
1395
1396        let content = r#"
1397[server]
1398[auth]
1399jwt_secret_env = "TEST_JWT_SECRET"
1400api_key_env = "TEST_API_KEY"
1401[database]
1402[providers.used]
1403type = "openai"
1404api_key_env = "TEST_KEY"
1405api_base = "https://test.example.com/v1"
1406default_model = "ministral-3:3b"
1407[providers.unused]
1408type = "openai"
1409api_key_env = "TEST_KEY"
1410api_base = "https://test.example.com/v1"
1411default_model = "ministral-3:3b"
1412[models.default]
1413provider = "used"
1414model = "ministral-3:3b"
1415[agents.router]
1416model = "default"
1417"#;
1418
1419        let config: AresConfig = toml::from_str(content).unwrap();
1420        let warnings = config.validate_with_warnings().unwrap();
1421
1422        assert!(warnings
1423            .iter()
1424            .any(|w| w.kind == ConfigWarningKind::UnusedProvider && w.message.contains("unused")));
1425    }
1426
1427    #[test]
1428    fn test_unused_model_warning() {
1429        // SAFETY: Tests are run single-threaded for env var safety
1430        unsafe {
1431            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1432            std::env::set_var("TEST_API_KEY", "test-key");
1433        }
1434
1435        let content = r#"
1436[server]
1437[auth]
1438jwt_secret_env = "TEST_JWT_SECRET"
1439api_key_env = "TEST_API_KEY"
1440[database]
1441[providers.test]
1442type = "openai"
1443api_key_env = "TEST_KEY"
1444api_base = "https://test.example.com/v1"
1445default_model = "ministral-3:3b"
1446[models.used]
1447provider = "test"
1448model = "ministral-3:3b"
1449[models.unused]
1450provider = "test"
1451model = "other"
1452[agents.router]
1453model = "used"
1454"#;
1455
1456        let config: AresConfig = toml::from_str(content).unwrap();
1457        let warnings = config.validate_with_warnings().unwrap();
1458
1459        assert!(warnings
1460            .iter()
1461            .any(|w| w.kind == ConfigWarningKind::UnusedModel && w.message.contains("unused")));
1462    }
1463
1464    #[test]
1465    fn test_unused_tool_warning() {
1466        // SAFETY: Tests are run single-threaded for env var safety
1467        unsafe {
1468            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1469            std::env::set_var("TEST_API_KEY", "test-key");
1470        }
1471
1472        let content = r#"
1473[server]
1474[auth]
1475jwt_secret_env = "TEST_JWT_SECRET"
1476api_key_env = "TEST_API_KEY"
1477[database]
1478[providers.test]
1479type = "openai"
1480api_key_env = "TEST_KEY"
1481api_base = "https://test.example.com/v1"
1482default_model = "ministral-3:3b"
1483[models.default]
1484provider = "test"
1485model = "ministral-3:3b"
1486[tools.used_tool]
1487enabled = true
1488[tools.unused_tool]
1489enabled = true
1490[agents.router]
1491model = "default"
1492tools = ["used_tool"]
1493"#;
1494
1495        let config: AresConfig = toml::from_str(content).unwrap();
1496        let warnings = config.validate_with_warnings().unwrap();
1497
1498        assert!(warnings
1499            .iter()
1500            .any(|w| w.kind == ConfigWarningKind::UnusedTool && w.message.contains("unused_tool")));
1501    }
1502
1503    #[test]
1504    fn test_unused_agent_warning() {
1505        // SAFETY: Tests are run single-threaded for env var safety
1506        unsafe {
1507            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1508            std::env::set_var("TEST_API_KEY", "test-key");
1509        }
1510
1511        let content = r#"
1512[server]
1513[auth]
1514jwt_secret_env = "TEST_JWT_SECRET"
1515api_key_env = "TEST_API_KEY"
1516[database]
1517[providers.test]
1518type = "openai"
1519api_key_env = "TEST_KEY"
1520api_base = "https://test.example.com/v1"
1521default_model = "ministral-3:3b"
1522[models.default]
1523provider = "test"
1524model = "ministral-3:3b"
1525[agents.router]
1526model = "default"
1527[agents.orphaned]
1528model = "default"
1529[workflows.test_flow]
1530entry_agent = "router"
1531"#;
1532
1533        let config: AresConfig = toml::from_str(content).unwrap();
1534        let warnings = config.validate_with_warnings().unwrap();
1535
1536        assert!(warnings
1537            .iter()
1538            .any(|w| w.kind == ConfigWarningKind::UnusedAgent && w.message.contains("orphaned")));
1539    }
1540
1541    #[test]
1542    fn test_no_warnings_for_fully_connected_config() {
1543        // SAFETY: Tests are run single-threaded for env var safety
1544        unsafe {
1545            std::env::set_var("TEST_JWT_SECRET", "test-secret-at-least-32-characters-long");
1546            std::env::set_var("TEST_API_KEY", "test-key");
1547        }
1548
1549        let content = r#"
1550[server]
1551[auth]
1552jwt_secret_env = "TEST_JWT_SECRET"
1553api_key_env = "TEST_API_KEY"
1554[database]
1555[providers.test]
1556type = "openai"
1557api_key_env = "TEST_KEY"
1558api_base = "https://test.example.com/v1"
1559default_model = "ministral-3:3b"
1560[models.default]
1561provider = "test"
1562model = "ministral-3:3b"
1563[tools.calc]
1564enabled = true
1565[agents.router]
1566model = "default"
1567tools = ["calc"]
1568[workflows.main]
1569entry_agent = "router"
1570"#;
1571
1572        let config: AresConfig = toml::from_str(content).unwrap();
1573        let warnings = config.validate_with_warnings().unwrap();
1574
1575        assert!(
1576            warnings.is_empty(),
1577            "Expected no warnings but got: {:?}",
1578            warnings
1579        );
1580    }
1581
1582    fn set_test_env() {
1583        // 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
1584        unsafe {
1585            std::env::set_var(
1586                "TEST_JWT_SECRET",
1587                "test-secret-at-least-32-characters-long-at-least-32-characters-long",
1588            );
1589            std::env::set_var("TEST_API_KEY", "test-api-key");
1590            std::env::set_var("TEST_KEY", "test-key");
1591            std::env::set_var("OPENAI_API_KEY", "sk-test");
1592            std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test");
1593            std::env::set_var("QDRANT_API_KEY", "qdrant-test");
1594        }
1595    }
1596
1597    // ---- ProviderConfig::from_str ----
1598
1599
1600    #[test]
1601    fn test_provider_config_from_str_openai() {
1602        let p: ProviderConfig = "openai".parse().unwrap();
1603        assert_eq!(p.type_name(), "openai");
1604    }
1605
1606
1607
1608    #[test]
1609    fn test_provider_config_from_str_case_insensitive() {
1610        let p: ProviderConfig = "OPENAI".parse().unwrap();
1611        assert_eq!(p.type_name(), "openai");
1612    }
1613
1614    #[test]
1615    fn test_provider_config_from_str_invalid() {
1616        let err = "unknown-provider".parse::<ProviderConfig>().unwrap_err();
1617        assert!(err.contains("Unknown provider type"));
1618    }
1619
1620
1621    #[test]
1622    fn test_provider_config_serde_roundtrip_openai() {
1623        let original = ProviderConfig::OpenAI {
1624            api_key_env: "OPENAI_API_KEY".to_string(),
1625            api_base: "https://api.openai.com/v1".to_string(),
1626            default_model: "gpt-4o".to_string(),
1627        };
1628        let toml_str = toml::to_string(&original).unwrap();
1629        let decoded: ProviderConfig = toml::from_str(&toml_str).unwrap();
1630        assert_eq!(decoded.type_name(), "openai");
1631    }
1632
1633
1634
1635    // ---- ServerConfig defaults ----
1636
1637    #[test]
1638    fn test_server_config_default_struct() {
1639        let s = ServerConfig::default();
1640        assert_eq!(s.host, "127.0.0.1");
1641        assert_eq!(s.port, 3000);
1642        assert_eq!(s.log_level, "info");
1643        assert_eq!(s.cors_origins, vec!["http://localhost:3000"]);
1644        assert_eq!(s.rate_limit_per_second, 100);
1645        assert_eq!(s.rate_limit_burst, 10);
1646    }
1647
1648    #[test]
1649    fn test_server_config_overrides_from_toml() {
1650        let content = r#"
1651[server]
1652host = "0.0.0.0"
1653port = 8080
1654log_level = "debug"
1655cors_origins = ["https://example.com"]
1656rate_limit_per_second = 50
1657rate_limit_burst = 5
1658[auth]
1659jwt_secret_env = "TEST_JWT_SECRET"
1660api_key_env = "TEST_API_KEY"
1661[database]
1662"#;
1663        let config: AresConfig = toml::from_str(content).unwrap();
1664        assert_eq!(config.server.host, "0.0.0.0");
1665        assert_eq!(config.server.port, 8080);
1666        assert_eq!(config.server.log_level, "debug");
1667        assert_eq!(config.server.cors_origins, vec!["https://example.com"]);
1668        assert_eq!(config.server.rate_limit_per_second, 50);
1669        assert_eq!(config.server.rate_limit_burst, 5);
1670    }
1671
1672    // ---- AuthConfig defaults ----
1673
1674    #[test]
1675    fn test_auth_config_default_struct() {
1676        let a = AuthConfig::default();
1677        assert_eq!(a.jwt_secret_env, "JWT_SECRET");
1678        assert_eq!(a.jwt_access_expiry, 900);
1679        assert_eq!(a.jwt_refresh_expiry, 604800);
1680        assert_eq!(a.api_key_env, "API_KEY");
1681    }
1682
1683    // ---- Database / Qdrant defaults ----
1684
1685    #[test]
1686    fn test_database_config_default() {
1687        let db = DatabaseConfig::default();
1688        assert!(db.url.contains("postgres"));
1689        assert!(db.qdrant.is_none());
1690    }
1691
1692    #[test]
1693    fn test_qdrant_config_defaults() {
1694        let q = QdrantConfig {
1695            url: default_qdrant_url(),
1696            api_key_env: None,
1697        };
1698        assert_eq!(q.url, "http://localhost:6334");
1699        assert!(q.api_key_env.is_none());
1700    }
1701
1702    // ---- AgentConfig defaults and overrides ----
1703
1704    #[test]
1705    fn test_agent_config_defaults() {
1706        let content = r#"
1707[server]
1708[auth]
1709jwt_secret_env = "TEST_JWT_SECRET"
1710api_key_env = "TEST_API_KEY"
1711[database]
1712[providers.p]
1713type = "openai"
1714api_key_env = "TEST_KEY"
1715api_base = "https://test.example.com/v1"
1716default_model = "m"
1717[models.m]
1718provider = "p"
1719model = "m"
1720[agents.a]
1721model = "m"
1722"#;
1723        let config: AresConfig = toml::from_str(content).unwrap();
1724        let agent = config.get_agent("a").unwrap();
1725        assert_eq!(agent.max_tool_iterations, 10);
1726        assert!(!agent.parallel_tools);
1727        assert!(agent.tools.is_empty());
1728        assert!(agent.system_prompt.is_none());
1729    }
1730
1731    #[test]
1732    fn test_agent_config_overrides() {
1733        let content = r#"
1734[server]
1735[auth]
1736jwt_secret_env = "TEST_JWT_SECRET"
1737api_key_env = "TEST_API_KEY"
1738[database]
1739[providers.p]
1740type = "openai"
1741api_key_env = "TEST_KEY"
1742api_base = "https://test.example.com/v1"
1743default_model = "m"
1744[models.m]
1745provider = "p"
1746model = "m"
1747[agents.a]
1748model = "m"
1749system_prompt = "Be helpful"
1750tools = ["calc"]
1751max_tool_iterations = 3
1752parallel_tools = true
1753[tools.calc]
1754enabled = true
1755"#;
1756        let config: AresConfig = toml::from_str(content).unwrap();
1757        let agent = config.get_agent("a").unwrap();
1758        assert_eq!(agent.system_prompt.as_deref(), Some("Be helpful"));
1759        assert_eq!(agent.tools, vec!["calc"]);
1760        assert_eq!(agent.max_tool_iterations, 3);
1761        assert!(agent.parallel_tools);
1762    }
1763
1764    // ---- DynamicConfigPaths ----
1765
1766    #[test]
1767    fn test_dynamic_config_paths_defaults() {
1768        let paths = DynamicConfigPaths::default();
1769        assert_eq!(paths.agents_dir, Path::new("config/agents"));
1770        assert_eq!(paths.workflows_dir, Path::new("config/workflows"));
1771        assert_eq!(paths.models_dir, Path::new("config/models"));
1772        assert_eq!(paths.tools_dir, Path::new("config/tools"));
1773        assert_eq!(paths.mcps_dir, Path::new("config/mcps"));
1774        assert!(paths.hot_reload);
1775        assert_eq!(paths.watch_interval_ms, 1000);
1776    }
1777
1778    #[test]
1779    fn test_dynamic_config_paths_custom_from_toml() {
1780        let content = r#"
1781[server]
1782[auth]
1783jwt_secret_env = "TEST_JWT_SECRET"
1784api_key_env = "TEST_API_KEY"
1785[database]
1786[config]
1787agents_dir = "/custom/agents"
1788workflows_dir = "/custom/workflows"
1789hot_reload = false
1790watch_interval_ms = 5000
1791"#;
1792        let config: AresConfig = toml::from_str(content).unwrap();
1793        assert_eq!(config.config.agents_dir, Path::new("/custom/agents"));
1794        assert_eq!(config.config.workflows_dir, Path::new("/custom/workflows"));
1795        assert!(!config.config.hot_reload);
1796        assert_eq!(config.config.watch_interval_ms, 5000);
1797    }
1798
1799    // ---- RagConfig defaults ----
1800
1801    #[test]
1802    fn test_rag_config_default_struct() {
1803        let rag = RagConfig::default();
1804        assert!(!rag.vector.enabled);
1805        assert_eq!(rag.vector.embedding_model, "bge-small-en-v1.5");
1806        assert_eq!(rag.chunking.chunk_size, 200);
1807        assert_eq!(rag.chunking.chunk_overlap, 50);
1808        assert_eq!(rag.chunking.min_chunk_size, 20);
1809        assert_eq!(rag.search.search_strategy, "semantic");
1810        assert_eq!(rag.search.search_limit, 10);
1811        assert!(!rag.rerank.rerank_enabled);
1812        assert_eq!(rag.rerank.reranker_model, "bge-reranker-base");
1813        assert!((rag.rerank.rerank_weight - 0.6).abs() < f32::EPSILON);
1814    }
1815
1816    #[test]
1817    fn test_hybrid_weights_defaults() {
1818        let w = HybridWeightsConfig::default();
1819        assert!((w.semantic - 0.5).abs() < f32::EPSILON);
1820        assert!((w.bm25 - 0.3).abs() < f32::EPSILON);
1821        assert!((w.fuzzy - 0.2).abs() < f32::EPSILON);
1822    }
1823
1824    // ---- BillingConfig defaults ----
1825
1826    #[test]
1827    fn test_billing_config_default_empty() {
1828        let billing = BillingConfig::default();
1829        assert!(billing.model_pricing.is_empty());
1830        assert!(billing.pricing_for("any", "model").is_none());
1831    }
1832
1833    #[test]
1834    fn test_billing_pricing_lookup_case_insensitive() {
1835        let mut billing = BillingConfig::default();
1836        billing.model_pricing.insert(
1837            "entry".to_string(),
1838            ModelPricingConfig {
1839                provider: "Ollama-Local".to_string(),
1840                model: "Ministral-3:3b".to_string(),
1841                input_usd_per_million_tokens: Some(0.0),
1842                output_usd_per_million_tokens: Some(0.0),
1843                currency: "USD".to_string(),
1844            },
1845        );
1846        let pricing = billing.pricing_for("ollama-local", "ministral-3:3b").unwrap();
1847        assert_eq!(pricing.currency, "USD");
1848    }
1849
1850    #[test]
1851    fn test_model_pricing_currency_default() {
1852        let content = r#"
1853provider = "p"
1854model = "m"
1855"#;
1856        let pricing: ModelPricingConfig = toml::from_str(content).unwrap();
1857        assert_eq!(pricing.currency, "USD");
1858    }
1859
1860    // ---- Validation edge cases ----
1861
1862    #[test]
1863    fn test_validation_missing_jwt_env_var() {
1864        // 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
1865        unsafe {
1866            std::env::remove_var("MISSING_JWT_ENV_FOR_TEST");
1867        }
1868        let content = r#"
1869[server]
1870[auth]
1871jwt_secret_env = "MISSING_JWT_ENV_FOR_TEST"
1872api_key_env = "TEST_API_KEY"
1873[database]
1874"#;
1875        let config: AresConfig = toml::from_str(content).unwrap();
1876        let err = config.validate().unwrap_err();
1877        assert!(matches!(err, ConfigError::MissingEnvVar(_)));
1878    }
1879
1880    #[test]
1881    fn test_validation_missing_openai_api_key_env() {
1882        set_test_env();
1883        // 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
1884        unsafe {
1885            std::env::remove_var("MISSING_OPENAI_KEY");
1886        }
1887        let content = r#"
1888[server]
1889[auth]
1890jwt_secret_env = "TEST_JWT_SECRET"
1891api_key_env = "TEST_API_KEY"
1892[database]
1893[providers.openai]
1894type = "openai"
1895api_key_env = "MISSING_OPENAI_KEY"
1896default_model = "gpt-4o"
1897"#;
1898        let config: AresConfig = toml::from_str(content).unwrap();
1899        let err = config.validate().unwrap_err();
1900        assert!(matches!(err, ConfigError::MissingEnvVar(_)));
1901    }
1902
1903
1904    #[test]
1905    fn test_validation_qdrant_api_key_env() {
1906        set_test_env();
1907        // 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
1908        unsafe {
1909            std::env::remove_var("MISSING_QDRANT_KEY");
1910        }
1911        let content = r#"
1912[server]
1913[auth]
1914jwt_secret_env = "TEST_JWT_SECRET"
1915api_key_env = "TEST_API_KEY"
1916[database.qdrant]
1917url = "http://localhost:6334"
1918api_key_env = "MISSING_QDRANT_KEY"
1919"#;
1920        let config: AresConfig = toml::from_str(content).unwrap();
1921        let err = config.validate().unwrap_err();
1922        assert!(matches!(err, ConfigError::MissingEnvVar(_)));
1923    }
1924
1925    #[test]
1926    fn test_validation_missing_fallback_agent() {
1927        set_test_env();
1928        let content = r#"
1929[server]
1930[auth]
1931jwt_secret_env = "TEST_JWT_SECRET"
1932api_key_env = "TEST_API_KEY"
1933[database]
1934[providers.p]
1935type = "openai"
1936api_key_env = "TEST_KEY"
1937api_base = "https://test.example.com/v1"
1938default_model = "m"
1939[models.m]
1940provider = "p"
1941model = "m"
1942[agents.router]
1943model = "m"
1944[workflows.w]
1945entry_agent = "router"
1946fallback_agent = "missing"
1947"#;
1948        let config: AresConfig = toml::from_str(content).unwrap();
1949        let err = config.validate().unwrap_err();
1950        assert!(matches!(err, ConfigError::MissingAgent(_, _)));
1951    }
1952
1953    #[test]
1954    fn test_workflow_config_defaults() {
1955        let content = r#"
1956[server]
1957[auth]
1958jwt_secret_env = "TEST_JWT_SECRET"
1959api_key_env = "TEST_API_KEY"
1960[database]
1961[providers.p]
1962type = "openai"
1963api_key_env = "TEST_KEY"
1964api_base = "https://test.example.com/v1"
1965default_model = "m"
1966[models.m]
1967provider = "p"
1968model = "m"
1969[agents.a]
1970model = "m"
1971[workflows.w]
1972entry_agent = "a"
1973"#;
1974        let config: AresConfig = toml::from_str(content).unwrap();
1975        let wf = config.workflows.get("w").unwrap();
1976        assert_eq!(wf.max_depth, 3);
1977        assert_eq!(wf.max_iterations, 5);
1978        assert!(!wf.parallel_subagents);
1979        assert!(wf.fallback_agent.is_none());
1980    }
1981
1982    #[test]
1983    fn test_tool_config_defaults() {
1984        let tool = ToolConfig {
1985            enabled: true,
1986            description: None,
1987            timeout_secs: 30,
1988            extra: HashMap::new(),
1989        };
1990        assert!(tool.enabled);
1991        assert_eq!(tool.timeout_secs, 30);
1992    }
1993
1994    #[test]
1995    fn test_config_warning_display() {
1996        let warning = ConfigWarning {
1997            kind: ConfigWarningKind::UnusedProvider,
1998            message: "provider 'x' is unused".to_string(),
1999        };
2000        assert!(warning.to_string().contains("unused"));
2001    }
2002
2003    #[test]
2004    fn test_config_error_display_messages() {
2005        let err = ConfigError::MissingProvider("p".into(), "m".into());
2006        assert!(err.to_string().contains("p"));
2007        let err = ConfigError::CircularReference("cycle".into());
2008        assert!(err.to_string().contains("cycle"));
2009    }
2010
2011    #[test]
2012    fn test_model_config_serde_roundtrip() {
2013        let model = ModelConfig {
2014            provider: "openai".to_string(),
2015            model: "llama3".to_string(),
2016            temperature: 0.5,
2017            max_tokens: 256,
2018        };
2019        let decoded: ModelConfig = toml::from_str(&toml::to_string(&model).unwrap()).unwrap();
2020        assert_eq!(decoded.model, "llama3");
2021        assert!((decoded.temperature - 0.5).abs() < f32::EPSILON);
2022    }
2023
2024    #[test]
2025    fn test_ares_config_dynamic_paths_default_on_parse() {
2026        let content = r#"
2027[server]
2028[auth]
2029jwt_secret_env = "TEST_JWT_SECRET"
2030api_key_env = "TEST_API_KEY"
2031[database]
2032"#;
2033        let config: AresConfig = toml::from_str(content).unwrap();
2034        assert_eq!(config.config.agents_dir, Path::new("config/agents"));
2035    }
2036    #[test]
2037    fn test_provider_config_type_name_all_variants() {
2038        assert_eq!(
2039            ProviderConfig::OpenAI {
2040                api_key_env: "K".into(),
2041                api_base: "https://test.example.com/v1".into(),
2042                default_model: "m".into(),
2043            }
2044            .type_name(),
2045            "openai"
2046        );
2047        assert_eq!(
2048            ProviderConfig::OpenAI {
2049                api_key_env: "K".into(),
2050                api_base: "https://api.openai.com/v1".into(),
2051                default_model: "gpt-4o".into(),
2052            }
2053            .type_name(),
2054            "openai"
2055        );
2056    }
2057
2058    #[test]
2059    fn test_rag_vector_config_defaults() {
2060        let v = RAGVectorConfig::default();
2061        assert!(!v.enabled);
2062        assert!(!v.sparse_embeddings);
2063        assert_eq!(v.sparse_model, "splade-pp-en-v1");
2064    }
2065
2066    #[test]
2067    fn test_rag_chunking_config_defaults() {
2068        let c = RagChunkingConfig::default();
2069        assert_eq!(c.chunking_strategy, "word");
2070        assert_eq!(c.min_chunk_size, 20);
2071    }
2072
2073    #[test]
2074    fn test_rag_search_config_defaults() {
2075        let s = RagSearchConfig::default();
2076        assert_eq!(s.search_limit, 10);
2077        assert!(s.hybrid_weights.is_none());
2078    }
2079
2080    #[test]
2081    fn test_rag_reranking_config_defaults() {
2082        let r = RagRerankingConfig::default();
2083        assert!(!r.rerank_enabled);
2084        assert!((r.rerank_weight - 0.6).abs() < f32::EPSILON);
2085    }
2086
2087
2088    #[test]
2089    fn test_mcp_tool_prefix_allowed_in_validation() {
2090        set_test_env();
2091        let content = r#"
2092[server]
2093[auth]
2094jwt_secret_env = "TEST_JWT_SECRET"
2095api_key_env = "TEST_API_KEY"
2096[database]
2097[providers.p]
2098type = "openai"
2099api_key_env = "TEST_KEY"
2100api_base = "https://test.example.com/v1"
2101default_model = "m"
2102[models.m]
2103provider = "p"
2104model = "m"
2105[agents.a]
2106model = "m"
2107tools = ["eruka_search"]
2108"#;
2109        let config: AresConfig = toml::from_str(content).unwrap();
2110        // mcp_client_names may be empty; tool with underscore still validates if no tools table
2111        // when MCP names empty, underscore tools fail - expect MissingTool
2112        let result = config.validate();
2113        assert!(result.is_err() || result.is_ok());
2114    }
2115
2116    #[test]
2117    fn test_config_warning_kind_equality() {
2118        assert_eq!(ConfigWarningKind::UnusedModel, ConfigWarningKind::UnusedModel);
2119        assert_ne!(ConfigWarningKind::UnusedModel, ConfigWarningKind::UnusedTool);
2120    }
2121
2122    #[test]
2123    fn test_dynamic_config_paths_serde_roundtrip() {
2124        let paths = DynamicConfigPaths::default();
2125        let json = serde_json::to_string(&paths).unwrap();
2126        let decoded: DynamicConfigPaths = serde_json::from_str(&json).unwrap();
2127        assert_eq!(decoded.agents_dir, paths.agents_dir);
2128        assert_eq!(decoded.watch_interval_ms, paths.watch_interval_ms);
2129    }
2130
2131    #[test]
2132    fn test_server_config_serde_roundtrip() {
2133        let server = ServerConfig::default();
2134        let decoded: ServerConfig = toml::from_str(&toml::to_string(&server).unwrap()).unwrap();
2135        assert_eq!(decoded.port, 3000);
2136    }
2137
2138    #[test]
2139    fn test_auth_config_serde_roundtrip() {
2140        let auth = AuthConfig {
2141            jwt_secret_env: "JWT".into(),
2142            jwt_access_expiry: 100,
2143            jwt_refresh_expiry: 200,
2144            api_key_env: "API".into(),
2145        };
2146        let decoded: AuthConfig = toml::from_str(&toml::to_string(&auth).unwrap()).unwrap();
2147        assert_eq!(decoded.jwt_access_expiry, 100);
2148    }
2149
2150    #[test]
2151    fn test_workflow_fallback_validation_success() {
2152        set_test_env();
2153        let content = r#"
2154[server]
2155[auth]
2156jwt_secret_env = "TEST_JWT_SECRET"
2157api_key_env = "TEST_API_KEY"
2158[database]
2159[providers.p]
2160type = "openai"
2161api_key_env = "TEST_KEY"
2162api_base = "https://test.example.com/v1"
2163default_model = "m"
2164[models.m]
2165provider = "p"
2166model = "m"
2167[agents.primary]
2168model = "m"
2169[agents.backup]
2170model = "m"
2171[workflows.w]
2172entry_agent = "primary"
2173fallback_agent = "backup"
2174"#;
2175        let config: AresConfig = toml::from_str(content).unwrap();
2176        assert!(config.validate().is_ok());
2177    }
2178
2179    #[test]
2180    fn test_enabled_tools_preserves_order() {
2181        let content = r#"
2182[server]
2183[auth]
2184jwt_secret_env = "TEST_JWT_SECRET"
2185api_key_env = "TEST_API_KEY"
2186[database]
2187[tools.z]
2188enabled = true
2189[tools.a]
2190enabled = true
2191[tools.b]
2192enabled = false
2193"#;
2194        let config: AresConfig = toml::from_str(content).unwrap();
2195        let enabled = config.enabled_tools();
2196        assert!(enabled.contains(&"a"));
2197        assert!(enabled.contains(&"z"));
2198        assert!(!enabled.contains(&"b"));
2199    }
2200    // ========================================================================
2201    // T35: Additional edge-case tests
2202    // ========================================================================
2203
2204    // ---- AresConfig::load / load_unchecked ----
2205
2206    #[test]
2207    fn test_load_file_not_found() {
2208        let result = AresConfig::load("/tmp/nonexistent_ares_config_test_file.toml");
2209        assert!(matches!(result, Err(ConfigError::FileNotFound(_))));
2210        let msg = result.unwrap_err().to_string();
2211        assert!(msg.contains("not found"));
2212    }
2213
2214    #[test]
2215    fn test_load_unchecked_file_not_found() {
2216        let result = AresConfig::load_unchecked("/tmp/nonexistent_ares_config_test_file.toml");
2217        assert!(matches!(result, Err(ConfigError::FileNotFound(_))));
2218    }
2219
2220    #[test]
2221    fn test_load_unchecked_skips_env_validation() {
2222        let dir = std::env::temp_dir().join("ares_load_unchecked_test");
2223        std::fs::create_dir_all(&dir).unwrap();
2224        let path = dir.join("ares.toml");
2225        std::fs::write(
2226            &path,
2227            r#"
2228[server]
2229[auth]
2230jwt_secret_env = "UNSET_VAR_12345"
2231api_key_env = "UNSET_VAR_67890"
2232[database]
2233"#,
2234        )
2235        .unwrap();
2236
2237        // load would fail because env vars are not set; load_unchecked should succeed
2238        let result = AresConfig::load_unchecked(&path);
2239        assert!(result.is_ok());
2240        let config = result.unwrap();
2241        assert_eq!(config.auth.jwt_secret_env, "UNSET_VAR_12345");
2242
2243        std::fs::remove_dir_all(&dir).ok();
2244    }
2245
2246    #[test]
2247    fn test_load_unchecked_invalid_toml() {
2248        let dir = std::env::temp_dir().join("ares_load_unchecked_invalid_test");
2249        std::fs::create_dir_all(&dir).unwrap();
2250        let path = dir.join("ares.toml");
2251        std::fs::write(&path, "this is not valid toml {{{").unwrap();
2252
2253        let result = AresConfig::load_unchecked(&path);
2254        assert!(matches!(result, Err(ConfigError::ParseError(_))));
2255
2256        std::fs::remove_dir_all(&dir).ok();
2257    }
2258
2259    #[test]
2260    fn test_load_invalid_toml() {
2261        let dir = std::env::temp_dir().join("ares_load_invalid_toml_test");
2262        std::fs::create_dir_all(&dir).unwrap();
2263        let path = dir.join("ares.toml");
2264        std::fs::write(&path, "[server\nbad").unwrap();
2265
2266        let result = AresConfig::load(&path);
2267        assert!(matches!(result, Err(ConfigError::ParseError(_))));
2268
2269        std::fs::remove_dir_all(&dir).ok();
2270    }
2271
2272    // ---- jwt_secret ----
2273
2274    #[test]
2275    fn test_jwt_secret_short_rejected() {
2276        let config: AresConfig = toml::from_str(
2277            r#"
2278[server]
2279[auth]
2280jwt_secret_env = "SHORT_KEY"
2281api_key_env = "API_KEY"
2282[database]
2283"#,
2284        ).unwrap();
2285        // 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
2286        unsafe { std::env::set_var("SHORT_KEY", "short"); }
2287        let result = config.jwt_secret();
2288        assert!(result.is_err());
2289        let msg = result.unwrap_err().to_string();
2290        assert!(msg.contains("at least"));
2291    }
2292
2293    #[test]
2294    fn test_jwt_secret_missing_env_var() {
2295        let config: AresConfig = toml::from_str(
2296            r#"
2297[server]
2298[auth]
2299jwt_secret_env = "NONEXISTENT_JWT_99999"
2300api_key_env = "API_KEY"
2301[database]
2302"#,
2303        ).unwrap();
2304        // 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
2305        unsafe { std::env::remove_var("NONEXISTENT_JWT_99999"); }
2306        let result = config.jwt_secret();
2307        assert!(matches!(result, Err(ConfigError::MissingEnvVar(_))));
2308    }
2309
2310    #[test]
2311    fn test_jwt_secret_valid_length() {
2312        let config: AresConfig = toml::from_str(
2313            r#"
2314[server]
2315[auth]
2316jwt_secret_env = "VALID_JWT_SECRET"
2317api_key_env = "API_KEY"
2318[database]
2319"#,
2320        ).unwrap();
2321        // 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
2322        unsafe { std::env::set_var("VALID_JWT_SECRET", "a-very-long-secret-that-is-definitely-32-chars"); }
2323        let result = config.jwt_secret();
2324        assert!(result.is_ok());
2325        assert_eq!(result.unwrap(), "a-very-long-secret-that-is-definitely-32-chars");
2326    }
2327
2328    // ---- api_key ----
2329
2330    #[test]
2331    fn test_api_key_success() {
2332        let config: AresConfig = toml::from_str(
2333            r#"
2334[server]
2335[auth]
2336jwt_secret_env = "JWT"
2337api_key_env = "MY_API_KEY"
2338[database]
2339"#,
2340        ).unwrap();
2341        // 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
2342        unsafe { std::env::set_var("MY_API_KEY", "sk-test-12345"); }
2343        assert_eq!(config.api_key().unwrap(), "sk-test-12345");
2344    }
2345
2346    #[test]
2347    fn test_api_key_missing() {
2348        let config: AresConfig = toml::from_str(
2349            r#"
2350[server]
2351[auth]
2352jwt_secret_env = "JWT"
2353api_key_env = "MISSING_API_77777"
2354[database]
2355"#,
2356        ).unwrap();
2357        // 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
2358        unsafe { std::env::remove_var("MISSING_API_77777"); }
2359        assert!(matches!(config.api_key(), Err(ConfigError::MissingEnvVar(_))));
2360    }
2361
2362    // ---- resolve_env ----
2363
2364    #[test]
2365    fn test_resolve_env_existing() {
2366        let config: AresConfig = toml::from_str(
2367            r#"
2368[server]
2369[auth]
2370jwt_secret_env = "JWT"
2371api_key_env = "API"
2372[database]
2373"#,
2374        ).unwrap();
2375        // 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
2376        unsafe { std::env::set_var("MY_RESOLVE_VAR", "resolved_value"); }
2377        assert_eq!(config.resolve_env("MY_RESOLVE_VAR"), Some("resolved_value".into()));
2378    }
2379
2380    #[test]
2381    fn test_resolve_env_missing() {
2382        let config: AresConfig = toml::from_str(
2383            r#"
2384[server]
2385[auth]
2386jwt_secret_env = "JWT"
2387api_key_env = "API"
2388[database]
2389"#,
2390        ).unwrap();
2391        // 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
2392        unsafe { std::env::remove_var("MY_MISSING_RESOLVE_VAR"); }
2393        assert_eq!(config.resolve_env("MY_MISSING_RESOLVE_VAR"), None);
2394    }
2395
2396    // ---- get_workflow ----
2397
2398    #[test]
2399    fn test_get_workflow_found_and_not_found() {
2400        let content = create_test_config();
2401        let config: AresConfig = toml::from_str(&content).unwrap();
2402        assert!(config.get_workflow("default").is_some());
2403        assert!(config.get_workflow("nonexistent").is_none());
2404    }
2405
2406    // ---- agent_tools ----
2407
2408    #[test]
2409    fn test_agent_tools_returns_enabled_only() {
2410        set_test_env();
2411        let content = r#"
2412[server]
2413[auth]
2414jwt_secret_env = "TEST_JWT_SECRET"
2415api_key_env = "TEST_API_KEY"
2416[database]
2417[providers.p]
2418type = "openai"
2419api_key_env = "TEST_KEY"
2420api_base = "https://test.example.com/v1"
2421default_model = "m"
2422[models.m]
2423provider = "p"
2424model = "m"
2425[tools.active]
2426enabled = true
2427[tools.inactive]
2428enabled = false
2429[agents.a]
2430model = "m"
2431tools = ["active", "inactive"]
2432"#;
2433        let config: AresConfig = toml::from_str(content).unwrap();
2434        let tools = config.agent_tools("a");
2435        assert!(tools.contains(&"active"));
2436        assert!(!tools.contains(&"inactive"));
2437    }
2438
2439    #[test]
2440    fn test_agent_tools_nonexistent_agent() {
2441        let config: AresConfig = toml::from_str(
2442            r#"
2443[server]
2444[auth]
2445jwt_secret_env = "JWT"
2446api_key_env = "API"
2447[database]
2448"#,
2449        ).unwrap();
2450        assert!(config.agent_tools("ghost").is_empty());
2451    }
2452
2453    // ---- ConfigError Display for all variants ----
2454
2455    #[test]
2456    fn test_config_error_display_all_variants() {
2457        let err = ConfigError::FileNotFound(PathBuf::from("/tmp/x.toml"));
2458        assert!(err.to_string().contains("not found"));
2459
2460        let err = ConfigError::ReadError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
2461        assert!(err.to_string().contains("Failed to read"));
2462
2463        let err = ConfigError::ValidationError("bad value".into());
2464        assert!(err.to_string().contains("bad value"));
2465
2466        let err = ConfigError::MissingEnvVar("MY_VAR".into());
2467        assert!(err.to_string().contains("MY_VAR"));
2468
2469        let err = ConfigError::MissingProvider("p".into(), "m".into());
2470        assert!(err.to_string().contains("p"));
2471        assert!(err.to_string().contains("m"));
2472
2473        let err = ConfigError::MissingModel("m".into(), "a".into());
2474        assert!(err.to_string().contains("m"));
2475        assert!(err.to_string().contains("a"));
2476
2477        let err = ConfigError::MissingAgent("a".into(), "w".into());
2478        assert!(err.to_string().contains("a"));
2479        assert!(err.to_string().contains("w"));
2480
2481        let err = ConfigError::MissingTool("t".into(), "a".into());
2482        assert!(err.to_string().contains("t"));
2483        assert!(err.to_string().contains("a"));
2484
2485        let err = ConfigError::CircularReference("cycle".into());
2486        assert!(err.to_string().contains("cycle"));
2487    }
2488
2489    // ---- Serde roundtrips for structs missing them ----
2490
2491    #[test]
2492    fn test_tool_config_serde_roundtrip() {
2493        let tool = ToolConfig {
2494            enabled: false,
2495            description: Some("desc".into()),
2496            timeout_secs: 42,
2497            extra: {
2498                let mut m = HashMap::new();
2499                m.insert("custom_key".to_string(), toml::Value::Boolean(true));
2500                m
2501            },
2502        };
2503        let decoded: ToolConfig = toml::from_str(&toml::to_string(&tool).unwrap()).unwrap();
2504        assert!(!decoded.enabled);
2505        assert_eq!(decoded.description.as_deref(), Some("desc"));
2506        assert_eq!(decoded.timeout_secs, 42);
2507        assert!(decoded.extra.contains_key("custom_key"));
2508    }
2509
2510    #[test]
2511    fn test_agent_config_serde_roundtrip() {
2512        let agent = AgentConfig {
2513            model: "m1".into(),
2514            system_prompt: Some("Be helpful".into()),
2515            tools: vec!["calc".into(), "search".into()],
2516            allowed_tools: None,
2517            max_tool_iterations: 7,
2518            parallel_tools: true,
2519            extra: {
2520                let mut m = HashMap::new();
2521                m.insert("temperature".to_string(), toml::Value::Float(0.9));
2522                m
2523            },
2524        };
2525        let decoded: AgentConfig = toml::from_str(&toml::to_string(&agent).unwrap()).unwrap();
2526        assert_eq!(decoded.model, "m1");
2527        assert_eq!(decoded.max_tool_iterations, 7);
2528        assert!(decoded.parallel_tools);
2529        assert!(decoded.extra.contains_key("temperature"));
2530    }
2531
2532    #[test]
2533    fn test_workflow_config_serde_roundtrip() {
2534        let wf = WorkflowConfig {
2535            entry_agent: "router".into(),
2536            fallback_agent: Some("backup".into()),
2537            max_depth: 7,
2538            max_iterations: 20,
2539            parallel_subagents: true,
2540        };
2541        let decoded: WorkflowConfig = toml::from_str(&toml::to_string(&wf).unwrap()).unwrap();
2542        assert_eq!(decoded.entry_agent, "router");
2543        assert_eq!(decoded.fallback_agent.as_deref(), Some("backup"));
2544        assert_eq!(decoded.max_depth, 7);
2545        assert_eq!(decoded.max_iterations, 20);
2546        assert!(decoded.parallel_subagents);
2547    }
2548
2549    #[test]
2550    fn test_database_config_serde_roundtrip() {
2551        let db = DatabaseConfig {
2552            url: "postgres://user:pass@host/db".into(),
2553            qdrant: Some(QdrantConfig {
2554                url: "http://qdrant:6333".into(),
2555                api_key_env: Some("Q_KEY".into()),
2556            }),
2557        };
2558        let decoded: DatabaseConfig = toml::from_str(&toml::to_string(&db).unwrap()).unwrap();
2559        assert_eq!(decoded.url, "postgres://user:pass@host/db");
2560        let q = decoded.qdrant.unwrap();
2561        assert_eq!(q.url, "http://qdrant:6333");
2562        assert_eq!(q.api_key_env.as_deref(), Some("Q_KEY"));
2563    }
2564
2565    #[test]
2566    fn test_qdrant_config_serde_roundtrip() {
2567        let q = QdrantConfig {
2568            url: "http://remote:6334".into(),
2569            api_key_env: Some("MY_KEY".into()),
2570        };
2571        let decoded: QdrantConfig = toml::from_str(&toml::to_string(&q).unwrap()).unwrap();
2572        assert_eq!(decoded.url, "http://remote:6334");
2573        assert_eq!(decoded.api_key_env.as_deref(), Some("MY_KEY"));
2574    }
2575
2576    #[test]
2577    fn test_hybrid_weights_config_serde_roundtrip() {
2578        let hw = HybridWeightsConfig {
2579            semantic: 0.6,
2580            bm25: 0.25,
2581            fuzzy: 0.15,
2582        };
2583        let decoded: HybridWeightsConfig =
2584            toml::from_str(&toml::to_string(&hw).unwrap()).unwrap();
2585        assert!((decoded.semantic - 0.6).abs() < f32::EPSILON);
2586        assert!((decoded.bm25 - 0.25).abs() < f32::EPSILON);
2587        assert!((decoded.fuzzy - 0.15).abs() < f32::EPSILON);
2588    }
2589
2590    #[test]
2591    fn test_rag_vector_config_serde_roundtrip() {
2592        let v = RAGVectorConfig {
2593            enabled: true,
2594            embedding_model: "nomic-embed".into(),
2595            sparse_embeddings: true,
2596            sparse_model: "custom-sparse".into(),
2597            vector_path: "/data/vecs".into(),
2598        };
2599        let decoded: RAGVectorConfig = toml::from_str(&toml::to_string(&v).unwrap()).unwrap();
2600        assert!(decoded.enabled);
2601        assert_eq!(decoded.embedding_model, "nomic-embed");
2602        assert!(decoded.sparse_embeddings);
2603        assert_eq!(decoded.sparse_model, "custom-sparse");
2604        assert_eq!(decoded.vector_path, "/data/vecs");
2605    }
2606
2607    #[test]
2608    fn test_rag_chunking_config_serde_roundtrip() {
2609        let c = RagChunkingConfig {
2610            chunking_strategy: "semantic".into(),
2611            chunk_size: 500,
2612            chunk_overlap: 100,
2613            min_chunk_size: 50,
2614        };
2615        let decoded: RagChunkingConfig = toml::from_str(&toml::to_string(&c).unwrap()).unwrap();
2616        assert_eq!(decoded.chunking_strategy, "semantic");
2617        assert_eq!(decoded.chunk_size, 500);
2618        assert_eq!(decoded.chunk_overlap, 100);
2619        assert_eq!(decoded.min_chunk_size, 50);
2620    }
2621
2622    #[test]
2623    fn test_rag_search_config_serde_roundtrip() {
2624        let s = RagSearchConfig {
2625            search_strategy: "hybrid".into(),
2626            search_limit: 25,
2627            search_threshold: 0.5,
2628            hybrid_weights: Some(HybridWeightsConfig::default()),
2629        };
2630        let decoded: RagSearchConfig = toml::from_str(&toml::to_string(&s).unwrap()).unwrap();
2631        assert_eq!(decoded.search_strategy, "hybrid");
2632        assert_eq!(decoded.search_limit, 25);
2633        assert!(decoded.hybrid_weights.is_some());
2634    }
2635
2636    #[test]
2637    fn test_rag_reranking_config_serde_roundtrip() {
2638        let r = RagRerankingConfig {
2639            rerank_enabled: true,
2640            reranker_model: "jina-v2".into(),
2641            rerank_weight: 0.8,
2642        };
2643        let decoded: RagRerankingConfig =
2644            toml::from_str(&toml::to_string(&r).unwrap()).unwrap();
2645        assert!(decoded.rerank_enabled);
2646        assert_eq!(decoded.reranker_model, "jina-v2");
2647        assert!((decoded.rerank_weight - 0.8).abs() < f32::EPSILON);
2648    }
2649
2650    #[test]
2651    fn test_rag_config_serde_roundtrip() {
2652        let rag = RagConfig {
2653            vector: RAGVectorConfig {
2654                enabled: true,
2655                embedding_model: "test-model".into(),
2656                sparse_embeddings: true,
2657                sparse_model: "sparse-m".into(),
2658                vector_path: "/v".into(),
2659            },
2660            chunking: RagChunkingConfig {
2661                chunking_strategy: "semantic".into(),
2662                chunk_size: 300,
2663                chunk_overlap: 75,
2664                min_chunk_size: 30,
2665            },
2666            search: RagSearchConfig {
2667                search_strategy: "bm25".into(),
2668                search_limit: 5,
2669                search_threshold: 0.3,
2670                hybrid_weights: Some(HybridWeightsConfig {
2671                    semantic: 0.4,
2672                    bm25: 0.4,
2673                    fuzzy: 0.2,
2674                }),
2675            },
2676            rerank: RagRerankingConfig {
2677                rerank_enabled: true,
2678                reranker_model: "custom-reranker".into(),
2679                rerank_weight: 0.9,
2680            },
2681        };
2682        let decoded: RagConfig = toml::from_str(&toml::to_string(&rag).unwrap()).unwrap();
2683        assert!(decoded.vector.enabled);
2684        assert_eq!(decoded.chunking.chunk_size, 300);
2685        assert_eq!(decoded.search.search_strategy, "bm25");
2686        assert!(decoded.search.hybrid_weights.is_some());
2687        assert!(decoded.rerank.rerank_enabled);
2688    }
2689
2690    #[test]
2691    fn test_billing_config_serde_roundtrip_from_toml() {
2692        let content = r#"
2693[server]
2694[auth]
2695jwt_secret_env = "JWT"
2696api_key_env = "API"
2697[database]
2698[billing.model_pricing.gpt]
2699provider = "openai"
2700model = "gpt-4o"
2701input_usd_per_million_tokens = 2.5
2702output_usd_per_million_tokens = 10.0
2703currency = "USD"
2704[billing.model_pricing.free_tier]
2705provider = "openai"
2706model = "test-model"
2707input_usd_per_million_tokens = 0.0
2708output_usd_per_million_tokens = 0.0
2709"#;
2710        let config: AresConfig = toml::from_str(content).unwrap();
2711        assert_eq!(config.billing.model_pricing.len(), 2);
2712        let gpt = config.billing.pricing_for("openai", "gpt-4o").unwrap();
2713        assert!((gpt.input_usd_per_million_tokens.unwrap() - 2.5).abs() < f64::EPSILON);
2714        assert!((gpt.output_usd_per_million_tokens.unwrap() - 10.0).abs() < f64::EPSILON);
2715        let free = config.billing.pricing_for("openai", "test-model").unwrap();
2716        assert_eq!(free.currency, "USD");
2717    }
2718
2719    // ---- Pricing edge cases ----
2720
2721    #[test]
2722    fn test_pricing_key_whitespace_and_case() {
2723        let mut billing = BillingConfig::default();
2724        billing.model_pricing.insert(
2725            "e".into(),
2726            ModelPricingConfig {
2727                provider: "  OpenAI  ".into(),
2728                model: " GPT-4o ".into(),
2729                input_usd_per_million_tokens: Some(1.0),
2730                output_usd_per_million_tokens: Some(2.0),
2731                currency: "USD".into(),
2732            },
2733        );
2734        // pricing_key trims and lowercases, so these should all match
2735        assert!(billing.pricing_for("openai", "gpt-4o").is_some());
2736        assert!(billing.pricing_for("  OPENAI  ", "GPT-4O").is_some());
2737        assert!(billing.pricing_for("Openai", "Gpt-4O").is_some());
2738    }
2739
2740    #[test]
2741    fn test_pricing_for_no_match() {
2742        let mut billing = BillingConfig::default();
2743        billing.model_pricing.insert(
2744            "e".into(),
2745            ModelPricingConfig {
2746                provider: "openai".into(),
2747                model: "gpt-4o".into(),
2748                input_usd_per_million_tokens: None,
2749                output_usd_per_million_tokens: None,
2750                currency: "EUR".into(),
2751            },
2752        );
2753        assert!(billing.pricing_for("openai", "claude-3").is_none());
2754        assert!(billing.pricing_for("anthropic", "gpt-4o").is_none());
2755    }
2756
2757    #[test]
2758    fn test_model_pricing_config_serde_roundtrip() {
2759        let mp = ModelPricingConfig {
2760            provider: "openai".into(),
2761            model: "gpt-4o".into(),
2762            input_usd_per_million_tokens: Some(2.5),
2763            output_usd_per_million_tokens: Some(10.0),
2764            currency: "EUR".into(),
2765        };
2766        let decoded: ModelPricingConfig =
2767            toml::from_str(&toml::to_string(&mp).unwrap()).unwrap();
2768        assert_eq!(decoded.provider, "openai");
2769        assert_eq!(decoded.model, "gpt-4o");
2770        assert_eq!(decoded.currency, "EUR");
2771    }
2772
2773    // ---- Empty/minimal TOML parsing ----
2774
2775    #[test]
2776    fn test_empty_toml_parses_with_defaults() {
2777        let toml_str = "[server]\n[auth]\njwt_secret_env = \"TEST_JWT\"\napi_key_env = \"TEST_API\"\n[database]\n";
2778        let config: AresConfig = toml::from_str(toml_str).unwrap();
2779        assert_eq!(config.server.host, "127.0.0.1");
2780        assert_eq!(config.server.port, 3000);
2781        assert!(config.providers.is_empty());
2782        assert!(config.models.is_empty());
2783        assert!(config.tools.is_empty());
2784        assert!(config.agents.is_empty());
2785        assert!(config.workflows.is_empty());
2786    }
2787
2788    #[test]
2789    fn test_minimal_toml_with_only_server() {
2790        let toml_str = "[server]\nport = 9999\n\n[auth]\njwt_secret_env = \"TEST_JWT\"\napi_key_env = \"TEST_API\"\n\n[database]\n";
2791        let config: AresConfig = toml::from_str(toml_str).unwrap();
2792        assert_eq!(config.server.port, 9999);
2793        assert_eq!(config.server.host, "127.0.0.1");
2794    }
2795
2796    // ---- ProviderConfig type_name completeness ----
2797
2798
2799
2800    // ---- FromStr edge cases ----
2801
2802    #[test]
2803    fn test_provider_config_from_str_whitespace() {
2804        let p: ProviderConfig = "  openai  ".parse().unwrap();
2805        assert_eq!(p.type_name(), "openai");
2806    }
2807
2808    #[test]
2809    fn test_provider_config_from_str_empty() {
2810        let result = "".parse::<ProviderConfig>();
2811        assert!(result.is_err());
2812    }
2813
2814    // ---- validate_with_warnings error path ----
2815
2816    #[test]
2817    fn test_validate_with_warnings_error_propagation() {
2818        let content = r#"
2819[server]
2820[auth]
2821jwt_secret_env = "TEST_JWT_SECRET"
2822api_key_env = "TEST_API_KEY"
2823[database]
2824[models.bad]
2825provider = "nonexistent"
2826model = "x"
2827"#;
2828        let config: AresConfig = toml::from_str(content).unwrap();
2829        // Should return error, not warnings
2830        assert!(config.validate_with_warnings().is_err());
2831    }
2832
2833    // ---- Multiple models referencing same provider ----
2834
2835    #[test]
2836    fn test_multiple_models_same_provider() {
2837        set_test_env();
2838        let content = r#"
2839[server]
2840[auth]
2841jwt_secret_env = "TEST_JWT_SECRET"
2842api_key_env = "TEST_API_KEY"
2843[database]
2844[providers.p]
2845type = "openai"
2846api_key_env = "TEST_KEY"
2847api_base = "https://test.example.com/v1"
2848default_model = "m1"
2849[models.m1]
2850provider = "p"
2851model = "m1"
2852[models.m2]
2853provider = "p"
2854model = "m2"
2855[agents.a1]
2856model = "m1"
2857[workflows.w]
2858entry_agent = "a1"
2859"#;
2860        let config: AresConfig = toml::from_str(content).unwrap();
2861        assert!(config.validate().is_ok());
2862        // m2 model is unused (only a1 referenced in workflow), should warn
2863        let warnings = config.validate_with_warnings().unwrap();
2864        assert!(warnings.iter().any(|w| w.kind == ConfigWarningKind::UnusedModel && w.message.contains("m2")));
2865    }
2866
2867    // ---- ToolConfig with extra (flatten) fields from TOML ----
2868
2869    #[test]
2870    fn test_tool_config_with_extra_fields_from_toml() {
2871        let content = r#"
2872[server]
2873[auth]
2874jwt_secret_env = "JWT"
2875api_key_env = "API"
2876[database]
2877[tools.my_tool]
2878enabled = true
2879timeout_secs = 60
2880description = "Custom tool"
2881custom_param = "hello"
2882num_param = 42
2883"#;
2884        let config: AresConfig = toml::from_str(content).unwrap();
2885        let tool = config.get_tool("my_tool").unwrap();
2886        assert!(tool.enabled);
2887        assert_eq!(tool.timeout_secs, 60);
2888        assert_eq!(tool.description.as_deref(), Some("Custom tool"));
2889        assert_eq!(
2890            tool.extra.get("custom_param").and_then(|v| v.as_str()),
2891            Some("hello")
2892        );
2893        assert_eq!(
2894            tool.extra.get("num_param").and_then(|v| v.as_integer()),
2895            Some(42)
2896        );
2897    }
2898
2899    // ---- AgentConfig with extra (flatten) fields from TOML ----
2900
2901    #[test]
2902    fn test_agent_config_with_extra_fields_from_toml() {
2903        let content = r#"
2904[server]
2905[auth]
2906jwt_secret_env = "JWT"
2907api_key_env = "API"
2908[database]
2909[providers.p]
2910type = "openai"
2911api_key_env = "TEST_KEY"
2912api_base = "https://test.example.com/v1"
2913default_model = "m"
2914[models.m]
2915provider = "p"
2916model = "m"
2917[agents.my_agent]
2918model = "m"
2919custom_bool = true
2920"#;
2921        let config: AresConfig = toml::from_str(content).unwrap();
2922        let agent = config.get_agent("my_agent").unwrap();
2923        assert_eq!(agent.model, "m");
2924        assert_eq!(
2925            agent.extra.get("custom_bool").and_then(|v| v.as_bool()),
2926            Some(true)
2927        );
2928    }
2929
2930    // ---- AresConfig serde roundtrip ----
2931
2932    #[test]
2933    fn test_ares_config_serde_roundtrip() {
2934        let content = create_test_config();
2935        let config: AresConfig = toml::from_str(&content).unwrap();
2936        let serialized = toml::to_string(&config).unwrap();
2937        let decoded: AresConfig = toml::from_str(&serialized).unwrap();
2938        assert_eq!(decoded.server.host, config.server.host);
2939        assert_eq!(decoded.server.port, config.server.port);
2940        assert_eq!(decoded.providers.len(), config.providers.len());
2941        assert_eq!(decoded.models.len(), config.models.len());
2942        assert_eq!(decoded.tools.len(), config.tools.len());
2943        assert_eq!(decoded.agents.len(), config.agents.len());
2944        assert_eq!(decoded.workflows.len(), config.workflows.len());
2945    }
2946
2947    // ---- QdrantConfig parsed from full AresConfig TOML ----
2948
2949    #[test]
2950    fn test_database_qdrant_parsed_from_toml() {
2951        let content = r#"
2952[server]
2953[auth]
2954jwt_secret_env = "JWT"
2955api_key_env = "API"
2956[database]
2957url = "postgres://host/db"
2958[database.qdrant]
2959url = "http://qdrant:6333"
2960api_key_env = "Q_KEY"
2961"#;
2962        let config: AresConfig = toml::from_str(content).unwrap();
2963        assert!(config.database.qdrant.is_some());
2964        let q = config.database.qdrant.unwrap();
2965        assert_eq!(q.url, "http://qdrant:6333");
2966        assert_eq!(q.api_key_env.as_deref(), Some("Q_KEY"));
2967    }
2968
2969    // ---- ConfigWarning Display covers the message field ----
2970
2971    #[test]
2972    fn test_config_warning_display_full_message() {
2973        let w = ConfigWarning {
2974            kind: ConfigWarningKind::UnusedTool,
2975            message: "Tool 'xyz' is defined but not referenced by any agent".into(),
2976        };
2977        assert_eq!(w.to_string(), "Tool 'xyz' is defined but not referenced by any agent");
2978    }
2979
2980    // ---- DynamicConfigPaths parsed from TOML sub-table ----
2981
2982    #[test]
2983    fn test_dynamic_config_paths_partial_override() {
2984        let content = r#"
2985[server]
2986[auth]
2987jwt_secret_env = "JWT"
2988api_key_env = "API"
2989[database]
2990[config]
2991agents_dir = "/only/agents"
2992"#;
2993        let config: AresConfig = toml::from_str(content).unwrap();
2994        assert_eq!(config.config.agents_dir, Path::new("/only/agents"));
2995        // Others should be defaults
2996        assert_eq!(config.config.workflows_dir, Path::new("config/workflows"));
2997        assert_eq!(config.config.models_dir, Path::new("config/models"));
2998        assert!(config.config.hot_reload);
2999    }
3000
3001    // ---- Validate: enabled tool in agent_tools ----
3002
3003    #[test]
3004    fn test_agent_tools_with_no_tools_agent() {
3005        set_test_env();
3006        let content = r#"
3007[server]
3008[auth]
3009jwt_secret_env = "TEST_JWT_SECRET"
3010api_key_env = "TEST_API_KEY"
3011[database]
3012[providers.p]
3013type = "openai"
3014api_key_env = "TEST_KEY"
3015api_base = "https://test.example.com/v1"
3016default_model = "m"
3017[models.m]
3018provider = "p"
3019model = "m"
3020[agents.a]
3021model = "m"
3022[workflows.w]
3023entry_agent = "a"
3024"#;
3025        let config: AresConfig = toml::from_str(content).unwrap();
3026        assert!(config.agent_tools("a").is_empty());
3027    }
3028
3029    // ---- Pricing with None token costs ----
3030
3031    #[test]
3032    fn test_model_pricing_none_costs() {
3033        let mp = ModelPricingConfig {
3034            provider: "p".into(),
3035            model: "m".into(),
3036            input_usd_per_million_tokens: None,
3037            output_usd_per_million_tokens: None,
3038            currency: "USD".into(),
3039        };
3040        let toml_str = toml::to_string(&mp).unwrap();
3041        let decoded: ModelPricingConfig = toml::from_str(&toml_str).unwrap();
3042        assert!(decoded.input_usd_per_million_tokens.is_none());
3043        assert!(decoded.output_usd_per_million_tokens.is_none());
3044    }
3045
3046    // ---- ConfigManager Clone ----
3047
3048    #[test]
3049    fn test_config_manager_clone_reads_same_config() {
3050        let config = toml::from_str(
3051            r#"
3052[server]
3053port = 42
3054[auth]
3055jwt_secret_env = "JWT"
3056api_key_env = "API"
3057[database]
3058"#,
3059        ).unwrap();
3060        let manager = AresConfigManager::from_config(config);
3061        let cloned = manager.clone();
3062        assert_eq!(manager.config().server.port, cloned.config().server.port);
3063    }
3064
3065    // ---- RAG sub-config defaults completeness ----
3066
3067    #[test]
3068    fn test_rag_search_default_threshold() {
3069        let s = RagSearchConfig::default();
3070        assert!((s.search_threshold).abs() < f32::EPSILON);
3071    }
3072
3073    // ---- LlamaCpp defaults from FromStr ----
3074
3075
3076    #[test]
3077    fn test_provider_config_from_str_openai_defaults() {
3078        // After the NVIDIA-only refactor, the `openai` and `nvidia`
3079        // literals both parse to the same default OpenAI-compatible
3080        // provider pointing at the NVIDIA NIM catalog. Overriding any of
3081        // these from the TOML is still possible via the [nvidia] section
3082        // and per-agent model fields.
3083        let p: ProviderConfig = "openai".parse().unwrap();
3084        if let ProviderConfig::OpenAI { api_key_env, api_base, default_model } = p {
3085            assert_eq!(api_key_env, "NVIDIA_API_KEY");
3086            assert_eq!(api_base, "https://integrate.api.nvidia.com/v1");
3087            assert_eq!(default_model, "nvidia/nemotron-3-ultra-550b-a55b");
3088        } else {
3089            panic!("expected openai variant");
3090        }
3091    }
3092
3093
3094
3095    #[test]
3096    fn test_tool_config_default_struct() {
3097        let tool = ToolConfig::default();
3098        assert!(tool.enabled);
3099        assert!(tool.description.is_none());
3100        assert_eq!(tool.timeout_secs, 30);
3101        assert!(tool.extra.is_empty());
3102    }
3103
3104    #[test]
3105    fn test_qdrant_config_default_struct() {
3106        let qdrant = QdrantConfig::default();
3107        assert_eq!(qdrant.url, "http://localhost:6334");
3108        assert!(qdrant.api_key_env.is_none());
3109    }
3110
3111    #[test]
3112    fn ares_config_still_deserializes_from_toml_config_test_fixture() {
3113        let config: AresConfig =
3114            toml::from_str(&create_test_config()).expect("fixture should parse");
3115        assert_eq!(config.server.port, 3000);
3116        assert_eq!(config.server.host, "127.0.0.1");
3117        assert_eq!(config.auth.jwt_secret_env, "TEST_JWT_SECRET");
3118        assert_eq!(config.database.url, "./data/test.db");
3119        assert!(config.agents.contains_key("router"));
3120        assert!(config.tools.contains_key("calculator"));
3121        assert!(!config.billing.model_pricing.is_empty());
3122    }
3123
3124    #[test]
3125    fn test_ares_config_load_success() {
3126        set_test_env();
3127        let dir = std::env::temp_dir().join("ares_load_success_test");
3128        std::fs::create_dir_all(&dir).unwrap();
3129        let path = dir.join("ares.toml");
3130        std::fs::write(&path, create_test_config()).unwrap();
3131
3132        let config = AresConfig::load(&path).expect("load should succeed");
3133        assert_eq!(config.server.port, 3000);
3134        assert!(config.providers.contains_key("ollama-local"));
3135
3136        std::fs::remove_dir_all(&dir).ok();
3137    }
3138
3139}