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}