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(|| std::env::var_os("HOME").map(|home| PathBuf::from(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        };
104
105        for w in &config_warnings {
106            eprintln!("warning: {w}");
107        }
108
109        Ok(RuntimeConfig::new(merged, loaded_entries, feature_config))
110    }
111}
112
113fn read_optional_json_object(
114    path: &Path,
115    warnings: &mut Vec<String>,
116) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
117    let is_flat_config = path.file_name().and_then(|name| name.to_str()) == Some(".codineer.json");
118    let contents = match fs::read_to_string(path) {
119        Ok(contents) => contents,
120        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
121        Err(error) => return Err(ConfigError::Io(error)),
122    };
123
124    if contents.trim().is_empty() {
125        return Ok(Some(BTreeMap::new()));
126    }
127
128    let parsed = match JsonValue::parse(&contents) {
129        Ok(parsed) => parsed,
130        Err(error) if is_flat_config => {
131            warnings.push(format!(
132                "ignoring malformed config '{}': {error}",
133                path.display()
134            ));
135            return Ok(None);
136        }
137        Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
138    };
139    let Some(object) = parsed.as_object() else {
140        if is_flat_config {
141            warnings.push(format!(
142                "ignoring config '{}': expected JSON object at top level",
143                path.display()
144            ));
145            return Ok(None);
146        }
147        return Err(ConfigError::Parse(format!(
148            "{}: top-level settings value must be a JSON object",
149            path.display()
150        )));
151    };
152    Ok(Some(object.clone()))
153}
154
155fn merge_mcp_servers(
156    target: &mut BTreeMap<String, ScopedMcpServerConfig>,
157    source: ConfigSource,
158    root: &BTreeMap<String, JsonValue>,
159    path: &Path,
160) -> Result<(), ConfigError> {
161    let Some(mcp_servers) = root.get("mcpServers") else {
162        return Ok(());
163    };
164    let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
165    for (name, value) in servers {
166        let parsed = parse_mcp_server_config(
167            name,
168            value,
169            &format!("{}: mcpServers.{name}", path.display()),
170        )?;
171        target.insert(
172            name.clone(),
173            ScopedMcpServerConfig {
174                scope: source,
175                config: parsed,
176            },
177        );
178    }
179    Ok(())
180}
181
182fn parse_optional_model(root: &JsonValue) -> Option<String> {
183    root.as_object()
184        .and_then(|object| object.get("model"))
185        .and_then(JsonValue::as_str)
186        .map(ToOwned::to_owned)
187}
188
189fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
190    let Some(object) = root.as_object() else {
191        return Ok(RuntimeHookConfig::default());
192    };
193    let Some(hooks_value) = object.get("hooks") else {
194        return Ok(RuntimeHookConfig::default());
195    };
196    let hooks = expect_object(hooks_value, "merged settings.hooks")?;
197    Ok(RuntimeHookConfig {
198        pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
199            .unwrap_or_default(),
200        post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
201            .unwrap_or_default(),
202    })
203}
204
205fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
206    let Some(object) = root.as_object() else {
207        return Ok(RuntimePluginConfig::default());
208    };
209
210    let mut config = RuntimePluginConfig::default();
211    if let Some(enabled_plugins) = object.get("enabledPlugins") {
212        config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
213    }
214
215    let Some(plugins_value) = object.get("plugins") else {
216        return Ok(config);
217    };
218    let plugins = expect_object(plugins_value, "merged settings.plugins")?;
219
220    if let Some(enabled_value) = plugins.get("enabled") {
221        config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
222    }
223    config.external_directories =
224        optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
225            .unwrap_or_default();
226    config.install_root =
227        optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
228    config.registry_path =
229        optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
230    config.bundled_root =
231        optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
232    Ok(config)
233}
234
235fn parse_optional_permission_mode(
236    root: &JsonValue,
237) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
238    let Some(object) = root.as_object() else {
239        return Ok(None);
240    };
241    if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
242        return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
243    }
244    let Some(mode) = object
245        .get("permissions")
246        .and_then(JsonValue::as_object)
247        .and_then(|permissions| permissions.get("defaultMode"))
248        .and_then(JsonValue::as_str)
249    else {
250        return Ok(None);
251    };
252    parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
253}
254
255fn parse_permission_mode_label(
256    mode: &str,
257    context: &str,
258) -> Result<ResolvedPermissionMode, ConfigError> {
259    match mode {
260        "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
261        "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
262        "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
263        other => Err(ConfigError::Parse(format!(
264            "{context}: unsupported permission mode {other}"
265        ))),
266    }
267}
268
269fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
270    let Some(object) = root.as_object() else {
271        return Ok(SandboxConfig::default());
272    };
273    let Some(sandbox_value) = object.get("sandbox") else {
274        return Ok(SandboxConfig::default());
275    };
276    let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
277    let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
278        .map(parse_filesystem_mode_label)
279        .transpose()?;
280    Ok(SandboxConfig {
281        enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
282        namespace_restrictions: optional_bool(
283            sandbox,
284            "namespaceRestrictions",
285            "merged settings.sandbox",
286        )?,
287        network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
288        filesystem_mode,
289        allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
290            .unwrap_or_default(),
291    })
292}
293
294fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
295    match value {
296        "off" => Ok(FilesystemIsolationMode::Off),
297        "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
298        "allow-list" => Ok(FilesystemIsolationMode::AllowList),
299        other => Err(ConfigError::Parse(format!(
300            "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
301        ))),
302    }
303}
304
305fn parse_optional_oauth_config(
306    root: &JsonValue,
307    context: &str,
308) -> Result<Option<OAuthConfig>, ConfigError> {
309    let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
310        return Ok(None);
311    };
312    let object = expect_object(oauth_value, context)?;
313    let client_id = expect_string(object, "clientId", context)?.to_string();
314    let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
315    let token_url = expect_string(object, "tokenUrl", context)?.to_string();
316    let callback_port = optional_u16(object, "callbackPort", context)?;
317    let manual_redirect_url =
318        optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
319    let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
320    Ok(Some(OAuthConfig {
321        client_id,
322        authorize_url,
323        token_url,
324        callback_port,
325        manual_redirect_url,
326        scopes,
327    }))
328}
329
330fn parse_mcp_server_config(
331    server_name: &str,
332    value: &JsonValue,
333    context: &str,
334) -> Result<McpServerConfig, ConfigError> {
335    let object = expect_object(value, context)?;
336    let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
337    match server_type {
338        "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
339            command: expect_string(object, "command", context)?.to_string(),
340            args: optional_string_array(object, "args", context)?.unwrap_or_default(),
341            env: optional_string_map(object, "env", context)?.unwrap_or_default(),
342        })),
343        "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
344            object, context,
345        )?)),
346        "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
347            object, context,
348        )?)),
349        "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
350            url: expect_string(object, "url", context)?.to_string(),
351            headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
352            headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
353        })),
354        "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
355            name: expect_string(object, "name", context)?.to_string(),
356        })),
357        "claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
358            url: expect_string(object, "url", context)?.to_string(),
359            id: expect_string(object, "id", context)?.to_string(),
360        })),
361        other => Err(ConfigError::Parse(format!(
362            "{context}: unsupported MCP server type for {server_name}: {other}"
363        ))),
364    }
365}
366
367fn parse_mcp_remote_server_config(
368    object: &BTreeMap<String, JsonValue>,
369    context: &str,
370) -> Result<McpRemoteServerConfig, ConfigError> {
371    Ok(McpRemoteServerConfig {
372        url: expect_string(object, "url", context)?.to_string(),
373        headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
374        headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
375        oauth: parse_optional_mcp_oauth_config(object, context)?,
376    })
377}
378
379fn parse_optional_mcp_oauth_config(
380    object: &BTreeMap<String, JsonValue>,
381    context: &str,
382) -> Result<Option<McpOAuthConfig>, ConfigError> {
383    let Some(value) = object.get("oauth") else {
384        return Ok(None);
385    };
386    let oauth = expect_object(value, &format!("{context}.oauth"))?;
387    Ok(Some(McpOAuthConfig {
388        client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
389        callback_port: optional_u16(oauth, "callbackPort", context)?,
390        auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
391            .map(str::to_string),
392        xaa: optional_bool(oauth, "xaa", context)?,
393    }))
394}
395
396fn expect_object<'a>(
397    value: &'a JsonValue,
398    context: &str,
399) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
400    value
401        .as_object()
402        .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
403}
404
405fn expect_string<'a>(
406    object: &'a BTreeMap<String, JsonValue>,
407    key: &str,
408    context: &str,
409) -> Result<&'a str, ConfigError> {
410    object
411        .get(key)
412        .and_then(JsonValue::as_str)
413        .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
414}
415
416fn optional_string<'a>(
417    object: &'a BTreeMap<String, JsonValue>,
418    key: &str,
419    context: &str,
420) -> Result<Option<&'a str>, ConfigError> {
421    match object.get(key) {
422        Some(value) => value
423            .as_str()
424            .map(Some)
425            .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
426        None => Ok(None),
427    }
428}
429
430fn optional_bool(
431    object: &BTreeMap<String, JsonValue>,
432    key: &str,
433    context: &str,
434) -> Result<Option<bool>, ConfigError> {
435    match object.get(key) {
436        Some(value) => value
437            .as_bool()
438            .map(Some)
439            .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
440        None => Ok(None),
441    }
442}
443
444fn optional_u16(
445    object: &BTreeMap<String, JsonValue>,
446    key: &str,
447    context: &str,
448) -> Result<Option<u16>, ConfigError> {
449    match object.get(key) {
450        Some(value) => {
451            let Some(number) = value.as_i64() else {
452                return Err(ConfigError::Parse(format!(
453                    "{context}: field {key} must be an integer"
454                )));
455            };
456            let number = u16::try_from(number).map_err(|_| {
457                ConfigError::Parse(format!("{context}: field {key} is out of range"))
458            })?;
459            Ok(Some(number))
460        }
461        None => Ok(None),
462    }
463}
464
465fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
466    let Some(map) = value.as_object() else {
467        return Err(ConfigError::Parse(format!(
468            "{context}: expected JSON object"
469        )));
470    };
471    map.iter()
472        .map(|(key, value)| {
473            value
474                .as_bool()
475                .map(|enabled| (key.clone(), enabled))
476                .ok_or_else(|| {
477                    ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
478                })
479        })
480        .collect()
481}
482
483fn optional_string_array(
484    object: &BTreeMap<String, JsonValue>,
485    key: &str,
486    context: &str,
487) -> Result<Option<Vec<String>>, ConfigError> {
488    match object.get(key) {
489        Some(value) => {
490            let Some(array) = value.as_array() else {
491                return Err(ConfigError::Parse(format!(
492                    "{context}: field {key} must be an array"
493                )));
494            };
495            array
496                .iter()
497                .map(|item| {
498                    item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
499                        ConfigError::Parse(format!(
500                            "{context}: field {key} must contain only strings"
501                        ))
502                    })
503                })
504                .collect::<Result<Vec<_>, _>>()
505                .map(Some)
506        }
507        None => Ok(None),
508    }
509}
510
511fn optional_string_map(
512    object: &BTreeMap<String, JsonValue>,
513    key: &str,
514    context: &str,
515) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
516    match object.get(key) {
517        Some(value) => {
518            let Some(map) = value.as_object() else {
519                return Err(ConfigError::Parse(format!(
520                    "{context}: field {key} must be an object"
521                )));
522            };
523            map.iter()
524                .map(|(entry_key, entry_value)| {
525                    entry_value
526                        .as_str()
527                        .map(|text| (entry_key.clone(), text.to_string()))
528                        .ok_or_else(|| {
529                            ConfigError::Parse(format!(
530                                "{context}: field {key} must contain only string values"
531                            ))
532                        })
533                })
534                .collect::<Result<BTreeMap<_, _>, _>>()
535                .map(Some)
536        }
537        None => Ok(None),
538    }
539}
540
541fn deep_merge_objects(
542    target: &mut BTreeMap<String, JsonValue>,
543    source: &BTreeMap<String, JsonValue>,
544) {
545    for (key, value) in source {
546        match (target.get_mut(key), value) {
547            (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
548                deep_merge_objects(existing, incoming);
549            }
550            _ => {
551                target.insert(key.clone(), value.clone());
552            }
553        }
554    }
555}