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