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 result.insert(
227 name.clone(),
228 CustomProviderConfig {
229 base_url,
230 api_version,
231 api_key,
232 api_key_env,
233 models,
234 default_model,
235 },
236 );
237 }
238 Ok(result)
239}
240
241fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
242 let Some(object) = root.as_object() else {
243 return Ok(RuntimeHookConfig::default());
244 };
245 let Some(hooks_value) = object.get("hooks") else {
246 return Ok(RuntimeHookConfig::default());
247 };
248 let hooks = expect_object(hooks_value, "merged settings.hooks")?;
249 Ok(RuntimeHookConfig {
250 pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
251 .unwrap_or_default(),
252 post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
253 .unwrap_or_default(),
254 })
255}
256
257fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
258 let Some(object) = root.as_object() else {
259 return Ok(RuntimePluginConfig::default());
260 };
261
262 let mut config = RuntimePluginConfig::default();
263 if let Some(enabled_plugins) = object.get("enabledPlugins") {
264 config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
265 }
266
267 let Some(plugins_value) = object.get("plugins") else {
268 return Ok(config);
269 };
270 let plugins = expect_object(plugins_value, "merged settings.plugins")?;
271
272 if let Some(enabled_value) = plugins.get("enabled") {
273 config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
274 }
275 config.external_directories =
276 optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
277 .unwrap_or_default();
278 config.install_root =
279 optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
280 config.registry_path =
281 optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
282 config.bundled_root =
283 optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
284 Ok(config)
285}
286
287fn parse_optional_permission_mode(
288 root: &JsonValue,
289) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
290 let Some(object) = root.as_object() else {
291 return Ok(None);
292 };
293 if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
294 return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
295 }
296 let Some(mode) = object
297 .get("permissions")
298 .and_then(JsonValue::as_object)
299 .and_then(|permissions| permissions.get("defaultMode"))
300 .and_then(JsonValue::as_str)
301 else {
302 return Ok(None);
303 };
304 parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
305}
306
307fn parse_permission_mode_label(
308 mode: &str,
309 context: &str,
310) -> Result<ResolvedPermissionMode, ConfigError> {
311 match mode {
312 "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
313 "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
314 "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
315 other => Err(ConfigError::Parse(format!(
316 "{context}: unsupported permission mode {other}"
317 ))),
318 }
319}
320
321fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
322 let Some(object) = root.as_object() else {
323 return Ok(SandboxConfig::default());
324 };
325 let Some(sandbox_value) = object.get("sandbox") else {
326 return Ok(SandboxConfig::default());
327 };
328 let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
329 let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
330 .map(parse_filesystem_mode_label)
331 .transpose()?;
332 Ok(SandboxConfig {
333 enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
334 namespace_restrictions: optional_bool(
335 sandbox,
336 "namespaceRestrictions",
337 "merged settings.sandbox",
338 )?,
339 network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
340 filesystem_mode,
341 allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
342 .unwrap_or_default(),
343 })
344}
345
346fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
347 match value {
348 "off" => Ok(FilesystemIsolationMode::Off),
349 "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
350 "allow-list" => Ok(FilesystemIsolationMode::AllowList),
351 other => Err(ConfigError::Parse(format!(
352 "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
353 ))),
354 }
355}
356
357fn parse_optional_oauth_config(
358 root: &JsonValue,
359 context: &str,
360) -> Result<Option<OAuthConfig>, ConfigError> {
361 let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
362 return Ok(None);
363 };
364 let object = expect_object(oauth_value, context)?;
365 let client_id = expect_string(object, "clientId", context)?.to_string();
366 let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
367 let token_url = expect_string(object, "tokenUrl", context)?.to_string();
368 let callback_port = optional_u16(object, "callbackPort", context)?;
369 let manual_redirect_url =
370 optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
371 let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
372 Ok(Some(OAuthConfig {
373 client_id,
374 authorize_url,
375 token_url,
376 callback_port,
377 manual_redirect_url,
378 scopes,
379 }))
380}
381
382fn parse_mcp_server_config(
383 server_name: &str,
384 value: &JsonValue,
385 context: &str,
386) -> Result<McpServerConfig, ConfigError> {
387 let object = expect_object(value, context)?;
388 let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
389 match server_type {
390 "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
391 command: expect_string(object, "command", context)?.to_string(),
392 args: optional_string_array(object, "args", context)?.unwrap_or_default(),
393 env: optional_string_map(object, "env", context)?.unwrap_or_default(),
394 })),
395 "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
396 object, context,
397 )?)),
398 "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
399 object, context,
400 )?)),
401 "ws" | "websocket" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
402 url: expect_string(object, "url", context)?.to_string(),
403 headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
404 headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
405 })),
406 "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
407 name: expect_string(object, "name", context)?.to_string(),
408 })),
409 "claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
410 url: expect_string(object, "url", context)?.to_string(),
411 id: expect_string(object, "id", context)?.to_string(),
412 })),
413 other => Err(ConfigError::Parse(format!(
414 "{context}: unsupported MCP server type for {server_name}: {other}"
415 ))),
416 }
417}
418
419fn parse_mcp_remote_server_config(
420 object: &BTreeMap<String, JsonValue>,
421 context: &str,
422) -> Result<McpRemoteServerConfig, ConfigError> {
423 Ok(McpRemoteServerConfig {
424 url: expect_string(object, "url", context)?.to_string(),
425 headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
426 headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
427 oauth: parse_optional_mcp_oauth_config(object, context)?,
428 })
429}
430
431fn parse_optional_mcp_oauth_config(
432 object: &BTreeMap<String, JsonValue>,
433 context: &str,
434) -> Result<Option<McpOAuthConfig>, ConfigError> {
435 let Some(value) = object.get("oauth") else {
436 return Ok(None);
437 };
438 let oauth = expect_object(value, &format!("{context}.oauth"))?;
439 Ok(Some(McpOAuthConfig {
440 client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
441 callback_port: optional_u16(oauth, "callbackPort", context)?,
442 auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
443 .map(str::to_string),
444 xaa: optional_bool(oauth, "xaa", context)?,
445 }))
446}
447
448fn expect_object<'a>(
449 value: &'a JsonValue,
450 context: &str,
451) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
452 value
453 .as_object()
454 .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
455}
456
457fn expect_string<'a>(
458 object: &'a BTreeMap<String, JsonValue>,
459 key: &str,
460 context: &str,
461) -> Result<&'a str, ConfigError> {
462 object
463 .get(key)
464 .and_then(JsonValue::as_str)
465 .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
466}
467
468fn optional_string<'a>(
469 object: &'a BTreeMap<String, JsonValue>,
470 key: &str,
471 context: &str,
472) -> Result<Option<&'a str>, ConfigError> {
473 match object.get(key) {
474 Some(value) => value
475 .as_str()
476 .map(Some)
477 .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
478 None => Ok(None),
479 }
480}
481
482fn optional_bool(
483 object: &BTreeMap<String, JsonValue>,
484 key: &str,
485 context: &str,
486) -> Result<Option<bool>, ConfigError> {
487 match object.get(key) {
488 Some(value) => value
489 .as_bool()
490 .map(Some)
491 .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
492 None => Ok(None),
493 }
494}
495
496fn optional_u16(
497 object: &BTreeMap<String, JsonValue>,
498 key: &str,
499 context: &str,
500) -> Result<Option<u16>, ConfigError> {
501 match object.get(key) {
502 Some(value) => {
503 let Some(number) = value.as_i64() else {
504 return Err(ConfigError::Parse(format!(
505 "{context}: field {key} must be an integer"
506 )));
507 };
508 let number = u16::try_from(number).map_err(|_| {
509 ConfigError::Parse(format!("{context}: field {key} is out of range"))
510 })?;
511 Ok(Some(number))
512 }
513 None => Ok(None),
514 }
515}
516
517fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
518 let Some(map) = value.as_object() else {
519 return Err(ConfigError::Parse(format!(
520 "{context}: expected JSON object"
521 )));
522 };
523 map.iter()
524 .map(|(key, value)| {
525 value
526 .as_bool()
527 .map(|enabled| (key.clone(), enabled))
528 .ok_or_else(|| {
529 ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
530 })
531 })
532 .collect()
533}
534
535fn optional_string_array(
536 object: &BTreeMap<String, JsonValue>,
537 key: &str,
538 context: &str,
539) -> Result<Option<Vec<String>>, ConfigError> {
540 match object.get(key) {
541 Some(value) => {
542 let Some(array) = value.as_array() else {
543 return Err(ConfigError::Parse(format!(
544 "{context}: field {key} must be an array"
545 )));
546 };
547 array
548 .iter()
549 .map(|item| {
550 item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
551 ConfigError::Parse(format!(
552 "{context}: field {key} must contain only strings"
553 ))
554 })
555 })
556 .collect::<Result<Vec<_>, _>>()
557 .map(Some)
558 }
559 None => Ok(None),
560 }
561}
562
563fn optional_string_map(
564 object: &BTreeMap<String, JsonValue>,
565 key: &str,
566 context: &str,
567) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
568 match object.get(key) {
569 Some(value) => {
570 let Some(map) = value.as_object() else {
571 return Err(ConfigError::Parse(format!(
572 "{context}: field {key} must be an object"
573 )));
574 };
575 map.iter()
576 .map(|(entry_key, entry_value)| {
577 entry_value
578 .as_str()
579 .map(|text| (entry_key.clone(), text.to_string()))
580 .ok_or_else(|| {
581 ConfigError::Parse(format!(
582 "{context}: field {key} must contain only string values"
583 ))
584 })
585 })
586 .collect::<Result<BTreeMap<_, _>, _>>()
587 .map(Some)
588 }
589 None => Ok(None),
590 }
591}
592
593fn parse_optional_credentials_config(root: &JsonValue) -> Result<CredentialConfig, ConfigError> {
594 let Some(object) = root.as_object() else {
595 return Ok(CredentialConfig::default());
596 };
597 let Some(cred_value) = object.get("credentials") else {
598 return Ok(CredentialConfig::default());
599 };
600 let cred = expect_object(cred_value, "merged settings.credentials")?;
601
602 let default_source =
603 optional_string(cred, "defaultSource", "merged settings.credentials")?.map(str::to_string);
604 let auto_discover =
605 optional_bool(cred, "autoDiscover", "merged settings.credentials")?.unwrap_or(true);
606
607 let claude_code_enabled = cred
608 .get("claudeCode")
609 .and_then(JsonValue::as_object)
610 .and_then(|obj| obj.get("enabled").and_then(JsonValue::as_bool))
611 .unwrap_or(true);
612
613 Ok(CredentialConfig {
614 default_source,
615 auto_discover,
616 claude_code_enabled: auto_discover && claude_code_enabled,
617 })
618}
619
620fn deep_merge_objects(
621 target: &mut BTreeMap<String, JsonValue>,
622 source: &BTreeMap<String, JsonValue>,
623) {
624 for (key, value) in source {
625 match (target.get_mut(key), value) {
626 (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
627 deep_merge_objects(existing, incoming);
628 }
629 _ => {
630 target.insert(key.clone(), value.clone());
631 }
632 }
633 }
634}