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
322#[derive(Debug, Clone, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct AgenticSearchConfig {
326 #[serde(default = "default_enabled")]
328 pub enabled: bool,
329
330 #[serde(default = "default_agentic_search_mode")]
332 pub default_mode: String,
333
334 #[serde(default = "default_agentic_search_max_results")]
336 pub max_results: usize,
337
338 #[serde(default = "default_agentic_search_context_lines")]
340 pub context_lines: usize,
341}
342
343impl Default for AgenticSearchConfig {
344 fn default() -> Self {
345 Self {
346 enabled: true,
347 default_mode: default_agentic_search_mode(),
348 max_results: default_agentic_search_max_results(),
349 context_lines: default_agentic_search_context_lines(),
350 }
351 }
352}
353
354impl AgenticSearchConfig {
355 pub fn normalized(&self) -> Self {
356 let default_mode = match self.default_mode.to_ascii_lowercase().as_str() {
357 "fast" => "fast".to_string(),
358 "deep" => "deep".to_string(),
359 "filename_only" | "filename" => "filename_only".to_string(),
360 _ => default_agentic_search_mode(),
361 };
362
363 Self {
364 enabled: self.enabled,
365 default_mode,
366 max_results: self.max_results.clamp(1, 100),
367 context_lines: self.context_lines.min(20),
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase")]
375pub struct AgenticParseConfig {
376 #[serde(default = "default_enabled")]
378 pub enabled: bool,
379
380 #[serde(default = "default_agentic_parse_strategy")]
382 pub default_strategy: String,
383
384 #[serde(default = "default_agentic_parse_max_chars")]
386 pub max_chars: usize,
387}
388
389impl Default for AgenticParseConfig {
390 fn default() -> Self {
391 Self {
392 enabled: true,
393 default_strategy: default_agentic_parse_strategy(),
394 max_chars: default_agentic_parse_max_chars(),
395 }
396 }
397}
398
399impl AgenticParseConfig {
400 pub fn normalized(&self) -> Self {
401 let default_strategy = match self.default_strategy.to_ascii_lowercase().as_str() {
402 "auto" => "auto".to_string(),
403 "structured" => "structured".to_string(),
404 "narrative" => "narrative".to_string(),
405 "tabular" => "tabular".to_string(),
406 "code" => "code".to_string(),
407 _ => default_agentic_parse_strategy(),
408 };
409
410 Self {
411 enabled: self.enabled,
412 default_strategy,
413 max_chars: self.max_chars.clamp(500, 200_000),
414 }
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct DocumentParserConfig {
422 #[serde(default = "default_enabled")]
424 pub enabled: bool,
425
426 #[serde(default = "default_document_parser_max_file_size_mb")]
428 pub max_file_size_mb: u64,
429
430 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub ocr: Option<DocumentOcrConfig>,
437
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub cache: Option<DocumentCacheConfig>,
441}
442
443impl Default for DocumentParserConfig {
444 fn default() -> Self {
445 Self {
446 enabled: true,
447 max_file_size_mb: default_document_parser_max_file_size_mb(),
448 ocr: None,
449 cache: Some(DocumentCacheConfig::default()),
450 }
451 }
452}
453
454impl DocumentParserConfig {
455 pub fn normalized(&self) -> Self {
456 Self {
457 enabled: self.enabled,
458 max_file_size_mb: self.max_file_size_mb.clamp(1, 1024),
459 ocr: self.ocr.as_ref().map(DocumentOcrConfig::normalized),
460 cache: self.cache.as_ref().map(DocumentCacheConfig::normalized),
461 }
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct DocumentCacheConfig {
468 #[serde(default = "default_enabled")]
469 pub enabled: bool,
470
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub directory: Option<PathBuf>,
473}
474
475impl Default for DocumentCacheConfig {
476 fn default() -> Self {
477 Self {
478 enabled: true,
479 directory: None,
480 }
481 }
482}
483
484impl DocumentCacheConfig {
485 pub fn normalized(&self) -> Self {
486 Self {
487 enabled: self.enabled,
488 directory: self.directory.clone(),
489 }
490 }
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct DocumentOcrConfig {
497 #[serde(default = "default_enabled")]
499 pub enabled: bool,
500
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub model: Option<String>,
504
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub prompt: Option<String>,
508
509 #[serde(default = "default_document_ocr_max_images")]
511 pub max_images: usize,
512
513 #[serde(default = "default_document_ocr_dpi")]
515 pub dpi: u32,
516
517 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub provider: Option<String>,
522
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub base_url: Option<String>,
526
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub api_key: Option<String>,
530}
531
532impl Default for DocumentOcrConfig {
533 fn default() -> Self {
534 Self {
535 enabled: false,
536 model: None,
537 prompt: None,
538 max_images: default_document_ocr_max_images(),
539 dpi: default_document_ocr_dpi(),
540 provider: None,
541 base_url: None,
542 api_key: None,
543 }
544 }
545}
546
547impl DocumentOcrConfig {
548 pub fn normalized(&self) -> Self {
549 Self {
550 enabled: self.enabled,
551 model: self.model.clone(),
552 prompt: self.prompt.clone(),
553 max_images: self.max_images.clamp(1, 64),
554 dpi: self.dpi.clamp(72, 600),
555 provider: self.provider.clone(),
556 base_url: self.base_url.clone(),
557 api_key: self.api_key.clone(),
558 }
559 }
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
564#[serde(rename_all = "camelCase")]
565pub struct SearchHealthConfig {
566 #[serde(default = "default_max_failures")]
568 pub max_failures: u32,
569
570 #[serde(default = "default_suspend_seconds")]
572 pub suspend_seconds: u64,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
577#[serde(rename_all = "camelCase")]
578pub struct SearchEngineConfig {
579 #[serde(default = "default_enabled")]
581 pub enabled: bool,
582
583 #[serde(default = "default_weight")]
585 pub weight: f64,
586
587 #[serde(skip_serializing_if = "Option::is_none")]
589 pub timeout: Option<u64>,
590}
591
592fn default_search_timeout() -> u64 {
593 10
594}
595
596fn default_max_failures() -> u32 {
597 3
598}
599
600fn default_suspend_seconds() -> u64 {
601 60
602}
603
604fn default_enabled() -> bool {
605 true
606}
607
608fn default_weight() -> f64 {
609 1.0
610}
611
612fn default_agentic_search_mode() -> String {
613 "fast".to_string()
614}
615
616fn default_agentic_search_max_results() -> usize {
617 10
618}
619
620fn default_agentic_search_context_lines() -> usize {
621 2
622}
623
624fn default_agentic_parse_strategy() -> String {
625 "auto".to_string()
626}
627
628fn default_agentic_parse_max_chars() -> usize {
629 8000
630}
631
632fn default_document_parser_max_file_size_mb() -> u64 {
633 50
634}
635
636fn default_document_ocr_max_images() -> usize {
637 8
638}
639
640fn default_document_ocr_dpi() -> u32 {
641 144
642}
643
644impl CodeConfig {
645 pub fn new() -> Self {
647 Self::default()
648 }
649
650 pub fn from_file(path: &Path) -> Result<Self> {
654 let content = std::fs::read_to_string(path).map_err(|e| {
655 CodeError::Config(format!(
656 "Failed to read config file {}: {}",
657 path.display(),
658 e
659 ))
660 })?;
661
662 Self::from_hcl(&content).map_err(|e| {
663 CodeError::Config(format!(
664 "Failed to parse HCL config {}: {}",
665 path.display(),
666 e
667 ))
668 })
669 }
670
671 pub fn from_hcl(content: &str) -> Result<Self> {
677 let body: hcl::Body = hcl::from_str(content)
678 .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
679 let json_value = hcl_body_to_json(&body);
680 serde_json::from_value(json_value)
681 .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
682 }
683
684 pub fn save_to_file(&self, path: &Path) -> Result<()> {
688 if let Some(parent) = path.parent() {
689 std::fs::create_dir_all(parent).map_err(|e| {
690 CodeError::Config(format!(
691 "Failed to create config directory {}: {}",
692 parent.display(),
693 e
694 ))
695 })?;
696 }
697
698 let content = serde_json::to_string_pretty(self)
699 .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
700
701 std::fs::write(path, content).map_err(|e| {
702 CodeError::Config(format!(
703 "Failed to write config file {}: {}",
704 path.display(),
705 e
706 ))
707 })?;
708
709 Ok(())
710 }
711
712 pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
714 self.providers.iter().find(|p| p.name == name)
715 }
716
717 pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
719 let default = self.default_model.as_ref()?;
720 let (provider_name, _) = default.split_once('/')?;
721 self.find_provider(provider_name)
722 }
723
724 pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
726 let default = self.default_model.as_ref()?;
727 let (provider_name, model_id) = default.split_once('/')?;
728 let provider = self.find_provider(provider_name)?;
729 let model = provider.find_model(model_id)?;
730 Some((provider, model))
731 }
732
733 pub fn default_llm_config(&self) -> Option<LlmConfig> {
737 let (provider, model) = self.default_model_config()?;
738 let api_key = provider.get_api_key(model)?;
739 let base_url = provider.get_base_url(model);
740 let headers = provider.get_headers(model);
741 let session_id_header = provider.get_session_id_header(model);
742
743 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
744 if let Some(url) = base_url {
745 config = config.with_base_url(url);
746 }
747 if !headers.is_empty() {
748 config = config.with_headers(headers);
749 }
750 if let Some(header_name) = session_id_header {
751 config = config.with_session_id_header(header_name);
752 }
753 config = apply_model_caps(config, model, self.thinking_budget);
754 Some(config)
755 }
756
757 pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
761 let provider = self.find_provider(provider_name)?;
762 let model = provider.find_model(model_id)?;
763 let api_key = provider.get_api_key(model)?;
764 let base_url = provider.get_base_url(model);
765 let headers = provider.get_headers(model);
766 let session_id_header = provider.get_session_id_header(model);
767
768 let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
769 if let Some(url) = base_url {
770 config = config.with_base_url(url);
771 }
772 if !headers.is_empty() {
773 config = config.with_headers(headers);
774 }
775 if let Some(header_name) = session_id_header {
776 config = config.with_session_id_header(header_name);
777 }
778 config = apply_model_caps(config, model, self.thinking_budget);
779 Some(config)
780 }
781
782 pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
784 self.providers
785 .iter()
786 .flat_map(|p| p.models.iter().map(move |m| (p, m)))
787 .collect()
788 }
789
790 pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
792 self.skill_dirs.push(dir.into());
793 self
794 }
795
796 pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
798 self.agent_dirs.push(dir.into());
799 self
800 }
801
802 pub fn has_directories(&self) -> bool {
804 !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
805 }
806
807 pub fn has_providers(&self) -> bool {
809 !self.providers.is_empty()
810 }
811}
812
813const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
819
820const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
824
825fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
827 hcl_body_to_json_inner(body, false)
828}
829
830fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
835 let mut map = serde_json::Map::new();
836
837 for attr in body.attributes() {
839 let key = if verbatim_keys {
840 attr.key.as_str().to_string()
841 } else {
842 snake_to_camel(attr.key.as_str())
843 };
844 let value = hcl_expr_to_json(attr.expr());
845 map.insert(key, value);
846 }
847
848 for block in body.blocks() {
850 let key = if verbatim_keys {
851 block.identifier.as_str().to_string()
852 } else {
853 snake_to_camel(block.identifier.as_str())
854 };
855 let child_verbatim = HCL_VERBATIM_BLOCKS.contains(&block.identifier.as_str());
857 let block_value = hcl_body_to_json_inner(block.body(), child_verbatim);
858
859 if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
860 let arr = map
862 .entry(key)
863 .or_insert_with(|| JsonValue::Array(Vec::new()));
864 if let JsonValue::Array(ref mut vec) = arr {
865 vec.push(block_value);
866 }
867 } else {
868 map.insert(key, block_value);
869 }
870 }
871
872 JsonValue::Object(map)
873}
874
875fn snake_to_camel(s: &str) -> String {
877 let mut result = String::with_capacity(s.len());
878 let mut capitalize_next = false;
879 for ch in s.chars() {
880 if ch == '_' {
881 capitalize_next = true;
882 } else if capitalize_next {
883 result.extend(ch.to_uppercase());
884 capitalize_next = false;
885 } else {
886 result.push(ch);
887 }
888 }
889 result
890}
891
892fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
894 match expr {
895 hcl::Expression::String(s) => JsonValue::String(s.clone()),
896 hcl::Expression::Number(n) => {
897 if let Some(i) = n.as_i64() {
898 JsonValue::Number(i.into())
899 } else if let Some(f) = n.as_f64() {
900 serde_json::Number::from_f64(f)
901 .map(JsonValue::Number)
902 .unwrap_or(JsonValue::Null)
903 } else {
904 JsonValue::Null
905 }
906 }
907 hcl::Expression::Bool(b) => JsonValue::Bool(*b),
908 hcl::Expression::Null => JsonValue::Null,
909 hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
910 hcl::Expression::Object(obj) => {
911 let map: serde_json::Map<String, JsonValue> = obj
914 .iter()
915 .map(|(k, v)| {
916 let key = match k {
917 hcl::ObjectKey::Identifier(id) => id.as_str().to_string(),
918 hcl::ObjectKey::Expression(expr) => {
919 if let hcl::Expression::String(s) = expr {
920 s.clone()
921 } else {
922 format!("{:?}", expr)
923 }
924 }
925 _ => format!("{:?}", k),
926 };
927 (key, hcl_expr_to_json(v))
928 })
929 .collect();
930 JsonValue::Object(map)
931 }
932 hcl::Expression::FuncCall(func_call) => eval_func_call(func_call),
933 hcl::Expression::TemplateExpr(tmpl) => eval_template_expr(tmpl),
934 _ => JsonValue::String(format!("{:?}", expr)),
935 }
936}
937
938fn eval_func_call(func_call: &hcl::expr::FuncCall) -> JsonValue {
943 let name = func_call.name.name.as_str();
944 match name {
945 "env" => {
946 if let Some(arg) = func_call.args.first() {
947 let var_name = match arg {
948 hcl::Expression::String(s) => s.as_str(),
949 _ => {
950 tracing::warn!("env() expects a string argument, got: {:?}", arg);
951 return JsonValue::Null;
952 }
953 };
954 match std::env::var(var_name) {
955 Ok(val) => JsonValue::String(val),
956 Err(_) => {
957 tracing::debug!("env(\"{}\") is not set, returning null", var_name);
958 JsonValue::Null
959 }
960 }
961 } else {
962 tracing::warn!("env() called with no arguments");
963 JsonValue::Null
964 }
965 }
966 _ => {
967 tracing::warn!("Unsupported HCL function: {}()", name);
968 JsonValue::String(format!("{}()", name))
969 }
970 }
971}
972
973fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
978 JsonValue::String(format!("{}", tmpl))
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[test]
991 fn test_config_default() {
992 let config = CodeConfig::default();
993 assert!(config.skill_dirs.is_empty());
994 assert!(config.agent_dirs.is_empty());
995 assert!(config.providers.is_empty());
996 assert!(config.default_model.is_none());
997 assert_eq!(config.storage_backend, StorageBackend::File);
998 assert!(config.sessions_dir.is_none());
999 }
1000
1001 #[test]
1002 fn test_storage_backend_default() {
1003 let backend = StorageBackend::default();
1004 assert_eq!(backend, StorageBackend::File);
1005 }
1006
1007 #[test]
1008 fn test_storage_backend_serde() {
1009 let memory = StorageBackend::Memory;
1011 let json = serde_json::to_string(&memory).unwrap();
1012 assert_eq!(json, "\"memory\"");
1013
1014 let file = StorageBackend::File;
1015 let json = serde_json::to_string(&file).unwrap();
1016 assert_eq!(json, "\"file\"");
1017
1018 let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
1020 assert_eq!(memory, StorageBackend::Memory);
1021
1022 let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
1023 assert_eq!(file, StorageBackend::File);
1024 }
1025
1026 #[test]
1027 fn test_config_with_storage_backend() {
1028 let temp_dir = tempfile::tempdir().unwrap();
1029 let config_path = temp_dir.path().join("config.hcl");
1030
1031 std::fs::write(
1032 &config_path,
1033 r#"
1034 storage_backend = "memory"
1035 sessions_dir = "/tmp/sessions"
1036 "#,
1037 )
1038 .unwrap();
1039
1040 let config = CodeConfig::from_file(&config_path).unwrap();
1041 assert_eq!(config.storage_backend, StorageBackend::Memory);
1042 assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
1043 }
1044
1045 #[test]
1046 fn test_config_builder() {
1047 let config = CodeConfig::new()
1048 .add_skill_dir("/tmp/skills")
1049 .add_agent_dir("/tmp/agents");
1050
1051 assert_eq!(config.skill_dirs.len(), 1);
1052 assert_eq!(config.agent_dirs.len(), 1);
1053 }
1054
1055 #[test]
1056 fn test_find_provider() {
1057 let config = CodeConfig {
1058 providers: vec![
1059 ProviderConfig {
1060 name: "anthropic".to_string(),
1061 api_key: Some("key1".to_string()),
1062 base_url: None,
1063 headers: HashMap::new(),
1064 session_id_header: None,
1065 models: vec![],
1066 },
1067 ProviderConfig {
1068 name: "openai".to_string(),
1069 api_key: Some("key2".to_string()),
1070 base_url: None,
1071 headers: HashMap::new(),
1072 session_id_header: None,
1073 models: vec![],
1074 },
1075 ],
1076 ..Default::default()
1077 };
1078
1079 assert!(config.find_provider("anthropic").is_some());
1080 assert!(config.find_provider("openai").is_some());
1081 assert!(config.find_provider("unknown").is_none());
1082 }
1083
1084 #[test]
1085 fn test_default_llm_config() {
1086 let config = CodeConfig {
1087 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1088 providers: vec![ProviderConfig {
1089 name: "anthropic".to_string(),
1090 api_key: Some("test-api-key".to_string()),
1091 base_url: Some("https://api.anthropic.com".to_string()),
1092 headers: HashMap::new(),
1093 session_id_header: None,
1094 models: vec![ModelConfig {
1095 id: "claude-sonnet-4".to_string(),
1096 name: "Claude Sonnet 4".to_string(),
1097 family: "claude-sonnet".to_string(),
1098 api_key: None,
1099 base_url: None,
1100 headers: HashMap::new(),
1101 session_id_header: None,
1102 attachment: false,
1103 reasoning: false,
1104 tool_call: true,
1105 temperature: true,
1106 release_date: None,
1107 modalities: ModelModalities::default(),
1108 cost: ModelCost::default(),
1109 limit: ModelLimit::default(),
1110 }],
1111 }],
1112 ..Default::default()
1113 };
1114
1115 let llm_config = config.default_llm_config().unwrap();
1116 assert_eq!(llm_config.provider, "anthropic");
1117 assert_eq!(llm_config.model, "claude-sonnet-4");
1118 assert_eq!(llm_config.api_key.expose(), "test-api-key");
1119 assert_eq!(
1120 llm_config.base_url,
1121 Some("https://api.anthropic.com".to_string())
1122 );
1123 }
1124
1125 #[test]
1126 fn test_model_api_key_override() {
1127 let provider = ProviderConfig {
1128 name: "openai".to_string(),
1129 api_key: Some("provider-key".to_string()),
1130 base_url: Some("https://api.openai.com".to_string()),
1131 headers: HashMap::new(),
1132 session_id_header: None,
1133 models: vec![
1134 ModelConfig {
1135 id: "gpt-4".to_string(),
1136 name: "GPT-4".to_string(),
1137 family: "gpt".to_string(),
1138 api_key: None, base_url: None,
1140 headers: HashMap::new(),
1141 session_id_header: None,
1142 attachment: false,
1143 reasoning: false,
1144 tool_call: true,
1145 temperature: true,
1146 release_date: None,
1147 modalities: ModelModalities::default(),
1148 cost: ModelCost::default(),
1149 limit: ModelLimit::default(),
1150 },
1151 ModelConfig {
1152 id: "custom-model".to_string(),
1153 name: "Custom Model".to_string(),
1154 family: "custom".to_string(),
1155 api_key: Some("model-specific-key".to_string()), base_url: Some("https://custom.api.com".to_string()), headers: HashMap::new(),
1158 session_id_header: None,
1159 attachment: false,
1160 reasoning: false,
1161 tool_call: true,
1162 temperature: true,
1163 release_date: None,
1164 modalities: ModelModalities::default(),
1165 cost: ModelCost::default(),
1166 limit: ModelLimit::default(),
1167 },
1168 ],
1169 };
1170
1171 let model1 = provider.find_model("gpt-4").unwrap();
1173 assert_eq!(provider.get_api_key(model1), Some("provider-key"));
1174 assert_eq!(
1175 provider.get_base_url(model1),
1176 Some("https://api.openai.com")
1177 );
1178
1179 let model2 = provider.find_model("custom-model").unwrap();
1181 assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
1182 assert_eq!(
1183 provider.get_base_url(model2),
1184 Some("https://custom.api.com")
1185 );
1186 }
1187
1188 #[test]
1189 fn test_list_models() {
1190 let config = CodeConfig {
1191 providers: vec![
1192 ProviderConfig {
1193 name: "anthropic".to_string(),
1194 api_key: None,
1195 base_url: None,
1196 headers: HashMap::new(),
1197 session_id_header: None,
1198 models: vec![
1199 ModelConfig {
1200 id: "claude-1".to_string(),
1201 name: "Claude 1".to_string(),
1202 family: "claude".to_string(),
1203 api_key: None,
1204 base_url: None,
1205 headers: HashMap::new(),
1206 session_id_header: None,
1207 attachment: false,
1208 reasoning: false,
1209 tool_call: true,
1210 temperature: true,
1211 release_date: None,
1212 modalities: ModelModalities::default(),
1213 cost: ModelCost::default(),
1214 limit: ModelLimit::default(),
1215 },
1216 ModelConfig {
1217 id: "claude-2".to_string(),
1218 name: "Claude 2".to_string(),
1219 family: "claude".to_string(),
1220 api_key: None,
1221 base_url: None,
1222 headers: HashMap::new(),
1223 session_id_header: None,
1224 attachment: false,
1225 reasoning: false,
1226 tool_call: true,
1227 temperature: true,
1228 release_date: None,
1229 modalities: ModelModalities::default(),
1230 cost: ModelCost::default(),
1231 limit: ModelLimit::default(),
1232 },
1233 ],
1234 },
1235 ProviderConfig {
1236 name: "openai".to_string(),
1237 api_key: None,
1238 base_url: None,
1239 headers: HashMap::new(),
1240 session_id_header: None,
1241 models: vec![ModelConfig {
1242 id: "gpt-4".to_string(),
1243 name: "GPT-4".to_string(),
1244 family: "gpt".to_string(),
1245 api_key: None,
1246 base_url: None,
1247 headers: HashMap::new(),
1248 session_id_header: None,
1249 attachment: false,
1250 reasoning: false,
1251 tool_call: true,
1252 temperature: true,
1253 release_date: None,
1254 modalities: ModelModalities::default(),
1255 cost: ModelCost::default(),
1256 limit: ModelLimit::default(),
1257 }],
1258 },
1259 ],
1260 ..Default::default()
1261 };
1262
1263 let models = config.list_models();
1264 assert_eq!(models.len(), 3);
1265 }
1266
1267 #[test]
1268 fn test_config_from_file_not_found() {
1269 let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
1270 assert!(result.is_err());
1271 }
1272
1273 #[test]
1274 fn test_config_has_directories() {
1275 let empty = CodeConfig::default();
1276 assert!(!empty.has_directories());
1277
1278 let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
1279 assert!(with_skills.has_directories());
1280
1281 let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
1282 assert!(with_agents.has_directories());
1283 }
1284
1285 #[test]
1286 fn test_config_has_providers() {
1287 let empty = CodeConfig::default();
1288 assert!(!empty.has_providers());
1289
1290 let with_providers = CodeConfig {
1291 providers: vec![ProviderConfig {
1292 name: "test".to_string(),
1293 api_key: None,
1294 base_url: None,
1295 headers: HashMap::new(),
1296 session_id_header: None,
1297 models: vec![],
1298 }],
1299 ..Default::default()
1300 };
1301 assert!(with_providers.has_providers());
1302 }
1303
1304 #[test]
1305 fn test_storage_backend_equality() {
1306 assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
1307 assert_eq!(StorageBackend::File, StorageBackend::File);
1308 assert_ne!(StorageBackend::Memory, StorageBackend::File);
1309 }
1310
1311 #[test]
1312 fn test_storage_backend_serde_custom() {
1313 let custom = StorageBackend::Custom;
1314 let json = serde_json::to_string(&custom).unwrap();
1316 assert_eq!(json, "\"custom\"");
1317
1318 let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
1320 assert_eq!(parsed, StorageBackend::Custom);
1321 }
1322
1323 #[test]
1324 fn test_model_cost_default() {
1325 let cost = ModelCost::default();
1326 assert_eq!(cost.input, 0.0);
1327 assert_eq!(cost.output, 0.0);
1328 assert_eq!(cost.cache_read, 0.0);
1329 assert_eq!(cost.cache_write, 0.0);
1330 }
1331
1332 #[test]
1333 fn test_model_cost_serialization() {
1334 let cost = ModelCost {
1335 input: 3.0,
1336 output: 15.0,
1337 cache_read: 0.3,
1338 cache_write: 3.75,
1339 };
1340 let json = serde_json::to_string(&cost).unwrap();
1341 assert!(json.contains("\"input\":3"));
1342 assert!(json.contains("\"output\":15"));
1343 }
1344
1345 #[test]
1346 fn test_model_cost_deserialization_missing_fields() {
1347 let json = r#"{"input":3.0}"#;
1348 let cost: ModelCost = serde_json::from_str(json).unwrap();
1349 assert_eq!(cost.input, 3.0);
1350 assert_eq!(cost.output, 0.0);
1351 assert_eq!(cost.cache_read, 0.0);
1352 assert_eq!(cost.cache_write, 0.0);
1353 }
1354
1355 #[test]
1356 fn test_model_limit_default() {
1357 let limit = ModelLimit::default();
1358 assert_eq!(limit.context, 0);
1359 assert_eq!(limit.output, 0);
1360 }
1361
1362 #[test]
1363 fn test_model_limit_serialization() {
1364 let limit = ModelLimit {
1365 context: 200000,
1366 output: 8192,
1367 };
1368 let json = serde_json::to_string(&limit).unwrap();
1369 assert!(json.contains("\"context\":200000"));
1370 assert!(json.contains("\"output\":8192"));
1371 }
1372
1373 #[test]
1374 fn test_model_limit_deserialization_missing_fields() {
1375 let json = r#"{"context":100000}"#;
1376 let limit: ModelLimit = serde_json::from_str(json).unwrap();
1377 assert_eq!(limit.context, 100000);
1378 assert_eq!(limit.output, 0);
1379 }
1380
1381 #[test]
1382 fn test_model_modalities_default() {
1383 let modalities = ModelModalities::default();
1384 assert!(modalities.input.is_empty());
1385 assert!(modalities.output.is_empty());
1386 }
1387
1388 #[test]
1389 fn test_model_modalities_serialization() {
1390 let modalities = ModelModalities {
1391 input: vec!["text".to_string(), "image".to_string()],
1392 output: vec!["text".to_string()],
1393 };
1394 let json = serde_json::to_string(&modalities).unwrap();
1395 assert!(json.contains("\"input\""));
1396 assert!(json.contains("\"text\""));
1397 }
1398
1399 #[test]
1400 fn test_model_modalities_deserialization_missing_fields() {
1401 let json = r#"{"input":["text"]}"#;
1402 let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1403 assert_eq!(modalities.input.len(), 1);
1404 assert!(modalities.output.is_empty());
1405 }
1406
1407 #[test]
1408 fn test_model_config_serialization() {
1409 let config = ModelConfig {
1410 id: "gpt-4o".to_string(),
1411 name: "GPT-4o".to_string(),
1412 family: "gpt-4".to_string(),
1413 api_key: Some("sk-test".to_string()),
1414 base_url: None,
1415 headers: HashMap::new(),
1416 session_id_header: None,
1417 attachment: true,
1418 reasoning: false,
1419 tool_call: true,
1420 temperature: true,
1421 release_date: Some("2024-05-13".to_string()),
1422 modalities: ModelModalities::default(),
1423 cost: ModelCost::default(),
1424 limit: ModelLimit::default(),
1425 };
1426 let json = serde_json::to_string(&config).unwrap();
1427 assert!(json.contains("\"id\":\"gpt-4o\""));
1428 assert!(json.contains("\"attachment\":true"));
1429 }
1430
1431 #[test]
1432 fn test_model_config_deserialization_with_defaults() {
1433 let json = r#"{"id":"test-model"}"#;
1434 let config: ModelConfig = serde_json::from_str(json).unwrap();
1435 assert_eq!(config.id, "test-model");
1436 assert_eq!(config.name, "");
1437 assert_eq!(config.family, "");
1438 assert!(config.api_key.is_none());
1439 assert!(!config.attachment);
1440 assert!(config.tool_call);
1441 assert!(config.temperature);
1442 }
1443
1444 #[test]
1445 fn test_model_config_all_optional_fields() {
1446 let json = r#"{
1447 "id": "claude-sonnet-4",
1448 "name": "Claude Sonnet 4",
1449 "family": "claude-sonnet",
1450 "apiKey": "sk-test",
1451 "baseUrl": "https://api.anthropic.com",
1452 "attachment": true,
1453 "reasoning": true,
1454 "toolCall": false,
1455 "temperature": false,
1456 "releaseDate": "2025-05-14"
1457 }"#;
1458 let config: ModelConfig = serde_json::from_str(json).unwrap();
1459 assert_eq!(config.id, "claude-sonnet-4");
1460 assert_eq!(config.name, "Claude Sonnet 4");
1461 assert_eq!(config.api_key, Some("sk-test".to_string()));
1462 assert_eq!(
1463 config.base_url,
1464 Some("https://api.anthropic.com".to_string())
1465 );
1466 assert!(config.attachment);
1467 assert!(config.reasoning);
1468 assert!(!config.tool_call);
1469 assert!(!config.temperature);
1470 }
1471
1472 #[test]
1473 fn test_provider_config_serialization() {
1474 let provider = ProviderConfig {
1475 name: "anthropic".to_string(),
1476 api_key: Some("sk-test".to_string()),
1477 base_url: Some("https://api.anthropic.com".to_string()),
1478 headers: HashMap::new(),
1479 session_id_header: None,
1480 models: vec![],
1481 };
1482 let json = serde_json::to_string(&provider).unwrap();
1483 assert!(json.contains("\"name\":\"anthropic\""));
1484 assert!(json.contains("\"apiKey\":\"sk-test\""));
1485 }
1486
1487 #[test]
1488 fn test_provider_config_deserialization_missing_optional() {
1489 let json = r#"{"name":"openai"}"#;
1490 let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1491 assert_eq!(provider.name, "openai");
1492 assert!(provider.api_key.is_none());
1493 assert!(provider.base_url.is_none());
1494 assert!(provider.models.is_empty());
1495 }
1496
1497 #[test]
1498 fn test_provider_config_find_model() {
1499 let provider = ProviderConfig {
1500 name: "anthropic".to_string(),
1501 api_key: None,
1502 base_url: None,
1503 headers: HashMap::new(),
1504 session_id_header: None,
1505 models: vec![ModelConfig {
1506 id: "claude-sonnet-4".to_string(),
1507 name: "Claude Sonnet 4".to_string(),
1508 family: "claude-sonnet".to_string(),
1509 api_key: None,
1510 base_url: None,
1511 headers: HashMap::new(),
1512 session_id_header: None,
1513 attachment: false,
1514 reasoning: false,
1515 tool_call: true,
1516 temperature: true,
1517 release_date: None,
1518 modalities: ModelModalities::default(),
1519 cost: ModelCost::default(),
1520 limit: ModelLimit::default(),
1521 }],
1522 };
1523
1524 let found = provider.find_model("claude-sonnet-4");
1525 assert!(found.is_some());
1526 assert_eq!(found.unwrap().id, "claude-sonnet-4");
1527
1528 let not_found = provider.find_model("gpt-4o");
1529 assert!(not_found.is_none());
1530 }
1531
1532 #[test]
1533 fn test_provider_config_get_api_key() {
1534 let provider = ProviderConfig {
1535 name: "anthropic".to_string(),
1536 api_key: Some("provider-key".to_string()),
1537 base_url: None,
1538 headers: HashMap::new(),
1539 session_id_header: None,
1540 models: vec![],
1541 };
1542
1543 let model_with_key = ModelConfig {
1544 id: "test".to_string(),
1545 name: "".to_string(),
1546 family: "".to_string(),
1547 api_key: Some("model-key".to_string()),
1548 base_url: None,
1549 headers: HashMap::new(),
1550 session_id_header: None,
1551 attachment: false,
1552 reasoning: false,
1553 tool_call: true,
1554 temperature: true,
1555 release_date: None,
1556 modalities: ModelModalities::default(),
1557 cost: ModelCost::default(),
1558 limit: ModelLimit::default(),
1559 };
1560
1561 let model_without_key = ModelConfig {
1562 id: "test2".to_string(),
1563 name: "".to_string(),
1564 family: "".to_string(),
1565 api_key: None,
1566 base_url: None,
1567 headers: HashMap::new(),
1568 session_id_header: None,
1569 attachment: false,
1570 reasoning: false,
1571 tool_call: true,
1572 temperature: true,
1573 release_date: None,
1574 modalities: ModelModalities::default(),
1575 cost: ModelCost::default(),
1576 limit: ModelLimit::default(),
1577 };
1578
1579 assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1580 assert_eq!(
1581 provider.get_api_key(&model_without_key),
1582 Some("provider-key")
1583 );
1584 }
1585
1586 #[test]
1587 fn test_provider_config_get_headers_and_session_id_header() {
1588 let mut provider_headers = HashMap::new();
1589 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1590 provider_headers.insert("X-Shared".to_string(), "provider".to_string());
1591
1592 let mut model_headers = HashMap::new();
1593 model_headers.insert("X-Model".to_string(), "model".to_string());
1594 model_headers.insert("X-Shared".to_string(), "model".to_string());
1595
1596 let provider = ProviderConfig {
1597 name: "openai".to_string(),
1598 api_key: Some("provider-key".to_string()),
1599 base_url: None,
1600 headers: provider_headers,
1601 session_id_header: Some("X-Session-Id".to_string()),
1602 models: vec![],
1603 };
1604
1605 let model = ModelConfig {
1606 id: "gpt-4o".to_string(),
1607 name: "".to_string(),
1608 family: "".to_string(),
1609 api_key: None,
1610 base_url: None,
1611 headers: model_headers,
1612 session_id_header: Some("X-Model-Session".to_string()),
1613 attachment: false,
1614 reasoning: false,
1615 tool_call: true,
1616 temperature: true,
1617 release_date: None,
1618 modalities: ModelModalities::default(),
1619 cost: ModelCost::default(),
1620 limit: ModelLimit::default(),
1621 };
1622
1623 let headers = provider.get_headers(&model);
1624 assert_eq!(headers.get("X-Provider"), Some(&"provider".to_string()));
1625 assert_eq!(headers.get("X-Model"), Some(&"model".to_string()));
1626 assert_eq!(headers.get("X-Shared"), Some(&"model".to_string()));
1627 assert_eq!(
1628 provider.get_session_id_header(&model),
1629 Some("X-Model-Session")
1630 );
1631 }
1632
1633 #[test]
1634 fn test_llm_config_includes_headers_and_runtime_session_header() {
1635 let mut provider_headers = HashMap::new();
1636 provider_headers.insert("X-Provider".to_string(), "provider".to_string());
1637
1638 let config = CodeConfig {
1639 default_model: Some("openai/gpt-4o".to_string()),
1640 providers: vec![ProviderConfig {
1641 name: "openai".to_string(),
1642 api_key: Some("sk-test".to_string()),
1643 base_url: Some("https://api.example.com".to_string()),
1644 headers: provider_headers,
1645 session_id_header: Some("X-Session-Id".to_string()),
1646 models: vec![ModelConfig {
1647 id: "gpt-4o".to_string(),
1648 name: "".to_string(),
1649 family: "".to_string(),
1650 api_key: None,
1651 base_url: None,
1652 headers: HashMap::new(),
1653 session_id_header: None,
1654 attachment: false,
1655 reasoning: false,
1656 tool_call: true,
1657 temperature: true,
1658 release_date: None,
1659 modalities: ModelModalities::default(),
1660 cost: ModelCost::default(),
1661 limit: ModelLimit::default(),
1662 }],
1663 }],
1664 ..Default::default()
1665 };
1666
1667 let llm_config = config.default_llm_config().unwrap();
1668 assert_eq!(
1669 llm_config.headers.get("X-Provider"),
1670 Some(&"provider".to_string())
1671 );
1672 assert_eq!(
1673 llm_config.session_id_header.as_deref(),
1674 Some("X-Session-Id")
1675 );
1676 }
1677
1678 #[test]
1679 fn test_code_config_default_provider_config() {
1680 let config = CodeConfig {
1681 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1682 providers: vec![ProviderConfig {
1683 name: "anthropic".to_string(),
1684 api_key: Some("sk-test".to_string()),
1685 base_url: None,
1686 headers: HashMap::new(),
1687 session_id_header: None,
1688 models: vec![],
1689 }],
1690 ..Default::default()
1691 };
1692
1693 let provider = config.default_provider_config();
1694 assert!(provider.is_some());
1695 assert_eq!(provider.unwrap().name, "anthropic");
1696 }
1697
1698 #[test]
1699 fn test_code_config_default_model_config() {
1700 let config = CodeConfig {
1701 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1702 providers: vec![ProviderConfig {
1703 name: "anthropic".to_string(),
1704 api_key: Some("sk-test".to_string()),
1705 base_url: None,
1706 headers: HashMap::new(),
1707 session_id_header: None,
1708 models: vec![ModelConfig {
1709 id: "claude-sonnet-4".to_string(),
1710 name: "Claude Sonnet 4".to_string(),
1711 family: "claude-sonnet".to_string(),
1712 api_key: None,
1713 base_url: None,
1714 headers: HashMap::new(),
1715 session_id_header: None,
1716 attachment: false,
1717 reasoning: false,
1718 tool_call: true,
1719 temperature: true,
1720 release_date: None,
1721 modalities: ModelModalities::default(),
1722 cost: ModelCost::default(),
1723 limit: ModelLimit::default(),
1724 }],
1725 }],
1726 ..Default::default()
1727 };
1728
1729 let result = config.default_model_config();
1730 assert!(result.is_some());
1731 let (provider, model) = result.unwrap();
1732 assert_eq!(provider.name, "anthropic");
1733 assert_eq!(model.id, "claude-sonnet-4");
1734 }
1735
1736 #[test]
1737 fn test_code_config_default_llm_config() {
1738 let config = CodeConfig {
1739 default_model: Some("anthropic/claude-sonnet-4".to_string()),
1740 providers: vec![ProviderConfig {
1741 name: "anthropic".to_string(),
1742 api_key: Some("sk-test".to_string()),
1743 base_url: Some("https://api.anthropic.com".to_string()),
1744 headers: HashMap::new(),
1745 session_id_header: None,
1746 models: vec![ModelConfig {
1747 id: "claude-sonnet-4".to_string(),
1748 name: "Claude Sonnet 4".to_string(),
1749 family: "claude-sonnet".to_string(),
1750 api_key: None,
1751 base_url: None,
1752 headers: HashMap::new(),
1753 session_id_header: None,
1754 attachment: false,
1755 reasoning: false,
1756 tool_call: true,
1757 temperature: true,
1758 release_date: None,
1759 modalities: ModelModalities::default(),
1760 cost: ModelCost::default(),
1761 limit: ModelLimit::default(),
1762 }],
1763 }],
1764 ..Default::default()
1765 };
1766
1767 let llm_config = config.default_llm_config();
1768 assert!(llm_config.is_some());
1769 }
1770
1771 #[test]
1772 fn test_code_config_list_models() {
1773 let config = CodeConfig {
1774 providers: vec![
1775 ProviderConfig {
1776 name: "anthropic".to_string(),
1777 api_key: None,
1778 base_url: None,
1779 headers: HashMap::new(),
1780 session_id_header: None,
1781 models: vec![ModelConfig {
1782 id: "claude-sonnet-4".to_string(),
1783 name: "".to_string(),
1784 family: "".to_string(),
1785 api_key: None,
1786 base_url: None,
1787 headers: HashMap::new(),
1788 session_id_header: None,
1789 attachment: false,
1790 reasoning: false,
1791 tool_call: true,
1792 temperature: true,
1793 release_date: None,
1794 modalities: ModelModalities::default(),
1795 cost: ModelCost::default(),
1796 limit: ModelLimit::default(),
1797 }],
1798 },
1799 ProviderConfig {
1800 name: "openai".to_string(),
1801 api_key: None,
1802 base_url: None,
1803 headers: HashMap::new(),
1804 session_id_header: None,
1805 models: vec![ModelConfig {
1806 id: "gpt-4o".to_string(),
1807 name: "".to_string(),
1808 family: "".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 ],
1824 ..Default::default()
1825 };
1826
1827 let models = config.list_models();
1828 assert_eq!(models.len(), 2);
1829 }
1830
1831 #[test]
1832 fn test_llm_config_specific_provider_model() {
1833 let model: ModelConfig = serde_json::from_value(serde_json::json!({
1834 "id": "claude-3",
1835 "name": "Claude 3"
1836 }))
1837 .unwrap();
1838
1839 let config = CodeConfig {
1840 providers: vec![ProviderConfig {
1841 name: "anthropic".to_string(),
1842 api_key: Some("sk-test".to_string()),
1843 base_url: None,
1844 headers: HashMap::new(),
1845 session_id_header: None,
1846 models: vec![model],
1847 }],
1848 ..Default::default()
1849 };
1850
1851 let llm = config.llm_config("anthropic", "claude-3");
1852 assert!(llm.is_some());
1853 let llm = llm.unwrap();
1854 assert_eq!(llm.provider, "anthropic");
1855 assert_eq!(llm.model, "claude-3");
1856 }
1857
1858 #[test]
1859 fn test_llm_config_missing_provider() {
1860 let config = CodeConfig::default();
1861 assert!(config.llm_config("nonexistent", "model").is_none());
1862 }
1863
1864 #[test]
1865 fn test_llm_config_missing_model() {
1866 let config = CodeConfig {
1867 providers: vec![ProviderConfig {
1868 name: "anthropic".to_string(),
1869 api_key: Some("sk-test".to_string()),
1870 base_url: None,
1871 headers: HashMap::new(),
1872 session_id_header: None,
1873 models: vec![],
1874 }],
1875 ..Default::default()
1876 };
1877 assert!(config.llm_config("anthropic", "nonexistent").is_none());
1878 }
1879
1880 #[test]
1881 fn test_from_hcl_string() {
1882 let hcl = r#"
1883 default_model = "anthropic/claude-sonnet-4"
1884
1885 providers {
1886 name = "anthropic"
1887 api_key = "test-key"
1888
1889 models {
1890 id = "claude-sonnet-4"
1891 name = "Claude Sonnet 4"
1892 }
1893 }
1894 "#;
1895
1896 let config = CodeConfig::from_hcl(hcl).unwrap();
1897 assert_eq!(
1898 config.default_model,
1899 Some("anthropic/claude-sonnet-4".to_string())
1900 );
1901 assert_eq!(config.providers.len(), 1);
1902 assert_eq!(config.providers[0].name, "anthropic");
1903 assert_eq!(config.providers[0].models.len(), 1);
1904 assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1905 }
1906
1907 #[test]
1908 fn test_from_hcl_multi_provider() {
1909 let hcl = r#"
1910 default_model = "anthropic/claude-sonnet-4"
1911
1912 providers {
1913 name = "anthropic"
1914 api_key = "sk-ant-test"
1915
1916 models {
1917 id = "claude-sonnet-4"
1918 name = "Claude Sonnet 4"
1919 }
1920
1921 models {
1922 id = "claude-opus-4"
1923 name = "Claude Opus 4"
1924 reasoning = true
1925 }
1926 }
1927
1928 providers {
1929 name = "openai"
1930 api_key = "sk-test"
1931
1932 models {
1933 id = "gpt-4o"
1934 name = "GPT-4o"
1935 }
1936 }
1937 "#;
1938
1939 let config = CodeConfig::from_hcl(hcl).unwrap();
1940 assert_eq!(config.providers.len(), 2);
1941 assert_eq!(config.providers[0].models.len(), 2);
1942 assert_eq!(config.providers[1].models.len(), 1);
1943 assert_eq!(config.providers[1].name, "openai");
1944 }
1945
1946 #[test]
1947 fn test_snake_to_camel() {
1948 assert_eq!(snake_to_camel("default_model"), "defaultModel");
1949 assert_eq!(snake_to_camel("api_key"), "apiKey");
1950 assert_eq!(snake_to_camel("base_url"), "baseUrl");
1951 assert_eq!(snake_to_camel("name"), "name");
1952 assert_eq!(snake_to_camel("tool_call"), "toolCall");
1953 }
1954
1955 #[test]
1956 fn test_from_file_auto_detect_hcl() {
1957 let temp_dir = tempfile::tempdir().unwrap();
1958 let config_path = temp_dir.path().join("config.hcl");
1959
1960 std::fs::write(
1961 &config_path,
1962 r#"
1963 default_model = "anthropic/claude-sonnet-4"
1964
1965 providers {
1966 name = "anthropic"
1967 api_key = "test-key"
1968
1969 models {
1970 id = "claude-sonnet-4"
1971 }
1972 }
1973 "#,
1974 )
1975 .unwrap();
1976
1977 let config = CodeConfig::from_file(&config_path).unwrap();
1978 assert_eq!(
1979 config.default_model,
1980 Some("anthropic/claude-sonnet-4".to_string())
1981 );
1982 }
1983
1984 #[test]
1985 fn test_from_hcl_with_queue_config() {
1986 let hcl = r#"
1987 default_model = "anthropic/claude-sonnet-4"
1988
1989 providers {
1990 name = "anthropic"
1991 api_key = "test-key"
1992 }
1993
1994 queue {
1995 query_max_concurrency = 20
1996 execute_max_concurrency = 5
1997 enable_metrics = true
1998 enable_dlq = true
1999 }
2000 "#;
2001
2002 let config = CodeConfig::from_hcl(hcl).unwrap();
2003 assert!(config.queue.is_some());
2004 let queue = config.queue.unwrap();
2005 assert_eq!(queue.query_max_concurrency, 20);
2006 assert_eq!(queue.execute_max_concurrency, 5);
2007 assert!(queue.enable_metrics);
2008 assert!(queue.enable_dlq);
2009 }
2010
2011 #[test]
2012 fn test_from_hcl_with_search_config() {
2013 let hcl = r#"
2014 default_model = "anthropic/claude-sonnet-4"
2015
2016 providers {
2017 name = "anthropic"
2018 api_key = "test-key"
2019 }
2020
2021 search {
2022 timeout = 30
2023
2024 health {
2025 max_failures = 5
2026 suspend_seconds = 120
2027 }
2028
2029 engine {
2030 google {
2031 enabled = true
2032 weight = 1.5
2033 }
2034 bing {
2035 enabled = true
2036 weight = 1.0
2037 timeout = 15
2038 }
2039 }
2040 }
2041 "#;
2042
2043 let config = CodeConfig::from_hcl(hcl).unwrap();
2044 assert!(config.search.is_some());
2045 let search = config.search.unwrap();
2046 assert_eq!(search.timeout, 30);
2047 assert!(search.health.is_some());
2048 let health = search.health.unwrap();
2049 assert_eq!(health.max_failures, 5);
2050 assert_eq!(health.suspend_seconds, 120);
2051 assert_eq!(search.engines.len(), 2);
2052 assert!(search.engines.contains_key("google"));
2053 assert!(search.engines.contains_key("bing"));
2054 let google = &search.engines["google"];
2055 assert!(google.enabled);
2056 assert_eq!(google.weight, 1.5);
2057 let bing = &search.engines["bing"];
2058 assert_eq!(bing.timeout, Some(15));
2059 }
2060
2061 #[test]
2062 fn test_from_hcl_with_queue_and_search() {
2063 let hcl = r#"
2064 default_model = "anthropic/claude-sonnet-4"
2065
2066 providers {
2067 name = "anthropic"
2068 api_key = "test-key"
2069 }
2070
2071 queue {
2072 query_max_concurrency = 10
2073 enable_metrics = true
2074 }
2075
2076 search {
2077 timeout = 20
2078 engine {
2079 duckduckgo {
2080 enabled = true
2081 }
2082 }
2083 }
2084 "#;
2085
2086 let config = CodeConfig::from_hcl(hcl).unwrap();
2087 assert!(config.queue.is_some());
2088 assert!(config.search.is_some());
2089 assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
2090 assert_eq!(config.search.unwrap().timeout, 20);
2091 }
2092
2093 #[test]
2094 fn test_from_hcl_multiple_mcp_servers() {
2095 let hcl = r#"
2096 mcp_servers {
2097 name = "fetch"
2098 transport = "stdio"
2099 command = "npx"
2100 args = ["-y", "@modelcontextprotocol/server-fetch"]
2101 enabled = true
2102 }
2103
2104 mcp_servers {
2105 name = "puppeteer"
2106 transport = "stdio"
2107 command = "npx"
2108 args = ["-y", "@anthropic/mcp-server-puppeteer"]
2109 enabled = true
2110 }
2111
2112 mcp_servers {
2113 name = "filesystem"
2114 transport = "stdio"
2115 command = "npx"
2116 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2117 enabled = false
2118 }
2119 "#;
2120
2121 let config = CodeConfig::from_hcl(hcl).unwrap();
2122 assert_eq!(
2123 config.mcp_servers.len(),
2124 3,
2125 "all 3 mcp_servers blocks should be parsed"
2126 );
2127 assert_eq!(config.mcp_servers[0].name, "fetch");
2128 assert_eq!(config.mcp_servers[1].name, "puppeteer");
2129 assert_eq!(config.mcp_servers[2].name, "filesystem");
2130 assert!(config.mcp_servers[0].enabled);
2131 assert!(!config.mcp_servers[2].enabled);
2132 }
2133
2134 #[test]
2135 fn test_from_hcl_with_advanced_queue_config() {
2136 let hcl = r#"
2137 default_model = "anthropic/claude-sonnet-4"
2138
2139 providers {
2140 name = "anthropic"
2141 api_key = "test-key"
2142 }
2143
2144 queue {
2145 query_max_concurrency = 20
2146 enable_metrics = true
2147
2148 retry_policy {
2149 strategy = "exponential"
2150 max_retries = 5
2151 initial_delay_ms = 200
2152 }
2153
2154 rate_limit {
2155 limit_type = "per_second"
2156 max_operations = 100
2157 }
2158
2159 priority_boost {
2160 strategy = "standard"
2161 deadline_ms = 300000
2162 }
2163
2164 pressure_threshold = 50
2165 }
2166 "#;
2167
2168 let config = CodeConfig::from_hcl(hcl).unwrap();
2169 assert!(config.queue.is_some());
2170 let queue = config.queue.unwrap();
2171
2172 assert_eq!(queue.query_max_concurrency, 20);
2173 assert!(queue.enable_metrics);
2174
2175 assert!(queue.retry_policy.is_some());
2177 let retry = queue.retry_policy.unwrap();
2178 assert_eq!(retry.strategy, "exponential");
2179 assert_eq!(retry.max_retries, 5);
2180 assert_eq!(retry.initial_delay_ms, 200);
2181
2182 assert!(queue.rate_limit.is_some());
2184 let rate = queue.rate_limit.unwrap();
2185 assert_eq!(rate.limit_type, "per_second");
2186 assert_eq!(rate.max_operations, Some(100));
2187
2188 assert!(queue.priority_boost.is_some());
2190 let boost = queue.priority_boost.unwrap();
2191 assert_eq!(boost.strategy, "standard");
2192 assert_eq!(boost.deadline_ms, Some(300000));
2193
2194 assert_eq!(queue.pressure_threshold, Some(50));
2196 }
2197
2198 #[test]
2199 fn test_hcl_env_function_resolved() {
2200 std::env::set_var("A3S_TEST_HCL_KEY", "test-secret-key-123");
2202
2203 let hcl_str = r#"
2204 providers {
2205 name = "test"
2206 api_key = env("A3S_TEST_HCL_KEY")
2207 }
2208 "#;
2209
2210 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2211 let json = hcl_body_to_json(&body);
2212
2213 let providers = json.get("providers").unwrap();
2215 let provider = providers.as_array().unwrap().first().unwrap();
2216 let api_key = provider.get("apiKey").unwrap();
2217
2218 assert_eq!(api_key.as_str().unwrap(), "test-secret-key-123");
2219
2220 std::env::remove_var("A3S_TEST_HCL_KEY");
2222 }
2223
2224 #[test]
2225 fn test_hcl_env_function_unset_returns_null() {
2226 std::env::remove_var("A3S_TEST_NONEXISTENT_VAR_12345");
2228
2229 let hcl_str = r#"
2230 providers {
2231 name = "test"
2232 api_key = env("A3S_TEST_NONEXISTENT_VAR_12345")
2233 }
2234 "#;
2235
2236 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2237 let json = hcl_body_to_json(&body);
2238
2239 let providers = json.get("providers").unwrap();
2240 let provider = providers.as_array().unwrap().first().unwrap();
2241 let api_key = provider.get("apiKey").unwrap();
2242
2243 assert!(api_key.is_null(), "Unset env var should return null");
2244 }
2245
2246 #[test]
2247 fn test_hcl_mcp_env_block_preserves_var_names() {
2248 std::env::set_var("A3S_TEST_SECRET", "my-secret");
2250
2251 let hcl_str = r#"
2252 mcp_servers {
2253 name = "test-server"
2254 transport = "stdio"
2255 command = "echo"
2256 env = {
2257 API_KEY = "sk-test-123"
2258 ANTHROPIC_API_KEY = env("A3S_TEST_SECRET")
2259 SIMPLE = "value"
2260 }
2261 }
2262 "#;
2263
2264 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2265 let json = hcl_body_to_json(&body);
2266
2267 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2268 let server = &servers[0];
2269 let env = server.get("env").unwrap().as_object().unwrap();
2270
2271 assert_eq!(env.get("API_KEY").unwrap().as_str().unwrap(), "sk-test-123");
2273 assert_eq!(
2274 env.get("ANTHROPIC_API_KEY").unwrap().as_str().unwrap(),
2275 "my-secret"
2276 );
2277 assert_eq!(env.get("SIMPLE").unwrap().as_str().unwrap(), "value");
2278
2279 assert!(
2281 env.get("apiKey").is_none(),
2282 "env var key should not be camelCase'd"
2283 );
2284 assert!(
2285 env.get("APIKEY").is_none(),
2286 "env var key should not have underscores stripped"
2287 );
2288 assert!(env.get("anthropicApiKey").is_none());
2289
2290 std::env::remove_var("A3S_TEST_SECRET");
2291 }
2292
2293 #[test]
2294 fn test_hcl_mcp_env_as_block_syntax() {
2295 let hcl_str = r#"
2297 mcp_servers {
2298 name = "test-server"
2299 transport = "stdio"
2300 command = "echo"
2301 env {
2302 MY_VAR = "hello"
2303 OTHER_VAR = "world"
2304 }
2305 }
2306 "#;
2307
2308 let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
2309 let json = hcl_body_to_json(&body);
2310
2311 let servers = json.get("mcpServers").unwrap().as_array().unwrap();
2312 let server = &servers[0];
2313 let env = server.get("env").unwrap().as_object().unwrap();
2314
2315 assert_eq!(env.get("MY_VAR").unwrap().as_str().unwrap(), "hello");
2316 assert_eq!(env.get("OTHER_VAR").unwrap().as_str().unwrap(), "world");
2317 assert!(
2318 env.get("myVar").is_none(),
2319 "block env keys should not be camelCase'd"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_hcl_mcp_full_deserialization_with_env() {
2325 std::env::set_var("A3S_TEST_MCP_KEY", "resolved-secret");
2327
2328 let hcl_str = r#"
2329 mcp_servers {
2330 name = "fetch"
2331 transport = "stdio"
2332 command = "npx"
2333 args = ["-y", "@modelcontextprotocol/server-fetch"]
2334 env = {
2335 NODE_ENV = "production"
2336 API_KEY = env("A3S_TEST_MCP_KEY")
2337 }
2338 tool_timeout_secs = 120
2339 }
2340 "#;
2341
2342 let config = CodeConfig::from_hcl(hcl_str).unwrap();
2343 assert_eq!(config.mcp_servers.len(), 1);
2344
2345 let server = &config.mcp_servers[0];
2346 assert_eq!(server.name, "fetch");
2347 assert_eq!(server.env.get("NODE_ENV").unwrap(), "production");
2348 assert_eq!(server.env.get("API_KEY").unwrap(), "resolved-secret");
2349 assert_eq!(server.tool_timeout_secs, 120);
2350
2351 std::env::remove_var("A3S_TEST_MCP_KEY");
2352 }
2353
2354 #[test]
2355 fn test_hcl_document_tool_config_parses() {
2356 let hcl = r#"
2357 agentic_search {
2358 enabled = false
2359 default_mode = "deep"
2360 max_results = 7
2361 context_lines = 4
2362 }
2363
2364 agentic_parse {
2365 enabled = true
2366 default_strategy = "structured"
2367 max_chars = 12000
2368 }
2369
2370 document_parser {
2371 enabled = true
2372 max_file_size_mb = 64
2373
2374 ocr {
2375 enabled = true
2376 model = "openai/gpt-4.1-mini"
2377 prompt = "Extract text from scanned pages."
2378 max_images = 6
2379 dpi = 200
2380 }
2381 }
2382 "#;
2383
2384 let config = CodeConfig::from_hcl(hcl).unwrap();
2385 let search = config.agentic_search.unwrap();
2386 let parse = config.agentic_parse.unwrap();
2387 let document_parser = config.document_parser.unwrap();
2388
2389 assert!(!search.enabled);
2390 assert_eq!(search.default_mode, "deep");
2391 assert_eq!(search.max_results, 7);
2392 assert_eq!(search.context_lines, 4);
2393
2394 assert!(parse.enabled);
2395 assert_eq!(parse.default_strategy, "structured");
2396 assert_eq!(parse.max_chars, 12000);
2397
2398 assert!(document_parser.enabled);
2399 assert_eq!(document_parser.max_file_size_mb, 64);
2400 let ocr = document_parser.ocr.unwrap();
2401 assert!(ocr.enabled);
2402 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2403 assert_eq!(
2404 ocr.prompt.as_deref(),
2405 Some("Extract text from scanned pages.")
2406 );
2407 assert_eq!(ocr.max_images, 6);
2408 assert_eq!(ocr.dpi, 200);
2409 }
2410
2411 #[test]
2412 fn test_hcl_document_parser_parses() {
2413 let hcl = r#"
2414 document_parser {
2415 enabled = true
2416 max_file_size_mb = 48
2417 cache {
2418 enabled = true
2419 directory = "/tmp/a3s-doc-cache"
2420 }
2421
2422 ocr {
2423 enabled = true
2424 model = "openai/gpt-4.1-mini"
2425 prompt = "Read scanned tables."
2426 max_images = 5
2427 dpi = 180
2428 }
2429 }
2430 "#;
2431
2432 let config = CodeConfig::from_hcl(hcl).unwrap();
2433 let parser = config.document_parser.unwrap();
2434
2435 assert!(parser.enabled);
2436 assert_eq!(parser.max_file_size_mb, 48);
2437 let cache = parser.cache.unwrap();
2438 assert!(cache.enabled);
2439 assert_eq!(
2440 cache.directory.as_deref(),
2441 Some(std::path::Path::new("/tmp/a3s-doc-cache"))
2442 );
2443 let ocr = parser.ocr.unwrap();
2444 assert!(ocr.enabled);
2445 assert_eq!(ocr.model.as_deref(), Some("openai/gpt-4.1-mini"));
2446 assert_eq!(ocr.prompt.as_deref(), Some("Read scanned tables."));
2447 assert_eq!(ocr.max_images, 5);
2448 assert_eq!(ocr.dpi, 180);
2449 }
2450
2451 #[test]
2452 fn test_agentic_search_config_normalizes_invalid_values() {
2453 let config = AgenticSearchConfig {
2454 enabled: true,
2455 default_mode: "weird".to_string(),
2456 max_results: 0,
2457 context_lines: 999,
2458 }
2459 .normalized();
2460
2461 assert_eq!(config.default_mode, "fast");
2462 assert_eq!(config.max_results, 1);
2463 assert_eq!(config.context_lines, 20);
2464 }
2465
2466 #[test]
2467 fn test_agentic_parse_config_normalizes_invalid_values() {
2468 let config = AgenticParseConfig {
2469 enabled: true,
2470 default_strategy: "unknown".to_string(),
2471 max_chars: 1,
2472 }
2473 .normalized();
2474
2475 assert_eq!(config.default_strategy, "auto");
2476 assert_eq!(config.max_chars, 500);
2477 }
2478
2479 #[test]
2480 fn test_document_parser_config_normalizes_nested_ocr_values() {
2481 let config = DocumentParserConfig {
2482 enabled: true,
2483 max_file_size_mb: 0,
2484 cache: Some(DocumentCacheConfig {
2485 enabled: true,
2486 directory: Some(PathBuf::from("/tmp/cache")),
2487 }),
2488 ocr: Some(DocumentOcrConfig {
2489 enabled: true,
2490 model: Some("openai/gpt-4.1-mini".to_string()),
2491 prompt: None,
2492 max_images: 0,
2493 dpi: 10,
2494 provider: None,
2495 base_url: None,
2496 api_key: None,
2497 }),
2498 }
2499 .normalized();
2500
2501 assert_eq!(config.max_file_size_mb, 1);
2502 let cache = config.cache.unwrap();
2503 assert!(cache.enabled);
2504 assert_eq!(cache.directory, Some(PathBuf::from("/tmp/cache")));
2505 let ocr = config.ocr.unwrap();
2506 assert_eq!(ocr.max_images, 1);
2507 assert_eq!(ocr.dpi, 72);
2508 }
2509}