use std::fs;
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::Instant;
use anyhow::{Context, Result};
use crate::checksum;
use crate::ndjson_types::PlatformInfo;
use crate::platform;
#[cfg(windows)]
use crate::error::AtomwriteError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteStrategy {
Rename,
InPlace,
CopyBack,
}
impl WriteStrategy {
#[inline]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Rename => "rename",
Self::InPlace => "inplace",
Self::CopyBack => "copyback",
}
}
}
pub struct AtomicWriteOptions {
pub backup: bool,
pub retention: u8,
pub preserve_timestamps: bool,
pub backup_output_dir: Option<std::path::PathBuf>,
pub strategy: Option<WriteStrategy>,
pub strict_atomic: bool,
pub syntax_check: bool,
}
impl Default for AtomicWriteOptions {
fn default() -> Self {
Self {
backup: false,
retention: 5,
preserve_timestamps: false,
backup_output_dir: None,
strategy: None,
strict_atomic: false,
syntax_check: false,
}
}
}
pub struct WriteResult {
pub bytes_written: u64,
pub checksum: String,
pub checksum_before: Option<String>,
pub backup_path: Option<String>,
pub elapsed_ms: u64,
pub platform: PlatformInfo,
pub hardlink_nlink: Option<u64>,
pub write_strategy: &'static str,
pub xattr_preserved: u32,
pub xattr_count: u32,
pub exdev_fallback: bool,
pub syntax_errors: u32,
}
#[tracing::instrument(skip_all, fields(path = %target.display()))]
pub fn atomic_write(
target: &Path,
content: &[u8],
opts: &AtomicWriteOptions,
workspace: &Path,
) -> Result<WriteResult> {
let start = Instant::now();
let target = crate::path_safety::validate_path(target, workspace)?;
let new_checksum = blake3::hash(content);
let wal_op_id = crate::wal::journal_started(
&target,
crate::wal::JournalOp::Write,
None, new_checksum,
)
.ok();
let (checksum_before, original_meta) = if target.exists() {
let meta =
fs::metadata(&target).with_context(|| format!("cannot stat {}", target.display()))?;
let hash = checksum::hash_file(&target, u64::MAX)?;
(Some(hash), Some(meta))
} else {
(None, None)
};
#[cfg(unix)]
let hardlink_nlink = if let Some(ref meta) = original_meta {
use std::os::unix::fs::MetadataExt;
let nlink = meta.nlink();
if nlink > 1 { Some(nlink) } else { None }
} else {
None
};
#[cfg(not(unix))]
let hardlink_nlink: Option<u64> = None;
let saved_xattrs = crate::xattr_restore::save_xattrs(&target).unwrap_or_else(|e| {
tracing::warn!(path = %target.display(), error = %e, "xattr save failed; continuing");
Vec::new()
});
let xattr_count = saved_xattrs.len() as u32;
let is_symlink = {
let sm = fs::symlink_metadata(&target);
sm.as_ref().map(fs::Metadata::is_symlink).unwrap_or(false)
};
let strategy = match opts.strategy {
Some(s) => s,
None => {
if hardlink_nlink.is_some_and(|n| n > 1) || is_symlink {
WriteStrategy::InPlace
} else {
WriteStrategy::Rename
}
}
};
if matches!(strategy, WriteStrategy::InPlace) {
if let Some(n) = hardlink_nlink {
tracing::info!(
path = %target.display(),
nlink = n,
"auto-switched to InPlace to preserve hardlink(s)"
);
} else if is_symlink {
tracing::info!(
path = %target.display(),
"auto-switched to InPlace because target is a symlink"
);
}
}
let (mtime, atime) = if let Some(ref meta) = original_meta {
(
filetime::FileTime::from_last_modification_time(meta),
filetime::FileTime::from_last_access_time(meta),
)
} else {
let now = filetime::FileTime::now();
(now, now)
};
let backup_path = if opts.backup && target.exists() {
Some(create_backup_in(
&target,
opts.retention,
opts.backup_output_dir.as_deref(),
)?)
} else {
None
};
if let Some(parent) = target.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create directories for {}", target.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(
parent,
fs::Permissions::from_mode(crate::constants::DIR_PERMISSIONS),
);
}
}
}
let syntax_errors: u32 = 0;
if opts.syntax_check {
match crate::syntax_check::syntax_check(&target, content) {
Ok(crate::syntax_check::SyntaxCheckResult::Ok) => {}
Ok(crate::syntax_check::SyntaxCheckResult::Skipped { .. }) => {
if let Some(reason) = syntax_heuristic_check(content) {
tracing::warn!(
path = %target.display(),
reason = %reason,
"G72 syntax heuristic (no tree-sitter parser) failed"
);
return Err(crate::error::AtomwriteError::SyntaxError {
path: target.to_path_buf(),
count: 1,
}
.into());
}
}
Ok(crate::syntax_check::SyntaxCheckResult::Errors { count, first }) => {
tracing::warn!(
path = %target.display(),
count = count,
line = first.line,
column = first.column,
kind = %first.kind,
message = %first.message,
"G72 tree-sitter syntax check failed"
);
return Err(crate::error::AtomwriteError::SyntaxError {
path: target.to_path_buf(),
count: count as u32,
}
.into());
}
Err(e) => {
tracing::warn!(
path = %target.display(),
error = %e,
"G72 tree-sitter check errored; falling back to heuristic"
);
if let Some(_reason) = syntax_heuristic_check(content) {
return Err(crate::error::AtomwriteError::SyntaxError {
path: target.to_path_buf(),
count: 1,
}
.into());
}
}
}
}
let exdev_fallback = match strategy {
WriteStrategy::Rename => write_rename_path(target.as_path(), content, opts.strict_atomic)?,
WriteStrategy::InPlace | WriteStrategy::CopyBack => {
write_inplace_path(target.as_path(), content)?
}
};
if let Some(parent) = target.parent() {
if let Err(e) = platform::fsync_dir(parent) {
tracing::warn!(
path = %parent.display(),
error = %e,
"fsync_dir after persist failed"
);
}
}
if let Some(ref meta) = original_meta {
let _ = fs::set_permissions(&target, meta.permissions());
}
let xattr_preserved = crate::xattr_restore::restore_xattrs(&target, &saved_xattrs)
.unwrap_or_else(|e| {
tracing::warn!(path = %target.display(), error = %e, "xattr restore failed");
0
});
if opts.preserve_timestamps && original_meta.is_some() {
let _ = platform::preserve_timestamps(&target, mtime, atime);
}
let checksum = checksum::hash_bytes(content);
if let Some(ref op_id) = wal_op_id {
let _ = crate::wal::journal_committed(&target, op_id);
}
Ok(WriteResult {
bytes_written: content.len() as u64,
checksum,
checksum_before,
backup_path: backup_path.map(|p| p.display().to_string()),
elapsed_ms: start.elapsed().as_millis() as u64,
platform: PlatformInfo {
fsync: platform::platform_fsync_name(),
dir_fsync: platform::platform_dir_fsync_name(),
},
hardlink_nlink,
write_strategy: strategy.as_str(),
xattr_preserved,
xattr_count,
exdev_fallback,
syntax_errors,
})
}
fn write_rename_path(
target: &Path,
content: &[u8],
#[cfg_attr(not(unix), allow(unused_variables))] strict_atomic: bool,
) -> Result<bool> {
let parent = target.parent().unwrap_or(Path::new("."));
let mut builder = tempfile::Builder::new();
builder
.prefix(crate::constants::TEMPFILE_PREFIX)
.suffix(crate::constants::TEMPFILE_SUFFIX);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(fs::Permissions::from_mode(
crate::constants::TEMPFILE_PERMISSIONS,
));
}
let temp = builder
.tempfile_in(parent)
.with_context(|| format!("cannot create tempfile in {}", parent.display()))?;
{
let mut writer = BufWriter::with_capacity(crate::constants::BUF_CAPACITY, temp.as_file());
writer
.write_all(content)
.with_context(|| format!("write error for {}", target.display()))?;
writer
.flush()
.with_context(|| format!("flush error for {}", target.display()))?;
writer.into_inner().map_err(|e| {
anyhow::anyhow!(
"BufWriter into_inner error for {}: {}",
target.display(),
e.error()
)
})?;
}
platform::fsync_file(temp.as_file())
.with_context(|| format!("fsync error for {}", target.display()))?;
#[cfg(windows)]
{
persist_with_retry(temp, target)?;
return Ok(false);
}
#[cfg(not(windows))]
{
match temp.persist(target) {
Ok(_) => Ok(false),
Err(e) => {
#[cfg(unix)]
{
if e.error.raw_os_error() == Some(libc::EXDEV) {
if strict_atomic {
return Err(crate::error::AtomwriteError::ExdevFallbackDisabled {
path: target.to_path_buf(),
}
.into());
}
tracing::warn!(
path = %target.display(),
"EXDEV detected, falling back to copy + fsync + cleanup"
);
let recovered = e.file;
copy_tempfile_to_target(recovered.as_file(), target, content)?;
return Ok(true);
}
}
return Err(e.error)
.with_context(|| format!("rename error for {}", target.display()));
}
}
}
}
fn syntax_heuristic_check(content: &[u8]) -> Option<String> {
let text = std::str::from_utf8(content).ok()?;
let stripped = strip_comments(text);
let stripped = strip_string_literals(&stripped);
let mut braces = 0i32;
let mut parens = 0i32;
let mut brackets = 0i32;
for c in stripped.chars() {
match c {
'{' => braces += 1,
'}' => braces -= 1,
'(' => parens += 1,
')' => parens -= 1,
'[' => brackets += 1,
']' => brackets -= 1,
_ => {}
}
}
if braces != 0 {
return Some(format!(
"unbalanced braces: {} more {} than {}",
braces.abs(),
if braces > 0 { "open" } else { "close" },
if braces > 0 { "close" } else { "open" }
));
}
if parens != 0 {
return Some(format!(
"unbalanced parentheses: {} more {} than {}",
parens.abs(),
if parens > 0 { "open" } else { "close" },
if parens > 0 { "close" } else { "open" }
));
}
if brackets != 0 {
return Some(format!(
"unbalanced brackets: {} more {} than {}",
brackets.abs(),
if brackets > 0 { "open" } else { "close" },
if brackets > 0 { "close" } else { "open" }
));
}
None
}
fn strip_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '/' {
match chars.peek() {
Some('/') => {
chars.next();
for nc in chars.by_ref() {
if nc == '\n' {
out.push('\n');
break;
}
}
}
Some('*') => {
chars.next();
let mut prev = '\0';
for nc in chars.by_ref() {
if prev == '*' && nc == '/' {
break;
}
prev = nc;
}
}
_ => out.push(c),
}
} else if c == '"' {
out.push(c);
while let Some(nc) = chars.next() {
out.push(nc);
if nc == '\\' {
if let Some(escaped) = chars.next() {
out.push(escaped);
}
} else if nc == '"' {
break;
}
}
} else if c == '\'' {
out.push(c);
while let Some(nc) = chars.next() {
out.push(nc);
if nc == '\\' {
if let Some(escaped) = chars.next() {
out.push(escaped);
}
} else if nc == '\'' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn strip_string_literals(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_string = false;
let mut prev = '\0';
for c in text.chars() {
if c == '"' && prev != '\\' {
in_string = !in_string;
}
if !in_string {
out.push(c);
}
prev = c;
}
out
}
#[cfg(unix)]
fn copy_tempfile_to_target(temp: &std::fs::File, target: &Path, _content: &[u8]) -> Result<()> {
use std::io::{Read, Seek, Write};
let mut temp_handle = temp.try_clone().context("cannot clone tempfile handle")?;
temp_handle
.seek(std::io::SeekFrom::Start(0))
.context("cannot seek tempfile")?;
let mut temp = std::io::BufReader::with_capacity(crate::constants::BUF_CAPACITY, temp_handle);
let mut buf = Vec::new();
temp.read_to_end(&mut buf).with_context(|| {
format!(
"cannot read tempfile for copy fallback to {}",
target.display()
)
})?;
let mut target_file = fs::OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
.open(target)
.with_context(|| format!("cannot open target for copy fallback: {}", target.display()))?;
target_file.write_all(&buf).with_context(|| {
format!(
"cannot write target for copy fallback: {}",
target.display()
)
})?;
let _ = target_file.sync_data();
let _ = target_file;
let mut target_file = fs::OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
.open(target)
.with_context(|| format!("cannot open target for copy fallback: {}", target.display()))?;
target_file.write_all(&buf).with_context(|| {
format!(
"cannot write target for copy fallback: {}",
target.display()
)
})?;
platform::fsync_file(&target_file).ok();
let _ = target_file;
if let Some(parent) = target.parent() {
if let Ok(entries) = fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(name) = name.to_str() {
if name.starts_with(crate::constants::TEMPFILE_PREFIX) {
let _ = fs::remove_file(entry.path());
}
}
}
}
}
Ok(())
}
fn write_inplace_path(target: &Path, content: &[u8]) -> Result<bool> {
use std::io::Write;
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(target)
.with_context(|| {
format!(
"cannot open target for in-place write: {}",
target.display()
)
})?;
file.set_len(0)
.with_context(|| format!("ftruncate failed for {}", target.display()))?;
file.write_all(content)
.with_context(|| format!("in-place write failed for {}", target.display()))?;
file.flush()
.with_context(|| format!("in-place flush failed for {}", target.display()))?;
let _ = file.sync_data();
Ok(false)
}
#[tracing::instrument(skip_all, fields(path = %target.display(), retention))]
pub(crate) fn create_backup(target: &Path, retention: u8) -> Result<std::path::PathBuf> {
create_backup_in(target, retention, None)
}
#[tracing::instrument(skip_all, fields(path = %target.display(), retention, output_dir))]
pub(crate) fn create_backup_in(
target: &Path,
retention: u8,
output_dir: Option<&Path>,
) -> Result<std::path::PathBuf> {
let now = utc_timestamp_formatted();
let filename = target.file_name().unwrap_or_default().to_string_lossy();
let backup_name = format!("{filename}.bak.{now}");
let backup_path = match output_dir {
Some(dir) => {
if !dir.exists() {
fs::create_dir_all(dir).with_context(|| {
format!("cannot create backup output dir {}", dir.display())
})?;
}
dir.join(&backup_name)
}
None => target.with_file_name(&backup_name),
};
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
reflink_copy::reflink_or_copy(target, &backup_path)
.with_context(|| format!("cannot create backup at {}", backup_path.display()))?;
let backup_file = fs::File::open(&backup_path)
.with_context(|| format!("cannot open backup for fsync: {}", backup_path.display()))?;
platform::fsync_file_best_effort(&backup_file);
if let Some(parent) = backup_path.parent() {
if let Err(e) = platform::fsync_dir(parent) {
tracing::warn!(
path = %parent.display(),
error = %e,
"fsync_dir after backup failed"
);
}
}
if retention > 0 {
cleanup_old_backups_in(
backup_path.parent().unwrap_or_else(|| Path::new(".")),
&filename,
retention,
);
}
Ok(backup_path)
}
fn cleanup_old_backups_in(parent: &Path, prefix_name: &str, retention: u8) {
let prefix = format!("{prefix_name}.bak.");
let mut backups: Vec<std::path::PathBuf> = match fs::read_dir(parent) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&prefix))
})
.collect(),
Err(_) => return,
};
if backups.len() <= retention as usize {
return;
}
backups.sort();
let to_remove = backups.len() - retention as usize;
for old in &backups[..to_remove] {
let _ = fs::remove_file(old);
}
}
fn utc_timestamp_formatted() -> String {
use std::time::SystemTime;
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (year, month, day, hour, min, sec) = epoch_to_utc(secs);
format!("{year:04}{month:02}{day:02}_{hour:02}{min:02}{sec:02}")
}
pub fn rfc3339_now() -> String {
use std::time::SystemTime;
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (y, m, d, h, min, sec) = epoch_to_utc(secs);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{sec:02}Z")
}
pub(crate) fn epoch_to_utc(epoch: u64) -> (u64, u64, u64, u64, u64, u64) {
let sec_of_day = epoch % 86400;
let hour = sec_of_day / 3600;
let min = (sec_of_day % 3600) / 60;
let sec = sec_of_day % 60;
let mut days = (epoch / 86400) as i64;
days += 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let doe = (days - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as u64, m, d, hour, min, sec)
}
#[cfg(windows)]
use tempfile::NamedTempFile;
#[cfg(windows)]
fn persist_with_retry(mut temp: NamedTempFile, target: &Path) -> Result<()> {
let delays = [100, 200, 400];
for delay_ms in &delays {
match temp.persist(target) {
Ok(_) => return Ok(()),
Err(e) => {
if e.error.kind() == std::io::ErrorKind::PermissionDenied {
std::thread::sleep(std::time::Duration::from_millis(*delay_ms));
temp = e.file;
continue;
}
return Err(anyhow::anyhow!(
"rename error for {}: {}",
target.display(),
e.error
));
}
}
}
Err(AtomwriteError::PermissionDenied {
path: target.to_path_buf(),
}
.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_to_utc_epoch_zero() {
assert_eq!(epoch_to_utc(0), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn epoch_to_utc_known_date() {
assert_eq!(epoch_to_utc(1704067200), (2024, 1, 1, 0, 0, 0));
}
#[test]
fn atomic_write_options_default_values() {
let opts = AtomicWriteOptions::default();
assert!(!opts.backup);
assert_eq!(opts.retention, 5);
assert!(!opts.preserve_timestamps);
}
#[test]
fn create_backup_and_retention() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(&file, "content").unwrap();
for _ in 0..7 {
create_backup(&file, 5).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
}
let backups: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("test.txt.bak."))
})
.collect();
assert!(
backups.len() <= 5,
"retention should keep at most 5 backups, got {}",
backups.len()
);
}
#[test]
fn atomic_write_updates_mtime_by_default() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(&file, "original").unwrap();
let original_meta = std::fs::metadata(&file).unwrap();
let original_mtime = filetime::FileTime::from_last_modification_time(&original_meta);
std::thread::sleep(std::time::Duration::from_millis(50));
let opts = AtomicWriteOptions::default();
assert!(
!opts.preserve_timestamps,
"GAP 12 fix: default must update mtime so cargo/make detect the change"
);
let _ = atomic_write(&file, b"updated content", &opts, dir.path()).unwrap();
let new_meta = std::fs::metadata(&file).unwrap();
let new_mtime = filetime::FileTime::from_last_modification_time(&new_meta);
assert!(
new_mtime > original_mtime,
"default behavior must update mtime to now (was {:?}, now {:?})",
original_mtime,
new_mtime
);
}
#[test]
fn atomic_write_preserves_mtime_when_opted_in() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(&file, "original").unwrap();
let original_meta = std::fs::metadata(&file).unwrap();
let original_mtime = filetime::FileTime::from_last_modification_time(&original_meta);
std::thread::sleep(std::time::Duration::from_millis(50));
let opts = AtomicWriteOptions {
preserve_timestamps: true,
..Default::default()
};
let _ = atomic_write(&file, b"updated content", &opts, dir.path()).unwrap();
let new_meta = std::fs::metadata(&file).unwrap();
let new_mtime = filetime::FileTime::from_last_modification_time(&new_meta);
assert_eq!(
new_mtime, original_mtime,
"preserve_timestamps=true must keep original mtime intact"
);
}
#[test]
fn write_strategy_rename_for_regular_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("regular.txt");
std::fs::write(&file, "old").unwrap();
let opts = AtomicWriteOptions::default();
let r = atomic_write(&file, b"new", &opts, dir.path()).unwrap();
assert_eq!(r.write_strategy, "rename", "nlink=1 must use rename");
assert!(r.hardlink_nlink.is_none());
}
#[cfg(unix)]
#[test]
fn write_strategy_inplace_for_hardlink_preserves_inode() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("with_hardlink.txt");
let link = dir.path().join("hardlink.txt");
std::fs::write(&file, "shared content").unwrap();
std::fs::hard_link(&file, &link).unwrap();
let original_ino = std::fs::metadata(&file).unwrap().ino();
let original_link_ino = std::fs::metadata(&link).unwrap().ino();
assert_eq!(
original_ino, original_link_ino,
"pre-condition: both must point to the same inode"
);
let opts = AtomicWriteOptions::default();
let r = atomic_write(&file, b"new shared content", &opts, dir.path()).unwrap();
assert_eq!(
r.write_strategy, "inplace",
"G55: nlink>1 must auto-switch to InPlace"
);
assert_eq!(r.hardlink_nlink, Some(2));
let new_file_ino = std::fs::metadata(&file).unwrap().ino();
let new_link_ino = std::fs::metadata(&link).unwrap().ino();
assert_eq!(
new_file_ino, original_ino,
"G55: file inode must be preserved (was {}, now {})",
original_ino, new_file_ino
);
assert_eq!(
new_link_ino, original_ino,
"G55: hardlink inode must be preserved (was {}, now {})",
original_ino, new_link_ino
);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"new shared content"
);
assert_eq!(
std::fs::read_to_string(&link).unwrap(),
"new shared content"
);
}
#[test]
fn write_result_includes_strategy_and_xattr_fields() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("fields.txt");
std::fs::write(&file, "x").unwrap();
let opts = AtomicWriteOptions::default();
let r = atomic_write(&file, b"y", &opts, dir.path()).unwrap();
assert!(
matches!(r.write_strategy, "rename" | "inplace" | "copyback"),
"write_strategy must be set, got: {}",
r.write_strategy
);
assert!(
r.xattr_preserved <= r.xattr_count,
"xattr_preserved ({}) must be <= xattr_count ({})",
r.xattr_preserved,
r.xattr_count
);
assert!(
!r.exdev_fallback,
"exdev_fallback must be false in normal flow"
);
}
#[test]
fn exdev_fallback_disabled_error_when_strict_atomic() {
let err = crate::error::AtomwriteError::ExdevFallbackDisabled {
path: std::path::PathBuf::from("/tmp/x"),
};
assert_eq!(err.exit_code(), 91);
assert_eq!(err.error_code(), "EXDEV_FALLBACK_DISABLED");
assert_eq!(
err.error_class(),
crate::error::ErrorClass::PreconditionFailed
);
}
}