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::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26#[serde(rename_all = "camelCase")]
27pub struct ModelCost {
28 #[serde(default)]
30 pub input: f64,
31 #[serde(default)]
33 pub output: f64,
34 #[serde(default)]
36 pub cache_read: f64,
37 #[serde(default)]
39 pub cache_write: f64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct ModelLimit {
45 #[serde(default)]
47 pub context: u32,
48 #[serde(default)]
50 pub output: u32,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct ModelModalities {
56 #[serde(default)]
58 pub input: Vec<String>,
59 #[serde(default)]
61 pub output: Vec<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ModelConfig {
68 pub id: String,
70 #[serde(default)]
72 pub name: String,
73 #[serde(default)]
75 pub family: String,
76 #[serde(default)]
78 pub api_key: Option<String>,
79 #[serde(default)]
81 pub base_url: Option<String>,
82 #[serde(default)]
84 pub headers: HashMap<String, String>,
85 #[serde(default)]
87 pub session_id_header: Option<String>,
88 #[serde(default)]
90 pub attachment: bool,
91 #[serde(default)]
93 pub reasoning: bool,
94 #[serde(default = "default_true")]
96 pub tool_call: bool,
97 #[serde(default = "default_true")]
99 pub temperature: bool,
100 #[serde(default)]
102 pub release_date: Option<String>,
103 #[serde(default)]
105 pub modalities: ModelModalities,
106 #[serde(default)]
108 pub cost: ModelCost,
109 #[serde(default)]
111 pub limit: ModelLimit,
112}
113
114fn default_true() -> bool {
115 true
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ProviderConfig {
122 pub name: String,
124 #[serde(default)]
126 pub api_key: Option<String>,
127 #[serde(default)]
129 pub base_url: Option<String>,
130 #[serde(default)]
132 pub headers: HashMap<String, String>,
133 #[serde(default)]
135 pub session_id_header: Option<String>,
136 #[serde(default)]
138 pub models: Vec<ModelConfig>,
139}
140
141fn apply_model_caps(
147 mut config: LlmConfig,
148 model: &ModelConfig,
149 thinking_budget: Option<usize>,
150) -> LlmConfig {
151 if model.reasoning {
153 if let Some(budget) = thinking_budget {
154 config = config.with_thinking_budget(budget);
155 }
156 }
157
158 if model.limit.output > 0 {
160 config = config.with_max_tokens(model.limit.output as usize);
161 }
162
163 if !model.temperature {
166 config.disable_temperature = true;
167 }
168
169 config
170}
171
172impl ProviderConfig {
173 pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
175 self.models.iter().find(|m| m.id == model_id)
176 }
177
178 pub fn get_api_key<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
180 model.api_key.as_deref().or(self.api_key.as_deref())
181 }
182
183 pub fn get_base_url<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
185 model.base_url.as_deref().or(self.base_url.as_deref())
186 }
187
188 pub fn get_headers(&self, model: &ModelConfig) -> HashMap<String, String> {
190 let mut headers = self.headers.clone();
191 headers.extend(model.headers.clone());
192 headers
193 }
194
195 pub fn get_session_id_header<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
197 model
198 .session_id_header
199 .as_deref()
200 .or(self.session_id_header.as_deref())
201 }
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
210#[serde(rename_all = "lowercase")]
211pub enum StorageBackend {
212 Memory,
214 #[default]
216 File,
217 Custom,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230#[serde(rename_all = "camelCase")]
231pub struct CodeConfig {
232 #[serde(default, alias = "default_model")]
234 pub default_model: Option<String>,
235
236 #[serde(default)]
238 pub providers: Vec<ProviderConfig>,
239
240 #[serde(default)]
242 pub storage_backend: StorageBackend,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub sessions_dir: Option<PathBuf>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub storage_url: Option<String>,
251
252 #[serde(default, alias = "skill_dirs")]
254 pub skill_dirs: Vec<PathBuf>,
255
256 #[serde(default, alias = "agent_dirs")]
258 pub agent_dirs: Vec<PathBuf>,
259
260 #[serde(default, alias = "max_tool_rounds")]
262 pub max_tool_rounds: Option<usize>,
263
264 #[serde(default, alias = "thinking_budget")]
266 pub thinking_budget: Option<usize>,
267
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub memory: Option<MemoryConfig>,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub queue: Option<crate::queue::SessionQueueConfig>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub search: Option<SearchConfig>,
279
280 #[serde(
282 default,
283 alias = "agentic_search",
284 skip_serializing_if = "Option::is_none"
285 )]
286 pub agentic_search: Option<AgenticSearchConfig>,
287
288 #[serde(
290 default,
291 alias = "agentic_parse",
292 skip_serializing_if = "Option::is_none"
293 )]
294 pub agentic_parse: Option<AgenticParseConfig>,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub document_parser: Option<DocumentParserConfig>,
299
300 #[serde(default, alias = "mcp_servers")]
302 pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct SearchConfig {
309 #[serde(default = "default_search_timeout")]
311 pub timeout: u64,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub health: Option<SearchHealthConfig>,
316
317 #[serde(default, rename = "engine")]
319 pub engines: std::collections::HashMap<String, SearchEngineConfig>,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub headless: Option<HeadlessConfig>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum BrowserBackend {
331 Chrome,
333 Lightpanda,
336}
337
338#[allow(clippy::derivable_impls)]
339impl Default for BrowserBackend {
340 fn default() -> Self {
341 BrowserBackend::Chrome
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct HeadlessConfig {
349 #[serde(default)]
351 pub backend: BrowserBackend,
352
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub browser_path: Option<String>,
356
357 #[serde(default = "default_headless_max_tabs")]
359 pub max_tabs: usize,
360
361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub launch_args: Vec<String>,
364}
365
366impl Default for HeadlessConfig {
367 fn default() -> Self {
368 Self {
369 backend: BrowserBackend::default(),
370 browser_path: None,
371 max_tabs: 4,
372 launch_args: Vec::new(),
373 }
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct AgenticSearchConfig {
381 #[serde(default = "default_enabled")]
383 pub enabled: bool,
384
385 #[serde(default = "default_agentic_search_mode")]
387 pub default_mode: String,
388
389 #[serde(default = "default_agentic_search_max_results")]
391 pub max_results: usize,
392
393 #[serde(default = "default_agentic_search_context_lines")]
395 pub context_lines: usize,
396}
397
398impl Default for AgenticSearchConfig {
399 fn default() -> Self {
400 Self {
401 enabled: true,
402 default_mode: default_agentic_search_mode(),
403 max_results: default_agentic_search_max_results(),
404 context_lines: default_agentic_search_context_lines(),
405 }
406 }
407}
408
409impl AgenticSearchConfig {
410 pub fn normalized(&self) -> Self {
411 let default_mode = match self.default_mode.to_ascii_lowercase().as_str() {
412 "fast" => "fast".to_string(),
413 "deep" => "deep".to_string(),
414 "filename_only" | "filename" => "filename_only".to_string(),
415 _ => default_agentic_search_mode(),
416 };
417
418 Self {
419 enabled: self.enabled,
420 default_mode,
421 max_results: self.max_results.clamp(1, 100),
422 context_lines: self.context_lines.min(20),
423 }
424 }
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(rename_all = "camelCase")]
430pub struct AgenticParseConfig {
431 #[serde(default = "default_enabled")]
433 pub enabled: bool,
434
435 #[serde(default = "default_agentic_parse_strategy")]
437 pub default_strategy: String,
438
439 #[serde(default = "default_agentic_parse_max_chars")]
441 pub max_chars: usize,
442}
443
444impl Default for AgenticParseConfig {
445 fn default() -> Self {
446 Self {
447 enabled: true,
448 default_strategy: default_agentic_parse_strategy(),
449 max_chars: default_agentic_parse_max_chars(),
450 }
451 }
452}
453
454impl AgenticParseConfig {
455 pub fn normalized(&self) -> Self {
456 let default_strategy = match self.default_strategy.to_ascii_lowercase().as_str() {
457 "auto" => "auto".to_string(),
458 "structured" => "structured".to_string(),
459 "narrative" => "narrative".to_string(),
460 "tabular" => "tabular".to_string(),
461 "code" => "code".to_string(),
462 _ => default_agentic_parse_strategy(),
463 };
464
465 Self {
466 enabled: self.enabled,
467 default_strategy,
468 max_chars: self.max_chars.clamp(500, 200_000),
469 }
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(rename_all = "camelCase")]
476pub struct DocumentParserConfig {
477 #[serde(default = "default_enabled")]
479 pub enabled: bool,
480
481 #[serde(default = "default_document_parser_max_file_size_mb")]
483 pub max_file_size_mb: u64,
484
485 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub ocr: Option<DocumentOcrConfig>,
492
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub cache: Option<DocumentCacheConfig>,
496}
497
498impl Default for DocumentParserConfig {
499 fn default() -> Self {
500 Self {
501 enabled: true,
502 max_file_size_mb: default_document_parser_max_file_size_mb(),
503 ocr: None,
504 cache: Some(DocumentCacheConfig::default()),
505 }
506 }
507}
508
509impl DocumentParserConfig {
510 pub fn normalized(&self) -> Self {
511 Self {
512 enabled: self.enabled,
513 max_file_size_mb: self.max_file_size_mb.clamp(1, 1024),
514 ocr: self.ocr.as_ref().map(DocumentOcrConfig::normalized),
515 cache: self.cache.as_ref().map(DocumentCacheConfig::normalized),
516 }
517 }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[serde(rename_all = "camelCase")]
522pub struct DocumentCacheConfig {
523 #[serde(default = "default_enabled")]
524 pub enabled: bool,
525
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub directory: Option<PathBuf>,
528}
529
530impl Default for DocumentCacheConfig {
531 fn default() -> Self {
532 Self {
533 enabled: true,
534 directory: None,
535 }
536 }
537}
538
539impl DocumentCacheConfig {
540 pub fn normalized(&self) -> Self {
541 Self {
542 enabled: self.enabled,
543 directory: self.directory.clone(),
544 }
545 }
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct DocumentOcrConfig {
552 #[serde(default = "default_enabled")]
554 pub enabled: bool,
555
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub model: Option<String>,
559
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub prompt: Option<String>,
563
564 #[serde(default = "default_document_ocr_max_images")]
566 pub max_images: usize,
567
568 #[serde(default = "default_document_ocr_dpi")]
570 pub dpi: u32,
571
572 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub provider: Option<String>,
577
578 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub base_url: Option<String>,
581
582 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub api_key: Option<String>,
585}
586
587impl Default for DocumentOcrConfig {
588 fn default() -> Self {
589 Self {
590 enabled: false,
591 model: None,
592 prompt: None,
593 max_images: default_document_ocr_max_images(),
594 dpi: default_document_ocr_dpi(),
595 provider: None,
596 base_url: None,
597 api_key: None,
598 }
599 }
600}
601
602impl DocumentOcrConfig {
603 pub fn normalized(&self) -> Self {
604 Self {
605 enabled: self.enabled,
606 model: self.model.clone(),
607 prompt: self.prompt.clone(),
608 max_images: self.max_images.clamp(1, 64),
609 dpi: self.dpi.clamp(72, 600),
610 provider: self.provider.clone(),
611 base_url: self.base_url.clone(),
612 api_key: self.api_key.clone(),
613 }
614 }
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize)]
619#[serde(rename_all = "camelCase")]
620pub struct SearchHealthConfig {
621 #[serde(default = "default_max_failures")]
623 pub max_failures: u32,
624
625 #[serde(default = "default_suspend_seconds")]
627 pub suspend_seconds: u64,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(rename_all = "camelCase")]
633pub struct SearchEngineConfig {
634 #[serde(default = "default_enabled")]
636 pub enabled: bool,
637
638 #[serde(default = "default_weight")]
640 pub weight: f64,
641
642 #[serde(skip_serializing_if = "Option::is_none")]
644 pub timeout: Option<u64>,
645}
646
647fn default_search_timeout() -> u64 {
648 10
649}
650
651fn default_headless_max_tabs() -> usize {
652 4
653}
654
655fn default_max_failures() -> u32 {
656 3
657}
658
659fn default_suspend_seconds() -> u64 {
660 60
661}
662
663fn default_enabled() -> bool {
664 true
665}
666
667fn default_weight() -> f64 {
668 1.0
669}
670
671fn default_agentic_search_mode() -> String {
672 "fast".to_string()
673}
674
675fn default_agentic_search_max_results() -> usize {
676 10
677}
678
679fn default_agentic_search_context_lines() -> usize {
680 2
681}
682
683fn default_agentic_parse_strategy() -> String {
684 "auto".to_string()
685}
686
687fn default_agentic_parse_max_chars() -> usize {
688 8000
689}
690
691fn default_document_parser_max_file_size_mb() -> u64 {
692 50
693}
694
695fn default_document_ocr_max_images() -> usize {
696 8
697}
698
699fn default_document_ocr_dpi() -> u32 {
700 144
701}
702
703impl CodeConfig {
704 pub fn new() -> Self {
706 Self::default()
707 }
708
709 pub fn from_file(path: &Path) -> Result<Self> {
713 let content = std::fs::read_to_string(path).map_err(|e| {
714 CodeError::Config(format!(
715 "Failed to read config file {}: {}",
716 path.display(),
717 e
718 ))
719 })?;
720
721 Self::from_hcl(&content).map_err(|e| {
722 CodeError::Config(format!(
723 "Failed to parse HCL config {}: {}",
724 path.display(),
725 e
726 ))
727 })
728 }
729
730 pub fn from_hcl(content: &str) -> Result<Self> {
736 let body: hcl::Body = hcl::from_str(content)
737 .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
738 let json_value = hcl_body_to_json(&body);
739 serde_json::from_value(json_value)
740 .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
741 }
742
743 pub fn from_acl(content: &str) -> Result<Self> {
748 use a3s_acl::{parse_acl, Value as AclValue};
749
750 let doc = parse_acl(content)
751 .map_err(|e| CodeError::Config(format!("Failed to parse ACL: {}", e)))?;
752
753 let mut config = Self::default();
754
755 for block in doc.blocks {
756 match block.name.as_str() {
757 "default_model" => {
758 if let Some(v) = block.attributes.get("default_model") {
760 if let AclValue::String(s) = v {
761 config.default_model = Some(s.clone());
762 }
763 } else if let Some(s) = block.labels.first() {
764 config.default_model = Some(s.clone());
765 }
766 }
767 "providers" => {
768 let provider_name = block.labels.first().cloned().ok_or_else(|| {
771 CodeError::Config(
772 "providers block requires a label (e.g., providers \"openai\")".into(),
773 )
774 })?;
775
776 let mut provider = ProviderConfig {
777 name: provider_name.clone(),
778 api_key: None,
779 base_url: None,
780 headers: HashMap::new(),
781 session_id_header: None,
782 models: Vec::new(),
783 };
784
785 for (key, value) in &block.attributes {
786 match key.as_str() {
787 "apiKey" | "api_key" => {
788 if let AclValue::String(s) = value {
789 provider.api_key = Some(s.clone());
790 }
791 }
792 "baseUrl" | "base_url" => {
793 if let AclValue::String(s) = value {
794 provider.base_url = Some(s.clone());
795 }
796 }
797 _ => {}
798 }
799 }
800
801 for model_block in &block.blocks {
803 if model_block.name == "models" {
804 let model_name =
805 model_block.labels.first().cloned().ok_or_else(|| {
806 CodeError::Config(
807 "models block requires a label (e.g., models \"gpt-4\")"
808 .into(),
809 )
810 })?;
811
812 let mut model = ModelConfig {
813 id: model_name.clone(),
814 name: model_name.clone(),
815 family: String::new(),
816 api_key: None,
817 base_url: None,
818 headers: HashMap::new(),
819 session_id_header: None,
820 attachment: false,
821 reasoning: false,
822 tool_call: true,
823 temperature: true,
824 release_date: None,
825 modalities: ModelModalities::default(),
826 cost: ModelCost::default(),
827 limit: ModelLimit::default(),
828 };
829
830 for (key, value) in &model_block.attributes {
831 match key.as_str() {
832 "name" => {
833 if let AclValue::String(s) = value {
834 model.name = s.clone();
835 }
836 }
837 "apiKey" | "api_key" => {
838 if let AclValue::String(s) = value {
839 model.api_key = Some(s.clone());
840 }
841 }
842 "baseUrl" | "base_url" => {
843 if let AclValue::String(s) = value {
844 model.base_url = Some(s.clone());
845 }
846 }
847 _ => {}
848 }
849 }
850
851 provider.models.push(model);
852 }
853 }
854
855 config.providers.push(provider);
856 }
857 _ => {
858 }
861 }
862 }
863
864 Ok(config)
865 }
866
867 pub fn save_to_file(&self, path: &Path) -> Result<()> {
871 if let Some(parent) = path.parent() {
872 std::fs::create_dir_all(parent).map_err(|e| {
873 CodeError::Config(format!(
874 "Failed to create config directory {}: {}",
875 parent.display(),
876 e
877 ))
878 })?;
879 }
880
881 let content = serde_json::to_string_pretty(self)
882 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
883
884 std::fs::write(path, content).map_err(|e| {
885 CodeError::Config(format!(
886 "Failed to write config file {}: {}",
887 path.display(),
888 e
889 ))
890 })?;
891
892 Ok(())
893 }
894
895 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
897 self.providers.iter().find(|p| p.name == name)
898 }
899
900 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
902 let default = self.default_model.as_ref()?;
903 let (provider_name, _) = default.split_once('/')?;
904 self.find_provider(provider_name)
905 }
906
907 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
909 let default = self.default_model.as_ref()?;
910 let (provider_name, model_id) = default.split_once('/')?;
911 let provider = self.find_provider(provider_name)?;
912 let model = provider.find_model(model_id)?;
913 Some((provider, model))
914 }
915
916 pub fn default_llm_config(&self) -> Option<LlmConfig> {
920 let (provider, model) = self.default_model_config()?;
921 let api_key = provider.get_api_key(model)?;
922 let base_url = provider.get_base_url(model);
923 let headers = provider.get_headers(model);
924 let session_id_header = provider.get_session_id_header(model);
925
926 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
927 if let Some(url) = base_url {
928 config = config.with_base_url(url);
929 }
930 if !headers.is_empty() {
931 config = config.with_headers(headers);
932 }
933 if let Some(header_name) = session_id_header {
934 config = config.with_session_id_header(header_name);
935 }
936 config = apply_model_caps(config, model, self.thinking_budget);
937 Some(config)
938 }
939
940 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
944 let provider = self.find_provider(provider_name)?;
945 let model = provider.find_model(model_id)?;
946 let api_key = provider.get_api_key(model)?;
947 let base_url = provider.get_base_url(model);
948 let headers = provider.get_headers(model);
949 let session_id_header = provider.get_session_id_header(model);
950
951 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
952 if let Some(url) = base_url {
953 config = config.with_base_url(url);
954 }
955 if !headers.is_empty() {
956 config = config.with_headers(headers);
957 }
958 if let Some(header_name) = session_id_header {
959 config = config.with_session_id_header(header_name);
960 }
961 config = apply_model_caps(config, model, self.thinking_budget);
962 Some(config)
963 }
964
965 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
967 self.providers
968 .iter()
969 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
970 .collect()
971 }
972
973 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
975 self.skill_dirs.push(dir.into());
976 self
977 }
978
979 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
981 self.agent_dirs.push(dir.into());
982 self
983 }
984
985 pub fn has_directories(&self) -> bool {
987 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
988 }
989
990 pub fn has_providers(&self) -> bool {
992 !self.providers.is_empty()
993 }
994}
995
996const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
1002
1003const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
1007
1008fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
1010 hcl_body_to_json_inner(body, false)
1011}
1012
1013fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
1018 let mut map = serde_json::Map::new();
1019
1020 for attr in body.attributes() {
1022 let key = if verbatim_keys {
1023 attr.key.as_str().to_string()
1024 } else {
1025 snake_to_camel(attr.key.as_str())
1026 };
1027 let value = hcl_expr_to_json(attr.expr());
1028 map.insert(key, value);
1029 }
1030
1031 for block in body.blocks() {
1033 let key = if verbatim_keys {
1034 block.identifier.as_str().to_string()
1035 } else {
1036 snake_to_camel(block.identifier.as_str())
1037 };
1038 let child_verbatim = HCL_VERBATIM_BLOCKS.contains(&block.identifier.as_str());
1040 let block_value = hcl_body_to_json_inner(block.body(), child_verbatim);
1041
1042 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
1043 let arr = map
1045 .entry(key)
1046 .or_insert_with(|| JsonValue::Array(Vec::new()));
1047 if let JsonValue::Array(ref mut vec) = arr {
1048 vec.push(block_value);
1049 }
1050 } else {
1051 map.insert(key, block_value);
1052 }
1053 }
1054
1055 JsonValue::Object(map)
1056}
1057
1058fn snake_to_camel(s: &str) -> String {
1060 let mut result = String::with_capacity(s.len());
1061 let mut capitalize_next = false;
1062 for ch in s.chars() {
1063 if ch == '_' {
1064 capitalize_next = true;
1065 } else if capitalize_next {
1066 result.extend(ch.to_uppercase());
1067 capitalize_next = false;
1068 } else {
1069 result.push(ch);
1070 }
1071 }
1072 result
1073}
1074
1075fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
1077 match expr {
1078 hcl::Expression::String(s) => JsonValue::String(s.clone()),
1079 hcl::Expression::Number(n) => {
1080 if let Some(i) = n.as_i64() {
1081 JsonValue::Number(i.into())
1082 } else if let Some(f) = n.as_f64() {
1083 serde_json::Number::from_f64(f)
1084 .map(JsonValue::Number)
1085 .unwrap_or(JsonValue::Null)
1086 } else {
1087 JsonValue::Null
1088 }
1089 }
1090 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
1091 hcl::Expression::Null => JsonValue::Null,
1092 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
1093 hcl::Expression::Object(obj) => {
1094 let map: serde_json::Map<String, JsonValue> = obj
1097 .iter()
1098 .map(|(k, v)| {
1099 let key = match k {
1100 hcl::ObjectKey::Identifier(id) => id.as_str().to_string(),
1101 hcl::ObjectKey::Expression(expr) => {
1102 if let hcl::Expression::String(s) = expr {
1103 s.clone()
1104 } else {
1105 format!("{:?}", expr)
1106 }
1107 }
1108 _ => format!("{:?}", k),
1109 };
1110 (key, hcl_expr_to_json(v))
1111 })
1112 .collect();
1113 JsonValue::Object(map)
1114 }
1115 hcl::Expression::FuncCall(func_call) => eval_func_call(func_call),
1116 hcl::Expression::TemplateExpr(tmpl) => eval_template_expr(tmpl),
1117 _ => JsonValue::String(format!("{:?}", expr)),
1118 }
1119}
1120
1121fn eval_func_call(func_call: &hcl::expr::FuncCall) -> JsonValue {
1126 let name = func_call.name.name.as_str();
1127 match name {
1128 "env" => {
1129 if let Some(arg) = func_call.args.first() {
1130 let var_name = match arg {
1131 hcl::Expression::String(s) => s.as_str(),
1132 _ => {
1133 tracing::warn!("env() expects a string argument, got: {:?}", arg);
1134 return JsonValue::Null;
1135 }
1136 };
1137 match std::env::var(var_name) {
1138 Ok(val) => JsonValue::String(val),
1139 Err(_) => {
1140 tracing::debug!("env(\"{}\") is not set, returning null", var_name);
1141 JsonValue::Null
1142 }
1143 }
1144 } else {
1145 tracing::warn!("env() called with no arguments");
1146 JsonValue::Null
1147 }
1148 }
1149 _ => {
1150 tracing::warn!("Unsupported HCL function: {}()", name);
1151 JsonValue::String(format!("{}()", name))
1152 }
1153 }
1154}
1155
1156fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
1161 JsonValue::String(format!("{}", tmpl))
1167}
1168
1169#[cfg(test)]
1174mod tests {
1175 use super::*;
1176
1177 #[test]
1178 fn test_config_default() {
1179 let config = CodeConfig::default();
1180 assert!(config.skill_dirs.is_empty());
1181 assert!(config.agent_dirs.is_empty());
1182 assert!(config.providers.is_empty());
1183 assert!(config.default_model.is_none());
1184 assert_eq!(config.storage_backend, StorageBackend::File);
1185 assert!(config.sessions_dir.is_none());
1186 }
1187
1188 #[test]
1189 fn test_storage_backend_default() {
1190 let backend = StorageBackend::default();
1191 assert_eq!(backend, StorageBackend::File);
1192 }
1193
1194 #[test]
1195 fn test_storage_backend_serde() {
1196 let memory = StorageBackend::Memory;
1198 let json = serde_json::to_string(&memory).unwrap();
1199 assert_eq!(json, "\"memory\"");
1200
1201 let file = StorageBackend::File;
1202 let json = serde_json::to_string(&file).unwrap();
1203 assert_eq!(json, "\"file\"");
1204
1205 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
1207 assert_eq!(memory, StorageBackend::Memory);
1208
1209 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
1210 assert_eq!(file, StorageBackend::File);
1211 }
1212
1213 #[test]
1214 fn test_config_with_storage_backend() {
1215 let temp_dir = tempfile::tempdir().unwrap();
1216 let config_path = temp_dir.path().join("config.hcl");
1217
1218 std::fs::write(
1219 &config_path,
1220 r#"
1221 storage_backend = "memory"
1222 sessions_dir = "/tmp/sessions"
1223 "#,
1224 )
1225 .unwrap();
1226
1227 let config = CodeConfig::from_file(&config_path).unwrap();
1228 assert_eq!(config.storage_backend, StorageBackend::Memory);
1229 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
1230 }
1231
1232 #[test]
1233 fn test_config_builder() {
1234 let config = CodeConfig::new()
1235 .add_skill_dir("/tmp/skills")
1236 .add_agent_dir("/tmp/agents");
1237
1238 assert_eq!(config.skill_dirs.len(), 1);
1239 assert_eq!(config.agent_dirs.len(), 1);
1240 }
1241
1242 #[test]
1243 fn test_find_provider() {
1244 let config = CodeConfig {
1245 providers: vec![
1246 ProviderConfig {
1247 name: "anthropic".to_string(),
1248 api_key: Some("key1".to_string()),
1249 base_url: None,
1250 headers: HashMap::new(),
1251 session_id_header: None,
1252 models: vec![],
1253 },
1254 ProviderConfig {
1255 name: "openai".to_string(),
1256 api_key: Some("key2".to_string()),
1257 base_url: None,
1258 headers: HashMap::new(),
1259 session_id_header: None,
1260 models: vec![],
1261 },
1262 ],
1263 ..Default::default()
1264 };
1265
1266 assert!(config.find_provider("anthropic").is_some());
1267 assert!(config.find_provider("openai").is_some());
1268 assert!(config.find_provider("unknown").is_none());
1269 }
1270
1271 #[test]
1272 fn test_default_llm_config() {
1273 let config = CodeConfig {
1274 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1275 providers: vec![ProviderConfig {
1276 name: "anthropic".to_string(),
1277 api_key: Some("test-api-key".to_string()),
1278 base_url: Some("https://api.anthropic.com".to_string()),
1279 headers: HashMap::new(),
1280 session_id_header: None,
1281 models: vec![ModelConfig {
1282 id: "claude-sonnet-4".to_string(),
1283 name: "Claude Sonnet 4".to_string(),
1284 family: "claude-sonnet".to_string(),
1285 api_key: None,
1286 base_url: None,
1287 headers: HashMap::new(),
1288 session_id_header: None,
1289 attachment: false,
1290 reasoning: false,
1291 tool_call: true,
1292 temperature: true,
1293 release_date: None,
1294 modalities: ModelModalities::default(),
1295 cost: ModelCost::default(),
1296 limit: ModelLimit::default(),
1297 }],
1298 }],
1299 ..Default::default()
1300 };
1301
1302 let llm_config = config.default_llm_config().unwrap();
1303 assert_eq!(llm_config.provider, "anthropic");
1304 assert_eq!(llm_config.model, "claude-sonnet-4");
1305 assert_eq!(llm_config.api_key.expose(), "test-api-key");
1306 assert_eq!(
1307 llm_config.base_url,
1308 Some("https://api.anthropic.com".to_string())
1309 );
1310 }
1311
1312 #[test]
1313 fn test_model_api_key_override() {
1314 let provider = ProviderConfig {
1315 name: "openai".to_string(),
1316 api_key: Some("provider-key".to_string()),
1317 base_url: Some("https://api.openai.com".to_string()),
1318 headers: HashMap::new(),
1319 session_id_header: None,
1320 models: vec![
1321 ModelConfig {
1322 id: "gpt-4".to_string(),
1323 name: "GPT-4".to_string(),
1324 family: "gpt".to_string(),
1325 api_key: None, base_url: None,
1327 headers: HashMap::new(),
1328 session_id_header: None,
1329 attachment: false,
1330 reasoning: false,
1331 tool_call: true,
1332 temperature: true,
1333 release_date: None,
1334 modalities: ModelModalities::default(),
1335 cost: ModelCost::default(),
1336 limit: ModelLimit::default(),
1337 },
1338 ModelConfig {
1339 id: "custom-model".to_string(),
1340 name: "Custom Model".to_string(),
1341 family: "custom".to_string(),
1342 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), headers: HashMap::new(),
1345 session_id_header: None,
1346 attachment: false,
1347 reasoning: false,
1348 tool_call: true,
1349 temperature: true,
1350 release_date: None,
1351 modalities: ModelModalities::default(),
1352 cost: ModelCost::default(),
1353 limit: ModelLimit::default(),
1354 },
1355 ],
1356 };
1357
1358 let model1 = provider.find_model("gpt-4").unwrap();
1360 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
1361 assert_eq!(
1362 provider.get_base_url(model1),
1363 Some("https://api.openai.com")
1364 );
1365
1366 let model2 = provider.find_model("custom-model").unwrap();
1368 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
1369 assert_eq!(
1370 provider.get_base_url(model2),
1371 Some("https://custom.api.com")
1372 );
1373 }
1374
1375 #[test]
1376 fn test_list_models() {
1377 let config = CodeConfig {
1378 providers: vec![
1379 ProviderConfig {
1380 name: "anthropic".to_string(),
1381 api_key: None,
1382 base_url: None,
1383 headers: HashMap::new(),
1384 session_id_header: None,
1385 models: vec![
1386 ModelConfig {
1387 id: "claude-1".to_string(),
1388 name: "Claude 1".to_string(),
1389 family: "claude".to_string(),
1390 api_key: None,
1391 base_url: None,
1392 headers: HashMap::new(),
1393 session_id_header: None,
1394 attachment: false,
1395 reasoning: false,
1396 tool_call: true,
1397 temperature: true,
1398 release_date: None,
1399 modalities: ModelModalities::default(),
1400 cost: ModelCost::default(),
1401 limit: ModelLimit::default(),
1402 },
1403 ModelConfig {
1404 id: "claude-2".to_string(),
1405 name: "Claude 2".to_string(),
1406 family: "claude".to_string(),
1407 api_key: None,
1408 base_url: None,
1409 headers: HashMap::new(),
1410 session_id_header: None,
1411 attachment: false,
1412 reasoning: false,
1413 tool_call: true,
1414 temperature: true,
1415 release_date: None,
1416 modalities: ModelModalities::default(),
1417 cost: ModelCost::default(),
1418 limit: ModelLimit::default(),
1419 },
1420 ],
1421 },
1422 ProviderConfig {
1423 name: "openai".to_string(),
1424 api_key: None,
1425 base_url: None,
1426 headers: HashMap::new(),
1427 session_id_header: None,
1428 models: vec![ModelConfig {
1429 id: "gpt-4".to_string(),
1430 name: "GPT-4".to_string(),
1431 family: "gpt".to_string(),
1432 api_key: None,
1433 base_url: None,
1434 headers: HashMap::new(),
1435 session_id_header: None,
1436 attachment: false,
1437 reasoning: false,
1438 tool_call: true,
1439 temperature: true,
1440 release_date: None,
1441 modalities: ModelModalities::default(),
1442 cost: ModelCost::default(),
1443 limit: ModelLimit::default(),
1444 }],
1445 },
1446 ],
1447 ..Default::default()
1448 };
1449
1450 let models = config.list_models();
1451 assert_eq!(models.len(), 3);
1452 }
1453
1454 #[test]
1455 fn test_config_from_file_not_found() {
1456 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
1457 assert!(result.is_err());
1458 }
1459
1460 #[test]
1461 fn test_config_has_directories() {
1462 let empty = CodeConfig::default();
1463 assert!(!empty.has_directories());
1464
1465 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
1466 assert!(with_skills.has_directories());
1467
1468 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
1469 assert!(with_agents.has_directories());
1470 }
1471
1472 #[test]
1473 fn test_config_has_providers() {
1474 let empty = CodeConfig::default();
1475 assert!(!empty.has_providers());
1476
1477 let with_providers = CodeConfig {
1478 providers: vec![ProviderConfig {
1479 name: "test".to_string(),
1480 api_key: None,
1481 base_url: None,
1482 headers: HashMap::new(),
1483 session_id_header: None,
1484 models: vec![],
1485 }],
1486 ..Default::default()
1487 };
1488 assert!(with_providers.has_providers());
1489 }
1490
1491 #[test]
1492 fn test_storage_backend_equality() {
1493 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
1494 assert_eq!(StorageBackend::File, StorageBackend::File);
1495 assert_ne!(StorageBackend::Memory, StorageBackend::File);
1496 }
1497
1498 #[test]
1499 fn test_storage_backend_serde_custom() {
1500 let custom = StorageBackend::Custom;
1501 let json = serde_json::to_string(&custom).unwrap();
1503 assert_eq!(json, "\"custom\"");
1504
1505 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
1507 assert_eq!(parsed, StorageBackend::Custom);
1508 }
1509
1510 #[test]
1511 fn test_model_cost_default() {
1512 let cost = ModelCost::default();
1513 assert_eq!(cost.input, 0.0);
1514 assert_eq!(cost.output, 0.0);
1515 assert_eq!(cost.cache_read, 0.0);
1516 assert_eq!(cost.cache_write, 0.0);
1517 }
1518
1519 #[test]
1520 fn test_model_cost_serialization() {
1521 let cost = ModelCost {
1522 input: 3.0,
1523 output: 15.0,
1524 cache_read: 0.3,
1525 cache_write: 3.75,
1526 };
1527 let json = serde_json::to_string(&cost).unwrap();
1528 assert!(json.contains("\"input\":3"));
1529 assert!(json.contains("\"output\":15"));
1530 }
1531
1532 #[test]
1533 fn test_model_cost_deserialization_missing_fields() {
1534 let json = r#"{"input":3.0}"#;
1535 let cost: ModelCost = serde_json::from_str(json).unwrap();
1536 assert_eq!(cost.input, 3.0);
1537 assert_eq!(cost.output, 0.0);
1538 assert_eq!(cost.cache_read, 0.0);
1539 assert_eq!(cost.cache_write, 0.0);
1540 }
1541
1542 #[test]
1543 fn test_model_limit_default() {
1544 let limit = ModelLimit::default();
1545 assert_eq!(limit.context, 0);
1546 assert_eq!(limit.output, 0);
1547 }
1548
1549 #[test]
1550 fn test_model_limit_serialization() {
1551 let limit = ModelLimit {
1552 context: 200000,
1553 output: 8192,
1554 };
1555 let json = serde_json::to_string(&limit).unwrap();
1556 assert!(json.contains("\"context\":200000"));
1557 assert!(json.contains("\"output\":8192"));
1558 }
1559
1560 #[test]
1561 fn test_model_limit_deserialization_missing_fields() {
1562 let json = r#"{"context":100000}"#;
1563 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1564 assert_eq!(limit.context, 100000);
1565 assert_eq!(limit.output, 0);
1566 }
1567
1568 #[test]
1569 fn test_model_modalities_default() {
1570 let modalities = ModelModalities::default();
1571 assert!(modalities.input.is_empty());
1572 assert!(modalities.output.is_empty());
1573 }
1574
1575 #[test]
1576 fn test_model_modalities_serialization() {
1577 let modalities = ModelModalities {
1578 input: vec!["text".to_string(), "image".to_string()],
1579 output: vec!["text".to_string()],
1580 };
1581 let json = serde_json::to_string(&modalities).unwrap();
1582 assert!(json.contains("\"input\""));
1583 assert!(json.contains("\"text\""));
1584 }
1585
1586 #[test]
1587 fn test_model_modalities_deserialization_missing_fields() {
1588 let json = r#"{"input":["text"]}"#;
1589 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1590 assert_eq!(modalities.input.len(), 1);
1591 assert!(modalities.output.is_empty());
1592 }
1593
1594 #[test]
1595 fn test_model_config_serialization() {
1596 let config = ModelConfig {
1597 id: "gpt-4o".to_string(),
1598 name: "GPT-4o".to_string(),
1599 family: "gpt-4".to_string(),
1600 api_key: Some("sk-test".to_string()),
1601 base_url: None,
1602 headers: HashMap::new(),
1603 session_id_header: None,
1604 attachment: true,
1605 reasoning: false,
1606 tool_call: true,
1607 temperature: true,
1608 release_date: Some("2024-05-13".to_string()),
1609 modalities: ModelModalities::default(),
1610 cost: ModelCost::default(),
1611 limit: ModelLimit::default(),
1612 };
1613 let json = serde_json::to_string(&config).unwrap();
1614 assert!(json.contains("\"id\":\"gpt-4o\""));
1615 assert!(json.contains("\"attachment\":true"));
1616 }
1617
1618 #[test]
1619 fn test_model_config_deserialization_with_defaults() {
1620 let json = r#"{"id":"test-model"}"#;
1621 let config: ModelConfig = serde_json::from_str(json).unwrap();
1622 assert_eq!(config.id, "test-model");
1623 assert_eq!(config.name, "");
1624 assert_eq!(config.family, "");
1625 assert!(config.api_key.is_none());
1626 assert!(!config.attachment);
1627 assert!(config.tool_call);
1628 assert!(config.temperature);
1629 }
1630
1631 #[test]
1632 fn test_model_config_all_optional_fields() {
1633 let json = r#"{
1634 "id": "claude-sonnet-4",
1635 "name": "Claude Sonnet 4",
1636 "family": "claude-sonnet",
1637 "apiKey": "sk-test",
1638 "baseUrl": "https://api.anthropic.com",
1639 "attachment": true,
1640 "reasoning": true,
1641 "toolCall": false,
1642 "temperature": false,
1643 "releaseDate": "2025-05-14"
1644 }"#;
1645 let config: ModelConfig = serde_json::from_str(json).unwrap();
1646 assert_eq!(config.id, "claude-sonnet-4");
1647 assert_eq!(config.name, "Claude Sonnet 4");
1648 assert_eq!(config.api_key, Some("sk-test".to_string()));
1649 assert_eq!(
1650 config.base_url,
1651 Some("https://api.anthropic.com".to_string())
1652 );
1653 assert!(config.attachment);
1654 assert!(config.reasoning);
1655 assert!(!config.tool_call);
1656 assert!(!config.temperature);
1657 }
1658
1659 #[test]
1660 fn test_provider_config_serialization() {
1661 let provider = ProviderConfig {
1662 name: "anthropic".to_string(),
1663 api_key: Some("sk-test".to_string()),
1664 base_url: Some("https://api.anthropic.com".to_string()),
1665 headers: HashMap::new(),
1666 session_id_header: None,
1667 models: vec![],
1668 };
1669 let json = serde_json::to_string(&provider).unwrap();
1670 assert!(json.contains("\"name\":\"anthropic\""));
1671 assert!(json.contains("\"apiKey\":\"sk-test\""));
1672 }
1673
1674 #[test]
1675 fn test_provider_config_deserialization_missing_optional() {
1676 let json = r#"{"name":"openai"}"#;
1677 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1678 assert_eq!(provider.name, "openai");
1679 assert!(provider.api_key.is_none());
1680 assert!(provider.base_url.is_none());
1681 assert!(provider.models.is_empty());
1682 }
1683
1684 #[test]
1685 fn test_provider_config_find_model() {
1686 let provider = ProviderConfig {
1687 name: "anthropic".to_string(),
1688 api_key: None,
1689 base_url: None,
1690 headers: HashMap::new(),
1691 session_id_header: None,
1692 models: vec![ModelConfig {
1693 id: "claude-sonnet-4".to_string(),
1694 name: "Claude Sonnet 4".to_string(),
1695 family: "claude-sonnet".to_string(),
1696 api_key: None,
1697 base_url: None,
1698 headers: HashMap::new(),
1699 session_id_header: None,
1700 attachment: false,
1701 reasoning: false,
1702 tool_call: true,
1703 temperature: true,
1704 release_date: None,
1705 modalities: ModelModalities::default(),
1706 cost: ModelCost::default(),
1707 limit: ModelLimit::default(),
1708 }],
1709 };
1710
1711 let found = provider.find_model("claude-sonnet-4");
1712 assert!(found.is_some());
1713 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1714
1715 let not_found = provider.find_model("gpt-4o");
1716 assert!(not_found.is_none());
1717 }
1718
1719 #[test]
1720 fn test_provider_config_get_api_key() {
1721 let provider = ProviderConfig {
1722 name: "anthropic".to_string(),
1723 api_key: Some("provider-key".to_string()),
1724 base_url: None,
1725 headers: HashMap::new(),
1726 session_id_header: None,
1727 models: vec![],
1728 };
1729
1730 let model_with_key = ModelConfig {
1731 id: "test".to_string(),
1732 name: "".to_string(),
1733 family: "".to_string(),
1734 api_key: Some("model-key".to_string()),
1735 base_url: None,
1736 headers: HashMap::new(),
1737 session_id_header: None,
1738 attachment: false,
1739 reasoning: false,
1740 tool_call: true,
1741 temperature: true,
1742 release_date: None,
1743 modalities: ModelModalities::default(),
1744 cost: ModelCost::default(),
1745 limit: ModelLimit::default(),
1746 };
1747
1748 let model_without_key = ModelConfig {
1749 id: "test2".to_string(),
1750 name: "".to_string(),
1751 family: "".to_string(),
1752 api_key: None,
1753 base_url: None,
1754 headers: HashMap::new(),
1755 session_id_header: None,
1756 attachment: false,
1757 reasoning: false,
1758 tool_call: true,
1759 temperature: true,
1760 release_date: None,
1761 modalities: ModelModalities::default(),
1762 cost: ModelCost::default(),
1763 limit: ModelLimit::default(),
1764 };
1765
1766 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1767 assert_eq!(
1768 provider.get_api_key(&model_without_key),
1769 Some("provider-key")
1770 );
1771 }
1772
1773 #[test]
1774 fn test_provider_config_get_headers_and_session_id_header() {
1775 let mut provider_headers = HashMap::new();
1776 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1777 provider_headers.insert("X-Shared".to_string(), "provider".to_string());
1778
1779 let mut model_headers = HashMap::new();
1780 model_headers.insert("X-Model".to_string(), "model".to_string());
1781 model_headers.insert("X-Shared".to_string(), "model".to_string());
1782
1783 let provider = ProviderConfig {
1784 name: "openai".to_string(),
1785 api_key: Some("provider-key".to_string()),
1786 base_url: None,
1787 headers: provider_headers,
1788 session_id_header: Some("X-Session-Id".to_string()),
1789 models: vec![],
1790 };
1791
1792 let model = ModelConfig {
1793 id: "gpt-4o".to_string(),
1794 name: "".to_string(),
1795 family: "".to_string(),
1796 api_key: None,
1797 base_url: None,
1798 headers: model_headers,
1799 session_id_header: Some("X-Model-Session".to_string()),
1800 attachment: false,
1801 reasoning: false,
1802 tool_call: true,
1803 temperature: true,
1804 release_date: None,
1805 modalities: ModelModalities::default(),
1806 cost: ModelCost::default(),
1807 limit: ModelLimit::default(),
1808 };
1809
1810 let headers = provider.get_headers(&model);
1811 assert_eq!(headers.get("X-Provider"), Some(&"provider".to_string()));
1812 assert_eq!(headers.get("X-Model"), Some(&"model".to_string()));
1813 assert_eq!(headers.get("X-Shared"), Some(&"model".to_string()));
1814 assert_eq!(
1815 provider.get_session_id_header(&model),
1816 Some("X-Model-Session")
1817 );
1818 }
1819
1820 #[test]
1821 fn test_llm_config_includes_headers_and_runtime_session_header() {
1822 let mut provider_headers = HashMap::new();
1823 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1824
1825 let config = CodeConfig {
1826 default_model: Some("openai/gpt-4o".to_string()),
1827 providers: vec![ProviderConfig {
1828 name: "openai".to_string(),
1829 api_key: Some("sk-test".to_string()),
1830 base_url: Some("https://api.example.com".to_string()),
1831 headers: provider_headers,
1832 session_id_header: Some("X-Session-Id".to_string()),
1833 models: vec![ModelConfig {
1834 id: "gpt-4o".to_string(),
1835 name: "".to_string(),
1836 family: "".to_string(),
1837 api_key: None,
1838 base_url: None,
1839 headers: HashMap::new(),
1840 session_id_header: None,
1841 attachment: false,
1842 reasoning: false,
1843 tool_call: true,
1844 temperature: true,
1845 release_date: None,
1846 modalities: ModelModalities::default(),
1847 cost: ModelCost::default(),
1848 limit: ModelLimit::default(),
1849 }],
1850 }],
1851 ..Default::default()
1852 };
1853
1854 let llm_config = config.default_llm_config().unwrap();
1855 assert_eq!(
1856 llm_config.headers.get("X-Provider"),
1857 Some(&"provider".to_string())
1858 );
1859 assert_eq!(
1860 llm_config.session_id_header.as_deref(),
1861 Some("X-Session-Id")
1862 );
1863 }
1864
1865 #[test]
1866 fn test_code_config_default_provider_config() {
1867 let config = CodeConfig {
1868 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1869 providers: vec![ProviderConfig {
1870 name: "anthropic".to_string(),
1871 api_key: Some("sk-test".to_string()),
1872 base_url: None,
1873 headers: HashMap::new(),
1874 session_id_header: None,
1875 models: vec![],
1876 }],
1877 ..Default::default()
1878 };
1879
1880 let provider = config.default_provider_config();
1881 assert!(provider.is_some());
1882 assert_eq!(provider.unwrap().name, "anthropic");
1883 }
1884
1885 #[test]
1886 fn test_code_config_default_model_config() {
1887 let config = CodeConfig {
1888 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1889 providers: vec![ProviderConfig {
1890 name: "anthropic".to_string(),
1891 api_key: Some("sk-test".to_string()),
1892 base_url: None,
1893 headers: HashMap::new(),
1894 session_id_header: None,
1895 models: vec![ModelConfig {
1896 id: "claude-sonnet-4".to_string(),
1897 name: "Claude Sonnet 4".to_string(),
1898 family: "claude-sonnet".to_string(),
1899 api_key: None,
1900 base_url: None,
1901 headers: HashMap::new(),
1902 session_id_header: None,
1903 attachment: false,
1904 reasoning: false,
1905 tool_call: true,
1906 temperature: true,
1907 release_date: None,
1908 modalities: ModelModalities::default(),
1909 cost: ModelCost::default(),
1910 limit: ModelLimit::default(),
1911 }],
1912 }],
1913 ..Default::default()
1914 };
1915
1916 let result = config.default_model_config();
1917 assert!(result.is_some());
1918 let (provider, model) = result.unwrap();
1919 assert_eq!(provider.name, "anthropic");
1920 assert_eq!(model.id, "claude-sonnet-4");
1921 }
1922
1923 #[test]
1924 fn test_code_config_default_llm_config() {
1925 let config = CodeConfig {
1926 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1927 providers: vec![ProviderConfig {
1928 name: "anthropic".to_string(),
1929 api_key: Some("sk-test".to_string()),
1930 base_url: Some("https://api.anthropic.com".to_string()),
1931 headers: HashMap::new(),
1932 session_id_header: None,
1933 models: vec![ModelConfig {
1934 id: "claude-sonnet-4".to_string(),
1935 name: "Claude Sonnet 4".to_string(),
1936 family: "claude-sonnet".to_string(),
1937 api_key: None,
1938 base_url: None,
1939 headers: HashMap::new(),
1940 session_id_header: None,
1941 attachment: false,
1942 reasoning: false,
1943 tool_call: true,
1944 temperature: true,
1945 release_date: None,
1946 modalities: ModelModalities::default(),
1947 cost: ModelCost::default(),
1948 limit: ModelLimit::default(),
1949 }],
1950 }],
1951 ..Default::default()
1952 };
1953
1954 let llm_config = config.default_llm_config();
1955 assert!(llm_config.is_some());
1956 }
1957
1958 #[test]
1959 fn test_code_config_list_models() {
1960 let config = CodeConfig {
1961 providers: vec![
1962 ProviderConfig {
1963 name: "anthropic".to_string(),
1964 api_key: None,
1965 base_url: None,
1966 headers: HashMap::new(),
1967 session_id_header: None,
1968 models: vec![ModelConfig {
1969 id: "claude-sonnet-4".to_string(),
1970 name: "".to_string(),
1971 family: "".to_string(),
1972 api_key: None,
1973 base_url: None,
1974 headers: HashMap::new(),
1975 session_id_header: None,
1976 attachment: false,
1977 reasoning: false,
1978 tool_call: true,
1979 temperature: true,
1980 release_date: None,
1981 modalities: ModelModalities::default(),
1982 cost: ModelCost::default(),
1983 limit: ModelLimit::default(),
1984 }],
1985 },
1986 ProviderConfig {
1987 name: "openai".to_string(),
1988 api_key: None,
1989 base_url: None,
1990 headers: HashMap::new(),
1991 session_id_header: None,
1992 models: vec![ModelConfig {
1993 id: "gpt-4o".to_string(),
1994 name: "".to_string(),
1995 family: "".to_string(),
1996 api_key: None,
1997 base_url: None,
1998 headers: HashMap::new(),
1999 session_id_header: None,
2000 attachment: false,
2001 reasoning: false,
2002 tool_call: true,
2003 temperature: true,
2004 release_date: None,
2005 modalities: ModelModalities::default(),
2006 cost: ModelCost::default(),
2007 limit: ModelLimit::default(),
2008 }],
2009 },
2010 ],
2011 ..Default::default()
2012 };
2013
2014 let models = config.list_models();
2015 assert_eq!(models.len(), 2);
2016 }
2017
2018 #[test]
2019 fn test_llm_config_specific_provider_model() {
2020 let model: ModelConfig = serde_json::from_value(serde_json::json!({
2021 "id": "claude-3",
2022 "name": "Claude 3"
2023 }))
2024 .unwrap();
2025
2026 let config = CodeConfig {
2027 providers: vec![ProviderConfig {
2028 name: "anthropic".to_string(),
2029 api_key: Some("sk-test".to_string()),
2030 base_url: None,
2031 headers: HashMap::new(),
2032 session_id_header: None,
2033 models: vec![model],
2034 }],
2035 ..Default::default()
2036 };
2037
2038 let llm = config.llm_config("anthropic", "claude-3");
2039 assert!(llm.is_some());
2040 let llm = llm.unwrap();
2041 assert_eq!(llm.provider, "anthropic");
2042 assert_eq!(llm.model, "claude-3");
2043 }
2044
2045 #[test]
2046 fn test_llm_config_missing_provider() {
2047 let config = CodeConfig::default();
2048 assert!(config.llm_config("nonexistent", "model").is_none());
2049 }
2050
2051 #[test]
2052 fn test_llm_config_missing_model() {
2053 let config = CodeConfig {
2054 providers: vec![ProviderConfig {
2055 name: "anthropic".to_string(),
2056 api_key: Some("sk-test".to_string()),
2057 base_url: None,
2058 headers: HashMap::new(),
2059 session_id_header: None,
2060 models: vec![],
2061 }],
2062 ..Default::default()
2063 };
2064 assert!(config.llm_config("anthropic", "nonexistent").is_none());
2065 }
2066
2067 #[test]
2068 fn test_from_hcl_string() {
2069 let hcl = r#"
2070 default_model = "anthropic/claude-sonnet-4"
2071
2072 providers {
2073 name = "anthropic"
2074 api_key = "test-key"
2075
2076 models {
2077 id = "claude-sonnet-4"
2078 name = "Claude Sonnet 4"
2079 }
2080 }
2081 "#;
2082
2083 let config = CodeConfig::from_hcl(hcl).unwrap();
2084 assert_eq!(
2085 config.default_model,
2086 Some("anthropic/claude-sonnet-4".to_string())
2087 );
2088 assert_eq!(config.providers.len(), 1);
2089 assert_eq!(config.providers[0].name, "anthropic");
2090 assert_eq!(config.providers[0].models.len(), 1);
2091 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
2092 }
2093
2094 #[test]
2095 fn test_from_hcl_multi_provider() {
2096 let hcl = r#"
2097 default_model = "anthropic/claude-sonnet-4"
2098
2099 providers {
2100 name = "anthropic"
2101 api_key = "sk-ant-test"
2102
2103 models {
2104 id = "claude-sonnet-4"
2105 name = "Claude Sonnet 4"
2106 }
2107
2108 models {
2109 id = "claude-opus-4"
2110 name = "Claude Opus 4"
2111 reasoning = true
2112 }
2113 }
2114
2115 providers {
2116 name = "openai"
2117 api_key = "sk-test"
2118
2119 models {
2120 id = "gpt-4o"
2121 name = "GPT-4o"
2122 }
2123 }
2124 "#;
2125
2126 let config = CodeConfig::from_hcl(hcl).unwrap();
2127 assert_eq!(config.providers.len(), 2);
2128 assert_eq!(config.providers[0].models.len(), 2);
2129 assert_eq!(config.providers[1].models.len(), 1);
2130 assert_eq!(config.providers[1].name, "openai");
2131 }
2132
2133 #[test]
2134 fn test_snake_to_camel() {
2135 assert_eq!(snake_to_camel("default_model"), "defaultModel");
2136 assert_eq!(snake_to_camel("api_key"), "apiKey");
2137 assert_eq!(snake_to_camel("base_url"), "baseUrl");
2138 assert_eq!(snake_to_camel("name"), "name");
2139 assert_eq!(snake_to_camel("tool_call"), "toolCall");
2140 }
2141
2142 #[test]
2143 fn test_from_file_auto_detect_hcl() {
2144 let temp_dir = tempfile::tempdir().unwrap();
2145 let config_path = temp_dir.path().join("config.hcl");
2146
2147 std::fs::write(
2148 &config_path,
2149 r#"
2150 default_model = "anthropic/claude-sonnet-4"
2151
2152 providers {
2153 name = "anthropic"
2154 api_key = "test-key"
2155
2156 models {
2157 id = "claude-sonnet-4"
2158 }
2159 }
2160 "#,
2161 )
2162 .unwrap();
2163
2164 let config = CodeConfig::from_file(&config_path).unwrap();
2165 assert_eq!(
2166 config.default_model,
2167 Some("anthropic/claude-sonnet-4".to_string())
2168 );
2169 }
2170
2171 #[test]
2172 fn test_from_hcl_with_queue_config() {
2173 let hcl = r#"
2174 default_model = "anthropic/claude-sonnet-4"
2175
2176 providers {
2177 name = "anthropic"
2178 api_key = "test-key"
2179 }
2180
2181 queue {
2182 query_max_concurrency = 20
2183 execute_max_concurrency = 5
2184 enable_metrics = true
2185 enable_dlq = true
2186 }
2187 "#;
2188
2189 let config = CodeConfig::from_hcl(hcl).unwrap();
2190 assert!(config.queue.is_some());
2191 let queue = config.queue.unwrap();
2192 assert_eq!(queue.query_max_concurrency, 20);
2193 assert_eq!(queue.execute_max_concurrency, 5);
2194 assert!(queue.enable_metrics);
2195 assert!(queue.enable_dlq);
2196 }
2197
2198 #[test]
2199 fn test_from_hcl_with_search_config() {
2200 let hcl = r#"
2201 default_model = "anthropic/claude-sonnet-4"
2202
2203 providers {
2204 name = "anthropic"
2205 api_key = "test-key"
2206 }
2207
2208 search {
2209 timeout = 30
2210
2211 health {
2212 max_failures = 5
2213 suspend_seconds = 120
2214 }
2215
2216 engine {
2217 google {
2218 enabled = true
2219 weight = 1.5
2220 }
2221 bing {
2222 enabled = true
2223 weight = 1.0
2224 timeout = 15
2225 }
2226 }
2227 }
2228 "#;
2229
2230 let config = CodeConfig::from_hcl(hcl).unwrap();
2231 assert!(config.search.is_some());
2232 let search = config.search.unwrap();
2233 assert_eq!(search.timeout, 30);
2234 assert!(search.health.is_some());
2235 let health = search.health.unwrap();
2236 assert_eq!(health.max_failures, 5);
2237 assert_eq!(health.suspend_seconds, 120);
2238 assert_eq!(search.engines.len(), 2);
2239 assert!(search.engines.contains_key("google"));
2240 assert!(search.engines.contains_key("bing"));
2241 let google = &search.engines["google"];
2242 assert!(google.enabled);
2243 assert_eq!(google.weight, 1.5);
2244 let bing = &search.engines["bing"];
2245 assert_eq!(bing.timeout, Some(15));
2246 }
2247
2248 #[test]
2249 fn test_from_hcl_with_queue_and_search() {
2250 let hcl = r#"
2251 default_model = "anthropic/claude-sonnet-4"
2252
2253 providers {
2254 name = "anthropic"
2255 api_key = "test-key"
2256 }
2257
2258 queue {
2259 query_max_concurrency = 10
2260 enable_metrics = true
2261 }
2262
2263 search {
2264 timeout = 20
2265 engine {
2266 duckduckgo {
2267 enabled = true
2268 }
2269 }
2270 }
2271 "#;
2272
2273 let config = CodeConfig::from_hcl(hcl).unwrap();
2274 assert!(config.queue.is_some());
2275 assert!(config.search.is_some());
2276 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
2277 assert_eq!(config.search.unwrap().timeout, 20);
2278 }
2279
2280 #[test]
2281 fn test_from_hcl_multiple_mcp_servers() {
2282 let hcl = r#"
2283 mcp_servers {
2284 name = "fetch"
2285 transport = "stdio"
2286 command = "npx"
2287 args = ["-y", "@modelcontextprotocol/server-fetch"]
2288 enabled = true
2289 }
2290
2291 mcp_servers {
2292 name = "puppeteer"
2293 transport = "stdio"
2294 command = "npx"
2295 args = ["-y", "@anthropic/mcp-server-puppeteer"]
2296 enabled = true
2297 }
2298
2299 mcp_servers {
2300 name = "filesystem"
2301 transport = "stdio"
2302 command = "npx"
2303 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2304 enabled = false
2305 }
2306 "#;
2307
2308 let config = CodeConfig::from_hcl(hcl).unwrap();
2309 assert_eq!(
2310 config.mcp_servers.len(),
2311 3,
2312 "all 3 mcp_servers blocks should be parsed"
2313 );
2314 assert_eq!(config.mcp_servers[0].name, "fetch");
2315 assert_eq!(config.mcp_servers[1].name, "puppeteer");
2316 assert_eq!(config.mcp_servers[2].name, "filesystem");
2317 assert!(config.mcp_servers[0].enabled);
2318 assert!(!config.mcp_servers[2].enabled);
2319 }
2320
2321 #[test]
2322 fn test_from_hcl_with_advanced_queue_config() {
2323 let hcl = r#"
2324 default_model = "anthropic/claude-sonnet-4"
2325
2326 providers {
2327 name = "anthropic"
2328 api_key = "test-key"
2329 }
2330
2331 queue {
2332 query_max_concurrency = 20
2333 enable_metrics = true
2334
2335 retry_policy {
2336 strategy = "exponential"
2337 max_retries = 5
2338 initial_delay_ms = 200
2339 }
2340
2341 rate_limit {
2342 limit_type = "per_second"
2343 max_operations = 100
2344 }
2345
2346 priority_boost {
2347 strategy = "standard"
2348 deadline_ms = 300000
2349 }
2350
2351 pressure_threshold = 50
2352 }
2353 "#;
2354
2355 let config = CodeConfig::from_hcl(hcl).unwrap();
2356 assert!(config.queue.is_some());
2357 let queue = config.queue.unwrap();
2358
2359 assert_eq!(queue.query_max_concurrency, 20);
2360 assert!(queue.enable_metrics);
2361
2362 assert!(queue.retry_policy.is_some());
2364 let retry = queue.retry_policy.unwrap();
2365 assert_eq!(retry.strategy, "exponential");
2366 assert_eq!(retry.max_retries, 5);
2367 assert_eq!(retry.initial_delay_ms, 200);
2368
2369 assert!(queue.rate_limit.is_some());
2371 let rate = queue.rate_limit.unwrap();
2372 assert_eq!(rate.limit_type, "per_second");
2373 assert_eq!(rate.max_operations, Some(100));
2374
2375 assert!(queue.priority_boost.is_some());
2377 let boost = queue.priority_boost.unwrap();
2378 assert_eq!(boost.strategy, "standard");
2379 assert_eq!(boost.deadline_ms, Some(300000));
2380
2381 assert_eq!(queue.pressure_threshold, Some(50));
2383 }
2384
2385 #[test]
2386 fn test_hcl_env_function_resolved() {
2387 std::env::set_var("A3S_TEST_HCL_KEY", "test-secret-key-123");
2389
2390 let hcl_str = r#"
2391 providers {
2392 name = "test"
2393 api_key = env("A3S_TEST_HCL_KEY")
2394 }
2395 "#;
2396
2397 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2398 let json = hcl_body_to_json(&body);
2399
2400 let providers = json.get("providers").unwrap();
2402 let provider = providers.as_array().unwrap().first().unwrap();
2403 let api_key = provider.get("apiKey").unwrap();
2404
2405 assert_eq!(api_key.as_str().unwrap(), "test-secret-key-123");
2406
2407 std::env::remove_var("A3S_TEST_HCL_KEY");
2409 }
2410
2411 #[test]
2412 fn test_hcl_env_function_unset_returns_null() {
2413 std::env::remove_var("A3S_TEST_NONEXISTENT_VAR_12345");
2415
2416 let hcl_str = r#"
2417 providers {
2418 name = "test"
2419 api_key = env("A3S_TEST_NONEXISTENT_VAR_12345")
2420 }
2421 "#;
2422
2423 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2424 let json = hcl_body_to_json(&body);
2425
2426 let providers = json.get("providers").unwrap();
2427 let provider = providers.as_array().unwrap().first().unwrap();
2428 let api_key = provider.get("apiKey").unwrap();
2429
2430 assert!(api_key.is_null(), "Unset env var should return null");
2431 }
2432
2433 #[test]
2434 fn test_hcl_mcp_env_block_preserves_var_names() {
2435 std::env::set_var("A3S_TEST_SECRET", "my-secret");
2437
2438 let hcl_str = r#"
2439 mcp_servers {
2440 name = "test-server"
2441 transport = "stdio"
2442 command = "echo"
2443 env = {
2444 API_KEY = "sk-test-123"
2445 ANTHROPIC_API_KEY = env("A3S_TEST_SECRET")
2446 SIMPLE = "value"
2447 }
2448 }
2449 "#;
2450
2451 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2452 let json = hcl_body_to_json(&body);
2453
2454 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2455 let server = &servers[0];
2456 let env = server.get("env").unwrap().as_object().unwrap();
2457
2458 assert_eq!(env.get("API_KEY").unwrap().as_str().unwrap(), "sk-test-123");
2460 assert_eq!(
2461 env.get("ANTHROPIC_API_KEY").unwrap().as_str().unwrap(),
2462 "my-secret"
2463 );
2464 assert_eq!(env.get("SIMPLE").unwrap().as_str().unwrap(), "value");
2465
2466 assert!(
2468 env.get("apiKey").is_none(),
2469 "env var key should not be camelCase'd"
2470 );
2471 assert!(
2472 env.get("APIKEY").is_none(),
2473 "env var key should not have underscores stripped"
2474 );
2475 assert!(env.get("anthropicApiKey").is_none());
2476
2477 std::env::remove_var("A3S_TEST_SECRET");
2478 }
2479
2480 #[test]
2481 fn test_hcl_mcp_env_as_block_syntax() {
2482 let hcl_str = r#"
2484 mcp_servers {
2485 name = "test-server"
2486 transport = "stdio"
2487 command = "echo"
2488 env {
2489 MY_VAR = "hello"
2490 OTHER_VAR = "world"
2491 }
2492 }
2493 "#;
2494
2495 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2496 let json = hcl_body_to_json(&body);
2497
2498 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2499 let server = &servers[0];
2500 let env = server.get("env").unwrap().as_object().unwrap();
2501
2502 assert_eq!(env.get("MY_VAR").unwrap().as_str().unwrap(), "hello");
2503 assert_eq!(env.get("OTHER_VAR").unwrap().as_str().unwrap(), "world");
2504 assert!(
2505 env.get("myVar").is_none(),
2506 "block env keys should not be camelCase'd"
2507 );
2508 }
2509
2510 #[test]
2511 fn test_hcl_mcp_full_deserialization_with_env() {
2512 std::env::set_var("A3S_TEST_MCP_KEY", "resolved-secret");
2514
2515 let hcl_str = r#"
2516 mcp_servers {
2517 name = "fetch"
2518 transport = "stdio"
2519 command = "npx"
2520 args = ["-y", "@modelcontextprotocol/server-fetch"]
2521 env = {
2522 NODE_ENV = "production"
2523 API_KEY = env("A3S_TEST_MCP_KEY")
2524 }
2525 tool_timeout_secs = 120
2526 }
2527 "#;
2528
2529 let config = CodeConfig::from_hcl(hcl_str).unwrap();
2530 assert_eq!(config.mcp_servers.len(), 1);
2531
2532 let server = &config.mcp_servers[0];
2533 assert_eq!(server.name, "fetch");
2534 assert_eq!(server.env.get("NODE_ENV").unwrap(), "production");
2535 assert_eq!(server.env.get("API_KEY").unwrap(), "resolved-secret");
2536 assert_eq!(server.tool_timeout_secs, 120);
2537
2538 std::env::remove_var("A3S_TEST_MCP_KEY");
2539 }
2540
2541 #[test]
2542 fn test_hcl_document_tool_config_parses() {
2543 let hcl = r#"
2544 agentic_search {
2545 enabled = false
2546 default_mode = "deep"
2547 max_results = 7
2548 context_lines = 4
2549 }
2550
2551 agentic_parse {
2552 enabled = true
2553 default_strategy = "structured"
2554 max_chars = 12000
2555 }
2556
2557 document_parser {
2558 enabled = true
2559 max_file_size_mb = 64
2560
2561 ocr {
2562 enabled = true
2563 model = "openai/gpt-4.1-mini"
2564 prompt = "Extract text from scanned pages."
2565 max_images = 6
2566 dpi = 200
2567 }
2568 }
2569 "#;
2570
2571 let config = CodeConfig::from_hcl(hcl).unwrap();
2572 let search = config.agentic_search.unwrap();
2573 let parse = config.agentic_parse.unwrap();
2574 let document_parser = config.document_parser.unwrap();
2575
2576 assert!(!search.enabled);
2577 assert_eq!(search.default_mode, "deep");
2578 assert_eq!(search.max_results, 7);
2579 assert_eq!(search.context_lines, 4);
2580
2581 assert!(parse.enabled);
2582 assert_eq!(parse.default_strategy, "structured");
2583 assert_eq!(parse.max_chars, 12000);
2584
2585 assert!(document_parser.enabled);
2586 assert_eq!(document_parser.max_file_size_mb, 64);
2587 let ocr = document_parser.ocr.unwrap();
2588 assert!(ocr.enabled);
2589 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2590 assert_eq!(
2591 ocr.prompt.as_deref(),
2592 Some("Extract text from scanned pages.")
2593 );
2594 assert_eq!(ocr.max_images, 6);
2595 assert_eq!(ocr.dpi, 200);
2596 }
2597
2598 #[test]
2599 fn test_hcl_document_parser_parses() {
2600 let hcl = r#"
2601 document_parser {
2602 enabled = true
2603 max_file_size_mb = 48
2604 cache {
2605 enabled = true
2606 directory = "/tmp/a3s-doc-cache"
2607 }
2608
2609 ocr {
2610 enabled = true
2611 model = "openai/gpt-4.1-mini"
2612 prompt = "Read scanned tables."
2613 max_images = 5
2614 dpi = 180
2615 }
2616 }
2617 "#;
2618
2619 let config = CodeConfig::from_hcl(hcl).unwrap();
2620 let parser = config.document_parser.unwrap();
2621
2622 assert!(parser.enabled);
2623 assert_eq!(parser.max_file_size_mb, 48);
2624 let cache = parser.cache.unwrap();
2625 assert!(cache.enabled);
2626 assert_eq!(
2627 cache.directory.as_deref(),
2628 Some(std::path::Path::new("/tmp/a3s-doc-cache"))
2629 );
2630 let ocr = parser.ocr.unwrap();
2631 assert!(ocr.enabled);
2632 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2633 assert_eq!(ocr.prompt.as_deref(), Some("Read scanned tables."));
2634 assert_eq!(ocr.max_images, 5);
2635 assert_eq!(ocr.dpi, 180);
2636 }
2637
2638 #[test]
2639 fn test_agentic_search_config_normalizes_invalid_values() {
2640 let config = AgenticSearchConfig {
2641 enabled: true,
2642 default_mode: "weird".to_string(),
2643 max_results: 0,
2644 context_lines: 999,
2645 }
2646 .normalized();
2647
2648 assert_eq!(config.default_mode, "fast");
2649 assert_eq!(config.max_results, 1);
2650 assert_eq!(config.context_lines, 20);
2651 }
2652
2653 #[test]
2654 fn test_agentic_parse_config_normalizes_invalid_values() {
2655 let config = AgenticParseConfig {
2656 enabled: true,
2657 default_strategy: "unknown".to_string(),
2658 max_chars: 1,
2659 }
2660 .normalized();
2661
2662 assert_eq!(config.default_strategy, "auto");
2663 assert_eq!(config.max_chars, 500);
2664 }
2665
2666 #[test]
2667 fn test_document_parser_config_normalizes_nested_ocr_values() {
2668 let config = DocumentParserConfig {
2669 enabled: true,
2670 max_file_size_mb: 0,
2671 cache: Some(DocumentCacheConfig {
2672 enabled: true,
2673 directory: Some(PathBuf::from("/tmp/cache")),
2674 }),
2675 ocr: Some(DocumentOcrConfig {
2676 enabled: true,
2677 model: Some("openai/gpt-4.1-mini".to_string()),
2678 prompt: None,
2679 max_images: 0,
2680 dpi: 10,
2681 provider: None,
2682 base_url: None,
2683 api_key: None,
2684 }),
2685 }
2686 .normalized();
2687
2688 assert_eq!(config.max_file_size_mb, 1);
2689 let cache = config.cache.unwrap();
2690 assert!(cache.enabled);
2691 assert_eq!(cache.directory, Some(PathBuf::from("/tmp/cache")));
2692 let ocr = config.ocr.unwrap();
2693 assert_eq!(ocr.max_images, 1);
2694 assert_eq!(ocr.dpi, 72);
2695 }
2696}