| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- 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<String, JsonValue>,
- loaded_entries: Vec<ConfigEntry>,
- }
- #[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<std::io::Error> 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<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
- Self {
- cwd: cwd.into(),
- config_home: config_home.into(),
- }
- }
- #[must_use]
- pub fn default_for(cwd: impl Into<PathBuf>) -> 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<ConfigEntry> {
- 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<RuntimeConfig, ConfigError> {
- 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<String, JsonValue> {
- &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<Option<BTreeMap<String, JsonValue>>, 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<String, JsonValue>,
- source: &BTreeMap<String, JsonValue>,
- ) {
- 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");
- }
- }
|