use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::{Map, Value, json};
pub const SCAN_SKIP_DIRS: &[&str] = &[
"target",
"node_modules",
".git",
"Library",
"Applications",
".Trash",
"build",
"dist",
".cache",
".npm",
".cargo",
];
const DEFAULT_SETTINGS_MAX_DEPTH: usize = 8;
pub fn discover_claude_settings(home: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut found = Vec::new();
collect_claude_settings(home, max_depth, &mut found);
found.sort();
found
}
fn collect_claude_settings(dir: &Path, depth_remaining: usize, out: &mut Vec<PathBuf>) {
if dir.file_name().and_then(|n| n.to_str()) == Some(".claude") {
for name in ["settings.json", "settings.local.json"] {
let candidate = dir.join(name);
if candidate.is_file() {
out.push(candidate);
}
}
return;
}
if depth_remaining == 0 {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return, };
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name != ".claude" && SCAN_SKIP_DIRS.contains(&name) {
continue;
}
collect_claude_settings(&path, depth_remaining.saturating_sub(1), out);
}
}
pub const fn default_settings_max_depth() -> usize {
DEFAULT_SETTINGS_MAX_DEPTH
}
pub fn mcp_server_entry(command: &str, args: &[&str]) -> Value {
json!({
"command": command,
"args": args,
})
}
pub fn write_json_atomic(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
let serialized =
serde_json::to_string_pretty(value).context("serialize JSON for atomic write")?;
if path.exists() {
let backup = backup_path(path);
std::fs::copy(path, &backup)
.with_context(|| format!("back up {} to {}", path.display(), backup.display()))?;
}
let tmp = tmp_path(path);
std::fs::write(&tmp, serialized.as_bytes())
.with_context(|| format!("write temp file {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} onto {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn patch_mcp_server(path: &Path, server_key: &str, entry: &Value) -> Result<bool> {
let mut root = load_json_object(path)?;
let servers = root
.entry("mcpServers")
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
let servers_obj = servers
.as_object_mut()
.expect("mcpServers coerced to object above");
if servers_obj.get(server_key) == Some(entry) {
return Ok(false); }
servers_obj.insert(server_key.to_string(), entry.clone());
write_json_atomic(path, &Value::Object(root))?;
Ok(true)
}
pub fn merge_hook_entries(existing: &Value, additions: &Value) -> Value {
let mut result = existing.clone();
let Some(add_hooks) = additions.get("hooks").and_then(Value::as_object) else {
return result; };
if !result.is_object() {
result = Value::Object(Map::new());
}
let root = result
.as_object_mut()
.expect("result coerced to object above");
let hooks = root
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks_obj = hooks
.as_object_mut()
.expect("hooks coerced to object above");
for (event, add_value) in add_hooks {
let Some(add_array) = add_value.as_array() else {
continue; };
let target = hooks_obj
.entry(event.clone())
.or_insert_with(|| Value::Array(Vec::new()));
if !target.is_array() {
*target = Value::Array(Vec::new());
}
let target_array = target
.as_array_mut()
.expect("target coerced to array above");
for item in add_array {
if !target_array.contains(item) {
target_array.push(item.clone());
}
}
}
result
}
fn backup_path(path: &Path) -> PathBuf {
append_extension(path, "bak")
}
fn tmp_path(path: &Path) -> PathBuf {
append_extension(path, "tmp")
}
fn append_extension(path: &Path, suffix: &str) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".");
name.push(suffix);
path.with_file_name(name)
}
fn load_json_object(path: &Path) -> Result<Map<String, Value>> {
match std::fs::read_to_string(path) {
Ok(text) => {
if text.trim().is_empty() {
return Ok(Map::new());
}
let value: Value = serde_json::from_str(&text)
.with_context(|| format!("parse JSON config {}", path.display()))?;
match value {
Value::Object(map) => Ok(map),
other => anyhow::bail!(
"config {} is not a JSON object (found {})",
path.display(),
json_type_name(&other)
),
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
Err(e) => {
Err(anyhow::Error::new(e)).with_context(|| format!("read config {}", path.display()))
}
}
}
fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_dir(tag: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!("trusty-claude-config-{tag}-{pid}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn mcp_server_entry_has_expected_shape() {
let e = mcp_server_entry("trusty-search", &["mcp", "--stdio"]);
assert_eq!(e["command"], "trusty-search");
assert_eq!(e["args"], json!(["mcp", "--stdio"]));
}
#[test]
fn mcp_server_entry_always_includes_args_array() {
let e = mcp_server_entry("foo", &[]);
assert!(e["args"].is_array());
assert_eq!(e["args"].as_array().unwrap().len(), 0);
}
#[test]
fn merge_hook_entries_preserves_existing() {
let existing = json!({
"hooks": { "Stop": [{ "command": "user-hook" }] },
"other": "untouched"
});
let additions = json!({
"hooks": { "Stop": [{ "command": "trusty-hook" }] }
});
let merged = merge_hook_entries(&existing, &additions);
let stop = merged["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 2);
assert!(stop.contains(&json!({ "command": "user-hook" })));
assert!(stop.contains(&json!({ "command": "trusty-hook" })));
assert_eq!(merged["other"], "untouched");
}
#[test]
fn merge_hook_entries_is_idempotent() {
let existing = json!({ "hooks": {} });
let additions = json!({
"hooks": {
"PostToolUse": [{ "command": "trusty" }],
"UserPromptSubmit": [{ "command": "trusty-prompt" }]
}
});
let once = merge_hook_entries(&existing, &additions);
let twice = merge_hook_entries(&once, &additions);
assert_eq!(
once, twice,
"merging the same additions twice must be a no-op"
);
assert_eq!(once["hooks"]["PostToolUse"].as_array().unwrap().len(), 1);
assert_eq!(
once["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
1
);
}
#[test]
fn merge_hook_entries_handles_missing_hooks_block() {
let existing = json!({ "model": "claude" });
let additions = json!({ "hooks": { "Stop": [{ "command": "trusty" }] } });
let merged = merge_hook_entries(&existing, &additions);
assert_eq!(merged["model"], "claude");
assert_eq!(merged["hooks"]["Stop"].as_array().unwrap().len(), 1);
}
#[test]
fn merge_hook_entries_noop_when_no_additions() {
let existing = json!({ "hooks": { "Stop": [{ "command": "x" }] } });
let merged = merge_hook_entries(&existing, &json!({}));
assert_eq!(merged, existing);
}
#[test]
fn append_extension_preserves_original() {
let p = Path::new("/tmp/.claude/settings.json");
assert_eq!(backup_path(p), Path::new("/tmp/.claude/settings.json.bak"));
assert_eq!(tmp_path(p), Path::new("/tmp/.claude/settings.json.tmp"));
}
#[test]
#[ignore = "touches the real filesystem"]
fn write_json_atomic_creates_and_backs_up() {
let dir = scratch_dir("atomic");
let path = dir.join("settings.json");
write_json_atomic(&path, &json!({ "v": 1 })).unwrap();
assert!(path.exists());
assert!(!backup_path(&path).exists(), "no backup on first write");
write_json_atomic(&path, &json!({ "v": 2 })).unwrap();
let backup = std::fs::read_to_string(backup_path(&path)).unwrap();
assert!(backup.contains("\"v\": 1"));
let current = std::fs::read_to_string(&path).unwrap();
assert!(current.contains("\"v\": 2"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore = "touches the real filesystem"]
fn patch_mcp_server_is_idempotent() {
let dir = scratch_dir("patch");
let path = dir.join("settings.json");
let entry = mcp_server_entry("trusty-search", &["mcp"]);
let first = patch_mcp_server(&path, "trusty-search", &entry).unwrap();
assert!(first, "first patch must modify the file");
let second = patch_mcp_server(&path, "trusty-search", &entry).unwrap();
assert!(!second, "re-patching identical entry must be a no-op");
let text = std::fs::read_to_string(&path).unwrap();
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["mcpServers"]["trusty-search"], entry);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore = "touches the real filesystem"]
fn patch_mcp_server_preserves_other_keys() {
let dir = scratch_dir("patch-preserve");
let path = dir.join("settings.json");
std::fs::write(
&path,
r#"{"model":"claude","mcpServers":{"existing":{"command":"x"}}}"#,
)
.unwrap();
let entry = mcp_server_entry("trusty-memory", &["mcp"]);
patch_mcp_server(&path, "trusty-memory", &entry).unwrap();
let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["model"], "claude");
assert_eq!(parsed["mcpServers"]["existing"]["command"], "x");
assert_eq!(parsed["mcpServers"]["trusty-memory"], entry);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore = "touches the real filesystem"]
fn discover_claude_settings_skips_blacklisted_dirs() {
let home = scratch_dir("discover");
let real = home.join("proj").join(".claude");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("settings.json"), "{}").unwrap();
std::fs::write(real.join("settings.local.json"), "{}").unwrap();
let buried = home.join("node_modules").join("pkg").join(".claude");
std::fs::create_dir_all(&buried).unwrap();
std::fs::write(buried.join("settings.json"), "{}").unwrap();
let found = discover_claude_settings(&home, default_settings_max_depth());
assert_eq!(found.len(), 2, "should find only the two non-skipped files");
assert!(
found
.iter()
.all(|p| !p.to_string_lossy().contains("node_modules"))
);
std::fs::remove_dir_all(&home).ok();
}
}