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