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, 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#[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 { "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
811pub type Overlay = AresConfigManager;
813
814#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
816pub struct OverlayConfig {
817 #[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
844fn 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 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 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
957pub 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 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 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 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 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 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 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 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 assert_eq!(config.auth.jwt_access_expiry, 900);
1251 assert_eq!(config.auth.jwt_refresh_expiry, 604800);
1252
1253 assert_eq!(
1255 config.database.url,
1256 "postgres://postgres:postgres@localhost:5432/ares"
1257 );
1258
1259 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1863 fn test_validation_missing_jwt_env_var() {
1864 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 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 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 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 #[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 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 #[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 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 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 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 #[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 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 unsafe { std::env::remove_var("MISSING_API_77777"); }
2359 assert!(matches!(config.api_key(), Err(ConfigError::MissingEnvVar(_))));
2360 }
2361
2362 #[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 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 unsafe { std::env::remove_var("MY_MISSING_RESOLVE_VAR"); }
2393 assert_eq!(config.resolve_env("MY_MISSING_RESOLVE_VAR"), None);
2394 }
2395
2396 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 assert!(config.validate_with_warnings().is_err());
2831 }
2832
2833 #[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 let warnings = config.validate_with_warnings().unwrap();
2864 assert!(warnings.iter().any(|w| w.kind == ConfigWarningKind::UnusedModel && w.message.contains("m2")));
2865 }
2866
2867 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[test]
3068 fn test_rag_search_default_threshold() {
3069 let s = RagSearchConfig::default();
3070 assert!((s.search_threshold).abs() < f32::EPSILON);
3071 }
3072
3073 #[test]
3077 fn test_provider_config_from_str_openai_defaults() {
3078 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}