Skip to main content

pulsedb/
error.rs

1//! Error types for PulseDB.
2//!
3//! PulseDB uses a hierarchical error system:
4//! - `PulseDBError` is the top-level error returned by all public APIs
5//! - Specific error types (`StorageError`, `ValidationError`) provide detail
6//!
7//! # Error Handling Pattern
8//! ```rust
9//! use pulsedb::{PulseDB, Config, Result};
10//!
11//! fn example() -> Result<()> {
12//!     let dir = tempfile::tempdir().unwrap();
13//!     let db = PulseDB::open(dir.path().join("test.db"), Config::default())?;
14//!     // ... operations that may fail ...
15//!     db.close()?;
16//!     Ok(())
17//! }
18//! ```
19
20use std::path::PathBuf;
21use thiserror::Error;
22
23#[cfg(feature = "sync")]
24use crate::sync::SyncError;
25
26/// Result type alias for PulseDB operations.
27pub type Result<T> = std::result::Result<T, PulseDBError>;
28
29/// Top-level error enum for all PulseDB operations.
30///
31/// This is the only error type returned by public APIs.
32/// Use pattern matching to handle specific error cases.
33#[derive(Debug, Error)]
34pub enum PulseDBError {
35    /// Storage layer error (I/O, corruption, transactions).
36    #[error("Storage error: {0}")]
37    Storage(#[from] StorageError),
38
39    /// Input validation error.
40    #[error("Validation error: {0}")]
41    Validation(#[from] ValidationError),
42
43    /// Configuration error.
44    #[error("Configuration error: {reason}")]
45    Config {
46        /// Description of what's wrong with the configuration.
47        reason: String,
48    },
49
50    /// Requested entity not found.
51    #[error("{0}")]
52    NotFound(#[from] NotFoundError),
53
54    /// General I/O error.
55    #[error("I/O error: {0}")]
56    Io(#[from] std::io::Error),
57
58    /// Embedding generation/validation error.
59    #[error("Embedding error: {0}")]
60    Embedding(String),
61
62    /// Vector index error (HNSW operations).
63    #[error("Vector index error: {0}")]
64    Vector(String),
65
66    /// Watch system error (subscription or event delivery).
67    #[error("Watch error: {0}")]
68    Watch(String),
69
70    /// Internal error (e.g., async runtime failure, task join error).
71    #[error("Internal error: {0}")]
72    Internal(String),
73
74    /// Database is in read-only mode.
75    ///
76    /// Returned when a mutation method is called on a database opened
77    /// with `Config::read_only()`.
78    #[error("Database is in read-only mode")]
79    ReadOnly,
80
81    /// Sync protocol error.
82    ///
83    /// Only available when the `sync` feature is enabled.
84    #[cfg(feature = "sync")]
85    #[cfg_attr(docsrs, doc(cfg(feature = "sync")))]
86    #[error("Sync error: {0}")]
87    Sync(#[from] SyncError),
88}
89
90impl PulseDBError {
91    /// Creates a configuration error with the given reason.
92    pub fn config(reason: impl Into<String>) -> Self {
93        Self::Config {
94            reason: reason.into(),
95        }
96    }
97
98    /// Creates an embedding error with the given message.
99    pub fn embedding(msg: impl Into<String>) -> Self {
100        Self::Embedding(msg.into())
101    }
102
103    /// Creates a vector index error with the given message.
104    pub fn vector(msg: impl Into<String>) -> Self {
105        Self::Vector(msg.into())
106    }
107
108    /// Creates a watch system error with the given message.
109    pub fn watch(msg: impl Into<String>) -> Self {
110        Self::Watch(msg.into())
111    }
112
113    /// Creates an internal error with the given message.
114    pub fn internal(msg: impl Into<String>) -> Self {
115        Self::Internal(msg.into())
116    }
117
118    /// Returns true if this is a "not found" error.
119    pub fn is_not_found(&self) -> bool {
120        matches!(self, Self::NotFound(_))
121    }
122
123    /// Returns true if this is a validation error.
124    pub fn is_validation(&self) -> bool {
125        matches!(self, Self::Validation(_))
126    }
127
128    /// Returns true if this is a storage error.
129    pub fn is_storage(&self) -> bool {
130        matches!(self, Self::Storage(_))
131    }
132
133    /// Returns true if this is a vector index error.
134    pub fn is_vector(&self) -> bool {
135        matches!(self, Self::Vector(_))
136    }
137
138    /// Returns true if this is a watch system error.
139    pub fn is_watch(&self) -> bool {
140        matches!(self, Self::Watch(_))
141    }
142
143    /// Returns true if this is an embedding error.
144    pub fn is_embedding(&self) -> bool {
145        matches!(self, Self::Embedding(_))
146    }
147
148    /// Returns true if this is an internal error.
149    pub fn is_internal(&self) -> bool {
150        matches!(self, Self::Internal(_))
151    }
152
153    /// Returns true if this is a configuration error.
154    pub fn is_config(&self) -> bool {
155        matches!(self, Self::Config { .. })
156    }
157
158    /// Returns true if this is an I/O error.
159    pub fn is_io(&self) -> bool {
160        matches!(self, Self::Io(_))
161    }
162
163    /// Returns true if this is a read-only error.
164    pub fn is_read_only(&self) -> bool {
165        matches!(self, Self::ReadOnly)
166    }
167
168    /// Returns true if this is a sync error.
169    ///
170    /// Only available when the `sync` feature is enabled.
171    #[cfg(feature = "sync")]
172    #[cfg_attr(docsrs, doc(cfg(feature = "sync")))]
173    pub fn is_sync(&self) -> bool {
174        matches!(self, Self::Sync(_))
175    }
176}
177
178/// Storage-related errors.
179///
180/// These errors indicate problems with the underlying storage layer.
181#[derive(Debug, Error)]
182pub enum StorageError {
183    /// Database file or data is corrupted.
184    #[error("Database corrupted: {0}")]
185    Corrupted(String),
186
187    /// Database file not found at expected path.
188    #[error("Database not found: {0}")]
189    DatabaseNotFound(PathBuf),
190
191    /// Database is locked by another process.
192    #[error("Database is locked by another writer")]
193    DatabaseLocked,
194
195    /// Transaction failed (commit, rollback, etc.).
196    #[error("Transaction failed: {0}")]
197    Transaction(String),
198
199    /// Serialization/deserialization error.
200    #[error("Serialization error: {0}")]
201    Serialization(String),
202
203    /// Error from the redb storage engine.
204    #[error("Storage engine error: {0}")]
205    Redb(String),
206
207    /// Database schema version doesn't match expected version.
208    #[error("Schema version mismatch: expected {expected}, found {found}")]
209    SchemaVersionMismatch {
210        /// Expected schema version.
211        expected: u32,
212        /// Actual schema version found in database.
213        found: u32,
214    },
215
216    /// Table not found in database.
217    #[error("Table not found: {0}")]
218    TableNotFound(String),
219
220    /// The database was written by an **older** substrate format and must be
221    /// migrated before it can be opened.
222    ///
223    /// The substrate format is the storage-substrate axis (redb file format +
224    /// value serializer), distinct from the logical `schema_version`. This is the
225    /// *typed, actionable* signal a guided migration keys off — it must never be a
226    /// raw redb `UpgradeRequired` or a bincode decode panic leaking through.
227    ///
228    /// A **writable** open of such a store migrates automatically (the migration
229    /// gate is wired in a later work item); this error is surfaced only when
230    /// migration cannot proceed (e.g. a read-only open).
231    #[error(
232        "Database substrate format {found} is older than the current format {current}; \
233         open the database writable once to migrate it (read-only opens of an \
234         un-migrated store cannot upgrade)"
235    )]
236    SubstrateUpgradeRequired {
237        /// Substrate format found in the database (0 = pre-4.0 / bincode era).
238        found: u8,
239        /// Current substrate format this build writes and reads.
240        current: u8,
241    },
242
243    /// The database was written by a **newer** PulseDB whose substrate format is
244    /// ahead of this build — a forward-incompatibility.
245    ///
246    /// This build must not touch the file: doing so risks silent corruption.
247    /// Upgrade PulseDB to a version that understands substrate format `found`.
248    #[error(
249        "Database substrate format {found} is newer than this build's format {current}; \
250         upgrade PulseDB to open this database (do not modify it with an older build)"
251    )]
252    SubstrateFormatTooNew {
253        /// Substrate format found in the database.
254        found: u8,
255        /// Current substrate format this build supports.
256        current: u8,
257    },
258
259    /// The bincode→postcard codec migration cannot run as a single transaction
260    /// because the store is larger than the safe single-txn ceiling, and no
261    /// declared available-memory budget (or a too-small one) authorizes it.
262    ///
263    /// This is the **fail-closed-above-floor** valve (VS-4.0.3 work-1.04 §6.4):
264    /// rather than risk an OOM mid-migration (which would leave the migration
265    /// unfinishable) or ship a half-correct phased path, the codec pass refuses
266    /// with **zero destructive writes**. The caller can either declare an
267    /// available-memory budget via `Config` to raise the single-txn ceiling, or
268    /// use the offline `pulsedb migrate` tool (VS-4.0.4) for very large stores.
269    ///
270    /// `store_size` is the on-disk file size at open; `projected_peak` is the
271    /// conservative peak-RSS estimate (`store_size × coefficient`); `budget` is
272    /// the single-txn ceiling that was exceeded.
273    #[error(
274        "store too large for a single-transaction codec migration: store size {store_size} bytes \
275         (projected peak ~{projected_peak} bytes) exceeds the single-txn budget of {budget} bytes; \
276         declare available memory via Config to raise the ceiling, or run the offline migration tool"
277    )]
278    SubstrateMigrationTooLarge {
279        /// On-disk redb file size at open, in bytes.
280        store_size: u64,
281        /// Conservative peak-RSS estimate for a single-txn re-encode, in bytes.
282        projected_peak: u64,
283        /// The single-txn budget (ceiling) that `projected_peak` exceeded, in bytes.
284        budget: u64,
285    },
286
287    /// The bincode→postcard codec migration cannot run because there is not enough
288    /// free disk space to hold the pristine `.pre-substrate.bak` backup plus the
289    /// migrated file plus a redb transaction-growth margin.
290    ///
291    /// This is the **disk axis** of the unified headroom preflight (VS-4.0.3
292    /// work-1.05 / audit C3), the companion of [`Self::SubstrateMigrationTooLarge`]
293    /// (the memory axis). The pristine backup taken before any destructive write
294    /// (`.pre-substrate.bak`) roughly **doubles** the on-disk footprint, and the
295    /// postcard re-encode does not shrink the size-dominant `Vec<f32>` embedding
296    /// table, so the migrated file is conservatively assumed to be ~1× the store
297    /// size. The preflight fails here with **zero destructive writes** (no
298    /// half-migration that runs the disk out mid-pass) rather than risk an
299    /// unfinishable migration. Free up disk, or run the offline `pulsedb migrate`
300    /// tool (VS-4.0.4) for very large stores.
301    ///
302    /// `store_size` is the on-disk file size at open; `required` is the conservative
303    /// free-space estimate (`backup ≈ store_size` + migrated ≈ store_size + redb
304    /// txn-growth margin); `available` is the free space observed on the store's
305    /// filesystem at open.
306    #[error(
307        "insufficient free disk space for the codec migration: store size {store_size} bytes \
308         needs ~{required} bytes free (pristine backup + migrated file + transaction margin) \
309         but only {available} bytes are available; free up disk or run the offline migration tool"
310    )]
311    SubstrateMigrationInsufficientDisk {
312        /// On-disk redb file size at open, in bytes.
313        store_size: u64,
314        /// Conservative free-space estimate the migration needs, in bytes.
315        required: u64,
316        /// Free space observed on the store's filesystem at open, in bytes.
317        available: u64,
318    },
319
320    /// The bincode→postcard codec migration was attempted by a build **without** the
321    /// `sync` feature, but the database contains `sync_cursors` rows written by a
322    /// prior sync-enabled build. Those rows can only be re-encoded by a build that
323    /// knows the `SyncCursor` type, so completing the migration here would leave them
324    /// bincode-encoded under a postcard marker — silent corruption a later sync build
325    /// would hit reading them as postcard. The migration fails closed with **no marker
326    /// bump** (the store stays re-migratable) so a `sync`-enabled build can finish it.
327    #[error(
328        "cannot migrate this database's sync state without the `sync` feature: it \
329         contains sync_cursors rows that require a sync-enabled PulseDB build to \
330         re-encode; rebuild or run PulseDB with the `sync` feature to migrate it"
331    )]
332    SubstrateMigrationRequiresSync,
333}
334
335impl StorageError {
336    /// Creates a corruption error with the given message.
337    pub fn corrupted(msg: impl Into<String>) -> Self {
338        Self::Corrupted(msg.into())
339    }
340
341    /// Creates a transaction error with the given message.
342    pub fn transaction(msg: impl Into<String>) -> Self {
343        Self::Transaction(msg.into())
344    }
345
346    /// Creates a serialization error with the given message.
347    pub fn serialization(msg: impl Into<String>) -> Self {
348        Self::Serialization(msg.into())
349    }
350
351    /// Creates a redb error with the given message.
352    pub fn redb(msg: impl Into<String>) -> Self {
353        Self::Redb(msg.into())
354    }
355
356    /// Creates a substrate-upgrade-required error.
357    ///
358    /// `found` is the substrate format stored in the database (0 = pre-4.0
359    /// bincode era), `current` is the format this build writes.
360    pub fn substrate_upgrade_required(found: u8, current: u8) -> Self {
361        Self::SubstrateUpgradeRequired { found, current }
362    }
363
364    /// Creates a substrate-format-too-new error (forward-incompatibility).
365    pub fn substrate_format_too_new(found: u8, current: u8) -> Self {
366        Self::SubstrateFormatTooNew { found, current }
367    }
368
369    /// Creates a substrate-migration-too-large error (single-txn ceiling exceeded).
370    pub fn substrate_migration_too_large(
371        store_size: u64,
372        projected_peak: u64,
373        budget: u64,
374    ) -> Self {
375        Self::SubstrateMigrationTooLarge {
376            store_size,
377            projected_peak,
378            budget,
379        }
380    }
381
382    /// Creates a substrate-migration-insufficient-disk error (disk-headroom axis).
383    pub fn substrate_migration_insufficient_disk(
384        store_size: u64,
385        required: u64,
386        available: u64,
387    ) -> Self {
388        Self::SubstrateMigrationInsufficientDisk {
389            store_size,
390            required,
391            available,
392        }
393    }
394}
395
396// Conversions from redb error types
397impl From<redb::Error> for StorageError {
398    fn from(err: redb::Error) -> Self {
399        StorageError::Redb(err.to_string())
400    }
401}
402
403impl From<redb::DatabaseError> for StorageError {
404    fn from(err: redb::DatabaseError) -> Self {
405        StorageError::Redb(err.to_string())
406    }
407}
408
409impl From<redb::TransactionError> for StorageError {
410    fn from(err: redb::TransactionError) -> Self {
411        StorageError::Transaction(err.to_string())
412    }
413}
414
415impl From<redb::CommitError> for StorageError {
416    fn from(err: redb::CommitError) -> Self {
417        StorageError::Transaction(format!("Commit failed: {}", err))
418    }
419}
420
421impl From<redb::TableError> for StorageError {
422    fn from(err: redb::TableError) -> Self {
423        StorageError::Redb(format!("Table error: {}", err))
424    }
425}
426
427impl From<redb::StorageError> for StorageError {
428    fn from(err: redb::StorageError) -> Self {
429        StorageError::Redb(format!("Storage error: {}", err))
430    }
431}
432
433// Convert postcard errors to StorageError
434impl From<postcard::Error> for StorageError {
435    fn from(err: postcard::Error) -> Self {
436        StorageError::Serialization(err.to_string())
437    }
438}
439
440// Also allow direct conversion to PulseDBError for convenience
441impl From<redb::Error> for PulseDBError {
442    fn from(err: redb::Error) -> Self {
443        PulseDBError::Storage(StorageError::from(err))
444    }
445}
446
447impl From<redb::DatabaseError> for PulseDBError {
448    fn from(err: redb::DatabaseError) -> Self {
449        PulseDBError::Storage(StorageError::from(err))
450    }
451}
452
453impl From<redb::TransactionError> for PulseDBError {
454    fn from(err: redb::TransactionError) -> Self {
455        PulseDBError::Storage(StorageError::from(err))
456    }
457}
458
459impl From<redb::CommitError> for PulseDBError {
460    fn from(err: redb::CommitError) -> Self {
461        PulseDBError::Storage(StorageError::from(err))
462    }
463}
464
465impl From<redb::TableError> for PulseDBError {
466    fn from(err: redb::TableError) -> Self {
467        PulseDBError::Storage(StorageError::from(err))
468    }
469}
470
471impl From<redb::StorageError> for PulseDBError {
472    fn from(err: redb::StorageError) -> Self {
473        PulseDBError::Storage(StorageError::from(err))
474    }
475}
476
477impl From<postcard::Error> for PulseDBError {
478    fn from(err: postcard::Error) -> Self {
479        PulseDBError::Storage(StorageError::from(err))
480    }
481}
482
483/// Validation errors for input data.
484///
485/// These errors indicate problems with data provided by the caller.
486#[derive(Debug, Error)]
487pub enum ValidationError {
488    /// Embedding dimension doesn't match collective's configured dimension.
489    #[error("Embedding dimension mismatch: expected {expected}, got {got}")]
490    DimensionMismatch {
491        /// Expected dimension from collective configuration.
492        expected: usize,
493        /// Actual dimension provided.
494        got: usize,
495    },
496
497    /// A field has an invalid value.
498    #[error("Invalid field '{field}': {reason}")]
499    InvalidField {
500        /// Name of the invalid field.
501        field: String,
502        /// Why the value is invalid.
503        reason: String,
504    },
505
506    /// Content exceeds maximum allowed size.
507    #[error("Content too large: {size} bytes (max: {max} bytes)")]
508    ContentTooLarge {
509        /// Actual content size in bytes.
510        size: usize,
511        /// Maximum allowed size in bytes.
512        max: usize,
513    },
514
515    /// A required field is missing or empty.
516    #[error("Required field missing: {field}")]
517    RequiredField {
518        /// Name of the missing field.
519        field: String,
520    },
521
522    /// Too many items in a collection field.
523    #[error("Too many items in '{field}': {count} (max: {max})")]
524    TooManyItems {
525        /// Name of the field.
526        field: String,
527        /// Actual count.
528        count: usize,
529        /// Maximum allowed.
530        max: usize,
531    },
532}
533
534impl ValidationError {
535    /// Creates a dimension mismatch error.
536    pub fn dimension_mismatch(expected: usize, got: usize) -> Self {
537        Self::DimensionMismatch { expected, got }
538    }
539
540    /// Creates an invalid field error.
541    pub fn invalid_field(field: impl Into<String>, reason: impl Into<String>) -> Self {
542        Self::InvalidField {
543            field: field.into(),
544            reason: reason.into(),
545        }
546    }
547
548    /// Creates a content too large error.
549    pub fn content_too_large(size: usize, max: usize) -> Self {
550        Self::ContentTooLarge { size, max }
551    }
552
553    /// Creates a required field error.
554    pub fn required_field(field: impl Into<String>) -> Self {
555        Self::RequiredField {
556            field: field.into(),
557        }
558    }
559
560    /// Creates a too many items error.
561    pub fn too_many_items(field: impl Into<String>, count: usize, max: usize) -> Self {
562        Self::TooManyItems {
563            field: field.into(),
564            count,
565            max,
566        }
567    }
568}
569
570/// Not found errors for specific entity types.
571#[derive(Debug, Error)]
572pub enum NotFoundError {
573    /// Collective with given ID not found.
574    #[error("Collective not found: {0}")]
575    Collective(String),
576
577    /// Experience with given ID not found.
578    #[error("Experience not found: {0}")]
579    Experience(String),
580
581    /// Relation with given ID not found.
582    #[error("Relation not found: {0}")]
583    Relation(String),
584
585    /// Insight with given ID not found.
586    #[error("Insight not found: {0}")]
587    Insight(String),
588
589    /// Activity not found for given agent/collective pair.
590    #[error("Activity not found: {0}")]
591    Activity(String),
592}
593
594impl NotFoundError {
595    /// Creates a collective not found error.
596    pub fn collective(id: impl ToString) -> Self {
597        Self::Collective(id.to_string())
598    }
599
600    /// Creates an experience not found error.
601    pub fn experience(id: impl ToString) -> Self {
602        Self::Experience(id.to_string())
603    }
604
605    /// Creates a relation not found error.
606    pub fn relation(id: impl ToString) -> Self {
607        Self::Relation(id.to_string())
608    }
609
610    /// Creates an insight not found error.
611    pub fn insight(id: impl ToString) -> Self {
612        Self::Insight(id.to_string())
613    }
614
615    /// Creates an activity not found error.
616    pub fn activity(id: impl ToString) -> Self {
617        Self::Activity(id.to_string())
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_error_display() {
627        let err = PulseDBError::config("Invalid dimension");
628        assert_eq!(err.to_string(), "Configuration error: Invalid dimension");
629    }
630
631    #[test]
632    fn test_storage_error_display() {
633        let err = StorageError::SchemaVersionMismatch {
634            expected: 2,
635            found: 1,
636        };
637        assert_eq!(
638            err.to_string(),
639            "Schema version mismatch: expected 2, found 1"
640        );
641    }
642
643    #[test]
644    fn test_validation_error_display() {
645        let err = ValidationError::dimension_mismatch(384, 768);
646        assert_eq!(
647            err.to_string(),
648            "Embedding dimension mismatch: expected 384, got 768"
649        );
650    }
651
652    #[test]
653    fn test_not_found_error_display() {
654        let err = NotFoundError::collective("abc-123");
655        assert_eq!(err.to_string(), "Collective not found: abc-123");
656    }
657
658    #[test]
659    fn test_is_not_found() {
660        let err: PulseDBError = NotFoundError::collective("test").into();
661        assert!(err.is_not_found());
662        assert!(!err.is_validation());
663    }
664
665    #[test]
666    fn test_is_validation() {
667        let err: PulseDBError = ValidationError::required_field("content").into();
668        assert!(err.is_validation());
669        assert!(!err.is_not_found());
670    }
671
672    #[test]
673    fn test_vector_error_display() {
674        let err = PulseDBError::vector("HNSW insert failed");
675        assert_eq!(err.to_string(), "Vector index error: HNSW insert failed");
676        assert!(err.is_vector());
677        assert!(!err.is_storage());
678    }
679
680    #[test]
681    fn test_error_conversion_chain() {
682        // Simulate a storage error propagating up
683        fn inner() -> Result<()> {
684            Err(StorageError::corrupted("test corruption"))?
685        }
686
687        let result = inner();
688        assert!(result.is_err());
689        assert!(result.unwrap_err().is_storage());
690    }
691
692    #[test]
693    fn test_watch_error_display() {
694        let err = PulseDBError::watch("subscribers lock poisoned");
695        assert_eq!(err.to_string(), "Watch error: subscribers lock poisoned");
696    }
697
698    #[test]
699    fn test_watch_constructor() {
700        let err = PulseDBError::watch("test");
701        assert!(err.is_watch());
702        assert!(!err.is_storage());
703    }
704
705    #[test]
706    fn test_is_watch() {
707        let err = PulseDBError::watch("test");
708        assert!(err.is_watch());
709        assert!(!err.is_not_found());
710    }
711
712    #[test]
713    fn test_is_embedding() {
714        let err = PulseDBError::embedding("model load failed");
715        assert!(err.is_embedding());
716        assert!(!err.is_vector());
717    }
718
719    #[test]
720    fn test_is_internal() {
721        let err = PulseDBError::internal("task join failed");
722        assert!(err.is_internal());
723        assert!(!err.is_storage());
724    }
725
726    #[test]
727    fn test_is_config() {
728        let err = PulseDBError::config("invalid dimension");
729        assert!(err.is_config());
730        assert!(!err.is_validation());
731    }
732
733    #[test]
734    fn test_is_io() {
735        let err = PulseDBError::Io(std::io::Error::new(
736            std::io::ErrorKind::NotFound,
737            "file missing",
738        ));
739        assert!(err.is_io());
740        assert!(!err.is_storage());
741    }
742
743    #[test]
744    fn test_substrate_upgrade_required_display_is_actionable() {
745        let err = StorageError::substrate_upgrade_required(0, 1);
746        assert!(matches!(
747            err,
748            StorageError::SubstrateUpgradeRequired {
749                found: 0,
750                current: 1
751            }
752        ));
753        let msg = err.to_string();
754        // Names both versions and tells the operator HOW to recover.
755        assert!(msg.contains("substrate format 0"), "msg: {msg}");
756        assert!(msg.contains("current format 1"), "msg: {msg}");
757        assert!(msg.contains("writable"), "msg: {msg}");
758    }
759
760    #[test]
761    fn test_substrate_format_too_new_display_is_actionable() {
762        let err = StorageError::substrate_format_too_new(7, 1);
763        assert!(matches!(
764            err,
765            StorageError::SubstrateFormatTooNew {
766                found: 7,
767                current: 1
768            }
769        ));
770        let msg = err.to_string();
771        assert!(msg.contains("substrate format 7"), "msg: {msg}");
772        assert!(msg.contains("format 1"), "msg: {msg}");
773        // Tells the operator to upgrade rather than touch the file.
774        assert!(msg.contains("upgrade PulseDB"), "msg: {msg}");
775    }
776
777    #[test]
778    fn test_substrate_errors_propagate_as_storage() {
779        let err: PulseDBError = StorageError::substrate_upgrade_required(0, 1).into();
780        assert!(err.is_storage());
781        let err: PulseDBError = StorageError::substrate_format_too_new(2, 1).into();
782        assert!(err.is_storage());
783    }
784}