1use std::path::PathBuf;
7use thiserror::Error;
8
9#[derive(Error, Debug)]
11pub enum DevSweepError {
12 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[error("TUI error: {0}")]
115 Tui(String),
116
117 #[error("Terminal not supported")]
118 TerminalNotSupported,
119
120 #[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 #[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 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 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 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 pub fn is_user_interrupt(&self) -> bool {
178 matches!(self, Self::ScanInterrupted)
179 }
180
181 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 pub fn exit_code(&self) -> i32 {
197 match self {
198 Self::ScanInterrupted => 130, Self::PermissionDenied(_) => 126,
200 Self::PathNotFound(_) | Self::NotADirectory(_) => 127,
201 Self::Config(_) | Self::ConfigParse { .. } => 78, Self::UncommittedChanges(_) | Self::CleanBlocked(_) => 1,
203 _ => 1,
204 }
205 }
206}
207
208pub type Result<T> = std::result::Result<T, DevSweepError>;
210
211pub trait ResultExt<T> {
213 fn context(self, context: impl Into<String>) -> Result<T>;
215
216 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
241pub trait OptionExt<T> {
243 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}