use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
pub(super) fn settings_path() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir).join("settings.json"));
}
}
let home = home::home_dir().ok_or_else(|| {
anyhow!("could not determine home directory (set CLAUDE_CONFIG_DIR or HOME)")
})?;
Ok(home.join(".claude").join("settings.json"))
}
const MAX_SETTINGS_BYTES: u64 = 10 * 1024 * 1024;
pub(super) fn read_settings_or_empty(path: &std::path::Path) -> Result<serde_json::Value> {
use std::fs;
use std::io::Read;
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(serde_json::json!({}));
}
Err(e) => return Err(anyhow::Error::from(e).context(format!("stat {}", path.display()))),
};
let target_meta = if meta.file_type().is_symlink() {
fs::metadata(path)
.with_context(|| format!("symlink target unreachable: {}", path.display()))?
} else {
meta
};
if target_meta.is_dir() {
anyhow::bail!(
"{} is not a regular file (it is a directory).",
path.display()
);
}
if !target_meta.is_file() {
anyhow::bail!(
"{} is not a regular file (FIFO, device, or socket).",
path.display()
);
}
if target_meta.len() > MAX_SETTINGS_BYTES {
anyhow::bail!(
"{} is suspiciously large (>{} MiB); refusing to parse.",
path.display(),
MAX_SETTINGS_BYTES / (1024 * 1024)
);
}
let mut buf = String::new();
fs::File::open(path)
.with_context(|| format!("open {}", path.display()))?
.take(MAX_SETTINGS_BYTES + 1)
.read_to_string(&mut buf)
.with_context(|| format!("read {}", path.display()))?;
let parsed: serde_json::Value = serde_json::from_str(&buf).with_context(|| {
format!(
"{} is not valid JSON. Fix or remove it before re-running.",
path.display()
)
})?;
if !parsed.is_object() {
anyhow::bail!("{} top-level value is not a JSON object.", path.display());
}
Ok(parsed)
}
pub(super) fn backup_path(settings: &std::path::Path) -> std::path::PathBuf {
use rand::Rng;
let now = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let rand: String = (0..4)
.map(|_| {
let n: u8 = rand::rng().random_range(0..16);
std::char::from_digit(n as u32, 16).expect("0..16 always valid hex digit")
})
.collect();
let mut buf = settings.as_os_str().to_owned();
buf.push(format!(".bak.{now}.{rand}"));
buf.into()
}
pub(super) fn atomic_write(target: &std::path::Path, contents: &[u8]) -> Result<()> {
use std::fs;
let resolved: std::path::PathBuf = match fs::symlink_metadata(target) {
Ok(m) if m.file_type().is_symlink() => fs::canonicalize(target)
.with_context(|| format!("canonicalize symlink target {}", target.display()))?,
_ => target.to_path_buf(),
};
let parent = resolved
.parent()
.ok_or_else(|| anyhow!("settings path has no parent: {}", resolved.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
#[cfg(unix)]
let prev_mode: Option<u32> = match fs::metadata(&resolved) {
Ok(m) => {
use std::os::unix::fs::PermissionsExt;
Some(m.permissions().mode())
}
Err(_) => None,
};
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("create temp file in {}", parent.display()))?;
{
use std::io::Write;
tmp.write_all(contents)
.with_context(|| format!("write temp for {}", resolved.display()))?;
tmp.as_file()
.sync_all()
.with_context(|| format!("fsync temp for {}", resolved.display()))?;
}
#[cfg(unix)]
if let Some(mode) = prev_mode {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode);
std::fs::set_permissions(tmp.path(), perms)
.with_context(|| format!("preserve mode {:o} on temp file", mode))?;
}
tmp.persist(&resolved)
.map_err(|e| anyhow!("persist {}: {}", resolved.display(), e.error))?;
Ok(())
}
pub(super) fn is_repotoire_hook_command(cmd: &str) -> bool {
let mut parts = cmd.split_whitespace();
let bin = match parts.next() {
Some(s) => s,
None => return false,
};
let leaf = std::path::Path::new(bin)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let is_our_bin = if cfg!(target_os = "windows") {
leaf.eq_ignore_ascii_case("repotoire") || leaf.eq_ignore_ascii_case("repotoire.exe")
} else {
leaf == "repotoire"
};
if !is_our_bin {
return false;
}
if parts.next() != Some("claude-hook") {
return false;
}
if parts.next() != Some("run") {
return false;
}
true
}
pub(super) fn is_old_style_hook_command(cmd: &str, home: &std::path::Path) -> bool {
let needle = home.join(".repotoire").join("hooks");
cmd.contains(needle.to_string_lossy().as_ref())
}
pub(super) fn insert_hook_entry(root: &mut serde_json::Value, our_command: &str) {
let obj = root.as_object_mut().expect("settings root must be object");
let hooks = obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::json!({}));
let hooks_obj = hooks
.as_object_mut()
.expect("hooks must be an object if present");
let pre = hooks_obj
.entry("PreToolUse".to_string())
.or_insert_with(|| serde_json::json!([]));
let pre_arr = pre
.as_array_mut()
.expect("PreToolUse must be an array if present");
pre_arr.push(serde_json::json!({
"matcher": "Bash",
"hooks": [
{"type": "command", "command": our_command}
]
}));
}
pub(super) fn find_pretool_entry_by_command<F>(
root: &serde_json::Value,
mut pred: F,
) -> Option<usize>
where
F: FnMut(&str) -> bool,
{
let arr = root.get("hooks")?.get("PreToolUse")?.as_array()?;
for (i, entry) in arr.iter().enumerate() {
if let Some(inner) = entry.get("hooks").and_then(|h| h.as_array()) {
for h in inner {
if let Some(cmd) = h.get("command").and_then(|c| c.as_str()) {
if pred(cmd) {
return Some(i);
}
}
}
}
}
None
}
pub(super) fn filter_pretool_entries<F>(root: &mut serde_json::Value, mut pred: F) -> usize
where
F: FnMut(&str) -> bool,
{
let arr = match root
.get_mut("hooks")
.and_then(|h| h.get_mut("PreToolUse"))
.and_then(|p| p.as_array_mut())
{
Some(a) => a,
None => return 0,
};
let before = arr.len();
arr.retain(|entry| {
let inner = match entry.get("hooks").and_then(|h| h.as_array()) {
Some(a) => a,
None => return true,
};
let any_match = inner.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.map(&mut pred)
.unwrap_or(false)
});
!any_match
});
before - arr.len()
}
pub(super) fn cleanup_empty_containers(root: &mut serde_json::Value) {
let obj = match root.as_object_mut() {
Some(o) => o,
None => return,
};
let hooks = match obj.get_mut("hooks").and_then(|v| v.as_object_mut()) {
Some(h) => h,
None => return,
};
if let Some(arr) = hooks.get("PreToolUse").and_then(|v| v.as_array()) {
if arr.is_empty() {
hooks.remove("PreToolUse");
}
}
if hooks.is_empty() {
obj.remove("hooks");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_env<R>(key: &str, val: Option<&str>, f: impl FnOnce() -> R) -> R {
let prev = std::env::var(key).ok();
match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
let result = f();
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
result
}
#[test]
fn settings_path_uses_claude_config_dir_when_set() {
with_env("CLAUDE_CONFIG_DIR", Some("/tmp/fake-claude-config"), || {
let p = settings_path().expect("path resolves");
assert_eq!(p, PathBuf::from("/tmp/fake-claude-config/settings.json"));
});
}
#[test]
fn settings_path_falls_back_to_home_when_claude_config_dir_empty() {
with_env("CLAUDE_CONFIG_DIR", Some(""), || {
let p = settings_path().expect("path resolves");
assert!(p.ends_with(".claude/settings.json"), "got {}", p.display());
});
}
#[test]
fn settings_path_falls_back_to_home_when_claude_config_dir_unset() {
with_env("CLAUDE_CONFIG_DIR", None, || {
let p = settings_path().expect("path resolves");
assert!(p.ends_with(".claude/settings.json"), "got {}", p.display());
});
}
#[test]
fn read_returns_empty_object_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.json");
let val = read_settings_or_empty(&path).unwrap();
assert_eq!(val, serde_json::json!({}));
}
#[test]
fn read_returns_parsed_object_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, r#"{"theme":"dark"}"#).unwrap();
let val = read_settings_or_empty(&path).unwrap();
assert_eq!(val["theme"], "dark");
}
#[test]
fn read_errors_on_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "{ broken json").unwrap();
let err = read_settings_or_empty(&path).unwrap_err();
assert!(format!("{err:#}").contains("is not valid JSON"));
}
#[test]
fn read_errors_when_path_is_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::create_dir(&path).unwrap();
let err = read_settings_or_empty(&path).unwrap_err();
assert!(format!("{err:#}").contains("not a regular file"));
}
#[test]
fn read_errors_on_non_object_top_level() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "[]").unwrap();
let err = read_settings_or_empty(&path).unwrap_err();
assert!(format!("{err:#}").contains("top-level value is not a JSON object"));
}
#[test]
fn atomic_write_creates_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
atomic_write(&path, b"{\"theme\":\"dark\"}").unwrap();
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read, "{\"theme\":\"dark\"}");
}
#[test]
fn atomic_write_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "old").unwrap();
atomic_write(&path, b"new").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
}
#[cfg(unix)]
#[test]
fn atomic_write_preserves_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
atomic_write(&path, b"new").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn atomic_write_preserves_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.json");
let link = dir.path().join("link.json");
std::fs::write(&real, "old").unwrap();
symlink(&real, &link).unwrap();
atomic_write(&link, b"new").unwrap();
assert!(std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(std::fs::read_to_string(&real).unwrap(), "new");
}
#[test]
fn backup_path_appends_suffix() {
let p = std::path::Path::new("/tmp/settings.json");
let b = backup_path(p);
let s = b.to_string_lossy();
assert!(s.starts_with("/tmp/settings.json.bak."), "got {s}");
assert!(
s.len() >= "/tmp/settings.json.bak.".len() + 14 + 1 + 4,
"got {s}"
);
}
#[test]
fn is_repotoire_hook_command_matches_canonical_form() {
assert!(is_repotoire_hook_command(
"/usr/local/bin/repotoire claude-hook run"
));
assert!(is_repotoire_hook_command(
"/opt/homebrew/bin/repotoire claude-hook run"
));
assert!(is_repotoire_hook_command(
"/usr/local/bin/repotoire claude-hook run --debug"
));
}
#[test]
fn is_repotoire_hook_command_rejects_unrelated() {
assert!(!is_repotoire_hook_command("/usr/bin/git commit"));
assert!(!is_repotoire_hook_command(
"/usr/local/bin/repotoire-test claude-hook run"
));
assert!(!is_repotoire_hook_command(
"/usr/local/bin/repotoire analyze"
));
assert!(!is_repotoire_hook_command(
"/usr/local/bin/repotoire claude-hook install"
));
assert!(!is_repotoire_hook_command(""));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn is_old_style_hook_command_matches_legacy_path() {
let home = std::path::Path::new("/home/user");
assert!(is_old_style_hook_command(
"/home/user/.repotoire/hooks/pre-commit.sh",
home
));
assert!(!is_old_style_hook_command(
"/usr/local/bin/repotoire claude-hook run",
home
));
}
#[test]
fn insert_hook_entry_into_empty_root() {
let mut root = serde_json::json!({});
insert_hook_entry(&mut root, "/bin/repotoire claude-hook run");
assert_eq!(root["hooks"]["PreToolUse"][0]["matcher"], "Bash");
assert_eq!(
root["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"/bin/repotoire claude-hook run"
);
}
#[test]
fn insert_hook_entry_preserves_other_keys() {
let mut root = serde_json::json!({"theme": "dark", "enabledPlugins": {"foo": true}});
insert_hook_entry(&mut root, "/bin/repotoire claude-hook run");
assert_eq!(root["theme"], "dark");
assert_eq!(root["enabledPlugins"]["foo"], true);
assert_eq!(root["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
}
#[test]
fn insert_hook_entry_appends_to_existing_array() {
let mut root = serde_json::json!({
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "command": "/bin/foo"}]}
]
}
});
insert_hook_entry(&mut root, "/bin/repotoire claude-hook run");
assert_eq!(root["hooks"]["PreToolUse"].as_array().unwrap().len(), 2);
assert_eq!(
root["hooks"]["PreToolUse"][1]["hooks"][0]["command"],
"/bin/repotoire claude-hook run"
);
}
#[test]
fn filter_pretool_entries_preserves_unrelated() {
let mut root = serde_json::json!({
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "command": "/bin/foo"}]},
{"matcher": "Bash", "hooks": [{"type": "command", "command": "/bin/repotoire claude-hook run"}]}
]
}
});
let removed = filter_pretool_entries(&mut root, is_repotoire_hook_command);
assert_eq!(removed, 1);
assert_eq!(root["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
assert_eq!(
root["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"/bin/foo"
);
}
#[test]
fn cleanup_empty_containers_removes_empty_pretooluse_and_hooks() {
let mut root = serde_json::json!({
"theme": "dark",
"hooks": {"PreToolUse": []}
});
cleanup_empty_containers(&mut root);
assert!(root.get("hooks").is_none());
assert_eq!(root["theme"], "dark");
}
#[test]
fn cleanup_empty_containers_keeps_hooks_when_other_event_present() {
let mut root = serde_json::json!({
"hooks": {
"PreToolUse": [],
"PostToolUse": [{"matcher": "Bash", "hooks": [{"type":"command","command":"/bin/foo"}]}]
}
});
cleanup_empty_containers(&mut root);
assert!(root.get("hooks").is_some());
assert!(root["hooks"].get("PreToolUse").is_none());
assert!(root["hooks"].get("PostToolUse").is_some());
}
}