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    /// MCP server configurations
253    #[serde(default, alias = "mcp_servers")]
254    pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
255}
256
257/// Search engine configuration (a3s-search integration)
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct SearchConfig {
261    /// Default timeout in seconds for all engines
262    #[serde(default = "default_search_timeout")]
263    pub timeout: u64,
264
265    /// Health monitor configuration
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub health: Option<SearchHealthConfig>,
268
269    /// Engine configurations
270    #[serde(default, rename = "engine")]
271    pub engines: std::collections::HashMap<String, SearchEngineConfig>,
272}
273
274/// Search health monitor configuration
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct SearchHealthConfig {
278    /// Number of consecutive failures before suspending
279    #[serde(default = "default_max_failures")]
280    pub max_failures: u32,
281
282    /// Suspension duration in seconds
283    #[serde(default = "default_suspend_seconds")]
284    pub suspend_seconds: u64,
285}
286
287/// Per-engine search configuration
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct SearchEngineConfig {
291    /// Whether the engine is enabled
292    #[serde(default = "default_enabled")]
293    pub enabled: bool,
294
295    /// Weight for ranking (higher = more influence)
296    #[serde(default = "default_weight")]
297    pub weight: f64,
298
299    /// Per-engine timeout override in seconds
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub timeout: Option<u64>,
302}
303
304fn default_search_timeout() -> u64 {
305    10
306}
307
308fn default_max_failures() -> u32 {
309    3
310}
311
312fn default_suspend_seconds() -> u64 {
313    60
314}
315
316fn default_enabled() -> bool {
317    true
318}
319
320fn default_weight() -> f64 {
321    1.0
322}
323
324impl CodeConfig {
325    /// Create a new empty configuration
326    pub fn new() -> Self {
327        Self::default()
328    }
329
330    /// Load configuration from an HCL file.
331    ///
332    /// Only `.hcl` files are supported. JSON support has been removed.
333    pub fn from_file(path: &Path) -> Result<Self> {
334        let content = std::fs::read_to_string(path).map_err(|e| {
335            CodeError::Config(format!(
336                "Failed to read config file {}: {}",
337                path.display(),
338                e
339            ))
340        })?;
341
342        Self::from_hcl(&content).map_err(|e| {
343            CodeError::Config(format!(
344                "Failed to parse HCL config {}: {}",
345                path.display(),
346                e
347            ))
348        })
349    }
350
351    /// Parse configuration from an HCL string.
352    ///
353    /// HCL attributes use `snake_case` which is converted to `camelCase` for
354    /// serde deserialization. Repeated blocks (e.g., `providers`, `models`)
355    /// are collected into JSON arrays.
356    pub fn from_hcl(content: &str) -> Result<Self> {
357        let body: hcl::Body = hcl::from_str(content)
358            .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
359        let json_value = hcl_body_to_json(&body);
360        serde_json::from_value(json_value)
361            .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
362    }
363
364    /// Save configuration to a JSON file (used for persistence)
365    ///
366    /// Note: This saves as JSON format. To use HCL format, manually create .hcl files.
367    pub fn save_to_file(&self, path: &Path) -> Result<()> {
368        if let Some(parent) = path.parent() {
369            std::fs::create_dir_all(parent).map_err(|e| {
370                CodeError::Config(format!(
371                    "Failed to create config directory {}: {}",
372                    parent.display(),
373                    e
374                ))
375            })?;
376        }
377
378        let content = serde_json::to_string_pretty(self)
379            .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
380
381        std::fs::write(path, content).map_err(|e| {
382            CodeError::Config(format!(
383                "Failed to write config file {}: {}",
384                path.display(),
385                e
386            ))
387        })?;
388
389        Ok(())
390    }
391
392    /// Find a provider by name
393    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
394        self.providers.iter().find(|p| p.name == name)
395    }
396
397    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
398    pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
399        let default = self.default_model.as_ref()?;
400        let (provider_name, _) = default.split_once('/')?;
401        self.find_provider(provider_name)
402    }
403
404    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
405    pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
406        let default = self.default_model.as_ref()?;
407        let (provider_name, model_id) = default.split_once('/')?;
408        let provider = self.find_provider(provider_name)?;
409        let model = provider.find_model(model_id)?;
410        Some((provider, model))
411    }
412
413    /// Get LlmConfig for the default provider and model
414    ///
415    /// Returns None if default provider/model is not configured or API key is missing.
416    pub fn default_llm_config(&self) -> Option<LlmConfig> {
417        let (provider, model) = self.default_model_config()?;
418        let api_key = provider.get_api_key(model)?;
419        let base_url = provider.get_base_url(model);
420
421        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
422        if let Some(url) = base_url {
423            config = config.with_base_url(url);
424        }
425        config = apply_model_caps(config, model, self.thinking_budget);
426        Some(config)
427    }
428
429    /// Get LlmConfig for a specific provider and model
430    ///
431    /// Returns None if provider/model is not found or API key is missing.
432    pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
433        let provider = self.find_provider(provider_name)?;
434        let model = provider.find_model(model_id)?;
435        let api_key = provider.get_api_key(model)?;
436        let base_url = provider.get_base_url(model);
437
438        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
439        if let Some(url) = base_url {
440            config = config.with_base_url(url);
441        }
442        config = apply_model_caps(config, model, self.thinking_budget);
443        Some(config)
444    }
445
446    /// List all available models across all providers
447    pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
448        self.providers
449            .iter()
450            .flat_map(|p| p.models.iter().map(move |m| (p, m)))
451            .collect()
452    }
453
454    /// Add a skill directory
455    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
456        self.skill_dirs.push(dir.into());
457        self
458    }
459
460    /// Add an agent directory
461    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
462        self.agent_dirs.push(dir.into());
463        self
464    }
465
466    /// Check if any directories are configured
467    pub fn has_directories(&self) -> bool {
468        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
469    }
470
471    /// Check if provider configuration is available
472    pub fn has_providers(&self) -> bool {
473        !self.providers.is_empty()
474    }
475}
476
477// ============================================================================
478// HCL Parsing Helpers
479// ============================================================================
480
481/// Block labels that should be collected into JSON arrays.
482const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models", "mcp_servers"];
483
484/// Block identifiers whose body attribute keys should be preserved verbatim
485/// (not converted to camelCase). These blocks contain user-defined key-value maps
486/// like environment variables or HTTP headers, not struct field names.
487const HCL_VERBATIM_BLOCKS: &[&str] = &["env", "headers"];
488
489/// Convert an HCL body into a JSON value with camelCase keys.
490fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
491    hcl_body_to_json_inner(body, false)
492}
493
494/// Inner conversion with `verbatim_keys` flag.
495///
496/// When `verbatim_keys` is true, attribute keys are preserved as-is
497/// (used for blocks like `env { ... }` where keys are user data).
498fn hcl_body_to_json_inner(body: &hcl::Body, verbatim_keys: bool) -> JsonValue {
499    let mut map = serde_json::Map::new();
500
501    // Process attributes (key = value)
502    for attr in body.attributes() {
503        let key = if verbatim_keys {
504            attr.key.as_str().to_string()
505        } else {
506            snake_to_camel(attr.key.as_str())
507        };
508        let value = hcl_expr_to_json(attr.expr());
509        map.insert(key, value);
510    }
511
512    // Process blocks (repeated structures like `providers { ... }`)
513    for block in body.blocks() {
514        let key = if verbatim_keys {
515            block.identifier.as_str().to_string()
516        } else {
517            snake_to_camel(block.identifier.as_str())
518        };
519        // Blocks in HCL_VERBATIM_BLOCKS contain user-defined maps, not struct fields
520        let child_verbatim = HCL_VERBATIM_BLOCKS.contains(&block.identifier.as_str());
521        let block_value = hcl_body_to_json_inner(block.body(), child_verbatim);
522
523        if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
524            // Collect into array
525            let arr = map
526                .entry(key)
527                .or_insert_with(|| JsonValue::Array(Vec::new()));
528            if let JsonValue::Array(ref mut vec) = arr {
529                vec.push(block_value);
530            }
531        } else {
532            map.insert(key, block_value);
533        }
534    }
535
536    JsonValue::Object(map)
537}
538
539/// Convert snake_case to camelCase.
540fn snake_to_camel(s: &str) -> String {
541    let mut result = String::with_capacity(s.len());
542    let mut capitalize_next = false;
543    for ch in s.chars() {
544        if ch == '_' {
545            capitalize_next = true;
546        } else if capitalize_next {
547            result.extend(ch.to_uppercase());
548            capitalize_next = false;
549        } else {
550            result.push(ch);
551        }
552    }
553    result
554}
555
556/// Convert an HCL expression to a JSON value.
557fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
558    match expr {
559        hcl::Expression::String(s) => JsonValue::String(s.clone()),
560        hcl::Expression::Number(n) => {
561            if let Some(i) = n.as_i64() {
562                JsonValue::Number(i.into())
563            } else if let Some(f) = n.as_f64() {
564                serde_json::Number::from_f64(f)
565                    .map(JsonValue::Number)
566                    .unwrap_or(JsonValue::Null)
567            } else {
568                JsonValue::Null
569            }
570        }
571        hcl::Expression::Bool(b) => JsonValue::Bool(*b),
572        hcl::Expression::Null => JsonValue::Null,
573        hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
574        hcl::Expression::Object(obj) => {
575            // Object expression keys are user data (env vars, headers, etc.),
576            // NOT struct field names — preserve them verbatim.
577            let map: serde_json::Map<String, JsonValue> = obj
578                .iter()
579                .map(|(k, v)| {
580                    let key = match k {
581                        hcl::ObjectKey::Identifier(id) => id.as_str().to_string(),
582                        hcl::ObjectKey::Expression(expr) => {
583                            if let hcl::Expression::String(s) = expr {
584                                s.clone()
585                            } else {
586                                format!("{:?}", expr)
587                            }
588                        }
589                        _ => format!("{:?}", k),
590                    };
591                    (key, hcl_expr_to_json(v))
592                })
593                .collect();
594            JsonValue::Object(map)
595        }
596        hcl::Expression::FuncCall(func_call) => eval_func_call(func_call),
597        hcl::Expression::TemplateExpr(tmpl) => eval_template_expr(tmpl),
598        _ => JsonValue::String(format!("{:?}", expr)),
599    }
600}
601
602/// Evaluate an HCL function call expression.
603///
604/// Supported functions:
605/// - `env("VAR_NAME")` — read environment variable, returns empty string if unset
606fn eval_func_call(func_call: &hcl::expr::FuncCall) -> JsonValue {
607    let name = func_call.name.name.as_str();
608    match name {
609        "env" => {
610            if let Some(arg) = func_call.args.first() {
611                let var_name = match arg {
612                    hcl::Expression::String(s) => s.as_str(),
613                    _ => {
614                        tracing::warn!("env() expects a string argument, got: {:?}", arg);
615                        return JsonValue::Null;
616                    }
617                };
618                match std::env::var(var_name) {
619                    Ok(val) => JsonValue::String(val),
620                    Err(_) => {
621                        tracing::debug!("env(\"{}\") is not set, returning null", var_name);
622                        JsonValue::Null
623                    }
624                }
625            } else {
626                tracing::warn!("env() called with no arguments");
627                JsonValue::Null
628            }
629        }
630        _ => {
631            tracing::warn!("Unsupported HCL function: {}()", name);
632            JsonValue::String(format!("{}()", name))
633        }
634    }
635}
636
637/// Evaluate an HCL template expression (string interpolation).
638///
639/// For quoted strings like `"prefix-${env("VAR")}-suffix"`, the template contains
640/// literal parts and interpolated expressions that we evaluate and concatenate.
641fn eval_template_expr(tmpl: &hcl::expr::TemplateExpr) -> JsonValue {
642    // TemplateExpr is either a quoted string or heredoc containing template directives.
643    // We convert it to string representation — the hcl-rs library stores the raw template.
644    // For simple cases, just return the string form. For interpolations, we'd need a
645    // full template evaluator which hcl-rs doesn't provide.
646    // Best effort: convert to display string.
647    JsonValue::String(format!("{}", tmpl))
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn test_config_default() {
656        let config = CodeConfig::default();
657        assert!(config.skill_dirs.is_empty());
658        assert!(config.agent_dirs.is_empty());
659        assert!(config.providers.is_empty());
660        assert!(config.default_model.is_none());
661        assert_eq!(config.storage_backend, StorageBackend::File);
662        assert!(config.sessions_dir.is_none());
663    }
664
665    #[test]
666    fn test_storage_backend_default() {
667        let backend = StorageBackend::default();
668        assert_eq!(backend, StorageBackend::File);
669    }
670
671    #[test]
672    fn test_storage_backend_serde() {
673        // Test serialization
674        let memory = StorageBackend::Memory;
675        let json = serde_json::to_string(&memory).unwrap();
676        assert_eq!(json, "\"memory\"");
677
678        let file = StorageBackend::File;
679        let json = serde_json::to_string(&file).unwrap();
680        assert_eq!(json, "\"file\"");
681
682        // Test deserialization
683        let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
684        assert_eq!(memory, StorageBackend::Memory);
685
686        let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
687        assert_eq!(file, StorageBackend::File);
688    }
689
690    #[test]
691    fn test_config_with_storage_backend() {
692        let temp_dir = tempfile::tempdir().unwrap();
693        let config_path = temp_dir.path().join("config.hcl");
694
695        std::fs::write(
696            &config_path,
697            r#"
698                storage_backend = "memory"
699                sessions_dir = "/tmp/sessions"
700            "#,
701        )
702        .unwrap();
703
704        let config = CodeConfig::from_file(&config_path).unwrap();
705        assert_eq!(config.storage_backend, StorageBackend::Memory);
706        assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
707    }
708
709    #[test]
710    fn test_config_builder() {
711        let config = CodeConfig::new()
712            .add_skill_dir("/tmp/skills")
713            .add_agent_dir("/tmp/agents");
714
715        assert_eq!(config.skill_dirs.len(), 1);
716        assert_eq!(config.agent_dirs.len(), 1);
717    }
718
719    #[test]
720    fn test_find_provider() {
721        let config = CodeConfig {
722            providers: vec![
723                ProviderConfig {
724                    name: "anthropic".to_string(),
725                    api_key: Some("key1".to_string()),
726                    base_url: None,
727                    models: vec![],
728                },
729                ProviderConfig {
730                    name: "openai".to_string(),
731                    api_key: Some("key2".to_string()),
732                    base_url: None,
733                    models: vec![],
734                },
735            ],
736            ..Default::default()
737        };
738
739        assert!(config.find_provider("anthropic").is_some());
740        assert!(config.find_provider("openai").is_some());
741        assert!(config.find_provider("unknown").is_none());
742    }
743
744    #[test]
745    fn test_default_llm_config() {
746        let config = CodeConfig {
747            default_model: Some("anthropic/claude-sonnet-4".to_string()),
748            providers: vec![ProviderConfig {
749                name: "anthropic".to_string(),
750                api_key: Some("test-api-key".to_string()),
751                base_url: Some("https://api.anthropic.com".to_string()),
752                models: vec![ModelConfig {
753                    id: "claude-sonnet-4".to_string(),
754                    name: "Claude Sonnet 4".to_string(),
755                    family: "claude-sonnet".to_string(),
756                    api_key: None,
757                    base_url: None,
758                    attachment: false,
759                    reasoning: false,
760                    tool_call: true,
761                    temperature: true,
762                    release_date: None,
763                    modalities: ModelModalities::default(),
764                    cost: ModelCost::default(),
765                    limit: ModelLimit::default(),
766                }],
767            }],
768            ..Default::default()
769        };
770
771        let llm_config = config.default_llm_config().unwrap();
772        assert_eq!(llm_config.provider, "anthropic");
773        assert_eq!(llm_config.model, "claude-sonnet-4");
774        assert_eq!(llm_config.api_key.expose(), "test-api-key");
775        assert_eq!(
776            llm_config.base_url,
777            Some("https://api.anthropic.com".to_string())
778        );
779    }
780
781    #[test]
782    fn test_model_api_key_override() {
783        let provider = ProviderConfig {
784            name: "openai".to_string(),
785            api_key: Some("provider-key".to_string()),
786            base_url: Some("https://api.openai.com".to_string()),
787            models: vec![
788                ModelConfig {
789                    id: "gpt-4".to_string(),
790                    name: "GPT-4".to_string(),
791                    family: "gpt".to_string(),
792                    api_key: None, // Uses provider key
793                    base_url: None,
794                    attachment: false,
795                    reasoning: false,
796                    tool_call: true,
797                    temperature: true,
798                    release_date: None,
799                    modalities: ModelModalities::default(),
800                    cost: ModelCost::default(),
801                    limit: ModelLimit::default(),
802                },
803                ModelConfig {
804                    id: "custom-model".to_string(),
805                    name: "Custom Model".to_string(),
806                    family: "custom".to_string(),
807                    api_key: Some("model-specific-key".to_string()), // Override
808                    base_url: Some("https://custom.api.com".to_string()), // Override
809                    attachment: false,
810                    reasoning: false,
811                    tool_call: true,
812                    temperature: true,
813                    release_date: None,
814                    modalities: ModelModalities::default(),
815                    cost: ModelCost::default(),
816                    limit: ModelLimit::default(),
817                },
818            ],
819        };
820
821        // Model without override uses provider key
822        let model1 = provider.find_model("gpt-4").unwrap();
823        assert_eq!(provider.get_api_key(model1), Some("provider-key"));
824        assert_eq!(
825            provider.get_base_url(model1),
826            Some("https://api.openai.com")
827        );
828
829        // Model with override uses its own key
830        let model2 = provider.find_model("custom-model").unwrap();
831        assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
832        assert_eq!(
833            provider.get_base_url(model2),
834            Some("https://custom.api.com")
835        );
836    }
837
838    #[test]
839    fn test_list_models() {
840        let config = CodeConfig {
841            providers: vec![
842                ProviderConfig {
843                    name: "anthropic".to_string(),
844                    api_key: None,
845                    base_url: None,
846                    models: vec![
847                        ModelConfig {
848                            id: "claude-1".to_string(),
849                            name: "Claude 1".to_string(),
850                            family: "claude".to_string(),
851                            api_key: None,
852                            base_url: None,
853                            attachment: false,
854                            reasoning: false,
855                            tool_call: true,
856                            temperature: true,
857                            release_date: None,
858                            modalities: ModelModalities::default(),
859                            cost: ModelCost::default(),
860                            limit: ModelLimit::default(),
861                        },
862                        ModelConfig {
863                            id: "claude-2".to_string(),
864                            name: "Claude 2".to_string(),
865                            family: "claude".to_string(),
866                            api_key: None,
867                            base_url: None,
868                            attachment: false,
869                            reasoning: false,
870                            tool_call: true,
871                            temperature: true,
872                            release_date: None,
873                            modalities: ModelModalities::default(),
874                            cost: ModelCost::default(),
875                            limit: ModelLimit::default(),
876                        },
877                    ],
878                },
879                ProviderConfig {
880                    name: "openai".to_string(),
881                    api_key: None,
882                    base_url: None,
883                    models: vec![ModelConfig {
884                        id: "gpt-4".to_string(),
885                        name: "GPT-4".to_string(),
886                        family: "gpt".to_string(),
887                        api_key: None,
888                        base_url: None,
889                        attachment: false,
890                        reasoning: false,
891                        tool_call: true,
892                        temperature: true,
893                        release_date: None,
894                        modalities: ModelModalities::default(),
895                        cost: ModelCost::default(),
896                        limit: ModelLimit::default(),
897                    }],
898                },
899            ],
900            ..Default::default()
901        };
902
903        let models = config.list_models();
904        assert_eq!(models.len(), 3);
905    }
906
907    #[test]
908    fn test_config_from_file_not_found() {
909        let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
910        assert!(result.is_err());
911    }
912
913    #[test]
914    fn test_config_has_directories() {
915        let empty = CodeConfig::default();
916        assert!(!empty.has_directories());
917
918        let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
919        assert!(with_skills.has_directories());
920
921        let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
922        assert!(with_agents.has_directories());
923    }
924
925    #[test]
926    fn test_config_has_providers() {
927        let empty = CodeConfig::default();
928        assert!(!empty.has_providers());
929
930        let with_providers = CodeConfig {
931            providers: vec![ProviderConfig {
932                name: "test".to_string(),
933                api_key: None,
934                base_url: None,
935                models: vec![],
936            }],
937            ..Default::default()
938        };
939        assert!(with_providers.has_providers());
940    }
941
942    #[test]
943    fn test_storage_backend_equality() {
944        assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
945        assert_eq!(StorageBackend::File, StorageBackend::File);
946        assert_ne!(StorageBackend::Memory, StorageBackend::File);
947    }
948
949    #[test]
950    fn test_storage_backend_serde_custom() {
951        let custom = StorageBackend::Custom;
952        // Custom variant is now serializable
953        let json = serde_json::to_string(&custom).unwrap();
954        assert_eq!(json, "\"custom\"");
955
956        // And deserializable
957        let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
958        assert_eq!(parsed, StorageBackend::Custom);
959    }
960
961    #[test]
962    fn test_model_cost_default() {
963        let cost = ModelCost::default();
964        assert_eq!(cost.input, 0.0);
965        assert_eq!(cost.output, 0.0);
966        assert_eq!(cost.cache_read, 0.0);
967        assert_eq!(cost.cache_write, 0.0);
968    }
969
970    #[test]
971    fn test_model_cost_serialization() {
972        let cost = ModelCost {
973            input: 3.0,
974            output: 15.0,
975            cache_read: 0.3,
976            cache_write: 3.75,
977        };
978        let json = serde_json::to_string(&cost).unwrap();
979        assert!(json.contains("\"input\":3"));
980        assert!(json.contains("\"output\":15"));
981    }
982
983    #[test]
984    fn test_model_cost_deserialization_missing_fields() {
985        let json = r#"{"input":3.0}"#;
986        let cost: ModelCost = serde_json::from_str(json).unwrap();
987        assert_eq!(cost.input, 3.0);
988        assert_eq!(cost.output, 0.0);
989        assert_eq!(cost.cache_read, 0.0);
990        assert_eq!(cost.cache_write, 0.0);
991    }
992
993    #[test]
994    fn test_model_limit_default() {
995        let limit = ModelLimit::default();
996        assert_eq!(limit.context, 0);
997        assert_eq!(limit.output, 0);
998    }
999
1000    #[test]
1001    fn test_model_limit_serialization() {
1002        let limit = ModelLimit {
1003            context: 200000,
1004            output: 8192,
1005        };
1006        let json = serde_json::to_string(&limit).unwrap();
1007        assert!(json.contains("\"context\":200000"));
1008        assert!(json.contains("\"output\":8192"));
1009    }
1010
1011    #[test]
1012    fn test_model_limit_deserialization_missing_fields() {
1013        let json = r#"{"context":100000}"#;
1014        let limit: ModelLimit = serde_json::from_str(json).unwrap();
1015        assert_eq!(limit.context, 100000);
1016        assert_eq!(limit.output, 0);
1017    }
1018
1019    #[test]
1020    fn test_model_modalities_default() {
1021        let modalities = ModelModalities::default();
1022        assert!(modalities.input.is_empty());
1023        assert!(modalities.output.is_empty());
1024    }
1025
1026    #[test]
1027    fn test_model_modalities_serialization() {
1028        let modalities = ModelModalities {
1029            input: vec!["text".to_string(), "image".to_string()],
1030            output: vec!["text".to_string()],
1031        };
1032        let json = serde_json::to_string(&modalities).unwrap();
1033        assert!(json.contains("\"input\""));
1034        assert!(json.contains("\"text\""));
1035    }
1036
1037    #[test]
1038    fn test_model_modalities_deserialization_missing_fields() {
1039        let json = r#"{"input":["text"]}"#;
1040        let modalities: ModelModalities = serde_json::from_str(json).unwrap();
1041        assert_eq!(modalities.input.len(), 1);
1042        assert!(modalities.output.is_empty());
1043    }
1044
1045    #[test]
1046    fn test_model_config_serialization() {
1047        let config = ModelConfig {
1048            id: "gpt-4o".to_string(),
1049            name: "GPT-4o".to_string(),
1050            family: "gpt-4".to_string(),
1051            api_key: Some("sk-test".to_string()),
1052            base_url: None,
1053            attachment: true,
1054            reasoning: false,
1055            tool_call: true,
1056            temperature: true,
1057            release_date: Some("2024-05-13".to_string()),
1058            modalities: ModelModalities::default(),
1059            cost: ModelCost::default(),
1060            limit: ModelLimit::default(),
1061        };
1062        let json = serde_json::to_string(&config).unwrap();
1063        assert!(json.contains("\"id\":\"gpt-4o\""));
1064        assert!(json.contains("\"attachment\":true"));
1065    }
1066
1067    #[test]
1068    fn test_model_config_deserialization_with_defaults() {
1069        let json = r#"{"id":"test-model"}"#;
1070        let config: ModelConfig = serde_json::from_str(json).unwrap();
1071        assert_eq!(config.id, "test-model");
1072        assert_eq!(config.name, "");
1073        assert_eq!(config.family, "");
1074        assert!(config.api_key.is_none());
1075        assert!(!config.attachment);
1076        assert!(config.tool_call);
1077        assert!(config.temperature);
1078    }
1079
1080    #[test]
1081    fn test_model_config_all_optional_fields() {
1082        let json = r#"{
1083            "id": "claude-sonnet-4",
1084            "name": "Claude Sonnet 4",
1085            "family": "claude-sonnet",
1086            "apiKey": "sk-test",
1087            "baseUrl": "https://api.anthropic.com",
1088            "attachment": true,
1089            "reasoning": true,
1090            "toolCall": false,
1091            "temperature": false,
1092            "releaseDate": "2025-05-14"
1093        }"#;
1094        let config: ModelConfig = serde_json::from_str(json).unwrap();
1095        assert_eq!(config.id, "claude-sonnet-4");
1096        assert_eq!(config.name, "Claude Sonnet 4");
1097        assert_eq!(config.api_key, Some("sk-test".to_string()));
1098        assert_eq!(
1099            config.base_url,
1100            Some("https://api.anthropic.com".to_string())
1101        );
1102        assert!(config.attachment);
1103        assert!(config.reasoning);
1104        assert!(!config.tool_call);
1105        assert!(!config.temperature);
1106    }
1107
1108    #[test]
1109    fn test_provider_config_serialization() {
1110        let provider = ProviderConfig {
1111            name: "anthropic".to_string(),
1112            api_key: Some("sk-test".to_string()),
1113            base_url: Some("https://api.anthropic.com".to_string()),
1114            models: vec![],
1115        };
1116        let json = serde_json::to_string(&provider).unwrap();
1117        assert!(json.contains("\"name\":\"anthropic\""));
1118        assert!(json.contains("\"apiKey\":\"sk-test\""));
1119    }
1120
1121    #[test]
1122    fn test_provider_config_deserialization_missing_optional() {
1123        let json = r#"{"name":"openai"}"#;
1124        let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1125        assert_eq!(provider.name, "openai");
1126        assert!(provider.api_key.is_none());
1127        assert!(provider.base_url.is_none());
1128        assert!(provider.models.is_empty());
1129    }
1130
1131    #[test]
1132    fn test_provider_config_find_model() {
1133        let provider = ProviderConfig {
1134            name: "anthropic".to_string(),
1135            api_key: None,
1136            base_url: None,
1137            models: vec![ModelConfig {
1138                id: "claude-sonnet-4".to_string(),
1139                name: "Claude Sonnet 4".to_string(),
1140                family: "claude-sonnet".to_string(),
1141                api_key: None,
1142                base_url: None,
1143                attachment: false,
1144                reasoning: false,
1145                tool_call: true,
1146                temperature: true,
1147                release_date: None,
1148                modalities: ModelModalities::default(),
1149                cost: ModelCost::default(),
1150                limit: ModelLimit::default(),
1151            }],
1152        };
1153
1154        let found = provider.find_model("claude-sonnet-4");
1155        assert!(found.is_some());
1156        assert_eq!(found.unwrap().id, "claude-sonnet-4");
1157
1158        let not_found = provider.find_model("gpt-4o");
1159        assert!(not_found.is_none());
1160    }
1161
1162    #[test]
1163    fn test_provider_config_get_api_key() {
1164        let provider = ProviderConfig {
1165            name: "anthropic".to_string(),
1166            api_key: Some("provider-key".to_string()),
1167            base_url: None,
1168            models: vec![],
1169        };
1170
1171        let model_with_key = ModelConfig {
1172            id: "test".to_string(),
1173            name: "".to_string(),
1174            family: "".to_string(),
1175            api_key: Some("model-key".to_string()),
1176            base_url: None,
1177            attachment: false,
1178            reasoning: false,
1179            tool_call: true,
1180            temperature: true,
1181            release_date: None,
1182            modalities: ModelModalities::default(),
1183            cost: ModelCost::default(),
1184            limit: ModelLimit::default(),
1185        };
1186
1187        let model_without_key = ModelConfig {
1188            id: "test2".to_string(),
1189            name: "".to_string(),
1190            family: "".to_string(),
1191            api_key: None,
1192            base_url: None,
1193            attachment: false,
1194            reasoning: false,
1195            tool_call: true,
1196            temperature: true,
1197            release_date: None,
1198            modalities: ModelModalities::default(),
1199            cost: ModelCost::default(),
1200            limit: ModelLimit::default(),
1201        };
1202
1203        assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1204        assert_eq!(
1205            provider.get_api_key(&model_without_key),
1206            Some("provider-key")
1207        );
1208    }
1209
1210    #[test]
1211    fn test_code_config_default_provider_config() {
1212        let config = CodeConfig {
1213            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1214            providers: vec![ProviderConfig {
1215                name: "anthropic".to_string(),
1216                api_key: Some("sk-test".to_string()),
1217                base_url: None,
1218                models: vec![],
1219            }],
1220            ..Default::default()
1221        };
1222
1223        let provider = config.default_provider_config();
1224        assert!(provider.is_some());
1225        assert_eq!(provider.unwrap().name, "anthropic");
1226    }
1227
1228    #[test]
1229    fn test_code_config_default_model_config() {
1230        let config = CodeConfig {
1231            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1232            providers: vec![ProviderConfig {
1233                name: "anthropic".to_string(),
1234                api_key: Some("sk-test".to_string()),
1235                base_url: None,
1236                models: vec![ModelConfig {
1237                    id: "claude-sonnet-4".to_string(),
1238                    name: "Claude Sonnet 4".to_string(),
1239                    family: "claude-sonnet".to_string(),
1240                    api_key: None,
1241                    base_url: None,
1242                    attachment: false,
1243                    reasoning: false,
1244                    tool_call: true,
1245                    temperature: true,
1246                    release_date: None,
1247                    modalities: ModelModalities::default(),
1248                    cost: ModelCost::default(),
1249                    limit: ModelLimit::default(),
1250                }],
1251            }],
1252            ..Default::default()
1253        };
1254
1255        let result = config.default_model_config();
1256        assert!(result.is_some());
1257        let (provider, model) = result.unwrap();
1258        assert_eq!(provider.name, "anthropic");
1259        assert_eq!(model.id, "claude-sonnet-4");
1260    }
1261
1262    #[test]
1263    fn test_code_config_default_llm_config() {
1264        let config = CodeConfig {
1265            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1266            providers: vec![ProviderConfig {
1267                name: "anthropic".to_string(),
1268                api_key: Some("sk-test".to_string()),
1269                base_url: Some("https://api.anthropic.com".to_string()),
1270                models: vec![ModelConfig {
1271                    id: "claude-sonnet-4".to_string(),
1272                    name: "Claude Sonnet 4".to_string(),
1273                    family: "claude-sonnet".to_string(),
1274                    api_key: None,
1275                    base_url: None,
1276                    attachment: false,
1277                    reasoning: false,
1278                    tool_call: true,
1279                    temperature: true,
1280                    release_date: None,
1281                    modalities: ModelModalities::default(),
1282                    cost: ModelCost::default(),
1283                    limit: ModelLimit::default(),
1284                }],
1285            }],
1286            ..Default::default()
1287        };
1288
1289        let llm_config = config.default_llm_config();
1290        assert!(llm_config.is_some());
1291    }
1292
1293    #[test]
1294    fn test_code_config_list_models() {
1295        let config = CodeConfig {
1296            providers: vec![
1297                ProviderConfig {
1298                    name: "anthropic".to_string(),
1299                    api_key: None,
1300                    base_url: None,
1301                    models: vec![ModelConfig {
1302                        id: "claude-sonnet-4".to_string(),
1303                        name: "".to_string(),
1304                        family: "".to_string(),
1305                        api_key: None,
1306                        base_url: None,
1307                        attachment: false,
1308                        reasoning: false,
1309                        tool_call: true,
1310                        temperature: true,
1311                        release_date: None,
1312                        modalities: ModelModalities::default(),
1313                        cost: ModelCost::default(),
1314                        limit: ModelLimit::default(),
1315                    }],
1316                },
1317                ProviderConfig {
1318                    name: "openai".to_string(),
1319                    api_key: None,
1320                    base_url: None,
1321                    models: vec![ModelConfig {
1322                        id: "gpt-4o".to_string(),
1323                        name: "".to_string(),
1324                        family: "".to_string(),
1325                        api_key: None,
1326                        base_url: None,
1327                        attachment: false,
1328                        reasoning: false,
1329                        tool_call: true,
1330                        temperature: true,
1331                        release_date: None,
1332                        modalities: ModelModalities::default(),
1333                        cost: ModelCost::default(),
1334                        limit: ModelLimit::default(),
1335                    }],
1336                },
1337            ],
1338            ..Default::default()
1339        };
1340
1341        let models = config.list_models();
1342        assert_eq!(models.len(), 2);
1343    }
1344
1345    #[test]
1346    fn test_llm_config_specific_provider_model() {
1347        let model: ModelConfig = serde_json::from_value(serde_json::json!({
1348            "id": "claude-3",
1349            "name": "Claude 3"
1350        }))
1351        .unwrap();
1352
1353        let config = CodeConfig {
1354            providers: vec![ProviderConfig {
1355                name: "anthropic".to_string(),
1356                api_key: Some("sk-test".to_string()),
1357                base_url: None,
1358                models: vec![model],
1359            }],
1360            ..Default::default()
1361        };
1362
1363        let llm = config.llm_config("anthropic", "claude-3");
1364        assert!(llm.is_some());
1365        let llm = llm.unwrap();
1366        assert_eq!(llm.provider, "anthropic");
1367        assert_eq!(llm.model, "claude-3");
1368    }
1369
1370    #[test]
1371    fn test_llm_config_missing_provider() {
1372        let config = CodeConfig::default();
1373        assert!(config.llm_config("nonexistent", "model").is_none());
1374    }
1375
1376    #[test]
1377    fn test_llm_config_missing_model() {
1378        let config = CodeConfig {
1379            providers: vec![ProviderConfig {
1380                name: "anthropic".to_string(),
1381                api_key: Some("sk-test".to_string()),
1382                base_url: None,
1383                models: vec![],
1384            }],
1385            ..Default::default()
1386        };
1387        assert!(config.llm_config("anthropic", "nonexistent").is_none());
1388    }
1389
1390    #[test]
1391    fn test_from_hcl_string() {
1392        let hcl = r#"
1393            default_model = "anthropic/claude-sonnet-4"
1394
1395            providers {
1396                name    = "anthropic"
1397                api_key = "test-key"
1398
1399                models {
1400                    id   = "claude-sonnet-4"
1401                    name = "Claude Sonnet 4"
1402                }
1403            }
1404        "#;
1405
1406        let config = CodeConfig::from_hcl(hcl).unwrap();
1407        assert_eq!(
1408            config.default_model,
1409            Some("anthropic/claude-sonnet-4".to_string())
1410        );
1411        assert_eq!(config.providers.len(), 1);
1412        assert_eq!(config.providers[0].name, "anthropic");
1413        assert_eq!(config.providers[0].models.len(), 1);
1414        assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1415    }
1416
1417    #[test]
1418    fn test_from_hcl_multi_provider() {
1419        let hcl = r#"
1420            default_model = "anthropic/claude-sonnet-4"
1421
1422            providers {
1423                name    = "anthropic"
1424                api_key = "sk-ant-test"
1425
1426                models {
1427                    id   = "claude-sonnet-4"
1428                    name = "Claude Sonnet 4"
1429                }
1430
1431                models {
1432                    id        = "claude-opus-4"
1433                    name      = "Claude Opus 4"
1434                    reasoning = true
1435                }
1436            }
1437
1438            providers {
1439                name    = "openai"
1440                api_key = "sk-test"
1441
1442                models {
1443                    id   = "gpt-4o"
1444                    name = "GPT-4o"
1445                }
1446            }
1447        "#;
1448
1449        let config = CodeConfig::from_hcl(hcl).unwrap();
1450        assert_eq!(config.providers.len(), 2);
1451        assert_eq!(config.providers[0].models.len(), 2);
1452        assert_eq!(config.providers[1].models.len(), 1);
1453        assert_eq!(config.providers[1].name, "openai");
1454    }
1455
1456    #[test]
1457    fn test_snake_to_camel() {
1458        assert_eq!(snake_to_camel("default_model"), "defaultModel");
1459        assert_eq!(snake_to_camel("api_key"), "apiKey");
1460        assert_eq!(snake_to_camel("base_url"), "baseUrl");
1461        assert_eq!(snake_to_camel("name"), "name");
1462        assert_eq!(snake_to_camel("tool_call"), "toolCall");
1463    }
1464
1465    #[test]
1466    fn test_from_file_auto_detect_hcl() {
1467        let temp_dir = tempfile::tempdir().unwrap();
1468        let config_path = temp_dir.path().join("config.hcl");
1469
1470        std::fs::write(
1471            &config_path,
1472            r#"
1473            default_model = "anthropic/claude-sonnet-4"
1474
1475            providers {
1476                name    = "anthropic"
1477                api_key = "test-key"
1478
1479                models {
1480                    id = "claude-sonnet-4"
1481                }
1482            }
1483        "#,
1484        )
1485        .unwrap();
1486
1487        let config = CodeConfig::from_file(&config_path).unwrap();
1488        assert_eq!(
1489            config.default_model,
1490            Some("anthropic/claude-sonnet-4".to_string())
1491        );
1492    }
1493
1494    #[test]
1495    fn test_from_hcl_with_queue_config() {
1496        let hcl = r#"
1497            default_model = "anthropic/claude-sonnet-4"
1498
1499            providers {
1500                name    = "anthropic"
1501                api_key = "test-key"
1502            }
1503
1504            queue {
1505                query_max_concurrency = 20
1506                execute_max_concurrency = 5
1507                enable_metrics = true
1508                enable_dlq = true
1509            }
1510        "#;
1511
1512        let config = CodeConfig::from_hcl(hcl).unwrap();
1513        assert!(config.queue.is_some());
1514        let queue = config.queue.unwrap();
1515        assert_eq!(queue.query_max_concurrency, 20);
1516        assert_eq!(queue.execute_max_concurrency, 5);
1517        assert!(queue.enable_metrics);
1518        assert!(queue.enable_dlq);
1519    }
1520
1521    #[test]
1522    fn test_from_hcl_with_search_config() {
1523        let hcl = r#"
1524            default_model = "anthropic/claude-sonnet-4"
1525
1526            providers {
1527                name    = "anthropic"
1528                api_key = "test-key"
1529            }
1530
1531            search {
1532                timeout = 30
1533
1534                health {
1535                    max_failures = 5
1536                    suspend_seconds = 120
1537                }
1538
1539                engine {
1540                    google {
1541                        enabled = true
1542                        weight = 1.5
1543                    }
1544                    bing {
1545                        enabled = true
1546                        weight = 1.0
1547                        timeout = 15
1548                    }
1549                }
1550            }
1551        "#;
1552
1553        let config = CodeConfig::from_hcl(hcl).unwrap();
1554        assert!(config.search.is_some());
1555        let search = config.search.unwrap();
1556        assert_eq!(search.timeout, 30);
1557        assert!(search.health.is_some());
1558        let health = search.health.unwrap();
1559        assert_eq!(health.max_failures, 5);
1560        assert_eq!(health.suspend_seconds, 120);
1561        assert_eq!(search.engines.len(), 2);
1562        assert!(search.engines.contains_key("google"));
1563        assert!(search.engines.contains_key("bing"));
1564        let google = &search.engines["google"];
1565        assert!(google.enabled);
1566        assert_eq!(google.weight, 1.5);
1567        let bing = &search.engines["bing"];
1568        assert_eq!(bing.timeout, Some(15));
1569    }
1570
1571    #[test]
1572    fn test_from_hcl_with_queue_and_search() {
1573        let hcl = r#"
1574            default_model = "anthropic/claude-sonnet-4"
1575
1576            providers {
1577                name    = "anthropic"
1578                api_key = "test-key"
1579            }
1580
1581            queue {
1582                query_max_concurrency = 10
1583                enable_metrics = true
1584            }
1585
1586            search {
1587                timeout = 20
1588                engine {
1589                    duckduckgo {
1590                        enabled = true
1591                    }
1592                }
1593            }
1594        "#;
1595
1596        let config = CodeConfig::from_hcl(hcl).unwrap();
1597        assert!(config.queue.is_some());
1598        assert!(config.search.is_some());
1599        assert_eq!(config.queue.unwrap().query_max_concurrency, 10);
1600        assert_eq!(config.search.unwrap().timeout, 20);
1601    }
1602
1603    #[test]
1604    fn test_from_hcl_multiple_mcp_servers() {
1605        let hcl = r#"
1606            mcp_servers {
1607                name      = "fetch"
1608                transport = "stdio"
1609                command   = "npx"
1610                args      = ["-y", "@modelcontextprotocol/server-fetch"]
1611                enabled   = true
1612            }
1613
1614            mcp_servers {
1615                name      = "puppeteer"
1616                transport = "stdio"
1617                command   = "npx"
1618                args      = ["-y", "@anthropic/mcp-server-puppeteer"]
1619                enabled   = true
1620            }
1621
1622            mcp_servers {
1623                name      = "filesystem"
1624                transport = "stdio"
1625                command   = "npx"
1626                args      = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1627                enabled   = false
1628            }
1629        "#;
1630
1631        let config = CodeConfig::from_hcl(hcl).unwrap();
1632        assert_eq!(
1633            config.mcp_servers.len(),
1634            3,
1635            "all 3 mcp_servers blocks should be parsed"
1636        );
1637        assert_eq!(config.mcp_servers[0].name, "fetch");
1638        assert_eq!(config.mcp_servers[1].name, "puppeteer");
1639        assert_eq!(config.mcp_servers[2].name, "filesystem");
1640        assert!(config.mcp_servers[0].enabled);
1641        assert!(!config.mcp_servers[2].enabled);
1642    }
1643
1644    #[test]
1645    fn test_from_hcl_with_advanced_queue_config() {
1646        let hcl = r#"
1647            default_model = "anthropic/claude-sonnet-4"
1648
1649            providers {
1650                name    = "anthropic"
1651                api_key = "test-key"
1652            }
1653
1654            queue {
1655                query_max_concurrency = 20
1656                enable_metrics = true
1657
1658                retry_policy {
1659                    strategy = "exponential"
1660                    max_retries = 5
1661                    initial_delay_ms = 200
1662                }
1663
1664                rate_limit {
1665                    limit_type = "per_second"
1666                    max_operations = 100
1667                }
1668
1669                priority_boost {
1670                    strategy = "standard"
1671                    deadline_ms = 300000
1672                }
1673
1674                pressure_threshold = 50
1675            }
1676        "#;
1677
1678        let config = CodeConfig::from_hcl(hcl).unwrap();
1679        assert!(config.queue.is_some());
1680        let queue = config.queue.unwrap();
1681
1682        assert_eq!(queue.query_max_concurrency, 20);
1683        assert!(queue.enable_metrics);
1684
1685        // Test retry policy
1686        assert!(queue.retry_policy.is_some());
1687        let retry = queue.retry_policy.unwrap();
1688        assert_eq!(retry.strategy, "exponential");
1689        assert_eq!(retry.max_retries, 5);
1690        assert_eq!(retry.initial_delay_ms, 200);
1691
1692        // Test rate limit
1693        assert!(queue.rate_limit.is_some());
1694        let rate = queue.rate_limit.unwrap();
1695        assert_eq!(rate.limit_type, "per_second");
1696        assert_eq!(rate.max_operations, Some(100));
1697
1698        // Test priority boost
1699        assert!(queue.priority_boost.is_some());
1700        let boost = queue.priority_boost.unwrap();
1701        assert_eq!(boost.strategy, "standard");
1702        assert_eq!(boost.deadline_ms, Some(300000));
1703
1704        // Test pressure threshold
1705        assert_eq!(queue.pressure_threshold, Some(50));
1706    }
1707
1708    #[test]
1709    fn test_hcl_env_function_resolved() {
1710        // Set a test env var
1711        std::env::set_var("A3S_TEST_HCL_KEY", "test-secret-key-123");
1712
1713        let hcl_str = r#"
1714            providers {
1715                name    = "test"
1716                api_key = env("A3S_TEST_HCL_KEY")
1717            }
1718        "#;
1719
1720        let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1721        let json = hcl_body_to_json(&body);
1722
1723        // The providers block should be an array
1724        let providers = json.get("providers").unwrap();
1725        let provider = providers.as_array().unwrap().first().unwrap();
1726        let api_key = provider.get("apiKey").unwrap();
1727
1728        assert_eq!(api_key.as_str().unwrap(), "test-secret-key-123");
1729
1730        // Clean up
1731        std::env::remove_var("A3S_TEST_HCL_KEY");
1732    }
1733
1734    #[test]
1735    fn test_hcl_env_function_unset_returns_null() {
1736        // Make sure this var doesn't exist
1737        std::env::remove_var("A3S_TEST_NONEXISTENT_VAR_12345");
1738
1739        let hcl_str = r#"
1740            providers {
1741                name    = "test"
1742                api_key = env("A3S_TEST_NONEXISTENT_VAR_12345")
1743            }
1744        "#;
1745
1746        let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1747        let json = hcl_body_to_json(&body);
1748
1749        let providers = json.get("providers").unwrap();
1750        let provider = providers.as_array().unwrap().first().unwrap();
1751        let api_key = provider.get("apiKey").unwrap();
1752
1753        assert!(api_key.is_null(), "Unset env var should return null");
1754    }
1755
1756    #[test]
1757    fn test_hcl_mcp_env_block_preserves_var_names() {
1758        // env block keys are environment variable names — must NOT be camelCase'd
1759        std::env::set_var("A3S_TEST_SECRET", "my-secret");
1760
1761        let hcl_str = r#"
1762            mcp_servers {
1763                name      = "test-server"
1764                transport = "stdio"
1765                command   = "echo"
1766                env = {
1767                    API_KEY           = "sk-test-123"
1768                    ANTHROPIC_API_KEY = env("A3S_TEST_SECRET")
1769                    SIMPLE            = "value"
1770                }
1771            }
1772        "#;
1773
1774        let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1775        let json = hcl_body_to_json(&body);
1776
1777        let servers = json.get("mcpServers").unwrap().as_array().unwrap();
1778        let server = &servers[0];
1779        let env = server.get("env").unwrap().as_object().unwrap();
1780
1781        // Keys must be preserved verbatim, not converted to camelCase
1782        assert_eq!(env.get("API_KEY").unwrap().as_str().unwrap(), "sk-test-123");
1783        assert_eq!(
1784            env.get("ANTHROPIC_API_KEY").unwrap().as_str().unwrap(),
1785            "my-secret"
1786        );
1787        assert_eq!(env.get("SIMPLE").unwrap().as_str().unwrap(), "value");
1788
1789        // These mangled keys must NOT exist
1790        assert!(
1791            env.get("apiKey").is_none(),
1792            "env var key should not be camelCase'd"
1793        );
1794        assert!(
1795            env.get("APIKEY").is_none(),
1796            "env var key should not have underscores stripped"
1797        );
1798        assert!(env.get("anthropicApiKey").is_none());
1799
1800        std::env::remove_var("A3S_TEST_SECRET");
1801    }
1802
1803    #[test]
1804    fn test_hcl_mcp_env_as_block_syntax() {
1805        // Test block syntax: env { KEY = "value" } (no equals sign)
1806        let hcl_str = r#"
1807            mcp_servers {
1808                name      = "test-server"
1809                transport = "stdio"
1810                command   = "echo"
1811                env {
1812                    MY_VAR     = "hello"
1813                    OTHER_VAR  = "world"
1814                }
1815            }
1816        "#;
1817
1818        let body: hcl::Body = hcl::from_str(hcl_str).unwrap();
1819        let json = hcl_body_to_json(&body);
1820
1821        let servers = json.get("mcpServers").unwrap().as_array().unwrap();
1822        let server = &servers[0];
1823        let env = server.get("env").unwrap().as_object().unwrap();
1824
1825        assert_eq!(env.get("MY_VAR").unwrap().as_str().unwrap(), "hello");
1826        assert_eq!(env.get("OTHER_VAR").unwrap().as_str().unwrap(), "world");
1827        assert!(
1828            env.get("myVar").is_none(),
1829            "block env keys should not be camelCase'd"
1830        );
1831    }
1832
1833    #[test]
1834    fn test_hcl_mcp_full_deserialization_with_env() {
1835        // End-to-end: HCL string → CodeConfig with McpServerConfig.env populated
1836        std::env::set_var("A3S_TEST_MCP_KEY", "resolved-secret");
1837
1838        let hcl_str = r#"
1839            mcp_servers {
1840                name      = "fetch"
1841                transport = "stdio"
1842                command   = "npx"
1843                args      = ["-y", "@modelcontextprotocol/server-fetch"]
1844                env = {
1845                    NODE_ENV = "production"
1846                    API_KEY  = env("A3S_TEST_MCP_KEY")
1847                }
1848                tool_timeout_secs = 120
1849            }
1850        "#;
1851
1852        let config = CodeConfig::from_hcl(hcl_str).unwrap();
1853        assert_eq!(config.mcp_servers.len(), 1);
1854
1855        let server = &config.mcp_servers[0];
1856        assert_eq!(server.name, "fetch");
1857        assert_eq!(server.env.get("NODE_ENV").unwrap(), "production");
1858        assert_eq!(server.env.get("API_KEY").unwrap(), "resolved-secret");
1859        assert_eq!(server.tool_timeout_secs, 120);
1860
1861        std::env::remove_var("A3S_TEST_MCP_KEY");
1862    }
1863}