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