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