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