Skip to main content

a3s_code_core/
config.rs

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