use std::fs;
use std::io::Write;
use std::path::Path;
use std::time::SystemTime;
use tracing::{info, warn};
use super::FileStore;
use crate::error::GettextError;
pub(crate) const TMP_FILE_PREFIX: &str = ".gettext-mcp-";
pub(crate) const TMP_FILE_SUFFIX: &str = ".tmp";
fn strip_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
pub fn cleanup_orphan_tmps(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(TMP_FILE_PREFIX) && name_str.ends_with(TMP_FILE_SUFFIX) {
let path = entry.path();
match fs::remove_file(&path) {
Ok(()) => info!("cleaned up orphan temp file: {}", path.display()),
Err(e) => warn!(
"failed to remove orphan temp file {}: {}",
path.display(),
e
),
}
}
}
}
#[derive(Debug, Default)]
pub struct FsFileStore;
impl FsFileStore {
pub fn new() -> Self {
Self
}
}
impl FileStore for FsFileStore {
fn read(&self, path: &Path) -> Result<String, GettextError> {
let content = fs::read_to_string(path)?;
Ok(strip_bom(&content).to_string())
}
fn write(&self, path: &Path, content: &str) -> Result<(), GettextError> {
atomic_write(path, content.as_bytes())
}
fn write_bytes(&self, path: &Path, bytes: &[u8]) -> Result<(), GettextError> {
atomic_write(path, bytes)
}
fn modified_time(&self, path: &Path) -> Result<SystemTime, GettextError> {
let meta = fs::metadata(path)?;
Ok(meta.modified()?)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
}
fn atomic_write(target: &Path, content: &[u8]) -> Result<(), GettextError> {
let dir = target.parent().ok_or_else(|| {
GettextError::InvalidPath(format!(
"no parent directory for target path: {}",
target.display()
))
})?;
let _lock_guard = acquire_flock(target)?;
static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_name = format!(
"{}{}-{}-{}{}",
TMP_FILE_PREFIX,
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
seq,
TMP_FILE_SUFFIX,
);
let tmp_path = dir.join(&tmp_name);
let result = (|| -> Result<(), GettextError> {
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
fs::rename(&tmp_path, target)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp_path);
}
result
}
#[cfg(unix)]
struct FlockGuard {
_file: fs::File,
}
#[cfg(unix)]
fn acquire_flock(target: &Path) -> Result<Option<FlockGuard>, GettextError> {
use std::os::unix::io::AsRawFd;
if !target.exists() {
return Ok(None);
}
let file = match fs::File::open(target) {
Ok(f) => f,
Err(e) => {
warn!(
"could not open {} for advisory lock: {} — proceeding without lock",
target.display(),
e
);
return Ok(None);
}
};
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if ret != 0 {
let errno = std::io::Error::last_os_error();
if errno.kind() == std::io::ErrorKind::WouldBlock {
return Err(GettextError::FileLocked {
path: target.to_path_buf(),
});
}
warn!(
"advisory flock unavailable for {}: {} — proceeding without lock",
target.display(),
errno
);
return Ok(None);
}
Ok(Some(FlockGuard { _file: file }))
}
#[cfg(not(unix))]
struct FlockGuard;
#[cfg(not(unix))]
fn acquire_flock(_target: &Path) -> Result<Option<FlockGuard>, GettextError> {
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleanup_orphan_tmps_removes_only_matching_files() {
let dir = tempfile::TempDir::new().unwrap();
let orphan = dir
.path()
.join(format!("{TMP_FILE_PREFIX}123-456{TMP_FILE_SUFFIX}"));
let keep = dir.path().join("messages.po");
std::fs::write(&orphan, b"junk").unwrap();
std::fs::write(&keep, b"msgid \"\"\nmsgstr \"\"\n").unwrap();
cleanup_orphan_tmps(dir.path());
assert!(!orphan.exists(), "orphan temp file should be removed");
assert!(keep.exists(), "unrelated .po file must be preserved");
}
#[test]
fn write_then_read_roundtrip() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("hello.po");
let store = FsFileStore::new();
store
.write(&path, "msgid \"Hi\"\nmsgstr \"Hej\"\n")
.unwrap();
let content = store.read(&path).unwrap();
assert!(content.contains("Hej"));
}
#[test]
fn read_strips_leading_bom() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("bom.po");
let body = "msgid \"\"\nmsgstr \"\"\n";
let with_bom = format!("\u{feff}{body}");
std::fs::write(&path, with_bom.as_bytes()).unwrap();
let store = FsFileStore::new();
let read = store.read(&path).unwrap();
assert!(!read.starts_with('\u{feff}'));
assert_eq!(read, body);
}
#[test]
fn atomic_write_leaves_no_orphan_tmp_files() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("clean.po");
let store = FsFileStore::new();
store.write(&path, "msgid \"a\"\nmsgstr \"b\"\n").unwrap();
store.write(&path, "msgid \"c\"\nmsgstr \"d\"\n").unwrap();
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(TMP_FILE_SUFFIX))
.collect();
assert!(leftovers.is_empty(), "found orphan tmp files");
}
#[cfg(unix)]
#[test]
fn flock_blocks_concurrent_write() {
use std::os::unix::io::AsRawFd;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("locked.po");
let store = FsFileStore::new();
store.write(&path, "initial").unwrap();
let lock_file = fs::File::open(&path).unwrap();
let fd = lock_file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(ret, 0);
let err = store.write(&path, "updated").unwrap_err();
assert!(
matches!(err, GettextError::FileLocked { .. }),
"expected FileLocked, got: {err}"
);
unsafe { libc::flock(fd, libc::LOCK_UN) };
drop(lock_file);
store.write(&path, "updated").unwrap();
let content = store.read(&path).unwrap();
assert_eq!(content, "updated");
}
#[test]
fn modified_time_returns_recent_value() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("timed.po");
let store = FsFileStore::new();
store.write(&path, "content").unwrap();
let mtime = store.modified_time(&path).unwrap();
let elapsed = SystemTime::now().duration_since(mtime).unwrap();
assert!(elapsed.as_secs() < 5);
}
#[test]
fn write_bytes_preserves_binary_content() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("data.bin");
let store = FsFileStore::new();
let bytes: Vec<u8> = vec![0xde, 0x12, 0x04, 0x95, 0x00, 0xff, 0x01];
store.write_bytes(&path, &bytes).unwrap();
let read_back = std::fs::read(&path).unwrap();
assert_eq!(read_back, bytes);
}
#[test]
fn exists_reflects_filesystem() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("ex.po");
let store = FsFileStore::new();
assert!(!store.exists(&path));
store.write(&path, "x").unwrap();
assert!(store.exists(&path));
}
}