use std::path::PathBuf;
use schemars::JsonSchema;
use serde::Serialize;
#[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}")]
WorkspaceJail {
path: 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("no matches found")]
NoMatches,
#[error("internal error: {reason}")]
InternalError {
reason: String,
},
}
impl AtomwriteError {
pub 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::WorkspaceJail { .. } => 126,
Self::SymlinkBlocked { .. } => 127,
Self::FileImmutable { .. } => 128,
Self::BinaryFile { .. } => 65,
Self::FifoDetected { .. } => 85,
Self::DeviceFile { .. } => 86,
Self::NoMatches => 1,
Self::InternalError { .. } => 255,
}
}
pub fn error_class(&self) -> &str {
match self {
Self::Io { .. } | Self::DiskFull { .. } | Self::QuotaExceeded { .. } => "transient",
Self::StateDrift { .. } | Self::CrossDevice { .. } => "conflict",
Self::BinaryFile { .. }
| Self::FileImmutable { .. }
| Self::SymlinkBlocked { .. }
| Self::WorkspaceJail { .. }
| Self::FifoDetected { .. }
| Self::DeviceFile { .. } => "precondition_failed",
Self::NoMatches => "permanent",
_ => "permanent",
}
}
pub fn is_retryable(&self) -> bool {
matches!(self.error_class(), "transient" | "conflict")
}
pub fn error_code(&self) -> &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::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::InternalError { .. } => "INTERNAL_ERROR",
}
}
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::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::InternalError { .. } => None,
}
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ErrorJson {
pub error: bool,
pub code: String,
pub exit: u8,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub error_class: String,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
impl ErrorJson {
pub fn from_error(err: &AtomwriteError) -> Self {
Self {
error: true,
code: err.error_code().to_owned(),
exit: err.exit_code(),
message: err.to_string(),
path: err.path().map(|p| p.display().to_string()),
error_class: err.error_class().to_owned(),
retryable: err.is_retryable(),
suggestion: suggestion_for(err),
}
}
}
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::StateDrift { .. } => {
Some("re-read the file to get current checksum, then retry".into())
}
AtomwriteError::WorkspaceJail { .. } => {
Some("use --workspace to set the correct workspace root".into())
}
AtomwriteError::BinaryFile { .. } => {
Some("use read --stat for metadata or --force-text to bypass".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())
}
_ => 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(), "transient");
assert!(err.is_retryable());
}
#[test]
fn error_class_conflict() {
let err = AtomwriteError::StateDrift {
path: PathBuf::from("/tmp"),
expected: "aaa".into(),
actual: "bbb".into(),
};
assert_eq!(err.error_class(), "conflict");
assert!(err.is_retryable());
}
#[test]
fn error_class_precondition() {
let err = AtomwriteError::BinaryFile {
path: PathBuf::from("/tmp"),
};
assert_eq!(err.error_class(), "precondition_failed");
assert!(!err.is_retryable());
}
#[test]
fn error_class_permanent() {
let err = AtomwriteError::NoMatches;
assert_eq!(err.error_class(), "permanent");
assert!(!err.is_retryable());
}
#[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_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);
}
}