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}