1use crate::error::{CodeError, Result};
13use crate::llm::LlmConfig;
14use crate::memory::MemoryConfig;
15use serde::{Deserialize, Serialize};
16use serde_json::Value as JsonValue;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25#[serde(rename_all = "camelCase")]
26pub struct ModelCost {
27 #[serde(default)]
29 pub input: f64,
30 #[serde(default)]
32 pub output: f64,
33 #[serde(default)]
35 pub cache_read: f64,
36 #[serde(default)]
38 pub cache_write: f64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ModelLimit {
44 #[serde(default)]
46 pub context: u32,
47 #[serde(default)]
49 pub output: u32,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct ModelModalities {
55 #[serde(default)]
57 pub input: Vec<String>,
58 #[serde(default)]
60 pub output: Vec<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ModelConfig {
67 pub id: String,
69 #[serde(default)]
71 pub name: String,
72 #[serde(default)]
74 pub family: String,
75 #[serde(default)]
77 pub api_key: Option<String>,
78 #[serde(default)]
80 pub base_url: Option<String>,
81 #[serde(default)]
83 pub attachment: bool,
84 #[serde(default)]
86 pub reasoning: bool,
87 #[serde(default = "default_true")]
89 pub tool_call: bool,
90 #[serde(default = "default_true")]
92 pub temperature: bool,
93 #[serde(default)]
95 pub release_date: Option<String>,
96 #[serde(default)]
98 pub modalities: ModelModalities,
99 #[serde(default)]
101 pub cost: ModelCost,
102 #[serde(default)]
104 pub limit: ModelLimit,
105}
106
107fn default_true() -> bool {
108 true
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct ProviderConfig {
115 pub name: String,
117 #[serde(default)]
119 pub api_key: Option<String>,
120 #[serde(default)]
122 pub base_url: Option<String>,
123 #[serde(default)]
125 pub models: Vec<ModelConfig>,
126}
127
128fn apply_model_caps(
134 mut config: LlmConfig,
135 model: &ModelConfig,
136 thinking_budget: Option<usize>,
137) -> LlmConfig {
138 if model.reasoning {
140 if let Some(budget) = thinking_budget {
141 config = config.with_thinking_budget(budget);
142 }
143 }
144
145 if model.limit.output > 0 {
147 config = config.with_max_tokens(model.limit.output as usize);
148 }
149
150 if !model.temperature {
153 config.disable_temperature = true;
154 }
155
156 config
157}
158
159impl ProviderConfig {
160 pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
162 self.models.iter().find(|m| m.id == model_id)
163 }
164
165 pub fn get_api_key<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
167 model.api_key.as_deref().or(self.api_key.as_deref())
168 }
169
170 pub fn get_base_url<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
172 model.base_url.as_deref().or(self.base_url.as_deref())
173 }
174}
175
176#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
182#[serde(rename_all = "lowercase")]
183pub enum StorageBackend {
184 Memory,
186 #[default]
188 File,
189 Custom,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, Default)]
202#[serde(rename_all = "camelCase")]
203pub struct CodeConfig {
204 #[serde(default, alias = "default_model")]
206 pub default_model: Option<String>,
207
208 #[serde(default)]
210 pub providers: Vec<ProviderConfig>,
211
212 #[serde(default)]
214 pub storage_backend: StorageBackend,
215
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub sessions_dir: Option<PathBuf>,
219
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub storage_url: Option<String>,
223
224 #[serde(default, alias = "skill_dirs")]
226 pub skill_dirs: Vec<PathBuf>,
227
228 #[serde(default, alias = "agent_dirs")]
230 pub agent_dirs: Vec<PathBuf>,
231
232 #[serde(default, alias = "max_tool_rounds")]
234 pub max_tool_rounds: Option<usize>,
235
236 #[serde(default, alias = "thinking_budget")]
238 pub thinking_budget: Option<usize>,
239
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub memory: Option<MemoryConfig>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub queue: Option<crate::queue::SessionQueueConfig>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub search: Option<SearchConfig>,
251
252 #[serde(default, alias = "mcp_servers")]
254 pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct SearchConfig {
261 #[serde(default = "default_search_timeout")]
263 pub timeout: u64,
264
265 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub health: Option<SearchHealthConfig>,
268
269 #[serde(default, rename = "engine")]
271 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct SearchHealthConfig {
278 #[serde(default = "default_max_failures")]
280 pub max_failures: u32,
281
282 #[serde(default = "default_suspend_seconds")]
284 pub suspend_seconds: u64,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct SearchEngineConfig {
291 #[serde(default = "default_enabled")]
293 pub enabled: bool,
294
295 #[serde(default = "default_weight")]
297 pub weight: f64,
298
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub timeout: Option<u64>,
302}
303
304fn default_search_timeout() -> u64 {
305 10
306}
307
308fn default_max_failures() -> u32 {
309 3
310}
311
312fn default_suspend_seconds() -> u64 {
313 60
314}
315
316fn default_enabled() -> bool {
317 true
318}
319
320fn default_weight() -> f64 {
321 1.0
322}
323
324impl CodeConfig {
325 pub fn new() -> Self {
327 Self::default()
328 }
329
330 pub fn from_file(path: &Path) -> Result<Self> {
334 let content = std::fs::read_to_string(path).map_err(|e| {
335 CodeError::Config(format!(
336 "Failed to read config file {}: {}",
337 path.display(),
338 e
339 ))
340 })?;
341
342 Self::from_hcl(&content).map_err(|e| {
343 CodeError::Config(format!(
344 "Failed to parse HCL config {}: {}",
345 path.display(),
346 e
347 ))
348 })
349 }
350
351 pub fn from_hcl(content: &str) -> Result<Self> {
357 let body: hcl::Body = hcl::from_str(content)
358 .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
359 let json_value = hcl_body_to_json(&body);
360 serde_json::from_value(json_value)
361 .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
362 }
363
364 pub fn save_to_file(&self, path: &Path) -> Result<()> {
368 if let Some(parent) = path.parent() {
369 std::fs::create_dir_all(parent).map_err(|e| {
370 CodeError::Config(format!(
371 "Failed to create config directory {}: {}",
372 parent.display(),
373 e
374 ))
375 })?;
376 }
377
378 let content = serde_json::to_string_pretty(self)
379 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
380
381 std::fs::write(path, content).map_err(|e| {
382 CodeError::Config(format!(
383 "Failed to write config file {}: {}",
384 path.display(),
385 e
386 ))
387 })?;
388
389 Ok(())
390 }
391
392 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
394 self.providers.iter().find(|p| p.name == name)
395 }
396
397 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
399 let default = self.default_model.as_ref()?;
400 let (provider_name, _) = default.split_once('/')?;
401 self.find_provider(provider_name)
402 }
403
404 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
406 let default = self.default_model.as_ref()?;
407 let (provider_name, model_id) = default.split_once('/')?;
408 let provider = self.find_provider(provider_name)?;
409 let model = provider.find_model(model_id)?;
410 Some((provider, model))
411 }
412
413 pub fn default_llm_config(&self) -> Option<LlmConfig> {
417 let (provider, model) = self.default_model_config()?;
418 let api_key = provider.get_api_key(model)?;
419 let base_url = provider.get_base_url(model);
420
421 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
422 if let Some(url) = base_url {
423 config = config.with_base_url(url);
424 }
425 config = apply_model_caps(config, model, self.thinking_budget);
426 Some(config)
427 }
428
429 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
433 let provider = self.find_provider(provider_name)?;
434 let model = provider.find_model(model_id)?;
435 let api_key = provider.get_api_key(model)?;
436 let base_url = provider.get_base_url(model);
437
438 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
439 if let Some(url) = base_url {
440 config = config.with_base_url(url);
441 }
442 config = apply_model_caps(config, model, self.thinking_budget);
443 Some(config)
444 }
445
446 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
448 self.providers
449 .iter()
450 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
451 .collect()
452 }
453
454 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
456 self.skill_dirs.push(dir.into());
457 self
458 }
459
460 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
462 self.agent_dirs.push(dir.into());
463 self
464 }
465
466 pub fn has_directories(&self) -> bool {
468 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
469 }
470
471 pub fn has_providers(&self) -> bool {
473 !self.providers.is_empty()
474 }
475}
476
477const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
483
484const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
488
489fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
491 hcl_body_to_json_inner(body, false)
492}
493
494fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
499 let mut map = serde_json::Map::new();
500
501 for attr in body.attributes() {
503 let key = if verbatim_keys {
504 attr.key.as_str().to_string()
505 } else {
506 snake_to_camel(attr.key.as_str())
507 };
508 let value = hcl_expr_to_json(attr.expr());
509 map.insert(key, value);
510 }
511
512 for block in body.blocks() {
514 let key = if verbatim_keys {
515 block.identifier.as_str().to_string()
516 } else {
517 snake_to_camel(block.identifier.as_str())
518 };
519 let child_verbatim = HCL_VERBATIM_BLOCKS.contains(&block.identifier.as_str());
521 let block_value = hcl_body_to_json_inner(block.body(), child_verbatim);
522
523 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
524 let arr = map
526 .entry(key)
527 .or_insert_with(|| JsonValue::Array(Vec::new()));
528 if let JsonValue::Array(ref mut vec) = arr {
529 vec.push(block_value);
530 }
531 } else {
532 map.insert(key, block_value);
533 }
534 }
535
536 JsonValue::Object(map)
537}
538
539fn snake_to_camel(s: &str) -> String {
541 let mut result = String::with_capacity(s.len());
542 let mut capitalize_next = false;
543 for ch in s.chars() {
544 if ch == '_' {
545 capitalize_next = true;
546 } else if capitalize_next {
547 result.extend(ch.to_uppercase());
548 capitalize_next = false;
549 } else {
550 result.push(ch);
551 }
552 }
553 result
554}
555
556fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
558 match expr {
559 hcl::Expression::String(s) => JsonValue::String(s.clone()),
560 hcl::Expression::Number(n) => {
561 if let Some(i) = n.as_i64() {
562 JsonValue::Number(i.into())
563 } else if let Some(f) = n.as_f64() {
564 serde_json::Number::from_f64(f)
565 .map(JsonValue::Number)
566 .unwrap_or(JsonValue::Null)
567 } else {
568 JsonValue::Null
569 }
570 }
571 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
572 hcl::Expression::Null => JsonValue::Null,
573 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
574 hcl::Expression::Object(obj) => {
575 let map: serde_json::Map<String, JsonValue> = obj
578 .iter()
579 .map(|(k, v)| {
580 let key = match k {
581 hcl::ObjectKey::Identifier(id) => id.as_str().to_string(),
582 hcl::ObjectKey::Expression(expr) => {
583 if let hcl::Expression::String(s) = expr {
584 s.clone()
585 } else {
586 format!("{:?}", expr)
587 }
588 }
589 _ => format!("{:?}", k),
590 };
591 (key, hcl_expr_to_json(v))
592 })
593 .collect();
594 JsonValue::Object(map)
595 }
596 hcl::Expression::FuncCall(func_call) => eval_func_call(func_call),
597 hcl::Expression::TemplateExpr(tmpl) => eval_template_expr(tmpl),
598 _ => JsonValue::String(format!("{:?}", expr)),
599 }
600}
601
602fn eval_func_call(func_call: &hcl::expr::FuncCall) -> JsonValue {
607 let name = func_call.name.name.as_str();
608 match name {
609 "env" => {
610 if let Some(arg) = func_call.args.first() {
611 let var_name = match arg {
612 hcl::Expression::String(s) => s.as_str(),
613 _ => {
614 tracing::warn!("env() expects a string argument, got: {:?}", arg);
615 return JsonValue::Null;
616 }
617 };
618 match std::env::var(var_name) {
619 Ok(val) => JsonValue::String(val),
620 Err(_) => {
621 tracing::debug!("env(\"{}\") is not set, returning null", var_name);
622 JsonValue::Null
623 }
624 }
625 } else {
626 tracing::warn!("env() called with no arguments");
627 JsonValue::Null
628 }
629 }
630 _ => {
631 tracing::warn!("Unsupported HCL function: {}()", name);
632 JsonValue::String(format!("{}()", name))
633 }
634 }
635}
636
637fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
642 JsonValue::String(format!("{}", tmpl))
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn test_config_default() {
656 let config = CodeConfig::default();
657 assert!(config.skill_dirs.is_empty());
658 assert!(config.agent_dirs.is_empty());
659 assert!(config.providers.is_empty());
660 assert!(config.default_model.is_none());
661 assert_eq!(config.storage_backend, StorageBackend::File);
662 assert!(config.sessions_dir.is_none());
663 }
664
665 #[test]
666 fn test_storage_backend_default() {
667 let backend = StorageBackend::default();
668 assert_eq!(backend, StorageBackend::File);
669 }
670
671 #[test]
672 fn test_storage_backend_serde() {
673 let memory = StorageBackend::Memory;
675 let json = serde_json::to_string(&memory).unwrap();
676 assert_eq!(json, "\"memory\"");
677
678 let file = StorageBackend::File;
679 let json = serde_json::to_string(&file).unwrap();
680 assert_eq!(json, "\"file\"");
681
682 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
684 assert_eq!(memory, StorageBackend::Memory);
685
686 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
687 assert_eq!(file, StorageBackend::File);
688 }
689
690 #[test]
691 fn test_config_with_storage_backend() {
692 let temp_dir = tempfile::tempdir().unwrap();
693 let config_path = temp_dir.path().join("config.hcl");
694
695 std::fs::write(
696 &config_path,
697 r#"
698 storage_backend = "memory"
699 sessions_dir = "/tmp/sessions"
700 "#,
701 )
702 .unwrap();
703
704 let config = CodeConfig::from_file(&config_path).unwrap();
705 assert_eq!(config.storage_backend, StorageBackend::Memory);
706 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
707 }
708
709 #[test]
710 fn test_config_builder() {
711 let config = CodeConfig::new()
712 .add_skill_dir("/tmp/skills")
713 .add_agent_dir("/tmp/agents");
714
715 assert_eq!(config.skill_dirs.len(), 1);
716 assert_eq!(config.agent_dirs.len(), 1);
717 }
718
719 #[test]
720 fn test_find_provider() {
721 let config = CodeConfig {
722 providers: vec![
723 ProviderConfig {
724 name: "anthropic".to_string(),
725 api_key: Some("key1".to_string()),
726 base_url: None,
727 models: vec![],
728 },
729 ProviderConfig {
730 name: "openai".to_string(),
731 api_key: Some("key2".to_string()),
732 base_url: None,
733 models: vec![],
734 },
735 ],
736 ..Default::default()
737 };
738
739 assert!(config.find_provider("anthropic").is_some());
740 assert!(config.find_provider("openai").is_some());
741 assert!(config.find_provider("unknown").is_none());
742 }
743
744 #[test]
745 fn test_default_llm_config() {
746 let config = CodeConfig {
747 default_model: Some("anthropic/claude-sonnet-4".to_string()),
748 providers: vec![ProviderConfig {
749 name: "anthropic".to_string(),
750 api_key: Some("test-api-key".to_string()),
751 base_url: Some("https://api.anthropic.com".to_string()),
752 models: vec![ModelConfig {
753 id: "claude-sonnet-4".to_string(),
754 name: "Claude Sonnet 4".to_string(),
755 family: "claude-sonnet".to_string(),
756 api_key: None,
757 base_url: None,
758 attachment: false,
759 reasoning: false,
760 tool_call: true,
761 temperature: true,
762 release_date: None,
763 modalities: ModelModalities::default(),
764 cost: ModelCost::default(),
765 limit: ModelLimit::default(),
766 }],
767 }],
768 ..Default::default()
769 };
770
771 let llm_config = config.default_llm_config().unwrap();
772 assert_eq!(llm_config.provider, "anthropic");
773 assert_eq!(llm_config.model, "claude-sonnet-4");
774 assert_eq!(llm_config.api_key.expose(), "test-api-key");
775 assert_eq!(
776 llm_config.base_url,
777 Some("https://api.anthropic.com".to_string())
778 );
779 }
780
781 #[test]
782 fn test_model_api_key_override() {
783 let provider = ProviderConfig {
784 name: "openai".to_string(),
785 api_key: Some("provider-key".to_string()),
786 base_url: Some("https://api.openai.com".to_string()),
787 models: vec![
788 ModelConfig {
789 id: "gpt-4".to_string(),
790 name: "GPT-4".to_string(),
791 family: "gpt".to_string(),
792 api_key: None, base_url: None,
794 attachment: false,
795 reasoning: false,
796 tool_call: true,
797 temperature: true,
798 release_date: None,
799 modalities: ModelModalities::default(),
800 cost: ModelCost::default(),
801 limit: ModelLimit::default(),
802 },
803 ModelConfig {
804 id: "custom-model".to_string(),
805 name: "Custom Model".to_string(),
806 family: "custom".to_string(),
807 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), attachment: false,
810 reasoning: false,
811 tool_call: true,
812 temperature: true,
813 release_date: None,
814 modalities: ModelModalities::default(),
815 cost: ModelCost::default(),
816 limit: ModelLimit::default(),
817 },
818 ],
819 };
820
821 let model1 = provider.find_model("gpt-4").unwrap();
823 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
824 assert_eq!(
825 provider.get_base_url(model1),
826 Some("https://api.openai.com")
827 );
828
829 let model2 = provider.find_model("custom-model").unwrap();
831 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
832 assert_eq!(
833 provider.get_base_url(model2),
834 Some("https://custom.api.com")
835 );
836 }
837
838 #[test]
839 fn test_list_models() {
840 let config = CodeConfig {
841 providers: vec![
842 ProviderConfig {
843 name: "anthropic".to_string(),
844 api_key: None,
845 base_url: None,
846 models: vec![
847 ModelConfig {
848 id: "claude-1".to_string(),
849 name: "Claude 1".to_string(),
850 family: "claude".to_string(),
851 api_key: None,
852 base_url: None,
853 attachment: false,
854 reasoning: false,
855 tool_call: true,
856 temperature: true,
857 release_date: None,
858 modalities: ModelModalities::default(),
859 cost: ModelCost::default(),
860 limit: ModelLimit::default(),
861 },
862 ModelConfig {
863 id: "claude-2".to_string(),
864 name: "Claude 2".to_string(),
865 family: "claude".to_string(),
866 api_key: None,
867 base_url: None,
868 attachment: false,
869 reasoning: false,
870 tool_call: true,
871 temperature: true,
872 release_date: None,
873 modalities: ModelModalities::default(),
874 cost: ModelCost::default(),
875 limit: ModelLimit::default(),
876 },
877 ],
878 },
879 ProviderConfig {
880 name: "openai".to_string(),
881 api_key: None,
882 base_url: None,
883 models: vec![ModelConfig {
884 id: "gpt-4".to_string(),
885 name: "GPT-4".to_string(),
886 family: "gpt".to_string(),
887 api_key: None,
888 base_url: None,
889 attachment: false,
890 reasoning: false,
891 tool_call: true,
892 temperature: true,
893 release_date: None,
894 modalities: ModelModalities::default(),
895 cost: ModelCost::default(),
896 limit: ModelLimit::default(),
897 }],
898 },
899 ],
900 ..Default::default()
901 };
902
903 let models = config.list_models();
904 assert_eq!(models.len(), 3);
905 }
906
907 #[test]
908 fn test_config_from_file_not_found() {
909 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
910 assert!(result.is_err());
911 }
912
913 #[test]
914 fn test_config_has_directories() {
915 let empty = CodeConfig::default();
916 assert!(!empty.has_directories());
917
918 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
919 assert!(with_skills.has_directories());
920
921 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
922 assert!(with_agents.has_directories());
923 }
924
925 #[test]
926 fn test_config_has_providers() {
927 let empty = CodeConfig::default();
928 assert!(!empty.has_providers());
929
930 let with_providers = CodeConfig {
931 providers: vec![ProviderConfig {
932 name: "test".to_string(),
933 api_key: None,
934 base_url: None,
935 models: vec![],
936 }],
937 ..Default::default()
938 };
939 assert!(with_providers.has_providers());
940 }
941
942 #[test]
943 fn test_storage_backend_equality() {
944 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
945 assert_eq!(StorageBackend::File, StorageBackend::File);
946 assert_ne!(StorageBackend::Memory, StorageBackend::File);
947 }
948
949 #[test]
950 fn test_storage_backend_serde_custom() {
951 let custom = StorageBackend::Custom;
952 let json = serde_json::to_string(&custom).unwrap();
954 assert_eq!(json, "\"custom\"");
955
956 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
958 assert_eq!(parsed, StorageBackend::Custom);
959 }
960
961 #[test]
962 fn test_model_cost_default() {
963 let cost = ModelCost::default();
964 assert_eq!(cost.input, 0.0);
965 assert_eq!(cost.output, 0.0);
966 assert_eq!(cost.cache_read, 0.0);
967 assert_eq!(cost.cache_write, 0.0);
968 }
969
970 #[test]
971 fn test_model_cost_serialization() {
972 let cost = ModelCost {
973 input: 3.0,
974 output: 15.0,
975 cache_read: 0.3,
976 cache_write: 3.75,
977 };
978 let json = serde_json::to_string(&cost).unwrap();
979 assert!(json.contains("\"input\":3"));
980 assert!(json.contains("\"output\":15"));
981 }
982
983 #[test]
984 fn test_model_cost_deserialization_missing_fields() {
985 let json = r#"{"input":3.0}"#;
986 let cost: ModelCost = serde_json::from_str(json).unwrap();
987 assert_eq!(cost.input, 3.0);
988 assert_eq!(cost.output, 0.0);
989 assert_eq!(cost.cache_read, 0.0);
990 assert_eq!(cost.cache_write, 0.0);
991 }
992
993 #[test]
994 fn test_model_limit_default() {
995 let limit = ModelLimit::default();
996 assert_eq!(limit.context, 0);
997 assert_eq!(limit.output, 0);
998 }
999
1000 #[test]
1001 fn test_model_limit_serialization() {
1002 let limit = ModelLimit {
1003 context: 200000,
1004 output: 8192,
1005 };
1006 let json = serde_json::to_string(&limit).unwrap();
1007 assert!(json.contains("\"context\":200000"));
1008 assert!(json.contains("\"output\":8192"));
1009 }
1010
1011 #[test]
1012 fn test_model_limit_deserialization_missing_fields() {
1013 let json = r#"{"context":100000}"#;
1014 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1015 assert_eq!(limit.context, 100000);
1016 assert_eq!(limit.output, 0);
1017 }
1018
1019 #[test]
1020 fn test_model_modalities_default() {
1021 let modalities = ModelModalities::default();
1022 assert!(modalities.input.is_empty());
1023 assert!(modalities.output.is_empty());
1024 }
1025
1026 #[test]
1027 fn test_model_modalities_serialization() {
1028 let modalities = ModelModalities {
1029 input: vec!["text".to_string(), "image".to_string()],
1030 output: vec!["text".to_string()],
1031 };
1032 let json = serde_json::to_string(&modalities).unwrap();
1033 assert!(json.contains("\"input\""));
1034 assert!(json.contains("\"text\""));
1035 }
1036
1037 #[test]
1038 fn test_model_modalities_deserialization_missing_fields() {
1039 let json = r#"{"input":["text"]}"#;
1040 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1041 assert_eq!(modalities.input.len(), 1);
1042 assert!(modalities.output.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_model_config_serialization() {
1047 let config = ModelConfig {
1048 id: "gpt-4o".to_string(),
1049 name: "GPT-4o".to_string(),
1050 family: "gpt-4".to_string(),
1051 api_key: Some("sk-test".to_string()),
1052 base_url: None,
1053 attachment: true,
1054 reasoning: false,
1055 tool_call: true,
1056 temperature: true,
1057 release_date: Some("2024-05-13".to_string()),
1058 modalities: ModelModalities::default(),
1059 cost: ModelCost::default(),
1060 limit: ModelLimit::default(),
1061 };
1062 let json = serde_json::to_string(&config).unwrap();
1063 assert!(json.contains("\"id\":\"gpt-4o\""));
1064 assert!(json.contains("\"attachment\":true"));
1065 }
1066
1067 #[test]
1068 fn test_model_config_deserialization_with_defaults() {
1069 let json = r#"{"id":"test-model"}"#;
1070 let config: ModelConfig = serde_json::from_str(json).unwrap();
1071 assert_eq!(config.id, "test-model");
1072 assert_eq!(config.name, "");
1073 assert_eq!(config.family, "");
1074 assert!(config.api_key.is_none());
1075 assert!(!config.attachment);
1076 assert!(config.tool_call);
1077 assert!(config.temperature);
1078 }
1079
1080 #[test]
1081 fn test_model_config_all_optional_fields() {
1082 let json = r#"{
1083 "id": "claude-sonnet-4",
1084 "name": "Claude Sonnet 4",
1085 "family": "claude-sonnet",
1086 "apiKey": "sk-test",
1087 "baseUrl": "https://api.anthropic.com",
1088 "attachment": true,
1089 "reasoning": true,
1090 "toolCall": false,
1091 "temperature": false,
1092 "releaseDate": "2025-05-14"
1093 }"#;
1094 let config: ModelConfig = serde_json::from_str(json).unwrap();
1095 assert_eq!(config.id, "claude-sonnet-4");
1096 assert_eq!(config.name, "Claude Sonnet 4");
1097 assert_eq!(config.api_key, Some("sk-test".to_string()));
1098 assert_eq!(
1099 config.base_url,
1100 Some("https://api.anthropic.com".to_string())
1101 );
1102 assert!(config.attachment);
1103 assert!(config.reasoning);
1104 assert!(!config.tool_call);
1105 assert!(!config.temperature);
1106 }
1107
1108 #[test]
1109 fn test_provider_config_serialization() {
1110 let provider = ProviderConfig {
1111 name: "anthropic".to_string(),
1112 api_key: Some("sk-test".to_string()),
1113 base_url: Some("https://api.anthropic.com".to_string()),
1114 models: vec![],
1115 };
1116 let json = serde_json::to_string(&provider).unwrap();
1117 assert!(json.contains("\"name\":\"anthropic\""));
1118 assert!(json.contains("\"apiKey\":\"sk-test\""));
1119 }
1120
1121 #[test]
1122 fn test_provider_config_deserialization_missing_optional() {
1123 let json = r#"{"name":"openai"}"#;
1124 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1125 assert_eq!(provider.name, "openai");
1126 assert!(provider.api_key.is_none());
1127 assert!(provider.base_url.is_none());
1128 assert!(provider.models.is_empty());
1129 }
1130
1131 #[test]
1132 fn test_provider_config_find_model() {
1133 let provider = ProviderConfig {
1134 name: "anthropic".to_string(),
1135 api_key: None,
1136 base_url: None,
1137 models: vec![ModelConfig {
1138 id: "claude-sonnet-4".to_string(),
1139 name: "Claude Sonnet 4".to_string(),
1140 family: "claude-sonnet".to_string(),
1141 api_key: None,
1142 base_url: None,
1143 attachment: false,
1144 reasoning: false,
1145 tool_call: true,
1146 temperature: true,
1147 release_date: None,
1148 modalities: ModelModalities::default(),
1149 cost: ModelCost::default(),
1150 limit: ModelLimit::default(),
1151 }],
1152 };
1153
1154 let found = provider.find_model("claude-sonnet-4");
1155 assert!(found.is_some());
1156 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1157
1158 let not_found = provider.find_model("gpt-4o");
1159 assert!(not_found.is_none());
1160 }
1161
1162 #[test]
1163 fn test_provider_config_get_api_key() {
1164 let provider = ProviderConfig {
1165 name: "anthropic".to_string(),
1166 api_key: Some("provider-key".to_string()),
1167 base_url: None,
1168 models: vec![],
1169 };
1170
1171 let model_with_key = ModelConfig {
1172 id: "test".to_string(),
1173 name: "".to_string(),
1174 family: "".to_string(),
1175 api_key: Some("model-key".to_string()),
1176 base_url: None,
1177 attachment: false,
1178 reasoning: false,
1179 tool_call: true,
1180 temperature: true,
1181 release_date: None,
1182 modalities: ModelModalities::default(),
1183 cost: ModelCost::default(),
1184 limit: ModelLimit::default(),
1185 };
1186
1187 let model_without_key = ModelConfig {
1188 id: "test2".to_string(),
1189 name: "".to_string(),
1190 family: "".to_string(),
1191 api_key: None,
1192 base_url: None,
1193 attachment: false,
1194 reasoning: false,
1195 tool_call: true,
1196 temperature: true,
1197 release_date: None,
1198 modalities: ModelModalities::default(),
1199 cost: ModelCost::default(),
1200 limit: ModelLimit::default(),
1201 };
1202
1203 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1204 assert_eq!(
1205 provider.get_api_key(&model_without_key),
1206 Some("provider-key")
1207 );
1208 }
1209
1210 #[test]
1211 fn test_code_config_default_provider_config() {
1212 let config = CodeConfig {
1213 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1214 providers: vec![ProviderConfig {
1215 name: "anthropic".to_string(),
1216 api_key: Some("sk-test".to_string()),
1217 base_url: None,
1218 models: vec![],
1219 }],
1220 ..Default::default()
1221 };
1222
1223 let provider = config.default_provider_config();
1224 assert!(provider.is_some());
1225 assert_eq!(provider.unwrap().name, "anthropic");
1226 }
1227
1228 #[test]
1229 fn test_code_config_default_model_config() {
1230 let config = CodeConfig {
1231 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1232 providers: vec![ProviderConfig {
1233 name: "anthropic".to_string(),
1234 api_key: Some("sk-test".to_string()),
1235 base_url: None,
1236 models: vec![ModelConfig {
1237 id: "claude-sonnet-4".to_string(),
1238 name: "Claude Sonnet 4".to_string(),
1239 family: "claude-sonnet".to_string(),
1240 api_key: None,
1241 base_url: None,
1242 attachment: false,
1243 reasoning: false,
1244 tool_call: true,
1245 temperature: true,
1246 release_date: None,
1247 modalities: ModelModalities::default(),
1248 cost: ModelCost::default(),
1249 limit: ModelLimit::default(),
1250 }],
1251 }],
1252 ..Default::default()
1253 };
1254
1255 let result = config.default_model_config();
1256 assert!(result.is_some());
1257 let (provider, model) = result.unwrap();
1258 assert_eq!(provider.name, "anthropic");
1259 assert_eq!(model.id, "claude-sonnet-4");
1260 }
1261
1262 #[test]
1263 fn test_code_config_default_llm_config() {
1264 let config = CodeConfig {
1265 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1266 providers: vec![ProviderConfig {
1267 name: "anthropic".to_string(),
1268 api_key: Some("sk-test".to_string()),
1269 base_url: Some("https://api.anthropic.com".to_string()),
1270 models: vec![ModelConfig {
1271 id: "claude-sonnet-4".to_string(),
1272 name: "Claude Sonnet 4".to_string(),
1273 family: "claude-sonnet".to_string(),
1274 api_key: None,
1275 base_url: None,
1276 attachment: false,
1277 reasoning: false,
1278 tool_call: true,
1279 temperature: true,
1280 release_date: None,
1281 modalities: ModelModalities::default(),
1282 cost: ModelCost::default(),
1283 limit: ModelLimit::default(),
1284 }],
1285 }],
1286 ..Default::default()
1287 };
1288
1289 let llm_config = config.default_llm_config();
1290 assert!(llm_config.is_some());
1291 }
1292
1293 #[test]
1294 fn test_code_config_list_models() {
1295 let config = CodeConfig {
1296 providers: vec![
1297 ProviderConfig {
1298 name: "anthropic".to_string(),
1299 api_key: None,
1300 base_url: None,
1301 models: vec![ModelConfig {
1302 id: "claude-sonnet-4".to_string(),
1303 name: "".to_string(),
1304 family: "".to_string(),
1305 api_key: None,
1306 base_url: None,
1307 attachment: false,
1308 reasoning: false,
1309 tool_call: true,
1310 temperature: true,
1311 release_date: None,
1312 modalities: ModelModalities::default(),
1313 cost: ModelCost::default(),
1314 limit: ModelLimit::default(),
1315 }],
1316 },
1317 ProviderConfig {
1318 name: "openai".to_string(),
1319 api_key: None,
1320 base_url: None,
1321 models: vec![ModelConfig {
1322 id: "gpt-4o".to_string(),
1323 name: "".to_string(),
1324 family: "".to_string(),
1325 api_key: None,
1326 base_url: None,
1327 attachment: false,
1328 reasoning: false,
1329 tool_call: true,
1330 temperature: true,
1331 release_date: None,
1332 modalities: ModelModalities::default(),
1333 cost: ModelCost::default(),
1334 limit: ModelLimit::default(),
1335 }],
1336 },
1337 ],
1338 ..Default::default()
1339 };
1340
1341 let models = config.list_models();
1342 assert_eq!(models.len(), 2);
1343 }
1344
1345 #[test]
1346 fn test_llm_config_specific_provider_model() {
1347 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1348 "id": "claude-3",
1349 "name": "Claude 3"
1350 }))
1351 .unwrap();
1352
1353 let config = CodeConfig {
1354 providers: vec![ProviderConfig {
1355 name: "anthropic".to_string(),
1356 api_key: Some("sk-test".to_string()),
1357 base_url: None,
1358 models: vec![model],
1359 }],
1360 ..Default::default()
1361 };
1362
1363 let llm = config.llm_config("anthropic", "claude-3");
1364 assert!(llm.is_some());
1365 let llm = llm.unwrap();
1366 assert_eq!(llm.provider, "anthropic");
1367 assert_eq!(llm.model, "claude-3");
1368 }
1369
1370 #[test]
1371 fn test_llm_config_missing_provider() {
1372 let config = CodeConfig::default();
1373 assert!(config.llm_config("nonexistent", "model").is_none());
1374 }
1375
1376 #[test]
1377 fn test_llm_config_missing_model() {
1378 let config = CodeConfig {
1379 providers: vec![ProviderConfig {
1380 name: "anthropic".to_string(),
1381 api_key: Some("sk-test".to_string()),
1382 base_url: None,
1383 models: vec![],
1384 }],
1385 ..Default::default()
1386 };
1387 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1388 }
1389
1390 #[test]
1391 fn test_from_hcl_string() {
1392 let hcl = r#"
1393 default_model = "anthropic/claude-sonnet-4"
1394
1395 providers {
1396 name = "anthropic"
1397 api_key = "test-key"
1398
1399 models {
1400 id = "claude-sonnet-4"
1401 name = "Claude Sonnet 4"
1402 }
1403 }
1404 "#;
1405
1406 let config = CodeConfig::from_hcl(hcl).unwrap();
1407 assert_eq!(
1408 config.default_model,
1409 Some("anthropic/claude-sonnet-4".to_string())
1410 );
1411 assert_eq!(config.providers.len(), 1);
1412 assert_eq!(config.providers[0].name, "anthropic");
1413 assert_eq!(config.providers[0].models.len(), 1);
1414 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1415 }
1416
1417 #[test]
1418 fn test_from_hcl_multi_provider() {
1419 let hcl = r#"
1420 default_model = "anthropic/claude-sonnet-4"
1421
1422 providers {
1423 name = "anthropic"
1424 api_key = "sk-ant-test"
1425
1426 models {
1427 id = "claude-sonnet-4"
1428 name = "Claude Sonnet 4"
1429 }
1430
1431 models {
1432 id = "claude-opus-4"
1433 name = "Claude Opus 4"
1434 reasoning = true
1435 }
1436 }
1437
1438 providers {
1439 name = "openai"
1440 api_key = "sk-test"
1441
1442 models {
1443 id = "gpt-4o"
1444 name = "GPT-4o"
1445 }
1446 }
1447 "#;
1448
1449 let config = CodeConfig::from_hcl(hcl).unwrap();
1450 assert_eq!(config.providers.len(), 2);
1451 assert_eq!(config.providers[0].models.len(), 2);
1452 assert_eq!(config.providers[1].models.len(), 1);
1453 assert_eq!(config.providers[1].name, "openai");
1454 }
1455
1456 #[test]
1457 fn test_snake_to_camel() {
1458 assert_eq!(snake_to_camel("default_model"), "defaultModel");
1459 assert_eq!(snake_to_camel("api_key"), "apiKey");
1460 assert_eq!(snake_to_camel("base_url"), "baseUrl");
1461 assert_eq!(snake_to_camel("name"), "name");
1462 assert_eq!(snake_to_camel("tool_call"), "toolCall");
1463 }
1464
1465 #[test]
1466 fn test_from_file_auto_detect_hcl() {
1467 let temp_dir = tempfile::tempdir().unwrap();
1468 let config_path = temp_dir.path().join("config.hcl");
1469
1470 std::fs::write(
1471 &config_path,
1472 r#"
1473 default_model = "anthropic/claude-sonnet-4"
1474
1475 providers {
1476 name = "anthropic"
1477 api_key = "test-key"
1478
1479 models {
1480 id = "claude-sonnet-4"
1481 }
1482 }
1483 "#,
1484 )
1485 .unwrap();
1486
1487 let config = CodeConfig::from_file(&config_path).unwrap();
1488 assert_eq!(
1489 config.default_model,
1490 Some("anthropic/claude-sonnet-4".to_string())
1491 );
1492 }
1493
1494 #[test]
1495 fn test_from_hcl_with_queue_config() {
1496 let hcl = r#"
1497 default_model = "anthropic/claude-sonnet-4"
1498
1499 providers {
1500 name = "anthropic"
1501 api_key = "test-key"
1502 }
1503
1504 queue {
1505 query_max_concurrency = 20
1506 execute_max_concurrency = 5
1507 enable_metrics = true
1508 enable_dlq = true
1509 }
1510 "#;
1511
1512 let config = CodeConfig::from_hcl(hcl).unwrap();
1513 assert!(config.queue.is_some());
1514 let queue = config.queue.unwrap();
1515 assert_eq!(queue.query_max_concurrency, 20);
1516 assert_eq!(queue.execute_max_concurrency, 5);
1517 assert!(queue.enable_metrics);
1518 assert!(queue.enable_dlq);
1519 }
1520
1521 #[test]
1522 fn test_from_hcl_with_search_config() {
1523 let hcl = r#"
1524 default_model = "anthropic/claude-sonnet-4"
1525
1526 providers {
1527 name = "anthropic"
1528 api_key = "test-key"
1529 }
1530
1531 search {
1532 timeout = 30
1533
1534 health {
1535 max_failures = 5
1536 suspend_seconds = 120
1537 }
1538
1539 engine {
1540 google {
1541 enabled = true
1542 weight = 1.5
1543 }
1544 bing {
1545 enabled = true
1546 weight = 1.0
1547 timeout = 15
1548 }
1549 }
1550 }
1551 "#;
1552
1553 let config = CodeConfig::from_hcl(hcl).unwrap();
1554 assert!(config.search.is_some());
1555 let search = config.search.unwrap();
1556 assert_eq!(search.timeout, 30);
1557 assert!(search.health.is_some());
1558 let health = search.health.unwrap();
1559 assert_eq!(health.max_failures, 5);
1560 assert_eq!(health.suspend_seconds, 120);
1561 assert_eq!(search.engines.len(), 2);
1562 assert!(search.engines.contains_key("google"));
1563 assert!(search.engines.contains_key("bing"));
1564 let google = &search.engines["google"];
1565 assert!(google.enabled);
1566 assert_eq!(google.weight, 1.5);
1567 let bing = &search.engines["bing"];
1568 assert_eq!(bing.timeout, Some(15));
1569 }
1570
1571 #[test]
1572 fn test_from_hcl_with_queue_and_search() {
1573 let hcl = r#"
1574 default_model = "anthropic/claude-sonnet-4"
1575
1576 providers {
1577 name = "anthropic"
1578 api_key = "test-key"
1579 }
1580
1581 queue {
1582 query_max_concurrency = 10
1583 enable_metrics = true
1584 }
1585
1586 search {
1587 timeout = 20
1588 engine {
1589 duckduckgo {
1590 enabled = true
1591 }
1592 }
1593 }
1594 "#;
1595
1596 let config = CodeConfig::from_hcl(hcl).unwrap();
1597 assert!(config.queue.is_some());
1598 assert!(config.search.is_some());
1599 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
1600 assert_eq!(config.search.unwrap().timeout, 20);
1601 }
1602
1603 #[test]
1604 fn test_from_hcl_multiple_mcp_servers() {
1605 let hcl = r#"
1606 mcp_servers {
1607 name = "fetch"
1608 transport = "stdio"
1609 command = "npx"
1610 args = ["-y", "@modelcontextprotocol/server-fetch"]
1611 enabled = true
1612 }
1613
1614 mcp_servers {
1615 name = "puppeteer"
1616 transport = "stdio"
1617 command = "npx"
1618 args = ["-y", "@anthropic/mcp-server-puppeteer"]
1619 enabled = true
1620 }
1621
1622 mcp_servers {
1623 name = "filesystem"
1624 transport = "stdio"
1625 command = "npx"
1626 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1627 enabled = false
1628 }
1629 "#;
1630
1631 let config = CodeConfig::from_hcl(hcl).unwrap();
1632 assert_eq!(
1633 config.mcp_servers.len(),
1634 3,
1635 "all 3 mcp_servers blocks should be parsed"
1636 );
1637 assert_eq!(config.mcp_servers[0].name, "fetch");
1638 assert_eq!(config.mcp_servers[1].name, "puppeteer");
1639 assert_eq!(config.mcp_servers[2].name, "filesystem");
1640 assert!(config.mcp_servers[0].enabled);
1641 assert!(!config.mcp_servers[2].enabled);
1642 }
1643
1644 #[test]
1645 fn test_from_hcl_with_advanced_queue_config() {
1646 let hcl = r#"
1647 default_model = "anthropic/claude-sonnet-4"
1648
1649 providers {
1650 name = "anthropic"
1651 api_key = "test-key"
1652 }
1653
1654 queue {
1655 query_max_concurrency = 20
1656 enable_metrics = true
1657
1658 retry_policy {
1659 strategy = "exponential"
1660 max_retries = 5
1661 initial_delay_ms = 200
1662 }
1663
1664 rate_limit {
1665 limit_type = "per_second"
1666 max_operations = 100
1667 }
1668
1669 priority_boost {
1670 strategy = "standard"
1671 deadline_ms = 300000
1672 }
1673
1674 pressure_threshold = 50
1675 }
1676 "#;
1677
1678 let config = CodeConfig::from_hcl(hcl).unwrap();
1679 assert!(config.queue.is_some());
1680 let queue = config.queue.unwrap();
1681
1682 assert_eq!(queue.query_max_concurrency, 20);
1683 assert!(queue.enable_metrics);
1684
1685 assert!(queue.retry_policy.is_some());
1687 let retry = queue.retry_policy.unwrap();
1688 assert_eq!(retry.strategy, "exponential");
1689 assert_eq!(retry.max_retries, 5);
1690 assert_eq!(retry.initial_delay_ms, 200);
1691
1692 assert!(queue.rate_limit.is_some());
1694 let rate = queue.rate_limit.unwrap();
1695 assert_eq!(rate.limit_type, "per_second");
1696 assert_eq!(rate.max_operations, Some(100));
1697
1698 assert!(queue.priority_boost.is_some());
1700 let boost = queue.priority_boost.unwrap();
1701 assert_eq!(boost.strategy, "standard");
1702 assert_eq!(boost.deadline_ms, Some(300000));
1703
1704 assert_eq!(queue.pressure_threshold, Some(50));
1706 }
1707
1708 #[test]
1709 fn test_hcl_env_function_resolved() {
1710 std::env::set_var("A3S_TEST_HCL_KEY", "test-secret-key-123");
1712
1713 let hcl_str = r#"
1714 providers {
1715 name = "test"
1716 api_key = env("A3S_TEST_HCL_KEY")
1717 }
1718 "#;
1719
1720 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1721 let json = hcl_body_to_json(&body);
1722
1723 let providers = json.get("providers").unwrap();
1725 let provider = providers.as_array().unwrap().first().unwrap();
1726 let api_key = provider.get("apiKey").unwrap();
1727
1728 assert_eq!(api_key.as_str().unwrap(), "test-secret-key-123");
1729
1730 std::env::remove_var("A3S_TEST_HCL_KEY");
1732 }
1733
1734 #[test]
1735 fn test_hcl_env_function_unset_returns_null() {
1736 std::env::remove_var("A3S_TEST_NONEXISTENT_VAR_12345");
1738
1739 let hcl_str = r#"
1740 providers {
1741 name = "test"
1742 api_key = env("A3S_TEST_NONEXISTENT_VAR_12345")
1743 }
1744 "#;
1745
1746 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1747 let json = hcl_body_to_json(&body);
1748
1749 let providers = json.get("providers").unwrap();
1750 let provider = providers.as_array().unwrap().first().unwrap();
1751 let api_key = provider.get("apiKey").unwrap();
1752
1753 assert!(api_key.is_null(), "Unset env var should return null");
1754 }
1755
1756 #[test]
1757 fn test_hcl_mcp_env_block_preserves_var_names() {
1758 std::env::set_var("A3S_TEST_SECRET", "my-secret");
1760
1761 let hcl_str = r#"
1762 mcp_servers {
1763 name = "test-server"
1764 transport = "stdio"
1765 command = "echo"
1766 env = {
1767 API_KEY = "sk-test-123"
1768 ANTHROPIC_API_KEY = env("A3S_TEST_SECRET")
1769 SIMPLE = "value"
1770 }
1771 }
1772 "#;
1773
1774 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1775 let json = hcl_body_to_json(&body);
1776
1777 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
1778 let server = &servers[0];
1779 let env = server.get("env").unwrap().as_object().unwrap();
1780
1781 assert_eq!(env.get("API_KEY").unwrap().as_str().unwrap(), "sk-test-123");
1783 assert_eq!(
1784 env.get("ANTHROPIC_API_KEY").unwrap().as_str().unwrap(),
1785 "my-secret"
1786 );
1787 assert_eq!(env.get("SIMPLE").unwrap().as_str().unwrap(), "value");
1788
1789 assert!(
1791 env.get("apiKey").is_none(),
1792 "env var key should not be camelCase'd"
1793 );
1794 assert!(
1795 env.get("APIKEY").is_none(),
1796 "env var key should not have underscores stripped"
1797 );
1798 assert!(env.get("anthropicApiKey").is_none());
1799
1800 std::env::remove_var("A3S_TEST_SECRET");
1801 }
1802
1803 #[test]
1804 fn test_hcl_mcp_env_as_block_syntax() {
1805 let hcl_str = r#"
1807 mcp_servers {
1808 name = "test-server"
1809 transport = "stdio"
1810 command = "echo"
1811 env {
1812 MY_VAR = "hello"
1813 OTHER_VAR = "world"
1814 }
1815 }
1816 "#;
1817
1818 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1819 let json = hcl_body_to_json(&body);
1820
1821 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
1822 let server = &servers[0];
1823 let env = server.get("env").unwrap().as_object().unwrap();
1824
1825 assert_eq!(env.get("MY_VAR").unwrap().as_str().unwrap(), "hello");
1826 assert_eq!(env.get("OTHER_VAR").unwrap().as_str().unwrap(), "world");
1827 assert!(
1828 env.get("myVar").is_none(),
1829 "block env keys should not be camelCase'd"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_hcl_mcp_full_deserialization_with_env() {
1835 std::env::set_var("A3S_TEST_MCP_KEY", "resolved-secret");
1837
1838 let hcl_str = r#"
1839 mcp_servers {
1840 name = "fetch"
1841 transport = "stdio"
1842 command = "npx"
1843 args = ["-y", "@modelcontextprotocol/server-fetch"]
1844 env = {
1845 NODE_ENV = "production"
1846 API_KEY = env("A3S_TEST_MCP_KEY")
1847 }
1848 tool_timeout_secs = 120
1849 }
1850 "#;
1851
1852 let config = CodeConfig::from_hcl(hcl_str).unwrap();
1853 assert_eq!(config.mcp_servers.len(), 1);
1854
1855 let server = &config.mcp_servers[0];
1856 assert_eq!(server.name, "fetch");
1857 assert_eq!(server.env.get("NODE_ENV").unwrap(), "production");
1858 assert_eq!(server.env.get("API_KEY").unwrap(), "resolved-secret");
1859 assert_eq!(server.tool_timeout_secs, 120);
1860
1861 std::env::remove_var("A3S_TEST_MCP_KEY");
1862 }
1863}