msy 0.4.2

Modern musl rsync alternative - Fast, parallel file synchronization
Documentation
use crate::resource::format_bytes;
use std::path::PathBuf;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum SyncError {
    #[allow(dead_code)] // Used in future phases (network sync)
    #[error(
        "Source path not found: {path}\nMake sure the path exists and you have read permissions."
    )]
    SourceNotFound { path: PathBuf },

    #[allow(dead_code)] // Used in future phases (network sync)
    #[error("Destination path not found: {path}\nThe parent directory must exist before syncing.")]
    DestinationNotFound { path: PathBuf },

    #[allow(dead_code)] // Used in future phases (permission handling)
    #[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 {
    /// Check if this error is retryable (network issues that might succeed on retry)
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            SyncError::NetworkTimeout { .. }
                | SyncError::NetworkDisconnected { .. }
                | SyncError::NetworkRetryable { .. }
        )
    }

    /// Check if this error requires reconnection (session is dead)
    #[allow(dead_code)] // Reserved for future connection pool health checks
    pub fn requires_reconnection(&self) -> bool {
        matches!(self, SyncError::NetworkDisconnected { .. })
    }

    /// Classify an IO error from SSH operations into appropriate network error types
    pub fn from_ssh_io_error(err: std::io::Error, context: &str) -> Self {
        use std::io::ErrorKind;

        match err.kind() {
            // Definitely retryable - connection issues
            ErrorKind::ConnectionRefused
            | ErrorKind::ConnectionReset
            | ErrorKind::ConnectionAborted
            | ErrorKind::BrokenPipe
            | ErrorKind::NotConnected => SyncError::NetworkDisconnected {
                reason: format!("{}: {}", context, err),
            },

            // Timeout - retryable
            ErrorKind::TimedOut => SyncError::NetworkTimeout {
                duration: std::time::Duration::from_secs(30), // Default, can be made configurable
            },

            // Temporary failures - retryable
            ErrorKind::Interrupted | ErrorKind::WouldBlock => SyncError::NetworkRetryable {
                message: format!("{}: {}", context, err),
                attempts: 0,
                max_attempts: 3, // Will be updated by retry logic
            },

            // Fatal - configuration or permission issues
            ErrorKind::PermissionDenied => SyncError::PermissionDenied {
                path: std::path::PathBuf::from(context),
            },

            ErrorKind::NotFound => SyncError::SourceNotFound {
                path: std::path::PathBuf::from(context),
            },

            // Everything else - fatal network error
            _ => 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");
        }
    }
}