Skip to main content

a3s_code_core/config/
loader.rs

1use super::provider::{
2    apply_model_caps, ModelConfig, ModelCost, ModelLimit, ModelModalities, ProviderConfig,
3};
4use super::{CodeConfig, StorageBackend};
5use crate::error::{CodeError, Result};
6use crate::llm::LlmConfig;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10// ============================================================================
11// ACL Parsing Helpers
12// ============================================================================
13
14fn acl_attr<'a>(block: &'a a3s_acl::Block, keys: &[&str]) -> Option<&'a a3s_acl::Value> {
15    keys.iter().find_map(|key| block.attributes.get(*key))
16}
17
18fn acl_string(value: &a3s_acl::Value) -> Option<String> {
19    match value {
20        a3s_acl::Value::String(s) => Some(s.clone()),
21        a3s_acl::Value::Call(name, args) if name == "env" => {
22            let var_name = args.first().and_then(acl_string)?;
23            std::env::var(var_name).ok()
24        }
25        _ => None,
26    }
27}
28
29fn acl_string_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
30    acl_attr(block, keys).and_then(acl_string)
31}
32
33fn acl_label_or_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<String> {
34    block
35        .labels
36        .first()
37        .cloned()
38        .or_else(|| acl_string_attr(block, keys))
39}
40
41fn acl_bool_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<bool> {
42    match acl_attr(block, keys) {
43        Some(a3s_acl::Value::Bool(value)) => Some(*value),
44        _ => None,
45    }
46}
47
48fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<usize> {
49    match acl_attr(block, keys) {
50        Some(a3s_acl::Value::Number(value)) if *value >= 0.0 => Some(*value as usize),
51        _ => None,
52    }
53}
54
55fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<Vec<PathBuf>> {
56    let value = acl_attr(block, keys)?;
57    match value {
58        a3s_acl::Value::List(items) => Some(
59            items
60                .iter()
61                .filter_map(acl_string)
62                .map(PathBuf::from)
63                .collect(),
64        ),
65        _ => acl_string(value).map(|s| vec![PathBuf::from(s)]),
66    }
67}
68
69// ============================================================================
70// CodeConfig Implementation
71// ============================================================================
72
73impl CodeConfig {
74    /// Create a new empty configuration
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Load configuration from an ACL-compatible config file.
80    ///
81    /// `.acl` is the only supported config file extension. JSON and legacy
82    /// `.hcl` config files are not supported.
83    pub fn from_file(path: &Path) -> Result<Self> {
84        let content = std::fs::read_to_string(path).map_err(|e| {
85            CodeError::Config(format!(
86                "Failed to read config file {}: {}",
87                path.display(),
88                e
89            ))
90        })?;
91
92        Self::from_acl(&content).map_err(|e| {
93            CodeError::Config(format!(
94                "Failed to parse ACL config {}: {}",
95                path.display(),
96                e
97            ))
98        })
99    }
100
101    /// Parse configuration from an ACL string.
102    ///
103    /// ACL (Agent Configuration Language) uses labeled blocks like
104    /// `providers "openai" { }`.
105    pub fn from_acl(content: &str) -> Result<Self> {
106        use a3s_acl::parse_acl;
107
108        let doc = parse_acl(content)
109            .map_err(|e| CodeError::Config(format!("Failed to parse ACL: {}", e)))?;
110
111        let mut config = Self::default();
112
113        for block in doc.blocks {
114            match block.name.as_str() {
115                "default_model" => {
116                    // ACL: default_model = "openai/gpt-4" or just "openai/gpt-4" as label
117                    if let Some(default_model) = acl_label_or_attr(&block, &["default_model"]) {
118                        config.default_model = Some(default_model);
119                    }
120                }
121                "storage_backend" => {
122                    if let Some(backend) = acl_string_attr(&block, &["storage_backend"]) {
123                        config.storage_backend = match backend.to_ascii_lowercase().as_str() {
124                            "memory" => StorageBackend::Memory,
125                            "custom" => StorageBackend::Custom,
126                            _ => StorageBackend::File,
127                        };
128                    }
129                }
130                "sessions_dir" => {
131                    if let Some(path) = acl_string_attr(&block, &["sessions_dir"]) {
132                        config.sessions_dir = Some(PathBuf::from(path));
133                    }
134                }
135                "storage_url" => {
136                    if let Some(storage_url) = acl_string_attr(&block, &["storage_url"]) {
137                        config.storage_url = Some(storage_url);
138                    }
139                }
140                "skill_dirs" | "skills" => {
141                    if let Some(paths) = acl_path_list_attr(&block, &["skill_dirs", "skills"]) {
142                        config.skill_dirs = paths;
143                    }
144                }
145                "agent_dirs" => {
146                    if let Some(paths) = acl_path_list_attr(&block, &["agent_dirs"]) {
147                        config.agent_dirs = paths;
148                    }
149                }
150                "max_tool_rounds" => {
151                    if let Some(max_tool_rounds) = acl_usize_attr(&block, &["max_tool_rounds"]) {
152                        config.max_tool_rounds = Some(max_tool_rounds);
153                    }
154                }
155                "thinking_budget" => {
156                    if let Some(thinking_budget) = acl_usize_attr(&block, &["thinking_budget"]) {
157                        config.thinking_budget = Some(thinking_budget);
158                    }
159                }
160                "providers" => {
161                    let provider_name = block.labels.first().cloned().ok_or_else(|| {
162                        CodeError::Config(
163                            "providers block requires a label (e.g., providers \"openai\" { ... })"
164                                .into(),
165                        )
166                    })?;
167
168                    let mut provider = ProviderConfig {
169                        name: provider_name.clone(),
170                        api_key: None,
171                        base_url: None,
172                        headers: HashMap::new(),
173                        session_id_header: None,
174                        models: Vec::new(),
175                    };
176
177                    for (key, value) in &block.attributes {
178                        match key.as_str() {
179                            "apiKey" | "api_key" => {
180                                if let Some(api_key) = acl_string(value) {
181                                    provider.api_key = Some(api_key);
182                                }
183                            }
184                            "baseUrl" | "base_url" => {
185                                if let Some(base_url) = acl_string(value) {
186                                    provider.base_url = Some(base_url);
187                                }
188                            }
189                            "sessionIdHeader" | "session_id_header" => {
190                                if let Some(header) = acl_string(value) {
191                                    provider.session_id_header = Some(header);
192                                }
193                            }
194                            _ => {}
195                        }
196                    }
197
198                    // Process nested models blocks
199                    for model_block in &block.blocks {
200                        if model_block.name == "models" {
201                            let model_name =
202                                model_block.labels.first().cloned().ok_or_else(|| {
203                                    CodeError::Config(
204                                        "models block requires a label (e.g., models \"gpt-4\" { ... })"
205                                            .into(),
206                                    )
207                                })?;
208
209                            let mut model = ModelConfig {
210                                id: model_name.clone(),
211                                name: model_name.clone(),
212                                family: String::new(),
213                                api_key: None,
214                                base_url: None,
215                                headers: HashMap::new(),
216                                session_id_header: None,
217                                attachment: false,
218                                reasoning: false,
219                                tool_call: true,
220                                temperature: true,
221                                release_date: None,
222                                modalities: ModelModalities::default(),
223                                cost: ModelCost::default(),
224                                limit: ModelLimit::default(),
225                            };
226
227                            for (key, value) in &model_block.attributes {
228                                match key.as_str() {
229                                    "name" => {
230                                        if let Some(s) = acl_string(value) {
231                                            model.name = s;
232                                        }
233                                    }
234                                    "family" => {
235                                        if let Some(s) = acl_string(value) {
236                                            model.family = s;
237                                        }
238                                    }
239                                    "apiKey" | "api_key" => {
240                                        if let Some(api_key) = acl_string(value) {
241                                            model.api_key = Some(api_key);
242                                        }
243                                    }
244                                    "baseUrl" | "base_url" => {
245                                        if let Some(base_url) = acl_string(value) {
246                                            model.base_url = Some(base_url);
247                                        }
248                                    }
249                                    "sessionIdHeader" | "session_id_header" => {
250                                        if let Some(header) = acl_string(value) {
251                                            model.session_id_header = Some(header);
252                                        }
253                                    }
254                                    "attachment" => {
255                                        model.attachment =
256                                            acl_bool_attr(model_block, &["attachment"])
257                                                .unwrap_or(model.attachment);
258                                    }
259                                    "reasoning" => {
260                                        model.reasoning =
261                                            acl_bool_attr(model_block, &["reasoning"])
262                                                .unwrap_or(model.reasoning);
263                                    }
264                                    "toolCall" | "tool_call" => {
265                                        model.tool_call =
266                                            acl_bool_attr(model_block, &["toolCall", "tool_call"])
267                                                .unwrap_or(model.tool_call);
268                                    }
269                                    "temperature" => {
270                                        model.temperature =
271                                            acl_bool_attr(model_block, &["temperature"])
272                                                .unwrap_or(model.temperature);
273                                    }
274                                    "releaseDate" | "release_date" => {
275                                        if let Some(release_date) = acl_string(value) {
276                                            model.release_date = Some(release_date);
277                                        }
278                                    }
279                                    _ => {}
280                                }
281                            }
282
283                            provider.models.push(model);
284                        }
285                    }
286
287                    config.providers.push(provider);
288                }
289                _ => {
290                    // Other top-level blocks are not mapped by the lightweight
291                    // ACL loader yet (queue, search, memory, MCP, etc.).
292                }
293            }
294        }
295
296        Ok(config)
297    }
298
299    /// Find a provider by name
300    pub fn find_provider(&self, name: &str) -> Option<&ProviderConfig> {
301        self.providers.iter().find(|p| p.name == name)
302    }
303
304    /// Get the default provider configuration (parsed from `default_model` "provider/model" format)
305    pub fn default_provider_config(&self) -> Option<&ProviderConfig> {
306        let default = self.default_model.as_ref()?;
307        let (provider_name, _) = default.split_once('/')?;
308        self.find_provider(provider_name)
309    }
310
311    /// Get the default model configuration (parsed from `default_model` "provider/model" format)
312    pub fn default_model_config(&self) -> Option<(&ProviderConfig, &ModelConfig)> {
313        let default = self.default_model.as_ref()?;
314        let (provider_name, model_id) = default.split_once('/')?;
315        let provider = self.find_provider(provider_name)?;
316        let model = provider.find_model(model_id)?;
317        Some((provider, model))
318    }
319
320    /// Get LlmConfig for the default provider and model
321    ///
322    /// Returns None if default provider/model is not configured or API key is missing.
323    pub fn default_llm_config(&self) -> Option<LlmConfig> {
324        let (provider, model) = self.default_model_config()?;
325        let api_key = provider.get_api_key(model)?;
326        let base_url = provider.get_base_url(model);
327        let headers = provider.get_headers(model);
328        let session_id_header = provider.get_session_id_header(model);
329
330        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
331        if let Some(url) = base_url {
332            config = config.with_base_url(url);
333        }
334        if !headers.is_empty() {
335            config = config.with_headers(headers);
336        }
337        if let Some(header_name) = session_id_header {
338            config = config.with_session_id_header(header_name);
339        }
340        config = apply_model_caps(config, model, self.thinking_budget);
341        Some(config)
342    }
343
344    /// Get LlmConfig for a specific provider and model
345    ///
346    /// Returns None if provider/model is not found or API key is missing.
347    pub fn llm_config(&self, provider_name: &str, model_id: &str) -> Option<LlmConfig> {
348        let provider = self.find_provider(provider_name)?;
349        let model = provider.find_model(model_id)?;
350        let api_key = provider.get_api_key(model)?;
351        let base_url = provider.get_base_url(model);
352        let headers = provider.get_headers(model);
353        let session_id_header = provider.get_session_id_header(model);
354
355        let mut config = LlmConfig::new(&provider.name, &model.id, api_key);
356        if let Some(url) = base_url {
357            config = config.with_base_url(url);
358        }
359        if !headers.is_empty() {
360            config = config.with_headers(headers);
361        }
362        if let Some(header_name) = session_id_header {
363            config = config.with_session_id_header(header_name);
364        }
365        config = apply_model_caps(config, model, self.thinking_budget);
366        Some(config)
367    }
368
369    /// List all available models across all providers
370    pub fn list_models(&self) -> Vec<(&ProviderConfig, &ModelConfig)> {
371        self.providers
372            .iter()
373            .flat_map(|p| p.models.iter().map(move |m| (p, m)))
374            .collect()
375    }
376
377    /// Add a skill directory
378    pub fn add_skill_dir(mut self, dir: impl Into<PathBuf>) -> Self {
379        self.skill_dirs.push(dir.into());
380        self
381    }
382
383    /// Add an agent directory
384    pub fn add_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
385        self.agent_dirs.push(dir.into());
386        self
387    }
388
389    /// Check if any directories are configured
390    pub fn has_directories(&self) -> bool {
391        !self.skill_dirs.is_empty() || !self.agent_dirs.is_empty()
392    }
393
394    /// Check if provider configuration is available
395    pub fn has_providers(&self) -> bool {
396        !self.providers.is_empty()
397    }
398}