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` into `storage_path`, using the [`ureq`] crate. It names the
16/// file after the last segment of the URL, unless the caller supplies a custom filename.
17///
18/// When the filename comes from the URL, it strips any trailing `?query` or `#fragment` (for
19/// example, `.../iris.csv?raw=1` yields `iris.csv`). A URL that ends in `/` has no final segment,
20/// so the caller must supply a custom `filename` 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 comes from
27///   the last segment of the URL.
28///
29/// # Errors
30///
31/// - `DatasetError` - Returned when the download fails, or when the function cannot derive the
32///   filename from the URL.
33///
34/// # Example
35/// ```no_run
36/// use dataset_core::download_to;
37/// use std::path::Path;
38///
39/// let download_dir = "./download_example";
40/// std::fs::create_dir_all(download_dir).unwrap();
41///
42/// // Download a file from the internet
43/// let url = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
44///
45/// // Use filename from URL
46/// download_to(url, Path::new(download_dir), None).unwrap();
47/// assert!(Path::new(download_dir).join("iris.csv").exists());
48///
49/// // Use custom filename
50/// download_to(url, Path::new(download_dir), Some("custom.csv")).unwrap();
51/// assert!(Path::new(download_dir).join("custom.csv").exists());
52/// ```
53pub fn download_to(
54    url: &str,
55    storage_path: &Path,
56    filename: Option<&str>,
57) -> Result<(), DatasetError> {
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    let mut file = File::create(save_path)?;
73    io::copy(&mut body, &mut file)?;
74
75    Ok(())
76}
77
78/// Download a remote file into the given directory. It retries transient failures.
79///
80/// This function wraps [`download_to`] for the unreliable hosts where many
81/// public datasets live. If the download fails, it retries up to `retries` more
82/// times. Each try waits twice as long as the last (500 ms, then 1 s, 2 s, and
83/// so on). When the caller sets `retries` to `0`, this function behaves exactly
84/// like [`download_to`].
85///
86/// A retry cannot fix two kinds of failure: a filename the function cannot
87/// derive from the URL, and a local file the function cannot create. The
88/// function returns either failure right away, without a retry. Once every try
89/// fails, it returns the last download error.
90///
91/// # Parameters
92///
93/// - `url` - The URL to download.
94/// - `storage_path` - The directory to store the downloaded file in.
95/// - `filename` - Optional custom filename (with extension). If `None`, the filename comes from
96///   the last segment of the URL.
97/// - `retries` - How many **additional** tries to make after the first one fails.
98///
99/// # Errors
100///
101/// - `DatasetError` - Returned when every try fails (it returns the last error), or right
102///   away for an error a retry cannot fix.
103///
104/// # Example
105/// ```no_run
106/// use dataset_core::download_to_with_retries;
107/// use std::path::Path;
108///
109/// let download_dir = Path::new("./download_retry_example");
110/// std::fs::create_dir_all(download_dir).unwrap();
111///
112/// // Try up to three times in total before giving up.
113/// download_to_with_retries(
114///     "https://archive.ics.uci.edu/static/public/53/iris.zip",
115///     download_dir,
116///     Some("iris.zip"),
117///     2,
118/// )
119/// .unwrap();
120/// ```
121pub fn download_to_with_retries(
122    url: &str,
123    storage_path: &Path,
124    filename: Option<&str>,
125    retries: u32,
126) -> Result<(), DatasetError> {
127    let mut attempt = 0;
128
129    loop {
130        match download_to(url, storage_path, filename) {
131            Ok(()) => return Ok(()),
132            // A retry cannot fix a malformed URL or an unwritable target.
133            Err(e @ (DatasetError::ValidationError(_) | DatasetError::IoError(_))) => {
134                return Err(e);
135            }
136            Err(e) => {
137                if attempt >= retries {
138                    return Err(e);
139                }
140                std::thread::sleep(RETRY_BASE_DELAY * 2u32.pow(attempt));
141                attempt += 1;
142            }
143        }
144    }
145}
146
147/// How long [`download_to_with_retries`] waits before its first retry. Each further retry
148/// doubles this wait.
149const RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
150
151/// Derive a filename from the last path segment of a URL.
152///
153/// It strips any `?query` / `#fragment` suffix, and returns `None` when the resulting segment
154/// is empty (for example, when the URL ends in `/`).
155fn filename_from_url(url: &str) -> Option<&str> {
156    // `rsplit('/').next()` yields the whole string when there is no '/'.
157    let last_segment = url.rsplit('/').next()?;
158    let name = last_segment
159        .split(['?', '#'])
160        .next()
161        .unwrap_or(last_segment);
162    (!name.is_empty()).then_some(name)
163}
164
165/// Extract a zip archive into a target directory.
166///
167/// It uses [`ZipArchive`] from the [`zip`] crate.
168///
169/// # Parameters
170///
171/// - `file_path` - Path to the `.zip` file to extract.
172/// - `extract_dir` - Directory to extract the archive contents into.
173///
174/// # Errors
175///
176/// - `DatasetError` - Returned when opening the zip file fails or when extraction fails.
177///
178/// # Example
179/// ```no_run
180/// use dataset_core::unzip;
181/// use std::fs::File;
182/// use std::io::Write;
183/// use std::path::Path;
184/// use zip::write::SimpleFileOptions;
185///
186/// let work_dir = Path::new("./unzip_example");
187/// std::fs::create_dir_all(work_dir).unwrap();
188///
189/// // Build a small `.zip` containing a single `hello.txt` entry.
190/// let archive_path = work_dir.join("data.zip");
191/// let mut zip = zip::ZipWriter::new(File::create(&archive_path).unwrap());
192/// zip.start_file("hello.txt", SimpleFileOptions::default()).unwrap();
193/// zip.write_all(b"hello world").unwrap();
194/// zip.finish().unwrap();
195///
196/// // Extract it into `work_dir`.
197/// unzip(&archive_path, work_dir).unwrap();
198/// assert!(work_dir.join("hello.txt").exists());
199/// ```
200pub fn unzip(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
201    let file = File::open(file_path).map_err(|e| DatasetError::from(ZipError::Io(e)))?;
202
203    ZipArchive::new(file)?.extract(extract_dir)?;
204
205    Ok(())
206}
207
208/// Decompress a gzip (`.gz`) file into a single output file.
209///
210/// Unlike [`unzip`], which extracts a multi-entry archive into a directory, a gzip stream wraps
211/// exactly **one** file. As a result, this function writes the decompressed bytes to a single
212/// `output_path`. It streams the bytes through [`flate2::read::GzDecoder`], so it never holds
213/// the whole file in memory at once. This suits large datasets such as the gzip-compressed
214/// `covtype.data.gz`.
215///
216/// This function creates the output file, or truncates it if the file already exists. Any
217/// leading directories in `output_path` must already exist.
218///
219/// # Parameters
220///
221/// - `file_path` - Path to the `.gz` file to decompress.
222/// - `output_path` - Path of the decompressed file to write (including filename).
223///
224/// # Errors
225///
226/// - `DatasetError::IoError` - Returned when opening the source fails, the gzip
227///   stream is malformed, or writing the output fails.
228///
229/// # Example
230/// ```no_run
231/// use dataset_core::{download_to, gunzip};
232/// use std::path::Path;
233///
234/// let work_dir = Path::new("./gunzip_example");
235/// std::fs::create_dir_all(work_dir).unwrap();
236///
237/// // Download a gzip-compressed dataset. Then decompress it in place.
238/// download_to("https://example.com/data.csv.gz", work_dir, Some("data.csv.gz")).unwrap();
239/// gunzip(&work_dir.join("data.csv.gz"), &work_dir.join("data.csv")).unwrap();
240/// assert!(work_dir.join("data.csv").exists());
241/// ```
242pub fn gunzip(file_path: &Path, output_path: &Path) -> Result<(), DatasetError> {
243    let input = File::open(file_path)?;
244    let mut decoder = GzDecoder::new(input);
245    let mut output = File::create(output_path)?;
246    io::copy(&mut decoder, &mut output)?;
247
248    Ok(())
249}
250
251/// Extract a tar (`.tar`) archive into a target directory.
252///
253/// It uses [`tar::Archive`] for the extraction.
254///
255/// This is the tar analogue of [`unzip`]: a `.tar` bundles a whole directory tree (unlike
256/// [`gunzip`], which decompresses a single-file gzip stream). For a common gzip-compressed
257/// tarball (`.tar.gz` / `.tgz`), use [`untar_gz`] instead. It streams the decompression and
258/// extraction together, and never writes an intermediate `.tar` file to disk.
259///
260/// This function unpacks the archive's entries **relative to** `extract_dir`, and creates that
261/// directory if needed. The [`tar`] crate rejects any entry whose path would escape
262/// `extract_dir`.
263///
264/// # Parameters
265///
266/// - `file_path` - Path to the `.tar` file to extract.
267/// - `extract_dir` - Directory to extract the archive contents into.
268///
269/// # Errors
270///
271/// - `DatasetError::IoError` - Returned when opening the archive fails or when
272///   extraction fails: the archive is malformed, or the function cannot write
273///   an entry.
274///
275/// # Example
276/// ```no_run
277/// use dataset_core::{download_to, untar};
278/// use std::path::Path;
279///
280/// let work_dir = Path::new("./untar_example");
281/// std::fs::create_dir_all(work_dir).unwrap();
282///
283/// // Download a tar archive. Then extract it in place.
284/// download_to("https://example.com/data.tar", work_dir, Some("data.tar")).unwrap();
285/// untar(&work_dir.join("data.tar"), work_dir).unwrap();
286/// ```
287pub fn untar(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
288    let file = File::open(file_path)?;
289    Archive::new(file).unpack(extract_dir)?;
290
291    Ok(())
292}
293
294/// Extract a gzip-compressed tar (`.tar.gz` / `.tgz`) archive into a target directory.
295///
296/// This function combines the two layers of a gzipped tarball in one streaming pass. The bytes
297/// flow through [`flate2::read::GzDecoder`] (the gzip layer, as in [`gunzip`]), straight into
298/// [`tar::Archive`] (the tar layer, as in [`untar`]). This function never writes the intermediate
299/// uncompressed `.tar` to disk, so it suits large datasets distributed as `.tar.gz`.
300///
301/// This function unpacks the archive's entries **relative to** `extract_dir`, and creates that
302/// directory if needed. The [`tar`] crate rejects any entry whose path would escape
303/// `extract_dir`.
304///
305/// # Parameters
306///
307/// - `file_path` - Path to the `.tar.gz` (or `.tgz`) file to extract.
308/// - `extract_dir` - Directory to extract the archive contents into.
309///
310/// # Errors
311///
312/// - `DatasetError::IoError` - Returned when opening the source fails, the gzip
313///   stream is malformed, or the tar extraction fails.
314///
315/// # Example
316/// ```no_run
317/// use dataset_core::{download_to, untar_gz};
318/// use std::path::Path;
319///
320/// let work_dir = Path::new("./untar_gz_example");
321/// std::fs::create_dir_all(work_dir).unwrap();
322///
323/// // Download a gzip-compressed tarball. Then extract it in place.
324/// download_to("https://example.com/data.tar.gz", work_dir, Some("data.tar.gz")).unwrap();
325/// untar_gz(&work_dir.join("data.tar.gz"), work_dir).unwrap();
326/// ```
327pub fn untar_gz(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
328    let input = File::open(file_path)?;
329    let decoder = GzDecoder::new(input);
330    Archive::new(decoder).unpack(extract_dir)?;
331
332    Ok(())
333}
334
335/// Create a temporary directory under the given parent directory.
336///
337/// This is a small wrapper around [`tempfile::Builder`]. [`acquire_dataset`] uses it internally
338/// to keep intermediate download and extraction artifacts isolated. The [`tempfile::TempDir`]
339/// removes the directory automatically when it is dropped.
340fn create_temp_dir(tempdir_in: &Path) -> Result<tempfile::TempDir, DatasetError> {
341    let temp_dir = tempfile::Builder::new().tempdir_in(tempdir_in)?;
342
343    Ok(temp_dir)
344}
345
346/// Compute a file's SHA256 hash and return it as a lowercase hex string.
347///
348/// This function streams the file in 8 KiB chunks, so hashing a multi-gigabyte dataset costs
349/// no more memory than hashing a small one.
350///
351/// Use this function to **pin** a hash. Run it once against a freshly downloaded
352/// file. Paste the result into the `expected_sha256` value that you pass to
353/// [`acquire_dataset`]. To check a file against a hash you already have, use
354/// [`verify_sha256`] instead of comparing strings yourself.
355///
356/// # Parameters
357///
358/// - `path` - Path to the file to hash.
359///
360/// # Returns
361///
362/// - `String` - The SHA256 digest as 64 lowercase hex characters.
363///
364/// # Errors
365///
366/// - `DatasetError::IoError` - Returned when the function cannot open or read the file.
367///
368/// # Example
369/// ```no_run
370/// use dataset_core::sha256_file;
371/// use std::path::Path;
372///
373/// let digest = sha256_file(Path::new("./data/iris.csv")).unwrap();
374/// println!("pin this: {digest}");
375/// ```
376pub fn sha256_file(path: &Path) -> Result<String, DatasetError> {
377    let mut file = File::open(path)?;
378
379    let mut hasher = Sha256::new();
380    let mut buf = [0u8; 8192];
381
382    loop {
383        let read = file.read(&mut buf)?;
384        if read == 0 {
385            break;
386        }
387        hasher.update(&buf[..read]);
388    }
389
390    let digest = hasher.finalize();
391    let mut hex = String::with_capacity(digest.len() * 2);
392    for b in digest {
393        // A `write!` into a `String` cannot fail.
394        let _ = write!(hex, "{:02x}", b);
395    }
396
397    Ok(hex)
398}
399
400/// Verify that a file's SHA256 hash matches an expected value (case-insensitive).
401///
402/// This is the same check that [`acquire_dataset`] performs internally on cached and freshly
403/// prepared files. Callers that need to validate a file outside that workflow can use it too.
404/// Most commonly, this is a test that asserts the file on disk is the expected one. This
405/// function returns `true` when the computed hash matches `expected_hex`.
406///
407/// # Parameters
408///
409/// - `path` - Path to the file to verify.
410/// - `expected_hex` - The expected SHA256 digest, in hex (either case).
411///
412/// # Returns
413///
414/// - `bool` - `true` if the file's hash matches `expected_hex`, `false` otherwise.
415///
416/// # Errors
417///
418/// - `DatasetError::IoError` - Returned when the function cannot open or read the file.
419///
420/// # Example
421/// ```no_run
422/// use dataset_core::verify_sha256;
423/// use std::path::Path;
424///
425/// const IRIS_SHA256: &str = "c52742e50315a99f956a383faedf7575552675f6409ef0f9a47076dd08479930";
426///
427/// assert!(verify_sha256(Path::new("./data/iris.csv"), IRIS_SHA256).unwrap());
428/// ```
429pub fn verify_sha256(path: &Path, expected_hex: &str) -> Result<bool, DatasetError> {
430    Ok(sha256_file(path)?.eq_ignore_ascii_case(expected_hex))
431}
432
433/// Read a file as Latin-1 (ISO-8859-1) text.
434///
435/// This function maps every byte to the Unicode scalar with the same value. That is exactly
436/// what Latin-1 decoding means, and what scikit-learn does for the older text corpora. Unlike
437/// [`std::fs::read_to_string`], this function never fails on non-UTF-8 input, and it never
438/// replaces bytes with `U+FFFD`. The decoding is lossless and reversible, so a corpus whose
439/// encoding is unknown or mixed survives the round trip.
440///
441/// Use it for raw document collections (newsgroup posts, movie reviews, and so on) that predate
442/// UTF-8. For data you know is UTF-8, prefer `std::fs::read_to_string`.
443///
444/// # Parameters
445///
446/// - `path` - Path to the file to read.
447///
448/// # Returns
449///
450/// - `String` - The file's contents, decoded byte-for-byte as Latin-1.
451///
452/// # Errors
453///
454/// - `DatasetError::IoError` - Returned when the function cannot open or read the file.
455///
456/// # Example
457/// ```no_run
458/// use dataset_core::read_latin1;
459/// use std::path::Path;
460///
461/// // A byte that is invalid UTF-8 decodes to the matching Latin-1 character
462/// // instead of failing the read.
463/// let text = read_latin1(Path::new("./corpus/post_00001")).unwrap();
464/// assert!(!text.is_empty());
465/// ```
466pub fn read_latin1(path: &Path) -> Result<String, DatasetError> {
467    let bytes = std::fs::read(path)?;
468
469    Ok(bytes.iter().map(|&b| b as char).collect())
470}
471
472/// State of the destination file for the dataset to cache.
473enum CacheState {
474    /// Destination file exists, and its hash matches if the caller gave one. The code
475    /// reuses the file without changes.
476    Fresh,
477    /// Destination file exists, but its hash does not match. The code must replace it.
478    Stale,
479    /// Destination file does not exist. The code must prepare a new file.
480    Missing,
481}
482
483/// Make sure `dir` exists, then classify the destination file `dst`.
484///
485/// When `expected_sha256` is `None`, any existing file counts as [`CacheState::Fresh`].
486fn inspect_cache(
487    dir: &Path,
488    dst: &Path,
489    expected_sha256: Option<&str>,
490) -> Result<CacheState, DatasetError> {
491    if !dir.exists() {
492        std::fs::create_dir_all(dir)?;
493    }
494
495    if !dst.exists() {
496        return Ok(CacheState::Missing);
497    }
498
499    match expected_sha256 {
500        Some(hash) if !verify_sha256(dst, hash)? => Ok(CacheState::Stale),
501        _ => Ok(CacheState::Fresh),
502    }
503}
504
505/// Get a dataset file, using a preparation closure that the caller provides.
506///
507/// This is the single entry point for getting a dataset, and the recommended way to populate a
508/// storage directory. It checks whether it can reuse the destination file, and creates a
509/// temporary directory when it needs a new file. It then delegates file preparation to a
510/// closure that the caller provides. It optionally validates the prepared file with SHA256, and
511/// atomically moves it to the final destination.
512///
513/// The function itself does not perform network I/O. The `prepare_file` closure prepares the
514/// dataset file. This may include downloading the file, extracting archives, or locating files
515/// inside an extracted directory.
516///
517/// # Parameters
518///
519/// - `dir` - Target storage directory path.
520/// - `filename` - Final dataset filename (stored as `dir/filename`).
521///   Include the file extension (for example, `"iris.csv"`).
522/// - `dataset_name` - Dataset name for error messages (for example, `"iris"`).
523/// - `expected_sha256` - Optional expected SHA256 hash of the dataset file. If `None`,
524///   the function accepts any existing file at the destination without validation, and
525///   newly prepared files skip SHA256 verification.
526/// - `prepare_file` - Closure that prepares the dataset file in the temporary directory.
527///   - Input: `temp_dir: &Path` - Path to the temporary directory. The caller should do
528///     file work inside this directory: the function cleans it up automatically when the
529///     closure returns. This is not a requirement. (The function moves the file to the
530///     final destination. It does not copy the file.)
531///   - Output: `Result<PathBuf, DatasetError>` - Path to the prepared dataset file (the
532///     function moves this file to `dir/filename`).
533///   - Responsibility: This closure can do any work needed to prepare the dataset file.
534///     This can include downloading it (use [`download_to`] from this crate), extracting
535///     archives (use [`unzip`] from this crate), or locating files inside extracted
536///     folders. The returned `PathBuf` must point to the final dataset file, ready for
537///     validation.
538///
539/// # Returns
540///
541/// - `PathBuf` - Path to the final dataset file (`dir/filename`).
542///
543/// # Errors
544///
545/// - `DatasetError::IoError` - Returned when directory creation, file operations, or
546///   hash verification fails.
547/// - `DatasetError::Sha256ValidationFailed` - Returned when the caller provides
548///   `expected_sha256` and the prepared file's SHA256 hash does not match it.
549/// - Any error the `prepare_file` closure returns.
550///
551/// # Example
552/// ```no_run
553/// // Implement the file preparation process for the Iris dataset.
554///
555/// /// The URL for the Iris dataset.
556/// ///
557/// /// # Citation
558/// ///
559/// /// R. A. Fisher. "Iris," UCI Machine Learning Repository, \[Online\].
560/// /// Available: <https://doi.org/10.24432/C56C76>
561/// const IRIS_DATA_URL: &str = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
562///
563/// /// The name of the Iris dataset file.
564/// const IRIS_FILENAME: &str = "iris.csv";
565///
566/// /// The SHA256 hash of the Iris dataset file.
567/// const IRIS_SHA256: &str = "c52742e50315a99f956a383faedf7575552675f6409ef0f9a47076dd08479930";
568///
569/// /// The name of the dataset.
570/// const IRIS_DATASET_NAME: &str = "iris";
571///
572/// use dataset_core::acquire_dataset;
573/// use dataset_core::download_to;
574///
575/// fn main() {
576///     let dir = "./somewhere";
577///
578///     let file_path = acquire_dataset(
579///         // Target storage directory path
580///         dir,
581///         // Final dataset filename (stored as `dir/filename`)
582///         IRIS_FILENAME,
583///         // Dataset name for error messages
584///         IRIS_DATASET_NAME,
585///         // Expected SHA256 hash of the dataset file
586///         Some(IRIS_SHA256),
587///         // Closure that prepares the dataset file in the temporary directory
588///         |temp_path| {
589///             // Download the dataset into the temporary directory
590///             download_to(IRIS_DATA_URL, temp_path, None)?;
591///             Ok(temp_path.join(IRIS_FILENAME))
592///         },
593///     ).unwrap();
594///
595///     // `file_path` is now the path to the Iris dataset file.
596///     // Use it to locate or parse the dataset.
597/// }
598/// ```
599pub fn acquire_dataset<F>(
600    dir: &str,
601    filename: &str,
602    dataset_name: &str,
603    expected_sha256: Option<&str>,
604    prepare_file: F,
605) -> Result<PathBuf, DatasetError>
606where
607    F: FnOnce(&Path) -> Result<PathBuf, DatasetError>,
608{
609    let dir_path = Path::new(dir);
610    let dst = dir_path.join(filename);
611
612    // Reuse a valid cached file without invoking the preparation closure.
613    let state = inspect_cache(dir_path, &dst, expected_sha256)?;
614    if matches!(state, CacheState::Fresh) {
615        return Ok(dst);
616    }
617
618    // The temporary directory cleans up on drop, including when the code
619    // returns early below.
620    let temp_dir = create_temp_dir(dir_path)?;
621    let src = prepare_file(temp_dir.path())?;
622
623    // Validate the freshly prepared file before it lands at the final path.
624    if let Some(hash) = expected_sha256
625        && !verify_sha256(&src, hash)?
626    {
627        return Err(DatasetError::sha256_validation_failed(
628            dataset_name,
629            filename,
630        ));
631    }
632
633    // The code must remove a stale file first: `fs::rename` does not overwrite on all platforms.
634    if matches!(state, CacheState::Stale) {
635        std::fs::remove_file(&dst)?;
636    }
637    std::fs::rename(&src, &dst)?;
638
639    Ok(dst)
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use std::fs::{self, File, create_dir_all, remove_dir_all};
646    use std::io::Write;
647
648    /// SHA256 of "hello world"
649    const HELLO_WORLD_SHA256: &str =
650        "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
651
652    /// SHA256 of an empty file
653    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
654
655    /// All-zero hash (always wrong)
656    const ZERO_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
657
658    #[test]
659    fn create_temp_dir_returns_existing_path() {
660        let parent = "./test_create_temp_dir_returns_existing_path";
661        create_dir_all(parent).unwrap();
662
663        let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
664        assert!(temp_dir.path().exists());
665
666        remove_dir_all(parent).unwrap();
667    }
668
669    #[test]
670    fn create_temp_dir_cleanup_on_drop() {
671        let parent = "./test_create_temp_dir_cleanup_on_drop";
672        create_dir_all(parent).unwrap();
673
674        let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
675        let temp_path = temp_dir.path().to_path_buf();
676
677        assert!(temp_path.exists());
678        drop(temp_dir);
679        assert!(!temp_path.exists());
680
681        remove_dir_all(parent).unwrap();
682    }
683
684    #[test]
685    fn create_temp_dir_nonexistent_parent_errors() {
686        let result = create_temp_dir(Path::new("./nonexistent_parent_xyz_abc_123"));
687        assert!(result.is_err());
688    }
689
690    /// Compress `content` into a gzip file at `path` (test helper).
691    fn write_gz(path: &Path, content: &[u8]) {
692        use flate2::Compression;
693        use flate2::write::GzEncoder;
694
695        let file = File::create(path).unwrap();
696        let mut encoder = GzEncoder::new(file, Compression::default());
697        encoder.write_all(content).unwrap();
698        encoder.finish().unwrap();
699    }
700
701    #[test]
702    fn gunzip_round_trips_content() {
703        let dir = "./test_gunzip_round_trips_content";
704        create_dir_all(dir).unwrap();
705        let dir_path = Path::new(dir);
706
707        let payload = b"col_a,col_b\n1,2\n3,4\n";
708        let gz_path = dir_path.join("data.csv.gz");
709        let out_path = dir_path.join("data.csv");
710        write_gz(&gz_path, payload);
711
712        gunzip(&gz_path, &out_path).unwrap();
713
714        assert_eq!(fs::read(&out_path).unwrap(), payload);
715
716        remove_dir_all(dir).unwrap();
717    }
718
719    #[test]
720    fn gunzip_nonexistent_source_errors() {
721        let result = gunzip(
722            Path::new("./no_such_file_for_gunzip_test.gz"),
723            Path::new("./no_such_file_for_gunzip_test.out"),
724        );
725        assert!(result.is_err());
726    }
727
728    /// Build a tar archive at `path` from `(name, content)` entries (test helper).
729    fn write_tar(path: &Path, entries: &[(&str, &[u8])]) {
730        let file = File::create(path).unwrap();
731        let mut builder = tar::Builder::new(file);
732        for (name, content) in entries {
733            let mut header = tar::Header::new_gnu();
734            header.set_size(content.len() as u64);
735            header.set_mode(0o644);
736            header.set_cksum();
737            builder.append_data(&mut header, name, *content).unwrap();
738        }
739        builder.into_inner().unwrap().sync_all().unwrap();
740    }
741
742    /// Build a gzip-compressed tar archive at `path` (test helper).
743    fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) {
744        use flate2::Compression;
745        use flate2::write::GzEncoder;
746
747        let file = File::create(path).unwrap();
748        let encoder = GzEncoder::new(file, Compression::default());
749        let mut builder = tar::Builder::new(encoder);
750        for (name, content) in entries {
751            let mut header = tar::Header::new_gnu();
752            header.set_size(content.len() as u64);
753            header.set_mode(0o644);
754            header.set_cksum();
755            builder.append_data(&mut header, name, *content).unwrap();
756        }
757        builder.into_inner().unwrap().finish().unwrap();
758    }
759
760    #[test]
761    fn untar_round_trips_entries() {
762        let dir = "./test_untar_round_trips_entries";
763        create_dir_all(dir).unwrap();
764        let dir_path = Path::new(dir);
765
766        let tar_path = dir_path.join("data.tar");
767        write_tar(
768            &tar_path,
769            &[("a.txt", b"hello"), ("nested/b.txt", b"world")],
770        );
771
772        let out_dir = dir_path.join("extracted");
773        untar(&tar_path, &out_dir).unwrap();
774
775        assert_eq!(fs::read(out_dir.join("a.txt")).unwrap(), b"hello");
776        assert_eq!(fs::read(out_dir.join("nested/b.txt")).unwrap(), b"world");
777
778        remove_dir_all(dir).unwrap();
779    }
780
781    #[test]
782    fn untar_nonexistent_source_errors() {
783        let result = untar(
784            Path::new("./no_such_file_for_untar_test.tar"),
785            Path::new("./no_such_dir_for_untar_test"),
786        );
787        assert!(result.is_err());
788    }
789
790    #[test]
791    fn untar_gz_round_trips_entries() {
792        let dir = "./test_untar_gz_round_trips_entries";
793        create_dir_all(dir).unwrap();
794        let dir_path = Path::new(dir);
795
796        let tar_gz_path = dir_path.join("data.tar.gz");
797        write_tar_gz(
798            &tar_gz_path,
799            &[("a.txt", b"hello"), ("nested/b.txt", b"world")],
800        );
801
802        let out_dir = dir_path.join("extracted");
803        untar_gz(&tar_gz_path, &out_dir).unwrap();
804
805        assert_eq!(fs::read(out_dir.join("a.txt")).unwrap(), b"hello");
806        assert_eq!(fs::read(out_dir.join("nested/b.txt")).unwrap(), b"world");
807
808        remove_dir_all(dir).unwrap();
809    }
810
811    #[test]
812    fn untar_gz_nonexistent_source_errors() {
813        let result = untar_gz(
814            Path::new("./no_such_file_for_untar_gz_test.tar.gz"),
815            Path::new("./no_such_dir_for_untar_gz_test"),
816        );
817        assert!(result.is_err());
818    }
819
820    #[test]
821    fn verify_sha256_correct_hash() {
822        let dir = "./test_verify_sha256_correct_hash";
823        create_dir_all(dir).unwrap();
824        let path = Path::new(dir).join("f.txt");
825        File::create(&path)
826            .unwrap()
827            .write_all(b"hello world")
828            .unwrap();
829
830        assert!(verify_sha256(&path, HELLO_WORLD_SHA256).unwrap());
831
832        remove_dir_all(dir).unwrap();
833    }
834
835    #[test]
836    fn verify_sha256_uppercase_hash() {
837        let dir = "./test_verify_sha256_uppercase_hash";
838        create_dir_all(dir).unwrap();
839        let path = Path::new(dir).join("f.txt");
840        File::create(&path)
841            .unwrap()
842            .write_all(b"hello world")
843            .unwrap();
844
845        assert!(verify_sha256(&path, &HELLO_WORLD_SHA256.to_uppercase()).unwrap());
846
847        remove_dir_all(dir).unwrap();
848    }
849
850    #[test]
851    fn verify_sha256_wrong_hash_returns_false() {
852        let dir = "./test_verify_sha256_wrong_hash_returns_false";
853        create_dir_all(dir).unwrap();
854        let path = Path::new(dir).join("f.txt");
855        File::create(&path)
856            .unwrap()
857            .write_all(b"hello world")
858            .unwrap();
859
860        assert!(!verify_sha256(&path, ZERO_SHA256).unwrap());
861
862        remove_dir_all(dir).unwrap();
863    }
864
865    #[test]
866    fn verify_sha256_empty_file() {
867        let dir = "./test_verify_sha256_empty_file";
868        create_dir_all(dir).unwrap();
869        let path = Path::new(dir).join("empty.txt");
870        File::create(&path).unwrap();
871
872        assert!(verify_sha256(&path, EMPTY_SHA256).unwrap());
873
874        remove_dir_all(dir).unwrap();
875    }
876
877    #[test]
878    fn verify_sha256_nonexistent_file_errors() {
879        let result = verify_sha256(Path::new("./no_such_file_sha256_test.txt"), ZERO_SHA256);
880        assert!(result.is_err());
881    }
882
883    #[test]
884    fn sha256_file_returns_lowercase_hex_digest() {
885        let dir = "./test_sha256_file_returns_lowercase_hex_digest";
886        create_dir_all(dir).unwrap();
887        let path = Path::new(dir).join("f.txt");
888        File::create(&path)
889            .unwrap()
890            .write_all(b"hello world")
891            .unwrap();
892
893        let digest = sha256_file(&path).unwrap();
894        assert_eq!(digest, HELLO_WORLD_SHA256);
895        assert_eq!(digest.len(), 64);
896        assert!(
897            digest
898                .chars()
899                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
900        );
901
902        remove_dir_all(dir).unwrap();
903    }
904
905    #[test]
906    fn sha256_file_hashes_larger_than_one_chunk() {
907        let dir = "./test_sha256_file_hashes_larger_than_one_chunk";
908        create_dir_all(dir).unwrap();
909        let path = Path::new(dir).join("big.bin");
910        // Larger than the 8 KiB read buffer, so the streaming loop runs several times.
911        let payload = vec![0xABu8; 8192 * 3 + 17];
912        fs::write(&path, &payload).unwrap();
913
914        // The streamed digest must equal the one-shot digest of the same bytes.
915        let expected = {
916            let mut hasher = Sha256::new();
917            hasher.update(&payload);
918            hasher
919                .finalize()
920                .iter()
921                .map(|b| format!("{:02x}", b))
922                .collect::<String>()
923        };
924        assert_eq!(sha256_file(&path).unwrap(), expected);
925
926        remove_dir_all(dir).unwrap();
927    }
928
929    #[test]
930    fn sha256_file_nonexistent_file_errors() {
931        assert!(sha256_file(Path::new("./no_such_file_for_sha256_file_test.bin")).is_err());
932    }
933
934    #[test]
935    fn read_latin1_decodes_every_byte() {
936        let dir = "./test_read_latin1_decodes_every_byte";
937        create_dir_all(dir).unwrap();
938        let path = Path::new(dir).join("latin1.txt");
939        // All 256 byte values, including sequences that are invalid UTF-8.
940        let bytes: Vec<u8> = (0u8..=255).collect();
941        fs::write(&path, &bytes).unwrap();
942
943        let text = read_latin1(&path).unwrap();
944
945        // One character per source byte, each with the byte's own scalar value.
946        assert_eq!(text.chars().count(), 256);
947        for (i, c) in text.chars().enumerate() {
948            assert_eq!(c as u32, i as u32, "byte {i} decoded to {c:?}");
949        }
950
951        remove_dir_all(dir).unwrap();
952    }
953
954    #[test]
955    fn read_latin1_empty_file_is_empty_string() {
956        let dir = "./test_read_latin1_empty_file_is_empty_string";
957        create_dir_all(dir).unwrap();
958        let path = Path::new(dir).join("empty.txt");
959        File::create(&path).unwrap();
960
961        assert_eq!(read_latin1(&path).unwrap(), "");
962
963        remove_dir_all(dir).unwrap();
964    }
965
966    #[test]
967    fn read_latin1_nonexistent_file_errors() {
968        assert!(read_latin1(Path::new("./no_such_file_for_read_latin1_test.txt")).is_err());
969    }
970
971    #[test]
972    fn download_to_with_retries_does_not_retry_unusable_url() {
973        let dir = "./test_download_to_with_retries_does_not_retry_unusable_url";
974        create_dir_all(dir).unwrap();
975
976        // A URL ending in `/` yields no filename. A retry cannot fix that, so this must
977        // fail immediately instead of sleeping through the retry schedule.
978        let started = std::time::Instant::now();
979        let result = download_to_with_retries("https://x.test/a/", Path::new(dir), None, 5);
980
981        assert!(matches!(result, Err(DatasetError::ValidationError(_))));
982        assert!(
983            started.elapsed() < RETRY_BASE_DELAY,
984            "a non-retryable error must not wait for a retry"
985        );
986
987        remove_dir_all(dir).unwrap();
988    }
989
990    #[test]
991    fn filename_from_url_plain_segment() {
992        assert_eq!(
993            filename_from_url("https://x.test/a/iris.csv"),
994            Some("iris.csv")
995        );
996    }
997
998    #[test]
999    fn filename_from_url_strips_query_and_fragment() {
1000        assert_eq!(
1001            filename_from_url("https://x.test/a/iris.csv?raw=1"),
1002            Some("iris.csv")
1003        );
1004        assert_eq!(
1005            filename_from_url("https://x.test/a/iris.csv#section"),
1006            Some("iris.csv")
1007        );
1008    }
1009
1010    #[test]
1011    fn filename_from_url_trailing_slash_is_none() {
1012        assert_eq!(filename_from_url("https://x.test/a/"), None);
1013    }
1014
1015    #[test]
1016    fn filename_from_url_no_slash() {
1017        assert_eq!(filename_from_url("iris.csv"), Some("iris.csv"));
1018    }
1019
1020    #[test]
1021    fn inspect_cache_missing_then_fresh_and_stale() {
1022        let dir = "./test_inspect_cache_states";
1023        let dir_path = Path::new(dir);
1024        let dst = dir_path.join("data.txt");
1025        let _ = remove_dir_all(dir);
1026
1027        // Missing: the code creates the directory on demand. The file is absent.
1028        assert!(matches!(
1029            inspect_cache(dir_path, &dst, None).unwrap(),
1030            CacheState::Missing
1031        ));
1032        assert!(dir_path.exists());
1033
1034        // Fresh: file present, hash matches.
1035        fs::write(&dst, b"hello world").unwrap();
1036        assert!(matches!(
1037            inspect_cache(dir_path, &dst, Some(HELLO_WORLD_SHA256)).unwrap(),
1038            CacheState::Fresh
1039        ));
1040        // Fresh: no hash requested.
1041        assert!(matches!(
1042            inspect_cache(dir_path, &dst, None).unwrap(),
1043            CacheState::Fresh
1044        ));
1045
1046        // Stale: file present but hash mismatches.
1047        assert!(matches!(
1048            inspect_cache(dir_path, &dst, Some(ZERO_SHA256)).unwrap(),
1049            CacheState::Stale
1050        ));
1051
1052        remove_dir_all(dir).unwrap();
1053    }
1054}