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//! - Directories for dynamic skill and agent loading
6//!
7//! Configuration can be loaded from JSON files, JSON strings, or HCL strings.
8
9use crate::error::{CodeError, Result};
10use crate::llm::LlmConfig;
11use serde::{Deserialize, Serialize};
12use serde_json::Value as JsonValue;
13use std::path::{Path, PathBuf};
14
15// ============================================================================
16// Provider Configuration
17// ============================================================================
18
19/// Model cost information (per million tokens)
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21#[serde(rename_all = "camelCase")]
22pub struct ModelCost {
23    /// Input token cost
24    #[serde(default)]
25    pub input: f64,
26    /// Output token cost
27    #[serde(default)]
28    pub output: f64,
29    /// Cache read cost
30    #[serde(default)]
31    pub cache_read: f64,
32    /// Cache write cost
33    #[serde(default)]
34    pub cache_write: f64,
35}
36
37/// Model limits
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct ModelLimit {
40    /// Maximum context tokens
41    #[serde(default)]
42    pub context: u32,
43    /// Maximum output tokens
44    #[serde(default)]
45    pub output: u32,
46}
47
48/// Model modalities (input/output types)
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct ModelModalities {
51    /// Supported input types
52    #[serde(default)]
53    pub input: Vec<String>,
54    /// Supported output types
55    #[serde(default)]
56    pub output: Vec<String>,
57}
58
59/// Model configuration
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct ModelConfig {
63    /// Model ID (e.g., "claude-sonnet-4-20250514")
64    pub id: String,
65    /// Display name
66    #[serde(default)]
67    pub name: String,
68    /// Model family (e.g., "claude-sonnet")
69    #[serde(default)]
70    pub family: String,
71    /// Per-model API key override
72    #[serde(default)]
73    pub api_key: Option<String>,
74    /// Per-model base URL override
75    #[serde(default)]
76    pub base_url: Option<String>,
77    /// Supports file attachments
78    #[serde(default)]
79    pub attachment: bool,
80    /// Supports reasoning/thinking
81    #[serde(default)]
82    pub reasoning: bool,
83    /// Supports tool calling
84    #[serde(default = "default_true")]
85    pub tool_call: bool,
86    /// Supports temperature setting
87    #[serde(default = "default_true")]
88    pub temperature: bool,
89    /// Release date
90    #[serde(default)]
91    pub release_date: Option<String>,
92    /// Input/output modalities
93    #[serde(default)]
94    pub modalities: ModelModalities,
95    /// Cost information
96    #[serde(default)]
97    pub cost: ModelCost,
98    /// Token limits
99    #[serde(default)]
100    pub limit: ModelLimit,
101}
102
103fn default_true() -> bool {
104    true
105}
106
107/// Provider configuration
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ProviderConfig {
111    /// Provider name (e.g., "anthropic", "openai")
112    pub name: String,
113    /// API key for this provider
114    #[serde(default)]
115    pub api_key: Option<String>,
116    /// Base URL for the API
117    #[serde(default)]
118    pub base_url: Option<String>,
119    /// Available models
120    #[serde(default)]
121    pub models: Vec<ModelConfig>,
122}
123
124impl ProviderConfig {
125    /// Find a model by ID
126    pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
127        self.models.iter().find(|m| m.id == model_id)
128    }
129
130    /// Get the effective API key for a model (model override or provider default)
131    pub fn get_api_key<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
132        model.api_key.as_deref().or(self.api_key.as_deref())
133    }
134
135    /// Get the effective base URL for a model (model override or provider default)
136    pub fn get_base_url<'a>(&'a self, model: &'a ModelConfig) -> Option<&'a str> {
137        model.base_url.as_deref().or(self.base_url.as_deref())
138    }
139}
140
141// ============================================================================
142// Storage Configuration
143// ============================================================================
144
145/// Session storage backend type
146#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
147#[serde(rename_all = "lowercase")]
148pub enum StorageBackend {
149    /// In-memory storage (no persistence)
150    Memory,
151    /// File-based storage (JSON files)
152    #[default]
153    File,
154    /// Custom external storage (Redis, PostgreSQL, etc.)
155    ///
156    /// Requires a `SessionStore` implementation registered via `SessionManager::with_store()`.
157    /// Use `storage_url` in config to pass connection details.
158    Custom,
159}
160
161// ============================================================================
162// Main Configuration
163// ============================================================================
164
165/// Configuration for A3S Code
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
167#[serde(rename_all = "camelCase")]
168pub struct CodeConfig {
169    /// Default model in "provider/model" format (e.g., "anthropic/claude-sonnet-4-20250514")
170    #[serde(default, alias = "default_model")]
171    pub default_model: Option<String>,
172
173    /// Provider configurations
174    #[serde(default)]
175    pub providers: Vec<ProviderConfig>,
176
177    /// Session storage backend
178    #[serde(default)]
179    pub storage_backend: StorageBackend,
180
181    /// Sessions directory (for file backend)
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub sessions_dir: Option<PathBuf>,
184
185    /// Connection URL for custom storage backend (e.g., "redis://localhost:6379", "postgres://user:pass@localhost/a3s")
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub storage_url: Option<String>,
188
189    /// Directories to scan for skill files (*.md with tool definitions)
190    #[serde(default, alias = "skill_dirs")]
191    pub skill_dirs: Vec<PathBuf>,
192
193    /// Directories to scan for agent files (*.yaml or *.md)
194    #[serde(default, alias = "agent_dirs")]
195    pub agent_dirs: Vec<PathBuf>,
196
197    /// Maximum tool execution rounds per turn (default: 25)
198    #[serde(default, alias = "max_tool_rounds")]
199    pub max_tool_rounds: Option<usize>,
200
201    /// Thinking/reasoning budget in tokens
202    #[serde(default, alias = "thinking_budget")]
203    pub thinking_budget: Option<usize>,
204
205    /// Memory system configuration
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub memory: Option<crate::memory::MemoryConfig>,
208}
209
210impl CodeConfig {
211    /// Create a new empty configuration
212    pub fn new() -> Self {
213        Self::default()
214    }
215
216    /// Load configuration from a file (auto-detects JSON or HCL by extension).
217    ///
218    /// - `.json` files are parsed as JSON
219    /// - `.hcl` files are parsed as HCL
220    /// - Other extensions default to JSON
221    pub fn from_file(path: &Path) -> Result<Self> {
222        let content = std::fs::read_to_string(path).map_err(|e| {
223            CodeError::Config(format!(
224                "Failed to read config file {}: {}",
225                path.display(),
226                e
227            ))
228        })?;
229
230        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
231
232        match ext {
233            "hcl" => Self::from_hcl(&content).map_err(|e| {
234                CodeError::Config(format!(
235                    "Failed to parse HCL config {}: {}",
236                    path.display(),
237                    e
238                ))
239            }),
240            _ => serde_json::from_str(&content).map_err(|e| {
241                CodeError::Config(format!(
242                    "Failed to parse JSON config {}: {}",
243                    path.display(),
244                    e
245                ))
246            }),
247        }
248    }
249
250    /// Parse configuration from a JSON string.
251    pub fn from_json(content: &str) -> Result<Self> {
252        serde_json::from_str(content)
253            .map_err(|e| CodeError::Config(format!("Failed to parse JSON config: {}", e)))
254    }
255
256    /// Parse configuration from an HCL string.
257    ///
258    /// HCL attributes use `snake_case` which is converted to `camelCase` for
259    /// serde deserialization. Repeated blocks (e.g., `providers`, `models`)
260    /// are collected into JSON arrays.
261    pub fn from_hcl(content: &str) -> Result<Self> {
262        let body: hcl::Body = hcl::from_str(content)
263            .map_err(|e| CodeError::Config(format!("Failed to parse HCL: {}", e)))?;
264        let json_value = hcl_body_to_json(&body);
265        serde_json::from_value(json_value)
266            .map_err(|e| CodeError::Config(format!("Failed to deserialize HCL config: {}", e)))
267    }
268
269    /// Save configuration to a JSON file (used for persistence)
270    pub fn save_to_file(&self, path: &Path) -> Result<()> {
271        if let Some(parent) = path.parent() {
272            std::fs::create_dir_all(parent).map_err(|e| {
273                CodeError::Config(format!(
274                    "Failed to create config directory {}: {}",
275                    parent.display(),
276                    e
277                ))
278            })?;
279        }
280
281        let content = serde_json::to_string_pretty(self)
282            .map_err(|e| CodeError::Config(format!("Failed to serialize config: {}", e)))?;
283
284        std::fs::write(path, content).map_err(|e| {
285            CodeError::Config(format!(
286                "Failed to write config file {}: {}",
287                path.display(),
288                e
289            ))
290        })?;
291
292        Ok(())
293    }
294
295    /// Find a provider by name
296    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
297        self.providers.iter().find(|p| p.name == name)
298    }
299
300    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
301    pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
302        let default = self.default_model.as_ref()?;
303        let (provider_name, _) = default.split_once('/')?;
304        self.find_provider(provider_name)
305    }
306
307    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
308    pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
309        let default = self.default_model.as_ref()?;
310        let (provider_name, model_id) = default.split_once('/')?;
311        let provider = self.find_provider(provider_name)?;
312        let model = provider.find_model(model_id)?;
313        Some((provider, model))
314    }
315
316    /// Get LlmConfig for the default provider and model
317    ///
318    /// Returns None if default provider/model is not configured or API key is missing.
319    pub fn default_llm_config(&self) -> Option<LlmConfig> {
320        let (provider, model) = self.default_model_config()?;
321        let api_key = provider.get_api_key(model)?;
322        let base_url = provider.get_base_url(model);
323
324        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
325        if let Some(url) = base_url {
326            config = config.with_base_url(url);
327        }
328        Some(config)
329    }
330
331    /// Get LlmConfig for a specific provider and model
332    ///
333    /// Returns None if provider/model is not found or API key is missing.
334    pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
335        let provider = self.find_provider(provider_name)?;
336        let model = provider.find_model(model_id)?;
337        let api_key = provider.get_api_key(model)?;
338        let base_url = provider.get_base_url(model);
339
340        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
341        if let Some(url) = base_url {
342            config = config.with_base_url(url);
343        }
344        Some(config)
345    }
346
347    /// List all available models across all providers
348    pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
349        self.providers
350            .iter()
351            .flat_map(|p| p.models.iter().map(move |m| (p, m)))
352            .collect()
353    }
354
355    /// Add a skill directory
356    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
357        self.skill_dirs.push(dir.into());
358        self
359    }
360
361    /// Add an agent directory
362    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
363        self.agent_dirs.push(dir.into());
364        self
365    }
366
367    /// Check if any directories are configured
368    pub fn has_directories(&self) -> bool {
369        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
370    }
371
372    /// Check if provider configuration is available
373    pub fn has_providers(&self) -> bool {
374        !self.providers.is_empty()
375    }
376}
377
378// ============================================================================
379// HCL Parsing Helpers
380// ============================================================================
381
382/// Block labels that should be collected into JSON arrays.
383const HCL_ARRAY_BLOCKS: &[&str] = &["providers", "models"];
384
385/// Convert an HCL body into a JSON value with camelCase keys.
386fn hcl_body_to_json(body: &hcl::Body) -> JsonValue {
387    let mut map = serde_json::Map::new();
388
389    // Process attributes (key = value)
390    for attr in body.attributes() {
391        let key = snake_to_camel(attr.key.as_str());
392        let value = hcl_expr_to_json(attr.expr());
393        map.insert(key, value);
394    }
395
396    // Process blocks (repeated structures like `providers { ... }`)
397    for block in body.blocks() {
398        let key = snake_to_camel(block.identifier.as_str());
399        let block_value = hcl_body_to_json(block.body());
400
401        if HCL_ARRAY_BLOCKS.contains(&block.identifier.as_str()) {
402            // Collect into array
403            let arr = map
404                .entry(key)
405                .or_insert_with(|| JsonValue::Array(Vec::new()));
406            if let JsonValue::Array(ref mut vec) = arr {
407                vec.push(block_value);
408            }
409        } else {
410            map.insert(key, block_value);
411        }
412    }
413
414    JsonValue::Object(map)
415}
416
417/// Convert snake_case to camelCase.
418fn snake_to_camel(s: &str) -> String {
419    let mut result = String::with_capacity(s.len());
420    let mut capitalize_next = false;
421    for ch in s.chars() {
422        if ch == '_' {
423            capitalize_next = true;
424        } else if capitalize_next {
425            result.extend(ch.to_uppercase());
426            capitalize_next = false;
427        } else {
428            result.push(ch);
429        }
430    }
431    result
432}
433
434/// Convert an HCL expression to a JSON value.
435fn hcl_expr_to_json(expr: &hcl::Expression) -> JsonValue {
436    match expr {
437        hcl::Expression::String(s) => JsonValue::String(s.clone()),
438        hcl::Expression::Number(n) => {
439            if let Some(i) = n.as_i64() {
440                JsonValue::Number(i.into())
441            } else if let Some(f) = n.as_f64() {
442                serde_json::Number::from_f64(f)
443                    .map(JsonValue::Number)
444                    .unwrap_or(JsonValue::Null)
445            } else {
446                JsonValue::Null
447            }
448        }
449        hcl::Expression::Bool(b) => JsonValue::Bool(*b),
450        hcl::Expression::Null => JsonValue::Null,
451        hcl::Expression::Array(arr) => JsonValue::Array(arr.iter().map(hcl_expr_to_json).collect()),
452        hcl::Expression::Object(obj) => {
453            let map: serde_json::Map<String, JsonValue> = obj
454                .iter()
455                .map(|(k, v)| {
456                    let key = match k {
457                        hcl::ObjectKey::Identifier(id) => snake_to_camel(id.as_str()),
458                        hcl::ObjectKey::Expression(expr) => {
459                            if let hcl::Expression::String(s) = expr {
460                                snake_to_camel(s)
461                            } else {
462                                format!("{:?}", expr)
463                            }
464                        }
465                        _ => format!("{:?}", k),
466                    };
467                    (key, hcl_expr_to_json(v))
468                })
469                .collect();
470            JsonValue::Object(map)
471        }
472        _ => JsonValue::String(format!("{:?}", expr)),
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_config_default() {
482        let config = CodeConfig::default();
483        assert!(config.skill_dirs.is_empty());
484        assert!(config.agent_dirs.is_empty());
485        assert!(config.providers.is_empty());
486        assert!(config.default_model.is_none());
487        assert_eq!(config.storage_backend, StorageBackend::File);
488        assert!(config.sessions_dir.is_none());
489    }
490
491    #[test]
492    fn test_storage_backend_default() {
493        let backend = StorageBackend::default();
494        assert_eq!(backend, StorageBackend::File);
495    }
496
497    #[test]
498    fn test_storage_backend_serde() {
499        // Test serialization
500        let memory = StorageBackend::Memory;
501        let json = serde_json::to_string(&memory).unwrap();
502        assert_eq!(json, "\"memory\"");
503
504        let file = StorageBackend::File;
505        let json = serde_json::to_string(&file).unwrap();
506        assert_eq!(json, "\"file\"");
507
508        // Test deserialization
509        let memory: StorageBackend = serde_json::from_str("\"memory\"").unwrap();
510        assert_eq!(memory, StorageBackend::Memory);
511
512        let file: StorageBackend = serde_json::from_str("\"file\"").unwrap();
513        assert_eq!(file, StorageBackend::File);
514    }
515
516    #[test]
517    fn test_config_with_storage_backend() {
518        let temp_dir = tempfile::tempdir().unwrap();
519        let config_path = temp_dir.path().join("config.json");
520
521        std::fs::write(
522            &config_path,
523            r#"{
524                "storageBackend": "memory",
525                "sessionsDir": "/tmp/sessions"
526            }"#,
527        )
528        .unwrap();
529
530        let config = CodeConfig::from_file(&config_path).unwrap();
531        assert_eq!(config.storage_backend, StorageBackend::Memory);
532        assert_eq!(config.sessions_dir, Some(PathBuf::from("/tmp/sessions")));
533    }
534
535    #[test]
536    fn test_config_builder() {
537        let config = CodeConfig::new()
538            .add_skill_dir("/tmp/skills")
539            .add_agent_dir("/tmp/agents");
540
541        assert_eq!(config.skill_dirs.len(), 1);
542        assert_eq!(config.agent_dirs.len(), 1);
543    }
544
545    #[test]
546    fn test_config_from_json_with_providers() {
547        let temp_dir = tempfile::tempdir().unwrap();
548        let config_path = temp_dir.path().join("config.json");
549
550        std::fs::write(
551            &config_path,
552            r#"{
553                "defaultModel": "anthropic/claude-sonnet-4",
554                "providers": [
555                    {
556                        "name": "anthropic",
557                        "apiKey": "test-key",
558                        "baseUrl": "https://api.anthropic.com",
559                        "models": [
560                            {
561                                "id": "claude-sonnet-4",
562                                "name": "Claude Sonnet 4",
563                                "family": "claude-sonnet",
564                                "toolCall": true
565                            }
566                        ]
567                    }
568                ],
569                "skill_dirs": ["/tmp/skills"]
570            }"#,
571        )
572        .unwrap();
573
574        let config = CodeConfig::from_file(&config_path).unwrap();
575        assert_eq!(
576            config.default_model,
577            Some("anthropic/claude-sonnet-4".to_string())
578        );
579        assert_eq!(config.providers.len(), 1);
580        assert_eq!(config.providers[0].name, "anthropic");
581        assert_eq!(config.providers[0].models.len(), 1);
582        assert_eq!(config.skill_dirs.len(), 1);
583    }
584
585    #[test]
586    fn test_find_provider() {
587        let config = CodeConfig {
588            providers: vec![
589                ProviderConfig {
590                    name: "anthropic".to_string(),
591                    api_key: Some("key1".to_string()),
592                    base_url: None,
593                    models: vec![],
594                },
595                ProviderConfig {
596                    name: "openai".to_string(),
597                    api_key: Some("key2".to_string()),
598                    base_url: None,
599                    models: vec![],
600                },
601            ],
602            ..Default::default()
603        };
604
605        assert!(config.find_provider("anthropic").is_some());
606        assert!(config.find_provider("openai").is_some());
607        assert!(config.find_provider("unknown").is_none());
608    }
609
610    #[test]
611    fn test_default_llm_config() {
612        let config = CodeConfig {
613            default_model: Some("anthropic/claude-sonnet-4".to_string()),
614            providers: vec![ProviderConfig {
615                name: "anthropic".to_string(),
616                api_key: Some("test-api-key".to_string()),
617                base_url: Some("https://api.anthropic.com".to_string()),
618                models: vec![ModelConfig {
619                    id: "claude-sonnet-4".to_string(),
620                    name: "Claude Sonnet 4".to_string(),
621                    family: "claude-sonnet".to_string(),
622                    api_key: None,
623                    base_url: None,
624                    attachment: false,
625                    reasoning: false,
626                    tool_call: true,
627                    temperature: true,
628                    release_date: None,
629                    modalities: ModelModalities::default(),
630                    cost: ModelCost::default(),
631                    limit: ModelLimit::default(),
632                }],
633            }],
634            ..Default::default()
635        };
636
637        let llm_config = config.default_llm_config().unwrap();
638        assert_eq!(llm_config.provider, "anthropic");
639        assert_eq!(llm_config.model, "claude-sonnet-4");
640        assert_eq!(llm_config.api_key.expose(), "test-api-key");
641        assert_eq!(
642            llm_config.base_url,
643            Some("https://api.anthropic.com".to_string())
644        );
645    }
646
647    #[test]
648    fn test_model_api_key_override() {
649        let provider = ProviderConfig {
650            name: "openai".to_string(),
651            api_key: Some("provider-key".to_string()),
652            base_url: Some("https://api.openai.com".to_string()),
653            models: vec![
654                ModelConfig {
655                    id: "gpt-4".to_string(),
656                    name: "GPT-4".to_string(),
657                    family: "gpt".to_string(),
658                    api_key: None, // Uses provider key
659                    base_url: None,
660                    attachment: false,
661                    reasoning: false,
662                    tool_call: true,
663                    temperature: true,
664                    release_date: None,
665                    modalities: ModelModalities::default(),
666                    cost: ModelCost::default(),
667                    limit: ModelLimit::default(),
668                },
669                ModelConfig {
670                    id: "custom-model".to_string(),
671                    name: "Custom Model".to_string(),
672                    family: "custom".to_string(),
673                    api_key: Some("model-specific-key".to_string()), // Override
674                    base_url: Some("https://custom.api.com".to_string()), // Override
675                    attachment: false,
676                    reasoning: false,
677                    tool_call: true,
678                    temperature: true,
679                    release_date: None,
680                    modalities: ModelModalities::default(),
681                    cost: ModelCost::default(),
682                    limit: ModelLimit::default(),
683                },
684            ],
685        };
686
687        // Model without override uses provider key
688        let model1 = provider.find_model("gpt-4").unwrap();
689        assert_eq!(provider.get_api_key(model1), Some("provider-key"));
690        assert_eq!(
691            provider.get_base_url(model1),
692            Some("https://api.openai.com")
693        );
694
695        // Model with override uses its own key
696        let model2 = provider.find_model("custom-model").unwrap();
697        assert_eq!(provider.get_api_key(model2), Some("model-specific-key"));
698        assert_eq!(
699            provider.get_base_url(model2),
700            Some("https://custom.api.com")
701        );
702    }
703
704    #[test]
705    fn test_list_models() {
706        let config = CodeConfig {
707            providers: vec![
708                ProviderConfig {
709                    name: "anthropic".to_string(),
710                    api_key: None,
711                    base_url: None,
712                    models: vec![
713                        ModelConfig {
714                            id: "claude-1".to_string(),
715                            name: "Claude 1".to_string(),
716                            family: "claude".to_string(),
717                            api_key: None,
718                            base_url: None,
719                            attachment: false,
720                            reasoning: false,
721                            tool_call: true,
722                            temperature: true,
723                            release_date: None,
724                            modalities: ModelModalities::default(),
725                            cost: ModelCost::default(),
726                            limit: ModelLimit::default(),
727                        },
728                        ModelConfig {
729                            id: "claude-2".to_string(),
730                            name: "Claude 2".to_string(),
731                            family: "claude".to_string(),
732                            api_key: None,
733                            base_url: None,
734                            attachment: false,
735                            reasoning: false,
736                            tool_call: true,
737                            temperature: true,
738                            release_date: None,
739                            modalities: ModelModalities::default(),
740                            cost: ModelCost::default(),
741                            limit: ModelLimit::default(),
742                        },
743                    ],
744                },
745                ProviderConfig {
746                    name: "openai".to_string(),
747                    api_key: None,
748                    base_url: None,
749                    models: vec![ModelConfig {
750                        id: "gpt-4".to_string(),
751                        name: "GPT-4".to_string(),
752                        family: "gpt".to_string(),
753                        api_key: None,
754                        base_url: None,
755                        attachment: false,
756                        reasoning: false,
757                        tool_call: true,
758                        temperature: true,
759                        release_date: None,
760                        modalities: ModelModalities::default(),
761                        cost: ModelCost::default(),
762                        limit: ModelLimit::default(),
763                    }],
764                },
765            ],
766            ..Default::default()
767        };
768
769        let models = config.list_models();
770        assert_eq!(models.len(), 3);
771    }
772
773    #[test]
774    fn test_config_from_json_missing_fields() {
775        let temp_dir = tempfile::tempdir().unwrap();
776        let config_path = temp_dir.path().join("config.json");
777
778        std::fs::write(&config_path, r#"{"skill_dirs": ["/tmp/skills"]}"#).unwrap();
779
780        let config = CodeConfig::from_file(&config_path).unwrap();
781        assert_eq!(config.skill_dirs.len(), 1);
782        assert!(config.agent_dirs.is_empty());
783        assert!(config.providers.is_empty());
784    }
785
786    #[test]
787    fn test_config_from_file_not_found() {
788        let result = CodeConfig::from_file(Path::new("/nonexistent/config.json"));
789        assert!(result.is_err());
790    }
791
792    #[test]
793    fn test_config_has_directories() {
794        let empty = CodeConfig::default();
795        assert!(!empty.has_directories());
796
797        let with_skills = CodeConfig::new().add_skill_dir("/tmp/skills");
798        assert!(with_skills.has_directories());
799
800        let with_agents = CodeConfig::new().add_agent_dir("/tmp/agents");
801        assert!(with_agents.has_directories());
802    }
803
804    #[test]
805    fn test_config_has_providers() {
806        let empty = CodeConfig::default();
807        assert!(!empty.has_providers());
808
809        let with_providers = CodeConfig {
810            providers: vec![ProviderConfig {
811                name: "test".to_string(),
812                api_key: None,
813                base_url: None,
814                models: vec![],
815            }],
816            ..Default::default()
817        };
818        assert!(with_providers.has_providers());
819    }
820
821    #[test]
822    fn test_storage_backend_equality() {
823        assert_eq!(StorageBackend::Memory, StorageBackend::Memory);
824        assert_eq!(StorageBackend::File, StorageBackend::File);
825        assert_ne!(StorageBackend::Memory, StorageBackend::File);
826    }
827
828    #[test]
829    fn test_storage_backend_serde_custom() {
830        let custom = StorageBackend::Custom;
831        // Custom variant is now serializable
832        let json = serde_json::to_string(&custom).unwrap();
833        assert_eq!(json, "\"custom\"");
834
835        // And deserializable
836        let parsed: StorageBackend = serde_json::from_str("\"custom\"").unwrap();
837        assert_eq!(parsed, StorageBackend::Custom);
838    }
839
840    #[test]
841    fn test_model_cost_default() {
842        let cost = ModelCost::default();
843        assert_eq!(cost.input, 0.0);
844        assert_eq!(cost.output, 0.0);
845        assert_eq!(cost.cache_read, 0.0);
846        assert_eq!(cost.cache_write, 0.0);
847    }
848
849    #[test]
850    fn test_model_cost_serialization() {
851        let cost = ModelCost {
852            input: 3.0,
853            output: 15.0,
854            cache_read: 0.3,
855            cache_write: 3.75,
856        };
857        let json = serde_json::to_string(&cost).unwrap();
858        assert!(json.contains("\"input\":3"));
859        assert!(json.contains("\"output\":15"));
860    }
861
862    #[test]
863    fn test_model_cost_deserialization_missing_fields() {
864        let json = r#"{"input":3.0}"#;
865        let cost: ModelCost = serde_json::from_str(json).unwrap();
866        assert_eq!(cost.input, 3.0);
867        assert_eq!(cost.output, 0.0);
868        assert_eq!(cost.cache_read, 0.0);
869        assert_eq!(cost.cache_write, 0.0);
870    }
871
872    #[test]
873    fn test_model_limit_default() {
874        let limit = ModelLimit::default();
875        assert_eq!(limit.context, 0);
876        assert_eq!(limit.output, 0);
877    }
878
879    #[test]
880    fn test_model_limit_serialization() {
881        let limit = ModelLimit {
882            context: 200000,
883            output: 8192,
884        };
885        let json = serde_json::to_string(&limit).unwrap();
886        assert!(json.contains("\"context\":200000"));
887        assert!(json.contains("\"output\":8192"));
888    }
889
890    #[test]
891    fn test_model_limit_deserialization_missing_fields() {
892        let json = r#"{"context":100000}"#;
893        let limit: ModelLimit = serde_json::from_str(json).unwrap();
894        assert_eq!(limit.context, 100000);
895        assert_eq!(limit.output, 0);
896    }
897
898    #[test]
899    fn test_model_modalities_default() {
900        let modalities = ModelModalities::default();
901        assert!(modalities.input.is_empty());
902        assert!(modalities.output.is_empty());
903    }
904
905    #[test]
906    fn test_model_modalities_serialization() {
907        let modalities = ModelModalities {
908            input: vec!["text".to_string(), "image".to_string()],
909            output: vec!["text".to_string()],
910        };
911        let json = serde_json::to_string(&modalities).unwrap();
912        assert!(json.contains("\"input\""));
913        assert!(json.contains("\"text\""));
914    }
915
916    #[test]
917    fn test_model_modalities_deserialization_missing_fields() {
918        let json = r#"{"input":["text"]}"#;
919        let modalities: ModelModalities = serde_json::from_str(json).unwrap();
920        assert_eq!(modalities.input.len(), 1);
921        assert!(modalities.output.is_empty());
922    }
923
924    #[test]
925    fn test_model_config_serialization() {
926        let config = ModelConfig {
927            id: "gpt-4o".to_string(),
928            name: "GPT-4o".to_string(),
929            family: "gpt-4".to_string(),
930            api_key: Some("sk-test".to_string()),
931            base_url: None,
932            attachment: true,
933            reasoning: false,
934            tool_call: true,
935            temperature: true,
936            release_date: Some("2024-05-13".to_string()),
937            modalities: ModelModalities::default(),
938            cost: ModelCost::default(),
939            limit: ModelLimit::default(),
940        };
941        let json = serde_json::to_string(&config).unwrap();
942        assert!(json.contains("\"id\":\"gpt-4o\""));
943        assert!(json.contains("\"attachment\":true"));
944    }
945
946    #[test]
947    fn test_model_config_deserialization_with_defaults() {
948        let json = r#"{"id":"test-model"}"#;
949        let config: ModelConfig = serde_json::from_str(json).unwrap();
950        assert_eq!(config.id, "test-model");
951        assert_eq!(config.name, "");
952        assert_eq!(config.family, "");
953        assert!(config.api_key.is_none());
954        assert!(!config.attachment);
955        assert!(config.tool_call);
956        assert!(config.temperature);
957    }
958
959    #[test]
960    fn test_model_config_all_optional_fields() {
961        let json = r#"{
962            "id": "claude-sonnet-4",
963            "name": "Claude Sonnet 4",
964            "family": "claude-sonnet",
965            "apiKey": "sk-test",
966            "baseUrl": "https://api.anthropic.com",
967            "attachment": true,
968            "reasoning": true,
969            "toolCall": false,
970            "temperature": false,
971            "releaseDate": "2025-05-14"
972        }"#;
973        let config: ModelConfig = serde_json::from_str(json).unwrap();
974        assert_eq!(config.id, "claude-sonnet-4");
975        assert_eq!(config.name, "Claude Sonnet 4");
976        assert_eq!(config.api_key, Some("sk-test".to_string()));
977        assert_eq!(
978            config.base_url,
979            Some("https://api.anthropic.com".to_string())
980        );
981        assert!(config.attachment);
982        assert!(config.reasoning);
983        assert!(!config.tool_call);
984        assert!(!config.temperature);
985    }
986
987    #[test]
988    fn test_provider_config_serialization() {
989        let provider = ProviderConfig {
990            name: "anthropic".to_string(),
991            api_key: Some("sk-test".to_string()),
992            base_url: Some("https://api.anthropic.com".to_string()),
993            models: vec![],
994        };
995        let json = serde_json::to_string(&provider).unwrap();
996        assert!(json.contains("\"name\":\"anthropic\""));
997        assert!(json.contains("\"apiKey\":\"sk-test\""));
998    }
999
1000    #[test]
1001    fn test_provider_config_deserialization_missing_optional() {
1002        let json = r#"{"name":"openai"}"#;
1003        let provider: ProviderConfig = serde_json::from_str(json).unwrap();
1004        assert_eq!(provider.name, "openai");
1005        assert!(provider.api_key.is_none());
1006        assert!(provider.base_url.is_none());
1007        assert!(provider.models.is_empty());
1008    }
1009
1010    #[test]
1011    fn test_provider_config_find_model() {
1012        let provider = ProviderConfig {
1013            name: "anthropic".to_string(),
1014            api_key: None,
1015            base_url: None,
1016            models: vec![ModelConfig {
1017                id: "claude-sonnet-4".to_string(),
1018                name: "Claude Sonnet 4".to_string(),
1019                family: "claude-sonnet".to_string(),
1020                api_key: None,
1021                base_url: None,
1022                attachment: false,
1023                reasoning: false,
1024                tool_call: true,
1025                temperature: true,
1026                release_date: None,
1027                modalities: ModelModalities::default(),
1028                cost: ModelCost::default(),
1029                limit: ModelLimit::default(),
1030            }],
1031        };
1032
1033        let found = provider.find_model("claude-sonnet-4");
1034        assert!(found.is_some());
1035        assert_eq!(found.unwrap().id, "claude-sonnet-4");
1036
1037        let not_found = provider.find_model("gpt-4o");
1038        assert!(not_found.is_none());
1039    }
1040
1041    #[test]
1042    fn test_provider_config_get_api_key() {
1043        let provider = ProviderConfig {
1044            name: "anthropic".to_string(),
1045            api_key: Some("provider-key".to_string()),
1046            base_url: None,
1047            models: vec![],
1048        };
1049
1050        let model_with_key = ModelConfig {
1051            id: "test".to_string(),
1052            name: "".to_string(),
1053            family: "".to_string(),
1054            api_key: Some("model-key".to_string()),
1055            base_url: None,
1056            attachment: false,
1057            reasoning: false,
1058            tool_call: true,
1059            temperature: true,
1060            release_date: None,
1061            modalities: ModelModalities::default(),
1062            cost: ModelCost::default(),
1063            limit: ModelLimit::default(),
1064        };
1065
1066        let model_without_key = ModelConfig {
1067            id: "test2".to_string(),
1068            name: "".to_string(),
1069            family: "".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        assert_eq!(provider.get_api_key(&model_with_key), Some("model-key"));
1083        assert_eq!(
1084            provider.get_api_key(&model_without_key),
1085            Some("provider-key")
1086        );
1087    }
1088
1089    #[test]
1090    fn test_code_config_from_file_invalid_json() {
1091        let temp_dir = tempfile::tempdir().unwrap();
1092        let config_path = temp_dir.path().join("config.json");
1093        std::fs::write(&config_path, "invalid json {").unwrap();
1094
1095        let result = CodeConfig::from_file(&config_path);
1096        assert!(result.is_err());
1097    }
1098
1099    #[test]
1100    fn test_code_config_default_provider_config() {
1101        let config = CodeConfig {
1102            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1103            providers: vec![ProviderConfig {
1104                name: "anthropic".to_string(),
1105                api_key: Some("sk-test".to_string()),
1106                base_url: None,
1107                models: vec![],
1108            }],
1109            ..Default::default()
1110        };
1111
1112        let provider = config.default_provider_config();
1113        assert!(provider.is_some());
1114        assert_eq!(provider.unwrap().name, "anthropic");
1115    }
1116
1117    #[test]
1118    fn test_code_config_default_model_config() {
1119        let config = CodeConfig {
1120            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1121            providers: vec![ProviderConfig {
1122                name: "anthropic".to_string(),
1123                api_key: Some("sk-test".to_string()),
1124                base_url: None,
1125                models: vec![ModelConfig {
1126                    id: "claude-sonnet-4".to_string(),
1127                    name: "Claude Sonnet 4".to_string(),
1128                    family: "claude-sonnet".to_string(),
1129                    api_key: None,
1130                    base_url: None,
1131                    attachment: false,
1132                    reasoning: false,
1133                    tool_call: true,
1134                    temperature: true,
1135                    release_date: None,
1136                    modalities: ModelModalities::default(),
1137                    cost: ModelCost::default(),
1138                    limit: ModelLimit::default(),
1139                }],
1140            }],
1141            ..Default::default()
1142        };
1143
1144        let result = config.default_model_config();
1145        assert!(result.is_some());
1146        let (provider, model) = result.unwrap();
1147        assert_eq!(provider.name, "anthropic");
1148        assert_eq!(model.id, "claude-sonnet-4");
1149    }
1150
1151    #[test]
1152    fn test_code_config_default_llm_config() {
1153        let config = CodeConfig {
1154            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1155            providers: vec![ProviderConfig {
1156                name: "anthropic".to_string(),
1157                api_key: Some("sk-test".to_string()),
1158                base_url: Some("https://api.anthropic.com".to_string()),
1159                models: vec![ModelConfig {
1160                    id: "claude-sonnet-4".to_string(),
1161                    name: "Claude Sonnet 4".to_string(),
1162                    family: "claude-sonnet".to_string(),
1163                    api_key: None,
1164                    base_url: None,
1165                    attachment: false,
1166                    reasoning: false,
1167                    tool_call: true,
1168                    temperature: true,
1169                    release_date: None,
1170                    modalities: ModelModalities::default(),
1171                    cost: ModelCost::default(),
1172                    limit: ModelLimit::default(),
1173                }],
1174            }],
1175            ..Default::default()
1176        };
1177
1178        let llm_config = config.default_llm_config();
1179        assert!(llm_config.is_some());
1180    }
1181
1182    #[test]
1183    fn test_code_config_list_models() {
1184        let config = CodeConfig {
1185            providers: vec![
1186                ProviderConfig {
1187                    name: "anthropic".to_string(),
1188                    api_key: None,
1189                    base_url: None,
1190                    models: vec![ModelConfig {
1191                        id: "claude-sonnet-4".to_string(),
1192                        name: "".to_string(),
1193                        family: "".to_string(),
1194                        api_key: None,
1195                        base_url: None,
1196                        attachment: false,
1197                        reasoning: false,
1198                        tool_call: true,
1199                        temperature: true,
1200                        release_date: None,
1201                        modalities: ModelModalities::default(),
1202                        cost: ModelCost::default(),
1203                        limit: ModelLimit::default(),
1204                    }],
1205                },
1206                ProviderConfig {
1207                    name: "openai".to_string(),
1208                    api_key: None,
1209                    base_url: None,
1210                    models: vec![ModelConfig {
1211                        id: "gpt-4o".to_string(),
1212                        name: "".to_string(),
1213                        family: "".to_string(),
1214                        api_key: None,
1215                        base_url: None,
1216                        attachment: false,
1217                        reasoning: false,
1218                        tool_call: true,
1219                        temperature: true,
1220                        release_date: None,
1221                        modalities: ModelModalities::default(),
1222                        cost: ModelCost::default(),
1223                        limit: ModelLimit::default(),
1224                    }],
1225                },
1226            ],
1227            ..Default::default()
1228        };
1229
1230        let models = config.list_models();
1231        assert_eq!(models.len(), 2);
1232    }
1233
1234    #[test]
1235    fn test_llm_config_specific_provider_model() {
1236        let model: ModelConfig = serde_json::from_value(serde_json::json!({
1237            "id": "claude-3",
1238            "name": "Claude 3"
1239        }))
1240        .unwrap();
1241
1242        let config = CodeConfig {
1243            providers: vec![ProviderConfig {
1244                name: "anthropic".to_string(),
1245                api_key: Some("sk-test".to_string()),
1246                base_url: None,
1247                models: vec![model],
1248            }],
1249            ..Default::default()
1250        };
1251
1252        let llm = config.llm_config("anthropic", "claude-3");
1253        assert!(llm.is_some());
1254        let llm = llm.unwrap();
1255        assert_eq!(llm.provider, "anthropic");
1256        assert_eq!(llm.model, "claude-3");
1257    }
1258
1259    #[test]
1260    fn test_llm_config_missing_provider() {
1261        let config = CodeConfig::default();
1262        assert!(config.llm_config("nonexistent", "model").is_none());
1263    }
1264
1265    #[test]
1266    fn test_llm_config_missing_model() {
1267        let config = CodeConfig {
1268            providers: vec![ProviderConfig {
1269                name: "anthropic".to_string(),
1270                api_key: Some("sk-test".to_string()),
1271                base_url: None,
1272                models: vec![],
1273            }],
1274            ..Default::default()
1275        };
1276        assert!(config.llm_config("anthropic", "nonexistent").is_none());
1277    }
1278
1279    #[test]
1280    fn test_save_to_file_and_load() {
1281        let temp_dir = tempfile::tempdir().unwrap();
1282        let config_path = temp_dir.path().join("config.json");
1283
1284        let config = CodeConfig {
1285            default_model: Some("anthropic/claude-sonnet-4".to_string()),
1286            providers: vec![ProviderConfig {
1287                name: "anthropic".to_string(),
1288                api_key: Some("test-key".to_string()),
1289                base_url: Some("https://api.anthropic.com".to_string()),
1290                models: vec![],
1291            }],
1292            storage_backend: StorageBackend::Memory,
1293            ..Default::default()
1294        };
1295
1296        config.save_to_file(&config_path).unwrap();
1297
1298        let loaded = CodeConfig::from_file(&config_path).unwrap();
1299        assert_eq!(
1300            loaded.default_model,
1301            Some("anthropic/claude-sonnet-4".to_string())
1302        );
1303        assert_eq!(loaded.providers.len(), 1);
1304        assert_eq!(loaded.providers[0].name, "anthropic");
1305        assert_eq!(loaded.storage_backend, StorageBackend::Memory);
1306    }
1307
1308    #[test]
1309    fn test_save_to_file_creates_parent_dirs() {
1310        let temp_dir = tempfile::tempdir().unwrap();
1311        let config_path = temp_dir
1312            .path()
1313            .join("nested")
1314            .join("dir")
1315            .join("config.json");
1316
1317        let config = CodeConfig::default();
1318        config.save_to_file(&config_path).unwrap();
1319
1320        assert!(config_path.exists());
1321    }
1322
1323    #[test]
1324    fn test_from_json_string() {
1325        let json = r#"{
1326            "defaultModel": "anthropic/claude-sonnet-4",
1327            "providers": [{
1328                "name": "anthropic",
1329                "apiKey": "test-key",
1330                "models": [{"id": "claude-sonnet-4"}]
1331            }]
1332        }"#;
1333
1334        let config = CodeConfig::from_json(json).unwrap();
1335        assert_eq!(
1336            config.default_model,
1337            Some("anthropic/claude-sonnet-4".to_string())
1338        );
1339        assert_eq!(config.providers.len(), 1);
1340    }
1341
1342    #[test]
1343    fn test_from_hcl_string() {
1344        let hcl = r#"
1345            default_model = "anthropic/claude-sonnet-4"
1346
1347            providers {
1348                name    = "anthropic"
1349                api_key = "test-key"
1350
1351                models {
1352                    id   = "claude-sonnet-4"
1353                    name = "Claude Sonnet 4"
1354                }
1355            }
1356        "#;
1357
1358        let config = CodeConfig::from_hcl(hcl).unwrap();
1359        assert_eq!(
1360            config.default_model,
1361            Some("anthropic/claude-sonnet-4".to_string())
1362        );
1363        assert_eq!(config.providers.len(), 1);
1364        assert_eq!(config.providers[0].name, "anthropic");
1365        assert_eq!(config.providers[0].models.len(), 1);
1366        assert_eq!(config.providers[0].models[0].id, "claude-sonnet-4");
1367    }
1368
1369    #[test]
1370    fn test_from_hcl_multi_provider() {
1371        let hcl = r#"
1372            default_model = "anthropic/claude-sonnet-4"
1373
1374            providers {
1375                name    = "anthropic"
1376                api_key = "sk-ant-test"
1377
1378                models {
1379                    id   = "claude-sonnet-4"
1380                    name = "Claude Sonnet 4"
1381                }
1382
1383                models {
1384                    id        = "claude-opus-4"
1385                    name      = "Claude Opus 4"
1386                    reasoning = true
1387                }
1388            }
1389
1390            providers {
1391                name    = "openai"
1392                api_key = "sk-test"
1393
1394                models {
1395                    id   = "gpt-4o"
1396                    name = "GPT-4o"
1397                }
1398            }
1399        "#;
1400
1401        let config = CodeConfig::from_hcl(hcl).unwrap();
1402        assert_eq!(config.providers.len(), 2);
1403        assert_eq!(config.providers[0].models.len(), 2);
1404        assert_eq!(config.providers[1].models.len(), 1);
1405        assert_eq!(config.providers[1].name, "openai");
1406    }
1407
1408    #[test]
1409    fn test_snake_to_camel() {
1410        assert_eq!(snake_to_camel("default_model"), "defaultModel");
1411        assert_eq!(snake_to_camel("api_key"), "apiKey");
1412        assert_eq!(snake_to_camel("base_url"), "baseUrl");
1413        assert_eq!(snake_to_camel("name"), "name");
1414        assert_eq!(snake_to_camel("tool_call"), "toolCall");
1415    }
1416
1417    #[test]
1418    fn test_from_file_auto_detect_hcl() {
1419        let temp_dir = tempfile::tempdir().unwrap();
1420        let config_path = temp_dir.path().join("config.hcl");
1421
1422        std::fs::write(
1423            &config_path,
1424            r#"
1425            default_model = "anthropic/claude-sonnet-4"
1426
1427            providers {
1428                name    = "anthropic"
1429                api_key = "test-key"
1430
1431                models {
1432                    id = "claude-sonnet-4"
1433                }
1434            }
1435        "#,
1436        )
1437        .unwrap();
1438
1439        let config = CodeConfig::from_file(&config_path).unwrap();
1440        assert_eq!(
1441            config.default_model,
1442            Some("anthropic/claude-sonnet-4".to_string())
1443        );
1444    }
1445}