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(
254 default,
255 alias = "agentic_search",
256 skip_serializing_if = "Option::is_none"
257 )]
258 pub agentic_search: Option<AgenticSearchConfig>,
259
260 #[serde(
262 default,
263 alias = "agentic_parse",
264 skip_serializing_if = "Option::is_none"
265 )]
266 pub agentic_parse: Option<AgenticParseConfig>,
267
268 #[serde(
270 default,
271 alias = "default_parser",
272 skip_serializing_if = "Option::is_none"
273 )]
274 pub default_parser: Option<DefaultParserConfig>,
275
276 #[serde(default, alias = "mcp_servers")]
278 pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct SearchConfig {
285 #[serde(default = "default_search_timeout")]
287 pub timeout: u64,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub health: Option<SearchHealthConfig>,
292
293 #[serde(default, rename = "engine")]
295 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct AgenticSearchConfig {
302 #[serde(default = "default_enabled")]
304 pub enabled: bool,
305
306 #[serde(default = "default_agentic_search_mode")]
308 pub default_mode: String,
309
310 #[serde(default = "default_agentic_search_max_results")]
312 pub max_results: usize,
313
314 #[serde(default = "default_agentic_search_context_lines")]
316 pub context_lines: usize,
317}
318
319impl Default for AgenticSearchConfig {
320 fn default() -> Self {
321 Self {
322 enabled: true,
323 default_mode: default_agentic_search_mode(),
324 max_results: default_agentic_search_max_results(),
325 context_lines: default_agentic_search_context_lines(),
326 }
327 }
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct AgenticParseConfig {
334 #[serde(default = "default_enabled")]
336 pub enabled: bool,
337
338 #[serde(default = "default_agentic_parse_strategy")]
340 pub default_strategy: String,
341
342 #[serde(default = "default_agentic_parse_max_chars")]
344 pub max_chars: usize,
345}
346
347impl Default for AgenticParseConfig {
348 fn default() -> Self {
349 Self {
350 enabled: true,
351 default_strategy: default_agentic_parse_strategy(),
352 max_chars: default_agentic_parse_max_chars(),
353 }
354 }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct DefaultParserConfig {
361 #[serde(default = "default_enabled")]
363 pub enabled: bool,
364
365 #[serde(default = "default_default_parser_max_file_size_mb")]
367 pub max_file_size_mb: u64,
368
369 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub ocr: Option<DefaultParserOcrConfig>,
376}
377
378impl Default for DefaultParserConfig {
379 fn default() -> Self {
380 Self {
381 enabled: true,
382 max_file_size_mb: default_default_parser_max_file_size_mb(),
383 ocr: None,
384 }
385 }
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
390#[serde(rename_all = "camelCase")]
391pub struct DefaultParserOcrConfig {
392 #[serde(default = "default_enabled")]
394 pub enabled: bool,
395
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub model: Option<String>,
399
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub prompt: Option<String>,
403
404 #[serde(default = "default_default_parser_ocr_max_images")]
406 pub max_images: usize,
407
408 #[serde(default = "default_default_parser_ocr_dpi")]
410 pub dpi: u32,
411}
412
413impl Default for DefaultParserOcrConfig {
414 fn default() -> Self {
415 Self {
416 enabled: false,
417 model: None,
418 prompt: None,
419 max_images: default_default_parser_ocr_max_images(),
420 dpi: default_default_parser_ocr_dpi(),
421 }
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427#[serde(rename_all = "camelCase")]
428pub struct SearchHealthConfig {
429 #[serde(default = "default_max_failures")]
431 pub max_failures: u32,
432
433 #[serde(default = "default_suspend_seconds")]
435 pub suspend_seconds: u64,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440#[serde(rename_all = "camelCase")]
441pub struct SearchEngineConfig {
442 #[serde(default = "default_enabled")]
444 pub enabled: bool,
445
446 #[serde(default = "default_weight")]
448 pub weight: f64,
449
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub timeout: Option<u64>,
453}
454
455fn default_search_timeout() -> u64 {
456 10
457}
458
459fn default_max_failures() -> u32 {
460 3
461}
462
463fn default_suspend_seconds() -> u64 {
464 60
465}
466
467fn default_enabled() -> bool {
468 true
469}
470
471fn default_weight() -> f64 {
472 1.0
473}
474
475fn default_agentic_search_mode() -> String {
476 "fast".to_string()
477}
478
479fn default_agentic_search_max_results() -> usize {
480 10
481}
482
483fn default_agentic_search_context_lines() -> usize {
484 2
485}
486
487fn default_agentic_parse_strategy() -> String {
488 "auto".to_string()
489}
490
491fn default_agentic_parse_max_chars() -> usize {
492 8000
493}
494
495fn default_default_parser_max_file_size_mb() -> u64 {
496 50
497}
498
499fn default_default_parser_ocr_max_images() -> usize {
500 8
501}
502
503fn default_default_parser_ocr_dpi() -> u32 {
504 144
505}
506
507impl CodeConfig {
508 pub fn new() -> Self {
510 Self::default()
511 }
512
513 pub fn from_file(path: &Path) -> Result<Self> {
517 let content = std::fs::read_to_string(path).map_err(|e| {
518 CodeError::Config(format!(
519 "Failed to read config file {}: {}",
520 path.display(),
521 e
522 ))
523 })?;
524
525 Self::from_hcl(&content).map_err(|e| {
526 CodeError::Config(format!(
527 "Failed to parse HCL config {}: {}",
528 path.display(),
529 e
530 ))
531 })
532 }
533
534 pub fn from_hcl(content: &str) -> Result<Self> {
540 let body: hcl::Body = hcl::from_str(content)
541 .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
542 let json_value = hcl_body_to_json(&body);
543 serde_json::from_value(json_value)
544 .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
545 }
546
547 pub fn save_to_file(&self, path: &Path) -> Result<()> {
551 if let Some(parent) = path.parent() {
552 std::fs::create_dir_all(parent).map_err(|e| {
553 CodeError::Config(format!(
554 "Failed to create config directory {}: {}",
555 parent.display(),
556 e
557 ))
558 })?;
559 }
560
561 let content = serde_json::to_string_pretty(self)
562 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
563
564 std::fs::write(path, content).map_err(|e| {
565 CodeError::Config(format!(
566 "Failed to write config file {}: {}",
567 path.display(),
568 e
569 ))
570 })?;
571
572 Ok(())
573 }
574
575 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
577 self.providers.iter().find(|p| p.name == name)
578 }
579
580 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
582 let default = self.default_model.as_ref()?;
583 let (provider_name, _) = default.split_once('/')?;
584 self.find_provider(provider_name)
585 }
586
587 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
589 let default = self.default_model.as_ref()?;
590 let (provider_name, model_id) = default.split_once('/')?;
591 let provider = self.find_provider(provider_name)?;
592 let model = provider.find_model(model_id)?;
593 Some((provider, model))
594 }
595
596 pub fn default_llm_config(&self) -> Option<LlmConfig> {
600 let (provider, model) = self.default_model_config()?;
601 let api_key = provider.get_api_key(model)?;
602 let base_url = provider.get_base_url(model);
603
604 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
605 if let Some(url) = base_url {
606 config = config.with_base_url(url);
607 }
608 config = apply_model_caps(config, model, self.thinking_budget);
609 Some(config)
610 }
611
612 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
616 let provider = self.find_provider(provider_name)?;
617 let model = provider.find_model(model_id)?;
618 let api_key = provider.get_api_key(model)?;
619 let base_url = provider.get_base_url(model);
620
621 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
622 if let Some(url) = base_url {
623 config = config.with_base_url(url);
624 }
625 config = apply_model_caps(config, model, self.thinking_budget);
626 Some(config)
627 }
628
629 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
631 self.providers
632 .iter()
633 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
634 .collect()
635 }
636
637 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
639 self.skill_dirs.push(dir.into());
640 self
641 }
642
643 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
645 self.agent_dirs.push(dir.into());
646 self
647 }
648
649 pub fn has_directories(&self) -> bool {
651 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
652 }
653
654 pub fn has_providers(&self) -> bool {
656 !self.providers.is_empty()
657 }
658}
659
660const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
666
667const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
671
672fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
674 hcl_body_to_json_inner(body, false)
675}
676
677fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
682 let mut map = serde_json::Map::new();
683
684 for attr in body.attributes() {
686 let key = if verbatim_keys {
687 attr.key.as_str().to_string()
688 } else {
689 snake_to_camel(attr.key.as_str())
690 };
691 let value = hcl_expr_to_json(attr.expr());
692 map.insert(key, value);
693 }
694
695 for block in body.blocks() {
697 let key = if verbatim_keys {
698 block.identifier.as_str().to_string()
699 } else {
700 snake_to_camel(block.identifier.as_str())
701 };
702 let child_verbatim = HCL_VERBATIM_BLOCKS.contains(&block.identifier.as_str());
704 let block_value = hcl_body_to_json_inner(block.body(), child_verbatim);
705
706 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
707 let arr = map
709 .entry(key)
710 .or_insert_with(|| JsonValue::Array(Vec::new()));
711 if let JsonValue::Array(ref mut vec) = arr {
712 vec.push(block_value);
713 }
714 } else {
715 map.insert(key, block_value);
716 }
717 }
718
719 JsonValue::Object(map)
720}
721
722fn snake_to_camel(s: &str) -> String {
724 let mut result = String::with_capacity(s.len());
725 let mut capitalize_next = false;
726 for ch in s.chars() {
727 if ch == '_' {
728 capitalize_next = true;
729 } else if capitalize_next {
730 result.extend(ch.to_uppercase());
731 capitalize_next = false;
732 } else {
733 result.push(ch);
734 }
735 }
736 result
737}
738
739fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
741 match expr {
742 hcl::Expression::String(s) => JsonValue::String(s.clone()),
743 hcl::Expression::Number(n) => {
744 if let Some(i) = n.as_i64() {
745 JsonValue::Number(i.into())
746 } else if let Some(f) = n.as_f64() {
747 serde_json::Number::from_f64(f)
748 .map(JsonValue::Number)
749 .unwrap_or(JsonValue::Null)
750 } else {
751 JsonValue::Null
752 }
753 }
754 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
755 hcl::Expression::Null => JsonValue::Null,
756 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
757 hcl::Expression::Object(obj) => {
758 let map: serde_json::Map<String, JsonValue> = obj
761 .iter()
762 .map(|(k, v)| {
763 let key = match k {
764 hcl::ObjectKey::Identifier(id) => id.as_str().to_string(),
765 hcl::ObjectKey::Expression(expr) => {
766 if let hcl::Expression::String(s) = expr {
767 s.clone()
768 } else {
769 format!("{:?}", expr)
770 }
771 }
772 _ => format!("{:?}", k),
773 };
774 (key, hcl_expr_to_json(v))
775 })
776 .collect();
777 JsonValue::Object(map)
778 }
779 hcl::Expression::FuncCall(func_call) => eval_func_call(func_call),
780 hcl::Expression::TemplateExpr(tmpl) => eval_template_expr(tmpl),
781 _ => JsonValue::String(format!("{:?}", expr)),
782 }
783}
784
785fn eval_func_call(func_call: &hcl::expr::FuncCall) -> JsonValue {
790 let name = func_call.name.name.as_str();
791 match name {
792 "env" => {
793 if let Some(arg) = func_call.args.first() {
794 let var_name = match arg {
795 hcl::Expression::String(s) => s.as_str(),
796 _ => {
797 tracing::warn!("env() expects a string argument, got: {:?}", arg);
798 return JsonValue::Null;
799 }
800 };
801 match std::env::var(var_name) {
802 Ok(val) => JsonValue::String(val),
803 Err(_) => {
804 tracing::debug!("env(\"{}\") is not set, returning null", var_name);
805 JsonValue::Null
806 }
807 }
808 } else {
809 tracing::warn!("env() called with no arguments");
810 JsonValue::Null
811 }
812 }
813 _ => {
814 tracing::warn!("Unsupported HCL function: {}()", name);
815 JsonValue::String(format!("{}()", name))
816 }
817 }
818}
819
820fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
825 JsonValue::String(format!("{}", tmpl))
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn test_config_default() {
839 let config = CodeConfig::default();
840 assert!(config.skill_dirs.is_empty());
841 assert!(config.agent_dirs.is_empty());
842 assert!(config.providers.is_empty());
843 assert!(config.default_model.is_none());
844 assert_eq!(config.storage_backend, StorageBackend::File);
845 assert!(config.sessions_dir.is_none());
846 }
847
848 #[test]
849 fn test_storage_backend_default() {
850 let backend = StorageBackend::default();
851 assert_eq!(backend, StorageBackend::File);
852 }
853
854 #[test]
855 fn test_storage_backend_serde() {
856 let memory = StorageBackend::Memory;
858 let json = serde_json::to_string(&memory).unwrap();
859 assert_eq!(json, "\"memory\"");
860
861 let file = StorageBackend::File;
862 let json = serde_json::to_string(&file).unwrap();
863 assert_eq!(json, "\"file\"");
864
865 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
867 assert_eq!(memory, StorageBackend::Memory);
868
869 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
870 assert_eq!(file, StorageBackend::File);
871 }
872
873 #[test]
874 fn test_config_with_storage_backend() {
875 let temp_dir = tempfile::tempdir().unwrap();
876 let config_path = temp_dir.path().join("config.hcl");
877
878 std::fs::write(
879 &config_path,
880 r#"
881 storage_backend = "memory"
882 sessions_dir = "/tmp/sessions"
883 "#,
884 )
885 .unwrap();
886
887 let config = CodeConfig::from_file(&config_path).unwrap();
888 assert_eq!(config.storage_backend, StorageBackend::Memory);
889 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
890 }
891
892 #[test]
893 fn test_config_builder() {
894 let config = CodeConfig::new()
895 .add_skill_dir("/tmp/skills")
896 .add_agent_dir("/tmp/agents");
897
898 assert_eq!(config.skill_dirs.len(), 1);
899 assert_eq!(config.agent_dirs.len(), 1);
900 }
901
902 #[test]
903 fn test_find_provider() {
904 let config = CodeConfig {
905 providers: vec![
906 ProviderConfig {
907 name: "anthropic".to_string(),
908 api_key: Some("key1".to_string()),
909 base_url: None,
910 models: vec![],
911 },
912 ProviderConfig {
913 name: "openai".to_string(),
914 api_key: Some("key2".to_string()),
915 base_url: None,
916 models: vec![],
917 },
918 ],
919 ..Default::default()
920 };
921
922 assert!(config.find_provider("anthropic").is_some());
923 assert!(config.find_provider("openai").is_some());
924 assert!(config.find_provider("unknown").is_none());
925 }
926
927 #[test]
928 fn test_default_llm_config() {
929 let config = CodeConfig {
930 default_model: Some("anthropic/claude-sonnet-4".to_string()),
931 providers: vec![ProviderConfig {
932 name: "anthropic".to_string(),
933 api_key: Some("test-api-key".to_string()),
934 base_url: Some("https://api.anthropic.com".to_string()),
935 models: vec![ModelConfig {
936 id: "claude-sonnet-4".to_string(),
937 name: "Claude Sonnet 4".to_string(),
938 family: "claude-sonnet".to_string(),
939 api_key: None,
940 base_url: None,
941 attachment: false,
942 reasoning: false,
943 tool_call: true,
944 temperature: true,
945 release_date: None,
946 modalities: ModelModalities::default(),
947 cost: ModelCost::default(),
948 limit: ModelLimit::default(),
949 }],
950 }],
951 ..Default::default()
952 };
953
954 let llm_config = config.default_llm_config().unwrap();
955 assert_eq!(llm_config.provider, "anthropic");
956 assert_eq!(llm_config.model, "claude-sonnet-4");
957 assert_eq!(llm_config.api_key.expose(), "test-api-key");
958 assert_eq!(
959 llm_config.base_url,
960 Some("https://api.anthropic.com".to_string())
961 );
962 }
963
964 #[test]
965 fn test_model_api_key_override() {
966 let provider = ProviderConfig {
967 name: "openai".to_string(),
968 api_key: Some("provider-key".to_string()),
969 base_url: Some("https://api.openai.com".to_string()),
970 models: vec![
971 ModelConfig {
972 id: "gpt-4".to_string(),
973 name: "GPT-4".to_string(),
974 family: "gpt".to_string(),
975 api_key: None, base_url: None,
977 attachment: false,
978 reasoning: false,
979 tool_call: true,
980 temperature: true,
981 release_date: None,
982 modalities: ModelModalities::default(),
983 cost: ModelCost::default(),
984 limit: ModelLimit::default(),
985 },
986 ModelConfig {
987 id: "custom-model".to_string(),
988 name: "Custom Model".to_string(),
989 family: "custom".to_string(),
990 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), attachment: false,
993 reasoning: false,
994 tool_call: true,
995 temperature: true,
996 release_date: None,
997 modalities: ModelModalities::default(),
998 cost: ModelCost::default(),
999 limit: ModelLimit::default(),
1000 },
1001 ],
1002 };
1003
1004 let model1 = provider.find_model("gpt-4").unwrap();
1006 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
1007 assert_eq!(
1008 provider.get_base_url(model1),
1009 Some("https://api.openai.com")
1010 );
1011
1012 let model2 = provider.find_model("custom-model").unwrap();
1014 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
1015 assert_eq!(
1016 provider.get_base_url(model2),
1017 Some("https://custom.api.com")
1018 );
1019 }
1020
1021 #[test]
1022 fn test_list_models() {
1023 let config = CodeConfig {
1024 providers: vec![
1025 ProviderConfig {
1026 name: "anthropic".to_string(),
1027 api_key: None,
1028 base_url: None,
1029 models: vec![
1030 ModelConfig {
1031 id: "claude-1".to_string(),
1032 name: "Claude 1".to_string(),
1033 family: "claude".to_string(),
1034 api_key: None,
1035 base_url: None,
1036 attachment: false,
1037 reasoning: false,
1038 tool_call: true,
1039 temperature: true,
1040 release_date: None,
1041 modalities: ModelModalities::default(),
1042 cost: ModelCost::default(),
1043 limit: ModelLimit::default(),
1044 },
1045 ModelConfig {
1046 id: "claude-2".to_string(),
1047 name: "Claude 2".to_string(),
1048 family: "claude".to_string(),
1049 api_key: None,
1050 base_url: None,
1051 attachment: false,
1052 reasoning: false,
1053 tool_call: true,
1054 temperature: true,
1055 release_date: None,
1056 modalities: ModelModalities::default(),
1057 cost: ModelCost::default(),
1058 limit: ModelLimit::default(),
1059 },
1060 ],
1061 },
1062 ProviderConfig {
1063 name: "openai".to_string(),
1064 api_key: None,
1065 base_url: None,
1066 models: vec![ModelConfig {
1067 id: "gpt-4".to_string(),
1068 name: "GPT-4".to_string(),
1069 family: "gpt".to_string(),
1070 api_key: None,
1071 base_url: None,
1072 attachment: false,
1073 reasoning: false,
1074 tool_call: true,
1075 temperature: true,
1076 release_date: None,
1077 modalities: ModelModalities::default(),
1078 cost: ModelCost::default(),
1079 limit: ModelLimit::default(),
1080 }],
1081 },
1082 ],
1083 ..Default::default()
1084 };
1085
1086 let models = config.list_models();
1087 assert_eq!(models.len(), 3);
1088 }
1089
1090 #[test]
1091 fn test_config_from_file_not_found() {
1092 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
1093 assert!(result.is_err());
1094 }
1095
1096 #[test]
1097 fn test_config_has_directories() {
1098 let empty = CodeConfig::default();
1099 assert!(!empty.has_directories());
1100
1101 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
1102 assert!(with_skills.has_directories());
1103
1104 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
1105 assert!(with_agents.has_directories());
1106 }
1107
1108 #[test]
1109 fn test_config_has_providers() {
1110 let empty = CodeConfig::default();
1111 assert!(!empty.has_providers());
1112
1113 let with_providers = CodeConfig {
1114 providers: vec![ProviderConfig {
1115 name: "test".to_string(),
1116 api_key: None,
1117 base_url: None,
1118 models: vec![],
1119 }],
1120 ..Default::default()
1121 };
1122 assert!(with_providers.has_providers());
1123 }
1124
1125 #[test]
1126 fn test_storage_backend_equality() {
1127 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
1128 assert_eq!(StorageBackend::File, StorageBackend::File);
1129 assert_ne!(StorageBackend::Memory, StorageBackend::File);
1130 }
1131
1132 #[test]
1133 fn test_storage_backend_serde_custom() {
1134 let custom = StorageBackend::Custom;
1135 let json = serde_json::to_string(&custom).unwrap();
1137 assert_eq!(json, "\"custom\"");
1138
1139 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
1141 assert_eq!(parsed, StorageBackend::Custom);
1142 }
1143
1144 #[test]
1145 fn test_model_cost_default() {
1146 let cost = ModelCost::default();
1147 assert_eq!(cost.input, 0.0);
1148 assert_eq!(cost.output, 0.0);
1149 assert_eq!(cost.cache_read, 0.0);
1150 assert_eq!(cost.cache_write, 0.0);
1151 }
1152
1153 #[test]
1154 fn test_model_cost_serialization() {
1155 let cost = ModelCost {
1156 input: 3.0,
1157 output: 15.0,
1158 cache_read: 0.3,
1159 cache_write: 3.75,
1160 };
1161 let json = serde_json::to_string(&cost).unwrap();
1162 assert!(json.contains("\"input\":3"));
1163 assert!(json.contains("\"output\":15"));
1164 }
1165
1166 #[test]
1167 fn test_model_cost_deserialization_missing_fields() {
1168 let json = r#"{"input":3.0}"#;
1169 let cost: ModelCost = serde_json::from_str(json).unwrap();
1170 assert_eq!(cost.input, 3.0);
1171 assert_eq!(cost.output, 0.0);
1172 assert_eq!(cost.cache_read, 0.0);
1173 assert_eq!(cost.cache_write, 0.0);
1174 }
1175
1176 #[test]
1177 fn test_model_limit_default() {
1178 let limit = ModelLimit::default();
1179 assert_eq!(limit.context, 0);
1180 assert_eq!(limit.output, 0);
1181 }
1182
1183 #[test]
1184 fn test_model_limit_serialization() {
1185 let limit = ModelLimit {
1186 context: 200000,
1187 output: 8192,
1188 };
1189 let json = serde_json::to_string(&limit).unwrap();
1190 assert!(json.contains("\"context\":200000"));
1191 assert!(json.contains("\"output\":8192"));
1192 }
1193
1194 #[test]
1195 fn test_model_limit_deserialization_missing_fields() {
1196 let json = r#"{"context":100000}"#;
1197 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1198 assert_eq!(limit.context, 100000);
1199 assert_eq!(limit.output, 0);
1200 }
1201
1202 #[test]
1203 fn test_model_modalities_default() {
1204 let modalities = ModelModalities::default();
1205 assert!(modalities.input.is_empty());
1206 assert!(modalities.output.is_empty());
1207 }
1208
1209 #[test]
1210 fn test_model_modalities_serialization() {
1211 let modalities = ModelModalities {
1212 input: vec!["text".to_string(), "image".to_string()],
1213 output: vec!["text".to_string()],
1214 };
1215 let json = serde_json::to_string(&modalities).unwrap();
1216 assert!(json.contains("\"input\""));
1217 assert!(json.contains("\"text\""));
1218 }
1219
1220 #[test]
1221 fn test_model_modalities_deserialization_missing_fields() {
1222 let json = r#"{"input":["text"]}"#;
1223 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1224 assert_eq!(modalities.input.len(), 1);
1225 assert!(modalities.output.is_empty());
1226 }
1227
1228 #[test]
1229 fn test_model_config_serialization() {
1230 let config = ModelConfig {
1231 id: "gpt-4o".to_string(),
1232 name: "GPT-4o".to_string(),
1233 family: "gpt-4".to_string(),
1234 api_key: Some("sk-test".to_string()),
1235 base_url: None,
1236 attachment: true,
1237 reasoning: false,
1238 tool_call: true,
1239 temperature: true,
1240 release_date: Some("2024-05-13".to_string()),
1241 modalities: ModelModalities::default(),
1242 cost: ModelCost::default(),
1243 limit: ModelLimit::default(),
1244 };
1245 let json = serde_json::to_string(&config).unwrap();
1246 assert!(json.contains("\"id\":\"gpt-4o\""));
1247 assert!(json.contains("\"attachment\":true"));
1248 }
1249
1250 #[test]
1251 fn test_model_config_deserialization_with_defaults() {
1252 let json = r#"{"id":"test-model"}"#;
1253 let config: ModelConfig = serde_json::from_str(json).unwrap();
1254 assert_eq!(config.id, "test-model");
1255 assert_eq!(config.name, "");
1256 assert_eq!(config.family, "");
1257 assert!(config.api_key.is_none());
1258 assert!(!config.attachment);
1259 assert!(config.tool_call);
1260 assert!(config.temperature);
1261 }
1262
1263 #[test]
1264 fn test_model_config_all_optional_fields() {
1265 let json = r#"{
1266 "id": "claude-sonnet-4",
1267 "name": "Claude Sonnet 4",
1268 "family": "claude-sonnet",
1269 "apiKey": "sk-test",
1270 "baseUrl": "https://api.anthropic.com",
1271 "attachment": true,
1272 "reasoning": true,
1273 "toolCall": false,
1274 "temperature": false,
1275 "releaseDate": "2025-05-14"
1276 }"#;
1277 let config: ModelConfig = serde_json::from_str(json).unwrap();
1278 assert_eq!(config.id, "claude-sonnet-4");
1279 assert_eq!(config.name, "Claude Sonnet 4");
1280 assert_eq!(config.api_key, Some("sk-test".to_string()));
1281 assert_eq!(
1282 config.base_url,
1283 Some("https://api.anthropic.com".to_string())
1284 );
1285 assert!(config.attachment);
1286 assert!(config.reasoning);
1287 assert!(!config.tool_call);
1288 assert!(!config.temperature);
1289 }
1290
1291 #[test]
1292 fn test_provider_config_serialization() {
1293 let provider = ProviderConfig {
1294 name: "anthropic".to_string(),
1295 api_key: Some("sk-test".to_string()),
1296 base_url: Some("https://api.anthropic.com".to_string()),
1297 models: vec![],
1298 };
1299 let json = serde_json::to_string(&provider).unwrap();
1300 assert!(json.contains("\"name\":\"anthropic\""));
1301 assert!(json.contains("\"apiKey\":\"sk-test\""));
1302 }
1303
1304 #[test]
1305 fn test_provider_config_deserialization_missing_optional() {
1306 let json = r#"{"name":"openai"}"#;
1307 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1308 assert_eq!(provider.name, "openai");
1309 assert!(provider.api_key.is_none());
1310 assert!(provider.base_url.is_none());
1311 assert!(provider.models.is_empty());
1312 }
1313
1314 #[test]
1315 fn test_provider_config_find_model() {
1316 let provider = ProviderConfig {
1317 name: "anthropic".to_string(),
1318 api_key: None,
1319 base_url: None,
1320 models: vec![ModelConfig {
1321 id: "claude-sonnet-4".to_string(),
1322 name: "Claude Sonnet 4".to_string(),
1323 family: "claude-sonnet".to_string(),
1324 api_key: None,
1325 base_url: None,
1326 attachment: false,
1327 reasoning: false,
1328 tool_call: true,
1329 temperature: true,
1330 release_date: None,
1331 modalities: ModelModalities::default(),
1332 cost: ModelCost::default(),
1333 limit: ModelLimit::default(),
1334 }],
1335 };
1336
1337 let found = provider.find_model("claude-sonnet-4");
1338 assert!(found.is_some());
1339 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1340
1341 let not_found = provider.find_model("gpt-4o");
1342 assert!(not_found.is_none());
1343 }
1344
1345 #[test]
1346 fn test_provider_config_get_api_key() {
1347 let provider = ProviderConfig {
1348 name: "anthropic".to_string(),
1349 api_key: Some("provider-key".to_string()),
1350 base_url: None,
1351 models: vec![],
1352 };
1353
1354 let model_with_key = ModelConfig {
1355 id: "test".to_string(),
1356 name: "".to_string(),
1357 family: "".to_string(),
1358 api_key: Some("model-key".to_string()),
1359 base_url: None,
1360 attachment: false,
1361 reasoning: false,
1362 tool_call: true,
1363 temperature: true,
1364 release_date: None,
1365 modalities: ModelModalities::default(),
1366 cost: ModelCost::default(),
1367 limit: ModelLimit::default(),
1368 };
1369
1370 let model_without_key = ModelConfig {
1371 id: "test2".to_string(),
1372 name: "".to_string(),
1373 family: "".to_string(),
1374 api_key: None,
1375 base_url: None,
1376 attachment: false,
1377 reasoning: false,
1378 tool_call: true,
1379 temperature: true,
1380 release_date: None,
1381 modalities: ModelModalities::default(),
1382 cost: ModelCost::default(),
1383 limit: ModelLimit::default(),
1384 };
1385
1386 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1387 assert_eq!(
1388 provider.get_api_key(&model_without_key),
1389 Some("provider-key")
1390 );
1391 }
1392
1393 #[test]
1394 fn test_code_config_default_provider_config() {
1395 let config = CodeConfig {
1396 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1397 providers: vec![ProviderConfig {
1398 name: "anthropic".to_string(),
1399 api_key: Some("sk-test".to_string()),
1400 base_url: None,
1401 models: vec![],
1402 }],
1403 ..Default::default()
1404 };
1405
1406 let provider = config.default_provider_config();
1407 assert!(provider.is_some());
1408 assert_eq!(provider.unwrap().name, "anthropic");
1409 }
1410
1411 #[test]
1412 fn test_code_config_default_model_config() {
1413 let config = CodeConfig {
1414 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1415 providers: vec![ProviderConfig {
1416 name: "anthropic".to_string(),
1417 api_key: Some("sk-test".to_string()),
1418 base_url: None,
1419 models: vec![ModelConfig {
1420 id: "claude-sonnet-4".to_string(),
1421 name: "Claude Sonnet 4".to_string(),
1422 family: "claude-sonnet".to_string(),
1423 api_key: None,
1424 base_url: None,
1425 attachment: false,
1426 reasoning: false,
1427 tool_call: true,
1428 temperature: true,
1429 release_date: None,
1430 modalities: ModelModalities::default(),
1431 cost: ModelCost::default(),
1432 limit: ModelLimit::default(),
1433 }],
1434 }],
1435 ..Default::default()
1436 };
1437
1438 let result = config.default_model_config();
1439 assert!(result.is_some());
1440 let (provider, model) = result.unwrap();
1441 assert_eq!(provider.name, "anthropic");
1442 assert_eq!(model.id, "claude-sonnet-4");
1443 }
1444
1445 #[test]
1446 fn test_code_config_default_llm_config() {
1447 let config = CodeConfig {
1448 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1449 providers: vec![ProviderConfig {
1450 name: "anthropic".to_string(),
1451 api_key: Some("sk-test".to_string()),
1452 base_url: Some("https://api.anthropic.com".to_string()),
1453 models: vec![ModelConfig {
1454 id: "claude-sonnet-4".to_string(),
1455 name: "Claude Sonnet 4".to_string(),
1456 family: "claude-sonnet".to_string(),
1457 api_key: None,
1458 base_url: None,
1459 attachment: false,
1460 reasoning: false,
1461 tool_call: true,
1462 temperature: true,
1463 release_date: None,
1464 modalities: ModelModalities::default(),
1465 cost: ModelCost::default(),
1466 limit: ModelLimit::default(),
1467 }],
1468 }],
1469 ..Default::default()
1470 };
1471
1472 let llm_config = config.default_llm_config();
1473 assert!(llm_config.is_some());
1474 }
1475
1476 #[test]
1477 fn test_code_config_list_models() {
1478 let config = CodeConfig {
1479 providers: vec![
1480 ProviderConfig {
1481 name: "anthropic".to_string(),
1482 api_key: None,
1483 base_url: None,
1484 models: vec![ModelConfig {
1485 id: "claude-sonnet-4".to_string(),
1486 name: "".to_string(),
1487 family: "".to_string(),
1488 api_key: None,
1489 base_url: None,
1490 attachment: false,
1491 reasoning: false,
1492 tool_call: true,
1493 temperature: true,
1494 release_date: None,
1495 modalities: ModelModalities::default(),
1496 cost: ModelCost::default(),
1497 limit: ModelLimit::default(),
1498 }],
1499 },
1500 ProviderConfig {
1501 name: "openai".to_string(),
1502 api_key: None,
1503 base_url: None,
1504 models: vec![ModelConfig {
1505 id: "gpt-4o".to_string(),
1506 name: "".to_string(),
1507 family: "".to_string(),
1508 api_key: None,
1509 base_url: None,
1510 attachment: false,
1511 reasoning: false,
1512 tool_call: true,
1513 temperature: true,
1514 release_date: None,
1515 modalities: ModelModalities::default(),
1516 cost: ModelCost::default(),
1517 limit: ModelLimit::default(),
1518 }],
1519 },
1520 ],
1521 ..Default::default()
1522 };
1523
1524 let models = config.list_models();
1525 assert_eq!(models.len(), 2);
1526 }
1527
1528 #[test]
1529 fn test_llm_config_specific_provider_model() {
1530 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1531 "id": "claude-3",
1532 "name": "Claude 3"
1533 }))
1534 .unwrap();
1535
1536 let config = CodeConfig {
1537 providers: vec![ProviderConfig {
1538 name: "anthropic".to_string(),
1539 api_key: Some("sk-test".to_string()),
1540 base_url: None,
1541 models: vec![model],
1542 }],
1543 ..Default::default()
1544 };
1545
1546 let llm = config.llm_config("anthropic", "claude-3");
1547 assert!(llm.is_some());
1548 let llm = llm.unwrap();
1549 assert_eq!(llm.provider, "anthropic");
1550 assert_eq!(llm.model, "claude-3");
1551 }
1552
1553 #[test]
1554 fn test_llm_config_missing_provider() {
1555 let config = CodeConfig::default();
1556 assert!(config.llm_config("nonexistent", "model").is_none());
1557 }
1558
1559 #[test]
1560 fn test_llm_config_missing_model() {
1561 let config = CodeConfig {
1562 providers: vec![ProviderConfig {
1563 name: "anthropic".to_string(),
1564 api_key: Some("sk-test".to_string()),
1565 base_url: None,
1566 models: vec![],
1567 }],
1568 ..Default::default()
1569 };
1570 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1571 }
1572
1573 #[test]
1574 fn test_from_hcl_string() {
1575 let hcl = r#"
1576 default_model = "anthropic/claude-sonnet-4"
1577
1578 providers {
1579 name = "anthropic"
1580 api_key = "test-key"
1581
1582 models {
1583 id = "claude-sonnet-4"
1584 name = "Claude Sonnet 4"
1585 }
1586 }
1587 "#;
1588
1589 let config = CodeConfig::from_hcl(hcl).unwrap();
1590 assert_eq!(
1591 config.default_model,
1592 Some("anthropic/claude-sonnet-4".to_string())
1593 );
1594 assert_eq!(config.providers.len(), 1);
1595 assert_eq!(config.providers[0].name, "anthropic");
1596 assert_eq!(config.providers[0].models.len(), 1);
1597 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1598 }
1599
1600 #[test]
1601 fn test_from_hcl_multi_provider() {
1602 let hcl = r#"
1603 default_model = "anthropic/claude-sonnet-4"
1604
1605 providers {
1606 name = "anthropic"
1607 api_key = "sk-ant-test"
1608
1609 models {
1610 id = "claude-sonnet-4"
1611 name = "Claude Sonnet 4"
1612 }
1613
1614 models {
1615 id = "claude-opus-4"
1616 name = "Claude Opus 4"
1617 reasoning = true
1618 }
1619 }
1620
1621 providers {
1622 name = "openai"
1623 api_key = "sk-test"
1624
1625 models {
1626 id = "gpt-4o"
1627 name = "GPT-4o"
1628 }
1629 }
1630 "#;
1631
1632 let config = CodeConfig::from_hcl(hcl).unwrap();
1633 assert_eq!(config.providers.len(), 2);
1634 assert_eq!(config.providers[0].models.len(), 2);
1635 assert_eq!(config.providers[1].models.len(), 1);
1636 assert_eq!(config.providers[1].name, "openai");
1637 }
1638
1639 #[test]
1640 fn test_snake_to_camel() {
1641 assert_eq!(snake_to_camel("default_model"), "defaultModel");
1642 assert_eq!(snake_to_camel("api_key"), "apiKey");
1643 assert_eq!(snake_to_camel("base_url"), "baseUrl");
1644 assert_eq!(snake_to_camel("name"), "name");
1645 assert_eq!(snake_to_camel("tool_call"), "toolCall");
1646 }
1647
1648 #[test]
1649 fn test_from_file_auto_detect_hcl() {
1650 let temp_dir = tempfile::tempdir().unwrap();
1651 let config_path = temp_dir.path().join("config.hcl");
1652
1653 std::fs::write(
1654 &config_path,
1655 r#"
1656 default_model = "anthropic/claude-sonnet-4"
1657
1658 providers {
1659 name = "anthropic"
1660 api_key = "test-key"
1661
1662 models {
1663 id = "claude-sonnet-4"
1664 }
1665 }
1666 "#,
1667 )
1668 .unwrap();
1669
1670 let config = CodeConfig::from_file(&config_path).unwrap();
1671 assert_eq!(
1672 config.default_model,
1673 Some("anthropic/claude-sonnet-4".to_string())
1674 );
1675 }
1676
1677 #[test]
1678 fn test_from_hcl_with_queue_config() {
1679 let hcl = r#"
1680 default_model = "anthropic/claude-sonnet-4"
1681
1682 providers {
1683 name = "anthropic"
1684 api_key = "test-key"
1685 }
1686
1687 queue {
1688 query_max_concurrency = 20
1689 execute_max_concurrency = 5
1690 enable_metrics = true
1691 enable_dlq = true
1692 }
1693 "#;
1694
1695 let config = CodeConfig::from_hcl(hcl).unwrap();
1696 assert!(config.queue.is_some());
1697 let queue = config.queue.unwrap();
1698 assert_eq!(queue.query_max_concurrency, 20);
1699 assert_eq!(queue.execute_max_concurrency, 5);
1700 assert!(queue.enable_metrics);
1701 assert!(queue.enable_dlq);
1702 }
1703
1704 #[test]
1705 fn test_from_hcl_with_search_config() {
1706 let hcl = r#"
1707 default_model = "anthropic/claude-sonnet-4"
1708
1709 providers {
1710 name = "anthropic"
1711 api_key = "test-key"
1712 }
1713
1714 search {
1715 timeout = 30
1716
1717 health {
1718 max_failures = 5
1719 suspend_seconds = 120
1720 }
1721
1722 engine {
1723 google {
1724 enabled = true
1725 weight = 1.5
1726 }
1727 bing {
1728 enabled = true
1729 weight = 1.0
1730 timeout = 15
1731 }
1732 }
1733 }
1734 "#;
1735
1736 let config = CodeConfig::from_hcl(hcl).unwrap();
1737 assert!(config.search.is_some());
1738 let search = config.search.unwrap();
1739 assert_eq!(search.timeout, 30);
1740 assert!(search.health.is_some());
1741 let health = search.health.unwrap();
1742 assert_eq!(health.max_failures, 5);
1743 assert_eq!(health.suspend_seconds, 120);
1744 assert_eq!(search.engines.len(), 2);
1745 assert!(search.engines.contains_key("google"));
1746 assert!(search.engines.contains_key("bing"));
1747 let google = &search.engines["google"];
1748 assert!(google.enabled);
1749 assert_eq!(google.weight, 1.5);
1750 let bing = &search.engines["bing"];
1751 assert_eq!(bing.timeout, Some(15));
1752 }
1753
1754 #[test]
1755 fn test_from_hcl_with_queue_and_search() {
1756 let hcl = r#"
1757 default_model = "anthropic/claude-sonnet-4"
1758
1759 providers {
1760 name = "anthropic"
1761 api_key = "test-key"
1762 }
1763
1764 queue {
1765 query_max_concurrency = 10
1766 enable_metrics = true
1767 }
1768
1769 search {
1770 timeout = 20
1771 engine {
1772 duckduckgo {
1773 enabled = true
1774 }
1775 }
1776 }
1777 "#;
1778
1779 let config = CodeConfig::from_hcl(hcl).unwrap();
1780 assert!(config.queue.is_some());
1781 assert!(config.search.is_some());
1782 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
1783 assert_eq!(config.search.unwrap().timeout, 20);
1784 }
1785
1786 #[test]
1787 fn test_from_hcl_multiple_mcp_servers() {
1788 let hcl = r#"
1789 mcp_servers {
1790 name = "fetch"
1791 transport = "stdio"
1792 command = "npx"
1793 args = ["-y", "@modelcontextprotocol/server-fetch"]
1794 enabled = true
1795 }
1796
1797 mcp_servers {
1798 name = "puppeteer"
1799 transport = "stdio"
1800 command = "npx"
1801 args = ["-y", "@anthropic/mcp-server-puppeteer"]
1802 enabled = true
1803 }
1804
1805 mcp_servers {
1806 name = "filesystem"
1807 transport = "stdio"
1808 command = "npx"
1809 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1810 enabled = false
1811 }
1812 "#;
1813
1814 let config = CodeConfig::from_hcl(hcl).unwrap();
1815 assert_eq!(
1816 config.mcp_servers.len(),
1817 3,
1818 "all 3 mcp_servers blocks should be parsed"
1819 );
1820 assert_eq!(config.mcp_servers[0].name, "fetch");
1821 assert_eq!(config.mcp_servers[1].name, "puppeteer");
1822 assert_eq!(config.mcp_servers[2].name, "filesystem");
1823 assert!(config.mcp_servers[0].enabled);
1824 assert!(!config.mcp_servers[2].enabled);
1825 }
1826
1827 #[test]
1828 fn test_from_hcl_with_advanced_queue_config() {
1829 let hcl = r#"
1830 default_model = "anthropic/claude-sonnet-4"
1831
1832 providers {
1833 name = "anthropic"
1834 api_key = "test-key"
1835 }
1836
1837 queue {
1838 query_max_concurrency = 20
1839 enable_metrics = true
1840
1841 retry_policy {
1842 strategy = "exponential"
1843 max_retries = 5
1844 initial_delay_ms = 200
1845 }
1846
1847 rate_limit {
1848 limit_type = "per_second"
1849 max_operations = 100
1850 }
1851
1852 priority_boost {
1853 strategy = "standard"
1854 deadline_ms = 300000
1855 }
1856
1857 pressure_threshold = 50
1858 }
1859 "#;
1860
1861 let config = CodeConfig::from_hcl(hcl).unwrap();
1862 assert!(config.queue.is_some());
1863 let queue = config.queue.unwrap();
1864
1865 assert_eq!(queue.query_max_concurrency, 20);
1866 assert!(queue.enable_metrics);
1867
1868 assert!(queue.retry_policy.is_some());
1870 let retry = queue.retry_policy.unwrap();
1871 assert_eq!(retry.strategy, "exponential");
1872 assert_eq!(retry.max_retries, 5);
1873 assert_eq!(retry.initial_delay_ms, 200);
1874
1875 assert!(queue.rate_limit.is_some());
1877 let rate = queue.rate_limit.unwrap();
1878 assert_eq!(rate.limit_type, "per_second");
1879 assert_eq!(rate.max_operations, Some(100));
1880
1881 assert!(queue.priority_boost.is_some());
1883 let boost = queue.priority_boost.unwrap();
1884 assert_eq!(boost.strategy, "standard");
1885 assert_eq!(boost.deadline_ms, Some(300000));
1886
1887 assert_eq!(queue.pressure_threshold, Some(50));
1889 }
1890
1891 #[test]
1892 fn test_hcl_env_function_resolved() {
1893 std::env::set_var("A3S_TEST_HCL_KEY", "test-secret-key-123");
1895
1896 let hcl_str = r#"
1897 providers {
1898 name = "test"
1899 api_key = env("A3S_TEST_HCL_KEY")
1900 }
1901 "#;
1902
1903 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1904 let json = hcl_body_to_json(&body);
1905
1906 let providers = json.get("providers").unwrap();
1908 let provider = providers.as_array().unwrap().first().unwrap();
1909 let api_key = provider.get("apiKey").unwrap();
1910
1911 assert_eq!(api_key.as_str().unwrap(), "test-secret-key-123");
1912
1913 std::env::remove_var("A3S_TEST_HCL_KEY");
1915 }
1916
1917 #[test]
1918 fn test_hcl_env_function_unset_returns_null() {
1919 std::env::remove_var("A3S_TEST_NONEXISTENT_VAR_12345");
1921
1922 let hcl_str = r#"
1923 providers {
1924 name = "test"
1925 api_key = env("A3S_TEST_NONEXISTENT_VAR_12345")
1926 }
1927 "#;
1928
1929 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1930 let json = hcl_body_to_json(&body);
1931
1932 let providers = json.get("providers").unwrap();
1933 let provider = providers.as_array().unwrap().first().unwrap();
1934 let api_key = provider.get("apiKey").unwrap();
1935
1936 assert!(api_key.is_null(), "Unset env var should return null");
1937 }
1938
1939 #[test]
1940 fn test_hcl_mcp_env_block_preserves_var_names() {
1941 std::env::set_var("A3S_TEST_SECRET", "my-secret");
1943
1944 let hcl_str = r#"
1945 mcp_servers {
1946 name = "test-server"
1947 transport = "stdio"
1948 command = "echo"
1949 env = {
1950 API_KEY = "sk-test-123"
1951 ANTHROPIC_API_KEY = env("A3S_TEST_SECRET")
1952 SIMPLE = "value"
1953 }
1954 }
1955 "#;
1956
1957 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1958 let json = hcl_body_to_json(&body);
1959
1960 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
1961 let server = &servers[0];
1962 let env = server.get("env").unwrap().as_object().unwrap();
1963
1964 assert_eq!(env.get("API_KEY").unwrap().as_str().unwrap(), "sk-test-123");
1966 assert_eq!(
1967 env.get("ANTHROPIC_API_KEY").unwrap().as_str().unwrap(),
1968 "my-secret"
1969 );
1970 assert_eq!(env.get("SIMPLE").unwrap().as_str().unwrap(), "value");
1971
1972 assert!(
1974 env.get("apiKey").is_none(),
1975 "env var key should not be camelCase'd"
1976 );
1977 assert!(
1978 env.get("APIKEY").is_none(),
1979 "env var key should not have underscores stripped"
1980 );
1981 assert!(env.get("anthropicApiKey").is_none());
1982
1983 std::env::remove_var("A3S_TEST_SECRET");
1984 }
1985
1986 #[test]
1987 fn test_hcl_mcp_env_as_block_syntax() {
1988 let hcl_str = r#"
1990 mcp_servers {
1991 name = "test-server"
1992 transport = "stdio"
1993 command = "echo"
1994 env {
1995 MY_VAR = "hello"
1996 OTHER_VAR = "world"
1997 }
1998 }
1999 "#;
2000
2001 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2002 let json = hcl_body_to_json(&body);
2003
2004 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2005 let server = &servers[0];
2006 let env = server.get("env").unwrap().as_object().unwrap();
2007
2008 assert_eq!(env.get("MY_VAR").unwrap().as_str().unwrap(), "hello");
2009 assert_eq!(env.get("OTHER_VAR").unwrap().as_str().unwrap(), "world");
2010 assert!(
2011 env.get("myVar").is_none(),
2012 "block env keys should not be camelCase'd"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_hcl_mcp_full_deserialization_with_env() {
2018 std::env::set_var("A3S_TEST_MCP_KEY", "resolved-secret");
2020
2021 let hcl_str = r#"
2022 mcp_servers {
2023 name = "fetch"
2024 transport = "stdio"
2025 command = "npx"
2026 args = ["-y", "@modelcontextprotocol/server-fetch"]
2027 env = {
2028 NODE_ENV = "production"
2029 API_KEY = env("A3S_TEST_MCP_KEY")
2030 }
2031 tool_timeout_secs = 120
2032 }
2033 "#;
2034
2035 let config = CodeConfig::from_hcl(hcl_str).unwrap();
2036 assert_eq!(config.mcp_servers.len(), 1);
2037
2038 let server = &config.mcp_servers[0];
2039 assert_eq!(server.name, "fetch");
2040 assert_eq!(server.env.get("NODE_ENV").unwrap(), "production");
2041 assert_eq!(server.env.get("API_KEY").unwrap(), "resolved-secret");
2042 assert_eq!(server.tool_timeout_secs, 120);
2043
2044 std::env::remove_var("A3S_TEST_MCP_KEY");
2045 }
2046
2047 #[test]
2048 fn test_hcl_agentic_tool_config_parses() {
2049 let hcl = r#"
2050 agentic_search {
2051 enabled = false
2052 default_mode = "deep"
2053 max_results = 7
2054 context_lines = 4
2055 }
2056
2057 agentic_parse {
2058 enabled = true
2059 default_strategy = "structured"
2060 max_chars = 12000
2061 }
2062
2063 default_parser {
2064 enabled = true
2065 max_file_size_mb = 64
2066
2067 ocr {
2068 enabled = true
2069 model = "openai/gpt-4.1-mini"
2070 prompt = "Extract text from scanned pages."
2071 max_images = 6
2072 dpi = 200
2073 }
2074 }
2075 "#;
2076
2077 let config = CodeConfig::from_hcl(hcl).unwrap();
2078 let search = config.agentic_search.unwrap();
2079 let parse = config.agentic_parse.unwrap();
2080 let default_parser = config.default_parser.unwrap();
2081
2082 assert!(!search.enabled);
2083 assert_eq!(search.default_mode, "deep");
2084 assert_eq!(search.max_results, 7);
2085 assert_eq!(search.context_lines, 4);
2086
2087 assert!(parse.enabled);
2088 assert_eq!(parse.default_strategy, "structured");
2089 assert_eq!(parse.max_chars, 12000);
2090
2091 assert!(default_parser.enabled);
2092 assert_eq!(default_parser.max_file_size_mb, 64);
2093 let ocr = default_parser.ocr.unwrap();
2094 assert!(ocr.enabled);
2095 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2096 assert_eq!(
2097 ocr.prompt.as_deref(),
2098 Some("Extract text from scanned pages.")
2099 );
2100 assert_eq!(ocr.max_images, 6);
2101 assert_eq!(ocr.dpi, 200);
2102 }
2103}