use anyhow::{Context, Result, anyhow};
use serde_json::{Map, Value};
use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct ClaudeJson {
pub raw: String,
pub data: Value,
}
impl ClaudeJson {
pub fn load(path: &Path) -> Result<Self> {
let raw =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let data: Value =
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
Ok(Self { raw, data })
}
pub fn projects(&self) -> Option<&Map<String, Value>> {
self.data.get("projects").and_then(Value::as_object)
}
pub fn projects_mut(&mut self) -> Option<&mut Map<String, Value>> {
self.data.get_mut("projects").and_then(Value::as_object_mut)
}
}
pub fn render(data: &Value) -> Result<String> {
serde_json::to_string_pretty(data).map_err(|e| anyhow!("serialize: {e}"))
}
pub fn project_entry<'a>(data: &'a Value, root: &Path) -> Option<&'a Value> {
let projects = data.get("projects")?.as_object()?;
if let Some(v) = projects.get(root.to_string_lossy().as_ref()) {
return Some(v);
}
projects
.iter()
.find_map(|(k, v)| (std::fs::canonicalize(k).ok()? == root).then_some(v))
}
pub fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("claude.json");
let tmp = {
let (tmp, mut f) = create_temp_sibling(dir, name)?;
let write_result = (|| -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = match std::fs::metadata(path) {
Ok(m) => m.permissions().mode() & 0o7777,
Err(_) => 0o600,
};
f.set_permissions(std::fs::Permissions::from_mode(mode))
.with_context(|| format!("set mode on temp {}", tmp.display()))?;
}
f.write_all(contents.as_bytes())
.with_context(|| format!("write temp {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("sync temp {}", tmp.display()))?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
tmp
};
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(anyhow!(
"rename {} -> {}: {e}",
tmp.display(),
path.display()
));
}
#[cfg(unix)]
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
Ok(())
}
fn create_temp_sibling(dir: &Path, name: &str) -> Result<(std::path::PathBuf, std::fs::File)> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
for attempt in 0..100 {
let tmp = dir.join(format!(
".{name}.tmp-{}-{nonce}-{attempt}",
std::process::id()
));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&tmp) {
Ok(file) => return Ok((tmp, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(anyhow!("create temp {}: {e}", tmp.display())),
}
}
Err(anyhow!(
"create temp in {}: exhausted collision retries",
dir.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn project_entry_matches_an_exact_key() {
let data = json!({ "projects": { "/x/y": { "k": 1 } } });
assert!(project_entry(&data, Path::new("/x/y")).is_some());
assert!(project_entry(&data, Path::new("/x/z")).is_none());
}
#[test]
fn project_entry_falls_back_to_canonical_comparison() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
std::fs::create_dir(&a).unwrap();
let root = a.canonicalize().unwrap();
let noncanon = dir.path().join(".").join("a");
let mut projects = Map::new();
projects.insert(noncanon.to_string_lossy().into_owned(), json!({ "k": 1 }));
let mut top = Map::new();
top.insert("projects".into(), Value::Object(projects));
let data = Value::Object(top);
assert!(project_entry(&data, &root).is_some());
}
#[test]
fn write_atomic_replaces_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.json");
std::fs::write(&path, "old").unwrap();
write_atomic(&path, "new").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
}
#[test]
fn write_atomic_removes_temp_file_after_success() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.json");
write_atomic(&path, "{}").unwrap();
let temps = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".c.json.tmp-")
})
.count();
assert_eq!(
temps, 0,
"successful atomic write should publish or clean temp"
);
}
#[cfg(unix)]
fn mode(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).unwrap().permissions().mode() & 0o7777
}
#[cfg(unix)]
fn chmod(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
#[cfg(unix)]
#[test]
fn write_atomic_preserves_a_restrictive_target_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.json");
std::fs::write(&path, "{}").unwrap();
chmod(&path, 0o600);
write_atomic(&path, "{\"k\":1}").unwrap();
assert_eq!(mode(&path), 0o600, "0600 target must stay 0600");
}
#[cfg(unix)]
#[test]
fn write_atomic_preserves_a_broad_target_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.json");
std::fs::write(&path, "{}").unwrap();
chmod(&path, 0o644);
write_atomic(&path, "{\"k\":1}").unwrap();
assert_eq!(mode(&path), 0o644, "deliberate modes are kept, not clamped");
}
#[cfg(unix)]
#[test]
fn write_atomic_creates_missing_targets_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("new.json");
write_atomic(&path, "{}").unwrap();
assert_eq!(mode(&path), 0o600, "this file class holds credentials");
}
}