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 save_to_file(&self, path: &Path) -> Result<()> {
747 if let Some(parent) = path.parent() {
748 std::fs::create_dir_all(parent).map_err(|e| {
749 CodeError::Config(format!(
750 "Failed to create config directory {}: {}",
751 parent.display(),
752 e
753 ))
754 })?;
755 }
756
757 let content = serde_json::to_string_pretty(self)
758 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
759
760 std::fs::write(path, content).map_err(|e| {
761 CodeError::Config(format!(
762 "Failed to write config file {}: {}",
763 path.display(),
764 e
765 ))
766 })?;
767
768 Ok(())
769 }
770
771 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
773 self.providers.iter().find(|p| p.name == name)
774 }
775
776 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
778 let default = self.default_model.as_ref()?;
779 let (provider_name, _) = default.split_once('/')?;
780 self.find_provider(provider_name)
781 }
782
783 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
785 let default = self.default_model.as_ref()?;
786 let (provider_name, model_id) = default.split_once('/')?;
787 let provider = self.find_provider(provider_name)?;
788 let model = provider.find_model(model_id)?;
789 Some((provider, model))
790 }
791
792 pub fn default_llm_config(&self) -> Option<LlmConfig> {
796 let (provider, model) = self.default_model_config()?;
797 let api_key = provider.get_api_key(model)?;
798 let base_url = provider.get_base_url(model);
799 let headers = provider.get_headers(model);
800 let session_id_header = provider.get_session_id_header(model);
801
802 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
803 if let Some(url) = base_url {
804 config = config.with_base_url(url);
805 }
806 if !headers.is_empty() {
807 config = config.with_headers(headers);
808 }
809 if let Some(header_name) = session_id_header {
810 config = config.with_session_id_header(header_name);
811 }
812 config = apply_model_caps(config, model, self.thinking_budget);
813 Some(config)
814 }
815
816 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
820 let provider = self.find_provider(provider_name)?;
821 let model = provider.find_model(model_id)?;
822 let api_key = provider.get_api_key(model)?;
823 let base_url = provider.get_base_url(model);
824 let headers = provider.get_headers(model);
825 let session_id_header = provider.get_session_id_header(model);
826
827 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
828 if let Some(url) = base_url {
829 config = config.with_base_url(url);
830 }
831 if !headers.is_empty() {
832 config = config.with_headers(headers);
833 }
834 if let Some(header_name) = session_id_header {
835 config = config.with_session_id_header(header_name);
836 }
837 config = apply_model_caps(config, model, self.thinking_budget);
838 Some(config)
839 }
840
841 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
843 self.providers
844 .iter()
845 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
846 .collect()
847 }
848
849 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
851 self.skill_dirs.push(dir.into());
852 self
853 }
854
855 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
857 self.agent_dirs.push(dir.into());
858 self
859 }
860
861 pub fn has_directories(&self) -> bool {
863 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
864 }
865
866 pub fn has_providers(&self) -> bool {
868 !self.providers.is_empty()
869 }
870}
871
872const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
878
879const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
883
884fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
886 hcl_body_to_json_inner(body, false)
887}
888
889fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
894 let mut map = serde_json::Map::new();
895
896 for attr in body.attributes() {
898 let key = if verbatim_keys {
899 attr.key.as_str().to_string()
900 } else {
901 snake_to_camel(attr.key.as_str())
902 };
903 let value = hcl_expr_to_json(attr.expr());
904 map.insert(key, value);
905 }
906
907 for block in body.blocks() {
909 let key = if verbatim_keys {
910 block.identifier.as_str().to_string()
911 } else {
912 snake_to_camel(block.identifier.as_str())
913 };
914 let child_verbatim = HCL_VERBATIM_BLOCKS.contains(&block.identifier.as_str());
916 let block_value = hcl_body_to_json_inner(block.body(), child_verbatim);
917
918 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
919 let arr = map
921 .entry(key)
922 .or_insert_with(|| JsonValue::Array(Vec::new()));
923 if let JsonValue::Array(ref mut vec) = arr {
924 vec.push(block_value);
925 }
926 } else {
927 map.insert(key, block_value);
928 }
929 }
930
931 JsonValue::Object(map)
932}
933
934fn snake_to_camel(s: &str) -> String {
936 let mut result = String::with_capacity(s.len());
937 let mut capitalize_next = false;
938 for ch in s.chars() {
939 if ch == '_' {
940 capitalize_next = true;
941 } else if capitalize_next {
942 result.extend(ch.to_uppercase());
943 capitalize_next = false;
944 } else {
945 result.push(ch);
946 }
947 }
948 result
949}
950
951fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
953 match expr {
954 hcl::Expression::String(s) => JsonValue::String(s.clone()),
955 hcl::Expression::Number(n) => {
956 if let Some(i) = n.as_i64() {
957 JsonValue::Number(i.into())
958 } else if let Some(f) = n.as_f64() {
959 serde_json::Number::from_f64(f)
960 .map(JsonValue::Number)
961 .unwrap_or(JsonValue::Null)
962 } else {
963 JsonValue::Null
964 }
965 }
966 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
967 hcl::Expression::Null => JsonValue::Null,
968 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
969 hcl::Expression::Object(obj) => {
970 let map: serde_json::Map<String, JsonValue> = obj
973 .iter()
974 .map(|(k, v)| {
975 let key = match k {
976 hcl::ObjectKey::Identifier(id) => id.as_str().to_string(),
977 hcl::ObjectKey::Expression(expr) => {
978 if let hcl::Expression::String(s) = expr {
979 s.clone()
980 } else {
981 format!("{:?}", expr)
982 }
983 }
984 _ => format!("{:?}", k),
985 };
986 (key, hcl_expr_to_json(v))
987 })
988 .collect();
989 JsonValue::Object(map)
990 }
991 hcl::Expression::FuncCall(func_call) => eval_func_call(func_call),
992 hcl::Expression::TemplateExpr(tmpl) => eval_template_expr(tmpl),
993 _ => JsonValue::String(format!("{:?}", expr)),
994 }
995}
996
997fn eval_func_call(func_call: &hcl::expr::FuncCall) -> JsonValue {
1002 let name = func_call.name.name.as_str();
1003 match name {
1004 "env" => {
1005 if let Some(arg) = func_call.args.first() {
1006 let var_name = match arg {
1007 hcl::Expression::String(s) => s.as_str(),
1008 _ => {
1009 tracing::warn!("env() expects a string argument, got: {:?}", arg);
1010 return JsonValue::Null;
1011 }
1012 };
1013 match std::env::var(var_name) {
1014 Ok(val) => JsonValue::String(val),
1015 Err(_) => {
1016 tracing::debug!("env(\"{}\") is not set, returning null", var_name);
1017 JsonValue::Null
1018 }
1019 }
1020 } else {
1021 tracing::warn!("env() called with no arguments");
1022 JsonValue::Null
1023 }
1024 }
1025 _ => {
1026 tracing::warn!("Unsupported HCL function: {}()", name);
1027 JsonValue::String(format!("{}()", name))
1028 }
1029 }
1030}
1031
1032fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
1037 JsonValue::String(format!("{}", tmpl))
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 #[test]
1050 fn test_config_default() {
1051 let config = CodeConfig::default();
1052 assert!(config.skill_dirs.is_empty());
1053 assert!(config.agent_dirs.is_empty());
1054 assert!(config.providers.is_empty());
1055 assert!(config.default_model.is_none());
1056 assert_eq!(config.storage_backend, StorageBackend::File);
1057 assert!(config.sessions_dir.is_none());
1058 }
1059
1060 #[test]
1061 fn test_storage_backend_default() {
1062 let backend = StorageBackend::default();
1063 assert_eq!(backend, StorageBackend::File);
1064 }
1065
1066 #[test]
1067 fn test_storage_backend_serde() {
1068 let memory = StorageBackend::Memory;
1070 let json = serde_json::to_string(&memory).unwrap();
1071 assert_eq!(json, "\"memory\"");
1072
1073 let file = StorageBackend::File;
1074 let json = serde_json::to_string(&file).unwrap();
1075 assert_eq!(json, "\"file\"");
1076
1077 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
1079 assert_eq!(memory, StorageBackend::Memory);
1080
1081 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
1082 assert_eq!(file, StorageBackend::File);
1083 }
1084
1085 #[test]
1086 fn test_config_with_storage_backend() {
1087 let temp_dir = tempfile::tempdir().unwrap();
1088 let config_path = temp_dir.path().join("config.hcl");
1089
1090 std::fs::write(
1091 &config_path,
1092 r#"
1093 storage_backend = "memory"
1094 sessions_dir = "/tmp/sessions"
1095 "#,
1096 )
1097 .unwrap();
1098
1099 let config = CodeConfig::from_file(&config_path).unwrap();
1100 assert_eq!(config.storage_backend, StorageBackend::Memory);
1101 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
1102 }
1103
1104 #[test]
1105 fn test_config_builder() {
1106 let config = CodeConfig::new()
1107 .add_skill_dir("/tmp/skills")
1108 .add_agent_dir("/tmp/agents");
1109
1110 assert_eq!(config.skill_dirs.len(), 1);
1111 assert_eq!(config.agent_dirs.len(), 1);
1112 }
1113
1114 #[test]
1115 fn test_find_provider() {
1116 let config = CodeConfig {
1117 providers: vec![
1118 ProviderConfig {
1119 name: "anthropic".to_string(),
1120 api_key: Some("key1".to_string()),
1121 base_url: None,
1122 headers: HashMap::new(),
1123 session_id_header: None,
1124 models: vec![],
1125 },
1126 ProviderConfig {
1127 name: "openai".to_string(),
1128 api_key: Some("key2".to_string()),
1129 base_url: None,
1130 headers: HashMap::new(),
1131 session_id_header: None,
1132 models: vec![],
1133 },
1134 ],
1135 ..Default::default()
1136 };
1137
1138 assert!(config.find_provider("anthropic").is_some());
1139 assert!(config.find_provider("openai").is_some());
1140 assert!(config.find_provider("unknown").is_none());
1141 }
1142
1143 #[test]
1144 fn test_default_llm_config() {
1145 let config = CodeConfig {
1146 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1147 providers: vec![ProviderConfig {
1148 name: "anthropic".to_string(),
1149 api_key: Some("test-api-key".to_string()),
1150 base_url: Some("https://api.anthropic.com".to_string()),
1151 headers: HashMap::new(),
1152 session_id_header: None,
1153 models: vec![ModelConfig {
1154 id: "claude-sonnet-4".to_string(),
1155 name: "Claude Sonnet 4".to_string(),
1156 family: "claude-sonnet".to_string(),
1157 api_key: None,
1158 base_url: None,
1159 headers: HashMap::new(),
1160 session_id_header: None,
1161 attachment: false,
1162 reasoning: false,
1163 tool_call: true,
1164 temperature: true,
1165 release_date: None,
1166 modalities: ModelModalities::default(),
1167 cost: ModelCost::default(),
1168 limit: ModelLimit::default(),
1169 }],
1170 }],
1171 ..Default::default()
1172 };
1173
1174 let llm_config = config.default_llm_config().unwrap();
1175 assert_eq!(llm_config.provider, "anthropic");
1176 assert_eq!(llm_config.model, "claude-sonnet-4");
1177 assert_eq!(llm_config.api_key.expose(), "test-api-key");
1178 assert_eq!(
1179 llm_config.base_url,
1180 Some("https://api.anthropic.com".to_string())
1181 );
1182 }
1183
1184 #[test]
1185 fn test_model_api_key_override() {
1186 let provider = ProviderConfig {
1187 name: "openai".to_string(),
1188 api_key: Some("provider-key".to_string()),
1189 base_url: Some("https://api.openai.com".to_string()),
1190 headers: HashMap::new(),
1191 session_id_header: None,
1192 models: vec![
1193 ModelConfig {
1194 id: "gpt-4".to_string(),
1195 name: "GPT-4".to_string(),
1196 family: "gpt".to_string(),
1197 api_key: None, base_url: None,
1199 headers: HashMap::new(),
1200 session_id_header: None,
1201 attachment: false,
1202 reasoning: false,
1203 tool_call: true,
1204 temperature: true,
1205 release_date: None,
1206 modalities: ModelModalities::default(),
1207 cost: ModelCost::default(),
1208 limit: ModelLimit::default(),
1209 },
1210 ModelConfig {
1211 id: "custom-model".to_string(),
1212 name: "Custom Model".to_string(),
1213 family: "custom".to_string(),
1214 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), headers: HashMap::new(),
1217 session_id_header: None,
1218 attachment: false,
1219 reasoning: false,
1220 tool_call: true,
1221 temperature: true,
1222 release_date: None,
1223 modalities: ModelModalities::default(),
1224 cost: ModelCost::default(),
1225 limit: ModelLimit::default(),
1226 },
1227 ],
1228 };
1229
1230 let model1 = provider.find_model("gpt-4").unwrap();
1232 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
1233 assert_eq!(
1234 provider.get_base_url(model1),
1235 Some("https://api.openai.com")
1236 );
1237
1238 let model2 = provider.find_model("custom-model").unwrap();
1240 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
1241 assert_eq!(
1242 provider.get_base_url(model2),
1243 Some("https://custom.api.com")
1244 );
1245 }
1246
1247 #[test]
1248 fn test_list_models() {
1249 let config = CodeConfig {
1250 providers: vec![
1251 ProviderConfig {
1252 name: "anthropic".to_string(),
1253 api_key: None,
1254 base_url: None,
1255 headers: HashMap::new(),
1256 session_id_header: None,
1257 models: vec![
1258 ModelConfig {
1259 id: "claude-1".to_string(),
1260 name: "Claude 1".to_string(),
1261 family: "claude".to_string(),
1262 api_key: None,
1263 base_url: None,
1264 headers: HashMap::new(),
1265 session_id_header: None,
1266 attachment: false,
1267 reasoning: false,
1268 tool_call: true,
1269 temperature: true,
1270 release_date: None,
1271 modalities: ModelModalities::default(),
1272 cost: ModelCost::default(),
1273 limit: ModelLimit::default(),
1274 },
1275 ModelConfig {
1276 id: "claude-2".to_string(),
1277 name: "Claude 2".to_string(),
1278 family: "claude".to_string(),
1279 api_key: None,
1280 base_url: None,
1281 headers: HashMap::new(),
1282 session_id_header: None,
1283 attachment: false,
1284 reasoning: false,
1285 tool_call: true,
1286 temperature: true,
1287 release_date: None,
1288 modalities: ModelModalities::default(),
1289 cost: ModelCost::default(),
1290 limit: ModelLimit::default(),
1291 },
1292 ],
1293 },
1294 ProviderConfig {
1295 name: "openai".to_string(),
1296 api_key: None,
1297 base_url: None,
1298 headers: HashMap::new(),
1299 session_id_header: None,
1300 models: vec![ModelConfig {
1301 id: "gpt-4".to_string(),
1302 name: "GPT-4".to_string(),
1303 family: "gpt".to_string(),
1304 api_key: None,
1305 base_url: None,
1306 headers: HashMap::new(),
1307 session_id_header: None,
1308 attachment: false,
1309 reasoning: false,
1310 tool_call: true,
1311 temperature: true,
1312 release_date: None,
1313 modalities: ModelModalities::default(),
1314 cost: ModelCost::default(),
1315 limit: ModelLimit::default(),
1316 }],
1317 },
1318 ],
1319 ..Default::default()
1320 };
1321
1322 let models = config.list_models();
1323 assert_eq!(models.len(), 3);
1324 }
1325
1326 #[test]
1327 fn test_config_from_file_not_found() {
1328 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
1329 assert!(result.is_err());
1330 }
1331
1332 #[test]
1333 fn test_config_has_directories() {
1334 let empty = CodeConfig::default();
1335 assert!(!empty.has_directories());
1336
1337 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
1338 assert!(with_skills.has_directories());
1339
1340 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
1341 assert!(with_agents.has_directories());
1342 }
1343
1344 #[test]
1345 fn test_config_has_providers() {
1346 let empty = CodeConfig::default();
1347 assert!(!empty.has_providers());
1348
1349 let with_providers = CodeConfig {
1350 providers: vec![ProviderConfig {
1351 name: "test".to_string(),
1352 api_key: None,
1353 base_url: None,
1354 headers: HashMap::new(),
1355 session_id_header: None,
1356 models: vec![],
1357 }],
1358 ..Default::default()
1359 };
1360 assert!(with_providers.has_providers());
1361 }
1362
1363 #[test]
1364 fn test_storage_backend_equality() {
1365 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
1366 assert_eq!(StorageBackend::File, StorageBackend::File);
1367 assert_ne!(StorageBackend::Memory, StorageBackend::File);
1368 }
1369
1370 #[test]
1371 fn test_storage_backend_serde_custom() {
1372 let custom = StorageBackend::Custom;
1373 let json = serde_json::to_string(&custom).unwrap();
1375 assert_eq!(json, "\"custom\"");
1376
1377 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
1379 assert_eq!(parsed, StorageBackend::Custom);
1380 }
1381
1382 #[test]
1383 fn test_model_cost_default() {
1384 let cost = ModelCost::default();
1385 assert_eq!(cost.input, 0.0);
1386 assert_eq!(cost.output, 0.0);
1387 assert_eq!(cost.cache_read, 0.0);
1388 assert_eq!(cost.cache_write, 0.0);
1389 }
1390
1391 #[test]
1392 fn test_model_cost_serialization() {
1393 let cost = ModelCost {
1394 input: 3.0,
1395 output: 15.0,
1396 cache_read: 0.3,
1397 cache_write: 3.75,
1398 };
1399 let json = serde_json::to_string(&cost).unwrap();
1400 assert!(json.contains("\"input\":3"));
1401 assert!(json.contains("\"output\":15"));
1402 }
1403
1404 #[test]
1405 fn test_model_cost_deserialization_missing_fields() {
1406 let json = r#"{"input":3.0}"#;
1407 let cost: ModelCost = serde_json::from_str(json).unwrap();
1408 assert_eq!(cost.input, 3.0);
1409 assert_eq!(cost.output, 0.0);
1410 assert_eq!(cost.cache_read, 0.0);
1411 assert_eq!(cost.cache_write, 0.0);
1412 }
1413
1414 #[test]
1415 fn test_model_limit_default() {
1416 let limit = ModelLimit::default();
1417 assert_eq!(limit.context, 0);
1418 assert_eq!(limit.output, 0);
1419 }
1420
1421 #[test]
1422 fn test_model_limit_serialization() {
1423 let limit = ModelLimit {
1424 context: 200000,
1425 output: 8192,
1426 };
1427 let json = serde_json::to_string(&limit).unwrap();
1428 assert!(json.contains("\"context\":200000"));
1429 assert!(json.contains("\"output\":8192"));
1430 }
1431
1432 #[test]
1433 fn test_model_limit_deserialization_missing_fields() {
1434 let json = r#"{"context":100000}"#;
1435 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1436 assert_eq!(limit.context, 100000);
1437 assert_eq!(limit.output, 0);
1438 }
1439
1440 #[test]
1441 fn test_model_modalities_default() {
1442 let modalities = ModelModalities::default();
1443 assert!(modalities.input.is_empty());
1444 assert!(modalities.output.is_empty());
1445 }
1446
1447 #[test]
1448 fn test_model_modalities_serialization() {
1449 let modalities = ModelModalities {
1450 input: vec!["text".to_string(), "image".to_string()],
1451 output: vec!["text".to_string()],
1452 };
1453 let json = serde_json::to_string(&modalities).unwrap();
1454 assert!(json.contains("\"input\""));
1455 assert!(json.contains("\"text\""));
1456 }
1457
1458 #[test]
1459 fn test_model_modalities_deserialization_missing_fields() {
1460 let json = r#"{"input":["text"]}"#;
1461 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1462 assert_eq!(modalities.input.len(), 1);
1463 assert!(modalities.output.is_empty());
1464 }
1465
1466 #[test]
1467 fn test_model_config_serialization() {
1468 let config = ModelConfig {
1469 id: "gpt-4o".to_string(),
1470 name: "GPT-4o".to_string(),
1471 family: "gpt-4".to_string(),
1472 api_key: Some("sk-test".to_string()),
1473 base_url: None,
1474 headers: HashMap::new(),
1475 session_id_header: None,
1476 attachment: true,
1477 reasoning: false,
1478 tool_call: true,
1479 temperature: true,
1480 release_date: Some("2024-05-13".to_string()),
1481 modalities: ModelModalities::default(),
1482 cost: ModelCost::default(),
1483 limit: ModelLimit::default(),
1484 };
1485 let json = serde_json::to_string(&config).unwrap();
1486 assert!(json.contains("\"id\":\"gpt-4o\""));
1487 assert!(json.contains("\"attachment\":true"));
1488 }
1489
1490 #[test]
1491 fn test_model_config_deserialization_with_defaults() {
1492 let json = r#"{"id":"test-model"}"#;
1493 let config: ModelConfig = serde_json::from_str(json).unwrap();
1494 assert_eq!(config.id, "test-model");
1495 assert_eq!(config.name, "");
1496 assert_eq!(config.family, "");
1497 assert!(config.api_key.is_none());
1498 assert!(!config.attachment);
1499 assert!(config.tool_call);
1500 assert!(config.temperature);
1501 }
1502
1503 #[test]
1504 fn test_model_config_all_optional_fields() {
1505 let json = r#"{
1506 "id": "claude-sonnet-4",
1507 "name": "Claude Sonnet 4",
1508 "family": "claude-sonnet",
1509 "apiKey": "sk-test",
1510 "baseUrl": "https://api.anthropic.com",
1511 "attachment": true,
1512 "reasoning": true,
1513 "toolCall": false,
1514 "temperature": false,
1515 "releaseDate": "2025-05-14"
1516 }"#;
1517 let config: ModelConfig = serde_json::from_str(json).unwrap();
1518 assert_eq!(config.id, "claude-sonnet-4");
1519 assert_eq!(config.name, "Claude Sonnet 4");
1520 assert_eq!(config.api_key, Some("sk-test".to_string()));
1521 assert_eq!(
1522 config.base_url,
1523 Some("https://api.anthropic.com".to_string())
1524 );
1525 assert!(config.attachment);
1526 assert!(config.reasoning);
1527 assert!(!config.tool_call);
1528 assert!(!config.temperature);
1529 }
1530
1531 #[test]
1532 fn test_provider_config_serialization() {
1533 let provider = ProviderConfig {
1534 name: "anthropic".to_string(),
1535 api_key: Some("sk-test".to_string()),
1536 base_url: Some("https://api.anthropic.com".to_string()),
1537 headers: HashMap::new(),
1538 session_id_header: None,
1539 models: vec![],
1540 };
1541 let json = serde_json::to_string(&provider).unwrap();
1542 assert!(json.contains("\"name\":\"anthropic\""));
1543 assert!(json.contains("\"apiKey\":\"sk-test\""));
1544 }
1545
1546 #[test]
1547 fn test_provider_config_deserialization_missing_optional() {
1548 let json = r#"{"name":"openai"}"#;
1549 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1550 assert_eq!(provider.name, "openai");
1551 assert!(provider.api_key.is_none());
1552 assert!(provider.base_url.is_none());
1553 assert!(provider.models.is_empty());
1554 }
1555
1556 #[test]
1557 fn test_provider_config_find_model() {
1558 let provider = ProviderConfig {
1559 name: "anthropic".to_string(),
1560 api_key: None,
1561 base_url: None,
1562 headers: HashMap::new(),
1563 session_id_header: None,
1564 models: vec![ModelConfig {
1565 id: "claude-sonnet-4".to_string(),
1566 name: "Claude Sonnet 4".to_string(),
1567 family: "claude-sonnet".to_string(),
1568 api_key: None,
1569 base_url: None,
1570 headers: HashMap::new(),
1571 session_id_header: None,
1572 attachment: false,
1573 reasoning: false,
1574 tool_call: true,
1575 temperature: true,
1576 release_date: None,
1577 modalities: ModelModalities::default(),
1578 cost: ModelCost::default(),
1579 limit: ModelLimit::default(),
1580 }],
1581 };
1582
1583 let found = provider.find_model("claude-sonnet-4");
1584 assert!(found.is_some());
1585 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1586
1587 let not_found = provider.find_model("gpt-4o");
1588 assert!(not_found.is_none());
1589 }
1590
1591 #[test]
1592 fn test_provider_config_get_api_key() {
1593 let provider = ProviderConfig {
1594 name: "anthropic".to_string(),
1595 api_key: Some("provider-key".to_string()),
1596 base_url: None,
1597 headers: HashMap::new(),
1598 session_id_header: None,
1599 models: vec![],
1600 };
1601
1602 let model_with_key = ModelConfig {
1603 id: "test".to_string(),
1604 name: "".to_string(),
1605 family: "".to_string(),
1606 api_key: Some("model-key".to_string()),
1607 base_url: None,
1608 headers: HashMap::new(),
1609 session_id_header: None,
1610 attachment: false,
1611 reasoning: false,
1612 tool_call: true,
1613 temperature: true,
1614 release_date: None,
1615 modalities: ModelModalities::default(),
1616 cost: ModelCost::default(),
1617 limit: ModelLimit::default(),
1618 };
1619
1620 let model_without_key = ModelConfig {
1621 id: "test2".to_string(),
1622 name: "".to_string(),
1623 family: "".to_string(),
1624 api_key: None,
1625 base_url: None,
1626 headers: HashMap::new(),
1627 session_id_header: None,
1628 attachment: false,
1629 reasoning: false,
1630 tool_call: true,
1631 temperature: true,
1632 release_date: None,
1633 modalities: ModelModalities::default(),
1634 cost: ModelCost::default(),
1635 limit: ModelLimit::default(),
1636 };
1637
1638 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1639 assert_eq!(
1640 provider.get_api_key(&model_without_key),
1641 Some("provider-key")
1642 );
1643 }
1644
1645 #[test]
1646 fn test_provider_config_get_headers_and_session_id_header() {
1647 let mut provider_headers = HashMap::new();
1648 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1649 provider_headers.insert("X-Shared".to_string(), "provider".to_string());
1650
1651 let mut model_headers = HashMap::new();
1652 model_headers.insert("X-Model".to_string(), "model".to_string());
1653 model_headers.insert("X-Shared".to_string(), "model".to_string());
1654
1655 let provider = ProviderConfig {
1656 name: "openai".to_string(),
1657 api_key: Some("provider-key".to_string()),
1658 base_url: None,
1659 headers: provider_headers,
1660 session_id_header: Some("X-Session-Id".to_string()),
1661 models: vec![],
1662 };
1663
1664 let model = ModelConfig {
1665 id: "gpt-4o".to_string(),
1666 name: "".to_string(),
1667 family: "".to_string(),
1668 api_key: None,
1669 base_url: None,
1670 headers: model_headers,
1671 session_id_header: Some("X-Model-Session".to_string()),
1672 attachment: false,
1673 reasoning: false,
1674 tool_call: true,
1675 temperature: true,
1676 release_date: None,
1677 modalities: ModelModalities::default(),
1678 cost: ModelCost::default(),
1679 limit: ModelLimit::default(),
1680 };
1681
1682 let headers = provider.get_headers(&model);
1683 assert_eq!(headers.get("X-Provider"), Some(&"provider".to_string()));
1684 assert_eq!(headers.get("X-Model"), Some(&"model".to_string()));
1685 assert_eq!(headers.get("X-Shared"), Some(&"model".to_string()));
1686 assert_eq!(
1687 provider.get_session_id_header(&model),
1688 Some("X-Model-Session")
1689 );
1690 }
1691
1692 #[test]
1693 fn test_llm_config_includes_headers_and_runtime_session_header() {
1694 let mut provider_headers = HashMap::new();
1695 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1696
1697 let config = CodeConfig {
1698 default_model: Some("openai/gpt-4o".to_string()),
1699 providers: vec![ProviderConfig {
1700 name: "openai".to_string(),
1701 api_key: Some("sk-test".to_string()),
1702 base_url: Some("https://api.example.com".to_string()),
1703 headers: provider_headers,
1704 session_id_header: Some("X-Session-Id".to_string()),
1705 models: vec![ModelConfig {
1706 id: "gpt-4o".to_string(),
1707 name: "".to_string(),
1708 family: "".to_string(),
1709 api_key: None,
1710 base_url: None,
1711 headers: HashMap::new(),
1712 session_id_header: None,
1713 attachment: false,
1714 reasoning: false,
1715 tool_call: true,
1716 temperature: true,
1717 release_date: None,
1718 modalities: ModelModalities::default(),
1719 cost: ModelCost::default(),
1720 limit: ModelLimit::default(),
1721 }],
1722 }],
1723 ..Default::default()
1724 };
1725
1726 let llm_config = config.default_llm_config().unwrap();
1727 assert_eq!(
1728 llm_config.headers.get("X-Provider"),
1729 Some(&"provider".to_string())
1730 );
1731 assert_eq!(
1732 llm_config.session_id_header.as_deref(),
1733 Some("X-Session-Id")
1734 );
1735 }
1736
1737 #[test]
1738 fn test_code_config_default_provider_config() {
1739 let config = CodeConfig {
1740 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1741 providers: vec![ProviderConfig {
1742 name: "anthropic".to_string(),
1743 api_key: Some("sk-test".to_string()),
1744 base_url: None,
1745 headers: HashMap::new(),
1746 session_id_header: None,
1747 models: vec![],
1748 }],
1749 ..Default::default()
1750 };
1751
1752 let provider = config.default_provider_config();
1753 assert!(provider.is_some());
1754 assert_eq!(provider.unwrap().name, "anthropic");
1755 }
1756
1757 #[test]
1758 fn test_code_config_default_model_config() {
1759 let config = CodeConfig {
1760 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1761 providers: vec![ProviderConfig {
1762 name: "anthropic".to_string(),
1763 api_key: Some("sk-test".to_string()),
1764 base_url: None,
1765 headers: HashMap::new(),
1766 session_id_header: None,
1767 models: vec![ModelConfig {
1768 id: "claude-sonnet-4".to_string(),
1769 name: "Claude Sonnet 4".to_string(),
1770 family: "claude-sonnet".to_string(),
1771 api_key: None,
1772 base_url: None,
1773 headers: HashMap::new(),
1774 session_id_header: None,
1775 attachment: false,
1776 reasoning: false,
1777 tool_call: true,
1778 temperature: true,
1779 release_date: None,
1780 modalities: ModelModalities::default(),
1781 cost: ModelCost::default(),
1782 limit: ModelLimit::default(),
1783 }],
1784 }],
1785 ..Default::default()
1786 };
1787
1788 let result = config.default_model_config();
1789 assert!(result.is_some());
1790 let (provider, model) = result.unwrap();
1791 assert_eq!(provider.name, "anthropic");
1792 assert_eq!(model.id, "claude-sonnet-4");
1793 }
1794
1795 #[test]
1796 fn test_code_config_default_llm_config() {
1797 let config = CodeConfig {
1798 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1799 providers: vec![ProviderConfig {
1800 name: "anthropic".to_string(),
1801 api_key: Some("sk-test".to_string()),
1802 base_url: Some("https://api.anthropic.com".to_string()),
1803 headers: HashMap::new(),
1804 session_id_header: None,
1805 models: vec![ModelConfig {
1806 id: "claude-sonnet-4".to_string(),
1807 name: "Claude Sonnet 4".to_string(),
1808 family: "claude-sonnet".to_string(),
1809 api_key: None,
1810 base_url: None,
1811 headers: HashMap::new(),
1812 session_id_header: None,
1813 attachment: false,
1814 reasoning: false,
1815 tool_call: true,
1816 temperature: true,
1817 release_date: None,
1818 modalities: ModelModalities::default(),
1819 cost: ModelCost::default(),
1820 limit: ModelLimit::default(),
1821 }],
1822 }],
1823 ..Default::default()
1824 };
1825
1826 let llm_config = config.default_llm_config();
1827 assert!(llm_config.is_some());
1828 }
1829
1830 #[test]
1831 fn test_code_config_list_models() {
1832 let config = CodeConfig {
1833 providers: vec![
1834 ProviderConfig {
1835 name: "anthropic".to_string(),
1836 api_key: None,
1837 base_url: None,
1838 headers: HashMap::new(),
1839 session_id_header: None,
1840 models: vec![ModelConfig {
1841 id: "claude-sonnet-4".to_string(),
1842 name: "".to_string(),
1843 family: "".to_string(),
1844 api_key: None,
1845 base_url: None,
1846 headers: HashMap::new(),
1847 session_id_header: None,
1848 attachment: false,
1849 reasoning: false,
1850 tool_call: true,
1851 temperature: true,
1852 release_date: None,
1853 modalities: ModelModalities::default(),
1854 cost: ModelCost::default(),
1855 limit: ModelLimit::default(),
1856 }],
1857 },
1858 ProviderConfig {
1859 name: "openai".to_string(),
1860 api_key: None,
1861 base_url: None,
1862 headers: HashMap::new(),
1863 session_id_header: None,
1864 models: vec![ModelConfig {
1865 id: "gpt-4o".to_string(),
1866 name: "".to_string(),
1867 family: "".to_string(),
1868 api_key: None,
1869 base_url: None,
1870 headers: HashMap::new(),
1871 session_id_header: None,
1872 attachment: false,
1873 reasoning: false,
1874 tool_call: true,
1875 temperature: true,
1876 release_date: None,
1877 modalities: ModelModalities::default(),
1878 cost: ModelCost::default(),
1879 limit: ModelLimit::default(),
1880 }],
1881 },
1882 ],
1883 ..Default::default()
1884 };
1885
1886 let models = config.list_models();
1887 assert_eq!(models.len(), 2);
1888 }
1889
1890 #[test]
1891 fn test_llm_config_specific_provider_model() {
1892 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1893 "id": "claude-3",
1894 "name": "Claude 3"
1895 }))
1896 .unwrap();
1897
1898 let config = CodeConfig {
1899 providers: vec![ProviderConfig {
1900 name: "anthropic".to_string(),
1901 api_key: Some("sk-test".to_string()),
1902 base_url: None,
1903 headers: HashMap::new(),
1904 session_id_header: None,
1905 models: vec![model],
1906 }],
1907 ..Default::default()
1908 };
1909
1910 let llm = config.llm_config("anthropic", "claude-3");
1911 assert!(llm.is_some());
1912 let llm = llm.unwrap();
1913 assert_eq!(llm.provider, "anthropic");
1914 assert_eq!(llm.model, "claude-3");
1915 }
1916
1917 #[test]
1918 fn test_llm_config_missing_provider() {
1919 let config = CodeConfig::default();
1920 assert!(config.llm_config("nonexistent", "model").is_none());
1921 }
1922
1923 #[test]
1924 fn test_llm_config_missing_model() {
1925 let config = CodeConfig {
1926 providers: vec![ProviderConfig {
1927 name: "anthropic".to_string(),
1928 api_key: Some("sk-test".to_string()),
1929 base_url: None,
1930 headers: HashMap::new(),
1931 session_id_header: None,
1932 models: vec![],
1933 }],
1934 ..Default::default()
1935 };
1936 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1937 }
1938
1939 #[test]
1940 fn test_from_hcl_string() {
1941 let hcl = r#"
1942 default_model = "anthropic/claude-sonnet-4"
1943
1944 providers {
1945 name = "anthropic"
1946 api_key = "test-key"
1947
1948 models {
1949 id = "claude-sonnet-4"
1950 name = "Claude Sonnet 4"
1951 }
1952 }
1953 "#;
1954
1955 let config = CodeConfig::from_hcl(hcl).unwrap();
1956 assert_eq!(
1957 config.default_model,
1958 Some("anthropic/claude-sonnet-4".to_string())
1959 );
1960 assert_eq!(config.providers.len(), 1);
1961 assert_eq!(config.providers[0].name, "anthropic");
1962 assert_eq!(config.providers[0].models.len(), 1);
1963 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1964 }
1965
1966 #[test]
1967 fn test_from_hcl_multi_provider() {
1968 let hcl = r#"
1969 default_model = "anthropic/claude-sonnet-4"
1970
1971 providers {
1972 name = "anthropic"
1973 api_key = "sk-ant-test"
1974
1975 models {
1976 id = "claude-sonnet-4"
1977 name = "Claude Sonnet 4"
1978 }
1979
1980 models {
1981 id = "claude-opus-4"
1982 name = "Claude Opus 4"
1983 reasoning = true
1984 }
1985 }
1986
1987 providers {
1988 name = "openai"
1989 api_key = "sk-test"
1990
1991 models {
1992 id = "gpt-4o"
1993 name = "GPT-4o"
1994 }
1995 }
1996 "#;
1997
1998 let config = CodeConfig::from_hcl(hcl).unwrap();
1999 assert_eq!(config.providers.len(), 2);
2000 assert_eq!(config.providers[0].models.len(), 2);
2001 assert_eq!(config.providers[1].models.len(), 1);
2002 assert_eq!(config.providers[1].name, "openai");
2003 }
2004
2005 #[test]
2006 fn test_snake_to_camel() {
2007 assert_eq!(snake_to_camel("default_model"), "defaultModel");
2008 assert_eq!(snake_to_camel("api_key"), "apiKey");
2009 assert_eq!(snake_to_camel("base_url"), "baseUrl");
2010 assert_eq!(snake_to_camel("name"), "name");
2011 assert_eq!(snake_to_camel("tool_call"), "toolCall");
2012 }
2013
2014 #[test]
2015 fn test_from_file_auto_detect_hcl() {
2016 let temp_dir = tempfile::tempdir().unwrap();
2017 let config_path = temp_dir.path().join("config.hcl");
2018
2019 std::fs::write(
2020 &config_path,
2021 r#"
2022 default_model = "anthropic/claude-sonnet-4"
2023
2024 providers {
2025 name = "anthropic"
2026 api_key = "test-key"
2027
2028 models {
2029 id = "claude-sonnet-4"
2030 }
2031 }
2032 "#,
2033 )
2034 .unwrap();
2035
2036 let config = CodeConfig::from_file(&config_path).unwrap();
2037 assert_eq!(
2038 config.default_model,
2039 Some("anthropic/claude-sonnet-4".to_string())
2040 );
2041 }
2042
2043 #[test]
2044 fn test_from_hcl_with_queue_config() {
2045 let hcl = r#"
2046 default_model = "anthropic/claude-sonnet-4"
2047
2048 providers {
2049 name = "anthropic"
2050 api_key = "test-key"
2051 }
2052
2053 queue {
2054 query_max_concurrency = 20
2055 execute_max_concurrency = 5
2056 enable_metrics = true
2057 enable_dlq = true
2058 }
2059 "#;
2060
2061 let config = CodeConfig::from_hcl(hcl).unwrap();
2062 assert!(config.queue.is_some());
2063 let queue = config.queue.unwrap();
2064 assert_eq!(queue.query_max_concurrency, 20);
2065 assert_eq!(queue.execute_max_concurrency, 5);
2066 assert!(queue.enable_metrics);
2067 assert!(queue.enable_dlq);
2068 }
2069
2070 #[test]
2071 fn test_from_hcl_with_search_config() {
2072 let hcl = r#"
2073 default_model = "anthropic/claude-sonnet-4"
2074
2075 providers {
2076 name = "anthropic"
2077 api_key = "test-key"
2078 }
2079
2080 search {
2081 timeout = 30
2082
2083 health {
2084 max_failures = 5
2085 suspend_seconds = 120
2086 }
2087
2088 engine {
2089 google {
2090 enabled = true
2091 weight = 1.5
2092 }
2093 bing {
2094 enabled = true
2095 weight = 1.0
2096 timeout = 15
2097 }
2098 }
2099 }
2100 "#;
2101
2102 let config = CodeConfig::from_hcl(hcl).unwrap();
2103 assert!(config.search.is_some());
2104 let search = config.search.unwrap();
2105 assert_eq!(search.timeout, 30);
2106 assert!(search.health.is_some());
2107 let health = search.health.unwrap();
2108 assert_eq!(health.max_failures, 5);
2109 assert_eq!(health.suspend_seconds, 120);
2110 assert_eq!(search.engines.len(), 2);
2111 assert!(search.engines.contains_key("google"));
2112 assert!(search.engines.contains_key("bing"));
2113 let google = &search.engines["google"];
2114 assert!(google.enabled);
2115 assert_eq!(google.weight, 1.5);
2116 let bing = &search.engines["bing"];
2117 assert_eq!(bing.timeout, Some(15));
2118 }
2119
2120 #[test]
2121 fn test_from_hcl_with_queue_and_search() {
2122 let hcl = r#"
2123 default_model = "anthropic/claude-sonnet-4"
2124
2125 providers {
2126 name = "anthropic"
2127 api_key = "test-key"
2128 }
2129
2130 queue {
2131 query_max_concurrency = 10
2132 enable_metrics = true
2133 }
2134
2135 search {
2136 timeout = 20
2137 engine {
2138 duckduckgo {
2139 enabled = true
2140 }
2141 }
2142 }
2143 "#;
2144
2145 let config = CodeConfig::from_hcl(hcl).unwrap();
2146 assert!(config.queue.is_some());
2147 assert!(config.search.is_some());
2148 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
2149 assert_eq!(config.search.unwrap().timeout, 20);
2150 }
2151
2152 #[test]
2153 fn test_from_hcl_multiple_mcp_servers() {
2154 let hcl = r#"
2155 mcp_servers {
2156 name = "fetch"
2157 transport = "stdio"
2158 command = "npx"
2159 args = ["-y", "@modelcontextprotocol/server-fetch"]
2160 enabled = true
2161 }
2162
2163 mcp_servers {
2164 name = "puppeteer"
2165 transport = "stdio"
2166 command = "npx"
2167 args = ["-y", "@anthropic/mcp-server-puppeteer"]
2168 enabled = true
2169 }
2170
2171 mcp_servers {
2172 name = "filesystem"
2173 transport = "stdio"
2174 command = "npx"
2175 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2176 enabled = false
2177 }
2178 "#;
2179
2180 let config = CodeConfig::from_hcl(hcl).unwrap();
2181 assert_eq!(
2182 config.mcp_servers.len(),
2183 3,
2184 "all 3 mcp_servers blocks should be parsed"
2185 );
2186 assert_eq!(config.mcp_servers[0].name, "fetch");
2187 assert_eq!(config.mcp_servers[1].name, "puppeteer");
2188 assert_eq!(config.mcp_servers[2].name, "filesystem");
2189 assert!(config.mcp_servers[0].enabled);
2190 assert!(!config.mcp_servers[2].enabled);
2191 }
2192
2193 #[test]
2194 fn test_from_hcl_with_advanced_queue_config() {
2195 let hcl = r#"
2196 default_model = "anthropic/claude-sonnet-4"
2197
2198 providers {
2199 name = "anthropic"
2200 api_key = "test-key"
2201 }
2202
2203 queue {
2204 query_max_concurrency = 20
2205 enable_metrics = true
2206
2207 retry_policy {
2208 strategy = "exponential"
2209 max_retries = 5
2210 initial_delay_ms = 200
2211 }
2212
2213 rate_limit {
2214 limit_type = "per_second"
2215 max_operations = 100
2216 }
2217
2218 priority_boost {
2219 strategy = "standard"
2220 deadline_ms = 300000
2221 }
2222
2223 pressure_threshold = 50
2224 }
2225 "#;
2226
2227 let config = CodeConfig::from_hcl(hcl).unwrap();
2228 assert!(config.queue.is_some());
2229 let queue = config.queue.unwrap();
2230
2231 assert_eq!(queue.query_max_concurrency, 20);
2232 assert!(queue.enable_metrics);
2233
2234 assert!(queue.retry_policy.is_some());
2236 let retry = queue.retry_policy.unwrap();
2237 assert_eq!(retry.strategy, "exponential");
2238 assert_eq!(retry.max_retries, 5);
2239 assert_eq!(retry.initial_delay_ms, 200);
2240
2241 assert!(queue.rate_limit.is_some());
2243 let rate = queue.rate_limit.unwrap();
2244 assert_eq!(rate.limit_type, "per_second");
2245 assert_eq!(rate.max_operations, Some(100));
2246
2247 assert!(queue.priority_boost.is_some());
2249 let boost = queue.priority_boost.unwrap();
2250 assert_eq!(boost.strategy, "standard");
2251 assert_eq!(boost.deadline_ms, Some(300000));
2252
2253 assert_eq!(queue.pressure_threshold, Some(50));
2255 }
2256
2257 #[test]
2258 fn test_hcl_env_function_resolved() {
2259 std::env::set_var("A3S_TEST_HCL_KEY", "test-secret-key-123");
2261
2262 let hcl_str = r#"
2263 providers {
2264 name = "test"
2265 api_key = env("A3S_TEST_HCL_KEY")
2266 }
2267 "#;
2268
2269 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2270 let json = hcl_body_to_json(&body);
2271
2272 let providers = json.get("providers").unwrap();
2274 let provider = providers.as_array().unwrap().first().unwrap();
2275 let api_key = provider.get("apiKey").unwrap();
2276
2277 assert_eq!(api_key.as_str().unwrap(), "test-secret-key-123");
2278
2279 std::env::remove_var("A3S_TEST_HCL_KEY");
2281 }
2282
2283 #[test]
2284 fn test_hcl_env_function_unset_returns_null() {
2285 std::env::remove_var("A3S_TEST_NONEXISTENT_VAR_12345");
2287
2288 let hcl_str = r#"
2289 providers {
2290 name = "test"
2291 api_key = env("A3S_TEST_NONEXISTENT_VAR_12345")
2292 }
2293 "#;
2294
2295 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2296 let json = hcl_body_to_json(&body);
2297
2298 let providers = json.get("providers").unwrap();
2299 let provider = providers.as_array().unwrap().first().unwrap();
2300 let api_key = provider.get("apiKey").unwrap();
2301
2302 assert!(api_key.is_null(), "Unset env var should return null");
2303 }
2304
2305 #[test]
2306 fn test_hcl_mcp_env_block_preserves_var_names() {
2307 std::env::set_var("A3S_TEST_SECRET", "my-secret");
2309
2310 let hcl_str = r#"
2311 mcp_servers {
2312 name = "test-server"
2313 transport = "stdio"
2314 command = "echo"
2315 env = {
2316 API_KEY = "sk-test-123"
2317 ANTHROPIC_API_KEY = env("A3S_TEST_SECRET")
2318 SIMPLE = "value"
2319 }
2320 }
2321 "#;
2322
2323 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2324 let json = hcl_body_to_json(&body);
2325
2326 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2327 let server = &servers[0];
2328 let env = server.get("env").unwrap().as_object().unwrap();
2329
2330 assert_eq!(env.get("API_KEY").unwrap().as_str().unwrap(), "sk-test-123");
2332 assert_eq!(
2333 env.get("ANTHROPIC_API_KEY").unwrap().as_str().unwrap(),
2334 "my-secret"
2335 );
2336 assert_eq!(env.get("SIMPLE").unwrap().as_str().unwrap(), "value");
2337
2338 assert!(
2340 env.get("apiKey").is_none(),
2341 "env var key should not be camelCase'd"
2342 );
2343 assert!(
2344 env.get("APIKEY").is_none(),
2345 "env var key should not have underscores stripped"
2346 );
2347 assert!(env.get("anthropicApiKey").is_none());
2348
2349 std::env::remove_var("A3S_TEST_SECRET");
2350 }
2351
2352 #[test]
2353 fn test_hcl_mcp_env_as_block_syntax() {
2354 let hcl_str = r#"
2356 mcp_servers {
2357 name = "test-server"
2358 transport = "stdio"
2359 command = "echo"
2360 env {
2361 MY_VAR = "hello"
2362 OTHER_VAR = "world"
2363 }
2364 }
2365 "#;
2366
2367 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2368 let json = hcl_body_to_json(&body);
2369
2370 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2371 let server = &servers[0];
2372 let env = server.get("env").unwrap().as_object().unwrap();
2373
2374 assert_eq!(env.get("MY_VAR").unwrap().as_str().unwrap(), "hello");
2375 assert_eq!(env.get("OTHER_VAR").unwrap().as_str().unwrap(), "world");
2376 assert!(
2377 env.get("myVar").is_none(),
2378 "block env keys should not be camelCase'd"
2379 );
2380 }
2381
2382 #[test]
2383 fn test_hcl_mcp_full_deserialization_with_env() {
2384 std::env::set_var("A3S_TEST_MCP_KEY", "resolved-secret");
2386
2387 let hcl_str = r#"
2388 mcp_servers {
2389 name = "fetch"
2390 transport = "stdio"
2391 command = "npx"
2392 args = ["-y", "@modelcontextprotocol/server-fetch"]
2393 env = {
2394 NODE_ENV = "production"
2395 API_KEY = env("A3S_TEST_MCP_KEY")
2396 }
2397 tool_timeout_secs = 120
2398 }
2399 "#;
2400
2401 let config = CodeConfig::from_hcl(hcl_str).unwrap();
2402 assert_eq!(config.mcp_servers.len(), 1);
2403
2404 let server = &config.mcp_servers[0];
2405 assert_eq!(server.name, "fetch");
2406 assert_eq!(server.env.get("NODE_ENV").unwrap(), "production");
2407 assert_eq!(server.env.get("API_KEY").unwrap(), "resolved-secret");
2408 assert_eq!(server.tool_timeout_secs, 120);
2409
2410 std::env::remove_var("A3S_TEST_MCP_KEY");
2411 }
2412
2413 #[test]
2414 fn test_hcl_document_tool_config_parses() {
2415 let hcl = r#"
2416 agentic_search {
2417 enabled = false
2418 default_mode = "deep"
2419 max_results = 7
2420 context_lines = 4
2421 }
2422
2423 agentic_parse {
2424 enabled = true
2425 default_strategy = "structured"
2426 max_chars = 12000
2427 }
2428
2429 document_parser {
2430 enabled = true
2431 max_file_size_mb = 64
2432
2433 ocr {
2434 enabled = true
2435 model = "openai/gpt-4.1-mini"
2436 prompt = "Extract text from scanned pages."
2437 max_images = 6
2438 dpi = 200
2439 }
2440 }
2441 "#;
2442
2443 let config = CodeConfig::from_hcl(hcl).unwrap();
2444 let search = config.agentic_search.unwrap();
2445 let parse = config.agentic_parse.unwrap();
2446 let document_parser = config.document_parser.unwrap();
2447
2448 assert!(!search.enabled);
2449 assert_eq!(search.default_mode, "deep");
2450 assert_eq!(search.max_results, 7);
2451 assert_eq!(search.context_lines, 4);
2452
2453 assert!(parse.enabled);
2454 assert_eq!(parse.default_strategy, "structured");
2455 assert_eq!(parse.max_chars, 12000);
2456
2457 assert!(document_parser.enabled);
2458 assert_eq!(document_parser.max_file_size_mb, 64);
2459 let ocr = document_parser.ocr.unwrap();
2460 assert!(ocr.enabled);
2461 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2462 assert_eq!(
2463 ocr.prompt.as_deref(),
2464 Some("Extract text from scanned pages.")
2465 );
2466 assert_eq!(ocr.max_images, 6);
2467 assert_eq!(ocr.dpi, 200);
2468 }
2469
2470 #[test]
2471 fn test_hcl_document_parser_parses() {
2472 let hcl = r#"
2473 document_parser {
2474 enabled = true
2475 max_file_size_mb = 48
2476 cache {
2477 enabled = true
2478 directory = "/tmp/a3s-doc-cache"
2479 }
2480
2481 ocr {
2482 enabled = true
2483 model = "openai/gpt-4.1-mini"
2484 prompt = "Read scanned tables."
2485 max_images = 5
2486 dpi = 180
2487 }
2488 }
2489 "#;
2490
2491 let config = CodeConfig::from_hcl(hcl).unwrap();
2492 let parser = config.document_parser.unwrap();
2493
2494 assert!(parser.enabled);
2495 assert_eq!(parser.max_file_size_mb, 48);
2496 let cache = parser.cache.unwrap();
2497 assert!(cache.enabled);
2498 assert_eq!(
2499 cache.directory.as_deref(),
2500 Some(std::path::Path::new("/tmp/a3s-doc-cache"))
2501 );
2502 let ocr = parser.ocr.unwrap();
2503 assert!(ocr.enabled);
2504 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2505 assert_eq!(ocr.prompt.as_deref(), Some("Read scanned tables."));
2506 assert_eq!(ocr.max_images, 5);
2507 assert_eq!(ocr.dpi, 180);
2508 }
2509
2510 #[test]
2511 fn test_agentic_search_config_normalizes_invalid_values() {
2512 let config = AgenticSearchConfig {
2513 enabled: true,
2514 default_mode: "weird".to_string(),
2515 max_results: 0,
2516 context_lines: 999,
2517 }
2518 .normalized();
2519
2520 assert_eq!(config.default_mode, "fast");
2521 assert_eq!(config.max_results, 1);
2522 assert_eq!(config.context_lines, 20);
2523 }
2524
2525 #[test]
2526 fn test_agentic_parse_config_normalizes_invalid_values() {
2527 let config = AgenticParseConfig {
2528 enabled: true,
2529 default_strategy: "unknown".to_string(),
2530 max_chars: 1,
2531 }
2532 .normalized();
2533
2534 assert_eq!(config.default_strategy, "auto");
2535 assert_eq!(config.max_chars, 500);
2536 }
2537
2538 #[test]
2539 fn test_document_parser_config_normalizes_nested_ocr_values() {
2540 let config = DocumentParserConfig {
2541 enabled: true,
2542 max_file_size_mb: 0,
2543 cache: Some(DocumentCacheConfig {
2544 enabled: true,
2545 directory: Some(PathBuf::from("/tmp/cache")),
2546 }),
2547 ocr: Some(DocumentOcrConfig {
2548 enabled: true,
2549 model: Some("openai/gpt-4.1-mini".to_string()),
2550 prompt: None,
2551 max_images: 0,
2552 dpi: 10,
2553 provider: None,
2554 base_url: None,
2555 api_key: None,
2556 }),
2557 }
2558 .normalized();
2559
2560 assert_eq!(config.max_file_size_mb, 1);
2561 let cache = config.cache.unwrap();
2562 assert!(cache.enabled);
2563 assert_eq!(cache.directory, Some(PathBuf::from("/tmp/cache")));
2564 let ocr = config.ocr.unwrap();
2565 assert_eq!(ocr.max_images, 1);
2566 assert_eq!(ocr.dpi, 72);
2567 }
2568}