use anyhow::{Context, Result, anyhow};
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
#[cfg(unix)]
pub(crate) fn read_regular_file_at(
directory: std::os::fd::BorrowedFd<'_>,
name: &std::ffi::CStr,
) -> Result<Vec<u8>> {
use rustix::fs::{Mode, OFlags};
use std::io::Read;
let descriptor = rustix::fs::openat(
directory,
name,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::NOCTTY,
Mode::empty(),
)
.context("journal open without following symlinks failed")?;
let mut file = File::from(descriptor);
anyhow::ensure!(file.metadata()?.is_file(), "journal is not a regular file");
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.context("journal read failed")?;
Ok(bytes)
}
#[cfg(unix)]
pub(crate) fn read_regular_file(path: &Path) -> Result<Vec<u8>> {
use rustix::fs::{Mode, OFlags};
use std::os::fd::AsFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let name = std::ffi::CString::new(
path.file_name()
.context("journal has no file name")?
.as_bytes(),
)?;
let directory = File::from(
rustix::fs::open(
parent,
OFlags::RDONLY
| OFlags::DIRECTORY
| OFlags::CLOEXEC
| OFlags::NOFOLLOW
| OFlags::NONBLOCK,
Mode::empty(),
)
.context("journal directory open without following symlinks failed")?,
);
let bytes = read_regular_file_at(directory.as_fd(), &name)?;
let held = directory.metadata()?;
let named = fs::symlink_metadata(parent)?;
anyhow::ensure!(
named.is_dir() && held.dev() == named.dev() && held.ino() == named.ino(),
"journal directory changed during observation"
);
Ok(bytes)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdempotentResult {
Created,
AlreadyExists,
Updated,
Unchanged,
DryRun,
Changed,
WouldChange(String),
NotApplicable(String),
}
impl std::fmt::Display for IdempotentResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IdempotentResult::Created => write!(f, "created"),
IdempotentResult::AlreadyExists => write!(f, "already exists"),
IdempotentResult::Updated => write!(f, "updated"),
IdempotentResult::Unchanged => write!(f, "unchanged"),
IdempotentResult::DryRun => write!(f, "dry-run"),
IdempotentResult::Changed => write!(f, "changed"),
IdempotentResult::WouldChange(msg) => write!(f, "would change: {}", msg),
IdempotentResult::NotApplicable(msg) => write!(f, "not applicable: {}", msg),
}
}
}
pub fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("No parent directory for path: {:?}", path))?;
if !parent.exists() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {:?}", parent))?;
}
let temp_name = format!(
".{}.{}.{}.tmp",
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default(),
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let temp_path = parent.join(temp_name);
struct TempGuard<'a>(Option<&'a std::path::Path>);
impl Drop for TempGuard<'_> {
fn drop(&mut self) {
if let Some(p) = self.0 {
let _ = fs::remove_file(p);
}
}
}
let mut guard = TempGuard(Some(temp_path.as_path()));
{
let mut file = File::create(&temp_path)
.with_context(|| format!("Failed to create temp file: {:?}", temp_path))?;
file.write_all(content)
.with_context(|| format!("Failed to write to temp file: {:?}", temp_path))?;
file.sync_all()
.with_context(|| format!("Failed to sync temp file: {:?}", temp_path))?;
}
fs::rename(&temp_path, path)
.with_context(|| format!("Failed to rename {:?} to {:?}", temp_path, path))?;
guard.0 = None;
if let Ok(dir) = File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
pub fn create_if_missing(path: &Path, content: &str) -> Result<IdempotentResult> {
if path.exists() {
return Ok(IdempotentResult::AlreadyExists);
}
if let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {:?}", parent))?;
}
atomic_write(path, content.as_bytes())?;
Ok(IdempotentResult::Created)
}
pub fn update_if_changed(path: &Path, new_content: &str, backup: bool) -> Result<IdempotentResult> {
if !path.exists() {
atomic_write(path, new_content.as_bytes())?;
return Ok(IdempotentResult::Created);
}
let existing = fs::read_to_string(path)
.with_context(|| format!("Failed to read existing file: {:?}", path))?;
if existing == new_content {
return Ok(IdempotentResult::Unchanged);
}
if backup {
create_backup(path)?;
}
atomic_write(path, new_content.as_bytes())?;
Ok(IdempotentResult::Updated)
}
pub fn create_backup(path: &Path) -> Result<std::path::PathBuf> {
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let backup_dir = dirs::data_dir()
.ok_or_else(|| anyhow!("Cannot determine data directory"))?
.join("rch/backups");
fs::create_dir_all(&backup_dir)
.with_context(|| format!("Failed to create backup directory: {:?}", backup_dir))?;
let filename = path
.file_name()
.ok_or_else(|| anyhow!("Invalid path: no filename"))?
.to_string_lossy();
let backup_path = backup_dir.join(format!("{}_{}.bak", filename, timestamp));
fs::copy(path, &backup_path)
.with_context(|| format!("Failed to copy {:?} to {:?}", path, backup_path))?;
cleanup_old_backups(&backup_dir, &filename, 10)?;
Ok(backup_path)
}
fn cleanup_old_backups(backup_dir: &Path, prefix: &str, keep: usize) -> Result<()> {
let anchor = format!("{}_", prefix);
let mut backups: Vec<_> = fs::read_dir(backup_dir)
.with_context(|| format!("Failed to read backup directory: {:?}", backup_dir))?
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with(&anchor) && name.ends_with(".bak")
})
.collect();
backups.sort_by(|a, b| {
b.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
.cmp(
&a.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
for backup in backups.into_iter().skip(keep) {
if let Err(e) = fs::remove_file(backup.path()) {
tracing::warn!("Failed to remove old backup {:?}: {}", backup.path(), e);
}
}
Ok(())
}
#[cfg(unix)]
pub fn ensure_symlink(link: &Path, target: &Path) -> Result<IdempotentResult> {
use std::os::unix::fs::symlink;
if link.symlink_metadata().is_ok() {
if let Ok(current_target) = fs::read_link(link)
&& current_target == target
{
return Ok(IdempotentResult::AlreadyExists);
}
fs::remove_file(link)
.with_context(|| format!("Failed to remove existing link: {:?}", link))?;
}
if let Some(parent) = link.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {:?}", parent))?;
}
symlink(target, link)
.with_context(|| format!("Failed to create symlink {:?} -> {:?}", link, target))?;
Ok(IdempotentResult::Created)
}
#[cfg(windows)]
pub fn ensure_symlink(link: &Path, target: &Path) -> Result<IdempotentResult> {
use std::os::windows::fs::symlink_file;
if link.symlink_metadata().is_ok() {
if let Ok(current_target) = fs::read_link(link)
&& current_target == target
{
return Ok(IdempotentResult::AlreadyExists);
}
fs::remove_file(link)
.with_context(|| format!("Failed to remove existing link: {:?}", link))?;
}
if let Some(parent) = link.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {:?}", parent))?;
}
symlink_file(target, link)
.with_context(|| format!("Failed to create symlink {:?} -> {:?}", link, target))?;
Ok(IdempotentResult::Created)
}
pub fn append_line_if_missing(path: &Path, line: &str) -> Result<IdempotentResult> {
let content = if path.exists() {
fs::read_to_string(path).with_context(|| format!("Failed to read file: {:?}", path))?
} else {
String::new()
};
if content.lines().any(|l| l.trim() == line.trim()) {
return Ok(IdempotentResult::AlreadyExists);
}
let was_empty = content.is_empty();
let mut new_content = content;
if !new_content.ends_with('\n') && !new_content.is_empty() {
new_content.push('\n');
}
new_content.push_str(line);
new_content.push('\n');
atomic_write(path, new_content.as_bytes())?;
if was_empty {
Ok(IdempotentResult::Created)
} else {
Ok(IdempotentResult::Updated)
}
}
pub fn ensure_directory(path: &Path) -> Result<IdempotentResult> {
if path.exists() {
if path.is_dir() {
return Ok(IdempotentResult::AlreadyExists);
}
return Err(anyhow!("Path exists but is not a directory: {:?}", path));
}
fs::create_dir_all(path).with_context(|| format!("Failed to create directory: {:?}", path))?;
Ok(IdempotentResult::Created)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tracing::info;
fn log_test_start(name: &str) {
info!("TEST START: {}", name);
}
fn log_test_pass(name: &str) {
info!("TEST PASS: {}", name);
}
#[cfg(unix)]
#[test]
fn regular_file_reader_preserves_large_bytes_and_rejects_nonregular_or_linked_roots() {
let retained = tempfile::tempdir().unwrap().keep();
let path = retained.join("large.json");
let bytes = vec![b'x'; 2 * 1024 * 1024];
fs::write(&path, &bytes).unwrap();
assert_eq!(read_regular_file(&path).unwrap(), bytes);
let link = retained.join("linked.json");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(read_regular_file(&link).is_err());
assert!(read_regular_file(&retained).is_err());
let linked_root = retained.join("linked-root");
std::os::unix::fs::symlink(&retained, &linked_root).unwrap();
assert!(read_regular_file(&linked_root.join("large.json")).is_err());
assert_eq!(fs::read(path).unwrap(), bytes);
}
#[test]
fn test_atomic_write_creates_file() {
log_test_start("test_atomic_write_creates_file");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
atomic_write(&path, b"hello").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
log_test_pass("test_atomic_write_creates_file");
}
#[test]
fn test_atomic_write_overwrites() {
log_test_start("test_atomic_write_overwrites");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
atomic_write(&path, b"original").unwrap();
atomic_write(&path, b"updated").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
log_test_pass("test_atomic_write_overwrites");
}
#[test]
fn test_atomic_write_creates_parent_dirs() {
log_test_start("test_atomic_write_creates_parent_dirs");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("a/b/c/test.txt");
atomic_write(&path, b"nested").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "nested");
log_test_pass("test_atomic_write_creates_parent_dirs");
}
#[test]
fn test_atomic_write_no_temp_files_left() {
log_test_start("test_atomic_write_no_temp_files_left");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
atomic_write(&path, b"content").unwrap();
let tmp_files: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "tmp"))
.collect();
assert!(tmp_files.is_empty(), "Temp files should be cleaned up");
log_test_pass("test_atomic_write_no_temp_files_left");
}
#[test]
fn test_create_if_missing_creates() {
log_test_start("test_create_if_missing_creates");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let result = create_if_missing(&path, "[general]").unwrap();
assert_eq!(result, IdempotentResult::Created);
assert_eq!(fs::read_to_string(&path).unwrap(), "[general]");
log_test_pass("test_create_if_missing_creates");
}
#[test]
fn test_create_if_missing_idempotent() {
log_test_start("test_create_if_missing_idempotent");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let r1 = create_if_missing(&path, "content1").unwrap();
assert_eq!(r1, IdempotentResult::Created);
let r2 = create_if_missing(&path, "content2").unwrap();
assert_eq!(r2, IdempotentResult::AlreadyExists);
assert_eq!(fs::read_to_string(&path).unwrap(), "content1");
log_test_pass("test_create_if_missing_idempotent");
}
#[test]
fn test_update_if_changed_creates() {
log_test_start("test_update_if_changed_creates");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let result = update_if_changed(&path, "v1", false).unwrap();
assert_eq!(result, IdempotentResult::Created);
log_test_pass("test_update_if_changed_creates");
}
#[test]
fn test_update_if_changed_updates() {
log_test_start("test_update_if_changed_updates");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
update_if_changed(&path, "v1", false).unwrap();
let result = update_if_changed(&path, "v2", false).unwrap();
assert_eq!(result, IdempotentResult::Updated);
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
log_test_pass("test_update_if_changed_updates");
}
#[test]
fn test_update_if_changed_unchanged() {
log_test_start("test_update_if_changed_unchanged");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
update_if_changed(&path, "same", false).unwrap();
let result = update_if_changed(&path, "same", false).unwrap();
assert_eq!(result, IdempotentResult::Unchanged);
log_test_pass("test_update_if_changed_unchanged");
}
#[test]
fn test_append_line_if_missing_creates() {
log_test_start("test_append_line_if_missing_creates");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
let result = append_line_if_missing(&path, "line1").unwrap();
assert_eq!(result, IdempotentResult::Created);
assert!(fs::read_to_string(&path).unwrap().contains("line1"));
log_test_pass("test_append_line_if_missing_creates");
}
#[test]
fn test_append_line_if_missing_appends() {
log_test_start("test_append_line_if_missing_appends");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
fs::write(&path, "existing\n").unwrap();
let result = append_line_if_missing(&path, "new").unwrap();
assert_eq!(result, IdempotentResult::Updated);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("existing"));
assert!(content.contains("new"));
log_test_pass("test_append_line_if_missing_appends");
}
#[test]
fn test_append_line_if_missing_idempotent() {
log_test_start("test_append_line_if_missing_idempotent");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
append_line_if_missing(&path, "line").unwrap();
let result = append_line_if_missing(&path, "line").unwrap();
assert_eq!(result, IdempotentResult::AlreadyExists);
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content.matches("line").count(), 1);
log_test_pass("test_append_line_if_missing_idempotent");
}
#[test]
fn test_ensure_directory_creates() {
log_test_start("test_ensure_directory_creates");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("new_dir");
let result = ensure_directory(&path).unwrap();
assert_eq!(result, IdempotentResult::Created);
assert!(path.is_dir());
log_test_pass("test_ensure_directory_creates");
}
#[test]
fn test_ensure_directory_idempotent() {
log_test_start("test_ensure_directory_idempotent");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("dir");
ensure_directory(&path).unwrap();
let result = ensure_directory(&path).unwrap();
assert_eq!(result, IdempotentResult::AlreadyExists);
log_test_pass("test_ensure_directory_idempotent");
}
#[test]
fn test_ensure_directory_errors_on_file() {
log_test_start("test_ensure_directory_errors_on_file");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("not_a_dir");
fs::write(&path, "data").unwrap();
let result = ensure_directory(&path);
assert!(result.is_err(), "Expected error when path is a file");
log_test_pass("test_ensure_directory_errors_on_file");
}
#[cfg(unix)]
#[test]
fn test_ensure_symlink_creates() {
log_test_start("test_ensure_symlink_creates");
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("target");
let link = tmp.path().join("link");
fs::write(&target, "content").unwrap();
let result = ensure_symlink(&link, &target).unwrap();
assert_eq!(result, IdempotentResult::Created);
assert_eq!(fs::read_link(&link).unwrap(), target);
log_test_pass("test_ensure_symlink_creates");
}
#[cfg(unix)]
#[test]
fn test_ensure_symlink_idempotent() {
log_test_start("test_ensure_symlink_idempotent");
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("target");
let link = tmp.path().join("link");
fs::write(&target, "content").unwrap();
ensure_symlink(&link, &target).unwrap();
let result = ensure_symlink(&link, &target).unwrap();
assert_eq!(result, IdempotentResult::AlreadyExists);
log_test_pass("test_ensure_symlink_idempotent");
}
#[cfg(unix)]
#[test]
fn test_ensure_symlink_updates_target() {
log_test_start("test_ensure_symlink_updates_target");
let tmp = TempDir::new().unwrap();
let target_a = tmp.path().join("target_a");
let target_b = tmp.path().join("target_b");
let link = tmp.path().join("link");
fs::write(&target_a, "a").unwrap();
fs::write(&target_b, "b").unwrap();
let created = ensure_symlink(&link, &target_a).unwrap();
assert_eq!(created, IdempotentResult::Created);
let updated = ensure_symlink(&link, &target_b).unwrap();
assert_eq!(updated, IdempotentResult::Created);
assert_eq!(fs::read_link(&link).unwrap(), target_b);
log_test_pass("test_ensure_symlink_updates_target");
}
#[test]
fn test_idempotent_result_display() {
log_test_start("test_idempotent_result_display");
assert_eq!(format!("{}", IdempotentResult::Created), "created");
assert_eq!(
format!("{}", IdempotentResult::AlreadyExists),
"already exists"
);
assert_eq!(format!("{}", IdempotentResult::Updated), "updated");
assert_eq!(format!("{}", IdempotentResult::Unchanged), "unchanged");
assert_eq!(format!("{}", IdempotentResult::DryRun), "dry-run");
log_test_pass("test_idempotent_result_display");
}
}