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;