use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
pub const STALE_AFTER: Duration = Duration::from_secs(3600);
const TEMP_EXTENSION: &str = "tmp";
const TEMP_TRIES: u32 = 64;
static SERIAL: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, PartialEq, Eq)]
enum Flush {
Full,
None,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Land {
Replace,
Refuse,
}
pub fn write(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
put(path, contents.as_ref(), Flush::Full, Land::Replace)
}
pub fn write_new(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
put(path, contents.as_ref(), Flush::Full, Land::Refuse)
}
pub fn write_cache(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
put(path, contents.as_ref(), Flush::None, Land::Replace)
}
pub fn temp_path(target: &Path, serial: u64) -> PathBuf {
let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let temp = format!(".{name}.{}-{serial}.{TEMP_EXTENSION}", std::process::id());
match target.parent() {
Some(dir) => dir.join(temp),
None => PathBuf::from(temp),
}
}
pub fn is_temp(path: &Path) -> bool {
path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
&& path.extension().is_some_and(|e| e == TEMP_EXTENSION)
}
fn put(path: &Path, bytes: &[u8], flush: Flush, land: Land) -> io::Result<()> {
let parent = match path.parent() {
Some(p) if p.as_os_str().is_empty() => Path::new("."),
Some(p) => p,
None => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no parent directory", path.display()),
));
}
};
fs::create_dir_all(parent)?;
let (tmp, mut file) = create_temp(path)?;
let written = file.write_all(bytes).and_then(|()| match flush {
Flush::Full => file.sync_all(),
Flush::None => Ok(()),
});
drop(file);
if let Err(e) = written {
let _ = fs::remove_file(&tmp);
return Err(e);
}
let landed = match land {
Land::Replace => fs::rename(&tmp, path),
Land::Refuse => fs::hard_link(&tmp, path),
};
if land == Land::Refuse || landed.is_err() {
let _ = fs::remove_file(&tmp);
}
landed?;
if flush == Flush::Full {
let _ = File::open(parent).and_then(|dir| dir.sync_all());
sweep_stale(parent, path);
}
Ok(())
}
fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
let mut last = None;
for _ in 0..TEMP_TRIES {
let tmp = temp_path(path, SERIAL.fetch_add(1, Ordering::Relaxed));
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(file) => return Ok((tmp, file)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
Err(e) => return Err(e),
}
}
Err(last.unwrap_or_else(|| io::Error::other("no unused temp name")))
}
fn is_temp_of(target_name: &str, file_name: &str) -> bool {
let Some(rest) = file_name.strip_prefix('.').and_then(|r| r.strip_prefix(target_name)).and_then(|r| r.strip_prefix('.'))
else {
return false;
};
let Some(pid_serial) = rest.strip_suffix(".tmp") else { return false };
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
pid_serial.split_once('-').is_some_and(|(pid, serial)| digits(pid) && digits(serial))
}
fn sweep_stale(parent: &Path, target: &Path) {
let Some(name) = target.file_name() else { return };
let name = name.to_string_lossy();
let Ok(entries) = fs::read_dir(parent) else { return };
let now = SystemTime::now();
for entry in entries.flatten() {
if !is_temp_of(&name, &entry.file_name().to_string_lossy()) {
continue;
}
let old = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|m| now.duration_since(m).ok())
.is_some_and(|age| age >= STALE_AFTER);
if old {
let _ = fs::remove_file(entry.path());
}
}
}