Skip to main content

dataset_core/
utils.rs

1use crate::DatasetError;
2use sha2::{Digest, Sha256};
3use std::fmt::Write;
4use std::fs::File;
5use std::io;
6use std::io::Read;
7use std::path::{Path, PathBuf};
8use zip::ZipArchive;
9use zip::result::ZipError;
10
11/// Download a remote file into the given directory.
12///
13/// It downloads the content at `url` (using [`ureq`] crate) into `storage_path` using the file name
14/// extracted from the last segment of the URL, unless a custom filename is provided.
15///
16/// When the filename is derived from the URL, any trailing `?query` or `#fragment` is stripped
17/// (e.g. `.../iris.csv?raw=1` yields `iris.csv`). A URL that ends in `/` has no final segment, so
18/// a custom `filename` must be supplied in that case.
19///
20/// # Parameters
21///
22/// - `url` - The URL to download.
23/// - `storage_path` - The directory to store the downloaded file in.
24/// - `filename` - Optional custom filename (with extension). If `None`, the filename is extracted
25///   from the last segment of the URL.
26///
27/// # Errors
28///
29/// - `DatasetError` - Returned when the download fails or the filename cannot be derived from the URL.
30///
31/// # Example
32/// ```no_run
33/// use dataset_core::download_to;
34/// use std::path::Path;
35///
36/// let download_dir = "./download_example";
37/// std::fs::create_dir_all(download_dir).unwrap();
38///
39/// // Download a file from the internet
40/// let url = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
41///
42/// // Use filename from URL
43/// download_to(url, Path::new(download_dir), None).unwrap();
44/// assert!(Path::new(download_dir).join("iris.csv").exists());
45///
46/// // Use custom filename
47/// download_to(url, Path::new(download_dir), Some("custom.csv")).unwrap();
48/// assert!(Path::new(download_dir).join("custom.csv").exists());
49/// ```
50pub fn download_to(
51    url: &str,
52    storage_path: &Path,
53    filename: Option<&str>,
54) -> Result<(), DatasetError> {
55    // Get the filename: use provided name, or fall back to URL extraction
56    let filename = match filename {
57        Some(name) => name,
58        None => filename_from_url(url).ok_or_else(|| {
59            DatasetError::ValidationError(
60                "Invalid URL: cannot extract filename from URL".to_string(),
61            )
62        })?,
63    };
64
65    let save_path = storage_path.join(filename);
66
67    let mut response = ureq::get(url).call()?;
68    let mut body = response.body_mut().as_reader();
69
70    // create local file and write body to it
71    let mut file = File::create(save_path)?;
72    io::copy(&mut body, &mut file)?;
73
74    Ok(())
75}
76
77/// Derive a filename from the last path segment of a URL.
78///
79/// Strips any `?query` / `#fragment` suffix and returns `None` when the resulting
80/// segment is empty (e.g. the URL ends in `/`).
81fn filename_from_url(url: &str) -> Option<&str> {
82    // `rsplit('/').next()` yields the whole string when there is no '/'.
83    let last_segment = url.rsplit('/').next()?;
84    let name = last_segment
85        .split(['?', '#'])
86        .next()
87        .unwrap_or(last_segment);
88    (!name.is_empty()).then_some(name)
89}
90
91/// Extract a zip archive into a target directory using [`ZipArchive`] in [`zip`] crate.
92///
93/// # Parameters
94///
95/// - `file_path` - Path to the `.zip` file to extract.
96/// - `extract_dir` - Directory to extract the archive contents into.
97///
98/// # Errors
99///
100/// - `DatasetError` - Returned when opening the zip file fails or when extraction fails.
101///
102/// # Example
103/// ```no_run
104/// use dataset_core::{download_to, unzip};
105/// use std::path::Path;
106///
107/// let work_dir = "./unzip_example";
108/// std::fs::create_dir_all(work_dir).unwrap();
109///
110/// // First download a file
111/// let url = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
112/// download_to(url, Path::new(work_dir), None).unwrap();
113///
114/// // The file is already a CSV (no extraction needed in this example)
115/// assert!(Path::new(work_dir).join("iris.csv").exists());
116/// ```
117pub fn unzip(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
118    let file = File::open(file_path).map_err(|e| DatasetError::from(ZipError::Io(e)))?;
119
120    ZipArchive::new(file)?.extract(extract_dir)?;
121
122    Ok(())
123}
124
125/// Create a temporary directory under the given parent directory.
126///
127/// A small wrapper around [`tempfile::Builder`] used internally by [`acquire_dataset`] to keep
128/// intermediate download/extraction artifacts isolated. The created directory is removed
129/// automatically when the returned [`tempfile::TempDir`] is dropped.
130fn create_temp_dir(tempdir_in: &Path) -> Result<tempfile::TempDir, DatasetError> {
131    let temp_dir = tempfile::Builder::new().tempdir_in(tempdir_in)?;
132
133    Ok(temp_dir)
134}
135
136/// Verify that a file's SHA256 hash matches an expected value (case-insensitive).
137///
138/// Used internally by [`acquire_dataset`] to validate cached and freshly prepared files.
139/// Returns `true` when the computed hash matches `expected_hex`.
140///
141/// # Errors
142///
143/// - `DatasetError::IoError` - Returned when file I/O operations fail (opening file, reading data).
144fn file_sha256_matches(path: &Path, expected_hex: &str) -> Result<bool, DatasetError> {
145    let mut file = File::open(path)?;
146
147    let mut hasher = Sha256::new();
148    let mut buf = [0u8; 8192];
149
150    loop {
151        let read = file.read(&mut buf)?;
152        if read == 0 {
153            break;
154        }
155        hasher.update(&buf[..read]);
156    }
157
158    let digest = hasher.finalize();
159    let mut actual_hex = String::with_capacity(digest.len() * 2);
160    for b in digest {
161        // Writing formatted bytes into a `String` is infallible.
162        let _ = write!(actual_hex, "{:02x}", b);
163    }
164    Ok(actual_hex.eq_ignore_ascii_case(expected_hex))
165}
166
167/// State of the destination file relative to the dataset we want to cache.
168enum CacheState {
169    /// Destination exists and (if a hash was given) matches — reuse it as-is.
170    Fresh,
171    /// Destination exists but its hash does not match — it must be replaced.
172    Stale,
173    /// Destination does not exist — a new file must be prepared.
174    Missing,
175}
176
177/// Ensure `dir` exists, then classify the destination file `dst`.
178///
179/// When `expected_sha256` is `None`, any existing file counts as [`CacheState::Fresh`].
180fn inspect_cache(
181    dir: &Path,
182    dst: &Path,
183    expected_sha256: Option<&str>,
184) -> Result<CacheState, DatasetError> {
185    if !dir.exists() {
186        std::fs::create_dir_all(dir)?;
187    }
188
189    if !dst.exists() {
190        return Ok(CacheState::Missing);
191    }
192
193    match expected_sha256 {
194        Some(hash) if !file_sha256_matches(dst, hash)? => Ok(CacheState::Stale),
195        _ => Ok(CacheState::Fresh),
196    }
197}
198
199/// Acquire a dataset file using a caller-provided preparation closure.
200///
201/// This is the single entry point for the dataset acquisition workflow and the
202/// recommended way to populate a storage directory. It checks whether the destination
203/// file can be reused, creates a temporary directory when a new file is needed,
204/// delegates file preparation to a user-provided closure, optionally validates the
205/// prepared file with SHA256, and atomically moves it to the final destination.
206///
207/// The function itself does not perform network I/O. The `prepare_file` closure
208/// is responsible for preparing the dataset file, which may include downloading,
209/// extracting archives, or locating files within an extracted directory.
210///
211/// # Parameters
212///
213/// - `dir` - Target storage directory path.
214/// - `filename` - Final dataset filename (stored as `dir/filename`).
215///   Please give the filename with the extension (e.g., `"iris.csv"`).
216/// - `dataset_name` - Dataset name for error messages (e.g., `"iris"`).
217/// - `expected_sha256` - Optional expected SHA256 hash of the dataset file. If `None`,
218///   any existing file at the destination is accepted without validation, and newly
219///   prepared files skip SHA256 verification.
220/// - `prepare_file` - Closure that prepares the dataset file in the temporary directory.
221///   - Input: `temp_dir: &Path` - Path to the temporary directory.
222///     It is recommended to execute file operations within this directory, as it will be
223///     cleaned up automatically when the closure returns. But it is not required.
224///     (Please note that the file will be moved to the final destination, not copied.)
225///   - Output: `Result<PathBuf, DatasetError>` - Path to the prepared dataset file
226///     (which will be moved to `dir/filename`).
227///   - Responsibility: This closure can perform any operations needed to prepare the
228///     dataset file, such as downloading (you can use [`download_to`] provided in this crate),
229///     extracting archives (you can use [`unzip`] provided in this crate), or locating files
230///     within extracted folders. The returned `PathBuf` must point to the final dataset file
231///     ready for validation.
232///
233/// # Returns
234///
235/// - `PathBuf` - Path to the final dataset file (`dir/filename`).
236///
237/// # Errors
238///
239/// - `DatasetError::IoError` - Returned when directory creation, file operations, or
240///   hash verification fails.
241/// - `DatasetError::Sha256ValidationFailed` - Returned when `expected_sha256` is provided
242///   and the prepared file's SHA256 hash does not match it.
243/// - Any error returned by the `prepare_file` closure.
244///
245/// # Example
246/// ```no_run
247/// // Implement the file preparation process for the Iris dataset.
248///
249/// /// The URL for the Iris dataset.
250/// ///
251/// /// # Citation
252/// ///
253/// /// R. A. Fisher. "Iris," UCI Machine Learning Repository, \[Online\].
254/// /// Available: <https://doi.org/10.24432/C56C76>
255/// const IRIS_DATA_URL: &str = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
256///
257/// /// The name of the Iris dataset file.
258/// const IRIS_FILENAME: &str = "iris.csv";
259///
260/// /// The SHA256 hash of the Iris dataset file.
261/// const IRIS_SHA256: &str = "c52742e50315a99f956a383faedf7575552675f6409ef0f9a47076dd08479930";
262///
263/// /// The name of the dataset.
264/// const IRIS_DATASET_NAME: &str = "iris";
265///
266/// use dataset_core::acquire_dataset;
267/// use dataset_core::download_to;
268///
269/// fn main() {
270///     let dir = "./somewhere";
271///
272///     let file_path = acquire_dataset(
273///         // Target storage directory path
274///         dir,
275///         // Final dataset filename (will be stored as `dir/filename`)
276///         IRIS_FILENAME,
277///         // Dataset name for error messages
278///         IRIS_DATASET_NAME,
279///         // Expected SHA256 hash of the dataset file
280///         Some(IRIS_SHA256),
281///         // Closure that prepares the dataset file in the temporary directory
282///         |temp_path| {
283///             // Download the dataset into the temporary directory
284///             download_to(IRIS_DATA_URL, temp_path, None)?;
285///             Ok(temp_path.join(IRIS_FILENAME))
286///         },
287///     ).unwrap();
288///
289///     // `file_path` is now the path to the acquired Iris dataset file.
290///     // It can be used to locate or parse the dataset.
291/// }
292/// ```
293pub fn acquire_dataset<F>(
294    dir: &str,
295    filename: &str,
296    dataset_name: &str,
297    expected_sha256: Option<&str>,
298    prepare_file: F,
299) -> Result<PathBuf, DatasetError>
300where
301    F: FnOnce(&Path) -> Result<PathBuf, DatasetError>,
302{
303    let dir_path = Path::new(dir);
304    let dst = dir_path.join(filename);
305
306    // Reuse a valid cached file without invoking the preparation closure.
307    let state = inspect_cache(dir_path, &dst, expected_sha256)?;
308    if matches!(state, CacheState::Fresh) {
309        return Ok(dst);
310    }
311
312    // Prepare the new file inside a temp dir that is cleaned up on drop (including
313    // on the early `return` below).
314    let temp_dir = create_temp_dir(dir_path)?;
315    let src = prepare_file(temp_dir.path())?;
316
317    // Validate the freshly prepared file before it lands at the final path.
318    if let Some(hash) = expected_sha256
319        && !file_sha256_matches(&src, hash)?
320    {
321        return Err(DatasetError::sha256_validation_failed(
322            dataset_name,
323            filename,
324        ));
325    }
326
327    // A stale file must be removed first: `fs::rename` does not overwrite on all platforms.
328    if matches!(state, CacheState::Stale) {
329        std::fs::remove_file(&dst)?;
330    }
331    std::fs::rename(&src, &dst)?;
332
333    Ok(dst)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::fs::{self, File, create_dir_all, remove_dir_all};
340    use std::io::Write;
341
342    /// SHA256 of "hello world"
343    const HELLO_WORLD_SHA256: &str =
344        "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
345
346    /// SHA256 of an empty file
347    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
348
349    /// All-zero hash (always wrong)
350    const ZERO_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
351
352    #[test]
353    fn create_temp_dir_returns_existing_path() {
354        let parent = "./test_create_temp_dir_returns_existing_path";
355        create_dir_all(parent).unwrap();
356
357        let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
358        assert!(temp_dir.path().exists());
359
360        remove_dir_all(parent).unwrap();
361    }
362
363    #[test]
364    fn create_temp_dir_cleanup_on_drop() {
365        let parent = "./test_create_temp_dir_cleanup_on_drop";
366        create_dir_all(parent).unwrap();
367
368        let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
369        let temp_path = temp_dir.path().to_path_buf();
370
371        assert!(temp_path.exists());
372        drop(temp_dir);
373        assert!(!temp_path.exists());
374
375        remove_dir_all(parent).unwrap();
376    }
377
378    #[test]
379    fn create_temp_dir_nonexistent_parent_errors() {
380        let result = create_temp_dir(Path::new("./nonexistent_parent_xyz_abc_123"));
381        assert!(result.is_err());
382    }
383
384    #[test]
385    fn file_sha256_matches_correct_hash() {
386        let dir = "./test_file_sha256_matches_correct_hash";
387        create_dir_all(dir).unwrap();
388        let path = Path::new(dir).join("f.txt");
389        File::create(&path)
390            .unwrap()
391            .write_all(b"hello world")
392            .unwrap();
393
394        assert!(file_sha256_matches(&path, HELLO_WORLD_SHA256).unwrap());
395
396        remove_dir_all(dir).unwrap();
397    }
398
399    #[test]
400    fn file_sha256_matches_uppercase_hash() {
401        let dir = "./test_file_sha256_matches_uppercase_hash";
402        create_dir_all(dir).unwrap();
403        let path = Path::new(dir).join("f.txt");
404        File::create(&path)
405            .unwrap()
406            .write_all(b"hello world")
407            .unwrap();
408
409        assert!(file_sha256_matches(&path, &HELLO_WORLD_SHA256.to_uppercase()).unwrap());
410
411        remove_dir_all(dir).unwrap();
412    }
413
414    #[test]
415    fn file_sha256_matches_wrong_hash_returns_false() {
416        let dir = "./test_file_sha256_matches_wrong_hash_returns_false";
417        create_dir_all(dir).unwrap();
418        let path = Path::new(dir).join("f.txt");
419        File::create(&path)
420            .unwrap()
421            .write_all(b"hello world")
422            .unwrap();
423
424        assert!(!file_sha256_matches(&path, ZERO_SHA256).unwrap());
425
426        remove_dir_all(dir).unwrap();
427    }
428
429    #[test]
430    fn file_sha256_matches_empty_file() {
431        let dir = "./test_file_sha256_matches_empty_file";
432        create_dir_all(dir).unwrap();
433        let path = Path::new(dir).join("empty.txt");
434        File::create(&path).unwrap();
435
436        assert!(file_sha256_matches(&path, EMPTY_SHA256).unwrap());
437
438        remove_dir_all(dir).unwrap();
439    }
440
441    #[test]
442    fn file_sha256_matches_nonexistent_file_errors() {
443        let result = file_sha256_matches(Path::new("./no_such_file_sha256_test.txt"), ZERO_SHA256);
444        assert!(result.is_err());
445    }
446
447    #[test]
448    fn filename_from_url_plain_segment() {
449        assert_eq!(
450            filename_from_url("https://x.test/a/iris.csv"),
451            Some("iris.csv")
452        );
453    }
454
455    #[test]
456    fn filename_from_url_strips_query_and_fragment() {
457        assert_eq!(
458            filename_from_url("https://x.test/a/iris.csv?raw=1"),
459            Some("iris.csv")
460        );
461        assert_eq!(
462            filename_from_url("https://x.test/a/iris.csv#section"),
463            Some("iris.csv")
464        );
465    }
466
467    #[test]
468    fn filename_from_url_trailing_slash_is_none() {
469        assert_eq!(filename_from_url("https://x.test/a/"), None);
470    }
471
472    #[test]
473    fn filename_from_url_no_slash() {
474        assert_eq!(filename_from_url("iris.csv"), Some("iris.csv"));
475    }
476
477    #[test]
478    fn inspect_cache_missing_then_fresh_and_stale() {
479        let dir = "./test_inspect_cache_states";
480        let dir_path = Path::new(dir);
481        let dst = dir_path.join("data.txt");
482        let _ = remove_dir_all(dir);
483
484        // Missing: directory is created on demand, file absent.
485        assert!(matches!(
486            inspect_cache(dir_path, &dst, None).unwrap(),
487            CacheState::Missing
488        ));
489        assert!(dir_path.exists());
490
491        // Fresh: file present, hash matches.
492        fs::write(&dst, b"hello world").unwrap();
493        assert!(matches!(
494            inspect_cache(dir_path, &dst, Some(HELLO_WORLD_SHA256)).unwrap(),
495            CacheState::Fresh
496        ));
497        // Fresh: no hash requested.
498        assert!(matches!(
499            inspect_cache(dir_path, &dst, None).unwrap(),
500            CacheState::Fresh
501        ));
502
503        // Stale: file present but hash mismatches.
504        assert!(matches!(
505            inspect_cache(dir_path, &dst, Some(ZERO_SHA256)).unwrap(),
506            CacheState::Stale
507        ));
508
509        remove_dir_all(dir).unwrap();
510    }
511}