Skip to main content

videre_core/
error_kind.rs

1//! Known classes of failure, attached where the cause is detected and read
2//! where the error is logged. A lower level adds one with
3//! `.context(ErrorKind::...)`; the boundary that logs the error finds it with
4//! [`ErrorKind::in_chain`] and records its code and remediation. Adding a
5//! variant is how a new failure class gets a remediation; nothing matches on
6//! error text.
7
8/// One known failure class. The set is deliberately small: most failures are
9/// environmental, and a handful of classes covers them.
10#[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    /// Stable machine code, written to the log. Never rename one: readers
35    /// and saved logs depend on it.
36    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    /// What the user can do about it, when there is something to do.
50    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    /// The kind attached anywhere in `err`'s context chain. Failing that, a
73    /// SQLite error anywhere in the chain is the database kind: classified by
74    /// type once, here, rather than tagged at every query.
75    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
84/// Whether an error is a systemic disk failure rather than per-item
85/// logic: the SQLITE_IOERR family (rusqlite flattens the extended
86/// IOERR_* codes to this base), a full disk, a read-only database, or a
87/// path that cannot be opened. The "stop the run" class, as opposed to
88/// "skip the item"; `DatabaseBusy` is deliberately absent, contention is
89/// transient and already has its own path.
90pub 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    // `chain` starts at the error itself, so one walk covers it all.
105    err.chain()
106        .any(|cause| cause.downcast_ref::<rusqlite::Error>().is_some_and(fatal))
107}
108
109impl std::fmt::Display for ErrorKind {
110    /// A short phrase that reads naturally inside `{e:#}` output.
111    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
127/// An I/O error as an `anyhow` error, carrying the kind its cause implies:
128/// a timed-out read, a missing file or a device error (`EIO`) means the
129/// source is unavailable, a refusal means permissions. Other causes carry no
130/// kind. The wrapped error stays the root cause, so `NotFound` checks on the
131/// chain still work. The one place I/O failures are
132/// classified, so every reader of the library volume agrees.
133pub fn from_io(e: std::io::Error) -> anyhow::Error {
134    /// `EIO`, the same number on macOS and Linux: the device failed the read.
135    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
151/// An image decode error, classified at its cause: an I/O failure reading
152/// the file is whatever [`from_io`] says (a drive, a permission), and only a
153/// failure to understand the bytes is `decode_failed`.
154pub 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}