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}