Skip to main content

a3s_code_core/
config.rs

1//! Configuration module for A3S Code
2//!
3//! Provides configuration for:
4//! - LLM providers and models (defaultModel in "provider/model" format, providers)
5//! - Queue configuration (a3s-lane integration)
6//! - Search configuration (a3s-search integration)
7//! - Directories for dynamic skill and agent loading
8//!
9//! Configuration is loaded from HCL files or HCL strings only.
10//! JSON support has been removed.
11
12use 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// ============================================================================
21// Provider Configuration
22// ============================================================================
23
24/// Model cost information (per million tokens)
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26#[serde(rename_all = "camelCase")]
27pub struct ModelCost {
28    /// Input token cost
29    #[serde(default)]
30    pub input: f64,
31    /// Output token cost
32    #[serde(default)]
33    pub output: f64,
34    /// Cache read cost
35    #[serde(default)]
36    pub cache_read: f64,
37    /// Cache write cost
38    #[serde(default)]
39    pub cache_write: f64,
40}
41
42/// Model limits
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct ModelLimit {
45    /// Maximum context tokens
46    #[serde(default)]
47    pub context: u32,
48    /// Maximum output tokens
49    #[serde(default)]
50    pub output: u32,
51}
52
53/// Model modalities (input/output types)
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct ModelModalities {
56    /// Supported input types
57    #[serde(default)]
58    pub input: Vec<String>,
59    /// Supported output types
60    #[serde(default)]
61    pub output: Vec<String>,
62}
63
64/// Model configuration
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ModelConfig {
68    /// Model ID (e.g., "claude-sonnet-4-20250514")
69    pub id: String,
70    /// Display name
71    #[serde(default)]
72    pub name: String,
73    /// Model family (e.g., "claude-sonnet")
74    #[serde(default)]
75    pub family: String,
76    /// Per-model API key override
77    #[serde(default)]
78    pub api_key: Option<String>,
79    /// Per-model base URL override
80    #[serde(default)]
81    pub base_url: Option<String>,
82    /// Static HTTP headers for this model
83    #[serde(default)]
84    pub headers: HashMap<String, String>,
85    /// Header name to receive the runtime session ID
86    #[serde(default)]
87    pub session_id_header: Option<String>,
88    /// Supports file attachments
89    #[serde(default)]
90    pub attachment: bool,
91    /// Supports reasoning/thinking
92    #[serde(default)]
93    pub reasoning: bool,
94    /// Supports tool calling
95    #[serde(default = "default_true")]
96    pub tool_call: bool,
97    /// Supports temperature setting
98    #[serde(default = "default_true")]
99    pub temperature: bool,
100    /// Release date
101    #[serde(default)]
102    pub release_date: Option<String>,
103    /// Input/output modalities
104    #[serde(default)]
105    pub modalities: ModelModalities,
106    /// Cost information
107    #[serde(default)]
108    pub cost: ModelCost,
109    /// Token limits
110    #[serde(default)]
111    pub limit: ModelLimit,
112}
113
114fn default_true() -> bool {
115    true
116}
117
118/// Provider configuration
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ProviderConfig {
122    /// Provider name (e.g., "anthropic", "openai")
123    pub name: String,
124    /// API key for this provider
125    #[serde(default)]
126    pub api_key: Option<String>,
127    /// Base URL for the API
128    #[serde(default)]
129    pub base_url: Option<String>,
130    /// Static HTTP headers for this provider
131    #[serde(default)]
132    pub headers: HashMap<String, String>,
133    /// Header name to receive the runtime session ID
134    #[serde(default)]
135    pub session_id_header: Option<String>,
136    /// Available models
137    #[serde(default)]
138    pub models: Vec<ModelConfig>,
139}
140
141/// Apply model capability flags to an LlmConfig.
142///
143/// - `temperature = false` → omit temperature (model ignores it, e.g. o1)
144/// - `reasoning = true` + `thinking_budget` set → pass budget to client
145/// - `limit.output > 0` → use as max_tokens
146fn apply_model_caps(
147    mut config: LlmConfig,
148    model: &ModelConfig,
149    thinking_budget: Option<usize>,
150) -> LlmConfig {
151    // reasoning=true + thinking_budget set → pass budget to client (Anthropic only)
152    if model.reasoning {
153        if let Some(budget) = thinking_budget {
154            config = config.with_thinking_budget(budget);
155        }
156    }
157
158    // limit.output > 0 → use as max_tokens cap
159    if model.limit.output > 0 {
160        config = config.with_max_tokens(model.limit.output as usize);
161    }
162
163    // temperature=false models (e.g. o1) must not receive a temperature param.
164    // Store the flag so the LLM client can gate it at call time.
165    if !model.temperature {
166        config.disable_temperature = true;
167    }
168
169    config
170}
171
172impl ProviderConfig {
173    /// Find a model by ID
174    pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
175        self.models.iter().find(|m| m.id == model_id)
176    }
177
178    /// Get the effective API key for a model (model override or provider default)
179    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    /// Get the effective base URL for a model (model override or provider default)
184    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    /// Get the effective static headers for a model (provider defaults with model overrides)
189    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    /// Get the header name that should carry the runtime session ID.
196    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// ============================================================================
205// Storage Configuration
206// ============================================================================
207
208/// Session storage backend type
209#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
210#[serde(rename_all = "lowercase")]
211pub enum StorageBackend {
212    /// In-memory storage (no persistence)
213    Memory,
214    /// File-based storage (JSON files)
215    #[default]
216    File,
217    /// Custom external storage (Redis, PostgreSQL, etc.)
218    ///
219    /// Requires a `SessionStore` implementation registered via `SessionManager::with_store()`.
220    /// Use `storage_url` in config to pass connection details.
221    Custom,
222}
223
224// ============================================================================
225// Main Configuration
226// ============================================================================
227
228/// Configuration for A3S Code
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230#[serde(rename_all = "camelCase")]
231pub struct CodeConfig {
232    /// Default model in "provider/model" format (e.g., "anthropic/claude-sonnet-4-20250514")
233    #[serde(default, alias = "default_model")]
234    pub default_model: Option<String>,
235
236    /// Provider configurations
237    #[serde(default)]
238    pub providers: Vec<ProviderConfig>,
239
240    /// Session storage backend
241    #[serde(default)]
242    pub storage_backend: StorageBackend,
243
244    /// Sessions directory (for file backend)
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub sessions_dir: Option<PathBuf>,
247
248    /// Connection URL for custom storage backend (e.g., "redis://localhost:6379", "postgres://user:pass@localhost/a3s")
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub storage_url: Option<String>,
251
252    /// Directories to scan for skill files (*.md with tool definitions)
253    #[serde(default, alias = "skill_dirs")]
254    pub skill_dirs: Vec<PathBuf>,
255
256    /// Directories to scan for agent files (*.yaml or *.md)
257    #[serde(default, alias = "agent_dirs")]
258    pub agent_dirs: Vec<PathBuf>,
259
260    /// Maximum tool execution rounds per turn (default: 25)
261    #[serde(default, alias = "max_tool_rounds")]
262    pub max_tool_rounds: Option<usize>,
263
264    /// Thinking/reasoning budget in tokens
265    #[serde(default, alias = "thinking_budget")]
266    pub thinking_budget: Option<usize>,
267
268    /// Memory system configuration
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub memory: Option<MemoryConfig>,
271
272    /// Queue configuration (a3s-lane integration)
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub queue: Option<crate::queue::SessionQueueConfig>,
275
276    /// Search configuration (a3s-search integration)
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub search: Option<SearchConfig>,
279
280    /// Agentic search tool configuration.
281    #[serde(
282        default,
283        alias = "agentic_search",
284        skip_serializing_if = "Option::is_none"
285    )]
286    pub agentic_search: Option<AgenticSearchConfig>,
287
288    /// Agentic parse tool configuration.
289    #[serde(
290        default,
291        alias = "agentic_parse",
292        skip_serializing_if = "Option::is_none"
293    )]
294    pub agentic_parse: Option<AgenticParseConfig>,
295
296    /// Built-in document context extraction configuration.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub document_parser: Option<DocumentParserConfig>,
299
300    /// MCP server configurations
301    #[serde(default, alias = "mcp_servers")]
302    pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
303}
304
305/// Search engine configuration (a3s-search integration)
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct SearchConfig {
309    /// Default timeout in seconds for all engines
310    #[serde(default = "default_search_timeout")]
311    pub timeout: u64,
312
313    /// Health monitor configuration
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub health: Option<SearchHealthConfig>,
316
317    /// Engine configurations
318    #[serde(default, rename = "engine")]
319    pub engines: std::collections::HashMap<String, SearchEngineConfig>,
320
321    /// Headless browser configuration for JS-rendered engines (google, baidu, bing_cn).
322    /// When enabled, the browser binary is auto-detected or downloaded.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub headless: Option<HeadlessConfig>,
325}
326
327/// Headless browser backend selection.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum BrowserBackend {
331    /// Chrome/Chromium headless. Auto-detected or downloaded from Google.
332    Chrome,
333    /// Lightpanda headless browser. Auto-detected or downloaded from GitHub.
334    /// Supported on Linux and macOS only.
335    Lightpanda,
336}
337
338#[allow(clippy::derivable_impls)]
339impl Default for BrowserBackend {
340    fn default() -> Self {
341        BrowserBackend::Chrome
342    }
343}
344
345/// Headless browser configuration.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct HeadlessConfig {
349    /// Which headless backend to use.
350    #[serde(default)]
351    pub backend: BrowserBackend,
352
353    /// Path to the browser executable. If None, auto-detected or downloaded.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub browser_path: Option<String>,
356
357    /// Maximum number of concurrent browser tabs.
358    #[serde(default = "default_headless_max_tabs")]
359    pub max_tabs: usize,
360
361    /// Additional launch arguments for the browser.
362    #[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/// Default configuration for the built-in `agentic_search` tool.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct AgenticSearchConfig {
381    /// Whether the tool is registered by default.
382    #[serde(default = "default_enabled")]
383    pub enabled: bool,
384
385    /// Default search mode when tool input omits `mode`.
386    #[serde(default = "default_agentic_search_mode")]
387    pub default_mode: String,
388
389    /// Default max results when tool input omits `max_results`.
390    #[serde(default = "default_agentic_search_max_results")]
391    pub max_results: usize,
392
393    /// Default context lines when tool input omits `context_lines`.
394    #[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/// Default configuration for the built-in `agentic_parse` tool.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(rename_all = "camelCase")]
430pub struct AgenticParseConfig {
431    /// Whether the tool is registered by default.
432    #[serde(default = "default_enabled")]
433    pub enabled: bool,
434
435    /// Default parse strategy when tool input omits `strategy`.
436    #[serde(default = "default_agentic_parse_strategy")]
437    pub default_strategy: String,
438
439    /// Default maximum characters sent to the LLM when tool input omits `max_chars`.
440    #[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/// Default configuration for built-in document context extraction.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(rename_all = "camelCase")]
476pub struct DocumentParserConfig {
477    /// Whether the default document extraction stack is registered in the parser registry.
478    #[serde(default = "default_enabled")]
479    pub enabled: bool,
480
481    /// Maximum file size accepted by the parser, in MiB.
482    #[serde(default = "default_document_parser_max_file_size_mb")]
483    pub max_file_size_mb: u64,
484
485    /// Optional OCR / vision-model settings for image-heavy documents.
486    ///
487    /// These settings control OCR fallback when context extraction reaches
488    /// scanned or image-heavy inputs. Current parsers may not execute OCR for
489    /// every format.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub ocr: Option<DocumentOcrConfig>,
492
493    /// Optional cache settings for parsed / normalized document context.
494    #[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/// OCR / vision-model configuration for built-in document context extraction.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct DocumentOcrConfig {
552    /// Whether OCR fallback is enabled for image-heavy documents.
553    #[serde(default = "default_enabled")]
554    pub enabled: bool,
555
556    /// Vision-capable model identifier, for example `openai/gpt-4.1-mini`.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub model: Option<String>,
559
560    /// Optional custom OCR prompt / extraction instruction.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub prompt: Option<String>,
563
564    /// Maximum number of rendered images/pages to send for OCR fallback.
565    #[serde(default = "default_document_ocr_max_images")]
566    pub max_images: usize,
567
568    /// Render DPI when rasterizing pages for OCR fallback.
569    #[serde(default = "default_document_ocr_dpi")]
570    pub dpi: u32,
571
572    /// OCR provider backend. Defaults to "vision" when model is set.
573    /// "vision" - Vision API (OpenAI-compatible)
574    /// "builtin" - Local tesseract (requires tesseract + pdftoppm binaries)
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub provider: Option<String>,
577
578    /// Base URL for vision API. Defaults to OpenAI API if not set.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub base_url: Option<String>,
581
582    /// API key for vision API.
583    #[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/// Search health monitor configuration
618#[derive(Debug, Clone, Serialize, Deserialize)]
619#[serde(rename_all = "camelCase")]
620pub struct SearchHealthConfig {
621    /// Number of consecutive failures before suspending
622    #[serde(default = "default_max_failures")]
623    pub max_failures: u32,
624
625    /// Suspension duration in seconds
626    #[serde(default = "default_suspend_seconds")]
627    pub suspend_seconds: u64,
628}
629
630/// Per-engine search configuration
631#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(rename_all = "camelCase")]
633pub struct SearchEngineConfig {
634    /// Whether the engine is enabled
635    #[serde(default = "default_enabled")]
636    pub enabled: bool,
637
638    /// Weight for ranking (higher = more influence)
639    #[serde(default = "default_weight")]
640    pub weight: f64,
641
642    /// Per-engine timeout override in seconds
643    #[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    /// Create a new empty configuration
705    pub fn new() -> Self {
706        Self::default()
707    }
708
709    /// Load configuration from an HCL file.
710    ///
711    /// Only `.hcl` files are supported. JSON support has been removed.
712    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    /// Parse configuration from an HCL string.
731    ///
732    /// HCL attributes use `snake_case` which is converted to `camelCase` for
733    /// serde deserialization. Repeated blocks (e.g., `providers`, `models`)
734    /// are collected into JSON arrays.
735    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    /// Save configuration to a JSON file (used for persistence)
744    ///
745    /// Note: This saves as JSON format. To use HCL format, manually create .hcl files.
746    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    /// Find a provider by name
772    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
773        self.providers.iter().find(|p| p.name == name)
774    }
775
776    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
777    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    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
784    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    /// Get LlmConfig for the default provider and model
793    ///
794    /// Returns None if default provider/model is not configured or API key is missing.
795    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    /// Get LlmConfig for a specific provider and model
817    ///
818    /// Returns None if provider/model is not found or API key is missing.
819    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    /// List all available models across all providers
842    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    /// Add a skill directory
850    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
851        self.skill_dirs.push(dir.into());
852        self
853    }
854
855    /// Add an agent directory
856    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
857        self.agent_dirs.push(dir.into());
858        self
859    }
860
861    /// Check if any directories are configured
862    pub fn has_directories(&self) -> bool {
863        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
864    }
865
866    /// Check if provider configuration is available
867    pub fn has_providers(&self) -> bool {
868        !self.providers.is_empty()
869    }
870}
871
872// ============================================================================
873// HCL Parsing Helpers
874// ============================================================================
875
876/// Block labels that should be collected into JSON arrays.
877const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
878
879/// Block identifiers whose body attribute keys should be preserved verbatim
880/// (not converted to camelCase). These blocks contain user-defined key-value maps
881/// like environment variables or HTTP headers, not struct field names.
882const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
883
884/// Convert an HCL body into a JSON value with camelCase keys.
885fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
886    hcl_body_to_json_inner(body, false)
887}
888
889/// Inner conversion with `verbatim_keys` flag.
890///
891/// When `verbatim_keys` is true, attribute keys are preserved as-is
892/// (used for blocks like `env { ... }` where keys are user data).
893fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
894    let mut map = serde_json::Map::new();
895
896    // Process attributes (key = value)
897    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    // Process blocks (repeated structures like `providers { ... }`)
908    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        // Blocks in HCL_VERBATIM_BLOCKS contain user-defined maps, not struct fields
915        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            // Collect into array
920            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
934/// Convert snake_case to camelCase.
935fn 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
951/// Convert an HCL expression to a JSON value.
952fn 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            // Object expression keys are user data (env vars, headers, etc.),
971            // NOT struct field names — preserve them verbatim.
972            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
997/// Evaluate an HCL function call expression.
998///
999/// Supported functions:
1000/// - `env("VAR_NAME")` — read environment variable, returns empty string if unset
1001fn 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
1032/// Evaluate an HCL template expression (string interpolation).
1033///
1034/// For quoted strings like `"prefix-${env("VAR")}-suffix"`, the template contains
1035/// literal parts and interpolated expressions that we evaluate and concatenate.
1036fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
1037    // TemplateExpr is either a quoted string or heredoc containing template directives.
1038    // We convert it to string representation — the hcl-rs library stores the raw template.
1039    // For simple cases, just return the string form. For interpolations, we'd need a
1040    // full template evaluator which hcl-rs doesn't provide.
1041    // Best effort: convert to display string.
1042    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        // Test serialization
1069        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        // Test deserialization
1078        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, // Uses provider key
1198                    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()), // Override
1215                    base_url: Some("https://custom.api.com".to_string()), // Override
1216                    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        // Model without override uses provider key
1231        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        // Model with override uses its own key
1239        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        // Custom variant is now serializable
1374        let json = serde_json::to_string(&custom).unwrap();
1375        assert_eq!(json, "\"custom\"");
1376
1377        // And deserializable
1378        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        // Test retry policy
2235        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        // Test rate limit
2242        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        // Test priority boost
2248        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        // Test pressure threshold
2254        assert_eq!(queue.pressure_threshold, Some(50));
2255    }
2256
2257    #[test]
2258    fn test_hcl_env_function_resolved() {
2259        // Set a test env var
2260        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        // The providers block should be an array
2273        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        // Clean up
2280        std::env::remove_var("A3S_TEST_HCL_KEY");
2281    }
2282
2283    #[test]
2284    fn test_hcl_env_function_unset_returns_null() {
2285        // Make sure this var doesn't exist
2286        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        // env block keys are environment variable names — must NOT be camelCase'd
2308        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        // Keys must be preserved verbatim, not converted to camelCase
2331        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        // These mangled keys must NOT exist
2339        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        // Test block syntax: env { KEY = "value" } (no equals sign)
2355        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        // End-to-end: HCL string → CodeConfig with McpServerConfig.env populated
2385        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}