Skip to main content

fsqlite_error/
lib.rs

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