Skip to main content

fsqlite_error/
lib.rs

1use std::path::PathBuf;
2
3use thiserror::Error;
4
5/// Primary error type for FrankenSQLite operations.
6///
7/// Modeled after SQLite's error codes with Rust-idiomatic structure.
8/// Follows the pattern from beads_rust: structured variants for common cases,
9/// recovery hints for user-facing errors.
10#[derive(Error, Debug)]
11pub enum FrankenError {
12    // === Database Errors ===
13    /// Database file not found.
14    #[error("database not found: '{path}'")]
15    DatabaseNotFound { path: PathBuf },
16
17    /// Database file is locked by another process.
18    #[error("database is locked: '{path}'")]
19    DatabaseLocked { path: PathBuf },
20
21    /// Database file is corrupt.
22    #[error("database disk image is malformed: {detail}")]
23    DatabaseCorrupt { detail: String },
24
25    /// Database file is not a valid SQLite database.
26    #[error("file is not a database: '{path}'")]
27    NotADatabase { path: PathBuf },
28
29    /// Database is full (max page count reached).
30    #[error("database is full")]
31    DatabaseFull,
32
33    /// Database schema has changed since the statement was prepared.
34    #[error("database schema has changed")]
35    SchemaChanged,
36
37    // === I/O Errors ===
38    /// File I/O error.
39    #[error("I/O error: {0}")]
40    Io(#[from] std::io::Error),
41
42    /// Disk I/O error during database read.
43    #[error("disk I/O error reading page {page}")]
44    IoRead { page: u32 },
45
46    /// Disk I/O error during database write.
47    #[error("disk I/O error writing page {page}")]
48    IoWrite { page: u32 },
49
50    /// Short read (fewer bytes than expected).
51    #[error("short read: expected {expected} bytes, got {actual}")]
52    ShortRead { expected: usize, actual: usize },
53
54    // === SQL Errors ===
55    /// SQL syntax error.
56    #[error("near \"{token}\": syntax error")]
57    SyntaxError { token: String },
58
59    /// SQL parsing error at a specific position.
60    #[error("SQL error at offset {offset}: {detail}")]
61    ParseError { offset: usize, detail: String },
62
63    /// Query executed successfully but produced no rows.
64    #[error("query returned no rows")]
65    QueryReturnedNoRows,
66
67    /// Query executed successfully but produced more than one row.
68    #[error("query returned more than one row")]
69    QueryReturnedMultipleRows,
70
71    /// No such table.
72    #[error("no such table: {name}")]
73    NoSuchTable { name: String },
74
75    /// No such column.
76    #[error("no such column: {name}")]
77    NoSuchColumn { name: String },
78
79    /// No such index.
80    #[error("no such index: {name}")]
81    NoSuchIndex { name: String },
82
83    /// Table already exists.
84    #[error("table {name} already exists")]
85    TableExists { name: String },
86
87    /// Index already exists.
88    #[error("index {name} already exists")]
89    IndexExists { name: String },
90
91    /// Ambiguous column reference.
92    #[error("ambiguous column name: {name}")]
93    AmbiguousColumn { name: String },
94
95    // === Constraint Errors ===
96    /// UNIQUE constraint violation.
97    #[error("UNIQUE constraint failed: {columns}")]
98    UniqueViolation { columns: String },
99
100    /// NOT NULL constraint violation.
101    #[error("NOT NULL constraint failed: {column}")]
102    NotNullViolation { column: String },
103
104    /// CHECK constraint violation.
105    #[error("CHECK constraint failed: {name}")]
106    CheckViolation { name: String },
107
108    /// FOREIGN KEY constraint violation.
109    #[error("FOREIGN KEY constraint failed")]
110    ForeignKeyViolation,
111
112    /// PRIMARY KEY constraint violation.
113    #[error("PRIMARY KEY constraint failed")]
114    PrimaryKeyViolation,
115
116    /// STRICT table datatype constraint violation (SQLITE_CONSTRAINT_DATATYPE).
117    #[error("cannot store {actual} value in {column_type} column {column}")]
118    DatatypeViolation {
119        column: String,
120        column_type: String,
121        actual: String,
122    },
123
124    // === Transaction Errors ===
125    /// Cannot start a transaction within a transaction.
126    #[error("cannot start a transaction within a transaction")]
127    NestedTransaction,
128
129    /// No transaction is active.
130    #[error("cannot commit - no transaction is active")]
131    NoActiveTransaction,
132
133    /// VACUUM cannot run while a transaction/savepoint is active.
134    #[error("cannot VACUUM from within a transaction")]
135    VacuumWithinTransaction,
136
137    /// Transaction was rolled back due to constraint violation.
138    #[error("transaction rolled back: {reason}")]
139    TransactionRolledBack { reason: String },
140
141    // === MVCC Errors ===
142    /// Page-level write conflict (another transaction modified the same page).
143    #[error("write conflict on page {page}: held by transaction {holder}")]
144    WriteConflict { page: u32, holder: u64 },
145
146    /// Serialization failure (first-committer-wins violation).
147    #[error("serialization failure: page {page} was modified after snapshot")]
148    SerializationFailure { page: u32 },
149
150    /// Snapshot is too old (required versions have been garbage collected).
151    #[error("snapshot too old: transaction {txn_id} is below GC horizon")]
152    SnapshotTooOld { txn_id: u64 },
153
154    // === BUSY ===
155    /// Database is busy (the SQLite classic).
156    #[error("database is busy")]
157    Busy,
158
159    /// Database is busy due to recovery.
160    #[error("database is busy (recovery in progress)")]
161    BusyRecovery,
162
163    /// Concurrent transaction commit failed due to page conflict (SQLITE_BUSY_SNAPSHOT).
164    /// Another transaction committed changes to pages in the write set since the
165    /// snapshot was established.
166    #[error("database is busy (snapshot conflict on pages: {conflicting_pages})")]
167    BusySnapshot { conflicting_pages: String },
168
169    /// BEGIN CONCURRENT is not available without fsqlite-shm (ยง5.6.6.2).
170    #[error(
171        "BEGIN CONCURRENT unavailable: fsqlite-shm not present (multi-writer MVCC requires shared memory coordination)"
172    )]
173    ConcurrentUnavailable,
174
175    // === Type Errors ===
176    /// Type mismatch in column access.
177    #[error("type mismatch: expected {expected}, got {actual}")]
178    TypeMismatch { expected: String, actual: String },
179
180    /// Integer overflow during computation.
181    #[error("integer overflow")]
182    IntegerOverflow,
183
184    /// Value out of range.
185    #[error("{what} out of range: {value}")]
186    OutOfRange { what: String, value: String },
187
188    // === Limit Errors ===
189    /// String or BLOB exceeds the size limit.
190    #[error("string or BLOB exceeds size limit")]
191    TooBig,
192
193    /// Too many columns.
194    #[error("too many columns: {count} (max {max})")]
195    TooManyColumns { count: usize, max: usize },
196
197    /// SQL statement too long.
198    #[error("SQL statement too long: {length} bytes (max {max})")]
199    SqlTooLong { length: usize, max: usize },
200
201    /// Expression tree too deep.
202    #[error("expression tree too deep (max {max})")]
203    ExpressionTooDeep { max: usize },
204
205    /// Too many attached databases.
206    #[error("too many attached databases (max {max})")]
207    TooManyAttached { max: usize },
208
209    /// Too many function arguments.
210    #[error("too many arguments to function {name}")]
211    TooManyArguments { name: String },
212
213    // === WAL Errors ===
214    /// WAL file is corrupt.
215    #[error("WAL file is corrupt: {detail}")]
216    WalCorrupt { detail: String },
217
218    /// WAL checkpoint failed.
219    #[error("WAL checkpoint failed: {detail}")]
220    CheckpointFailed { detail: String },
221
222    // === VFS Errors ===
223    /// File locking failed.
224    #[error("file locking failed: {detail}")]
225    LockFailed { detail: String },
226
227    /// Cannot open file.
228    #[error("unable to open database file: '{path}'")]
229    CannotOpen { path: PathBuf },
230
231    // === Internal Errors ===
232    /// Internal logic error (should never happen).
233    #[error("internal error: {0}")]
234    Internal(String),
235
236    /// Operation is not supported by the current backend or configuration.
237    #[error("unsupported operation")]
238    Unsupported,
239
240    /// Feature not yet implemented.
241    #[error("not implemented: {0}")]
242    NotImplemented(String),
243
244    /// Abort due to callback.
245    #[error("callback requested query abort")]
246    Abort,
247
248    /// Authorization denied.
249    #[error("authorization denied")]
250    AuthDenied,
251
252    /// Out of memory.
253    #[error("out of memory")]
254    OutOfMemory,
255
256    /// SQL function domain/runtime error (analogous to `sqlite3_result_error`).
257    #[error("{0}")]
258    FunctionError(String),
259
260    /// A background runtime worker failed and poisoned the shared database state.
261    #[error("background worker failed: {0}")]
262    BackgroundWorkerFailed(String),
263
264    /// Attempt to write a read-only database or virtual table.
265    #[error("attempt to write a readonly database")]
266    ReadOnly,
267
268    /// Interrupted by a cancellation-aware execution context.
269    #[error("interrupted")]
270    Interrupt,
271
272    /// Execution error within the VDBE bytecode engine.
273    #[error("VDBE execution error: {detail}")]
274    VdbeExecutionError { detail: String },
275}
276
277/// SQLite result/error codes for wire protocol compatibility.
278///
279/// These match the numeric values from C SQLite's `sqlite3.h`.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
281#[repr(i32)]
282pub enum ErrorCode {
283    /// Successful result.
284    Ok = 0,
285    /// Generic error.
286    Error = 1,
287    /// Internal logic error.
288    Internal = 2,
289    /// Access permission denied.
290    Perm = 3,
291    /// Callback requested abort.
292    Abort = 4,
293    /// Database file is locked.
294    Busy = 5,
295    /// Table is locked.
296    Locked = 6,
297    /// Out of memory.
298    NoMem = 7,
299    /// Attempt to write a read-only database.
300    ReadOnly = 8,
301    /// Interrupted by `sqlite3_interrupt()`.
302    Interrupt = 9,
303    /// Disk I/O error.
304    IoErr = 10,
305    /// Database disk image is malformed.
306    Corrupt = 11,
307    /// Not found (internal).
308    NotFound = 12,
309    /// Database or disk is full.
310    Full = 13,
311    /// Unable to open database file.
312    CantOpen = 14,
313    /// Locking protocol error.
314    Protocol = 15,
315    /// (Not used).
316    Empty = 16,
317    /// Database schema has changed.
318    Schema = 17,
319    /// String or BLOB exceeds size limit.
320    TooBig = 18,
321    /// Constraint violation.
322    Constraint = 19,
323    /// Data type mismatch.
324    Mismatch = 20,
325    /// Library used incorrectly.
326    Misuse = 21,
327    /// OS feature not available.
328    NoLfs = 22,
329    /// Authorization denied.
330    Auth = 23,
331    /// Not used.
332    Format = 24,
333    /// Bind parameter out of range.
334    Range = 25,
335    /// Not a database file.
336    NotADb = 26,
337    /// Notification (not an error).
338    Notice = 27,
339    /// Warning (not an error).
340    Warning = 28,
341    /// `sqlite3_step()` has another row ready.
342    Row = 100,
343    /// `sqlite3_step()` has finished executing.
344    Done = 101,
345}
346
347impl FrankenError {
348    /// Map this error to a SQLite error code for compatibility.
349    #[allow(clippy::match_same_arms)]
350    pub const fn error_code(&self) -> ErrorCode {
351        match self {
352            Self::DatabaseNotFound { .. } | Self::CannotOpen { .. } => ErrorCode::CantOpen,
353            Self::DatabaseLocked { .. } => ErrorCode::Busy,
354            Self::DatabaseCorrupt { .. } | Self::WalCorrupt { .. } => ErrorCode::Corrupt,
355            Self::NotADatabase { .. } => ErrorCode::NotADb,
356            Self::DatabaseFull => ErrorCode::Full,
357            Self::SchemaChanged => ErrorCode::Schema,
358            Self::Io(_)
359            | Self::IoRead { .. }
360            | Self::IoWrite { .. }
361            | Self::ShortRead { .. }
362            | Self::CheckpointFailed { .. } => ErrorCode::IoErr,
363            Self::SyntaxError { .. }
364            | Self::ParseError { .. }
365            | Self::QueryReturnedNoRows
366            | Self::QueryReturnedMultipleRows
367            | Self::NoSuchTable { .. }
368            | Self::NoSuchColumn { .. }
369            | Self::NoSuchIndex { .. }
370            | Self::TableExists { .. }
371            | Self::IndexExists { .. }
372            | Self::AmbiguousColumn { .. }
373            | Self::NestedTransaction
374            | Self::VacuumWithinTransaction
375            | Self::NoActiveTransaction
376            | Self::TransactionRolledBack { .. }
377            | Self::TooManyColumns { .. }
378            | Self::SqlTooLong { .. }
379            | Self::ExpressionTooDeep { .. }
380            | Self::TooManyAttached { .. }
381            | Self::TooManyArguments { .. }
382            | Self::NotImplemented(_)
383            | Self::FunctionError(_)
384            | Self::BackgroundWorkerFailed(_)
385            | Self::ConcurrentUnavailable => ErrorCode::Error,
386            Self::UniqueViolation { .. }
387            | Self::NotNullViolation { .. }
388            | Self::CheckViolation { .. }
389            | Self::ForeignKeyViolation
390            | Self::PrimaryKeyViolation
391            | Self::DatatypeViolation { .. } => ErrorCode::Constraint,
392            Self::WriteConflict { .. }
393            | Self::SerializationFailure { .. }
394            | Self::Busy
395            | Self::BusyRecovery
396            | Self::BusySnapshot { .. }
397            | Self::SnapshotTooOld { .. }
398            | Self::LockFailed { .. } => ErrorCode::Busy,
399            Self::TypeMismatch { .. } => ErrorCode::Mismatch,
400            Self::IntegerOverflow | Self::OutOfRange { .. } => ErrorCode::Range,
401            Self::TooBig => ErrorCode::TooBig,
402            Self::Internal(_) => ErrorCode::Internal,
403            Self::Abort => ErrorCode::Abort,
404            Self::AuthDenied => ErrorCode::Auth,
405            Self::OutOfMemory => ErrorCode::NoMem,
406            Self::Unsupported => ErrorCode::NoLfs,
407            Self::ReadOnly => ErrorCode::ReadOnly,
408            Self::Interrupt => ErrorCode::Interrupt,
409            Self::VdbeExecutionError { .. } => ErrorCode::Error,
410        }
411    }
412
413    /// Whether the user can likely fix this without code changes.
414    pub const fn is_user_recoverable(&self) -> bool {
415        matches!(
416            self,
417            Self::DatabaseNotFound { .. }
418                | Self::DatabaseLocked { .. }
419                | Self::Busy
420                | Self::BusyRecovery
421                | Self::BusySnapshot { .. }
422                | Self::Unsupported
423                | Self::SyntaxError { .. }
424                | Self::ParseError { .. }
425                | Self::QueryReturnedNoRows
426                | Self::QueryReturnedMultipleRows
427                | Self::NoSuchTable { .. }
428                | Self::NoSuchColumn { .. }
429                | Self::TypeMismatch { .. }
430                | Self::CannotOpen { .. }
431        )
432    }
433
434    /// Human-friendly suggestion for fixing this error.
435    pub const fn suggestion(&self) -> Option<&'static str> {
436        match self {
437            Self::DatabaseNotFound { .. } => Some("Check the file path or create a new database"),
438            Self::DatabaseLocked { .. } => {
439                Some("Close other connections or wait for the lock to be released")
440            }
441            Self::Busy | Self::BusyRecovery => Some("Retry the operation after a short delay"),
442            Self::BusySnapshot { .. } => {
443                Some("Retry the transaction; another writer committed to the same pages")
444            }
445            Self::WriteConflict { .. } | Self::SerializationFailure { .. } => {
446                Some("Retry the transaction; the conflict is transient")
447            }
448            Self::SnapshotTooOld { .. } => Some("Begin a new transaction to get a fresh snapshot"),
449            Self::DatabaseCorrupt { .. } => {
450                Some("Run PRAGMA integrity_check; restore from backup if needed")
451            }
452            Self::TooBig => Some("Reduce the size of the value being inserted"),
453            Self::NotImplemented(_) => Some("This feature is not yet available in FrankenSQLite"),
454            Self::ConcurrentUnavailable => Some(
455                "Use a filesystem that supports shared memory, or use BEGIN (serialized) instead",
456            ),
457            Self::BackgroundWorkerFailed(_) => {
458                Some("Close and reopen the database; inspect the logged worker failure details")
459            }
460            Self::QueryReturnedNoRows => Some("Use query() when zero rows are acceptable"),
461            Self::QueryReturnedMultipleRows => {
462                Some("Use query() when multiple rows are acceptable, or tighten the query")
463            }
464            _ => None,
465        }
466    }
467
468    /// Whether this is a transient error that may succeed on retry.
469    pub const fn is_transient(&self) -> bool {
470        matches!(
471            self,
472            Self::Busy
473                | Self::BusyRecovery
474                | Self::BusySnapshot { .. }
475                | Self::DatabaseLocked { .. }
476                | Self::WriteConflict { .. }
477                | Self::SerializationFailure { .. }
478        )
479    }
480
481    /// Get the process exit code for this error (for CLI use).
482    pub const fn exit_code(&self) -> i32 {
483        self.error_code() as i32
484    }
485
486    /// Get the extended SQLite error code.
487    ///
488    /// SQLite extended error codes encode additional information in the upper bits:
489    /// `extended_code = (ext_num << 8) | base_code`
490    ///
491    /// For most errors, this returns the base error code. For BUSY variants:
492    /// - `Busy` โ†’ 5 (SQLITE_BUSY)
493    /// - `BusyRecovery` โ†’ 261 (SQLITE_BUSY_RECOVERY = 5 | (1 << 8))
494    /// - `BusySnapshot` โ†’ 517 (SQLITE_BUSY_SNAPSHOT = 5 | (2 << 8))
495    pub const fn extended_error_code(&self) -> i32 {
496        match self {
497            Self::Busy => 5,                           // SQLITE_BUSY
498            Self::BusyRecovery => 5 | (1 << 8),        // SQLITE_BUSY_RECOVERY = 261
499            Self::BusySnapshot { .. } => 5 | (2 << 8), // SQLITE_BUSY_SNAPSHOT = 517
500            Self::DatatypeViolation { .. } => 3091,    // SQLITE_CONSTRAINT_DATATYPE
501            _ => self.error_code() as i32,
502        }
503    }
504
505    /// Create a syntax error.
506    pub fn syntax(token: impl Into<String>) -> Self {
507        Self::SyntaxError {
508            token: token.into(),
509        }
510    }
511
512    /// Create a parse error.
513    pub fn parse(offset: usize, detail: impl Into<String>) -> Self {
514        Self::ParseError {
515            offset,
516            detail: detail.into(),
517        }
518    }
519
520    /// Create an internal error.
521    pub fn internal(msg: impl Into<String>) -> Self {
522        Self::Internal(msg.into())
523    }
524
525    /// Create a not-implemented error.
526    pub fn not_implemented(feature: impl Into<String>) -> Self {
527        Self::NotImplemented(feature.into())
528    }
529
530    /// Create a function domain error.
531    pub fn function_error(msg: impl Into<String>) -> Self {
532        Self::FunctionError(msg.into())
533    }
534}
535
536/// Result type alias using `FrankenError`.
537pub type Result<T> = std::result::Result<T, FrankenError>;
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn error_display() {
545        let err = FrankenError::syntax("SELEC");
546        assert_eq!(err.to_string(), r#"near "SELEC": syntax error"#);
547    }
548
549    #[test]
550    fn error_display_corrupt() {
551        let err = FrankenError::DatabaseCorrupt {
552            detail: "invalid page header".to_owned(),
553        };
554        assert_eq!(
555            err.to_string(),
556            "database disk image is malformed: invalid page header"
557        );
558    }
559
560    #[test]
561    fn error_display_write_conflict() {
562        let err = FrankenError::WriteConflict {
563            page: 42,
564            holder: 7,
565        };
566        assert_eq!(
567            err.to_string(),
568            "write conflict on page 42: held by transaction 7"
569        );
570    }
571
572    #[test]
573    fn error_code_mapping() {
574        assert_eq!(FrankenError::syntax("x").error_code(), ErrorCode::Error);
575        assert_eq!(
576            FrankenError::QueryReturnedNoRows.error_code(),
577            ErrorCode::Error
578        );
579        assert_eq!(
580            FrankenError::QueryReturnedMultipleRows.error_code(),
581            ErrorCode::Error
582        );
583        assert_eq!(FrankenError::Busy.error_code(), ErrorCode::Busy);
584        assert_eq!(FrankenError::Abort.error_code(), ErrorCode::Abort);
585        assert_eq!(
586            FrankenError::DatabaseCorrupt {
587                detail: String::new()
588            }
589            .error_code(),
590            ErrorCode::Corrupt
591        );
592        assert_eq!(FrankenError::DatabaseFull.error_code(), ErrorCode::Full);
593        assert_eq!(FrankenError::TooBig.error_code(), ErrorCode::TooBig);
594        assert_eq!(FrankenError::OutOfMemory.error_code(), ErrorCode::NoMem);
595        assert_eq!(FrankenError::AuthDenied.error_code(), ErrorCode::Auth);
596    }
597
598    #[test]
599    fn user_recoverable() {
600        assert!(FrankenError::Busy.is_user_recoverable());
601        assert!(FrankenError::QueryReturnedNoRows.is_user_recoverable());
602        assert!(FrankenError::QueryReturnedMultipleRows.is_user_recoverable());
603        assert!(FrankenError::syntax("x").is_user_recoverable());
604        assert!(!FrankenError::internal("bug").is_user_recoverable());
605        assert!(!FrankenError::DatabaseFull.is_user_recoverable());
606    }
607
608    #[test]
609    fn is_transient() {
610        assert!(FrankenError::Busy.is_transient());
611        assert!(FrankenError::BusyRecovery.is_transient());
612        assert!(FrankenError::WriteConflict { page: 1, holder: 1 }.is_transient());
613        assert!(!FrankenError::DatabaseFull.is_transient());
614        assert!(!FrankenError::syntax("x").is_transient());
615    }
616
617    #[test]
618    fn suggestions() {
619        assert!(FrankenError::Busy.suggestion().is_some());
620        assert!(FrankenError::not_implemented("CTE").suggestion().is_some());
621        assert!(
622            FrankenError::QueryReturnedMultipleRows
623                .suggestion()
624                .is_some()
625        );
626        assert!(FrankenError::DatabaseFull.suggestion().is_none());
627    }
628
629    #[test]
630    fn convenience_constructors() {
631        // Keep test strings clearly non-sensitive so UBS doesn't flag them as secrets.
632        let expected_kw = "kw_where";
633        let err = FrankenError::syntax(expected_kw);
634        assert!(matches!(
635            err,
636            FrankenError::SyntaxError { token: got_kw } if got_kw == expected_kw
637        ));
638
639        let err = FrankenError::parse(42, "unexpected token");
640        assert!(matches!(err, FrankenError::ParseError { offset: 42, .. }));
641
642        let err = FrankenError::internal("assertion failed");
643        assert!(matches!(err, FrankenError::Internal(msg) if msg == "assertion failed"));
644
645        let err = FrankenError::not_implemented("window functions");
646        assert!(matches!(err, FrankenError::NotImplemented(msg) if msg == "window functions"));
647    }
648
649    #[test]
650    fn io_error_from() {
651        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
652        let err: FrankenError = io_err.into();
653        assert!(matches!(err, FrankenError::Io(_)));
654        assert_eq!(err.error_code(), ErrorCode::IoErr);
655    }
656
657    #[test]
658    fn error_code_values() {
659        assert_eq!(ErrorCode::Ok as i32, 0);
660        assert_eq!(ErrorCode::Error as i32, 1);
661        assert_eq!(ErrorCode::Busy as i32, 5);
662        assert_eq!(ErrorCode::Constraint as i32, 19);
663        assert_eq!(ErrorCode::Row as i32, 100);
664        assert_eq!(ErrorCode::Done as i32, 101);
665    }
666
667    #[test]
668    fn exit_code() {
669        assert_eq!(FrankenError::Busy.exit_code(), 5);
670        assert_eq!(FrankenError::internal("x").exit_code(), 2);
671        assert_eq!(FrankenError::syntax("x").exit_code(), 1);
672    }
673
674    #[test]
675    fn extended_error_codes() {
676        // Base SQLITE_BUSY = 5
677        assert_eq!(FrankenError::Busy.extended_error_code(), 5);
678        assert_eq!(FrankenError::Busy.error_code(), ErrorCode::Busy);
679
680        // SQLITE_BUSY_RECOVERY = 5 | (1 << 8) = 261
681        assert_eq!(FrankenError::BusyRecovery.extended_error_code(), 261);
682        assert_eq!(FrankenError::BusyRecovery.error_code(), ErrorCode::Busy);
683
684        // SQLITE_BUSY_SNAPSHOT = 5 | (2 << 8) = 517
685        let busy_snapshot = FrankenError::BusySnapshot {
686            conflicting_pages: "1, 2, 3".to_owned(),
687        };
688        assert_eq!(busy_snapshot.extended_error_code(), 517);
689        assert_eq!(busy_snapshot.error_code(), ErrorCode::Busy);
690
691        // All three share the same base error code but distinct extended codes
692        assert_eq!(FrankenError::Busy.error_code(), ErrorCode::Busy);
693        assert_eq!(FrankenError::BusyRecovery.error_code(), ErrorCode::Busy);
694        assert_eq!(busy_snapshot.error_code(), ErrorCode::Busy);
695        assert_ne!(
696            FrankenError::Busy.extended_error_code(),
697            busy_snapshot.extended_error_code()
698        );
699        assert_ne!(
700            FrankenError::BusyRecovery.extended_error_code(),
701            busy_snapshot.extended_error_code()
702        );
703    }
704
705    #[test]
706    fn constraint_errors() {
707        let err = FrankenError::UniqueViolation {
708            columns: "users.email".to_owned(),
709        };
710        assert_eq!(err.to_string(), "UNIQUE constraint failed: users.email");
711        assert_eq!(err.error_code(), ErrorCode::Constraint);
712
713        let err = FrankenError::NotNullViolation {
714            column: "name".to_owned(),
715        };
716        assert_eq!(err.to_string(), "NOT NULL constraint failed: name");
717
718        assert_eq!(
719            FrankenError::ForeignKeyViolation.to_string(),
720            "FOREIGN KEY constraint failed"
721        );
722    }
723
724    #[test]
725    fn mvcc_errors() {
726        let err = FrankenError::WriteConflict {
727            page: 5,
728            holder: 10,
729        };
730        assert!(err.is_transient());
731        assert_eq!(err.error_code(), ErrorCode::Busy);
732
733        let err = FrankenError::SerializationFailure { page: 5 };
734        assert!(err.is_transient());
735
736        let err = FrankenError::SnapshotTooOld { txn_id: 42 };
737        assert!(!err.is_transient());
738        assert!(err.suggestion().is_some());
739    }
740
741    // ---- Additional comprehensive tests for bd-2ddl coverage ----
742
743    #[test]
744    fn display_database_not_found() {
745        let err = FrankenError::DatabaseNotFound {
746            path: PathBuf::from("/tmp/test.db"),
747        };
748        assert_eq!(err.to_string(), "database not found: '/tmp/test.db'");
749    }
750
751    #[test]
752    fn display_database_locked() {
753        let err = FrankenError::DatabaseLocked {
754            path: PathBuf::from("/tmp/test.db"),
755        };
756        assert_eq!(err.to_string(), "database is locked: '/tmp/test.db'");
757    }
758
759    #[test]
760    fn display_not_a_database() {
761        let err = FrankenError::NotADatabase {
762            path: PathBuf::from("/tmp/random.bin"),
763        };
764        assert_eq!(err.to_string(), "file is not a database: '/tmp/random.bin'");
765    }
766
767    #[test]
768    fn display_database_full() {
769        assert_eq!(FrankenError::DatabaseFull.to_string(), "database is full");
770    }
771
772    #[test]
773    fn display_schema_changed() {
774        assert_eq!(
775            FrankenError::SchemaChanged.to_string(),
776            "database schema has changed"
777        );
778    }
779
780    #[test]
781    fn display_io_read_write() {
782        let err = FrankenError::IoRead { page: 17 };
783        assert_eq!(err.to_string(), "disk I/O error reading page 17");
784
785        let err = FrankenError::IoWrite { page: 42 };
786        assert_eq!(err.to_string(), "disk I/O error writing page 42");
787    }
788
789    #[test]
790    fn display_short_read() {
791        let err = FrankenError::ShortRead {
792            expected: 4096,
793            actual: 2048,
794        };
795        assert_eq!(err.to_string(), "short read: expected 4096 bytes, got 2048");
796    }
797
798    #[test]
799    fn display_no_such_table_column_index() {
800        assert_eq!(
801            FrankenError::NoSuchTable {
802                name: "users".to_owned()
803            }
804            .to_string(),
805            "no such table: users"
806        );
807        assert_eq!(
808            FrankenError::NoSuchColumn {
809                name: "email".to_owned()
810            }
811            .to_string(),
812            "no such column: email"
813        );
814        assert_eq!(
815            FrankenError::NoSuchIndex {
816                name: "idx_email".to_owned()
817            }
818            .to_string(),
819            "no such index: idx_email"
820        );
821    }
822
823    #[test]
824    fn display_already_exists() {
825        assert_eq!(
826            FrankenError::TableExists {
827                name: "t1".to_owned()
828            }
829            .to_string(),
830            "table t1 already exists"
831        );
832        assert_eq!(
833            FrankenError::IndexExists {
834                name: "i1".to_owned()
835            }
836            .to_string(),
837            "index i1 already exists"
838        );
839    }
840
841    #[test]
842    fn display_ambiguous_column() {
843        let err = FrankenError::AmbiguousColumn {
844            name: "id".to_owned(),
845        };
846        assert_eq!(err.to_string(), "ambiguous column name: id");
847    }
848
849    #[test]
850    fn display_transaction_errors() {
851        assert_eq!(
852            FrankenError::NestedTransaction.to_string(),
853            "cannot start a transaction within a transaction"
854        );
855        assert_eq!(
856            FrankenError::NoActiveTransaction.to_string(),
857            "cannot commit - no transaction is active"
858        );
859        assert_eq!(
860            FrankenError::VacuumWithinTransaction.to_string(),
861            "cannot VACUUM from within a transaction"
862        );
863        assert_eq!(
864            FrankenError::TransactionRolledBack {
865                reason: "constraint".to_owned()
866            }
867            .to_string(),
868            "transaction rolled back: constraint"
869        );
870    }
871
872    #[test]
873    fn display_serialization_failure() {
874        let err = FrankenError::SerializationFailure { page: 99 };
875        assert_eq!(
876            err.to_string(),
877            "serialization failure: page 99 was modified after snapshot"
878        );
879    }
880
881    #[test]
882    fn display_snapshot_too_old() {
883        let err = FrankenError::SnapshotTooOld { txn_id: 100 };
884        assert_eq!(
885            err.to_string(),
886            "snapshot too old: transaction 100 is below GC horizon"
887        );
888    }
889
890    #[test]
891    fn display_busy_variants() {
892        assert_eq!(FrankenError::Busy.to_string(), "database is busy");
893        assert_eq!(
894            FrankenError::BusyRecovery.to_string(),
895            "database is busy (recovery in progress)"
896        );
897    }
898
899    #[test]
900    fn display_concurrent_unavailable() {
901        let err = FrankenError::ConcurrentUnavailable;
902        assert!(err.to_string().contains("BEGIN CONCURRENT unavailable"));
903    }
904
905    #[test]
906    fn display_type_errors() {
907        let err = FrankenError::TypeMismatch {
908            expected: "INTEGER".to_owned(),
909            actual: "TEXT".to_owned(),
910        };
911        assert_eq!(err.to_string(), "type mismatch: expected INTEGER, got TEXT");
912
913        assert_eq!(
914            FrankenError::IntegerOverflow.to_string(),
915            "integer overflow"
916        );
917
918        let err = FrankenError::OutOfRange {
919            what: "page number".to_owned(),
920            value: "0".to_owned(),
921        };
922        assert_eq!(err.to_string(), "page number out of range: 0");
923    }
924
925    #[test]
926    fn display_limit_errors() {
927        assert_eq!(
928            FrankenError::TooBig.to_string(),
929            "string or BLOB exceeds size limit"
930        );
931
932        let err = FrankenError::TooManyColumns {
933            count: 2001,
934            max: 2000,
935        };
936        assert_eq!(err.to_string(), "too many columns: 2001 (max 2000)");
937
938        let err = FrankenError::SqlTooLong {
939            length: 2_000_000,
940            max: 1_000_000,
941        };
942        assert_eq!(
943            err.to_string(),
944            "SQL statement too long: 2000000 bytes (max 1000000)"
945        );
946
947        let err = FrankenError::ExpressionTooDeep { max: 1000 };
948        assert_eq!(err.to_string(), "expression tree too deep (max 1000)");
949
950        let err = FrankenError::TooManyAttached { max: 10 };
951        assert_eq!(err.to_string(), "too many attached databases (max 10)");
952
953        let err = FrankenError::TooManyArguments {
954            name: "my_func".to_owned(),
955        };
956        assert_eq!(err.to_string(), "too many arguments to function my_func");
957    }
958
959    #[test]
960    fn display_wal_errors() {
961        let err = FrankenError::WalCorrupt {
962            detail: "invalid checksum".to_owned(),
963        };
964        assert_eq!(err.to_string(), "WAL file is corrupt: invalid checksum");
965
966        let err = FrankenError::CheckpointFailed {
967            detail: "busy".to_owned(),
968        };
969        assert_eq!(err.to_string(), "WAL checkpoint failed: busy");
970    }
971
972    #[test]
973    fn display_vfs_errors() {
974        let err = FrankenError::LockFailed {
975            detail: "permission denied".to_owned(),
976        };
977        assert_eq!(err.to_string(), "file locking failed: permission denied");
978
979        let err = FrankenError::CannotOpen {
980            path: PathBuf::from("/readonly/test.db"),
981        };
982        assert_eq!(
983            err.to_string(),
984            "unable to open database file: '/readonly/test.db'"
985        );
986    }
987
988    #[test]
989    fn display_internal_errors() {
990        assert_eq!(
991            FrankenError::Internal("assertion failed".to_owned()).to_string(),
992            "internal error: assertion failed"
993        );
994        assert_eq!(
995            FrankenError::Unsupported.to_string(),
996            "unsupported operation"
997        );
998        assert_eq!(
999            FrankenError::NotImplemented("CTE".to_owned()).to_string(),
1000            "not implemented: CTE"
1001        );
1002        assert_eq!(
1003            FrankenError::Abort.to_string(),
1004            "callback requested query abort"
1005        );
1006        assert_eq!(FrankenError::AuthDenied.to_string(), "authorization denied");
1007        assert_eq!(FrankenError::OutOfMemory.to_string(), "out of memory");
1008        assert_eq!(
1009            FrankenError::ReadOnly.to_string(),
1010            "attempt to write a readonly database"
1011        );
1012    }
1013
1014    #[test]
1015    fn display_function_error() {
1016        let err = FrankenError::FunctionError("domain error".to_owned());
1017        assert_eq!(err.to_string(), "domain error");
1018    }
1019
1020    #[test]
1021    #[allow(clippy::too_many_lines)]
1022    fn error_code_comprehensive_mapping() {
1023        // Database errors
1024        assert_eq!(
1025            FrankenError::DatabaseNotFound {
1026                path: PathBuf::new()
1027            }
1028            .error_code(),
1029            ErrorCode::CantOpen
1030        );
1031        assert_eq!(
1032            FrankenError::DatabaseLocked {
1033                path: PathBuf::new()
1034            }
1035            .error_code(),
1036            ErrorCode::Busy
1037        );
1038        assert_eq!(
1039            FrankenError::NotADatabase {
1040                path: PathBuf::new()
1041            }
1042            .error_code(),
1043            ErrorCode::NotADb
1044        );
1045        assert_eq!(FrankenError::SchemaChanged.error_code(), ErrorCode::Schema);
1046
1047        // I/O errors
1048        assert_eq!(
1049            FrankenError::IoRead { page: 1 }.error_code(),
1050            ErrorCode::IoErr
1051        );
1052        assert_eq!(
1053            FrankenError::IoWrite { page: 1 }.error_code(),
1054            ErrorCode::IoErr
1055        );
1056        assert_eq!(
1057            FrankenError::ShortRead {
1058                expected: 1,
1059                actual: 0
1060            }
1061            .error_code(),
1062            ErrorCode::IoErr
1063        );
1064
1065        // SQL errors map to Error
1066        assert_eq!(
1067            FrankenError::NoSuchTable {
1068                name: String::new()
1069            }
1070            .error_code(),
1071            ErrorCode::Error
1072        );
1073        assert_eq!(
1074            FrankenError::NoSuchColumn {
1075                name: String::new()
1076            }
1077            .error_code(),
1078            ErrorCode::Error
1079        );
1080        assert_eq!(
1081            FrankenError::NoSuchIndex {
1082                name: String::new()
1083            }
1084            .error_code(),
1085            ErrorCode::Error
1086        );
1087        assert_eq!(
1088            FrankenError::TableExists {
1089                name: String::new()
1090            }
1091            .error_code(),
1092            ErrorCode::Error
1093        );
1094        assert_eq!(
1095            FrankenError::IndexExists {
1096                name: String::new()
1097            }
1098            .error_code(),
1099            ErrorCode::Error
1100        );
1101        assert_eq!(
1102            FrankenError::AmbiguousColumn {
1103                name: String::new()
1104            }
1105            .error_code(),
1106            ErrorCode::Error
1107        );
1108
1109        // Transaction errors
1110        assert_eq!(
1111            FrankenError::NestedTransaction.error_code(),
1112            ErrorCode::Error
1113        );
1114        assert_eq!(
1115            FrankenError::VacuumWithinTransaction.error_code(),
1116            ErrorCode::Error
1117        );
1118        assert_eq!(
1119            FrankenError::NoActiveTransaction.error_code(),
1120            ErrorCode::Error
1121        );
1122
1123        // MVCC errors map to Busy
1124        assert_eq!(
1125            FrankenError::SerializationFailure { page: 1 }.error_code(),
1126            ErrorCode::Busy
1127        );
1128        assert_eq!(
1129            FrankenError::SnapshotTooOld { txn_id: 1 }.error_code(),
1130            ErrorCode::Busy
1131        );
1132        assert_eq!(
1133            FrankenError::LockFailed {
1134                detail: String::new()
1135            }
1136            .error_code(),
1137            ErrorCode::Busy
1138        );
1139
1140        // Type errors
1141        assert_eq!(
1142            FrankenError::TypeMismatch {
1143                expected: String::new(),
1144                actual: String::new()
1145            }
1146            .error_code(),
1147            ErrorCode::Mismatch
1148        );
1149        assert_eq!(FrankenError::IntegerOverflow.error_code(), ErrorCode::Range);
1150        assert_eq!(
1151            FrankenError::OutOfRange {
1152                what: String::new(),
1153                value: String::new()
1154            }
1155            .error_code(),
1156            ErrorCode::Range
1157        );
1158
1159        // Limit errors
1160        assert_eq!(
1161            FrankenError::TooManyColumns { count: 1, max: 1 }.error_code(),
1162            ErrorCode::Error
1163        );
1164        assert_eq!(
1165            FrankenError::SqlTooLong { length: 1, max: 1 }.error_code(),
1166            ErrorCode::Error
1167        );
1168        assert_eq!(
1169            FrankenError::ExpressionTooDeep { max: 1 }.error_code(),
1170            ErrorCode::Error
1171        );
1172        assert_eq!(
1173            FrankenError::TooManyAttached { max: 1 }.error_code(),
1174            ErrorCode::Error
1175        );
1176        assert_eq!(
1177            FrankenError::TooManyArguments {
1178                name: String::new()
1179            }
1180            .error_code(),
1181            ErrorCode::Error
1182        );
1183
1184        // WAL errors
1185        assert_eq!(
1186            FrankenError::WalCorrupt {
1187                detail: String::new()
1188            }
1189            .error_code(),
1190            ErrorCode::Corrupt
1191        );
1192        assert_eq!(
1193            FrankenError::CheckpointFailed {
1194                detail: String::new()
1195            }
1196            .error_code(),
1197            ErrorCode::IoErr
1198        );
1199
1200        // VFS errors
1201        assert_eq!(
1202            FrankenError::CannotOpen {
1203                path: PathBuf::new()
1204            }
1205            .error_code(),
1206            ErrorCode::CantOpen
1207        );
1208
1209        // Internal/misc errors
1210        assert_eq!(
1211            FrankenError::Internal(String::new()).error_code(),
1212            ErrorCode::Internal
1213        );
1214        assert_eq!(FrankenError::Unsupported.error_code(), ErrorCode::NoLfs);
1215        assert_eq!(FrankenError::Abort.error_code(), ErrorCode::Abort);
1216        assert_eq!(FrankenError::ReadOnly.error_code(), ErrorCode::ReadOnly);
1217        assert_eq!(
1218            FrankenError::FunctionError(String::new()).error_code(),
1219            ErrorCode::Error
1220        );
1221        assert_eq!(
1222            FrankenError::ConcurrentUnavailable.error_code(),
1223            ErrorCode::Error
1224        );
1225    }
1226
1227    #[test]
1228    fn is_user_recoverable_comprehensive() {
1229        // Recoverable
1230        assert!(
1231            FrankenError::DatabaseNotFound {
1232                path: PathBuf::new()
1233            }
1234            .is_user_recoverable()
1235        );
1236        assert!(
1237            FrankenError::DatabaseLocked {
1238                path: PathBuf::new()
1239            }
1240            .is_user_recoverable()
1241        );
1242        assert!(FrankenError::BusyRecovery.is_user_recoverable());
1243        assert!(FrankenError::Unsupported.is_user_recoverable());
1244        assert!(
1245            FrankenError::ParseError {
1246                offset: 0,
1247                detail: String::new()
1248            }
1249            .is_user_recoverable()
1250        );
1251        assert!(
1252            FrankenError::NoSuchTable {
1253                name: String::new()
1254            }
1255            .is_user_recoverable()
1256        );
1257        assert!(
1258            FrankenError::NoSuchColumn {
1259                name: String::new()
1260            }
1261            .is_user_recoverable()
1262        );
1263        assert!(
1264            FrankenError::TypeMismatch {
1265                expected: String::new(),
1266                actual: String::new()
1267            }
1268            .is_user_recoverable()
1269        );
1270        assert!(
1271            FrankenError::CannotOpen {
1272                path: PathBuf::new()
1273            }
1274            .is_user_recoverable()
1275        );
1276
1277        // Not recoverable
1278        assert!(
1279            !FrankenError::NotADatabase {
1280                path: PathBuf::new()
1281            }
1282            .is_user_recoverable()
1283        );
1284        assert!(!FrankenError::TooBig.is_user_recoverable());
1285        assert!(!FrankenError::OutOfMemory.is_user_recoverable());
1286        assert!(!FrankenError::WriteConflict { page: 1, holder: 1 }.is_user_recoverable());
1287        assert!(
1288            !FrankenError::UniqueViolation {
1289                columns: String::new()
1290            }
1291            .is_user_recoverable()
1292        );
1293        assert!(!FrankenError::ReadOnly.is_user_recoverable());
1294        assert!(!FrankenError::Abort.is_user_recoverable());
1295    }
1296
1297    #[test]
1298    fn is_transient_comprehensive() {
1299        // Transient
1300        assert!(
1301            FrankenError::DatabaseLocked {
1302                path: PathBuf::new()
1303            }
1304            .is_transient()
1305        );
1306        assert!(FrankenError::SerializationFailure { page: 1 }.is_transient());
1307
1308        // Not transient
1309        assert!(
1310            !FrankenError::DatabaseCorrupt {
1311                detail: String::new()
1312            }
1313            .is_transient()
1314        );
1315        assert!(
1316            !FrankenError::NotADatabase {
1317                path: PathBuf::new()
1318            }
1319            .is_transient()
1320        );
1321        assert!(!FrankenError::TooBig.is_transient());
1322        assert!(!FrankenError::Internal(String::new()).is_transient());
1323        assert!(!FrankenError::OutOfMemory.is_transient());
1324        assert!(
1325            !FrankenError::UniqueViolation {
1326                columns: String::new()
1327            }
1328            .is_transient()
1329        );
1330        assert!(!FrankenError::ReadOnly.is_transient());
1331        assert!(!FrankenError::ConcurrentUnavailable.is_transient());
1332    }
1333
1334    #[test]
1335    fn suggestion_comprehensive() {
1336        // Has suggestion
1337        assert!(
1338            FrankenError::DatabaseNotFound {
1339                path: PathBuf::new()
1340            }
1341            .suggestion()
1342            .is_some()
1343        );
1344        assert!(
1345            FrankenError::DatabaseLocked {
1346                path: PathBuf::new()
1347            }
1348            .suggestion()
1349            .is_some()
1350        );
1351        assert!(FrankenError::BusyRecovery.suggestion().is_some());
1352        assert!(
1353            FrankenError::WriteConflict { page: 1, holder: 1 }
1354                .suggestion()
1355                .is_some()
1356        );
1357        assert!(
1358            FrankenError::SerializationFailure { page: 1 }
1359                .suggestion()
1360                .is_some()
1361        );
1362        assert!(
1363            FrankenError::SnapshotTooOld { txn_id: 1 }
1364                .suggestion()
1365                .is_some()
1366        );
1367        assert!(
1368            FrankenError::DatabaseCorrupt {
1369                detail: String::new()
1370            }
1371            .suggestion()
1372            .is_some()
1373        );
1374        assert!(FrankenError::TooBig.suggestion().is_some());
1375        assert!(FrankenError::ConcurrentUnavailable.suggestion().is_some());
1376        assert!(FrankenError::QueryReturnedNoRows.suggestion().is_some());
1377        assert!(
1378            FrankenError::QueryReturnedMultipleRows
1379                .suggestion()
1380                .is_some()
1381        );
1382
1383        // No suggestion
1384        assert!(FrankenError::Abort.suggestion().is_none());
1385        assert!(FrankenError::AuthDenied.suggestion().is_none());
1386        assert!(FrankenError::OutOfMemory.suggestion().is_none());
1387        assert!(FrankenError::Internal(String::new()).suggestion().is_none());
1388        assert!(FrankenError::ReadOnly.suggestion().is_none());
1389    }
1390
1391    #[test]
1392    fn error_code_enum_repr_values() {
1393        // Verify all ErrorCode numeric values match C SQLite
1394        assert_eq!(ErrorCode::Internal as i32, 2);
1395        assert_eq!(ErrorCode::Perm as i32, 3);
1396        assert_eq!(ErrorCode::Abort as i32, 4);
1397        assert_eq!(ErrorCode::Locked as i32, 6);
1398        assert_eq!(ErrorCode::NoMem as i32, 7);
1399        assert_eq!(ErrorCode::ReadOnly as i32, 8);
1400        assert_eq!(ErrorCode::Interrupt as i32, 9);
1401        assert_eq!(ErrorCode::IoErr as i32, 10);
1402        assert_eq!(ErrorCode::Corrupt as i32, 11);
1403        assert_eq!(ErrorCode::NotFound as i32, 12);
1404        assert_eq!(ErrorCode::Full as i32, 13);
1405        assert_eq!(ErrorCode::CantOpen as i32, 14);
1406        assert_eq!(ErrorCode::Protocol as i32, 15);
1407        assert_eq!(ErrorCode::Empty as i32, 16);
1408        assert_eq!(ErrorCode::Schema as i32, 17);
1409        assert_eq!(ErrorCode::TooBig as i32, 18);
1410        assert_eq!(ErrorCode::Mismatch as i32, 20);
1411        assert_eq!(ErrorCode::Misuse as i32, 21);
1412        assert_eq!(ErrorCode::NoLfs as i32, 22);
1413        assert_eq!(ErrorCode::Auth as i32, 23);
1414        assert_eq!(ErrorCode::Format as i32, 24);
1415        assert_eq!(ErrorCode::Range as i32, 25);
1416        assert_eq!(ErrorCode::NotADb as i32, 26);
1417        assert_eq!(ErrorCode::Notice as i32, 27);
1418        assert_eq!(ErrorCode::Warning as i32, 28);
1419    }
1420
1421    #[test]
1422    fn error_code_clone_eq() {
1423        let code = ErrorCode::Busy;
1424        let cloned = code;
1425        assert_eq!(code, cloned);
1426        assert_eq!(code, ErrorCode::Busy);
1427        assert_ne!(code, ErrorCode::Error);
1428    }
1429
1430    #[test]
1431    fn function_error_constructor() {
1432        let err = FrankenError::function_error("division by zero");
1433        assert!(matches!(err, FrankenError::FunctionError(ref msg) if msg == "division by zero"));
1434        assert_eq!(err.error_code(), ErrorCode::Error);
1435    }
1436
1437    #[test]
1438    fn background_worker_failed_is_non_transient_generic_error() {
1439        let err = FrankenError::BackgroundWorkerFailed("gc worker panic".to_owned());
1440        assert_eq!(err.to_string(), "background worker failed: gc worker panic");
1441        assert_eq!(err.error_code(), ErrorCode::Error);
1442        assert!(!err.is_transient());
1443        assert!(!err.is_user_recoverable());
1444        assert!(err.suggestion().is_some());
1445    }
1446
1447    #[test]
1448    fn constraint_error_codes_all_variants() {
1449        assert_eq!(
1450            FrankenError::CheckViolation {
1451                name: "ck1".to_owned()
1452            }
1453            .error_code(),
1454            ErrorCode::Constraint
1455        );
1456        assert_eq!(
1457            FrankenError::PrimaryKeyViolation.error_code(),
1458            ErrorCode::Constraint
1459        );
1460        assert_eq!(
1461            FrankenError::CheckViolation {
1462                name: "ck1".to_owned()
1463            }
1464            .to_string(),
1465            "CHECK constraint failed: ck1"
1466        );
1467        assert_eq!(
1468            FrankenError::PrimaryKeyViolation.to_string(),
1469            "PRIMARY KEY constraint failed"
1470        );
1471
1472        let err = FrankenError::DatatypeViolation {
1473            column: "t1.name".to_owned(),
1474            column_type: "INTEGER".to_owned(),
1475            actual: "TEXT".to_owned(),
1476        };
1477        assert_eq!(
1478            err.to_string(),
1479            "cannot store TEXT value in INTEGER column t1.name"
1480        );
1481        assert_eq!(err.error_code(), ErrorCode::Constraint);
1482        assert_eq!(err.extended_error_code(), 3091);
1483    }
1484
1485    #[test]
1486    fn exit_code_matches_error_code() {
1487        // exit_code() should be the i32 repr of error_code()
1488        let cases: Vec<FrankenError> = vec![
1489            FrankenError::DatabaseFull,
1490            FrankenError::TooBig,
1491            FrankenError::OutOfMemory,
1492            FrankenError::AuthDenied,
1493            FrankenError::Abort,
1494            FrankenError::ReadOnly,
1495            FrankenError::Unsupported,
1496        ];
1497        for err in cases {
1498            assert_eq!(err.exit_code(), err.error_code() as i32);
1499        }
1500    }
1501}