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