config.rs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. use std::collections::BTreeMap;
  2. use std::fmt::{Display, Formatter};
  3. use std::fs;
  4. use std::path::{Path, PathBuf};
  5. use crate::json::JsonValue;
  6. pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
  7. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  8. pub enum ConfigSource {
  9. User,
  10. Project,
  11. Local,
  12. }
  13. #[derive(Debug, Clone, PartialEq, Eq)]
  14. pub struct ConfigEntry {
  15. pub source: ConfigSource,
  16. pub path: PathBuf,
  17. }
  18. #[derive(Debug, Clone, PartialEq, Eq)]
  19. pub struct RuntimeConfig {
  20. merged: BTreeMap<String, JsonValue>,
  21. loaded_entries: Vec<ConfigEntry>,
  22. }
  23. #[derive(Debug)]
  24. pub enum ConfigError {
  25. Io(std::io::Error),
  26. Parse(String),
  27. }
  28. impl Display for ConfigError {
  29. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  30. match self {
  31. Self::Io(error) => write!(f, "{error}"),
  32. Self::Parse(error) => write!(f, "{error}"),
  33. }
  34. }
  35. }
  36. impl std::error::Error for ConfigError {}
  37. impl From<std::io::Error> for ConfigError {
  38. fn from(value: std::io::Error) -> Self {
  39. Self::Io(value)
  40. }
  41. }
  42. #[derive(Debug, Clone, PartialEq, Eq)]
  43. pub struct ConfigLoader {
  44. cwd: PathBuf,
  45. config_home: PathBuf,
  46. }
  47. impl ConfigLoader {
  48. #[must_use]
  49. pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
  50. Self {
  51. cwd: cwd.into(),
  52. config_home: config_home.into(),
  53. }
  54. }
  55. #[must_use]
  56. pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
  57. let cwd = cwd.into();
  58. let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
  59. .map(PathBuf::from)
  60. .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
  61. .unwrap_or_else(|| PathBuf::from(".claude"));
  62. Self { cwd, config_home }
  63. }
  64. #[must_use]
  65. pub fn discover(&self) -> Vec<ConfigEntry> {
  66. vec![
  67. ConfigEntry {
  68. source: ConfigSource::User,
  69. path: self.config_home.join("settings.json"),
  70. },
  71. ConfigEntry {
  72. source: ConfigSource::Project,
  73. path: self.cwd.join(".claude").join("settings.json"),
  74. },
  75. ConfigEntry {
  76. source: ConfigSource::Local,
  77. path: self.cwd.join(".claude").join("settings.local.json"),
  78. },
  79. ]
  80. }
  81. pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
  82. let mut merged = BTreeMap::new();
  83. let mut loaded_entries = Vec::new();
  84. for entry in self.discover() {
  85. let Some(value) = read_optional_json_object(&entry.path)? else {
  86. continue;
  87. };
  88. deep_merge_objects(&mut merged, &value);
  89. loaded_entries.push(entry);
  90. }
  91. Ok(RuntimeConfig {
  92. merged,
  93. loaded_entries,
  94. })
  95. }
  96. }
  97. impl RuntimeConfig {
  98. #[must_use]
  99. pub fn empty() -> Self {
  100. Self {
  101. merged: BTreeMap::new(),
  102. loaded_entries: Vec::new(),
  103. }
  104. }
  105. #[must_use]
  106. pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
  107. &self.merged
  108. }
  109. #[must_use]
  110. pub fn loaded_entries(&self) -> &[ConfigEntry] {
  111. &self.loaded_entries
  112. }
  113. #[must_use]
  114. pub fn get(&self, key: &str) -> Option<&JsonValue> {
  115. self.merged.get(key)
  116. }
  117. #[must_use]
  118. pub fn as_json(&self) -> JsonValue {
  119. JsonValue::Object(self.merged.clone())
  120. }
  121. }
  122. fn read_optional_json_object(
  123. path: &Path,
  124. ) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
  125. let contents = match fs::read_to_string(path) {
  126. Ok(contents) => contents,
  127. Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
  128. Err(error) => return Err(ConfigError::Io(error)),
  129. };
  130. if contents.trim().is_empty() {
  131. return Ok(Some(BTreeMap::new()));
  132. }
  133. let parsed = JsonValue::parse(&contents)
  134. .map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
  135. let object = parsed.as_object().ok_or_else(|| {
  136. ConfigError::Parse(format!(
  137. "{}: top-level settings value must be a JSON object",
  138. path.display()
  139. ))
  140. })?;
  141. Ok(Some(object.clone()))
  142. }
  143. fn deep_merge_objects(
  144. target: &mut BTreeMap<String, JsonValue>,
  145. source: &BTreeMap<String, JsonValue>,
  146. ) {
  147. for (key, value) in source {
  148. match (target.get_mut(key), value) {
  149. (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
  150. deep_merge_objects(existing, incoming);
  151. }
  152. _ => {
  153. target.insert(key.clone(), value.clone());
  154. }
  155. }
  156. }
  157. }
  158. #[cfg(test)]
  159. mod tests {
  160. use super::{ConfigLoader, ConfigSource, CLAUDE_CODE_SETTINGS_SCHEMA_NAME};
  161. use crate::json::JsonValue;
  162. use std::fs;
  163. use std::time::{SystemTime, UNIX_EPOCH};
  164. fn temp_dir() -> std::path::PathBuf {
  165. let nanos = SystemTime::now()
  166. .duration_since(UNIX_EPOCH)
  167. .expect("time should be after epoch")
  168. .as_nanos();
  169. std::env::temp_dir().join(format!("runtime-config-{nanos}"))
  170. }
  171. #[test]
  172. fn rejects_non_object_settings_files() {
  173. let root = temp_dir();
  174. let cwd = root.join("project");
  175. let home = root.join("home").join(".claude");
  176. fs::create_dir_all(&home).expect("home config dir");
  177. fs::create_dir_all(&cwd).expect("project dir");
  178. fs::write(home.join("settings.json"), "[]").expect("write bad settings");
  179. let error = ConfigLoader::new(&cwd, &home)
  180. .load()
  181. .expect_err("config should fail");
  182. assert!(error
  183. .to_string()
  184. .contains("top-level settings value must be a JSON object"));
  185. fs::remove_dir_all(root).expect("cleanup temp dir");
  186. }
  187. #[test]
  188. fn loads_and_merges_claude_code_config_files_by_precedence() {
  189. let root = temp_dir();
  190. let cwd = root.join("project");
  191. let home = root.join("home").join(".claude");
  192. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  193. fs::create_dir_all(&home).expect("home config dir");
  194. fs::write(
  195. home.join("settings.json"),
  196. r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
  197. )
  198. .expect("write user settings");
  199. fs::write(
  200. cwd.join(".claude").join("settings.json"),
  201. r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
  202. )
  203. .expect("write project settings");
  204. fs::write(
  205. cwd.join(".claude").join("settings.local.json"),
  206. r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
  207. )
  208. .expect("write local settings");
  209. let loaded = ConfigLoader::new(&cwd, &home)
  210. .load()
  211. .expect("config should load");
  212. assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
  213. assert_eq!(loaded.loaded_entries().len(), 3);
  214. assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
  215. assert_eq!(
  216. loaded.get("model"),
  217. Some(&JsonValue::String("opus".to_string()))
  218. );
  219. assert_eq!(
  220. loaded
  221. .get("env")
  222. .and_then(JsonValue::as_object)
  223. .expect("env object")
  224. .len(),
  225. 2
  226. );
  227. assert!(loaded
  228. .get("hooks")
  229. .and_then(JsonValue::as_object)
  230. .expect("hooks object")
  231. .contains_key("PreToolUse"));
  232. fs::remove_dir_all(root).expect("cleanup temp dir");
  233. }
  234. }