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,
237 pub user_local: PathBuf,
239 pub project: Option<PathBuf>,
241 pub project_local: Option<PathBuf>,
243}
244
245pub fn redact_env_values(value: &mut Value) {
255 let Some(obj) = value.as_object_mut() else {
256 return;
257 };
258 let Some(env) = obj.get_mut("env") else {
259 return;
260 };
261 let Some(env_obj) = env.as_object_mut() else {
262 return;
263 };
264 for (_, v) in env_obj.iter_mut() {
265 *v = Value::String("<redacted>".to_string());
266 }
267}
268
269fn read_layer(path: &Path) -> Result<Option<Value>> {
270 match fs::read_to_string(path) {
271 Ok(raw) => {
272 let parsed: Value = serde_json::from_str(&raw).map_err(|e| Error::Artifacts {
273 message: format!("settings file {} is not valid JSON: {e}", path.display()),
274 })?;
275 Ok(Some(parsed))
276 }
277 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
278 Err(e) => Err(e.into()),
279 }
280}
281
282fn home_dir() -> Option<PathBuf> {
283 if let Ok(h) = std::env::var("HOME")
284 && !h.is_empty()
285 {
286 return Some(PathBuf::from(h));
287 }
288 if let Ok(h) = std::env::var("USERPROFILE")
289 && !h.is_empty()
290 {
291 return Some(PathBuf::from(h));
292 }
293 None
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use serde_json::json;
300
301 fn write_layer(path: &Path, value: &Value) {
302 if let Some(parent) = path.parent() {
303 fs::create_dir_all(parent).expect("mkdir parent");
304 }
305 fs::write(path, serde_json::to_string_pretty(value).expect("ser")).expect("write");
306 }
307
308 #[test]
309 fn load_with_only_user_layer_present() {
310 let user_root = tempfile::tempdir().expect("tempdir");
311 write_layer(
312 &user_root.path().join("settings.json"),
313 &json!({"env": {"FOO": "bar"}, "permissions": {"allow": ["Bash(ls *)"]}}),
314 );
315 let loader = SettingsLoader::at(user_root.path(), None);
316 let layers = loader.load().expect("load");
317 assert!(layers.user.is_some());
318 assert!(layers.user_local.is_none());
319 assert!(layers.project.is_none());
320 assert!(layers.project_local.is_none());
321 assert_eq!(
322 layers.user.as_ref().unwrap()["env"]["FOO"].as_str(),
323 Some("bar")
324 );
325 }
326
327 #[test]
328 fn load_with_all_four_layers() {
329 let user_root = tempfile::tempdir().expect("user");
330 let project_root = tempfile::tempdir().expect("project");
331 write_layer(
332 &user_root.path().join("settings.json"),
333 &json!({"layer": "user"}),
334 );
335 write_layer(
336 &user_root.path().join("settings.local.json"),
337 &json!({"layer": "user_local"}),
338 );
339 write_layer(
340 &project_root.path().join(".claude").join("settings.json"),
341 &json!({"layer": "project"}),
342 );
343 write_layer(
344 &project_root
345 .path()
346 .join(".claude")
347 .join("settings.local.json"),
348 &json!({"layer": "project_local"}),
349 );
350 let layers = SettingsLoader::at(user_root.path(), Some(project_root.path().to_path_buf()))
351 .load()
352 .expect("load");
353 assert_eq!(
354 layers.user.as_ref().unwrap()["layer"].as_str(),
355 Some("user")
356 );
357 assert_eq!(
358 layers.user_local.as_ref().unwrap()["layer"].as_str(),
359 Some("user_local")
360 );
361 assert_eq!(
362 layers.project.as_ref().unwrap()["layer"].as_str(),
363 Some("project")
364 );
365 assert_eq!(
366 layers.project_local.as_ref().unwrap()["layer"].as_str(),
367 Some("project_local")
368 );
369 }
370
371 #[test]
372 fn missing_root_dir_treated_as_empty_not_error() {
373 let user_root = tempfile::tempdir().expect("user");
374 let layers = SettingsLoader::at(user_root.path(), None)
375 .load()
376 .expect("load");
377 assert!(layers.user.is_none());
378 assert!(layers.user_local.is_none());
379 }
380
381 #[test]
382 fn project_root_unset_means_no_project_layers() {
383 let user_root = tempfile::tempdir().expect("user");
384 write_layer(&user_root.path().join("settings.json"), &json!({"x": 1}));
385 let layers = SettingsLoader::at(user_root.path(), None)
386 .load()
387 .expect("load");
388 assert!(layers.user.is_some());
389 assert!(layers.project.is_none());
390 assert!(layers.project_local.is_none());
391 assert!(layers.paths.project.is_none());
392 }
393
394 #[test]
395 fn malformed_json_returns_error() {
396 let user_root = tempfile::tempdir().expect("user");
397 fs::write(user_root.path().join("settings.json"), "{not json").expect("write");
398 let err = SettingsLoader::at(user_root.path(), None)
399 .load()
400 .unwrap_err();
401 assert!(err.to_string().contains("not valid JSON"), "got: {err}");
402 }
403
404 #[test]
405 fn paths_reflect_configured_roots() {
406 let user_root = tempfile::tempdir().expect("user");
407 let project_root = tempfile::tempdir().expect("project");
408 let layers = SettingsLoader::at(user_root.path(), Some(project_root.path().to_path_buf()))
409 .load()
410 .expect("load");
411 assert_eq!(layers.paths.user, user_root.path().join("settings.json"));
412 assert_eq!(
413 layers.paths.project,
414 Some(project_root.path().join(".claude").join("settings.json"))
415 );
416 }
417
418 #[test]
419 fn get_indexes_by_layer() {
420 let user_root = tempfile::tempdir().expect("user");
421 write_layer(
422 &user_root.path().join("settings.json"),
423 &json!({"k": "user"}),
424 );
425 write_layer(
426 &user_root.path().join("settings.local.json"),
427 &json!({"k": "user_local"}),
428 );
429 let layers = SettingsLoader::at(user_root.path(), None)
430 .load()
431 .expect("load");
432 assert_eq!(
433 layers.get(SettingsLayer::User).unwrap()["k"].as_str(),
434 Some("user")
435 );
436 assert_eq!(
437 layers.get(SettingsLayer::UserLocal).unwrap()["k"].as_str(),
438 Some("user_local")
439 );
440 assert!(layers.get(SettingsLayer::Project).is_none());
441 }
442
443 #[test]
446 fn redact_env_replaces_values_keeps_keys() {
447 let mut v = json!({
448 "env": {"ANTHROPIC_API_KEY": "sk-xxx", "DEBUG": "1"},
449 "permissions": {"allow": ["Bash(ls *)"]},
450 });
451 redact_env_values(&mut v);
452 assert_eq!(v["env"]["ANTHROPIC_API_KEY"].as_str(), Some("<redacted>"));
453 assert_eq!(v["env"]["DEBUG"].as_str(), Some("<redacted>"));
454 assert_eq!(v["permissions"]["allow"][0].as_str(), Some("Bash(ls *)"));
456 }
457
458 #[test]
459 fn redact_env_noop_when_no_env_field() {
460 let mut v = json!({"permissions": {"allow": ["Bash(ls *)"]}});
461 let before = v.clone();
462 redact_env_values(&mut v);
463 assert_eq!(v, before);
464 }
465
466 #[test]
467 fn redact_env_noop_on_non_object_root() {
468 let mut v = json!(["not", "an", "object"]);
469 let before = v.clone();
470 redact_env_values(&mut v);
471 assert_eq!(v, before);
472 }
473
474 #[test]
475 fn redact_env_noop_when_env_not_object() {
476 let mut v = json!({"env": "weird-but-tolerated"});
477 let before = v.clone();
478 redact_env_values(&mut v);
479 assert_eq!(v, before);
480 }
481}