Skip to main content

fsqlite_error/
lib.rs

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