1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum ErrorKind {
12 SourceUnavailable,
13 PermissionDenied,
14 DecodeFailed,
15 QuicklookUnavailable,
16 ModelUnavailable,
17 LibraryBusy,
18 LibrarySchema,
19 Database,
20}
21
22impl ErrorKind {
23 pub const ALL: [ErrorKind; 8] = [
24 ErrorKind::SourceUnavailable,
25 ErrorKind::PermissionDenied,
26 ErrorKind::DecodeFailed,
27 ErrorKind::QuicklookUnavailable,
28 ErrorKind::ModelUnavailable,
29 ErrorKind::LibraryBusy,
30 ErrorKind::LibrarySchema,
31 ErrorKind::Database,
32 ];
33
34 pub fn code(self) -> &'static str {
37 match self {
38 ErrorKind::SourceUnavailable => "source_unavailable",
39 ErrorKind::PermissionDenied => "permission_denied",
40 ErrorKind::DecodeFailed => "decode_failed",
41 ErrorKind::QuicklookUnavailable => "quicklook_unavailable",
42 ErrorKind::ModelUnavailable => "model_unavailable",
43 ErrorKind::LibraryBusy => "library_busy",
44 ErrorKind::LibrarySchema => "library_schema",
45 ErrorKind::Database => "database",
46 }
47 }
48
49 pub fn remediation(self) -> Option<&'static str> {
51 match self {
52 ErrorKind::SourceUnavailable => {
53 Some("Reconnect the drive holding the library, then run the command again.")
54 }
55 ErrorKind::PermissionDenied => Some("Grant read access to the file or folder."),
56 ErrorKind::DecodeFailed => {
57 Some("The file is unreadable or unsupported; videre skips it after two attempts.")
58 }
59 ErrorKind::QuicklookUnavailable => Some(
60 "HEIC and video need macOS QuickLook; these files are skipped on this platform.",
61 ),
62 ErrorKind::ModelUnavailable => {
63 Some("Check network access for the first run, or the Hugging Face cache location.")
64 }
65 ErrorKind::LibraryBusy => {
66 Some("Another videre command is using this library; retry when it finishes.")
67 }
68 ErrorKind::LibrarySchema | ErrorKind::Database => None,
69 }
70 }
71
72 pub fn in_chain(err: &anyhow::Error) -> Option<ErrorKind> {
76 err.downcast_ref::<ErrorKind>().copied().or_else(|| {
77 err.chain()
78 .any(|e| e.is::<rusqlite::Error>())
79 .then_some(ErrorKind::Database)
80 })
81 }
82}
83
84pub fn is_fatal_io_error(err: &anyhow::Error) -> bool {
91 fn fatal(e: &rusqlite::Error) -> bool {
92 matches!(
93 e,
94 rusqlite::Error::SqliteFailure(ffi, _)
95 if matches!(
96 ffi.code,
97 rusqlite::ErrorCode::SystemIoFailure
98 | rusqlite::ErrorCode::DiskFull
99 | rusqlite::ErrorCode::ReadOnly
100 | rusqlite::ErrorCode::CannotOpen
101 )
102 )
103 }
104 err.chain()
106 .any(|cause| cause.downcast_ref::<rusqlite::Error>().is_some_and(fatal))
107}
108
109impl std::fmt::Display for ErrorKind {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.write_str(match self {
113 ErrorKind::SourceUnavailable => "the file could not be read from its drive",
114 ErrorKind::PermissionDenied => "permission denied",
115 ErrorKind::DecodeFailed => "the file could not be decoded",
116 ErrorKind::QuicklookUnavailable => "QuickLook is unavailable",
117 ErrorKind::ModelUnavailable => "the model could not be loaded",
118 ErrorKind::LibraryBusy => "the library is busy",
119 ErrorKind::LibrarySchema => "the library database needs attention",
120 ErrorKind::Database => "database error",
121 })
122 }
123}
124
125impl std::error::Error for ErrorKind {}
126
127pub fn from_io(e: std::io::Error) -> anyhow::Error {
134 const EIO: i32 = 5;
136 let kind = match e.kind() {
137 std::io::ErrorKind::TimedOut | std::io::ErrorKind::NotFound => {
138 Some(ErrorKind::SourceUnavailable)
139 }
140 std::io::ErrorKind::PermissionDenied => Some(ErrorKind::PermissionDenied),
141 _ if e.raw_os_error() == Some(EIO) => Some(ErrorKind::SourceUnavailable),
142 _ => None,
143 };
144 let err = anyhow::Error::new(e);
145 match kind {
146 Some(kind) => err.context(kind),
147 None => err,
148 }
149}
150
151pub fn from_image(e: image::ImageError) -> anyhow::Error {
155 match e {
156 image::ImageError::IoError(io) => from_io(io),
157 other => anyhow::Error::new(other).context(ErrorKind::DecodeFailed),
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use anyhow::Context;
165
166 #[test]
167 fn codes_are_unique_and_snake_case() {
168 let mut seen = std::collections::HashSet::new();
169 for kind in ErrorKind::ALL {
170 let code = kind.code();
171 assert!(seen.insert(code), "duplicate code {code}");
172 assert!(
173 code.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
174 "{code}"
175 );
176 }
177 }
178
179 #[test]
180 fn a_kind_is_found_anywhere_in_the_context_chain() {
181 let err = Err::<(), _>(std::io::Error::other("read timed out"))
182 .context(ErrorKind::SourceUnavailable)
183 .context("hash /Volumes/Fotoğraflar/İstanbul/Çağla_2019.jpg")
184 .unwrap_err();
185 assert_eq!(
186 ErrorKind::in_chain(&err),
187 Some(ErrorKind::SourceUnavailable)
188 );
189 }
190
191 #[test]
192 fn io_errors_carry_the_kind_their_cause_implies() {
193 use std::io::{Error, ErrorKind as Io};
194 let timed_out = from_io(Error::new(Io::TimedOut, "read timed out"));
195 assert_eq!(
196 ErrorKind::in_chain(&timed_out),
197 Some(ErrorKind::SourceUnavailable)
198 );
199 let denied = from_io(Error::new(Io::PermissionDenied, "denied"));
200 assert_eq!(
201 ErrorKind::in_chain(&denied),
202 Some(ErrorKind::PermissionDenied)
203 );
204 let other = from_io(Error::new(Io::InvalidData, "bad bytes"));
205 assert_eq!(ErrorKind::in_chain(&other), None);
206 assert!(format!("{other:#}").contains("bad bytes"));
207 let missing = from_io(Error::new(Io::NotFound, "gone"));
208 assert_eq!(
209 ErrorKind::in_chain(&missing),
210 Some(ErrorKind::SourceUnavailable)
211 );
212 let eio = from_io(Error::from_raw_os_error(5));
213 assert_eq!(
214 ErrorKind::in_chain(&eio),
215 Some(ErrorKind::SourceUnavailable)
216 );
217 }
218
219 #[test]
220 fn image_errors_are_classified_by_their_cause() {
221 let io = image::ImageError::IoError(std::io::Error::new(
222 std::io::ErrorKind::PermissionDenied,
223 "denied",
224 ));
225 assert_eq!(
226 ErrorKind::in_chain(&from_image(io)),
227 Some(ErrorKind::PermissionDenied)
228 );
229 let bad =
230 image::ImageError::Unsupported(image::error::UnsupportedError::from_format_and_kind(
231 image::error::ImageFormatHint::Unknown,
232 image::error::UnsupportedErrorKind::Format(image::error::ImageFormatHint::Unknown),
233 ));
234 assert_eq!(
235 ErrorKind::in_chain(&from_image(bad)),
236 Some(ErrorKind::DecodeFailed)
237 );
238 }
239
240 #[test]
241 fn a_database_error_without_an_explicit_kind_is_the_database_kind() {
242 let err = anyhow::Error::new(rusqlite::Error::InvalidQuery).context("load rows");
243 assert_eq!(ErrorKind::in_chain(&err), Some(ErrorKind::Database));
244 let explicit =
245 anyhow::Error::new(rusqlite::Error::InvalidQuery).context(ErrorKind::LibraryBusy);
246 assert_eq!(ErrorKind::in_chain(&explicit), Some(ErrorKind::LibraryBusy));
247 }
248
249 fn sql_err(code: i32) -> anyhow::Error {
250 anyhow::Error::new(rusqlite::Error::SqliteFailure(
251 rusqlite::ffi::Error::new(code),
252 None,
253 ))
254 }
255
256 #[test]
257 fn the_ioerr_family_full_readonly_and_cantopen_are_fatal() {
258 for code in [
259 rusqlite::ffi::SQLITE_IOERR,
260 rusqlite::ffi::SQLITE_IOERR_WRITE,
261 rusqlite::ffi::SQLITE_FULL,
262 rusqlite::ffi::SQLITE_READONLY,
263 rusqlite::ffi::SQLITE_CANTOPEN,
264 ] {
265 assert!(is_fatal_io_error(&sql_err(code)), "code {code}");
266 }
267 }
268
269 #[test]
270 fn contention_and_logic_errors_are_not_fatal() {
271 for code in [
272 rusqlite::ffi::SQLITE_BUSY,
273 rusqlite::ffi::SQLITE_BUSY_RECOVERY,
274 rusqlite::ffi::SQLITE_CONSTRAINT,
275 ] {
276 assert!(!is_fatal_io_error(&sql_err(code)), "code {code}");
277 }
278 assert!(!is_fatal_io_error(&anyhow::Error::new(
279 rusqlite::Error::InvalidQuery
280 )));
281 assert!(!is_fatal_io_error(&anyhow::anyhow!("not a database error")));
282 }
283
284 #[test]
285 fn a_fatal_error_is_found_through_its_context_chain() {
286 let wrapped = sql_err(rusqlite::ffi::SQLITE_IOERR_WRITE).context("write failed /x.jpg");
287 assert!(is_fatal_io_error(&wrapped));
288 }
289
290 #[test]
291 fn a_read_only_connection_fails_fatal() {
292 let conn = rusqlite::Connection::open_in_memory().unwrap();
293 conn.execute_batch("CREATE TABLE t(x)").unwrap();
294 conn.pragma_update(None, "query_only", true).unwrap();
295 let err = conn.execute("INSERT INTO t VALUES (1)", []).unwrap_err();
296 assert!(is_fatal_io_error(&anyhow::Error::new(err)));
297 }
298
299 #[test]
300 fn an_error_without_a_kind_has_none() {
301 let err = anyhow::anyhow!("plain failure").context("outer");
302 assert_eq!(ErrorKind::in_chain(&err), None);
303 }
304
305 #[test]
306 fn the_label_reads_as_part_of_a_sentence() {
307 let err = anyhow::anyhow!("timed out").context(ErrorKind::SourceUnavailable);
308 assert_eq!(
309 format!("{err:#}"),
310 "the file could not be read from its drive: timed out"
311 );
312 }
313}