Skip to main content

null_e/error/
mod.rs

1//! Error handling for DevSweep
2//!
3//! Provides a comprehensive error type hierarchy with context support
4//! and user-friendly error messages.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Main error type for DevSweep operations
10#[derive(Error, Debug)]
11pub enum DevSweepError {
12    // ═══════════════════════════════════════════════════════════════
13    // I/O Errors
14    // ═══════════════════════════════════════════════════════════════
15    #[error("I/O error: {0}")]
16    Io(#[from] std::io::Error),
17
18    #[error("Path not found: {}", .0.display())]
19    PathNotFound(PathBuf),
20
21    #[error("Permission denied: {}", .0.display())]
22    PermissionDenied(PathBuf),
23
24    #[error("Path is not a directory: {}", .0.display())]
25    NotADirectory(PathBuf),
26
27    // ═══════════════════════════════════════════════════════════════
28    // Scanner Errors
29    // ═══════════════════════════════════════════════════════════════
30    #[error("Scanner error: {0}")]
31    Scanner(String),
32
33    #[error("Scan interrupted by user")]
34    ScanInterrupted,
35
36    #[error("Scan timeout after {0} seconds")]
37    ScanTimeout(u64),
38
39    // ═══════════════════════════════════════════════════════════════
40    // Plugin Errors
41    // ═══════════════════════════════════════════════════════════════
42    #[error("Plugin '{plugin}' error: {message}")]
43    Plugin { plugin: String, message: String },
44
45    #[error("Plugin not found: {0}")]
46    PluginNotFound(String),
47
48    #[error("Plugin '{0}' failed to initialize")]
49    PluginInitFailed(String),
50
51    // ═══════════════════════════════════════════════════════════════
52    // Git Errors
53    // ═══════════════════════════════════════════════════════════════
54    #[error("Git error: {0}")]
55    Git(String),
56
57    #[error("Uncommitted changes detected in: {}", .0.display())]
58    UncommittedChanges(PathBuf),
59
60    #[error("Not a git repository: {}", .0.display())]
61    NotAGitRepo(PathBuf),
62
63    // ═══════════════════════════════════════════════════════════════
64    // Docker Errors
65    // ═══════════════════════════════════════════════════════════════
66    #[error("Docker error: {0}")]
67    Docker(String),
68
69    #[error("Docker daemon not available. Is Docker running?")]
70    DockerNotAvailable,
71
72    #[error("Docker operation timed out")]
73    DockerTimeout,
74
75    // ═══════════════════════════════════════════════════════════════
76    // Trash Errors
77    // ═══════════════════════════════════════════════════════════════
78    #[error("Trash error: {0}")]
79    Trash(String),
80
81    #[error("Cannot restore '{0}': original location exists")]
82    RestoreConflict(String),
83
84    #[error("Cannot restore '{0}': {1}")]
85    RestoreFailed(String, String),
86
87    // ═══════════════════════════════════════════════════════════════
88    // Clean Errors
89    // ═══════════════════════════════════════════════════════════════
90    #[error("Clean operation blocked: {0}")]
91    CleanBlocked(String),
92
93    #[error("Failed to clean {}: {}", .path.display(), .reason)]
94    CleanFailed { path: PathBuf, reason: String },
95
96    #[error("Partial clean failure: {succeeded} succeeded, {failed} failed")]
97    PartialCleanFailure { succeeded: usize, failed: usize },
98
99    // ═══════════════════════════════════════════════════════════════
100    // Config Errors
101    // ═══════════════════════════════════════════════════════════════
102    #[error("Configuration error: {0}")]
103    Config(String),
104
105    #[error("Invalid config file at {}: {}", .path.display(), .reason)]
106    ConfigParse { path: PathBuf, reason: String },
107
108    #[error("Invalid glob pattern: {0}")]
109    InvalidPattern(String),
110
111    // ═══════════════════════════════════════════════════════════════
112    // TUI Errors
113    // ═══════════════════════════════════════════════════════════════
114    #[error("TUI error: {0}")]
115    Tui(String),
116
117    #[error("Terminal not supported")]
118    TerminalNotSupported,
119
120    // ═══════════════════════════════════════════════════════════════
121    // Serialization Errors
122    // ═══════════════════════════════════════════════════════════════
123    #[error("JSON error: {0}")]
124    Json(#[from] serde_json::Error),
125
126    #[error("TOML parse error: {0}")]
127    TomlParse(#[from] toml::de::Error),
128
129    #[error("TOML serialize error: {0}")]
130    TomlSerialize(#[from] toml::ser::Error),
131
132    // ═══════════════════════════════════════════════════════════════
133    // Generic
134    // ═══════════════════════════════════════════════════════════════
135    #[error("{0}")]
136    Other(String),
137
138    #[error("{context}: {source}")]
139    WithContext {
140        context: String,
141        #[source]
142        source: Box<DevSweepError>,
143    },
144}
145
146impl DevSweepError {
147    /// Create a plugin error with plugin name and message
148    pub fn plugin(plugin: impl Into<String>, message: impl Into<String>) -> Self {
149        Self::Plugin {
150            plugin: plugin.into(),
151            message: message.into(),
152        }
153    }
154
155    /// Create an error with additional context
156    pub fn with_context(self, context: impl Into<String>) -> Self {
157        Self::WithContext {
158            context: context.into(),
159            source: Box::new(self),
160        }
161    }
162
163    /// Check if this error is recoverable (operation can continue)
164    pub fn is_recoverable(&self) -> bool {
165        matches!(
166            self,
167            Self::PathNotFound(_)
168                | Self::PermissionDenied(_)
169                | Self::ScanInterrupted
170                | Self::DockerNotAvailable
171                | Self::NotAGitRepo(_)
172                | Self::Plugin { .. }
173        )
174    }
175
176    /// Check if this error is a user-caused interruption
177    pub fn is_user_interrupt(&self) -> bool {
178        matches!(self, Self::ScanInterrupted)
179    }
180
181    /// Get a suggested action for the user
182    pub fn suggested_action(&self) -> Option<&'static str> {
183        match self {
184            Self::PermissionDenied(_) => Some("Try running with elevated permissions (sudo)"),
185            Self::UncommittedChanges(_) => Some("Commit or stash your changes first, or use --force"),
186            Self::DockerNotAvailable => Some("Start Docker Desktop or the Docker daemon"),
187            Self::RestoreFailed(_, _) => Some("Check the trash directory or restore manually"),
188            Self::NotAGitRepo(_) => Some("Initialize a git repository or use --no-git-check"),
189            Self::ConfigParse { .. } => Some("Check your config file syntax"),
190            Self::InvalidPattern(_) => Some("Check glob pattern syntax"),
191            _ => None,
192        }
193    }
194
195    /// Get the error code for CLI exit status
196    pub fn exit_code(&self) -> i32 {
197        match self {
198            Self::ScanInterrupted => 130, // Standard SIGINT exit code
199            Self::PermissionDenied(_) => 126,
200            Self::PathNotFound(_) | Self::NotADirectory(_) => 127,
201            Self::Config(_) | Self::ConfigParse { .. } => 78, // EX_CONFIG
202            Self::UncommittedChanges(_) | Self::CleanBlocked(_) => 1,
203            _ => 1,
204        }
205    }
206}
207
208/// Result type alias for DevSweep operations
209pub type Result<T> = std::result::Result<T, DevSweepError>;
210
211/// Extension trait for adding context to Results
212pub trait ResultExt<T> {
213    /// Add context to an error
214    fn context(self, context: impl Into<String>) -> Result<T>;
215
216    /// Add path context to an error
217    fn with_path(self, path: impl Into<PathBuf>) -> Result<T>;
218}
219
220impl<T, E: Into<DevSweepError>> ResultExt<T> for std::result::Result<T, E> {
221    fn context(self, context: impl Into<String>) -> Result<T> {
222        self.map_err(|e| e.into().with_context(context))
223    }
224
225    fn with_path(self, path: impl Into<PathBuf>) -> Result<T> {
226        let path = path.into();
227        self.map_err(|e| {
228            let err = e.into();
229            match &err {
230                DevSweepError::Io(io_err) => match io_err.kind() {
231                    std::io::ErrorKind::NotFound => DevSweepError::PathNotFound(path),
232                    std::io::ErrorKind::PermissionDenied => DevSweepError::PermissionDenied(path),
233                    _ => err,
234                },
235                _ => err,
236            }
237        })
238    }
239}
240
241/// Extension trait for Option types
242pub trait OptionExt<T> {
243    /// Convert None to an error with message
244    fn ok_or_err(self, msg: impl Into<String>) -> Result<T>;
245}
246
247impl<T> OptionExt<T> for Option<T> {
248    fn ok_or_err(self, msg: impl Into<String>) -> Result<T> {
249        self.ok_or_else(|| DevSweepError::Other(msg.into()))
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_error_is_recoverable() {
259        assert!(DevSweepError::PathNotFound(PathBuf::from("/test")).is_recoverable());
260        assert!(DevSweepError::ScanInterrupted.is_recoverable());
261        assert!(!DevSweepError::Other("fatal".into()).is_recoverable());
262    }
263
264    #[test]
265    fn test_error_suggested_action() {
266        let err = DevSweepError::UncommittedChanges(PathBuf::from("/test"));
267        assert!(err.suggested_action().is_some());
268
269        let err = DevSweepError::Other("generic".into());
270        assert!(err.suggested_action().is_none());
271    }
272
273    #[test]
274    fn test_error_with_context() {
275        let err = DevSweepError::Io(std::io::Error::new(
276            std::io::ErrorKind::NotFound,
277            "file not found",
278        ));
279        let with_ctx = err.with_context("reading config");
280
281        assert!(matches!(with_ctx, DevSweepError::WithContext { .. }));
282    }
283}