use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
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 const CLAUDE_MPM_SUB_AGENT_ENV_VAR: &str = "CLAUDE_MPM_SUB_AGENT";
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")?;
let stamp = staging_stamp();
if path.exists() {
let backup = backup_path(path);
let staged = append_extension(path, &format!("bak.{stamp}"));
stage_then_publish(&staged, &backup, |dest| {
std::fs::copy(path, dest).map(|_| ())
})
.with_context(|| format!("back up {} to {}", path.display(), backup.display()))?;
}
let staged = append_extension(path, &format!("tmp.{stamp}"));
stage_then_publish(&staged, path, |dest| {
std::fs::write(dest, serialized.as_bytes())
})
.with_context(|| format!("atomically write {}", path.display()))
}
fn stage_then_publish(
staged: &Path,
dest: &Path,
fill: impl FnOnce(&Path) -> std::io::Result<()>,
) -> Result<()> {
let filled = fill(staged);
let reached_publish = filled.is_ok();
let result = filled.and_then(|()| std::fs::rename(staged, dest));
if result.is_err() {
let _ = std::fs::remove_file(staged);
}
result.with_context(|| {
if reached_publish {
format!("publish {} onto {}", staged.display(), dest.display())
} else {
format!("fill staging file {}", staged.display())
}
})
}
fn staging_stamp() -> String {
static SEQ: AtomicU64 = AtomicU64::new(0);
format!(
"{}.{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
)
}
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
}
pub fn quarantine_path(path: &Path) -> PathBuf {
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
quarantine_path_with_stamp(path, &stamp)
}
fn quarantine_path_with_stamp(path: &Path, stamp: &str) -> PathBuf {
let base = append_extension(path, &format!("corrupt-{stamp}"));
if !base.exists() {
return base;
}
for n in 1..1000 {
let candidate = append_extension(path, &format!("corrupt-{stamp}-{n}"));
if !candidate.exists() {
return candidate;
}
}
base
}
fn backup_path(path: &Path) -> PathBuf {
append_extension(path, "bak")
}
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 quarantine_path_is_timestamped_and_unique() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".claude.json");
let first = quarantine_path(&path);
let name = first.file_name().unwrap().to_string_lossy().into_owned();
assert!(
name.starts_with(".claude.json.corrupt-"),
"quarantine name must extend the original file name: {name}"
);
let stamp = name
.strip_prefix(".claude.json.corrupt-")
.expect("prefix asserted above");
assert!(
stamp.len() >= 20 && stamp.ends_with('Z') && stamp.contains('T'),
"expected a `YYYYmmddTHHMMSS.sssZ` UTC stamp, got {stamp:?}"
);
assert_eq!(
first.parent(),
path.parent(),
"quarantine must stay a sibling of the file it replaces"
);
std::fs::write(&first, b"{ corrupt").unwrap();
let second = quarantine_path(&path);
assert_ne!(
first, second,
"a second quarantine must never reuse an occupied name"
);
}
#[test]
fn quarantine_path_avoids_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".claude.json");
let stamp = "20260728T025723.418Z";
let base = quarantine_path_with_stamp(&path, stamp);
assert_eq!(
base.file_name().unwrap(),
".claude.json.corrupt-20260728T025723.418Z"
);
std::fs::write(&base, b"first corruption").unwrap();
let second = quarantine_path_with_stamp(&path, stamp);
assert_eq!(
second.file_name().unwrap(),
".claude.json.corrupt-20260728T025723.418Z-1",
"an occupied name must be sidestepped, not overwritten"
);
std::fs::write(&second, b"second corruption").unwrap();
let third = quarantine_path_with_stamp(&path, stamp);
assert_eq!(
third.file_name().unwrap(),
".claude.json.corrupt-20260728T025723.418Z-2",
"the probe must keep walking, not stop at -1"
);
assert_eq!(std::fs::read(&base).unwrap(), b"first corruption");
assert_eq!(std::fs::read(&second).unwrap(), b"second corruption");
}
#[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!(
append_extension(p, "tmp.7.0"),
Path::new("/tmp/.claude/settings.json.tmp.7.0")
);
}
#[test]
fn staging_paths_are_unique_per_call() {
let p = Path::new("/tmp/.claude/settings.json");
let first = append_extension(p, &format!("tmp.{}", staging_stamp()));
let second = append_extension(p, &format!("tmp.{}", staging_stamp()));
assert_ne!(
first, second,
"two writers must never stage through the same filename"
);
assert_eq!(first.parent(), p.parent(), "staging must stay a sibling");
assert_eq!(second.parent(), p.parent(), "staging must stay a sibling");
}
#[test]
fn failed_write_leaves_no_staging_file() {
let dir = tempfile::tempdir().expect("tempdir");
let dir = dir.path();
let dest = dir.join("settings.json");
let staged = dir.join("settings.json.tmp.test");
let err = stage_then_publish(&staged, &dest, |p| {
std::fs::write(p, b"partial")?;
Err(std::io::Error::other("simulated fill failure"))
})
.expect_err("a failing fill must propagate");
assert!(
!staged.exists(),
"the staged file must be removed when the publish fails: {err}"
);
assert!(
!dest.exists(),
"a failed publish must not create the target"
);
let chain = format!("{err:#}");
assert!(
chain.contains("fill staging file"),
"a fill failure must not be reported as a publish that never happened: {chain}"
);
}
#[test]
fn failed_rename_leaves_destination_and_no_staging_file() {
let dir = tempfile::tempdir().expect("tempdir");
let dir = dir.path();
let path = dir.join("settings.json");
write_json_atomic(&path, &json!({ "v": 1 })).expect("seed target");
let before = std::fs::read(&path).expect("read seeded target");
let backup = backup_path(&path);
std::fs::create_dir(&backup).expect("plant backup directory");
std::fs::write(backup.join("occupant"), b"not a backup").expect("occupy it");
let err = write_json_atomic(&path, &json!({ "v": 2 }))
.expect_err("a rename that cannot succeed must propagate");
let chain = format!("{err:#}");
assert!(
chain.contains("publish ") && chain.contains(" onto "),
"a rename failure must name the publish stage: {chain}"
);
assert_eq!(
std::fs::read(&path).expect("target must survive"),
before,
"a failed publish must leave the target byte-for-byte unchanged"
);
let leftovers: Vec<String> = std::fs::read_dir(dir)
.expect("list scratch dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("settings.json.bak.") || n.starts_with("settings.json.tmp."))
.collect();
assert!(
leftovers.is_empty(),
"a failed publish must remove its own staging file, left: {leftovers:?}"
);
assert_eq!(
std::fs::read(backup.join("occupant")).expect("occupant must survive"),
b"not a backup",
"the rename destination must be untouched by the failed publish"
);
}
fn racing_payload(writer: usize) -> Value {
json!({ "writer": writer, "pad": "x".repeat(4096) })
}
fn observe_intact(path: &Path) -> Option<bool> {
let text = std::fs::read_to_string(path).ok()?;
let Ok(value) = serde_json::from_str::<Value>(&text) else {
return Some(false); };
let intact = value["writer"]
.as_u64()
.is_some_and(|w| value == racing_payload(w as usize));
Some(intact)
}
#[test]
fn concurrent_writers_never_publish_a_torn_file() {
const WRITERS: usize = 8;
const READERS: usize = 2;
const ROUNDS: usize = 60;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("settings.json");
write_json_atomic(&path, &racing_payload(0)).expect("seed write");
let write_failures = AtomicU64::new(0);
let torn_reads = AtomicU64::new(0);
let done = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|scope| {
for _ in 0..READERS {
let (path, torn_reads, done) = (&path, &torn_reads, &done);
scope.spawn(move || {
while !done.load(Ordering::Relaxed) {
if observe_intact(path) == Some(false) {
torn_reads.fetch_add(1, Ordering::Relaxed);
}
}
});
}
let writers: Vec<_> = (0..WRITERS)
.map(|writer| {
let (path, write_failures) = (&path, &write_failures);
scope.spawn(move || {
for _ in 0..ROUNDS {
if write_json_atomic(path, &racing_payload(writer)).is_err() {
write_failures.fetch_add(1, Ordering::Relaxed);
}
}
})
})
.collect();
for handle in writers {
handle.join().expect("a writer thread must not panic");
}
done.store(true, Ordering::Relaxed);
});
assert_eq!(
torn_reads.load(Ordering::Relaxed),
0,
"a reader observed .claude.json as a splice of two writers — concurrent \
writers shared a staging file (issue #4077)"
);
assert_eq!(
write_failures.load(Ordering::Relaxed),
0,
"concurrent atomic writes failed — two writers collided on one staging \
filename (issue #4077)"
);
assert_eq!(
observe_intact(&path),
Some(true),
"the final .claude.json is not any single writer's complete payload"
);
}
#[test]
fn concurrent_writers_never_tear_the_backup() {
const WRITERS: usize = 8;
const READERS: usize = 2;
const ROUNDS: usize = 60;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("settings.json");
let backup = backup_path(&path);
write_json_atomic(&path, &racing_payload(0)).expect("seed target");
write_json_atomic(&path, &racing_payload(0)).expect("seed backup");
let torn_reads = AtomicU64::new(0);
let done = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|scope| {
for _ in 0..READERS {
let (backup, torn_reads, done) = (&backup, &torn_reads, &done);
scope.spawn(move || {
while !done.load(Ordering::Relaxed) {
if observe_intact(backup) == Some(false) {
torn_reads.fetch_add(1, Ordering::Relaxed);
}
}
});
}
let writers: Vec<_> = (0..WRITERS)
.map(|writer| {
let path = &path;
scope.spawn(move || {
for _ in 0..ROUNDS {
let _ = write_json_atomic(path, &racing_payload(writer));
}
})
})
.collect();
for handle in writers {
handle.join().expect("a writer thread must not panic");
}
done.store(true, Ordering::Relaxed);
});
assert_eq!(
torn_reads.load(Ordering::Relaxed),
0,
"a reader observed .claude.json.bak as a splice of two writers — concurrent \
backups shared one destination (issue #4077)"
);
assert_eq!(
observe_intact(&backup),
Some(true),
"the final backup is not any single writer's complete payload"
);
}
#[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();
}
}