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