1use std::fs;
63use std::path::{Path, PathBuf};
64
65use serde::Serialize;
66use serde_json::Value;
67
68use crate::error::{Error, Result};
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SettingsLayer {
78 User,
80 UserLocal,
82 Project,
84 ProjectLocal,
86}
87
88impl SettingsLayer {
89 pub fn filename(self) -> &'static str {
93 match self {
94 Self::User | Self::Project => "settings.json",
95 Self::UserLocal | Self::ProjectLocal => "settings.local.json",
96 }
97 }
98
99 pub fn all() -> [Self; 4] {
101 [
102 Self::User,
103 Self::UserLocal,
104 Self::Project,
105 Self::ProjectLocal,
106 ]
107 }
108}
109
110#[derive(Debug, Clone)]
117pub struct SettingsLoader {
118 user_root: PathBuf,
119 project_root: Option<PathBuf>,
120}
121
122impl SettingsLoader {
123 pub fn home() -> Result<Self> {
126 let home = home_dir().ok_or_else(|| Error::Artifacts {
127 message: "could not determine user home directory".to_string(),
128 })?;
129 Ok(Self {
130 user_root: home.join(".claude"),
131 project_root: None,
132 })
133 }
134
135 pub fn at(user_root: impl Into<PathBuf>, project_root: Option<PathBuf>) -> Self {
139 Self {
140 user_root: user_root.into(),
141 project_root,
142 }
143 }
144
145 #[must_use]
149 pub fn project_root(mut self, dir: impl Into<PathBuf>) -> Self {
150 self.project_root = Some(dir.into());
151 self
152 }
153
154 pub fn user_root_path(&self) -> &Path {
156 &self.user_root
157 }
158
159 pub fn project_root_path(&self) -> Option<&Path> {
161 self.project_root.as_deref()
162 }
163
164 pub fn load(&self) -> Result<Settings> {
167 Ok(Settings {
168 user: read_layer(&self.user_root.join("settings.json"))?,
169 user_local: read_layer(&self.user_root.join("settings.local.json"))?,
170 project: match &self.project_root {
171 Some(root) => read_layer(&root.join(".claude").join("settings.json"))?,
172 None => None,
173 },
174 project_local: match &self.project_root {
175 Some(root) => read_layer(&root.join(".claude").join("settings.local.json"))?,
176 None => None,
177 },
178 paths: SettingsPaths {
179 user: self.user_root.join("settings.json"),
180 user_local: self.user_root.join("settings.local.json"),
181 project: self
182 .project_root
183 .as_ref()
184 .map(|r| r.join(".claude").join("settings.json")),
185 project_local: self
186 .project_root
187 .as_ref()
188 .map(|r| r.join(".claude").join("settings.local.json")),
189 },
190 })
191 }
192}
193
194#[derive(Debug, Clone, Serialize)]
200pub struct Settings {
201 pub user: Option<Value>,
203 pub user_local: Option<Value>,
205 pub project: Option<Value>,
208 pub project_local: Option<Value>,
211 pub paths: SettingsPaths,
215}
216
217impl Settings {
218 pub fn get(&self, layer: SettingsLayer) -> Option<&Value> {
220 match layer {
221 SettingsLayer::User => self.user.as_ref(),
222 SettingsLayer::UserLocal => self.user_local.as_ref(),
223 SettingsLayer::Project => self.project.as_ref(),
224 SettingsLayer::ProjectLocal => self.project_local.as_ref(),
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize)]
234pub struct SettingsPaths {
235 pub user: PathBuf,
236 pub user_local: PathBuf,
237 pub project: Option<PathBuf>,
238 pub project_local: Option<PathBuf>,
239}
240
241pub fn redact_env_values(value: &mut Value) {
251 let Some(obj) = value.as_object_mut() else {
252 return;
253 };
254 let Some(env) = obj.get_mut("env") else {
255 return;
256 };
257 let Some(env_obj) = env.as_object_mut() else {
258 return;
259 };
260 for (_, v) in env_obj.iter_mut() {
261 *v = Value::String("<redacted>".to_string());
262 }
263}
264
265fn read_layer(path: &Path) -> Result<Option<Value>> {
266 match fs::read_to_string(path) {
267 Ok(raw) => {
268 let parsed: Value = serde_json::from_str(&raw).map_err(|e| Error::Artifacts {
269 message: format!("settings file {} is not valid JSON: {e}", path.display()),
270 })?;
271 Ok(Some(parsed))
272 }
273 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
274 Err(e) => Err(e.into()),
275 }
276}
277
278fn home_dir() -> Option<PathBuf> {
279 if let Ok(h) = std::env::var("HOME")
280 && !h.is_empty()
281 {
282 return Some(PathBuf::from(h));
283 }
284 if let Ok(h) = std::env::var("USERPROFILE")
285 && !h.is_empty()
286 {
287 return Some(PathBuf::from(h));
288 }
289 None
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use serde_json::json;
296
297 fn write_layer(path: &Path, value: &Value) {
298 if let Some(parent) = path.parent() {
299 fs::create_dir_all(parent).expect("mkdir parent");
300 }
301 fs::write(path, serde_json::to_string_pretty(value).expect("ser")).expect("write");
302 }
303
304 #[test]
305 fn load_with_only_user_layer_present() {
306 let user_root = tempfile::tempdir().expect("tempdir");
307 write_layer(
308 &user_root.path().join("settings.json"),
309 &json!({"env": {"FOO": "bar"}, "permissions": {"allow": ["Bash(ls *)"]}}),
310 );
311 let loader = SettingsLoader::at(user_root.path(), None);
312 let layers = loader.load().expect("load");
313 assert!(layers.user.is_some());
314 assert!(layers.user_local.is_none());
315 assert!(layers.project.is_none());
316 assert!(layers.project_local.is_none());
317 assert_eq!(
318 layers.user.as_ref().unwrap()["env"]["FOO"].as_str(),
319 Some("bar")
320 );
321 }
322
323 #[test]
324 fn load_with_all_four_layers() {
325 let user_root = tempfile::tempdir().expect("user");
326 let project_root = tempfile::tempdir().expect("project");
327 write_layer(
328 &user_root.path().join("settings.json"),
329 &json!({"layer": "user"}),
330 );
331 write_layer(
332 &user_root.path().join("settings.local.json"),
333 &json!({"layer": "user_local"}),
334 );
335 write_layer(
336 &project_root.path().join(".claude").join("settings.json"),
337 &json!({"layer": "project"}),
338 );
339 write_layer(
340 &project_root
341 .path()
342 .join(".claude")
343 .join("settings.local.json"),
344 &json!({"layer": "project_local"}),
345 );
346 let layers = SettingsLoader::at(user_root.path(), Some(project_root.path().to_path_buf()))
347 .load()
348 .expect("load");
349 assert_eq!(
350 layers.user.as_ref().unwrap()["layer"].as_str(),
351 Some("user")
352 );
353 assert_eq!(
354 layers.user_local.as_ref().unwrap()["layer"].as_str(),
355 Some("user_local")
356 );
357 assert_eq!(
358 layers.project.as_ref().unwrap()["layer"].as_str(),
359 Some("project")
360 );
361 assert_eq!(
362 layers.project_local.as_ref().unwrap()["layer"].as_str(),
363 Some("project_local")
364 );
365 }
366
367 #[test]
368 fn missing_root_dir_treated_as_empty_not_error() {
369 let user_root = tempfile::tempdir().expect("user");
370 let layers = SettingsLoader::at(user_root.path(), None)
371 .load()
372 .expect("load");
373 assert!(layers.user.is_none());
374 assert!(layers.user_local.is_none());
375 }
376
377 #[test]
378 fn project_root_unset_means_no_project_layers() {
379 let user_root = tempfile::tempdir().expect("user");
380 write_layer(&user_root.path().join("settings.json"), &json!({"x": 1}));
381 let layers = SettingsLoader::at(user_root.path(), None)
382 .load()
383 .expect("load");
384 assert!(layers.user.is_some());
385 assert!(layers.project.is_none());
386 assert!(layers.project_local.is_none());
387 assert!(layers.paths.project.is_none());
388 }
389
390 #[test]
391 fn malformed_json_returns_error() {
392 let user_root = tempfile::tempdir().expect("user");
393 fs::write(user_root.path().join("settings.json"), "{not json").expect("write");
394 let err = SettingsLoader::at(user_root.path(), None)
395 .load()
396 .unwrap_err();
397 assert!(err.to_string().contains("not valid JSON"), "got: {err}");
398 }
399
400 #[test]
401 fn paths_reflect_configured_roots() {
402 let user_root = tempfile::tempdir().expect("user");
403 let project_root = tempfile::tempdir().expect("project");
404 let layers = SettingsLoader::at(user_root.path(), Some(project_root.path().to_path_buf()))
405 .load()
406 .expect("load");
407 assert_eq!(layers.paths.user, user_root.path().join("settings.json"));
408 assert_eq!(
409 layers.paths.project,
410 Some(project_root.path().join(".claude").join("settings.json"))
411 );
412 }
413
414 #[test]
415 fn get_indexes_by_layer() {
416 let user_root = tempfile::tempdir().expect("user");
417 write_layer(
418 &user_root.path().join("settings.json"),
419 &json!({"k": "user"}),
420 );
421 write_layer(
422 &user_root.path().join("settings.local.json"),
423 &json!({"k": "user_local"}),
424 );
425 let layers = SettingsLoader::at(user_root.path(), None)
426 .load()
427 .expect("load");
428 assert_eq!(
429 layers.get(SettingsLayer::User).unwrap()["k"].as_str(),
430 Some("user")
431 );
432 assert_eq!(
433 layers.get(SettingsLayer::UserLocal).unwrap()["k"].as_str(),
434 Some("user_local")
435 );
436 assert!(layers.get(SettingsLayer::Project).is_none());
437 }
438
439 #[test]
442 fn redact_env_replaces_values_keeps_keys() {
443 let mut v = json!({
444 "env": {"ANTHROPIC_API_KEY": "sk-xxx", "DEBUG": "1"},
445 "permissions": {"allow": ["Bash(ls *)"]},
446 });
447 redact_env_values(&mut v);
448 assert_eq!(v["env"]["ANTHROPIC_API_KEY"].as_str(), Some("<redacted>"));
449 assert_eq!(v["env"]["DEBUG"].as_str(), Some("<redacted>"));
450 assert_eq!(v["permissions"]["allow"][0].as_str(), Some("Bash(ls *)"));
452 }
453
454 #[test]
455 fn redact_env_noop_when_no_env_field() {
456 let mut v = json!({"permissions": {"allow": ["Bash(ls *)"]}});
457 let before = v.clone();
458 redact_env_values(&mut v);
459 assert_eq!(v, before);
460 }
461
462 #[test]
463 fn redact_env_noop_on_non_object_root() {
464 let mut v = json!(["not", "an", "object"]);
465 let before = v.clone();
466 redact_env_values(&mut v);
467 assert_eq!(v, before);
468 }
469
470 #[test]
471 fn redact_env_noop_when_env_not_object() {
472 let mut v = json!({"env": "weird-but-tolerated"});
473 let before = v.clone();
474 redact_env_values(&mut v);
475 assert_eq!(v, before);
476 }
477}