use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; use crate::json::JsonValue; pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema"; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ConfigSource { User, Project, Local, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigEntry { pub source: ConfigSource, pub path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfig { merged: BTreeMap, loaded_entries: Vec, } #[derive(Debug)] pub enum ConfigError { Io(std::io::Error), Parse(String), } impl Display for ConfigError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Io(error) => write!(f, "{error}"), Self::Parse(error) => write!(f, "{error}"), } } } impl std::error::Error for ConfigError {} impl From for ConfigError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigLoader { cwd: PathBuf, config_home: PathBuf, } impl ConfigLoader { #[must_use] pub fn new(cwd: impl Into, config_home: impl Into) -> Self { Self { cwd: cwd.into(), config_home: config_home.into(), } } #[must_use] pub fn default_for(cwd: impl Into) -> Self { let cwd = cwd.into(); let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) .unwrap_or_else(|| PathBuf::from(".claude")); Self { cwd, config_home } } #[must_use] pub fn discover(&self) -> Vec { vec![ ConfigEntry { source: ConfigSource::User, path: self.config_home.join("settings.json"), }, ConfigEntry { source: ConfigSource::Project, path: self.cwd.join(".claude").join("settings.json"), }, ConfigEntry { source: ConfigSource::Local, path: self.cwd.join(".claude").join("settings.local.json"), }, ] } pub fn load(&self) -> Result { let mut merged = BTreeMap::new(); let mut loaded_entries = Vec::new(); for entry in self.discover() { let Some(value) = read_optional_json_object(&entry.path)? else { continue; }; deep_merge_objects(&mut merged, &value); loaded_entries.push(entry); } Ok(RuntimeConfig { merged, loaded_entries, }) } } impl RuntimeConfig { #[must_use] pub fn empty() -> Self { Self { merged: BTreeMap::new(), loaded_entries: Vec::new(), } } #[must_use] pub fn merged(&self) -> &BTreeMap { &self.merged } #[must_use] pub fn loaded_entries(&self) -> &[ConfigEntry] { &self.loaded_entries } #[must_use] pub fn get(&self, key: &str) -> Option<&JsonValue> { self.merged.get(key) } #[must_use] pub fn as_json(&self) -> JsonValue { JsonValue::Object(self.merged.clone()) } } fn read_optional_json_object( path: &Path, ) -> Result>, ConfigError> { let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(ConfigError::Io(error)), }; if contents.trim().is_empty() { return Ok(Some(BTreeMap::new())); } let parsed = JsonValue::parse(&contents) .map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?; let object = parsed.as_object().ok_or_else(|| { ConfigError::Parse(format!( "{}: top-level settings value must be a JSON object", path.display() )) })?; Ok(Some(object.clone())) } fn deep_merge_objects( target: &mut BTreeMap, source: &BTreeMap, ) { for (key, value) in source { match (target.get_mut(key), value) { (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => { deep_merge_objects(existing, incoming); } _ => { target.insert(key.clone(), value.clone()); } } } } #[cfg(test)] mod tests { use super::{ConfigLoader, ConfigSource, CLAUDE_CODE_SETTINGS_SCHEMA_NAME}; use crate::json::JsonValue; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_dir() -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("runtime-config-{nanos}")) } #[test] fn rejects_non_object_settings_files() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(&home).expect("home config dir"); fs::create_dir_all(&cwd).expect("project dir"); fs::write(home.join("settings.json"), "[]").expect("write bad settings"); let error = ConfigLoader::new(&cwd, &home) .load() .expect_err("config should fail"); assert!(error .to_string() .contains("top-level settings value must be a JSON object")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn loads_and_merges_claude_code_config_files_by_precedence() { let root = temp_dir(); let cwd = root.join("project"); let home = root.join("home").join(".claude"); fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(&home).expect("home config dir"); fs::write( home.join("settings.json"), r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#, ) .expect("write user settings"); fs::write( cwd.join(".claude").join("settings.json"), r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#, ) .expect("write project settings"); fs::write( cwd.join(".claude").join("settings.local.json"), r#"{"model":"opus","permissionMode":"acceptEdits"}"#, ) .expect("write local settings"); let loaded = ConfigLoader::new(&cwd, &home) .load() .expect("config should load"); assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema"); assert_eq!(loaded.loaded_entries().len(), 3); assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User); assert_eq!( loaded.get("model"), Some(&JsonValue::String("opus".to_string())) ); assert_eq!( loaded .get("env") .and_then(JsonValue::as_object) .expect("env object") .len(), 2 ); assert!(loaded .get("hooks") .and_then(JsonValue::as_object) .expect("hooks object") .contains_key("PreToolUse")); fs::remove_dir_all(root).expect("cleanup temp dir"); } }