use std::path::PathBuf;
use schemars::JsonSchema;
use serde::Serialize;
use crate::ndjson_types::{BestCandidate, PairResult};
#[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("match failed: {reason}")]
MatchFailed {
reason: String,
best_candidate: Option<Box<BestCandidate>>,
candidates: Option<Vec<BestCandidate>>,
},
#[error("match ambiguous: {reason}")]
MatchAmbiguous {
reason: String,
count: u64,
best_candidate: Option<Box<BestCandidate>>,
},
#[error("cancelled: {reason}")]
Cancelled {
reason: String,
exit: u8,
},
#[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,
},
#[error("lock timeout on {path} after {timeout_ms}ms")]
LockTimeout {
path: PathBuf,
timeout_ms: u64,
},
#[error("syntax error detected in {path} ({count} nodes with ERROR)")]
SyntaxError {
path: PathBuf,
count: u32,
},
#[error("cross-device rename on {path} and --strict-atomic forbids fallback")]
ExdevFallbackDisabled {
path: PathBuf,
},
#[error("copy-back BLAKE3 verification failed for {path}")]
CopyBackBlake3Failed {
path: PathBuf,
},
#[error("orphaned atomwrite journal at {journal} could not be recovered: {reason}")]
OrphanJournal {
journal: PathBuf,
reason: String,
},
#[error("edit pair {index} of {total} failed: {reason}")]
EditPairFailed {
index: u64,
total: u64,
reason: String,
pair_results: Box<Vec<PairResult>>,
best_candidate: Option<Box<BestCandidate>>,
},
}
impl AtomwriteError {
#[inline]
pub const fn exit_code(&self) -> u8 {
match self {
Self::NotFound { .. } => 4,
Self::InvalidInput { .. } => 65,
Self::MatchFailed { .. } | Self::MatchAmbiguous { .. } => 65,
Self::Cancelled { exit, .. } => *exit,
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,
Self::LockTimeout { .. } => 83,
Self::SyntaxError { .. } => 88,
Self::ExdevFallbackDisabled { .. } => 91,
Self::CopyBackBlake3Failed { .. } => 92,
Self::OrphanJournal { .. } => 93,
Self::EditPairFailed { .. } => 65,
}
}
#[inline]
pub const fn error_class(&self) -> ErrorClass {
match self {
Self::Io { .. } | Self::DiskFull { .. } | Self::QuotaExceeded { .. } => {
ErrorClass::Transient
}
Self::StateDrift { .. }
| Self::CrossDevice { .. }
| Self::LockTimeout { .. }
| Self::CopyBackBlake3Failed { .. } => ErrorClass::Conflict,
Self::ChecksumVerifyFailed { .. }
| Self::FileTooLarge { .. }
| Self::SyntaxError { .. }
| Self::ExdevFallbackDisabled { .. }
| Self::OrphanJournal { .. } => 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::MatchFailed { .. } => "INVALID_INPUT",
Self::MatchAmbiguous { .. } => "MATCH_AMBIGUOUS",
Self::Cancelled { .. } => "CANCELLED",
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",
Self::LockTimeout { .. } => "LOCK_TIMEOUT",
Self::SyntaxError { .. } => "SYNTAX_ERROR_DETECTED",
Self::ExdevFallbackDisabled { .. } => "EXDEV_FALLBACK_DISABLED",
Self::CopyBackBlake3Failed { .. } => "COPY_BACK_BLAKE3_FAILED",
Self::OrphanJournal { .. } => "ORPHAN_JOURNAL",
Self::EditPairFailed { .. } => "INVALID_INPUT",
}
}
#[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 }
| Self::LockTimeout { path, .. }
| Self::SyntaxError { path, .. }
| Self::ExdevFallbackDisabled { path }
| Self::CopyBackBlake3Failed { path }
| Self::OrphanJournal { journal: path, .. } => Some(path),
Self::InvalidInput { .. }
| Self::EditPairFailed { .. }
| Self::Io { .. }
| Self::ConfigInvalid { .. }
| Self::NoMatches
| Self::BrokenPipe
| Self::InternalError { .. }
| Self::MatchFailed { .. }
| Self::MatchAmbiguous { .. }
| Self::Cancelled { .. } => 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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub failed_pair_index: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pairs_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pair_results: Option<Vec<PairResult>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub best_candidate: Option<BestCandidate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub candidates: Option<Vec<BestCandidate>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub similar_paths: Option<Vec<String>>,
}
impl ErrorJson {
#[cold]
#[track_caller]
pub fn from_error(err: &AtomwriteError) -> Self {
Self::from_error_with_context(err, &ErrorContext::default())
}
#[cold]
#[track_caller]
pub fn from_error_with_context(err: &AtomwriteError, ctx: &ErrorContext) -> Self {
let workspace = match err {
AtomwriteError::WorkspaceJail { workspace, .. } => {
Some(workspace.display().to_string())
}
_ => None,
};
let (failed_pair_index, pairs_total, pair_results) = match err {
AtomwriteError::EditPairFailed {
index,
total,
pair_results,
..
} => (
Some(*index),
Some(*total),
Some(pair_results.as_ref().clone()),
),
_ => (None, None, None),
};
let best_candidate = match err {
AtomwriteError::MatchFailed { best_candidate, .. } => {
best_candidate.as_ref().map(|b| (**b).clone())
}
AtomwriteError::MatchAmbiguous { best_candidate, .. } => {
best_candidate.as_ref().map(|b| (**b).clone())
}
AtomwriteError::EditPairFailed { best_candidate, .. } => {
best_candidate.as_ref().map(|b| (**b).clone())
}
_ => None,
};
let candidates = match err {
AtomwriteError::MatchFailed { candidates, .. } => candidates.clone(),
_ => None,
};
let match_count = match err {
AtomwriteError::MatchAmbiguous { count, .. } => Some(*count),
_ => 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, ctx),
workspace,
failed_pair_index,
pairs_total,
pair_results,
best_candidate,
candidates,
match_count,
similar_paths: similar_paths_for(err, ctx),
}
}
}
fn similar_paths_for(err: &AtomwriteError, ctx: &ErrorContext) -> Option<Vec<String>> {
let path = match err {
AtomwriteError::NotFound { path } => path,
_ => return None,
};
let wanted = path.file_name()?.to_string_lossy().to_string();
if wanted.is_empty() {
return None;
}
let root = ctx.workspace.as_ref()?;
let mut scored: Vec<(f64, String)> = Vec::new();
let walker = ignore::WalkBuilder::new(root).max_depth(Some(6)).build();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let name = entry.file_name().to_string_lossy();
let score = strsim::jaro_winkler(&wanted, &name);
if score >= 0.75 {
scored.push((score, entry.path().display().to_string()));
}
}
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(5);
if scored.is_empty() {
None
} else {
Some(scored.into_iter().map(|(_, p)| p).collect())
}
}
#[derive(Debug, Default, Clone)]
pub struct ErrorContext {
pub workspace_provided: bool,
pub workspace: Option<PathBuf>,
}
#[cold]
fn suggestion_for(err: &AtomwriteError, ctx: &ErrorContext) -> Option<String> {
match err {
AtomwriteError::NotFound { .. } => Some("verify the file path exists".into()),
AtomwriteError::EditPairFailed { index, .. } => Some(format!(
"pair {index} did not match; fix that pair, retry with --fuzzy aggressive, or pass --partial to apply the matching pairs and report the rest"
)),
AtomwriteError::InvalidInput { reason } => Some(format!(
"review the {reason}; check arguments and input content for syntax errors"
)),
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::Io { source } => {
Some(format!("inspect the underlying I/O error: {source}"))
}
AtomwriteError::ConfigInvalid { reason } => {
Some(format!("fix the configuration: {reason}"))
}
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 { workspace, .. } => {
if ctx.workspace_provided {
Some(format!(
"use a path inside the workspace ({})",
workspace.display()
))
} else {
Some("set --workspace <root> or export ATOMWRITE_WORKSPACE=<path>".into())
}
}
AtomwriteError::SymlinkBlocked { .. } => {
Some("use --follow-symlinks to allow symbolic links".into())
}
AtomwriteError::FileImmutable { path } => Some(format!(
"remove the immutable attribute (chattr -i on Unix, fsutil on Windows) from {}",
path.display()
)),
AtomwriteError::BinaryFile { .. } => Some(
"binary content detected; use read --stat for metadata only \
or use read --format raw to emit raw bytes without JSON envelope"
.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::NoMatches => Some(
"broaden the search pattern; check --include / --exclude filters; \
verify the file content"
.into(),
),
AtomwriteError::BrokenPipe => None,
AtomwriteError::MatchAmbiguous { .. } => Some(
"narrow --old with more surrounding context or pass --replace-all".into(),
),
AtomwriteError::MatchFailed { .. } => Some(
"inspect best_candidate in the error NDJSON and adjust --old, or lower --fuzzy-threshold".into(),
),
AtomwriteError::Cancelled { .. } => Some(
"operation cancelled by signal; retry after inspecting journal/temp cleanup".into(),
),
AtomwriteError::InternalError { reason } => Some(format!(
"this is a bug; please report it with the {reason} context"
)),
AtomwriteError::LockTimeout { path, timeout_ms } => Some(format!(
"another process is editing {}; wait, kill the holder, or raise --lock-timeout above {} ms",
path.display(),
timeout_ms
)),
AtomwriteError::SyntaxError { path, count } => Some(format!(
"post-write syntax check found {count} syntax error(s) in {}; \
inspect the content (or remove --syntax-check) and retry",
path.display()
)),
AtomwriteError::ExdevFallbackDisabled { path } => Some(format!(
"rename across filesystems failed for {} and --strict-atomic was set; \
either unset --strict-atomic to enable copy-fallback, or move source/destination \
to the same filesystem",
path.display()
)),
AtomwriteError::CopyBackBlake3Failed { path } => Some(format!(
"BLAKE3 verification after in-place write failed for {}; the on-disk file \
may be partially written. Inspect manually before retrying.",
path.display()
)),
AtomwriteError::OrphanJournal { journal, reason } => Some(format!(
"a previous atomwrite run crashed and left journal {} ({}). \
Manually inspect the target file and the journal, then delete {} \
once the file is in the expected state",
journal.display(),
reason,
journal.display()
)),
}
}
#[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,
),
(
AtomwriteError::LockTimeout {
path: p.clone(),
timeout_ms: 5000,
},
83,
ErrorClass::Conflict,
"LOCK_TIMEOUT",
true,
),
(
AtomwriteError::SyntaxError {
path: p.clone(),
count: 1,
},
88,
ErrorClass::PreconditionFailed,
"SYNTAX_ERROR_DETECTED",
true,
),
(
AtomwriteError::ExdevFallbackDisabled { path: p.clone() },
91,
ErrorClass::PreconditionFailed,
"EXDEV_FALLBACK_DISABLED",
true,
),
(
AtomwriteError::CopyBackBlake3Failed { path: p.clone() },
92,
ErrorClass::Conflict,
"COPY_BACK_BLAKE3_FAILED",
true,
),
(
AtomwriteError::OrphanJournal {
journal: p.clone(),
reason: "x".into(),
},
93,
ErrorClass::PreconditionFailed,
"ORPHAN_JOURNAL",
true,
),
];
assert_eq!(variants.len(), 25, "test must cover all 25 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);
}
#[test]
fn gap13_workspace_jail_suggestion_when_workspace_not_provided() {
let err = AtomwriteError::WorkspaceJail {
path: PathBuf::from("/etc/passwd"),
workspace: PathBuf::from("/home/user/project"),
};
let ctx = ErrorContext::default();
let json = ErrorJson::from_error_with_context(&err, &ctx);
let s = json.suggestion.expect("must have suggestion");
assert!(
s.contains("--workspace"),
"without workspace_provided, suggestion must mention --workspace, got: {s}"
);
assert_eq!(json.workspace.as_deref(), Some("/home/user/project"));
}
#[test]
fn gap13_workspace_jail_suggestion_when_workspace_provided() {
let err = AtomwriteError::WorkspaceJail {
path: PathBuf::from("/etc/passwd"),
workspace: PathBuf::from("/home/user/project"),
};
let ctx = ErrorContext {
workspace_provided: true,
workspace: Some(PathBuf::from("/home/user/project")),
};
let json = ErrorJson::from_error_with_context(&err, &ctx);
let s = json.suggestion.expect("must have suggestion");
assert!(
s.contains("inside the workspace"),
"with workspace_provided, suggestion must say 'inside the workspace', got: {s}"
);
assert!(
!s.contains("--workspace"),
"with workspace_provided, suggestion must NOT mention --workspace flag, got: {s}"
);
}
#[test]
fn gap13_all_variants_have_suggestion() {
let variants: Vec<AtomwriteError> = vec![
AtomwriteError::NotFound {
path: PathBuf::from("/x"),
},
AtomwriteError::InvalidInput { reason: "x".into() },
AtomwriteError::PermissionDenied {
path: PathBuf::from("/x"),
},
AtomwriteError::DiskFull {
path: PathBuf::from("/x"),
},
AtomwriteError::QuotaExceeded {
path: PathBuf::from("/x"),
},
AtomwriteError::CrossDevice {
path: PathBuf::from("/x"),
},
AtomwriteError::Io {
source: std::io::Error::other("x"),
},
AtomwriteError::ConfigInvalid { reason: "x".into() },
AtomwriteError::StateDrift {
path: PathBuf::from("/x"),
expected: "a".into(),
actual: "b".into(),
},
AtomwriteError::ChecksumVerifyFailed {
path: PathBuf::from("/x"),
expected: "a".into(),
},
AtomwriteError::FileTooLarge {
path: PathBuf::from("/x"),
size: 1,
max_size: 2,
},
AtomwriteError::WorkspaceJail {
path: PathBuf::from("/x"),
workspace: PathBuf::from("/w"),
},
AtomwriteError::SymlinkBlocked {
path: PathBuf::from("/x"),
},
AtomwriteError::FileImmutable {
path: PathBuf::from("/x"),
},
AtomwriteError::BinaryFile {
path: PathBuf::from("/x"),
},
AtomwriteError::FifoDetected {
path: PathBuf::from("/x"),
},
AtomwriteError::DeviceFile {
path: PathBuf::from("/x"),
},
AtomwriteError::NoMatches,
AtomwriteError::BrokenPipe,
AtomwriteError::InternalError { reason: "x".into() },
AtomwriteError::LockTimeout {
path: PathBuf::from("/x"),
timeout_ms: 5000,
},
AtomwriteError::SyntaxError {
path: PathBuf::from("/x"),
count: 1,
},
AtomwriteError::ExdevFallbackDisabled {
path: PathBuf::from("/x"),
},
AtomwriteError::CopyBackBlake3Failed {
path: PathBuf::from("/x"),
},
AtomwriteError::OrphanJournal {
journal: PathBuf::from("/x"),
reason: "x".into(),
},
AtomwriteError::EditPairFailed {
index: 2,
total: 3,
reason: "x".into(),
pair_results: Box::new(vec![]),
best_candidate: None,
},
];
assert_eq!(variants.len(), 26);
for err in &variants {
let json = ErrorJson::from_error(err);
if matches!(err, AtomwriteError::BrokenPipe) {
assert!(
json.suggestion.is_none(),
"BrokenPipe must remain without suggestion (SIGPIPE is not actionable)"
);
} else {
assert!(
json.suggestion.is_some(),
"GAP 13: variant {err:?} must have suggestion"
);
}
}
}
#[test]
fn gap13_binary_file_suggestion_does_not_mention_force_text_wrong_flag() {
let err = AtomwriteError::BinaryFile {
path: PathBuf::from("/x"),
};
let json = ErrorJson::from_error(&err);
let s = json.suggestion.expect("must have suggestion");
assert!(
s.contains("read --stat"),
"BinaryFile suggestion must mention read --stat, got: {s}"
);
}
#[test]
fn gap13_file_immutable_suggestion_mentions_chattr() {
let err = AtomwriteError::FileImmutable {
path: PathBuf::from("/etc/shadow"),
};
let json = ErrorJson::from_error(&err);
let s = json.suggestion.expect("must have suggestion");
assert!(
s.contains("chattr"),
"FileImmutable suggestion must mention chattr, got: {s}"
);
}
#[test]
fn gap13_no_matches_suggestion_mentions_filters() {
let err = AtomwriteError::NoMatches;
let json = ErrorJson::from_error(&err);
let s = json.suggestion.expect("must have suggestion");
assert!(
s.contains("--include") || s.contains("broaden"),
"NoMatches suggestion must mention broadening or filters, got: {s}"
);
}
#[test]
fn gap13_error_context_default_matches_legacy_behavior() {
let err = AtomwriteError::NotFound {
path: PathBuf::from("/x"),
};
let json = ErrorJson::from_error(&err);
assert_eq!(
json.suggestion.as_deref(),
Some("verify the file path exists")
);
}
}