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