Skip to main content

dataset_core/
utils.rs

1use crate::DatasetError;
2use flate2::read::GzDecoder;
3use sha2::{Digest, Sha256};
4use std::fmt::Write;
5use std::fs::File;
6use std::io;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use tar::Archive;
10use zip::ZipArchive;
11use zip::result::ZipError;
12
13/// Download a remote file into the given directory.
14///
15/// It downloads the content at `url` (using [`ureq`] crate) into `storage_path` using the file name
16/// extracted from the last segment of the URL, unless a custom filename is provided.
17///
18/// When the filename is derived from the URL, any trailing `?query` or `#fragment` is stripped
19/// (e.g. `.../iris.csv?raw=1` yields `iris.csv`). A URL that ends in `/` has no final segment, so
20/// a custom `filename` must be supplied in that case.
21///
22/// # Parameters
23///
24/// - `url` - The URL to download.
25/// - `storage_path` - The directory to store the downloaded file in.
26/// - `filename` - Optional custom filename (with extension). If `None`, the filename is extracted
27///   from the last segment of the URL.
28///
29/// # Errors
30///
31/// - `DatasetError` - Returned when the download fails or the filename cannot be derived from the URL.
32///
33/// # Example
34/// ```no_run
35/// use dataset_core::download_to;
36/// use std::path::Path;
37///
38/// let download_dir = "./download_example";
39/// std::fs::create_dir_all(download_dir).unwrap();
40///
41/// // Download a file from the internet
42/// let url = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
43///
44/// // Use filename from URL
45/// download_to(url, Path::new(download_dir), None).unwrap();
46/// assert!(Path::new(download_dir).join("iris.csv").exists());
47///
48/// // Use custom filename
49/// download_to(url, Path::new(download_dir), Some("custom.csv")).unwrap();
50/// assert!(Path::new(download_dir).join("custom.csv").exists());
51/// ```
52pub fn download_to(
53    url: &str,
54    storage_path: &Path,
55    filename: Option<&str>,
56) -> Result<(), DatasetError> {
57    // Get the filename: use provided name, or fall back to URL extraction
58    let filename = match filename {
59        Some(name) => name,
60        None => filename_from_url(url).ok_or_else(|| {
61            DatasetError::ValidationError(
62                "Invalid URL: cannot extract filename from URL".to_string(),
63            )
64        })?,
65    };
66
67    let save_path = storage_path.join(filename);
68
69    let mut response = ureq::get(url).call()?;
70    let mut body = response.body_mut().as_reader();
71
72    // create local file and write body to it
73    let mut file = File::create(save_path)?;
74    io::copy(&mut body, &mut file)?;
75
76    Ok(())
77}
78
79/// Derive a filename from the last path segment of a URL.
80///
81/// Strips any `?query` / `#fragment` suffix and returns `None` when the resulting
82/// segment is empty (e.g. the URL ends in `/`).
83fn filename_from_url(url: &str) -> Option<&str> {
84    // `rsplit('/').next()` yields the whole string when there is no '/'.
85    let last_segment = url.rsplit('/').next()?;
86    let name = last_segment
87        .split(['?', '#'])
88        .next()
89        .unwrap_or(last_segment);
90    (!name.is_empty()).then_some(name)
91}
92
93/// Extract a zip archive into a target directory using [`ZipArchive`] in [`zip`] crate.
94///
95/// # Parameters
96///
97/// - `file_path` - Path to the `.zip` file to extract.
98/// - `extract_dir` - Directory to extract the archive contents into.
99///
100/// # Errors
101///
102/// - `DatasetError` - Returned when opening the zip file fails or when extraction fails.
103///
104/// # Example
105/// ```no_run
106/// use dataset_core::unzip;
107/// use std::fs::File;
108/// use std::io::Write;
109/// use std::path::Path;
110/// use zip::write::SimpleFileOptions;
111///
112/// let work_dir = Path::new("./unzip_example");
113/// std::fs::create_dir_all(work_dir).unwrap();
114///
115/// // Build a small `.zip` containing a single `hello.txt` entry.
116/// let archive_path = work_dir.join("data.zip");
117/// let mut zip = zip::ZipWriter::new(File::create(&archive_path).unwrap());
118/// zip.start_file("hello.txt", SimpleFileOptions::default()).unwrap();
119/// zip.write_all(b"hello world").unwrap();
120/// zip.finish().unwrap();
121///
122/// // Extract it into `work_dir`.
123/// unzip(&archive_path, work_dir).unwrap();
124/// assert!(work_dir.join("hello.txt").exists());
125/// ```
126pub fn unzip(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
127    let file = File::open(file_path).map_err(|e| DatasetError::from(ZipError::Io(e)))?;
128
129    ZipArchive::new(file)?.extract(extract_dir)?;
130
131    Ok(())
132}
133
134/// Decompress a gzip (`.gz`) file into a single output file.
135///
136/// Unlike [`unzip`], which extracts a multi-entry archive into a directory, a gzip
137/// stream wraps exactly **one** file, so this writes the decompressed bytes to a
138/// single `output_path`. It streams through [`flate2::read::GzDecoder`], so the
139/// whole file is never held in memory at once — suitable for large datasets such as
140/// the gzip-compressed `covtype.data.gz`.
141///
142/// The output file is created (or truncated if it already exists). Any leading
143/// directories in `output_path` must already exist.
144///
145/// # Parameters
146///
147/// - `file_path` - Path to the `.gz` file to decompress.
148/// - `output_path` - Path of the decompressed file to write (including filename).
149///
150/// # Errors
151///
152/// - `DatasetError::IoError` - Returned when opening the source fails, the gzip
153///   stream is malformed, or writing the output fails.
154///
155/// # Example
156/// ```no_run
157/// use dataset_core::{download_to, gunzip};
158/// use std::path::Path;
159///
160/// let work_dir = Path::new("./gunzip_example");
161/// std::fs::create_dir_all(work_dir).unwrap();
162///
163/// // Download a gzip-compressed dataset, then decompress it in place.
164/// download_to("https://example.com/data.csv.gz", work_dir, Some("data.csv.gz")).unwrap();
165/// gunzip(&work_dir.join("data.csv.gz"), &work_dir.join("data.csv")).unwrap();
166/// assert!(work_dir.join("data.csv").exists());
167/// ```
168pub fn gunzip(file_path: &Path, output_path: &Path) -> Result<(), DatasetError> {
169    let input = File::open(file_path)?;
170    let mut decoder = GzDecoder::new(input);
171    let mut output = File::create(output_path)?;
172    io::copy(&mut decoder, &mut output)?;
173
174    Ok(())
175}
176
177/// Extract a tar (`.tar`) archive into a target directory using [`tar::Archive`].
178///
179/// This is the tar analogue of [`unzip`]: a `.tar` bundles a whole directory tree
180/// (unlike [`gunzip`], which decompresses a single-file gzip stream). For the very
181/// common gzip-compressed tarball (`.tar.gz` / `.tgz`), use [`untar_gz`], which
182/// streams the decompression and extraction together without writing an
183/// intermediate `.tar` to disk.
184///
185/// The archive's entries are unpacked **relative to** `extract_dir` (which is
186/// created if needed); the [`tar`] crate rejects entries whose paths would escape
187/// it.
188///
189/// # Parameters
190///
191/// - `file_path` - Path to the `.tar` file to extract.
192/// - `extract_dir` - Directory to extract the archive contents into.
193///
194/// # Errors
195///
196/// - `DatasetError::IoError` - Returned when opening the archive fails or when
197///   extraction fails (a malformed archive, or an entry that cannot be written).
198///
199/// # Example
200/// ```no_run
201/// use dataset_core::{download_to, untar};
202/// use std::path::Path;
203///
204/// let work_dir = Path::new("./untar_example");
205/// std::fs::create_dir_all(work_dir).unwrap();
206///
207/// // Download a tar archive, then extract it in place.
208/// download_to("https://example.com/data.tar", work_dir, Some("data.tar")).unwrap();
209/// untar(&work_dir.join("data.tar"), work_dir).unwrap();
210/// ```
211pub fn untar(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
212    let file = File::open(file_path)?;
213    Archive::new(file).unpack(extract_dir)?;
214
215    Ok(())
216}
217
218/// Extract a gzip-compressed tar (`.tar.gz` / `.tgz`) archive into a target directory.
219///
220/// This composes the two layers of a gzipped tarball in one streaming pass: the
221/// bytes flow through [`flate2::read::GzDecoder`] (the gzip layer, as in
222/// [`gunzip`]) straight into [`tar::Archive`] (the tar layer, as in [`untar`]), so
223/// the intermediate uncompressed `.tar` is never written to disk — suitable for
224/// large datasets distributed as `.tar.gz`.
225///
226/// The archive's entries are unpacked **relative to** `extract_dir` (which is
227/// created if needed); the [`tar`] crate rejects entries whose paths would escape
228/// it.
229///
230/// # Parameters
231///
232/// - `file_path` - Path to the `.tar.gz` (or `.tgz`) file to extract.
233/// - `extract_dir` - Directory to extract the archive contents into.
234///
235/// # Errors
236///
237/// - `DatasetError::IoError` - Returned when opening the source fails, the gzip
238///   stream is malformed, or the tar extraction fails.
239///
240/// # Example
241/// ```no_run
242/// use dataset_core::{download_to, untar_gz};
243/// use std::path::Path;
244///
245/// let work_dir = Path::new("./untar_gz_example");
246/// std::fs::create_dir_all(work_dir).unwrap();
247///
248/// // Download a gzip-compressed tarball, then extract it in place.
249/// download_to("https://example.com/data.tar.gz", work_dir, Some("data.tar.gz")).unwrap();
250/// untar_gz(&work_dir.join("data.tar.gz"), work_dir).unwrap();
251/// ```
252pub fn untar_gz(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
253    let input = File::open(file_path)?;
254    let decoder = GzDecoder::new(input);
255    Archive::new(decoder).unpack(extract_dir)?;
256
257    Ok(())
258}
259
260/// Create a temporary directory under the given parent directory.
261///
262/// A small wrapper around [`tempfile::Builder`] used internally by [`acquire_dataset`] to keep
263/// intermediate download/extraction artifacts isolated. The created directory is removed
264/// automatically when the returned [`tempfile::TempDir`] is dropped.
265fn create_temp_dir(tempdir_in: &Path) -> Result<tempfile::TempDir, DatasetError> {
266    let temp_dir = tempfile::Builder::new().tempdir_in(tempdir_in)?;
267
268    Ok(temp_dir)
269}
270
271/// Verify that a file's SHA256 hash matches an expected value (case-insensitive).
272///
273/// Used internally by [`acquire_dataset`] to validate cached and freshly prepared files.
274/// Returns `true` when the computed hash matches `expected_hex`.
275///
276/// # Errors
277///
278/// - `DatasetError::IoError` - Returned when file I/O operations fail (opening file, reading data).
279fn file_sha256_matches(path: &Path, expected_hex: &str) -> Result<bool, DatasetError> {
280    let mut file = File::open(path)?;
281
282    let mut hasher = Sha256::new();
283    let mut buf = [0u8; 8192];
284
285    loop {
286        let read = file.read(&mut buf)?;
287        if read == 0 {
288            break;
289        }
290        hasher.update(&buf[..read]);
291    }
292
293    let digest = hasher.finalize();
294    let mut actual_hex = String::with_capacity(digest.len() * 2);
295    for b in digest {
296        // Writing formatted bytes into a `String` is infallible.
297        let _ = write!(actual_hex, "{:02x}", b);
298    }
299    Ok(actual_hex.eq_ignore_ascii_case(expected_hex))
300}
301
302/// State of the destination file relative to the dataset we want to cache.
303enum CacheState {
304    /// Destination exists and (if a hash was given) matches — reuse it as-is.
305    Fresh,
306    /// Destination exists but its hash does not match — it must be replaced.
307    Stale,
308    /// Destination does not exist — a new file must be prepared.
309    Missing,
310}
311
312/// Ensure `dir` exists, then classify the destination file `dst`.
313///
314/// When `expected_sha256` is `None`, any existing file counts as [`CacheState::Fresh`].
315fn inspect_cache(
316    dir: &Path,
317    dst: &Path,
318    expected_sha256: Option<&str>,
319) -> Result<CacheState, DatasetError> {
320    if !dir.exists() {
321        std::fs::create_dir_all(dir)?;
322    }
323
324    if !dst.exists() {
325        return Ok(CacheState::Missing);
326    }
327
328    match expected_sha256 {
329        Some(hash) if !file_sha256_matches(dst, hash)? => Ok(CacheState::Stale),
330        _ => Ok(CacheState::Fresh),
331    }
332}
333
334/// Acquire a dataset file using a caller-provided preparation closure.
335///
336/// This is the single entry point for the dataset acquisition workflow and the
337/// recommended way to populate a storage directory. It checks whether the destination
338/// file can be reused, creates a temporary directory when a new file is needed,
339/// delegates file preparation to a user-provided closure, optionally validates the
340/// prepared file with SHA256, and atomically moves it to the final destination.
341///
342/// The function itself does not perform network I/O. The `prepare_file` closure
343/// is responsible for preparing the dataset file, which may include downloading,
344/// extracting archives, or locating files within an extracted directory.
345///
346/// # Parameters
347///
348/// - `dir` - Target storage directory path.
349/// - `filename` - Final dataset filename (stored as `dir/filename`).
350///   Please give the filename with the extension (e.g., `"iris.csv"`).
351/// - `dataset_name` - Dataset name for error messages (e.g., `"iris"`).
352/// - `expected_sha256` - Optional expected SHA256 hash of the dataset file. If `None`,
353///   any existing file at the destination is accepted without validation, and newly
354///   prepared files skip SHA256 verification.
355/// - `prepare_file` - Closure that prepares the dataset file in the temporary directory.
356///   - Input: `temp_dir: &Path` - Path to the temporary directory.
357///     It is recommended to execute file operations within this directory, as it will be
358///     cleaned up automatically when the closure returns. But it is not required.
359///     (Please note that the file will be moved to the final destination, not copied.)
360///   - Output: `Result<PathBuf, DatasetError>` - Path to the prepared dataset file
361///     (which will be moved to `dir/filename`).
362///   - Responsibility: This closure can perform any operations needed to prepare the
363///     dataset file, such as downloading (you can use [`download_to`] provided in this crate),
364///     extracting archives (you can use [`unzip`] provided in this crate), or locating files
365///     within extracted folders. The returned `PathBuf` must point to the final dataset file
366///     ready for validation.
367///
368/// # Returns
369///
370/// - `PathBuf` - Path to the final dataset file (`dir/filename`).
371///
372/// # Errors
373///
374/// - `DatasetError::IoError` - Returned when directory creation, file operations, or
375///   hash verification fails.
376/// - `DatasetError::Sha256ValidationFailed` - Returned when `expected_sha256` is provided
377///   and the prepared file's SHA256 hash does not match it.
378/// - Any error returned by the `prepare_file` closure.
379///
380/// # Example
381/// ```no_run
382/// // Implement the file preparation process for the Iris dataset.
383///
384/// /// The URL for the Iris dataset.
385/// ///
386/// /// # Citation
387/// ///
388/// /// R. A. Fisher. "Iris," UCI Machine Learning Repository, \[Online\].
389/// /// Available: <https://doi.org/10.24432/C56C76>
390/// const IRIS_DATA_URL: &str = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
391///
392/// /// The name of the Iris dataset file.
393/// const IRIS_FILENAME: &str = "iris.csv";
394///
395/// /// The SHA256 hash of the Iris dataset file.
396/// const IRIS_SHA256: &str = "c52742e50315a99f956a383faedf7575552675f6409ef0f9a47076dd08479930";
397///
398/// /// The name of the dataset.
399/// const IRIS_DATASET_NAME: &str = "iris";
400///
401/// use dataset_core::acquire_dataset;
402/// use dataset_core::download_to;
403///
404/// fn main() {
405///     let dir = "./somewhere";
406///
407///     let file_path = acquire_dataset(
408///         // Target storage directory path
409///         dir,
410///         // Final dataset filename (will be stored as `dir/filename`)
411///         IRIS_FILENAME,
412///         // Dataset name for error messages
413///         IRIS_DATASET_NAME,
414///         // Expected SHA256 hash of the dataset file
415///         Some(IRIS_SHA256),
416///         // Closure that prepares the dataset file in the temporary directory
417///         |temp_path| {
418///             // Download the dataset into the temporary directory
419///             download_to(IRIS_DATA_URL, temp_path, None)?;
420///             Ok(temp_path.join(IRIS_FILENAME))
421///         },
422///     ).unwrap();
423///
424///     // `file_path` is now the path to the acquired Iris dataset file.
425///     // It can be used to locate or parse the dataset.
426/// }
427/// ```
428pub fn acquire_dataset<F>(
429    dir: &str,
430    filename: &str,
431    dataset_name: &str,
432    expected_sha256: Option<&str>,
433    prepare_file: F,
434) -> Result<PathBuf, DatasetError>
435where
436    F: FnOnce(&Path) -> Result<PathBuf, DatasetError>,
437{
438    let dir_path = Path::new(dir);
439    let dst = dir_path.join(filename);
440
441    // Reuse a valid cached file without invoking the preparation closure.
442    let state = inspect_cache(dir_path, &dst, expected_sha256)?;
443    if matches!(state, CacheState::Fresh) {
444        return Ok(dst);
445    }
446
447    // Prepare the new file inside a temp dir that is cleaned up on drop (including
448    // on the early `return` below).
449    let temp_dir = create_temp_dir(dir_path)?;
450    let src = prepare_file(temp_dir.path())?;
451
452    // Validate the freshly prepared file before it lands at the final path.
453    if let Some(hash) = expected_sha256
454        && !file_sha256_matches(&src, hash)?
455    {
456        return Err(DatasetError::sha256_validation_failed(
457            dataset_name,
458            filename,
459        ));
460    }
461
462    // A stale file must be removed first: `fs::rename` does not overwrite on all platforms.
463    if matches!(state, CacheState::Stale) {
464        std::fs::remove_file(&dst)?;
465    }
466    std::fs::rename(&src, &dst)?;
467
468    Ok(dst)
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use std::fs::{self, File, create_dir_all, remove_dir_all};
475    use std::io::Write;
476
477    /// SHA256 of "hello world"
478    const HELLO_WORLD_SHA256: &str =
479        "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
480
481    /// SHA256 of an empty file
482    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
483
484    /// All-zero hash (always wrong)
485    const ZERO_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
486
487    #[test]
488    fn create_temp_dir_returns_existing_path() {
489        let parent = "./test_create_temp_dir_returns_existing_path";
490        create_dir_all(parent).unwrap();
491
492        let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
493        assert!(temp_dir.path().exists());
494
495        remove_dir_all(parent).unwrap();
496    }
497
498    #[test]
499    fn create_temp_dir_cleanup_on_drop() {
500        let parent = "./test_create_temp_dir_cleanup_on_drop";
501        create_dir_all(parent).unwrap();
502
503        let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
504        let temp_path = temp_dir.path().to_path_buf();
505
506        assert!(temp_path.exists());
507        drop(temp_dir);
508        assert!(!temp_path.exists());
509
510        remove_dir_all(parent).unwrap();
511    }
512
513    #[test]
514    fn create_temp_dir_nonexistent_parent_errors() {
515        let result = create_temp_dir(Path::new("./nonexistent_parent_xyz_abc_123"));
516        assert!(result.is_err());
517    }
518
519    /// Compress `content` into a gzip file at `path` (test helper).
520    fn write_gz(path: &Path, content: &[u8]) {
521        use flate2::Compression;
522        use flate2::write::GzEncoder;
523
524        let file = File::create(path).unwrap();
525        let mut encoder = GzEncoder::new(file, Compression::default());
526        encoder.write_all(content).unwrap();
527        encoder.finish().unwrap();
528    }
529
530    #[test]
531    fn gunzip_round_trips_content() {
532        let dir = "./test_gunzip_round_trips_content";
533        create_dir_all(dir).unwrap();
534        let dir_path = Path::new(dir);
535
536        let payload = b"col_a,col_b\n1,2\n3,4\n";
537        let gz_path = dir_path.join("data.csv.gz");
538        let out_path = dir_path.join("data.csv");
539        write_gz(&gz_path, payload);
540
541        gunzip(&gz_path, &out_path).unwrap();
542
543        assert_eq!(fs::read(&out_path).unwrap(), payload);
544
545        remove_dir_all(dir).unwrap();
546    }
547
548    #[test]
549    fn gunzip_nonexistent_source_errors() {
550        let result = gunzip(
551            Path::new("./no_such_file_for_gunzip_test.gz"),
552            Path::new("./no_such_file_for_gunzip_test.out"),
553        );
554        assert!(result.is_err());
555    }
556
557    /// Build a tar archive at `path` from `(name, content)` entries (test helper).
558    fn write_tar(path: &Path, entries: &[(&str, &[u8])]) {
559        let file = File::create(path).unwrap();
560        let mut builder = tar::Builder::new(file);
561        for (name, content) in entries {
562            let mut header = tar::Header::new_gnu();
563            header.set_size(content.len() as u64);
564            header.set_mode(0o644);
565            header.set_cksum();
566            builder.append_data(&mut header, name, *content).unwrap();
567        }
568        builder.into_inner().unwrap().sync_all().unwrap();
569    }
570
571    /// Build a gzip-compressed tar archive at `path` (test helper).
572    fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) {
573        use flate2::Compression;
574        use flate2::write::GzEncoder;
575
576        let file = File::create(path).unwrap();
577        let encoder = GzEncoder::new(file, Compression::default());
578        let mut builder = tar::Builder::new(encoder);
579        for (name, content) in entries {
580            let mut header = tar::Header::new_gnu();
581            header.set_size(content.len() as u64);
582            header.set_mode(0o644);
583            header.set_cksum();
584            builder.append_data(&mut header, name, *content).unwrap();
585        }
586        builder.into_inner().unwrap().finish().unwrap();
587    }
588
589    #[test]
590    fn untar_round_trips_entries() {
591        let dir = "./test_untar_round_trips_entries";
592        create_dir_all(dir).unwrap();
593        let dir_path = Path::new(dir);
594
595        let tar_path = dir_path.join("data.tar");
596        write_tar(
597            &tar_path,
598            &[("a.txt", b"hello"), ("nested/b.txt", b"world")],
599        );
600
601        let out_dir = dir_path.join("extracted");
602        untar(&tar_path, &out_dir).unwrap();
603
604        assert_eq!(fs::read(out_dir.join("a.txt")).unwrap(), b"hello");
605        assert_eq!(fs::read(out_dir.join("nested/b.txt")).unwrap(), b"world");
606
607        remove_dir_all(dir).unwrap();
608    }
609
610    #[test]
611    fn untar_nonexistent_source_errors() {
612        let result = untar(
613            Path::new("./no_such_file_for_untar_test.tar"),
614            Path::new("./no_such_dir_for_untar_test"),
615        );
616        assert!(result.is_err());
617    }
618
619    #[test]
620    fn untar_gz_round_trips_entries() {
621        let dir = "./test_untar_gz_round_trips_entries";
622        create_dir_all(dir).unwrap();
623        let dir_path = Path::new(dir);
624
625        let tar_gz_path = dir_path.join("data.tar.gz");
626        write_tar_gz(
627            &tar_gz_path,
628            &[("a.txt", b"hello"), ("nested/b.txt", b"world")],
629        );
630
631        let out_dir = dir_path.join("extracted");
632        untar_gz(&tar_gz_path, &out_dir).unwrap();
633
634        assert_eq!(fs::read(out_dir.join("a.txt")).unwrap(), b"hello");
635        assert_eq!(fs::read(out_dir.join("nested/b.txt")).unwrap(), b"world");
636
637        remove_dir_all(dir).unwrap();
638    }
639
640    #[test]
641    fn untar_gz_nonexistent_source_errors() {
642        let result = untar_gz(
643            Path::new("./no_such_file_for_untar_gz_test.tar.gz"),
644            Path::new("./no_such_dir_for_untar_gz_test"),
645        );
646        assert!(result.is_err());
647    }
648
649    #[test]
650    fn file_sha256_matches_correct_hash() {
651        let dir = "./test_file_sha256_matches_correct_hash";
652        create_dir_all(dir).unwrap();
653        let path = Path::new(dir).join("f.txt");
654        File::create(&path)
655            .unwrap()
656            .write_all(b"hello world")
657            .unwrap();
658
659        assert!(file_sha256_matches(&path, HELLO_WORLD_SHA256).unwrap());
660
661        remove_dir_all(dir).unwrap();
662    }
663
664    #[test]
665    fn file_sha256_matches_uppercase_hash() {
666        let dir = "./test_file_sha256_matches_uppercase_hash";
667        create_dir_all(dir).unwrap();
668        let path = Path::new(dir).join("f.txt");
669        File::create(&path)
670            .unwrap()
671            .write_all(b"hello world")
672            .unwrap();
673
674        assert!(file_sha256_matches(&path, &HELLO_WORLD_SHA256.to_uppercase()).unwrap());
675
676        remove_dir_all(dir).unwrap();
677    }
678
679    #[test]
680    fn file_sha256_matches_wrong_hash_returns_false() {
681        let dir = "./test_file_sha256_matches_wrong_hash_returns_false";
682        create_dir_all(dir).unwrap();
683        let path = Path::new(dir).join("f.txt");
684        File::create(&path)
685            .unwrap()
686            .write_all(b"hello world")
687            .unwrap();
688
689        assert!(!file_sha256_matches(&path, ZERO_SHA256).unwrap());
690
691        remove_dir_all(dir).unwrap();
692    }
693
694    #[test]
695    fn file_sha256_matches_empty_file() {
696        let dir = "./test_file_sha256_matches_empty_file";
697        create_dir_all(dir).unwrap();
698        let path = Path::new(dir).join("empty.txt");
699        File::create(&path).unwrap();
700
701        assert!(file_sha256_matches(&path, EMPTY_SHA256).unwrap());
702
703        remove_dir_all(dir).unwrap();
704    }
705
706    #[test]
707    fn file_sha256_matches_nonexistent_file_errors() {
708        let result = file_sha256_matches(Path::new("./no_such_file_sha256_test.txt"), ZERO_SHA256);
709        assert!(result.is_err());
710    }
711
712    #[test]
713    fn filename_from_url_plain_segment() {
714        assert_eq!(
715            filename_from_url("https://x.test/a/iris.csv"),
716            Some("iris.csv")
717        );
718    }
719
720    #[test]
721    fn filename_from_url_strips_query_and_fragment() {
722        assert_eq!(
723            filename_from_url("https://x.test/a/iris.csv?raw=1"),
724            Some("iris.csv")
725        );
726        assert_eq!(
727            filename_from_url("https://x.test/a/iris.csv#section"),
728            Some("iris.csv")
729        );
730    }
731
732    #[test]
733    fn filename_from_url_trailing_slash_is_none() {
734        assert_eq!(filename_from_url("https://x.test/a/"), None);
735    }
736
737    #[test]
738    fn filename_from_url_no_slash() {
739        assert_eq!(filename_from_url("iris.csv"), Some("iris.csv"));
740    }
741
742    #[test]
743    fn inspect_cache_missing_then_fresh_and_stale() {
744        let dir = "./test_inspect_cache_states";
745        let dir_path = Path::new(dir);
746        let dst = dir_path.join("data.txt");
747        let _ = remove_dir_all(dir);
748
749        // Missing: directory is created on demand, file absent.
750        assert!(matches!(
751            inspect_cache(dir_path, &dst, None).unwrap(),
752            CacheState::Missing
753        ));
754        assert!(dir_path.exists());
755
756        // Fresh: file present, hash matches.
757        fs::write(&dst, b"hello world").unwrap();
758        assert!(matches!(
759            inspect_cache(dir_path, &dst, Some(HELLO_WORLD_SHA256)).unwrap(),
760            CacheState::Fresh
761        ));
762        // Fresh: no hash requested.
763        assert!(matches!(
764            inspect_cache(dir_path, &dst, None).unwrap(),
765            CacheState::Fresh
766        ));
767
768        // Stale: file present but hash mismatches.
769        assert!(matches!(
770            inspect_cache(dir_path, &dst, Some(ZERO_SHA256)).unwrap(),
771            CacheState::Stale
772        ));
773
774        remove_dir_all(dir).unwrap();
775    }
776}