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