use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use serde_json::json;
use tempfile::TempDir;
use super::*;
fn lock_path(settings_path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
Ok(lock_sidecar(&stable_path(settings_path)?))
}
#[test]
fn lock_sidecar_is_named_after_the_settings_file() {
assert_eq!(
lock_sidecar(std::path::Path::new(".claude/settings.json")),
std::path::PathBuf::from(".claude/settings.json.lock"),
"a relative settings path must yield a relative sidecar"
);
}
#[test]
fn lock_path_is_a_sidecar_of_the_settings_file() {
let dir = TempDir::new().expect("tempdir");
let settings = dir.path().join(".claude").join("settings.json");
let lock = lock_path(&settings).expect("lock path");
assert_eq!(lock.file_name().unwrap(), "settings.json.lock");
assert_eq!(
lock.parent().unwrap(),
settings.parent().unwrap().canonicalize().unwrap(),
"the sidecar must live in the settings file's own directory"
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_directory_resolves_to_one_lock() {
let dir = TempDir::new().expect("tempdir");
let real = dir.path().join("real");
std::fs::create_dir_all(&real).expect("create real dir");
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let through_real = lock_path(&real.join("settings.json")).expect("lock path via real");
let through_link = lock_path(&link.join("settings.json")).expect("lock path via link");
assert_eq!(through_real, through_link);
}
#[test]
fn serialises_concurrent_threads() {
let dir = TempDir::new().expect("tempdir");
let settings = Arc::new(dir.path().join("settings.json"));
let inside = Arc::new(AtomicUsize::new(0));
let overlapped = Arc::new(AtomicBool::new(false));
let handles: Vec<_> = (0..4)
.map(|_| {
let settings = Arc::clone(&settings);
let inside = Arc::clone(&inside);
let overlapped = Arc::clone(&overlapped);
std::thread::spawn(move || {
for _ in 0..25 {
with_settings_lock(&settings, || {
if inside.fetch_add(1, Ordering::SeqCst) != 0 {
overlapped.store(true, Ordering::SeqCst);
}
std::thread::yield_now();
inside.fetch_sub(1, Ordering::SeqCst);
})
.expect("lock");
}
})
})
.collect();
for handle in handles {
handle.join().expect("thread");
}
assert!(
!overlapped.load(Ordering::SeqCst),
"two holders were inside the critical section at once"
);
}
#[test]
fn errors_when_the_lock_is_unopenable() {
let dir = TempDir::new().expect("tempdir");
let settings = dir.path().join("settings.json");
std::fs::create_dir(lock_path(&settings).expect("lock path")).expect("occupy the sidecar");
let ran = AtomicBool::new(false);
let result = with_settings_lock(&settings, || ran.store(true, Ordering::SeqCst));
assert!(result.is_err(), "an unopenable sidecar must not lock");
assert!(!ran.load(Ordering::SeqCst), "the closure must not have run");
}
#[test]
fn an_acquisition_failure_names_the_sidecar() {
let dir = TempDir::new().expect("tempdir");
let settings = dir.path().join("settings.json");
std::fs::create_dir(lock_path(&settings).expect("lock path")).expect("occupy the sidecar");
let err = with_settings_lock(&settings, || ()).expect_err("an unopenable sidecar must error");
let message = err.to_string();
assert!(
message.contains("settings.json.lock"),
"the message must name the sidecar: {message}"
);
}
#[test]
fn errors_when_the_parent_cannot_be_created() {
let dir = TempDir::new().expect("tempdir");
let blocker = dir.path().join("not-a-dir");
std::fs::write(&blocker, b"x").expect("write blocker");
let ran = AtomicBool::new(false);
let result = with_settings_lock(&blocker.join("settings.json"), || {
ran.store(true, Ordering::SeqCst);
});
assert!(result.is_err());
assert!(!ran.load(Ordering::SeqCst));
}
#[test]
fn publish_replaces_the_file_and_leaves_no_bak() {
let dir = TempDir::new().expect("tempdir");
let settings = dir.path().join("settings.json");
std::fs::write(&settings, br#"{"outputStyle":"old"}"#).expect("seed");
publish(&settings, &json!({ "outputStyle": "new" })).expect("publish");
let text = std::fs::read_to_string(&settings).expect("read back");
assert!(text.contains("\"new\""), "{text}");
assert!(
!settings.with_file_name("settings.json.bak").exists(),
"the publish must not leave a .bak sibling"
);
}
#[test]
fn publish_leaves_no_staged_file_behind() {
let dir = TempDir::new().expect("tempdir");
let settings = dir.path().join("settings.json");
publish(&settings, &json!({ "a": 1 })).expect("publish");
let strays: Vec<_> = std::fs::read_dir(dir.path())
.expect("read dir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "left staging files: {strays:?}");
}
#[test]
fn a_crash_between_stage_and_rename_leaves_the_original_intact() {
let dir = TempDir::new().expect("tempdir");
let settings = dir.path().join("settings.json");
let original = br#"{"outputStyle":"original"}"#;
std::fs::write(&settings, original).expect("seed");
let staged = stage(&settings, b"{\"outputStyle\":\"interrupted\"}").expect("stage");
assert_eq!(
std::fs::read(&settings).expect("read original"),
original,
"the settings file must be byte-for-byte what it was"
);
assert_eq!(
staged.parent().unwrap(),
settings.parent().unwrap(),
"the staged file must be a sibling so the publish is a same-filesystem rename"
);
}
#[test]
fn staged_paths_are_unique_per_call() {
let settings = std::path::Path::new("/tmp/settings.json");
let first = temp_path(settings);
let second = temp_path(settings);
assert_ne!(first, second);
assert_eq!(first.parent(), settings.parent());
}