use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const LOCK_FILE: &str = "bynk.schema.lock";
pub fn lock_path(project_root: &Path) -> PathBuf {
project_root.join(LOCK_FILE)
}
pub fn read(project_root: &Path) -> io::Result<Option<String>> {
match std::fs::read_to_string(lock_path(project_root)) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn write(project_root: &Path, content: &str) -> io::Result<()> {
let path = lock_path(project_root);
if let Ok(existing) = std::fs::read_to_string(&path)
&& existing == content
{
return Ok(());
}
let dir = project_root;
let (tmp, mut file) = loop {
let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let candidate = dir.join(format!(".{LOCK_FILE}.{}.{n}.tmp", std::process::id()));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(f) => break (candidate, f),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
};
let write_then_sync = file
.write_all(content.as_bytes())
.and_then(|()| file.sync_all());
if let Err(e) = write_then_sync {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
drop(file);
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Ok(dir_handle) = std::fs::File::open(dir) {
let _ = dir_handle.sync_all();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn scratch_dir(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!(
"bynk_1078_schema_lock_{tag}_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
#[test]
fn read_of_an_absent_file_is_verified_absent() {
let dir = scratch_dir("absent");
assert_eq!(read(&dir.0).unwrap(), None);
}
#[cfg(unix)]
#[test]
fn read_of_an_unreadable_present_file_is_a_real_error_not_absent() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch_dir("unreadable");
std::fs::write(lock_path(&dir.0), "version = 1\n").unwrap();
std::fs::set_permissions(lock_path(&dir.0), std::fs::Permissions::from_mode(0o000))
.unwrap();
if std::fs::read_to_string(lock_path(&dir.0)).is_ok() {
return;
}
assert!(
read(&dir.0).is_err(),
"an unreadable-but-present file must not be reported as absent"
);
}
#[test]
fn write_then_read_round_trips() {
let dir = scratch_dir("roundtrip");
let content = "version = 1\n";
write(&dir.0, content).unwrap();
assert_eq!(read(&dir.0).unwrap().as_deref(), Some(content));
}
#[test]
fn write_is_a_no_op_when_content_is_unchanged() {
let dir = scratch_dir("noop-write");
let content = "version = 1\n";
write(&dir.0, content).unwrap();
let mtime_before = std::fs::metadata(lock_path(&dir.0))
.unwrap()
.modified()
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
write(&dir.0, content).unwrap();
let mtime_after = std::fs::metadata(lock_path(&dir.0))
.unwrap()
.modified()
.unwrap();
assert_eq!(
mtime_before, mtime_after,
"unchanged content must not rewrite the file"
);
}
}