Skip to main content

dataset_core/
lib.rs

1//! A generic, thread-safe dataset container with lazy loading and caching.
2//!
3//! `dataset-core` provides [`Dataset<T, E>`], a lightweight wrapper that pairs a storage
4//! directory with a lazily-initialized value of any type `T`. The actual downloading
5//! and parsing logic is supplied by the caller through a loader closure stored at
6//! construction time, making `Dataset<T, E>` suitable for any data source — local
7//! files, remote URLs, databases, or in-memory generation.
8//!
9//! On top of this core type, the crate offers an **optional** feature-gated module:
10//!
11//! - **`utils`** — helper functions for downloading files, extracting archives,
12//!   verifying SHA-256 hashes, and managing temporary directories.
13//!
14//! Ready-to-use loaders for 26 classic ML datasets — from Iris, Breast Cancer, and
15//! Titanic to Forest CoverType, KDD Cup '99, and 20 Newsgroups — live in the
16//! companion crate [`dataset-ml`](https://crates.io/crates/dataset-ml), which depends
17//! on `dataset-core` with the `utils` feature enabled and serves as the reference
18//! implementation for wrapping `Dataset<T, E>`.
19//!
20//! # Feature Flags
21//!
22//! | Feature | What it enables                                                                            |
23//! |---------|--------------------------------------------------------------------------------------------|
24//! | `utils` | `download_to`, `unzip`, `gunzip`, `untar`, `untar_gz`, `acquire_dataset`, and the `error` module |
25//!
26//! With no features enabled, only `Dataset<T, E>` is available — depending only on
27//! `std::sync::OnceLock`.
28//!
29//! # Quick Start — `Dataset<T, E>`
30//!
31//! ```rust
32//! use dataset_core::Dataset;
33//!
34//! fn my_loader(dir: &str) -> Result<Vec<String>, std::io::Error> {
35//!     // In a real use case you would read/download files from `dir`.
36//!     Ok(vec!["hello".to_string(), "world".to_string()])
37//! }
38//!
39//! // The loader is supplied once, at construction time.
40//! let mut ds: Dataset<Vec<String>, std::io::Error> = Dataset::new("./my_data", my_loader);
41//!
42//! // First call runs the loader; subsequent calls return the cached reference.
43//! let data = ds.load().unwrap();
44//! assert_eq!(data.len(), 2);
45//!
46//! let data_again = ds.load().unwrap();
47//! assert!(std::ptr::eq(data, data_again)); // same reference, no reload
48//!
49//! // `get` borrows the cached value without ever running the loader;
50//! // `get_mut` edits it in place (no clone, no reload — the change stays cached).
51//! assert!(ds.get().is_some());
52//! if let Some(v) = ds.get_mut() {
53//!     v[0] = "HELLO".to_string();
54//! }
55//! assert_eq!(ds.get().unwrap()[0], "HELLO");
56//!
57//! // Move the cached value out without cloning. `take` leaves `ds` reusable
58//! // (a later `load` re-runs the loader); `into_inner` consumes `ds`.
59//! let owned = ds.take().unwrap();
60//! assert_eq!(owned.len(), 2);
61//! assert!(!ds.is_loaded());
62//!
63//! ds.load().unwrap(); // `take` reset the cache, so this reloads
64//! let owned = ds.into_inner().unwrap();
65//! assert_eq!(owned.len(), 2);
66//! ```
67//!
68//! # Swapping the loader
69//!
70//! Because the loader lives inside the `Dataset`, you change *how* the data is
71//! parsed with [`Dataset::set_loader`], which also invalidates the cache so the
72//! next access re-parses with the new loader. To re-run the **same** loader
73//! (e.g. the file on disk changed), use [`Dataset::invalidate`].
74//!
75//! ```rust
76//! use dataset_core::Dataset;
77//!
78//! let mut ds: Dataset<i32, std::convert::Infallible> = Dataset::new("./data", |_| Ok(1));
79//! assert_eq!(*ds.load().unwrap(), 1);
80//!
81//! ds.set_loader(|_| Ok(2)); // swap the loader; old cache is dropped
82//! assert!(!ds.is_loaded());
83//! assert_eq!(*ds.load().unwrap(), 2); // next load uses the new loader
84//! ```
85//!
86//! # Utility Functions (feature `utils`)
87//!
88//! - `download_to` — download a remote file into a directory
89//! - `unzip` — extract a ZIP archive
90//! - `gunzip` — decompress a gzip (`.gz`) file into a single output file
91//! - `untar` — extract a tar (`.tar`) archive into a directory
92//! - `untar_gz` — extract a gzip-compressed tar (`.tar.gz` / `.tgz`) archive, streaming
93//! - `acquire_dataset` — cache-aware dataset acquisition workflow
94//!   (temp dir → prepare → optional hash check → move to final location)
95//!
96//! `acquire_dataset` is the single entry point for caching a dataset file; temp-dir
97//! creation and SHA-256 verification are internal steps it performs for you.
98
99#[cfg(feature = "utils")]
100pub use error::{DataFormatErrorKind, DatasetError};
101use std::sync::OnceLock;
102#[cfg(feature = "utils")]
103pub use utils::{acquire_dataset, download_to, gunzip, untar, untar_gz, unzip};
104
105/// The boxed loader stored inside a [`Dataset`].
106///
107/// A loader takes the storage directory path and returns the parsed dataset (or
108/// an error). It is stored behind a `Box<dyn Fn ...>` so the concrete closure
109/// type does not leak into `Dataset`'s type parameters. The `Send + Sync` bound
110/// keeps `Dataset<T, E>` shareable across threads (matching the guarantee given
111/// by the internal `OnceLock`); the implied `'static` bound means the loader may
112/// not borrow from its environment — capture by value or clone instead.
113type Loader<T, E> = Box<dyn Fn(&str) -> Result<T, E> + Send + Sync>;
114
115/// A generic, thread-safe dataset container with lazy loading and in-memory caching.
116///
117/// `Dataset<T, E>` is a thin caching wrapper that holds a `storage_dir` (the directory
118/// where dataset files are stored on disk), a loader closure, and a lazily-initialized
119/// value of type `T`. The downloading and parsing logic is provided by the caller
120/// through the loader passed to [`Dataset::new`] and run on first access by
121/// [`Dataset::load`].
122///
123/// This struct is designed to be the building block for both the loaders shipped in
124/// the companion crate [`dataset-ml`](https://crates.io/crates/dataset-ml) and any
125/// custom datasets defined by external users.
126///
127/// # Type Parameters
128///
129/// - `T` - The type of the parsed dataset. Can be any type, such as
130///   `(Array2<f64>, Array1<f64>)`, a custom struct, or any other data representation.
131///   `T` must implement `Send + Sync` for `Dataset<T, E>` to be shared across threads.
132/// - `E` - The error type returned by the loader. Callers choose it freely (e.g.
133///   `std::io::Error`, a crate-specific `DatasetError`, or `std::convert::Infallible`
134///   for loaders that cannot fail).
135///
136/// # Thread Safety
137///
138/// `Dataset<T, E>` is `Send + Sync` when `T` is `Send + Sync` (the stored loader is
139/// always `Send + Sync`). The internal `OnceLock` ensures that the loader runs at
140/// most once, even when multiple threads call [`Dataset::load`] concurrently.
141///
142/// # Example
143///
144/// ```rust
145/// use dataset_core::Dataset;
146///
147/// // Define a simple loader that reads a value from the storage directory path.
148/// // The loader can return any error type you choose.
149/// fn my_loader(dir: &str) -> Result<Vec<String>, std::io::Error> {
150///     // In a real use case, you would download/read files from `dir`.
151///     // Here we just demonstrate the caching behavior.
152///     Ok(vec!["hello".to_string(), "world".to_string()])
153/// }
154///
155/// // The loader is bound to the dataset at construction time.
156/// let mut dataset: Dataset<Vec<String>, std::io::Error> = Dataset::new("./my_data", my_loader);
157///
158/// // The first call to `load` triggers the loader
159/// let data = dataset.load().unwrap();
160/// assert_eq!(data.len(), 2);
161///
162/// // Subsequent calls return the cached reference instantly
163/// let data_again = dataset.load().unwrap();
164/// assert!(std::ptr::eq(data, data_again)); // same reference, no re-load
165///
166/// // Check whether data has been loaded
167/// assert!(dataset.is_loaded());
168///
169/// // Borrow the cached value without reloading, or edit it in place via `get_mut`.
170/// if let Some(v) = dataset.get_mut() {
171///     v[0] = "HELLO".to_string();
172/// }
173/// assert_eq!(dataset.get().unwrap()[0], "HELLO");
174///
175/// // Move the cached value out without cloning.
176/// // `take` leaves `dataset` reusable; `into_inner` consumes it.
177/// let owned = dataset.take().unwrap();
178/// assert_eq!(owned.len(), 2);
179/// assert!(!dataset.is_loaded()); // `take` reset it to unloaded
180///
181/// dataset.load().unwrap(); // reloads, since `take` cleared the cache
182/// let owned = dataset.into_inner().unwrap();
183/// assert_eq!(owned.len(), 2);
184/// ```
185pub struct Dataset<T, E> {
186    storage_dir: String,
187    loader: Loader<T, E>,
188    data: OnceLock<T>,
189}
190
191impl<T, E> Dataset<T, E> {
192    /// Create a new `Dataset` instance without loading any data.
193    ///
194    /// This is a lightweight operation that only stores the storage directory path
195    /// and the loader. No I/O or network requests are performed until
196    /// [`Dataset::load`] is called.
197    ///
198    /// # Parameters
199    ///
200    /// - `storage_dir` - Directory where dataset files will be stored. The directory
201    ///   will be created automatically when the loader runs if it does not exist.
202    /// - `loader` - A closure or function that takes the storage directory path (`&str`)
203    ///   and returns `Result<T, E>`. This is where you perform downloading, file I/O,
204    ///   and parsing. It runs at most once (see [`Dataset::load`]). Because it is
205    ///   stored behind `Box<dyn Fn ...>`, it must be `Send + Sync + 'static` —
206    ///   capture owned values or clones rather than borrowing from the environment.
207    ///
208    /// # Returns
209    ///
210    /// A new `Dataset<T, E>` instance ready for lazy loading.
211    pub fn new(
212        storage_dir: &str,
213        loader: impl Fn(&str) -> Result<T, E> + Send + Sync + 'static,
214    ) -> Self {
215        Dataset {
216            storage_dir: storage_dir.to_string(),
217            loader: Box::new(loader),
218            data: OnceLock::new(),
219        }
220    }
221
222    /// Load the dataset, executing the stored loader on first call and caching the result.
223    ///
224    /// On the first call, the loader supplied to [`Dataset::new`] (or last set via
225    /// [`Dataset::set_loader`]) is invoked with the storage directory path. The
226    /// returned value is cached internally. All subsequent calls — from any thread —
227    /// return a reference to the cached value without running the loader again.
228    ///
229    /// # Returns
230    ///
231    /// - `Ok(&T)` - A reference to the cached dataset.
232    ///
233    /// # Errors
234    ///
235    /// Returns any error produced by the loader on first invocation. Once data is
236    /// successfully loaded and cached, this method never returns an error.
237    pub fn load(&self) -> Result<&T, E> {
238        if let Some(data) = self.data.get() {
239            return Ok(data);
240        }
241
242        let value = (self.loader)(&self.storage_dir)?;
243        let _ = self.data.set(value);
244
245        Ok(self
246            .data
247            .get()
248            .expect("data should be set after successful load"))
249    }
250
251    /// Replace the loader and invalidate any cached data.
252    ///
253    /// Use this when the parsing logic itself needs to change. The new loader is
254    /// **not** run immediately: this method only swaps the loader and drops the
255    /// cached value (resetting the `Dataset` to its unloaded state), so the next
256    /// [`Dataset::load`] lazily re-parses with the new loader. This keeps the
257    /// "no I/O until access" contract intact.
258    ///
259    /// To re-run the *same* loader instead, use [`Dataset::invalidate`].
260    ///
261    /// # Parameters
262    ///
263    /// - `loader` - The replacement loader. Like the one given to [`Dataset::new`],
264    ///   it must be `Send + Sync + 'static`.
265    ///
266    /// # Example
267    ///
268    /// ```rust
269    /// use dataset_core::Dataset;
270    ///
271    /// let mut ds: Dataset<i32, std::convert::Infallible> = Dataset::new("./data", |_| Ok(1));
272    /// assert_eq!(*ds.load().unwrap(), 1);
273    ///
274    /// ds.set_loader(|_| Ok(2)); // swap the loader; the old cache is dropped
275    /// assert!(!ds.is_loaded());
276    /// assert_eq!(*ds.load().unwrap(), 2); // next load uses the new loader
277    /// ```
278    pub fn set_loader(&mut self, loader: impl Fn(&str) -> Result<T, E> + Send + Sync + 'static) {
279        self.loader = Box::new(loader);
280        self.invalidate();
281    }
282
283    /// Drop the cached value, keeping the current loader.
284    ///
285    /// Resets the `Dataset` to its unloaded state so the next [`Dataset::load`]
286    /// re-runs the **current** loader from scratch — useful when the underlying
287    /// files have changed on disk and you want to re-parse them. To swap in a
288    /// *different* loader, use [`Dataset::set_loader`].
289    ///
290    /// Unlike [`Dataset::take`], this does not hand the cached value back; it simply
291    /// discards it.
292    ///
293    /// # Example
294    ///
295    /// ```rust
296    /// use dataset_core::Dataset;
297    ///
298    /// let mut ds: Dataset<i32, std::convert::Infallible> = Dataset::new("./data", |_| Ok(1));
299    /// ds.load().unwrap();
300    /// assert!(ds.is_loaded());
301    ///
302    /// ds.invalidate(); // drop the cache, keep the loader
303    /// assert!(!ds.is_loaded());
304    /// assert_eq!(*ds.load().unwrap(), 1); // reloads with the same loader
305    /// ```
306    pub fn invalidate(&mut self) {
307        let _ = self.data.take();
308    }
309
310    /// Check whether the dataset has been loaded into memory.
311    ///
312    /// # Returns
313    ///
314    /// `true` if [`Dataset::load`] has been called successfully at least once,
315    /// `false` otherwise.
316    pub fn is_loaded(&self) -> bool {
317        self.data.get().is_some()
318    }
319
320    /// Get the storage directory path.
321    ///
322    /// # Returns
323    ///
324    /// The storage directory path as a string slice.
325    pub fn storage_dir(&self) -> &str {
326        &self.storage_dir
327    }
328
329    /// Get a reference to the cached value **without** triggering loading.
330    ///
331    /// Unlike [`Dataset::load`], this never runs the loader: if the dataset has
332    /// not been loaded yet, it returns `None` rather than downloading/parsing.
333    /// Use it when you only want the data if it is already in memory and want to
334    /// avoid paying the loader's I/O cost otherwise — for example a fast path
335    /// that falls back to other work when the dataset is not yet cached.
336    ///
337    /// This is the reference-returning companion of [`Dataset::is_loaded`]:
338    /// `is_loaded()` answers *whether* the value is cached, `get()` hands you the
339    /// cached reference when it is.
340    ///
341    /// # Returns
342    ///
343    /// - `Some(&T)` - a reference to the cached value, if the dataset had been loaded.
344    /// - `None` - if the dataset has not been loaded.
345    ///
346    /// # Example
347    ///
348    /// ```rust
349    /// use dataset_core::Dataset;
350    ///
351    /// let ds: Dataset<Vec<i32>, std::convert::Infallible> =
352    ///     Dataset::new("./data", |_| Ok(vec![1, 2, 3]));
353    /// assert!(ds.get().is_none()); // not loaded yet — no loader is run
354    ///
355    /// ds.load().unwrap();
356    /// assert_eq!(ds.get(), Some(&vec![1, 2, 3]));
357    /// ```
358    pub fn get(&self) -> Option<&T> {
359        self.data.get()
360    }
361
362    /// Get a mutable reference to the cached value for **in-place** editing.
363    ///
364    /// This is the only way to mutate the cached value without moving it out:
365    /// you can tweak the loaded data (e.g. normalize features, fill in missing
366    /// entries, augment samples) and the changes persist in the cache, so later
367    /// [`Dataset::load`] / [`Dataset::get`] calls observe them.
368    ///
369    /// Because it requires unique access (`&mut self`), there is no aliasing or
370    /// race concern. And unlike [`take`](Dataset::take) /
371    /// [`into_inner`](Dataset::into_inner), it neither clones nor removes the
372    /// value — the `Dataset` stays loaded.
373    ///
374    /// Like [`Dataset::get`], this does **not** trigger loading: it returns
375    /// `None` if the dataset has not been loaded. Call [`Dataset::load`] first if
376    /// you need to ensure the value is present.
377    ///
378    /// # Returns
379    ///
380    /// - `Some(&mut T)` - a mutable reference to the cached value, if the dataset
381    ///   had been loaded.
382    /// - `None` - if the dataset has not been loaded.
383    ///
384    /// # Example
385    ///
386    /// ```rust
387    /// use dataset_core::Dataset;
388    ///
389    /// let mut ds: Dataset<Vec<i32>, std::convert::Infallible> =
390    ///     Dataset::new("./data", |_| Ok(vec![1, 2, 3]));
391    /// assert!(ds.get_mut().is_none()); // not loaded yet — no loader is run
392    ///
393    /// ds.load().unwrap();
394    /// if let Some(data) = ds.get_mut() {
395    ///     data.push(4); // edit the cached value in place, no clone, no reload
396    /// }
397    /// assert_eq!(ds.get(), Some(&vec![1, 2, 3, 4])); // the change persisted
398    /// ```
399    pub fn get_mut(&mut self) -> Option<&mut T> {
400        self.data.get_mut()
401    }
402
403    /// Consume the `Dataset` and return the cached value, if any.
404    ///
405    /// This **moves** the cached `T` out of the container — there is no clone.
406    /// Because it takes `self` by value, the `Dataset` is consumed and cannot be
407    /// used afterwards.
408    ///
409    /// This method does **not** trigger loading: it returns `None` if the dataset
410    /// was never loaded. Call [`Dataset::load`] first if you need to ensure the
411    /// value is present.
412    ///
413    /// # `into_inner` vs [`take`](Dataset::take)
414    ///
415    /// Both move the cached value out without cloning; the difference is what
416    /// happens to the container:
417    ///
418    /// - [`into_inner`](Dataset::into_inner) takes `self` and **consumes** the
419    ///   `Dataset`. Use it when you are done with the container.
420    /// - [`take`](Dataset::take) takes `&mut self`, leaving the `Dataset`
421    ///   **reusable** in its unloaded state (a later [`load`](Dataset::load)
422    ///   re-runs the loader).
423    ///
424    /// # Returns
425    ///
426    /// - `Some(T)` - the cached value, if the dataset had been loaded.
427    /// - `None` - if the dataset was never loaded.
428    ///
429    /// # Example
430    ///
431    /// ```rust
432    /// use dataset_core::Dataset;
433    ///
434    /// let ds: Dataset<Vec<i32>, std::convert::Infallible> =
435    ///     Dataset::new("./data", |_| Ok(vec![1, 2, 3]));
436    /// ds.load().unwrap();
437    ///
438    /// let owned: Vec<i32> = ds.into_inner().unwrap();
439    /// assert_eq!(owned, vec![1, 2, 3]);
440    /// // `ds` has been consumed and can no longer be used.
441    ///
442    /// // A dataset that was never loaded yields `None`.
443    /// let empty: Dataset<Vec<i32>, std::convert::Infallible> =
444    ///     Dataset::new("./data", |_| Ok(vec![1, 2, 3]));
445    /// assert!(empty.into_inner().is_none());
446    /// ```
447    #[must_use = "this consumes the Dataset; discarding the returned value drops the loaded data"]
448    pub fn into_inner(self) -> Option<T> {
449        self.data.into_inner()
450    }
451
452    /// Take the cached value out of the `Dataset`, leaving it reusable.
453    ///
454    /// This **moves** the cached `T` out — there is no clone — and resets the
455    /// `Dataset` to its unloaded state. Unlike [`into_inner`](Dataset::into_inner),
456    /// the container is left intact: it can be used again, and a later
457    /// [`Dataset::load`] will run the loader from scratch.
458    ///
459    /// This method does **not** trigger loading: it returns `None` if the dataset
460    /// was not loaded.
461    ///
462    /// # `take` vs [`into_inner`](Dataset::into_inner)
463    ///
464    /// Both move the cached value out without cloning; the difference is what
465    /// happens to the container:
466    ///
467    /// - [`take`](Dataset::take) takes `&mut self` and keeps the `Dataset`
468    ///   **reusable** (reset to unloaded) after extracting the value.
469    /// - [`into_inner`](Dataset::into_inner) takes `self` and **consumes** the
470    ///   container entirely.
471    ///
472    /// # Returns
473    ///
474    /// - `Some(T)` - the cached value, if the dataset had been loaded.
475    /// - `None` - if the dataset was not loaded.
476    ///
477    /// # Example
478    ///
479    /// ```rust
480    /// use dataset_core::Dataset;
481    ///
482    /// let mut ds: Dataset<i32, std::convert::Infallible> = Dataset::new("./data", |_| Ok(1));
483    /// ds.load().unwrap();
484    /// assert!(ds.is_loaded());
485    ///
486    /// let taken = ds.take().unwrap();
487    /// assert_eq!(taken, 1);
488    /// assert!(!ds.is_loaded()); // reset to unloaded, but `ds` is still usable
489    ///
490    /// // Because it was reset, `load` runs the loader again:
491    /// let reloaded = ds.load().unwrap();
492    /// assert_eq!(*reloaded, 1);
493    /// ```
494    #[must_use = "discarding the returned value drops the data taken out of the Dataset"]
495    pub fn take(&mut self) -> Option<T> {
496        self.data.take()
497    }
498}
499
500impl<T, E> std::fmt::Debug for Dataset<T, E> {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        f.debug_struct("Dataset")
503            .field("storage_dir", &self.storage_dir)
504            .field("data_loaded", &self.is_loaded())
505            .finish()
506    }
507}
508
509/// Error handling module.
510///
511/// Provides structured error types for dataset loading operations including
512/// download failures, validation errors, I/O errors, and detailed data format
513/// errors with line numbers and contextual information for debugging.
514#[cfg(feature = "utils")]
515pub mod error;
516
517/// Utility functions for dataset authors.
518///
519/// Provides helpers for downloading files, extracting archives, verifying
520/// SHA256 hashes, and managing the dataset acquisition workflow.
521#[cfg(feature = "utils")]
522pub mod utils;