use crate::resource::format_bytes;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SyncError {
#[allow(dead_code)] #[error("Source path not found: {path}\nMake sure the path exists and you have read permissions.")]
SourceNotFound { path: PathBuf },
#[allow(dead_code)] #[error("Destination path not found: {path}\nThe parent directory must exist before syncing.")]
DestinationNotFound { path: PathBuf },
#[allow(dead_code)] #[error("Permission denied: {path}\nTry checking file ownership or running with appropriate permissions.")]
PermissionDenied { path: PathBuf },
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to read directory: {path}\nCause: {source}\nCheck that the directory exists and you have read permissions.")]
ReadDirError { path: PathBuf, source: std::io::Error },
#[error("Failed to copy file: {path}\nCause: {source}\nCheck disk space and write permissions on the destination.")]
CopyError { path: PathBuf, source: std::io::Error },
#[error("Delta sync failed for {path}\nStrategy: {strategy}\nCause: {source}\n{hint}")]
#[allow(clippy::enum_variant_names)]
DeltaSyncError { path: PathBuf, strategy: String, source: std::io::Error, hint: String },
#[error("Invalid path: {path}\nPaths must be valid UTF-8 and not contain invalid characters.")]
InvalidPath { path: PathBuf },
#[error("Insufficient disk space: {path}\nRequired: {required} bytes ({required_fmt})\nAvailable: {available} bytes ({available_fmt})\nFree up space or reduce the amount of data to sync.",
required_fmt = format_bytes(*required),
available_fmt = format_bytes(*available))]
InsufficientDiskSpace { path: PathBuf, required: u64, available: u64 },
#[error("Network timeout after {duration:?}\nThe connection timed out. This is usually temporary - retry with --retry flag.")]
NetworkTimeout { duration: std::time::Duration },
#[error("Network disconnected: {reason}\nThe SSH connection was lost. This is usually temporary - retry with --retry flag.")]
NetworkDisconnected { reason: String },
#[error("Network error (retryable): {message}\nAttempts: {attempts}/{max_attempts}\nThis error may succeed if retried.")]
NetworkRetryable { message: String, attempts: u32, max_attempts: u32 },
#[error("Network error (fatal): {message}\nThis error cannot be resolved by retrying. Check your configuration.")]
NetworkFatal { message: String },
#[error("Hook execution failed: {0}\nCheck your hook script for errors or use --no-hooks to disable.")]
Hook(String),
#[error("Configuration error: {0}")]
Config(String),
#[error(
"Bisync state file corrupted: {path}\nReason: {reason}\n\nTo recover:\n 1. Backup the corrupt file (optional): cp {path} {path}.backup\n 2. Rebuild state from scratch: sy --force-resync <source> <dest>\n\nNote: First sync after recovery will treat all differences as new changes."
)]
StateCorruption { path: PathBuf, reason: String },
#[error(
"Sync already in progress for this directory pair:\n Source: {source_path}\n Dest: {dest_path}\n Lock file: {lock_file}\n\nAnother sy process is currently syncing these directories.\nWait for it to complete or check if the process is still running.\n\nIf no sync is running and the lock is stale:\n rm {lock_file}"
)]
SyncLocked { source_path: String, dest_path: String, lock_file: String },
#[error("Database error: {0}\nCheck that the destination directory is writable.")]
Database(String),
#[error(
"Data corruption detected: {path}\nBlock {block_number} checksum mismatch after write.\nExpected: {expected_checksum}\nActual: {actual_checksum}\nThis indicates storage or memory corruption. The transfer has been aborted."
)]
BlockCorruption { path: PathBuf, block_number: usize, expected_checksum: String, actual_checksum: String },
}
impl From<bincode::Error> for SyncError {
fn from(err: bincode::Error) -> Self {
SyncError::Database(err.to_string())
}
}
impl From<fjall::Error> for SyncError {
fn from(err: fjall::Error) -> Self {
SyncError::Database(err.to_string())
}
}
impl SyncError {
pub fn is_retryable(&self) -> bool {
matches!(self, SyncError::NetworkTimeout { .. } | SyncError::NetworkDisconnected { .. } | SyncError::NetworkRetryable { .. })
}
#[allow(dead_code)] pub fn requires_reconnection(&self) -> bool {
matches!(self, SyncError::NetworkDisconnected { .. })
}
pub fn from_ssh_io_error(err: std::io::Error, context: &str) -> Self {
use std::io::ErrorKind;
match err.kind() {
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted | ErrorKind::BrokenPipe | ErrorKind::NotConnected => {
SyncError::NetworkDisconnected { reason: format!("{}: {}", context, err) }
}
ErrorKind::TimedOut => SyncError::NetworkTimeout {
duration: std::time::Duration::from_secs(30), },
ErrorKind::Interrupted | ErrorKind::WouldBlock => SyncError::NetworkRetryable {
message: format!("{}: {}", context, err),
attempts: 0,
max_attempts: 3, },
ErrorKind::PermissionDenied => SyncError::PermissionDenied { path: std::path::PathBuf::from(context) },
ErrorKind::NotFound => SyncError::SourceNotFound { path: std::path::PathBuf::from(context) },
_ => SyncError::NetworkFatal { message: format!("{}: {}", context, err) },
}
}
}
pub type Result<T> = std::result::Result<T, SyncError>;
#[cfg(test)]
mod tests {
use super::*;
use std::io::ErrorKind;
use std::time::Duration;
#[test]
fn test_is_retryable_network_timeout() {
let err = SyncError::NetworkTimeout { duration: Duration::from_secs(30) };
assert!(err.is_retryable());
}
#[test]
fn test_is_retryable_network_disconnected() {
let err = SyncError::NetworkDisconnected { reason: "Connection lost".to_string() };
assert!(err.is_retryable());
}
#[test]
fn test_is_retryable_network_retryable() {
let err = SyncError::NetworkRetryable { message: "Temporary failure".to_string(), attempts: 1, max_attempts: 3 };
assert!(err.is_retryable());
}
#[test]
fn test_is_retryable_network_fatal() {
let err = SyncError::NetworkFatal { message: "Fatal error".to_string() };
assert!(!err.is_retryable());
}
#[test]
fn test_is_retryable_other_errors() {
let err = SyncError::Io(std::io::Error::other("Some IO error"));
assert!(!err.is_retryable());
let err = SyncError::Config("Invalid config".to_string());
assert!(!err.is_retryable());
}
#[test]
fn test_requires_reconnection_network_disconnected() {
let err = SyncError::NetworkDisconnected { reason: "Connection lost".to_string() };
assert!(err.requires_reconnection());
}
#[test]
fn test_requires_reconnection_other_errors() {
let err = SyncError::NetworkTimeout { duration: Duration::from_secs(30) };
assert!(!err.requires_reconnection());
let err = SyncError::NetworkRetryable { message: "Temporary failure".to_string(), attempts: 1, max_attempts: 3 };
assert!(!err.requires_reconnection());
let err = SyncError::NetworkFatal { message: "Fatal error".to_string() };
assert!(!err.requires_reconnection());
}
#[test]
fn test_from_ssh_io_error_connection_errors() {
let test_cases = vec![
ErrorKind::ConnectionRefused,
ErrorKind::ConnectionReset,
ErrorKind::ConnectionAborted,
ErrorKind::BrokenPipe,
ErrorKind::NotConnected,
];
for kind in test_cases {
let io_err = std::io::Error::new(kind, "test");
let sync_err = SyncError::from_ssh_io_error(io_err, "test context");
assert!(
matches!(sync_err, SyncError::NetworkDisconnected { .. }),
"Expected NetworkDisconnected for {:?}, got {:?}",
kind,
sync_err
);
assert!(sync_err.is_retryable());
assert!(sync_err.requires_reconnection());
}
}
#[test]
fn test_from_ssh_io_error_timeout() {
let io_err = std::io::Error::new(ErrorKind::TimedOut, "timeout");
let sync_err = SyncError::from_ssh_io_error(io_err, "test context");
assert!(matches!(sync_err, SyncError::NetworkTimeout { .. }));
assert!(sync_err.is_retryable());
assert!(!sync_err.requires_reconnection());
}
#[test]
fn test_from_ssh_io_error_temporary_failures() {
let test_cases = vec![ErrorKind::Interrupted, ErrorKind::WouldBlock];
for kind in test_cases {
let io_err = std::io::Error::new(kind, "test");
let sync_err = SyncError::from_ssh_io_error(io_err, "test context");
assert!(matches!(sync_err, SyncError::NetworkRetryable { .. }), "Expected NetworkRetryable for {:?}, got {:?}", kind, sync_err);
assert!(sync_err.is_retryable());
assert!(!sync_err.requires_reconnection());
}
}
#[test]
fn test_from_ssh_io_error_permission_denied() {
let io_err = std::io::Error::new(ErrorKind::PermissionDenied, "access denied");
let sync_err = SyncError::from_ssh_io_error(io_err, "test context");
assert!(matches!(sync_err, SyncError::PermissionDenied { .. }));
assert!(!sync_err.is_retryable());
}
#[test]
fn test_from_ssh_io_error_not_found() {
let io_err = std::io::Error::new(ErrorKind::NotFound, "not found");
let sync_err = SyncError::from_ssh_io_error(io_err, "test context");
assert!(matches!(sync_err, SyncError::SourceNotFound { .. }));
assert!(!sync_err.is_retryable());
}
#[test]
fn test_from_ssh_io_error_fatal() {
let io_err = std::io::Error::other("unknown error");
let sync_err = SyncError::from_ssh_io_error(io_err, "test context");
assert!(matches!(sync_err, SyncError::NetworkFatal { .. }));
assert!(!sync_err.is_retryable());
}
#[test]
fn test_from_ssh_io_error_context_preserved() {
let io_err = std::io::Error::new(ErrorKind::ConnectionReset, "reset");
let sync_err = SyncError::from_ssh_io_error(io_err, "reading file");
if let SyncError::NetworkDisconnected { reason } = sync_err {
assert!(reason.contains("reading file"));
assert!(reason.contains("reset"));
} else {
panic!("Expected NetworkDisconnected");
}
}
}