Skip to main content

codineer_runtime/config/
loader.rs

1use std::borrow::ToOwned;
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::json::JsonValue;
7use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
8
9use super::types::*;
10
11#[must_use]
12pub fn default_config_home() -> PathBuf {
13    std::env::var_os("CODINEER_CONFIG_HOME")
14        .map(PathBuf::from)
15        .or_else(|| crate::home_dir().map(|home| home.join(".codineer")))
16        .unwrap_or_else(|| PathBuf::from(".codineer"))
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ConfigLoader {
21    cwd: PathBuf,
22    config_home: PathBuf,
23}
24
25impl ConfigLoader {
26    #[must_use]
27    pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
28        Self {
29            cwd: cwd.into(),
30            config_home: config_home.into(),
31        }
32    }
33
34    #[must_use]
35    pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
36        let cwd = cwd.into();
37        let config_home = default_config_home();
38        Self { cwd, config_home }
39    }
40
41    #[must_use]
42    pub fn config_home(&self) -> &Path {
43        &self.config_home
44    }
45
46    #[must_use]
47    pub fn discover(&self) -> Vec<ConfigEntry> {
48        let user_flat_config = self.config_home.parent().map_or_else(
49            || PathBuf::from(".codineer.json"),
50            |parent| parent.join(".codineer.json"),
51        );
52        vec![
53            ConfigEntry {
54                source: ConfigSource::User,
55                path: user_flat_config,
56            },
57            ConfigEntry {
58                source: ConfigSource::User,
59                path: self.config_home.join("settings.json"),
60            },
61            ConfigEntry {
62                source: ConfigSource::Project,
63                path: self.cwd.join(".codineer.json"),
64            },
65            ConfigEntry {
66                source: ConfigSource::Project,
67                path: self.cwd.join(".codineer").join("settings.json"),
68            },
69            ConfigEntry {
70                source: ConfigSource::Local,
71                path: self.cwd.join(".codineer").join("settings.local.json"),
72            },
73        ]
74    }
75
76    pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
77        let mut merged = BTreeMap::new();
78        let mut loaded_entries = Vec::new();
79        let mut mcp_servers = BTreeMap::new();
80
81        let mut config_warnings = Vec::new();
82        for entry in self.discover() {
83            let Some(value) = read_optional_json_object(&entry.path, &mut config_warnings)? else {
84                continue;
85            };
86            merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
87            deep_merge_objects(&mut merged, &value);
88            loaded_entries.push(entry);
89        }
90
91        let merged_value = JsonValue::Object(merged.clone());
92
93        let feature_config = RuntimeFeatureConfig {
94            hooks: parse_optional_hooks_config(&merged_value)?,
95            plugins: parse_optional_plugin_config(&merged_value)?,
96            mcp: McpConfigCollection {
97                servers: mcp_servers,
98            },
99            oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
100            model: parse_optional_model(&merged_value),
101            permission_mode: parse_optional_permission_mode(&merged_value)?,
102            sandbox: parse_optional_sandbox_config(&merged_value)?,
103            providers: parse_optional_providers_config(&merged_value)?,
104        };
105
106        for w in &config_warnings {
107            eprintln!("warning: {w}");
108        }
109
110        Ok(RuntimeConfig::new(merged, loaded_entries, feature_config))
111    }
112}
113
114fn read_optional_json_object(
115    path: &Path,
116    warnings: &mut Vec<String>,
117) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
118    let is_flat_config = path.file_name().and_then(|name| name.to_str()) == Some(".codineer.json");
119    let contents = match fs::read_to_string(path) {
120        Ok(contents) => contents,
121        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
122        Err(error) => return Err(ConfigError::Io(error)),
123    };
124
125    if contents.trim().is_empty() {
126        return Ok(Some(BTreeMap::new()));
127    }
128
129    let parsed = match JsonValue::parse(&contents) {
130        Ok(parsed) => parsed,
131        Err(error) if is_flat_config => {
132            warnings.push(format!(
133                "ignoring malformed config '{}': {error}",
134                path.display()
135            ));
136            return Ok(None);
137        }
138        Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
139    };
140    let Some(object) = parsed.as_object() else {
141        if is_flat_config {
142            warnings.push(format!(
143                "ignoring config '{}': expected JSON object at top level",
144                path.display()
145            ));
146            return Ok(None);
147        }
148        return Err(ConfigError::Parse(format!(
149            "{}: top-level settings value must be a JSON object",
150            path.display()
151        )));
152    };
153    Ok(Some(object.clone()))
154}
155
156fn merge_mcp_servers(
157    target: &mut BTreeMap<String, ScopedMcpServerConfig>,
158    source: ConfigSource,
159    root: &BTreeMap<String, JsonValue>,
160    path: &Path,
161) -> Result<(), ConfigError> {
162    let Some(mcp_servers) = root.get("mcpServers") else {
163        return Ok(());
164    };
165    let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
166    for (name, value) in servers {
167        let parsed = parse_mcp_server_config(
168            name,
169            value,
170            &format!("{}: mcpServers.{name}", path.display()),
171        )?;
172        target.insert(
173            name.clone(),
174            ScopedMcpServerConfig {
175                scope: source,
176                config: parsed,
177            },
178        );
179    }
180    Ok(())
181}
182
183fn parse_optional_model(root: &JsonValue) -> Option<String> {
184    root.as_object()
185        .and_then(|object| object.get("model"))
186        .and_then(JsonValue::as_str)
187        .map(ToOwned::to_owned)
188}
189
190fn parse_optional_providers_config(
191    root: &JsonValue,
192) -> Result<BTreeMap<String, CustomProviderConfig>, ConfigError> {
193    let Some(object) = root.as_object() else {
194        return Ok(BTreeMap::new());
195    };
196    let Some(providers_value) = object.get("providers") else {
197        return Ok(BTreeMap::new());
198    };
199    let providers_obj = expect_object(providers_value, "merged settings.providers")?;
200    let mut result = BTreeMap::new();
201    for (name, value) in providers_obj {
202        let ctx = format!("merged settings.providers.{name}");
203        let provider_obj = expect_object(value, &ctx)?;
204        let base_url = expect_string(provider_obj, "baseUrl", &ctx)?.to_string();
205        let api_key = optional_string(provider_obj, "apiKey", &ctx)?.map(str::to_string);
206        let api_key_env = optional_string(provider_obj, "apiKeyEnv", &ctx)?.map(str::to_string);
207        let models = optional_string_array(provider_obj, "models", &ctx)?.unwrap_or_default();
208        let default_model =
209            optional_string(provider_obj, "defaultModel", &ctx)?.map(str::to_string);
210        result.insert(
211            name.clone(),
212            CustomProviderConfig {
213                base_url,
214                api_key,
215                api_key_env,
216                models,
217                default_model,
218            },
219        );
220    }
221    Ok(result)
222}
223
224fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
225    let Some(object) = root.as_object() else {
226        return Ok(RuntimeHookConfig::default());
227    };
228    let Some(hooks_value) = object.get("hooks") else {
229        return Ok(RuntimeHookConfig::default());
230    };
231    let hooks = expect_object(hooks_value, "merged settings.hooks")?;
232    Ok(RuntimeHookConfig {
233        pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
234            .unwrap_or_default(),
235        post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
236            .unwrap_or_default(),
237    })
238}
239
240fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
241    let Some(object) = root.as_object() else {
242        return Ok(RuntimePluginConfig::default());
243    };
244
245    let mut config = RuntimePluginConfig::default();
246    if let Some(enabled_plugins) = object.get("enabledPlugins") {
247        config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
248    }
249
250    let Some(plugins_value) = object.get("plugins") else {
251        return Ok(config);
252    };
253    let plugins = expect_object(plugins_value, "merged settings.plugins")?;
254
255    if let Some(enabled_value) = plugins.get("enabled") {
256        config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
257    }
258    config.external_directories =
259        optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
260            .unwrap_or_default();
261    config.install_root =
262        optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
263    config.registry_path =
264        optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
265    config.bundled_root =
266        optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
267    Ok(config)
268}
269
270fn parse_optional_permission_mode(
271    root: &JsonValue,
272) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
273    let Some(object) = root.as_object() else {
274        return Ok(None);
275    };
276    if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
277        return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
278    }
279    let Some(mode) = object
280        .get("permissions")
281        .and_then(JsonValue::as_object)
282        .and_then(|permissions| permissions.get("defaultMode"))
283        .and_then(JsonValue::as_str)
284    else {
285        return Ok(None);
286    };
287    parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
288}
289
290fn parse_permission_mode_label(
291    mode: &str,
292    context: &str,
293) -> Result<ResolvedPermissionMode, ConfigError> {
294    match mode {
295        "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
296        "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
297        "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
298        other => Err(ConfigError::Parse(format!(
299            "{context}: unsupported permission mode {other}"
300        ))),
301    }
302}
303
304fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
305    let Some(object) = root.as_object() else {
306        return Ok(SandboxConfig::default());
307    };
308    let Some(sandbox_value) = object.get("sandbox") else {
309        return Ok(SandboxConfig::default());
310    };
311    let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
312    let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
313        .map(parse_filesystem_mode_label)
314        .transpose()?;
315    Ok(SandboxConfig {
316        enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
317        namespace_restrictions: optional_bool(
318            sandbox,
319            "namespaceRestrictions",
320            "merged settings.sandbox",
321        )?,
322        network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
323        filesystem_mode,
324        allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
325            .unwrap_or_default(),
326    })
327}
328
329fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
330    match value {
331        "off" => Ok(FilesystemIsolationMode::Off),
332        "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
333        "allow-list" => Ok(FilesystemIsolationMode::AllowList),
334        other => Err(ConfigError::Parse(format!(
335            "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
336        ))),
337    }
338}
339
340fn parse_optional_oauth_config(
341    root: &JsonValue,
342    context: &str,
343) -> Result<Option<OAuthConfig>, ConfigError> {
344    let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
345        return Ok(None);
346    };
347    let object = expect_object(oauth_value, context)?;
348    let client_id = expect_string(object, "clientId", context)?.to_string();
349    let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
350    let token_url = expect_string(object, "tokenUrl", context)?.to_string();
351    let callback_port = optional_u16(object, "callbackPort", context)?;
352    let manual_redirect_url =
353        optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
354    let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
355    Ok(Some(OAuthConfig {
356        client_id,
357        authorize_url,
358        token_url,
359        callback_port,
360        manual_redirect_url,
361        scopes,
362    }))
363}
364
365fn parse_mcp_server_config(
366    server_name: &str,
367    value: &JsonValue,
368    context: &str,
369) -> Result<McpServerConfig, ConfigError> {
370    let object = expect_object(value, context)?;
371    let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
372    match server_type {
373        "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
374            command: expect_string(object, "command", context)?.to_string(),
375            args: optional_string_array(object, "args", context)?.unwrap_or_default(),
376            env: optional_string_map(object, "env", context)?.unwrap_or_default(),
377        })),
378        "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
379            object, context,
380        )?)),
381        "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
382            object, context,
383        )?)),
384        "ws" | "websocket" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
385            url: expect_string(object, "url", context)?.to_string(),
386            headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
387            headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
388        })),
389        "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
390            name: expect_string(object, "name", context)?.to_string(),
391        })),
392        "claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
393            url: expect_string(object, "url", context)?.to_string(),
394            id: expect_string(object, "id", context)?.to_string(),
395        })),
396        other => Err(ConfigError::Parse(format!(
397            "{context}: unsupported MCP server type for {server_name}: {other}"
398        ))),
399    }
400}
401
402fn parse_mcp_remote_server_config(
403    object: &BTreeMap<String, JsonValue>,
404    context: &str,
405) -> Result<McpRemoteServerConfig, ConfigError> {
406    Ok(McpRemoteServerConfig {
407        url: expect_string(object, "url", context)?.to_string(),
408        headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
409        headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
410        oauth: parse_optional_mcp_oauth_config(object, context)?,
411    })
412}
413
414fn parse_optional_mcp_oauth_config(
415    object: &BTreeMap<String, JsonValue>,
416    context: &str,
417) -> Result<Option<McpOAuthConfig>, ConfigError> {
418    let Some(value) = object.get("oauth") else {
419        return Ok(None);
420    };
421    let oauth = expect_object(value, &format!("{context}.oauth"))?;
422    Ok(Some(McpOAuthConfig {
423        client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
424        callback_port: optional_u16(oauth, "callbackPort", context)?,
425        auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
426            .map(str::to_string),
427        xaa: optional_bool(oauth, "xaa", context)?,
428    }))
429}
430
431fn expect_object<'a>(
432    value: &'a JsonValue,
433    context: &str,
434) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
435    value
436        .as_object()
437        .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
438}
439
440fn expect_string<'a>(
441    object: &'a BTreeMap<String, JsonValue>,
442    key: &str,
443    context: &str,
444) -> Result<&'a str, ConfigError> {
445    object
446        .get(key)
447        .and_then(JsonValue::as_str)
448        .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
449}
450
451fn optional_string<'a>(
452    object: &'a BTreeMap<String, JsonValue>,
453    key: &str,
454    context: &str,
455) -> Result<Option<&'a str>, ConfigError> {
456    match object.get(key) {
457        Some(value) => value
458            .as_str()
459            .map(Some)
460            .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
461        None => Ok(None),
462    }
463}
464
465fn optional_bool(
466    object: &BTreeMap<String, JsonValue>,
467    key: &str,
468    context: &str,
469) -> Result<Option<bool>, ConfigError> {
470    match object.get(key) {
471        Some(value) => value
472            .as_bool()
473            .map(Some)
474            .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
475        None => Ok(None),
476    }
477}
478
479fn optional_u16(
480    object: &BTreeMap<String, JsonValue>,
481    key: &str,
482    context: &str,
483) -> Result<Option<u16>, ConfigError> {
484    match object.get(key) {
485        Some(value) => {
486            let Some(number) = value.as_i64() else {
487                return Err(ConfigError::Parse(format!(
488                    "{context}: field {key} must be an integer"
489                )));
490            };
491            let number = u16::try_from(number).map_err(|_| {
492                ConfigError::Parse(format!("{context}: field {key} is out of range"))
493            })?;
494            Ok(Some(number))
495        }
496        None => Ok(None),
497    }
498}
499
500fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
501    let Some(map) = value.as_object() else {
502        return Err(ConfigError::Parse(format!(
503            "{context}: expected JSON object"
504        )));
505    };
506    map.iter()
507        .map(|(key, value)| {
508            value
509                .as_bool()
510                .map(|enabled| (key.clone(), enabled))
511                .ok_or_else(|| {
512                    ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
513                })
514        })
515        .collect()
516}
517
518fn optional_string_array(
519    object: &BTreeMap<String, JsonValue>,
520    key: &str,
521    context: &str,
522) -> Result<Option<Vec<String>>, ConfigError> {
523    match object.get(key) {
524        Some(value) => {
525            let Some(array) = value.as_array() else {
526                return Err(ConfigError::Parse(format!(
527                    "{context}: field {key} must be an array"
528                )));
529            };
530            array
531                .iter()
532                .map(|item| {
533                    item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
534                        ConfigError::Parse(format!(
535                            "{context}: field {key} must contain only strings"
536                        ))
537                    })
538                })
539                .collect::<Result<Vec<_>, _>>()
540                .map(Some)
541        }
542        None => Ok(None),
543    }
544}
545
546fn optional_string_map(
547    object: &BTreeMap<String, JsonValue>,
548    key: &str,
549    context: &str,
550) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
551    match object.get(key) {
552        Some(value) => {
553            let Some(map) = value.as_object() else {
554                return Err(ConfigError::Parse(format!(
555                    "{context}: field {key} must be an object"
556                )));
557            };
558            map.iter()
559                .map(|(entry_key, entry_value)| {
560                    entry_value
561                        .as_str()
562                        .map(|text| (entry_key.clone(), text.to_string()))
563                        .ok_or_else(|| {
564                            ConfigError::Parse(format!(
565                                "{context}: field {key} must contain only string values"
566                            ))
567                        })
568                })
569                .collect::<Result<BTreeMap<_, _>, _>>()
570                .map(Some)
571        }
572        None => Ok(None),
573    }
574}
575
576fn deep_merge_objects(
577    target: &mut BTreeMap<String, JsonValue>,
578    source: &BTreeMap<String, JsonValue>,
579) {
580    for (key, value) in source {
581        match (target.get_mut(key), value) {
582            (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
583                deep_merge_objects(existing, incoming);
584            }
585            _ => {
586                target.insert(key.clone(), value.clone());
587            }
588        }
589    }
590}