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}