use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use atomicwrites::AtomicFile;
use atomicwrites::OverwriteBehavior;
use colored::Colorize;
use serde_json::Value;
use serde_json::json;
use std::collections::HashSet;
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone)]
pub struct InjectionSummary {
pub settings_path: PathBuf,
pub added_additional_dirs: Vec<PathBuf>,
pub added_allow_rules: Vec<String>,
pub already_present_additional_dirs: Vec<PathBuf>,
pub already_present_allow_rules: Vec<String>,
pub warn_conflicting_denies: Vec<String>,
}
pub fn inject_additional_directories(repo_root: &Path) -> Result<InjectionSummary> {
let settings_path = get_local_settings_path(repo_root);
ensure_parent_dir(&settings_path)?;
let td = repo_root.join(".thoughts-data");
let canonical_thoughts_data = match fs::canonicalize(&td) {
Ok(p) => p,
Err(e) => {
eprintln!(
"{}: Failed to canonicalize {} ({}). Falling back to non-canonical path.",
"Warning".yellow(),
td.display(),
e
);
td
}
};
let ReadOutcome {
mut value,
had_valid_json,
} = read_or_init_settings(&settings_path)?;
ensure_permissions_scaffold(&mut value);
let mut added_additional_dirs = Vec::new();
let mut already_present_additional_dirs = Vec::new();
let mut added_allow_rules = Vec::new();
let mut already_present_allow_rules = Vec::new();
{
let permissions = value
.get_mut("permissions")
.ok_or_else(|| anyhow!("permissions key missing after scaffold — this is a bug"))?;
ensure_array_field(permissions, "additionalDirectories", &settings_path)?;
let add_dirs = permissions["additionalDirectories"]
.as_array_mut()
.ok_or_else(|| {
anyhow!("additionalDirectories is not an array after scaffold — this is a bug")
})?;
let mut existing_add_dirs: HashSet<String> = add_dirs
.iter()
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect();
let dir_str = canonical_thoughts_data.to_string_lossy().to_string();
if existing_add_dirs.contains(&dir_str) {
already_present_additional_dirs.push(canonical_thoughts_data);
} else {
add_dirs.push(Value::String(dir_str.clone()));
existing_add_dirs.insert(dir_str);
added_additional_dirs.push(canonical_thoughts_data);
}
}
let warn_conflicting_denies = {
let permissions = value
.get_mut("permissions")
.ok_or_else(|| anyhow!("permissions key missing after scaffold — this is a bug"))?;
ensure_array_field(permissions, "allow", &settings_path)?;
let allow = permissions["allow"]
.as_array_mut()
.ok_or_else(|| anyhow!("allow is not an array after scaffold — this is a bug"))?;
let mut existing_allow: HashSet<String> = allow
.iter()
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect();
let required_rules = vec![
"Read(thoughts/**)".to_string(),
"Read(context/**)".to_string(),
"Read(references/**)".to_string(),
];
for r in required_rules {
if existing_allow.contains(&r) {
already_present_allow_rules.push(r);
} else {
allow.push(Value::String(r.clone()));
existing_allow.insert(r.clone());
added_allow_rules.push(r);
}
}
collect_conflicting_denies(permissions, &existing_allow)
};
if !added_additional_dirs.is_empty() || !added_allow_rules.is_empty() {
if had_valid_json && settings_path.exists() {
backup_valid_to_bak(&settings_path).with_context(|| {
format!("Failed to create backup for {}", settings_path.display())
})?;
}
let serialized = serde_json::to_string_pretty(&value)
.context("Failed to serialize Claude settings JSON")?;
AtomicFile::new(&settings_path, OverwriteBehavior::AllowOverwrite)
.write(|f| f.write_all(serialized.as_bytes()))
.with_context(|| format!("Failed to write {}", settings_path.display()))?;
}
if let Err(e) = prune_malformed_backups(&settings_path, 3) {
eprintln!(
"{}: Failed to prune malformed Claude backups: {}",
"Warning".yellow(),
e
);
}
Ok(InjectionSummary {
settings_path,
added_additional_dirs,
added_allow_rules,
already_present_additional_dirs,
already_present_allow_rules,
warn_conflicting_denies,
})
}
fn get_local_settings_path(repo_root: &Path) -> PathBuf {
repo_root.join(".claude").join("settings.local.json")
}
fn ensure_parent_dir(settings_path: &Path) -> Result<()> {
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
Ok(())
}
struct ReadOutcome {
value: Value,
had_valid_json: bool,
}
fn read_or_init_settings(settings_path: &Path) -> Result<ReadOutcome> {
if !settings_path.exists() {
return Ok(ReadOutcome {
value: json!({}),
had_valid_json: false,
});
}
let raw = fs::read_to_string(settings_path)
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
if let Ok(value) = serde_json::from_str::<Value>(&raw) {
Ok(ReadOutcome {
value,
had_valid_json: true,
})
} else {
let malformed =
quarantine_malformed_settings(settings_path, &raw, current_malformed_backup_suffix())?;
eprintln!(
"{}: Existing Claude settings were malformed. Quarantined to {}",
"Warning".yellow(),
malformed.display()
);
if let Err(e) = prune_malformed_backups(settings_path, 3) {
eprintln!(
"{}: Failed to prune malformed Claude backups: {}",
"Warning".yellow(),
e
);
}
Ok(ReadOutcome {
value: json!({}),
had_valid_json: false,
})
}
}
fn current_malformed_backup_suffix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.try_into()
.unwrap_or(u64::MAX)
}
fn quarantine_malformed_settings(
settings_path: &Path,
raw: &str,
initial_suffix: u64,
) -> Result<PathBuf> {
let mut suffix = initial_suffix;
loop {
let malformed = settings_path.with_extension(format!("json.malformed.{suffix}.bak"));
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&malformed)
{
Ok(mut file) => {
file.write_all(raw.as_bytes()).with_context(|| {
format!(
"Failed to write quarantined malformed Claude settings {}",
malformed.display()
)
})?;
drop(file);
fs::remove_file(settings_path).with_context(|| {
format!(
"Failed to remove malformed Claude settings {} after quarantine",
settings_path.display()
)
})?;
return Ok(malformed);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
suffix = suffix.checked_add(1).ok_or_else(|| {
anyhow!(
"Exhausted malformed backup suffixes for {}",
settings_path.display()
)
})?;
}
Err(e) => {
return Err(e).with_context(|| {
format!(
"Failed to reserve malformed Claude settings quarantine path {}",
malformed.display()
)
});
}
}
}
}
fn ensure_permissions_scaffold(root: &mut Value) {
if !root.is_object() {
*root = json!({});
}
if !root
.get("permissions")
.is_some_and(serde_json::Value::is_object)
{
root["permissions"] = json!({});
}
if root["permissions"].get("deny").is_none() {
root["permissions"]["deny"] = json!([]);
}
if root["permissions"].get("ask").is_none() {
root["permissions"]["ask"] = json!([]);
}
}
fn ensure_array_field(permissions: &mut Value, key: &str, settings_path: &Path) -> Result<()> {
match permissions.get(key) {
None => {
permissions[key] = json!([]);
Ok(())
}
Some(value) if value.is_array() => Ok(()),
Some(_) => bail!(
"permissions.{key} must be an array in {}",
settings_path.display()
),
}
}
fn backup_valid_to_bak(settings_path: &Path) -> Result<()> {
let bak = settings_path.with_extension("json.bak");
fs::copy(settings_path, &bak).with_context(|| {
format!(
"Failed to copy {} -> {}",
settings_path.display(),
bak.display()
)
})?;
Ok(())
}
fn collect_conflicting_denies(permissions: &Value, allow_set: &HashSet<String>) -> Vec<String> {
let mut conflicts = Vec::new();
if let Some(deny) = permissions.get("deny").and_then(|d| d.as_array()) {
for d in deny {
if let Some(ds) = d.as_str()
&& allow_set.contains(ds)
{
conflicts.push(ds.to_string());
}
}
}
conflicts
}
fn prune_malformed_backups(settings_path: &Path, keep: usize) -> Result<usize> {
let dir = settings_path
.parent()
.ok_or_else(|| anyhow!("Missing parent dir for settings"))?;
let prefix = "settings.local.json.malformed.";
let suffix = ".bak";
let mut entries: Vec<(u64, PathBuf)> = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
let p = entry?.path();
let Some(name_os) = p.file_name() else {
continue;
};
let name = name_os.to_string_lossy();
if !name.starts_with(prefix) || !name.ends_with(suffix) {
continue;
}
let ts_str = &name[prefix.len()..name.len() - suffix.len()];
if let Ok(ts) = ts_str.parse::<u64>() {
entries.push((ts, p));
}
}
entries.sort_by_key(|(ts, _)| *ts);
entries.reverse();
let mut deleted = 0usize;
for (_, p) in entries.into_iter().skip(keep) {
match fs::remove_file(&p) {
Ok(()) => deleted += 1,
Err(e) => eprintln!(
"{}: Failed to remove old malformed backup {}: {}",
"Warning".yellow(),
p.display(),
e
),
}
}
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn creates_file_and_adds_additional_dir_and_rules() {
let td = TempDir::new().unwrap();
let repo = td.path();
let td_path = repo.join(".thoughts-data");
fs::create_dir_all(&td_path).unwrap();
let summary = inject_additional_directories(repo).unwrap();
assert!(
summary
.settings_path
.ends_with(".claude/settings.local.json")
);
assert_eq!(summary.added_additional_dirs.len(), 1);
assert_eq!(summary.added_allow_rules.len(), 3);
let content = fs::read_to_string(&summary.settings_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
let add_dirs = json["permissions"]["additionalDirectories"]
.as_array()
.unwrap();
let allow = json["permissions"]["allow"].as_array().unwrap();
let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(add_dirs_strs.len(), 1);
assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
let allow_strs: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
assert!(allow_strs.contains(&"Read(thoughts/**)"));
assert!(allow_strs.contains(&"Read(context/**)"));
assert!(allow_strs.contains(&"Read(references/**)"));
}
#[test]
fn idempotent_no_duplicates() {
let td = TempDir::new().unwrap();
let repo = td.path();
fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
let _ = inject_additional_directories(repo).unwrap();
let again = inject_additional_directories(repo).unwrap();
assert!(again.added_additional_dirs.is_empty());
assert!(again.added_allow_rules.is_empty());
let content = fs::read_to_string(&again.settings_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
let allow = json["permissions"]["allow"].as_array().unwrap();
let mut seen = std::collections::HashSet::new();
for item in allow {
if let Some(s) = item.as_str() {
assert!(seen.insert(s.to_string()), "Duplicate found: {s}");
}
}
}
#[test]
fn malformed_settings_is_quarantined() {
let td = TempDir::new().unwrap();
let repo = td.path();
fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(&settings, "not-json").unwrap();
let summary = inject_additional_directories(repo).unwrap();
assert!(summary.settings_path.exists());
let dir = settings.parent().unwrap();
let entries = fs::read_dir(dir).unwrap();
let mut found_malformed = false;
for e in entries {
let p = e.unwrap().path();
let name = p.file_name().unwrap().to_string_lossy();
if name.contains("settings.local.json.malformed.") {
found_malformed = true;
break;
}
}
assert!(found_malformed);
}
#[test]
fn quarantine_uses_next_numeric_suffix_when_backup_exists() {
let td = TempDir::new().unwrap();
let settings = td.path().join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(&settings, "second").unwrap();
let existing = settings.with_extension("json.malformed.42.bak");
fs::write(&existing, "first").unwrap();
let malformed = super::quarantine_malformed_settings(&settings, "second", 42).unwrap();
assert_eq!(malformed, settings.with_extension("json.malformed.43.bak"));
assert_eq!(fs::read_to_string(&existing).unwrap(), "first");
assert_eq!(fs::read_to_string(&malformed).unwrap(), "second");
assert!(!settings.exists());
}
#[test]
fn backup_valid_before_write() {
let td = TempDir::new().unwrap();
let repo = td.path();
fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(
&settings,
r#"{"permissions":{"allow":[],"deny":[],"ask":[]}}"#,
)
.unwrap();
let _ = inject_additional_directories(repo).unwrap();
let bak = settings.with_extension("json.bak");
assert!(bak.exists());
}
#[test]
fn rejects_non_array_additional_directories() {
let td = TempDir::new().unwrap();
let repo = td.path();
fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(
&settings,
r#"{"permissions":{"additionalDirectories":"bad","allow":[],"deny":[],"ask":[]}}"#,
)
.unwrap();
let err = inject_additional_directories(repo).unwrap_err();
assert!(
err.to_string()
.contains("permissions.additionalDirectories must be an array")
);
}
#[test]
fn rejects_non_array_allow() {
let td = TempDir::new().unwrap();
let repo = td.path();
fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(
&settings,
r#"{"permissions":{"additionalDirectories":[],"allow":"bad","deny":[],"ask":[]}}"#,
)
.unwrap();
let err = inject_additional_directories(repo).unwrap_err();
assert!(
err.to_string()
.contains("permissions.allow must be an array")
);
}
#[test]
fn fallback_to_non_canonical_on_missing_path() {
let td = TempDir::new().unwrap();
let repo = td.path();
let summary = inject_additional_directories(repo).unwrap();
let content = fs::read_to_string(&summary.settings_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
let add_dirs = json["permissions"]["additionalDirectories"]
.as_array()
.unwrap();
let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(add_dirs_strs.len(), 1);
assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
}
#[test]
fn prunes_to_last_three_malformed_backups() {
let td = TempDir::new().unwrap();
let repo = td.path();
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
for ts in [100, 200, 300, 400, 500] {
let p = settings.with_extension(format!("json.malformed.{ts}.bak"));
fs::write(&p, b"{}").unwrap();
}
let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
assert_eq!(deleted, 2);
let kept: Vec<u64> = fs::read_dir(settings.parent().unwrap())
.unwrap()
.filter_map(|e| {
let name = e.unwrap().file_name().to_string_lossy().into_owned();
if let Some(s) = name
.strip_prefix("settings.local.json.malformed.")
.and_then(|s| s.strip_suffix(".bak"))
{
s.parse::<u64>().ok()
} else {
None
}
})
.collect();
assert_eq!(kept.len(), 3);
assert!(kept.contains(&300) && kept.contains(&400) && kept.contains(&500));
}
#[test]
fn ignores_non_numeric_malformed_backups() {
let td = TempDir::new().unwrap();
let repo = td.path();
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
let bad = settings.with_extension("json.malformed.bad.bak");
fs::write(&bad, b"{}").unwrap();
let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
assert_eq!(deleted, 0);
assert!(bad.exists());
}
#[test]
fn quarantine_then_prune_leaves_three() {
let td = TempDir::new().unwrap();
let repo = td.path();
fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
for _ in 0..5 {
let settings = repo.join(".claude").join("settings.local.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(&settings, "not-json").unwrap();
let _ = inject_additional_directories(repo).unwrap();
}
let dir = repo.join(".claude");
let count = fs::read_dir(&dir)
.unwrap()
.filter(|e| {
e.as_ref()
.ok()
.and_then(|x| {
x.file_name()
.to_str()
.map(|s| s.contains("settings.local.json.malformed."))
})
.unwrap_or(false)
})
.count();
assert!(count <= 3);
}
}