use std::path::PathBuf;
use schemars::JsonSchema;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorClass {
Transient,
Conflict,
PreconditionFailed,
Permanent,
}
impl ErrorClass {
#[inline]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Transient => "transient",
Self::Conflict => "conflict",
Self::PreconditionFailed => "precondition_failed",
Self::Permanent => "permanent",
}
}
#[inline]
pub const fn is_retryable(&self) -> bool {
matches!(self, Self::Transient | Self::Conflict)
}
#[inline]
pub const fn is_permanent(&self) -> bool {
matches!(self, Self::Permanent)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AtomwriteError {
#[error("file not found: {path}")]
NotFound {
path: PathBuf,
},
#[error("invalid input: {reason}")]
InvalidInput {
reason: String,
},
#[error("permission denied: {path}")]
PermissionDenied {
path: PathBuf,
},
#[error("disk full writing to {path}")]
DiskFull {
path: PathBuf,
},
#[error("quota exceeded writing to {path}")]
QuotaExceeded {
path: PathBuf,
},
#[error("cross-device rename: {path}")]
CrossDevice {
path: PathBuf,
},
#[error("I/O error: {source}")]
Io {
#[from]
source: std::io::Error,
},
#[error("invalid configuration: {reason}")]
ConfigInvalid {
reason: String,
},
#[error("state drift detected on {path}: expected checksum {expected}, got {actual}")]
StateDrift {
path: PathBuf,
expected: String,
actual: String,
},
#[error("path outside workspace jail: {path} (workspace: {workspace})")]
WorkspaceJail {
path: PathBuf,
workspace: PathBuf,
},
#[error("symlink blocked: {path}")]
SymlinkBlocked {
path: PathBuf,
},
#[error("file is immutable: {path}")]
FileImmutable {
path: PathBuf,
},
#[error("binary file detected: {path}")]
BinaryFile {
path: PathBuf,
},
#[error("FIFO detected: {path}")]
FifoDetected {
path: PathBuf,
},
#[error("device file detected: {path}")]
DeviceFile {
path: PathBuf,
},
#[error("checksum verification failed on {path}: expected {expected}")]
ChecksumVerifyFailed {
path: PathBuf,
expected: String,
},
#[error("file too large: {path} is {size} bytes (max: {max_size})")]
FileTooLarge {
path: PathBuf,
size: u64,
max_size: u64,
},
#[error("no matches found")]
NoMatches,
#[error("broken pipe")]
BrokenPipe,
#[error("internal error: {reason}")]
InternalError {
reason: String,
},
}
impl AtomwriteError {
#[inline]
pub const fn exit_code(&self) -> u8 {
match self {
Self::NotFound { .. } => 4,
Self::InvalidInput { .. } => 65,
Self::PermissionDenied { .. } => 13,
Self::DiskFull { .. } => 28,
Self::QuotaExceeded { .. } => 30,
Self::CrossDevice { .. } => 73,
Self::Io { .. } => 74,
Self::ConfigInvalid { .. } => 78,
Self::StateDrift { .. } => 82,
Self::ChecksumVerifyFailed { .. } => 81,
Self::FileTooLarge { .. } => 65,
Self::WorkspaceJail { .. } => 126,
Self::SymlinkBlocked { .. } => 127,
Self::FileImmutable { .. } => 128,
Self::BinaryFile { .. } => 65,
Self::FifoDetected { .. } => 85,
Self::DeviceFile { .. } => 86,
Self::NoMatches => 1,
Self::BrokenPipe => 141,
Self::InternalError { .. } => 255,
}
}
#[inline]
pub const fn error_class(&self) -> ErrorClass {
match self {
Self::Io { .. } | Self::DiskFull { .. } | Self::QuotaExceeded { .. } => {
ErrorClass::Transient
}
Self::StateDrift { .. } | Self::CrossDevice { .. } => ErrorClass::Conflict,
Self::ChecksumVerifyFailed { .. } | Self::FileTooLarge { .. } => {
ErrorClass::PreconditionFailed
}
Self::BinaryFile { .. }
| Self::FileImmutable { .. }
| Self::SymlinkBlocked { .. }
| Self::WorkspaceJail { .. }
| Self::FifoDetected { .. }
| Self::DeviceFile { .. } => ErrorClass::PreconditionFailed,
Self::NoMatches | Self::BrokenPipe => ErrorClass::Permanent,
_ => ErrorClass::Permanent,
}
}
#[inline]
pub fn is_retryable(&self) -> bool {
self.error_class().is_retryable()
}
#[inline]
pub fn is_permanent(&self) -> bool {
self.error_class().is_permanent()
}
#[inline]
pub const fn error_code(&self) -> &'static str {
match self {
Self::NotFound { .. } => "FILE_NOT_FOUND",
Self::InvalidInput { .. } => "INVALID_INPUT",
Self::PermissionDenied { .. } => "PERMISSION_DENIED",
Self::DiskFull { .. } => "DISK_FULL",
Self::QuotaExceeded { .. } => "QUOTA_EXCEEDED",
Self::CrossDevice { .. } => "CROSS_DEVICE",
Self::Io { .. } => "IO_ERROR",
Self::ConfigInvalid { .. } => "CONFIG_INVALID",
Self::StateDrift { .. } => "STATE_DRIFT",
Self::ChecksumVerifyFailed { .. } => "CHECKSUM_VERIFY_FAILED",
Self::FileTooLarge { .. } => "FILE_TOO_LARGE",
Self::WorkspaceJail { .. } => "WORKSPACE_JAIL",
Self::SymlinkBlocked { .. } => "SYMLINK_BLOCKED",
Self::FileImmutable { .. } => "IMMUTABLE_FILE",
Self::BinaryFile { .. } => "BINARY_FILE",
Self::FifoDetected { .. } => "FIFO_DETECTED",
Self::DeviceFile { .. } => "DEVICE_FILE",
Self::NoMatches => "NO_MATCHES",
Self::BrokenPipe => "BROKEN_PIPE",
Self::InternalError { .. } => "INTERNAL_ERROR",
}
}
#[inline]
pub fn path(&self) -> Option<&PathBuf> {
match self {
Self::NotFound { path }
| Self::PermissionDenied { path }
| Self::DiskFull { path }
| Self::QuotaExceeded { path }
| Self::CrossDevice { path }
| Self::StateDrift { path, .. }
| Self::ChecksumVerifyFailed { path, .. }
| Self::FileTooLarge { path, .. }
| Self::WorkspaceJail { path, .. }
| Self::SymlinkBlocked { path }
| Self::FileImmutable { path }
| Self::BinaryFile { path }
| Self::FifoDetected { path }
| Self::DeviceFile { path } => Some(path),
Self::InvalidInput { .. }
| Self::Io { .. }
| Self::ConfigInvalid { .. }
| Self::NoMatches
| Self::BrokenPipe
| Self::InternalError { .. } => None,
}
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ErrorJson {
pub error: bool,
pub code: &'static str,
pub exit: u8,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub error_class: &'static str,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
}
impl ErrorJson {
#[cold]
#[track_caller]
pub fn from_error(err: &AtomwriteError) -> Self {
let workspace = match err {
AtomwriteError::WorkspaceJail { workspace, .. } => {
Some(workspace.display().to_string())
}
_ => None,
};
Self {
error: true,
code: err.error_code(),
exit: err.exit_code(),
message: err.to_string(),
path: err.path().map(|p| p.display().to_string()),
error_class: err.error_class().as_str(),
retryable: err.is_retryable(),
suggestion: suggestion_for(err),
workspace,
}
}
}
#[cold]
fn suggestion_for(err: &AtomwriteError) -> Option<String> {
match err {
AtomwriteError::NotFound { .. } => Some("verify the file path exists".into()),
AtomwriteError::PermissionDenied { .. } => Some("check file permissions".into()),
AtomwriteError::DiskFull { .. } => Some("free disk space and retry".into()),
AtomwriteError::QuotaExceeded { .. } => Some("check disk quota and free space".into()),
AtomwriteError::CrossDevice { .. } => {
Some("ensure source and destination are on the same filesystem".into())
}
AtomwriteError::StateDrift { .. } => {
Some("re-read the file to get current checksum, then retry".into())
}
AtomwriteError::ChecksumVerifyFailed { .. } => {
Some("re-read the file to get current checksum".into())
}
AtomwriteError::FileTooLarge { .. } => {
Some("use --max-filesize to increase the limit or process smaller files".into())
}
AtomwriteError::WorkspaceJail { .. } => {
Some("set --workspace <root> or export ATOMWRITE_WORKSPACE=<path>".into())
}
AtomwriteError::SymlinkBlocked { .. } => {
Some("use --follow-symlinks to allow symbolic links".into())
}
AtomwriteError::BinaryFile { .. } => Some("use read --stat for metadata only".into()),
AtomwriteError::FifoDetected { .. } => {
Some("skip this file or use stdin redirection instead".into())
}
AtomwriteError::DeviceFile { .. } => {
Some("skip this file or use stdin redirection instead".into())
}
AtomwriteError::BrokenPipe => None,
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_class_transient() {
let err = AtomwriteError::DiskFull {
path: PathBuf::from("/tmp"),
};
assert_eq!(err.error_class(), ErrorClass::Transient);
assert!(err.is_retryable());
assert!(!err.is_permanent());
}
#[test]
fn error_class_conflict() {
let err = AtomwriteError::StateDrift {
path: PathBuf::from("/tmp"),
expected: "aaa".into(),
actual: "bbb".into(),
};
assert_eq!(err.error_class(), ErrorClass::Conflict);
assert!(err.is_retryable());
assert!(!err.is_permanent());
}
#[test]
fn error_class_precondition() {
let err = AtomwriteError::BinaryFile {
path: PathBuf::from("/tmp"),
};
assert_eq!(err.error_class(), ErrorClass::PreconditionFailed);
assert!(!err.is_retryable());
assert!(!err.is_permanent());
}
#[test]
fn error_class_permanent() {
let err = AtomwriteError::NoMatches;
assert_eq!(err.error_class(), ErrorClass::Permanent);
assert!(!err.is_retryable());
assert!(err.is_permanent());
}
#[test]
fn exit_code_not_found() {
let err = AtomwriteError::NotFound {
path: PathBuf::from("/x"),
};
assert_eq!(err.exit_code(), 4);
}
#[test]
fn error_code_strings() {
assert_eq!(
AtomwriteError::NotFound {
path: PathBuf::from("/x")
}
.error_code(),
"FILE_NOT_FOUND"
);
assert_eq!(
AtomwriteError::FifoDetected {
path: PathBuf::from("/x")
}
.error_code(),
"FIFO_DETECTED"
);
assert_eq!(
AtomwriteError::DeviceFile {
path: PathBuf::from("/x")
}
.error_code(),
"DEVICE_FILE"
);
}
#[test]
fn fifo_and_device_exit_codes() {
assert_eq!(
AtomwriteError::FifoDetected {
path: PathBuf::from("/x")
}
.exit_code(),
85
);
assert_eq!(
AtomwriteError::DeviceFile {
path: PathBuf::from("/x")
}
.exit_code(),
86
);
}
#[test]
fn error_enum_size_audit() {
let size = std::mem::size_of::<AtomwriteError>();
assert!(size <= 80, "AtomwriteError grew beyond 80 bytes: {size}");
}
#[test]
fn all_variants_properties() {
let p = PathBuf::from("/test");
let variants: Vec<(AtomwriteError, u8, ErrorClass, &str, bool)> = vec![
(
AtomwriteError::NotFound { path: p.clone() },
4,
ErrorClass::Permanent,
"FILE_NOT_FOUND",
true,
),
(
AtomwriteError::InvalidInput { reason: "x".into() },
65,
ErrorClass::Permanent,
"INVALID_INPUT",
false,
),
(
AtomwriteError::PermissionDenied { path: p.clone() },
13,
ErrorClass::Permanent,
"PERMISSION_DENIED",
true,
),
(
AtomwriteError::DiskFull { path: p.clone() },
28,
ErrorClass::Transient,
"DISK_FULL",
true,
),
(
AtomwriteError::QuotaExceeded { path: p.clone() },
30,
ErrorClass::Transient,
"QUOTA_EXCEEDED",
true,
),
(
AtomwriteError::CrossDevice { path: p.clone() },
73,
ErrorClass::Conflict,
"CROSS_DEVICE",
true,
),
(
AtomwriteError::Io {
source: std::io::Error::other("x"),
},
74,
ErrorClass::Transient,
"IO_ERROR",
false,
),
(
AtomwriteError::ConfigInvalid { reason: "x".into() },
78,
ErrorClass::Permanent,
"CONFIG_INVALID",
false,
),
(
AtomwriteError::StateDrift {
path: p.clone(),
expected: "a".into(),
actual: "b".into(),
},
82,
ErrorClass::Conflict,
"STATE_DRIFT",
true,
),
(
AtomwriteError::WorkspaceJail {
path: p.clone(),
workspace: p.clone(),
},
126,
ErrorClass::PreconditionFailed,
"WORKSPACE_JAIL",
true,
),
(
AtomwriteError::SymlinkBlocked { path: p.clone() },
127,
ErrorClass::PreconditionFailed,
"SYMLINK_BLOCKED",
true,
),
(
AtomwriteError::FileImmutable { path: p.clone() },
128,
ErrorClass::PreconditionFailed,
"IMMUTABLE_FILE",
true,
),
(
AtomwriteError::BinaryFile { path: p.clone() },
65,
ErrorClass::PreconditionFailed,
"BINARY_FILE",
true,
),
(
AtomwriteError::FifoDetected { path: p.clone() },
85,
ErrorClass::PreconditionFailed,
"FIFO_DETECTED",
true,
),
(
AtomwriteError::DeviceFile { path: p.clone() },
86,
ErrorClass::PreconditionFailed,
"DEVICE_FILE",
true,
),
(
AtomwriteError::ChecksumVerifyFailed {
path: p.clone(),
expected: "a".into(),
},
81,
ErrorClass::PreconditionFailed,
"CHECKSUM_VERIFY_FAILED",
true,
),
(
AtomwriteError::FileTooLarge {
path: p.clone(),
size: 100,
max_size: 50,
},
65,
ErrorClass::PreconditionFailed,
"FILE_TOO_LARGE",
true,
),
(
AtomwriteError::NoMatches,
1,
ErrorClass::Permanent,
"NO_MATCHES",
false,
),
(
AtomwriteError::BrokenPipe,
141,
ErrorClass::Permanent,
"BROKEN_PIPE",
false,
),
(
AtomwriteError::InternalError { reason: "x".into() },
255,
ErrorClass::Permanent,
"INTERNAL_ERROR",
false,
),
];
assert_eq!(variants.len(), 20, "test must cover all 20 variants");
for (err, exit, class, code, has_path) in &variants {
assert_eq!(err.exit_code(), *exit, "exit_code mismatch for {code}");
assert_eq!(err.error_class(), *class, "error_class mismatch for {code}");
assert_eq!(err.error_code(), *code, "error_code mismatch for {code}");
assert_eq!(
err.is_retryable(),
class.is_retryable(),
"retryable mismatch for {code}"
);
assert_eq!(err.path().is_some(), *has_path, "path mismatch for {code}");
let json = ErrorJson::from_error(err);
assert!(json.error);
assert_eq!(json.exit, *exit);
assert_eq!(json.code, *code);
assert_eq!(json.error_class, class.as_str());
let _ = serde_json::to_string(&json).expect("ErrorJson must serialize");
}
}
#[test]
fn error_class_as_str_roundtrip() {
assert_eq!(ErrorClass::Transient.as_str(), "transient");
assert_eq!(ErrorClass::Conflict.as_str(), "conflict");
assert_eq!(
ErrorClass::PreconditionFailed.as_str(),
"precondition_failed"
);
assert_eq!(ErrorClass::Permanent.as_str(), "permanent");
}
#[test]
fn error_class_is_permanent() {
assert!(ErrorClass::Permanent.is_permanent());
assert!(!ErrorClass::Transient.is_permanent());
assert!(!ErrorClass::Conflict.is_permanent());
assert!(!ErrorClass::PreconditionFailed.is_permanent());
}
#[test]
fn error_json_from_error() {
let err = AtomwriteError::NotFound {
path: PathBuf::from("/missing"),
};
let json = ErrorJson::from_error(&err);
assert!(json.error);
assert_eq!(json.code, "FILE_NOT_FOUND");
assert_eq!(json.exit, 4);
assert!(!json.retryable);
}
}