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.
167///
168/// # Construction convention (`open_or_create`)
169///
170/// Each backend is expected to provide a method with the following signature
171/// convention (though it is not enforced by this trait because associated
172/// functions cannot be used through trait objects):
173///
174/// ```text
175/// impl BackendStore {
176/// pub fn open(path: impl AsRef<Path>) -> Result<Self, BackendError>;
177/// pub fn open_in_memory() -> Result<Self, BackendError>; // for tests
178/// }
179/// ```
180///
181/// Use the `oxistore` facade's `open` and `open_in_memory` functions
182/// for backend-agnostic construction.
183pub trait KvStore: Send + Sync {
184 /// Retrieve the value associated with `key`, or `None` if it is absent.
185 fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
186
187 /// Insert or overwrite a key-value pair.
188 fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StoreError>;
189
190 /// Remove a key. No-op if the key is absent.
191 fn delete(&self, key: &[u8]) -> Result<(), StoreError>;
192
193 /// Retrieve values for multiple keys in a single call.
194 ///
195 /// Returns a `Vec` of `Option<Vec<u8>>` in the same order as `keys`.
196 /// The default implementation calls [`KvStore::get`] for each key
197 /// individually; backends with batch-read support should override for
198 /// better performance.
199 fn get_many(&self, keys: &[&[u8]]) -> Result<Vec<Option<Vec<u8>>>, StoreError> {
200 keys.iter().map(|k| self.get(k)).collect()
201 }
202
203 /// Retrieve a value as a [`std::borrow::Cow`], avoiding a clone when the
204 /// backend can return a borrowed slice.
205 ///
206 /// The default implementation calls [`KvStore::get`] and wraps the owned
207 /// `Vec<u8>` in `Cow::Owned`. Backends that can return zero-copy
208 /// references should override this method.
209 fn get_ref<'a>(&'a self, key: &[u8]) -> Result<Option<std::borrow::Cow<'a, [u8]>>, StoreError> {
210 self.get(key).map(|opt| opt.map(std::borrow::Cow::Owned))
211 }
212
213 /// Return `true` if `key` is present in the store.
214 ///
215 /// Default implementation delegates to [`KvStore::get`].
216 fn contains(&self, key: &[u8]) -> Result<bool, StoreError> {
217 Ok(self.get(key)?.is_some())
218 }
219
220 /// Return all key-value pairs whose keys fall within `[lo, hi)`,
221 /// in ascending key order.
222 fn range<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError>;
223
224 /// Return all key-value pairs whose keys fall within `[lo, hi)`,
225 /// in **descending** key order.
226 ///
227 /// The default implementation delegates to [`KvStore::range`], collects the
228 /// results, and reverses the resulting `Vec`. Backends that support native
229 /// reverse iteration should override for better performance.
230 fn range_rev<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError> {
231 let items: Vec<RangeItem> = self.range(lo, hi)?.collect();
232 Ok(Box::new(items.into_iter().rev()))
233 }
234
235 /// Iterate all key-value pairs sharing the given `prefix`, in ascending
236 /// key order.
237 ///
238 /// The default implementation computes the exclusive upper bound from the
239 /// prefix and delegates to [`KvStore::range`]. When the prefix is empty,
240 /// the full store is scanned via [`KvStore::iter`]. When the prefix
241 /// consists entirely of `0xFF` bytes (no upper bound exists), the result
242 /// is obtained via [`KvStore::iter`] filtered to keys that start with the
243 /// prefix.
244 fn prefix_scan<'a>(&'a self, prefix: &[u8]) -> Result<RangeIter<'a>, StoreError> {
245 if prefix.is_empty() {
246 return self.iter();
247 }
248 match prefix_upper_bound(prefix) {
249 Some(hi) => self.range(prefix, &hi),
250 None => {
251 // All-0xFF prefix: no upper bound can be computed.
252 // Collect from iter() and filter to keys that start with the prefix.
253 let prefix_owned = prefix.to_vec();
254 let items: Vec<RangeItem> = self
255 .iter()?
256 .filter(|r| {
257 r.as_ref()
258 .map(|(k, _)| k.starts_with(&prefix_owned))
259 .unwrap_or(true) // propagate errors
260 })
261 .collect();
262 Ok(Box::new(items.into_iter()))
263 }
264 }
265 }
266
267 /// Insert multiple key-value pairs atomically in a single batch.
268 ///
269 /// The default implementation opens a transaction, inserts all pairs,
270 /// and commits. Backends may override for better performance.
271 fn batch_write(&self, pairs: &[(&[u8], &[u8])]) -> Result<(), StoreError> {
272 let mut txn = self.transaction()?;
273 for &(k, v) in pairs {
274 txn.put(k, v)?;
275 }
276 txn.commit()
277 }
278
279 /// Delete multiple keys atomically in a single batch.
280 ///
281 /// The default implementation opens a transaction, deletes all keys,
282 /// and commits. Backends may override for better performance.
283 fn batch_delete(&self, keys: &[&[u8]]) -> Result<(), StoreError> {
284 let mut txn = self.transaction()?;
285 for &k in keys {
286 txn.delete(k)?;
287 }
288 txn.commit()
289 }
290
291 /// Return the total number of keys in the store.
292 ///
293 /// The default implementation performs a full iteration and counts entries.
294 /// Backends that maintain key counts natively should override for O(1).
295 fn count(&self) -> Result<u64, StoreError> {
296 let mut n = 0u64;
297 for item in self.iter()? {
298 let _ = item?;
299 n += 1;
300 }
301 Ok(n)
302 }
303
304 /// Return the approximate byte size of the store on disk.
305 ///
306 /// The default implementation returns 0 (unknown). Backends should
307 /// override if they can compute the on-disk size cheaply.
308 fn size_on_disk(&self) -> Result<u64, StoreError> {
309 Ok(0)
310 }
311
312 /// Iterate all key-value pairs in the store in ascending key order.
313 ///
314 /// This is a required method -- all backend implementations must provide
315 /// a full-store iteration.
316 fn iter<'a>(&'a self) -> Result<RangeIter<'a>, StoreError>;
317
318 /// Iterate all keys (without loading values) in ascending order.
319 ///
320 /// The default implementation wraps [`KvStore::iter`] and discards values.
321 /// Backends that can iterate keys without reading values should override.
322 fn keys<'a>(&'a self) -> Result<KeysIter<'a>, StoreError> {
323 let it = self.iter()?;
324 Ok(Box::new(it.map(|r| r.map(|(k, _v)| k))))
325 }
326
327 /// Atomic compare-and-swap: if the current value for `key` equals
328 /// `expected`, replace it with `new_value` and return `Ok(true)`.
329 /// If the current value does not match `expected`, return `Ok(false)`.
330 ///
331 /// `expected` is `None` for "key must not exist", `Some(v)` for
332 /// "key must hold value v".
333 ///
334 /// The default implementation uses a transaction for atomicity.
335 fn compare_and_swap(
336 &self,
337 key: &[u8],
338 expected: Option<&[u8]>,
339 new_value: &[u8],
340 ) -> Result<bool, StoreError> {
341 let mut txn = self.transaction()?;
342 let current = txn.get(key)?;
343 let matches = match (current.as_deref(), expected) {
344 (None, None) => true,
345 (Some(cur), Some(exp)) => cur == exp,
346 _ => false,
347 };
348 if matches {
349 txn.put(key, new_value)?;
350 txn.commit()?;
351 Ok(true)
352 } else {
353 txn.rollback()?;
354 Ok(false)
355 }
356 }
357
358 /// Insert a key-value pair with a time-to-live. After `ttl` has elapsed,
359 /// the key is treated as absent (expired).
360 ///
361 /// Backends that support native TTL should override this method.
362 /// The default implementation returns [`StoreError::Unsupported`].
363 fn put_with_ttl(&self, _key: &[u8], _value: &[u8], _ttl: Duration) -> Result<(), StoreError> {
364 Err(StoreError::Unsupported("TTL not supported".to_string()))
365 }
366
367 /// Set a TTL on an existing key. The key must already exist.
368 ///
369 /// After `ttl` has elapsed the key is treated as absent.
370 /// The default implementation returns [`StoreError::Unsupported`].
371 fn expire(&self, _key: &[u8], _ttl: Duration) -> Result<(), StoreError> {
372 Err(StoreError::Unsupported("TTL not supported".to_string()))
373 }
374
375 /// Return the remaining TTL for a key.
376 ///
377 /// Returns `Ok(None)` if the key exists but has no TTL attached.
378 /// Returns `Err(StoreError::KeyNotFound)` if the key does not exist.
379 /// Returns `Err(StoreError::Unsupported)` by default.
380 fn ttl(&self, _key: &[u8]) -> Result<Option<Duration>, StoreError> {
381 Err(StoreError::Unsupported("TTL not supported".to_string()))
382 }
383
384 /// Remove the TTL from a key, making it persistent.
385 ///
386 /// Returns `Ok(true)` if the key existed and its TTL was removed,
387 /// `Ok(false)` if the key exists but had no TTL.
388 /// Returns `Err(StoreError::KeyNotFound)` if the key does not exist.
389 /// The default implementation returns [`StoreError::Unsupported`].
390 fn persist(&self, _key: &[u8]) -> Result<bool, StoreError> {
391 Err(StoreError::Unsupported("TTL not supported".to_string()))
392 }
393
394 /// Scan and delete all expired keys eagerly.
395 ///
396 /// Returns the count of keys that were deleted.
397 /// The default implementation is a no-op returning `Ok(0)`.
398 fn purge_expired(&self) -> Result<u64, StoreError> {
399 Ok(0)
400 }
401
402 /// Trigger manual compaction on backends that support it.
403 ///
404 /// The default implementation is a no-op.
405 fn compact(&self) -> Result<(), StoreError> {
406 Ok(())
407 }
408
409 /// Create a point-in-time backup to the given path.
410 ///
411 /// The default implementation returns an error indicating backup is
412 /// not supported. Backends should override if they support backup.
413 fn backup(&self, _path: &Path) -> Result<(), StoreError> {
414 Err(StoreError::Other(
415 "backup not supported for this backend".to_string(),
416 ))
417 }
418
419 /// Restore from a backup at the given path.
420 ///
421 /// The default implementation returns an error. Backends should
422 /// override if they support restore.
423 fn restore(&self, _path: &Path) -> Result<(), StoreError> {
424 Err(StoreError::Other(
425 "restore not supported for this backend".to_string(),
426 ))
427 }
428
429 /// Begin an explicit write transaction.
430 ///
431 /// Changes made through [`KvTxn`] are only visible after [`KvTxn::commit`].
432 fn transaction(&self) -> Result<Box<dyn KvTxn + '_>, StoreError>;
433
434 /// Capture a point-in-time read-only snapshot of the store.
435 fn snapshot(&self) -> Result<Box<dyn KvSnapshot + '_>, StoreError>;
436
437 /// Ensure all committed data has been written to durable storage.
438 ///
439 /// The exact semantics depend on the backend; for backends that auto-flush
440 /// (e.g. redb commits), this is a no-op or an advisory hint.
441 fn flush(&self) -> Result<(), StoreError>;
442}
443
444/// An explicit write transaction obtained from [`KvStore::transaction`].
445///
446/// All mutations made through `KvTxn` are buffered until [`KvTxn::commit`] is
447/// called. Dropping without committing has the same effect as
448/// [`KvTxn::rollback`].
449pub trait KvTxn {
450 /// Read a value from the store within this transaction's view.
451 ///
452 /// Implementations that support read-your-writes should return buffered
453 /// writes that have not yet been committed.
454 fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
455
456 /// Stage a key-value insertion in the transaction.
457 fn put(&mut self, key: &[u8], value: &[u8]) -> Result<(), StoreError>;
458
459 /// Stage a key deletion in the transaction.
460 fn delete(&mut self, key: &[u8]) -> Result<(), StoreError>;
461
462 /// Check whether `key` exists within this transaction's view.
463 ///
464 /// Default implementation delegates to [`KvTxn::get`].
465 fn contains(&self, key: &[u8]) -> Result<bool, StoreError> {
466 Ok(self.get(key)?.is_some())
467 }
468
469 /// Range scan within the transaction's view.
470 ///
471 /// Implementations supporting read-your-writes should merge buffered
472 /// writes with committed data. The default implementation returns an
473 /// error indicating range is not supported within transactions.
474 fn range<'a>(&'a self, _lo: &[u8], _hi: &[u8]) -> Result<RangeIter<'a>, StoreError> {
475 Err(StoreError::Other(
476 "range not supported within this transaction type".to_string(),
477 ))
478 }
479
480 /// Commit all staged changes atomically.
481 fn commit(self: Box<Self>) -> Result<(), StoreError>;
482
483 /// Discard all staged changes.
484 fn rollback(self: Box<Self>) -> Result<(), StoreError>;
485}
486
487/// A point-in-time read-only view of the store obtained from [`KvStore::snapshot`].
488pub trait KvSnapshot {
489 /// Read a value from the snapshot.
490 fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
491
492 /// Return all key-value pairs whose keys fall within `[lo, hi)`,
493 /// in ascending key order.
494 fn range<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError>;
495
496 /// Return all key-value pairs sharing the given `prefix`, in ascending
497 /// key order.
498 ///
499 /// Default implementation uses [`prefix_upper_bound`] and delegates to
500 /// [`KvSnapshot::range`].
501 fn prefix_scan<'a>(&'a self, prefix: &[u8]) -> Result<RangeIter<'a>, StoreError> {
502 match prefix_upper_bound(prefix) {
503 Some(hi) => self.range(prefix, &hi),
504 None => {
505 // No upper bound — scan everything.
506 self.range(&[], &[])
507 }
508 }
509 }
510
511 /// Check whether `key` exists in the snapshot.
512 ///
513 /// Default implementation delegates to [`KvSnapshot::get`].
514 fn contains(&self, key: &[u8]) -> Result<bool, StoreError> {
515 Ok(self.get(key)?.is_some())
516 }
517}
518
519/// Stub trait for M2+ columnar store — defined here so facade re-exports remain stable.
520pub trait ColumnarStore: Send + Sync {}
521
522/// Marker trait for blob stores that is compatible with `oxistore-blob::BlobStore`.
523///
524/// This stub is defined here so that the `oxistore` facade can reference it
525/// without depending on the full `oxistore-blob` crate. The canonical, fully
526/// featured async `BlobStore` trait (with `put`, `get`, `delete`, `head`,
527/// `list`, `exists`, `copy`, `rename`, CAS, streaming, etc.) is defined in
528/// the `oxistore-blob` crate.
529///
530/// Every type that implements `oxistore_blob::BlobStore` automatically
531/// satisfies this marker via a blanket impl in `oxistore-blob`.
532///
533/// # Design note
534///
535/// `oxistore-core` is intentionally dependency-free. Adding async methods
536/// here would require `bytes` and `tokio`, which contradicts that policy.
537/// The full trait lives in `oxistore-blob`; this marker exists only to keep
538/// facade re-exports stable.
539pub trait BlobStore: Send + Sync {}
540
541// Intentionally no methods here — see `oxistore_blob::BlobStore` for the full API.
542
543/// Convenience alias: a heap-allocated [`KvStore`] with `'static` lifetime.
544///
545/// Returned by `oxistore::open`.
546pub type BoxKvStore = Box<dyn KvStore>;
547
548/// A single item produced by a range scan: a `(key, value)` pair or an error.
549pub type RangeItem = Result<(Vec<u8>, Vec<u8>), StoreError>;
550
551/// A boxed iterator over [`RangeItem`]s with a given lifetime.
552pub type RangeIter<'a> = Box<dyn Iterator<Item = RangeItem> + 'a>;
553
554/// A boxed iterator over keys (without values) with a given lifetime.
555///
556/// Used by [`KvStore::keys`].
557pub type KeysIter<'a> = Box<dyn Iterator<Item = Result<Vec<u8>, StoreError>> + 'a>;
558
559/// Backend-agnostic configuration for opening a store.
560///
561/// Each backend maps the fields it supports and ignores the rest.
562#[derive(Debug, Clone)]
563pub struct StoreConfig {
564 /// Block cache size in bytes (backend-specific interpretation).
565 pub cache_size_bytes: Option<u64>,
566 /// Whether to sync writes to disk on every commit.
567 pub sync_writes: bool,
568 /// Whether to open the store in read-only mode.
569 pub read_only: bool,
570}
571
572impl Default for StoreConfig {
573 fn default() -> Self {
574 StoreConfig {
575 cache_size_bytes: None,
576 sync_writes: true,
577 read_only: false,
578 }
579 }
580}
581
582/// Runtime statistics for a store (reads, writes, cache hits, etc.).
583#[derive(Debug, Clone, Default)]
584pub struct StoreMetrics {
585 /// Total number of `get` calls.
586 pub reads: u64,
587 /// Total number of `put` calls.
588 pub writes: u64,
589 /// Total number of `delete` calls.
590 pub deletes: u64,
591 /// Total bytes read.
592 pub bytes_read: u64,
593 /// Total bytes written.
594 pub bytes_written: u64,
595 /// Cache hit count (if a cache layer is in use).
596 pub cache_hits: u64,
597 /// Cache miss count (if a cache layer is in use).
598 pub cache_misses: u64,
599}
600
601impl StoreMetrics {
602 /// Compute the cache hit rate as a fraction (0.0 to 1.0).
603 ///
604 /// Returns 0.0 if no cache lookups have been performed.
605 #[must_use]
606 pub fn cache_hit_rate(&self) -> f64 {
607 let total = self.cache_hits + self.cache_misses;
608 if total == 0 {
609 0.0
610 } else {
611 self.cache_hits as f64 / total as f64
612 }
613 }
614}
615
616/// Ensure the path's parent directory exists, creating it if necessary.
617///
618/// This helper is used by backend `open` implementations to avoid confusing
619/// "file not found" errors when the parent directory does not exist.
620pub fn ensure_parent_dir(path: &Path) -> Result<(), StoreError> {
621 if let Some(parent) = path.parent() {
622 if !parent.as_os_str().is_empty() {
623 std::fs::create_dir_all(parent)?;
624 }
625 }
626 Ok(())
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn prefix_upper_bound_basic() {
635 assert_eq!(prefix_upper_bound(b"foo"), Some(b"fop".to_vec()));
636 }
637
638 #[test]
639 fn prefix_upper_bound_trailing_ff() {
640 assert_eq!(prefix_upper_bound(b"ab\xff"), Some(b"ac".to_vec()));
641 }
642
643 #[test]
644 fn prefix_upper_bound_all_ff() {
645 assert_eq!(prefix_upper_bound(b"\xff\xff"), None);
646 }
647
648 #[test]
649 fn prefix_upper_bound_empty() {
650 assert_eq!(prefix_upper_bound(b""), None);
651 }
652
653 #[test]
654 fn prefix_upper_bound_single_byte() {
655 assert_eq!(prefix_upper_bound(b"a"), Some(b"b".to_vec()));
656 }
657
658 #[test]
659 fn store_error_display() {
660 assert_eq!(format!("{}", StoreError::NotFound), "not found");
661 assert_eq!(format!("{}", StoreError::ReadOnly), "store is read-only");
662 assert_eq!(format!("{}", StoreError::Timeout), "operation timed out");
663 assert_eq!(
664 format!("{}", StoreError::CapacityExceeded),
665 "capacity exceeded"
666 );
667 assert_eq!(
668 format!("{}", StoreError::CasMismatch),
669 "compare-and-swap mismatch"
670 );
671 }
672
673 #[test]
674 fn store_error_from_string() {
675 let err: StoreError = "test error".to_string().into();
676 assert_eq!(format!("{err}"), "error: test error");
677 }
678
679 #[test]
680 fn store_config_default() {
681 let cfg = StoreConfig::default();
682 assert!(cfg.cache_size_bytes.is_none());
683 assert!(cfg.sync_writes);
684 assert!(!cfg.read_only);
685 }
686
687 #[test]
688 fn store_metrics_hit_rate() {
689 let m = StoreMetrics {
690 cache_hits: 80,
691 cache_misses: 20,
692 ..StoreMetrics::default()
693 };
694 assert!((m.cache_hit_rate() - 0.8).abs() < f64::EPSILON);
695 }
696
697 #[test]
698 fn store_metrics_hit_rate_zero() {
699 let m = StoreMetrics::default();
700 assert!((m.cache_hit_rate()).abs() < f64::EPSILON);
701 }
702
703 // ── core-clone-error ────────────────────────────────────────────────────
704
705 #[test]
706 fn store_error_clone_io() {
707 let original = StoreError::from(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
708 let cloned = original.clone();
709 if let StoreError::Io(arc) = cloned {
710 assert_eq!(arc.kind(), std::io::ErrorKind::NotFound);
711 } else {
712 panic!("expected StoreError::Io after clone");
713 }
714 }
715
716 #[test]
717 fn store_error_clone_non_io_variants() {
718 let variants = [
719 StoreError::NotFound,
720 StoreError::AlreadyExists,
721 StoreError::TxnConflict,
722 StoreError::ReadOnly,
723 StoreError::Timeout,
724 StoreError::CapacityExceeded,
725 StoreError::CasMismatch,
726 StoreError::KeyNotFound,
727 StoreError::Corruption("bad".to_string()),
728 StoreError::Unsupported("nope".to_string()),
729 StoreError::Other("misc".to_string()),
730 ];
731 for v in &variants {
732 let _ = v.clone(); // must not panic
733 }
734 }
735
736 // ── core-range-rev ──────────────────────────────────────────────────────
737
738 /// Minimal in-memory `KvStore` for unit-testing default-method behaviour.
739 struct MemKv(std::sync::Mutex<std::collections::BTreeMap<Vec<u8>, Vec<u8>>>);
740
741 impl MemKv {
742 fn new() -> Self {
743 MemKv(std::sync::Mutex::new(std::collections::BTreeMap::new()))
744 }
745 }
746
747 impl KvStore for MemKv {
748 fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError> {
749 Ok(self.0.lock().unwrap().get(key).cloned())
750 }
751
752 fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StoreError> {
753 self.0.lock().unwrap().insert(key.to_vec(), value.to_vec());
754 Ok(())
755 }
756
757 fn delete(&self, key: &[u8]) -> Result<(), StoreError> {
758 self.0.lock().unwrap().remove(key);
759 Ok(())
760 }
761
762 fn range<'a>(&'a self, lo: &[u8], hi: &[u8]) -> Result<RangeIter<'a>, StoreError> {
763 use std::ops::Bound;
764 let map = self.0.lock().unwrap();
765 let pairs: Vec<RangeItem> = map
766 .range((Bound::Included(lo.to_vec()), Bound::Excluded(hi.to_vec())))
767 .map(|(k, v)| Ok((k.clone(), v.clone())))
768 .collect();
769 Ok(Box::new(pairs.into_iter()))
770 }
771
772 fn iter<'a>(&'a self) -> Result<RangeIter<'a>, StoreError> {
773 let map = self.0.lock().unwrap();
774 let pairs: Vec<RangeItem> = map
775 .iter()
776 .map(|(k, v)| Ok((k.clone(), v.clone())))
777 .collect();
778 Ok(Box::new(pairs.into_iter()))
779 }
780
781 fn transaction(&self) -> Result<Box<dyn KvTxn + '_>, StoreError> {
782 Err(StoreError::Unsupported("no txn in MemKv".to_string()))
783 }
784
785 fn snapshot(&self) -> Result<Box<dyn KvSnapshot + '_>, StoreError> {
786 Err(StoreError::Unsupported("no snapshot in MemKv".to_string()))
787 }
788
789 fn flush(&self) -> Result<(), StoreError> {
790 Ok(())
791 }
792 }
793
794 #[test]
795 fn range_rev_descending_order() {
796 let store = MemKv::new();
797 store.put(b"a", b"1").unwrap();
798 store.put(b"b", b"2").unwrap();
799 store.put(b"c", b"3").unwrap();
800 store.put(b"d", b"4").unwrap();
801
802 // range_rev over [a, e) should yield d, c, b, a in that order.
803 let items: Vec<(Vec<u8>, Vec<u8>)> = store
804 .range_rev(b"a", b"e")
805 .unwrap()
806 .map(|r| r.unwrap())
807 .collect();
808
809 let keys: Vec<&[u8]> = items.iter().map(|(k, _)| k.as_slice()).collect();
810 assert_eq!(keys, vec![b"d", b"c", b"b", b"a"]);
811 }
812
813 #[test]
814 fn range_rev_empty_range() {
815 let store = MemKv::new();
816 store.put(b"x", b"v").unwrap();
817
818 // [z, z) is empty — range_rev should return an empty iterator.
819 let items: Vec<_> = store.range_rev(b"z", b"z").unwrap().collect();
820 assert!(items.is_empty());
821 }
822
823 // ── ensure_parent_dir edge cases ────────────────────────────────────────
824
825 #[test]
826 fn ensure_parent_dir_empty_path() {
827 // Empty path has no parent — should succeed (no-op)
828 let result = ensure_parent_dir(std::path::Path::new("some_file.db"));
829 assert!(result.is_ok());
830 }
831
832 #[test]
833 fn ensure_parent_dir_nested() {
834 use std::process;
835 let tmp = std::env::temp_dir().join(format!("oxistore_ensure_parent_{}", process::id()));
836 let deep = tmp.join("a").join("b").join("file.db");
837 let result = ensure_parent_dir(&deep);
838 assert!(result.is_ok());
839 assert!(deep.parent().expect("has parent").exists());
840 let _ = std::fs::remove_dir_all(&tmp);
841 }
842
843 #[test]
844 fn ensure_parent_dir_already_exists() {
845 let tmp = std::env::temp_dir();
846 let path = tmp.join("existing_check.db");
847 // tmp already exists — should succeed without creating anything new
848 let result = ensure_parent_dir(&path);
849 assert!(result.is_ok());
850 }
851
852 // ── StoreError::from(io::Error) variants ───────────────────────────────
853
854 #[test]
855 fn store_error_from_io_error_variants() {
856 use std::io;
857 let kinds = [
858 io::ErrorKind::NotFound,
859 io::ErrorKind::PermissionDenied,
860 io::ErrorKind::AlreadyExists,
861 io::ErrorKind::WouldBlock,
862 io::ErrorKind::TimedOut,
863 ];
864 for kind in kinds {
865 let io_err = io::Error::new(kind, "test error");
866 let store_err: StoreError = io_err.into();
867 match &store_err {
868 StoreError::Io(arc) => assert_eq!(arc.kind(), kind),
869 other => panic!("expected StoreError::Io, got {other:?}"),
870 }
871 }
872 }
873}