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