Skip to main content

oxistore_core/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! `oxistore-core` — Pure Rust storage primitives for OxiStore.
5//!
6//! This crate provides the foundational traits and error types shared across
7//! all OxiStore backends. It is intentionally dependency-free.
8//!
9//! # Key Traits
10//!
11//! - [`KvStore`] — key-value store with reads, writes, range scans, transactions, and snapshots.
12//! - [`KvTxn`] — explicit write transaction (commit / rollback).
13//! - [`KvSnapshot`] — point-in-time read-only view.
14//! - [`ColumnarStore`] — stub for M2+ columnar storage.
15//! - [`BlobStore`] — stub for M4+ blob storage.
16
17use std::path::Path;
18use std::sync::Arc;
19use std::time::Duration;
20
21/// Typed KV adapter with configurable codec (available with `serde-typed` feature).
22#[cfg(feature = "serde-typed")]
23pub mod typed;
24
25#[cfg(feature = "serde-typed")]
26pub use typed::{JsonCodec, TypedCodec, TypedKvError, TypedKvStore};
27
28/// Errors that can be returned by any OxiStore backend.
29#[derive(Debug, Clone)]
30pub enum StoreError {
31    /// An I/O error occurred at the file-system level.
32    Io(Arc<std::io::Error>),
33    /// The database file is corrupt or in an unrecognized format.
34    Corruption(String),
35    /// The requested key was not found (used when absence is treated as error).
36    NotFound,
37    /// A key was inserted but already exists (reserved for unique-insert APIs).
38    AlreadyExists,
39    /// A write transaction conflicted with a concurrent transaction and must be retried.
40    TxnConflict,
41    /// The store is open in read-only mode and does not accept writes.
42    ReadOnly,
43    /// An operation timed out.
44    Timeout,
45    /// A bounded store or cache has exceeded its capacity limit.
46    CapacityExceeded,
47    /// A compare-and-swap operation failed because the expected value did not
48    /// match the current stored value.
49    CasMismatch,
50    /// The requested key was not found in the store.
51    KeyNotFound,
52    /// The operation is not supported by this backend or configuration.
53    Unsupported(String),
54    /// Any other backend-specific error.
55    Other(String),
56}
57
58impl std::fmt::Display for StoreError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            StoreError::Io(e) => write!(f, "I/O error: {e}"),
62            StoreError::Corruption(s) => write!(f, "corruption: {s}"),
63            StoreError::NotFound => write!(f, "not found"),
64            StoreError::AlreadyExists => write!(f, "already exists"),
65            StoreError::TxnConflict => write!(f, "transaction conflict"),
66            StoreError::ReadOnly => write!(f, "store is read-only"),
67            StoreError::Timeout => write!(f, "operation timed out"),
68            StoreError::CapacityExceeded => write!(f, "capacity exceeded"),
69            StoreError::CasMismatch => write!(f, "compare-and-swap mismatch"),
70            StoreError::KeyNotFound => write!(f, "key not found"),
71            StoreError::Unsupported(s) => write!(f, "unsupported: {s}"),
72            StoreError::Other(s) => write!(f, "error: {s}"),
73        }
74    }
75}
76
77impl std::error::Error for StoreError {}
78
79impl From<std::io::Error> for StoreError {
80    fn from(e: std::io::Error) -> Self {
81        StoreError::Io(Arc::new(e))
82    }
83}
84
85impl From<String> for StoreError {
86    fn from(s: String) -> Self {
87        StoreError::Other(s)
88    }
89}
90
91/// Compute the exclusive upper-bound key for a prefix scan.
92///
93/// Given a prefix like `b"foo"`, returns `Some(b"fop")` — the first key
94/// that would sort after all keys sharing the prefix.
95///
96/// Returns `None` if the prefix is empty or consists entirely of `0xFF`
97/// bytes (i.e. every key in the store matches the prefix).
98///
99/// # Examples
100///
101/// ```
102/// use oxistore_core::prefix_upper_bound;
103///
104/// assert_eq!(prefix_upper_bound(b"foo"), Some(b"fop".to_vec()));
105/// assert_eq!(prefix_upper_bound(b"ab\xff"), Some(b"ac".to_vec()));
106/// assert_eq!(prefix_upper_bound(b"\xff\xff"), None);
107/// assert_eq!(prefix_upper_bound(b""), None);
108/// ```
109pub fn prefix_upper_bound(prefix: &[u8]) -> Option<Vec<u8>> {
110    if prefix.is_empty() {
111        return None;
112    }
113    // Walk backwards to find the last byte that is not 0xFF.
114    let mut upper = prefix.to_vec();
115    while let Some(&last) = upper.last() {
116        if last == 0xFF {
117            upper.pop();
118        } else {
119            // Increment the last non-0xFF byte.
120            if let Some(b) = upper.last_mut() {
121                *b += 1;
122            }
123            return Some(upper);
124        }
125    }
126    // All bytes were 0xFF — no upper bound.
127    None
128}
129
130/// Encode a TTL as an expiry unix-epoch-milliseconds `u64`.
131///
132/// Adds `ttl` to the current [`std::time::SystemTime`] and returns the
133/// resulting point in time as milliseconds since the Unix epoch.
134///
135/// # Errors
136///
137/// Returns [`StoreError::Other`] if the system clock is before the Unix epoch.
138pub fn expiry_epoch_millis(ttl: Duration) -> Result<u64, StoreError> {
139    std::time::SystemTime::now()
140        .duration_since(std::time::UNIX_EPOCH)
141        .map_err(|e| StoreError::Other(e.to_string()))
142        .map(|d| d.checked_add(ttl).unwrap_or(d).as_millis() as u64)
143}
144
145/// Return `true` if an epoch-milliseconds timestamp is in the past.
146///
147/// A timestamp is considered expired when the current time equals or exceeds
148/// `expiry_millis`.
149#[must_use]
150pub fn is_expired(expiry_millis: u64) -> bool {
151    let now = std::time::SystemTime::now()
152        .duration_since(std::time::UNIX_EPOCH)
153        .map(|d| d.as_millis() as u64)
154        .unwrap_or(0);
155    expiry_millis <= now
156}
157
158/// Core key-value store trait.
159///
160/// All backend implementations (`redb`, `sled`, ...) implement this trait so that
161/// callers can depend only on `oxistore-core` and swap backends via the facade.
162///
163/// # Thread Safety
164///
165/// Implementations are required to be `Send + Sync`; interior mutability
166/// (e.g. via `Mutex`) is the backend's responsibility.
167pub trait KvStore: Send + Sync {
168    /// Retrieve the value associated with `key`, or `None` if it is absent.
169    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
170
171    /// Insert or overwrite a key-value pair.
172    fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StoreError>;
173
174    /// Remove a key.  No-op if the key is absent.
175    fn delete(&self, key: &[u8]) -> Result<(), StoreError>;
176
177    /// Retrieve values for multiple keys in a single call.
178    ///
179    /// Returns a `Vec` of `Option<Vec<u8>>` in the same order as `keys`.
180    /// The default implementation calls [`KvStore::get`] for each key
181    /// individually; backends with batch-read support should override for
182    /// better performance.
183    fn get_many(&self, keys: &[&[u8]]) -> Result<Vec<Option<Vec<u8>>>, StoreError> {
184        keys.iter().map(|k| self.get(k)).collect()
185    }
186
187    /// Retrieve a value as a [`std::borrow::Cow`], avoiding a clone when the
188    /// backend can return a borrowed slice.
189    ///
190    /// The default implementation calls [`KvStore::get`] and wraps the owned
191    /// `Vec<u8>` in `Cow::Owned`.  Backends that can return zero-copy
192    /// references should override this method.
193    fn get_ref<'a>(&'a self, key: &[u8]) -> Result<Option<std::borrow::Cow<'a, [u8]>>, StoreError> {
194        self.get(key).map(|opt| opt.map(std::borrow::Cow::Owned))
195    }
196
197    /// Return `true` if `key` is present in the store.
198    ///
199    /// Default implementation delegates to [`KvStore::get`].
200    fn contains(&self, key: &[u8]) -> Result<bool, StoreError> {
201        Ok(self.get(key)?.is_some())
202    }
203
204    /// Return all key-value pairs whose keys fall within `[lo, hi)`,
205    /// in ascending key order.
206    fn range<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError>;
207
208    /// Return all key-value pairs whose keys fall within `[lo, hi)`,
209    /// in **descending** key order.
210    ///
211    /// The default implementation delegates to [`KvStore::range`], collects the
212    /// results, and reverses the resulting `Vec`.  Backends that support native
213    /// reverse iteration should override for better performance.
214    fn range_rev<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError> {
215        let items: Vec<RangeItem> = self.range(lo, hi)?.collect();
216        Ok(Box::new(items.into_iter().rev()))
217    }
218
219    /// Iterate all key-value pairs sharing the given `prefix`, in ascending
220    /// key order.
221    ///
222    /// The default implementation computes the exclusive upper bound from the
223    /// prefix and delegates to [`KvStore::range`].  When the prefix is empty,
224    /// the full store is scanned via [`KvStore::iter`].  When the prefix
225    /// consists entirely of `0xFF` bytes (no upper bound exists), the result
226    /// is obtained via [`KvStore::iter`] filtered to keys that start with the
227    /// prefix.
228    fn prefix_scan<'a>(&'a self, prefix: &[u8]) -> Result<RangeIter<'a>, StoreError> {
229        if prefix.is_empty() {
230            return self.iter();
231        }
232        match prefix_upper_bound(prefix) {
233            Some(hi) => self.range(prefix, &hi),
234            None => {
235                // All-0xFF prefix: no upper bound can be computed.
236                // Collect from iter() and filter to keys that start with the prefix.
237                let prefix_owned = prefix.to_vec();
238                let items: Vec<RangeItem> = self
239                    .iter()?
240                    .filter(|r| {
241                        r.as_ref()
242                            .map(|(k, _)| k.starts_with(&prefix_owned))
243                            .unwrap_or(true) // propagate errors
244                    })
245                    .collect();
246                Ok(Box::new(items.into_iter()))
247            }
248        }
249    }
250
251    /// Insert multiple key-value pairs atomically in a single batch.
252    ///
253    /// The default implementation opens a transaction, inserts all pairs,
254    /// and commits.  Backends may override for better performance.
255    fn batch_write(&self, pairs: &[(&[u8], &[u8])]) -> Result<(), StoreError> {
256        let mut txn = self.transaction()?;
257        for &(k, v) in pairs {
258            txn.put(k, v)?;
259        }
260        txn.commit()
261    }
262
263    /// Delete multiple keys atomically in a single batch.
264    ///
265    /// The default implementation opens a transaction, deletes all keys,
266    /// and commits.  Backends may override for better performance.
267    fn batch_delete(&self, keys: &[&[u8]]) -> Result<(), StoreError> {
268        let mut txn = self.transaction()?;
269        for &k in keys {
270            txn.delete(k)?;
271        }
272        txn.commit()
273    }
274
275    /// Return the total number of keys in the store.
276    ///
277    /// The default implementation performs a full iteration and counts entries.
278    /// Backends that maintain key counts natively should override for O(1).
279    fn count(&self) -> Result<u64, StoreError> {
280        let mut n = 0u64;
281        for item in self.iter()? {
282            let _ = item?;
283            n += 1;
284        }
285        Ok(n)
286    }
287
288    /// Return the approximate byte size of the store on disk.
289    ///
290    /// The default implementation returns 0 (unknown).  Backends should
291    /// override if they can compute the on-disk size cheaply.
292    fn size_on_disk(&self) -> Result<u64, StoreError> {
293        Ok(0)
294    }
295
296    /// Iterate all key-value pairs in the store in ascending key order.
297    ///
298    /// This is a required method -- all backend implementations must provide
299    /// a full-store iteration.
300    fn iter<'a>(&'a self) -> Result<RangeIter<'a>, StoreError>;
301
302    /// Iterate all keys (without loading values) in ascending order.
303    ///
304    /// The default implementation wraps [`KvStore::iter`] and discards values.
305    /// Backends that can iterate keys without reading values should override.
306    fn keys<'a>(&'a self) -> Result<KeysIter<'a>, StoreError> {
307        let it = self.iter()?;
308        Ok(Box::new(it.map(|r| r.map(|(k, _v)| k))))
309    }
310
311    /// Atomic compare-and-swap: if the current value for `key` equals
312    /// `expected`, replace it with `new_value` and return `Ok(true)`.
313    /// If the current value does not match `expected`, return `Ok(false)`.
314    ///
315    /// `expected` is `None` for "key must not exist", `Some(v)` for
316    /// "key must hold value v".
317    ///
318    /// The default implementation uses a transaction for atomicity.
319    fn compare_and_swap(
320        &self,
321        key: &[u8],
322        expected: Option<&[u8]>,
323        new_value: &[u8],
324    ) -> Result<bool, StoreError> {
325        let mut txn = self.transaction()?;
326        let current = txn.get(key)?;
327        let matches = match (current.as_deref(), expected) {
328            (None, None) => true,
329            (Some(cur), Some(exp)) => cur == exp,
330            _ => false,
331        };
332        if matches {
333            txn.put(key, new_value)?;
334            txn.commit()?;
335            Ok(true)
336        } else {
337            txn.rollback()?;
338            Ok(false)
339        }
340    }
341
342    /// Insert a key-value pair with a time-to-live.  After `ttl` has elapsed,
343    /// the key is treated as absent (expired).
344    ///
345    /// Backends that support native TTL should override this method.
346    /// The default implementation returns [`StoreError::Unsupported`].
347    fn put_with_ttl(&self, _key: &[u8], _value: &[u8], _ttl: Duration) -> Result<(), StoreError> {
348        Err(StoreError::Unsupported("TTL not supported".to_string()))
349    }
350
351    /// Set a TTL on an existing key.  The key must already exist.
352    ///
353    /// After `ttl` has elapsed the key is treated as absent.
354    /// The default implementation returns [`StoreError::Unsupported`].
355    fn expire(&self, _key: &[u8], _ttl: Duration) -> Result<(), StoreError> {
356        Err(StoreError::Unsupported("TTL not supported".to_string()))
357    }
358
359    /// Return the remaining TTL for a key.
360    ///
361    /// Returns `Ok(None)` if the key exists but has no TTL attached.
362    /// Returns `Err(StoreError::KeyNotFound)` if the key does not exist.
363    /// Returns `Err(StoreError::Unsupported)` by default.
364    fn ttl(&self, _key: &[u8]) -> Result<Option<Duration>, StoreError> {
365        Err(StoreError::Unsupported("TTL not supported".to_string()))
366    }
367
368    /// Remove the TTL from a key, making it persistent.
369    ///
370    /// Returns `Ok(true)` if the key existed and its TTL was removed,
371    /// `Ok(false)` if the key exists but had no TTL.
372    /// Returns `Err(StoreError::KeyNotFound)` if the key does not exist.
373    /// The default implementation returns [`StoreError::Unsupported`].
374    fn persist(&self, _key: &[u8]) -> Result<bool, StoreError> {
375        Err(StoreError::Unsupported("TTL not supported".to_string()))
376    }
377
378    /// Scan and delete all expired keys eagerly.
379    ///
380    /// Returns the count of keys that were deleted.
381    /// The default implementation is a no-op returning `Ok(0)`.
382    fn purge_expired(&self) -> Result<u64, StoreError> {
383        Ok(0)
384    }
385
386    /// Trigger manual compaction on backends that support it.
387    ///
388    /// The default implementation is a no-op.
389    fn compact(&self) -> Result<(), StoreError> {
390        Ok(())
391    }
392
393    /// Create a point-in-time backup to the given path.
394    ///
395    /// The default implementation returns an error indicating backup is
396    /// not supported.  Backends should override if they support backup.
397    fn backup(&self, _path: &Path) -> Result<(), StoreError> {
398        Err(StoreError::Other(
399            "backup not supported for this backend".to_string(),
400        ))
401    }
402
403    /// Restore from a backup at the given path.
404    ///
405    /// The default implementation returns an error.  Backends should
406    /// override if they support restore.
407    fn restore(&self, _path: &Path) -> Result<(), StoreError> {
408        Err(StoreError::Other(
409            "restore not supported for this backend".to_string(),
410        ))
411    }
412
413    /// Begin an explicit write transaction.
414    ///
415    /// Changes made through [`KvTxn`] are only visible after [`KvTxn::commit`].
416    fn transaction(&self) -> Result<Box<dyn KvTxn + '_>, StoreError>;
417
418    /// Capture a point-in-time read-only snapshot of the store.
419    fn snapshot(&self) -> Result<Box<dyn KvSnapshot + '_>, StoreError>;
420
421    /// Ensure all committed data has been written to durable storage.
422    ///
423    /// The exact semantics depend on the backend; for backends that auto-flush
424    /// (e.g. redb commits), this is a no-op or an advisory hint.
425    fn flush(&self) -> Result<(), StoreError>;
426}
427
428/// An explicit write transaction obtained from [`KvStore::transaction`].
429///
430/// All mutations made through `KvTxn` are buffered until [`KvTxn::commit`] is
431/// called.  Dropping without committing has the same effect as
432/// [`KvTxn::rollback`].
433pub trait KvTxn {
434    /// Read a value from the store within this transaction's view.
435    ///
436    /// Implementations that support read-your-writes should return buffered
437    /// writes that have not yet been committed.
438    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
439
440    /// Stage a key-value insertion in the transaction.
441    fn put(&mut self, key: &[u8], value: &[u8]) -> Result<(), StoreError>;
442
443    /// Stage a key deletion in the transaction.
444    fn delete(&mut self, key: &[u8]) -> Result<(), StoreError>;
445
446    /// Check whether `key` exists within this transaction's view.
447    ///
448    /// Default implementation delegates to [`KvTxn::get`].
449    fn contains(&self, key: &[u8]) -> Result<bool, StoreError> {
450        Ok(self.get(key)?.is_some())
451    }
452
453    /// Range scan within the transaction's view.
454    ///
455    /// Implementations supporting read-your-writes should merge buffered
456    /// writes with committed data.  The default implementation returns an
457    /// error indicating range is not supported within transactions.
458    fn range<'a>(&'a self, _lo: &[u8], _hi: &[u8]) -> Result<RangeIter<'a>, StoreError> {
459        Err(StoreError::Other(
460            "range not supported within this transaction type".to_string(),
461        ))
462    }
463
464    /// Commit all staged changes atomically.
465    fn commit(self: Box<Self>) -> Result<(), StoreError>;
466
467    /// Discard all staged changes.
468    fn rollback(self: Box<Self>) -> Result<(), StoreError>;
469}
470
471/// A point-in-time read-only view of the store obtained from [`KvStore::snapshot`].
472pub trait KvSnapshot {
473    /// Read a value from the snapshot.
474    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
475
476    /// Return all key-value pairs whose keys fall within `[lo, hi)`,
477    /// in ascending key order.
478    fn range<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError>;
479
480    /// Return all key-value pairs sharing the given `prefix`, in ascending
481    /// key order.
482    ///
483    /// Default implementation uses [`prefix_upper_bound`] and delegates to
484    /// [`KvSnapshot::range`].
485    fn prefix_scan<'a>(&'a self, prefix: &[u8]) -> Result<RangeIter<'a>, StoreError> {
486        match prefix_upper_bound(prefix) {
487            Some(hi) => self.range(prefix, &hi),
488            None => {
489                // No upper bound — scan everything.
490                self.range(&[], &[])
491            }
492        }
493    }
494
495    /// Check whether `key` exists in the snapshot.
496    ///
497    /// Default implementation delegates to [`KvSnapshot::get`].
498    fn contains(&self, key: &[u8]) -> Result<bool, StoreError> {
499        Ok(self.get(key)?.is_some())
500    }
501}
502
503/// Stub trait for M2+ columnar store — defined here so facade re-exports remain stable.
504pub trait ColumnarStore: Send + Sync {}
505
506/// Stub trait for M4+ blob store — defined here so facade re-exports remain stable.
507pub trait BlobStore: Send + Sync {}
508
509/// Convenience alias: a heap-allocated [`KvStore`] with `'static` lifetime.
510///
511/// Returned by `oxistore::open`.
512pub type BoxKvStore = Box<dyn KvStore>;
513
514/// A single item produced by a range scan: a `(key, value)` pair or an error.
515pub type RangeItem = Result<(Vec<u8>, Vec<u8>), StoreError>;
516
517/// A boxed iterator over [`RangeItem`]s with a given lifetime.
518pub type RangeIter<'a> = Box<dyn Iterator<Item = RangeItem> + 'a>;
519
520/// A boxed iterator over keys (without values) with a given lifetime.
521///
522/// Used by [`KvStore::keys`].
523pub type KeysIter<'a> = Box<dyn Iterator<Item = Result<Vec<u8>, StoreError>> + 'a>;
524
525/// Backend-agnostic configuration for opening a store.
526///
527/// Each backend maps the fields it supports and ignores the rest.
528#[derive(Debug, Clone)]
529pub struct StoreConfig {
530    /// Block cache size in bytes (backend-specific interpretation).
531    pub cache_size_bytes: Option<u64>,
532    /// Whether to sync writes to disk on every commit.
533    pub sync_writes: bool,
534    /// Whether to open the store in read-only mode.
535    pub read_only: bool,
536}
537
538impl Default for StoreConfig {
539    fn default() -> Self {
540        StoreConfig {
541            cache_size_bytes: None,
542            sync_writes: true,
543            read_only: false,
544        }
545    }
546}
547
548/// Runtime statistics for a store (reads, writes, cache hits, etc.).
549#[derive(Debug, Clone, Default)]
550pub struct StoreMetrics {
551    /// Total number of `get` calls.
552    pub reads: u64,
553    /// Total number of `put` calls.
554    pub writes: u64,
555    /// Total number of `delete` calls.
556    pub deletes: u64,
557    /// Total bytes read.
558    pub bytes_read: u64,
559    /// Total bytes written.
560    pub bytes_written: u64,
561    /// Cache hit count (if a cache layer is in use).
562    pub cache_hits: u64,
563    /// Cache miss count (if a cache layer is in use).
564    pub cache_misses: u64,
565}
566
567impl StoreMetrics {
568    /// Compute the cache hit rate as a fraction (0.0 to 1.0).
569    ///
570    /// Returns 0.0 if no cache lookups have been performed.
571    #[must_use]
572    pub fn cache_hit_rate(&self) -> f64 {
573        let total = self.cache_hits + self.cache_misses;
574        if total == 0 {
575            0.0
576        } else {
577            self.cache_hits as f64 / total as f64
578        }
579    }
580}
581
582/// Ensure the path's parent directory exists, creating it if necessary.
583///
584/// This helper is used by backend `open` implementations to avoid confusing
585/// "file not found" errors when the parent directory does not exist.
586pub fn ensure_parent_dir(path: &Path) -> Result<(), StoreError> {
587    if let Some(parent) = path.parent() {
588        if !parent.as_os_str().is_empty() {
589            std::fs::create_dir_all(parent)?;
590        }
591    }
592    Ok(())
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn prefix_upper_bound_basic() {
601        assert_eq!(prefix_upper_bound(b"foo"), Some(b"fop".to_vec()));
602    }
603
604    #[test]
605    fn prefix_upper_bound_trailing_ff() {
606        assert_eq!(prefix_upper_bound(b"ab\xff"), Some(b"ac".to_vec()));
607    }
608
609    #[test]
610    fn prefix_upper_bound_all_ff() {
611        assert_eq!(prefix_upper_bound(b"\xff\xff"), None);
612    }
613
614    #[test]
615    fn prefix_upper_bound_empty() {
616        assert_eq!(prefix_upper_bound(b""), None);
617    }
618
619    #[test]
620    fn prefix_upper_bound_single_byte() {
621        assert_eq!(prefix_upper_bound(b"a"), Some(b"b".to_vec()));
622    }
623
624    #[test]
625    fn store_error_display() {
626        assert_eq!(format!("{}", StoreError::NotFound), "not found");
627        assert_eq!(format!("{}", StoreError::ReadOnly), "store is read-only");
628        assert_eq!(format!("{}", StoreError::Timeout), "operation timed out");
629        assert_eq!(
630            format!("{}", StoreError::CapacityExceeded),
631            "capacity exceeded"
632        );
633        assert_eq!(
634            format!("{}", StoreError::CasMismatch),
635            "compare-and-swap mismatch"
636        );
637    }
638
639    #[test]
640    fn store_error_from_string() {
641        let err: StoreError = "test error".to_string().into();
642        assert_eq!(format!("{err}"), "error: test error");
643    }
644
645    #[test]
646    fn store_config_default() {
647        let cfg = StoreConfig::default();
648        assert!(cfg.cache_size_bytes.is_none());
649        assert!(cfg.sync_writes);
650        assert!(!cfg.read_only);
651    }
652
653    #[test]
654    fn store_metrics_hit_rate() {
655        let m = StoreMetrics {
656            cache_hits: 80,
657            cache_misses: 20,
658            ..StoreMetrics::default()
659        };
660        assert!((m.cache_hit_rate() - 0.8).abs() < f64::EPSILON);
661    }
662
663    #[test]
664    fn store_metrics_hit_rate_zero() {
665        let m = StoreMetrics::default();
666        assert!((m.cache_hit_rate()).abs() < f64::EPSILON);
667    }
668
669    // ── core-clone-error ────────────────────────────────────────────────────
670
671    #[test]
672    fn store_error_clone_io() {
673        let original = StoreError::from(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
674        let cloned = original.clone();
675        if let StoreError::Io(arc) = cloned {
676            assert_eq!(arc.kind(), std::io::ErrorKind::NotFound);
677        } else {
678            panic!("expected StoreError::Io after clone");
679        }
680    }
681
682    #[test]
683    fn store_error_clone_non_io_variants() {
684        let variants = [
685            StoreError::NotFound,
686            StoreError::AlreadyExists,
687            StoreError::TxnConflict,
688            StoreError::ReadOnly,
689            StoreError::Timeout,
690            StoreError::CapacityExceeded,
691            StoreError::CasMismatch,
692            StoreError::KeyNotFound,
693            StoreError::Corruption("bad".to_string()),
694            StoreError::Unsupported("nope".to_string()),
695            StoreError::Other("misc".to_string()),
696        ];
697        for v in &variants {
698            let _ = v.clone(); // must not panic
699        }
700    }
701
702    // ── core-range-rev ──────────────────────────────────────────────────────
703
704    /// Minimal in-memory `KvStore` for unit-testing default-method behaviour.
705    struct MemKv(std::sync::Mutex<std::collections::BTreeMap<Vec<u8>, Vec<u8>>>);
706
707    impl MemKv {
708        fn new() -> Self {
709            MemKv(std::sync::Mutex::new(std::collections::BTreeMap::new()))
710        }
711    }
712
713    impl KvStore for MemKv {
714        fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError> {
715            Ok(self.0.lock().unwrap().get(key).cloned())
716        }
717
718        fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StoreError> {
719            self.0.lock().unwrap().insert(key.to_vec(), value.to_vec());
720            Ok(())
721        }
722
723        fn delete(&self, key: &[u8]) -> Result<(), StoreError> {
724            self.0.lock().unwrap().remove(key);
725            Ok(())
726        }
727
728        fn range<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError> {
729            use std::ops::Bound;
730            let map = self.0.lock().unwrap();
731            let pairs: Vec<RangeItem> = map
732                .range((Bound::Included(lo.to_vec()), Bound::Excluded(hi.to_vec())))
733                .map(|(k, v)| Ok((k.clone(), v.clone())))
734                .collect();
735            Ok(Box::new(pairs.into_iter()))
736        }
737
738        fn iter<'a>(&'a self) -> Result<RangeIter<'a>, StoreError> {
739            let map = self.0.lock().unwrap();
740            let pairs: Vec<RangeItem> = map
741                .iter()
742                .map(|(k, v)| Ok((k.clone(), v.clone())))
743                .collect();
744            Ok(Box::new(pairs.into_iter()))
745        }
746
747        fn transaction(&self) -> Result<Box<dyn KvTxn + '_>, StoreError> {
748            Err(StoreError::Unsupported("no txn in MemKv".to_string()))
749        }
750
751        fn snapshot(&self) -> Result<Box<dyn KvSnapshot + '_>, StoreError> {
752            Err(StoreError::Unsupported("no snapshot in MemKv".to_string()))
753        }
754
755        fn flush(&self) -> Result<(), StoreError> {
756            Ok(())
757        }
758    }
759
760    #[test]
761    fn range_rev_descending_order() {
762        let store = MemKv::new();
763        store.put(b"a", b"1").unwrap();
764        store.put(b"b", b"2").unwrap();
765        store.put(b"c", b"3").unwrap();
766        store.put(b"d", b"4").unwrap();
767
768        // range_rev over [a, e) should yield d, c, b, a in that order.
769        let items: Vec<(Vec<u8>, Vec<u8>)> = store
770            .range_rev(b"a", b"e")
771            .unwrap()
772            .map(|r| r.unwrap())
773            .collect();
774
775        let keys: Vec<&[u8]> = items.iter().map(|(k, _)| k.as_slice()).collect();
776        assert_eq!(keys, vec![b"d", b"c", b"b", b"a"]);
777    }
778
779    #[test]
780    fn range_rev_empty_range() {
781        let store = MemKv::new();
782        store.put(b"x", b"v").unwrap();
783
784        // [z, z) is empty — range_rev should return an empty iterator.
785        let items: Vec<_> = store.range_rev(b"z", b"z").unwrap().collect();
786        assert!(items.is_empty());
787    }
788
789    // ── ensure_parent_dir edge cases ────────────────────────────────────────
790
791    #[test]
792    fn ensure_parent_dir_empty_path() {
793        // Empty path has no parent — should succeed (no-op)
794        let result = ensure_parent_dir(std::path::Path::new("some_file.db"));
795        assert!(result.is_ok());
796    }
797
798    #[test]
799    fn ensure_parent_dir_nested() {
800        use std::process;
801        let tmp = std::env::temp_dir().join(format!("oxistore_ensure_parent_{}", process::id()));
802        let deep = tmp.join("a").join("b").join("file.db");
803        let result = ensure_parent_dir(&deep);
804        assert!(result.is_ok());
805        assert!(deep.parent().expect("has parent").exists());
806        let _ = std::fs::remove_dir_all(&tmp);
807    }
808
809    #[test]
810    fn ensure_parent_dir_already_exists() {
811        let tmp = std::env::temp_dir();
812        let path = tmp.join("existing_check.db");
813        // tmp already exists — should succeed without creating anything new
814        let result = ensure_parent_dir(&path);
815        assert!(result.is_ok());
816    }
817
818    // ── StoreError::from(io::Error) variants ───────────────────────────────
819
820    #[test]
821    fn store_error_from_io_error_variants() {
822        use std::io;
823        let kinds = [
824            io::ErrorKind::NotFound,
825            io::ErrorKind::PermissionDenied,
826            io::ErrorKind::AlreadyExists,
827            io::ErrorKind::WouldBlock,
828            io::ErrorKind::TimedOut,
829        ];
830        for kind in kinds {
831            let io_err = io::Error::new(kind, "test error");
832            let store_err: StoreError = io_err.into();
833            match &store_err {
834                StoreError::Io(arc) => assert_eq!(arc.kind(), kind),
835                other => panic!("expected StoreError::Io, got {other:?}"),
836            }
837        }
838    }
839}