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