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