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