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