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}