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