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