use crate::DatasetError;
use sha2::{Digest, Sha256};
use std::fmt::Write;
use std::fs::File;
use std::io;
use std::io::Read;
use std::path::{Path, PathBuf};
use zip::ZipArchive;
use zip::result::ZipError;
pub fn download_to(
url: &str,
storage_path: &Path,
filename: Option<&str>,
) -> Result<(), DatasetError> {
let filename = match filename {
Some(name) => name,
None => filename_from_url(url).ok_or_else(|| {
DatasetError::ValidationError(
"Invalid URL: cannot extract filename from URL".to_string(),
)
})?,
};
let save_path = storage_path.join(filename);
let mut response = ureq::get(url).call()?;
let mut body = response.body_mut().as_reader();
let mut file = File::create(save_path)?;
io::copy(&mut body, &mut file)?;
Ok(())
}
fn filename_from_url(url: &str) -> Option<&str> {
let last_segment = url.rsplit('/').next()?;
let name = last_segment
.split(['?', '#'])
.next()
.unwrap_or(last_segment);
(!name.is_empty()).then_some(name)
}
pub fn unzip(file_path: &Path, extract_dir: &Path) -> Result<(), DatasetError> {
let file = File::open(file_path).map_err(|e| DatasetError::from(ZipError::Io(e)))?;
ZipArchive::new(file)?.extract(extract_dir)?;
Ok(())
}
fn create_temp_dir(tempdir_in: &Path) -> Result<tempfile::TempDir, DatasetError> {
let temp_dir = tempfile::Builder::new().tempdir_in(tempdir_in)?;
Ok(temp_dir)
}
fn file_sha256_matches(path: &Path, expected_hex: &str) -> Result<bool, DatasetError> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let read = file.read(&mut buf)?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
let digest = hasher.finalize();
let mut actual_hex = String::with_capacity(digest.len() * 2);
for b in digest {
let _ = write!(actual_hex, "{:02x}", b);
}
Ok(actual_hex.eq_ignore_ascii_case(expected_hex))
}
enum CacheState {
Fresh,
Stale,
Missing,
}
fn inspect_cache(
dir: &Path,
dst: &Path,
expected_sha256: Option<&str>,
) -> Result<CacheState, DatasetError> {
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
if !dst.exists() {
return Ok(CacheState::Missing);
}
match expected_sha256 {
Some(hash) if !file_sha256_matches(dst, hash)? => Ok(CacheState::Stale),
_ => Ok(CacheState::Fresh),
}
}
pub fn acquire_dataset<F>(
dir: &str,
filename: &str,
dataset_name: &str,
expected_sha256: Option<&str>,
prepare_file: F,
) -> Result<PathBuf, DatasetError>
where
F: FnOnce(&Path) -> Result<PathBuf, DatasetError>,
{
let dir_path = Path::new(dir);
let dst = dir_path.join(filename);
let state = inspect_cache(dir_path, &dst, expected_sha256)?;
if matches!(state, CacheState::Fresh) {
return Ok(dst);
}
let temp_dir = create_temp_dir(dir_path)?;
let src = prepare_file(temp_dir.path())?;
if let Some(hash) = expected_sha256
&& !file_sha256_matches(&src, hash)?
{
return Err(DatasetError::sha256_validation_failed(
dataset_name,
filename,
));
}
if matches!(state, CacheState::Stale) {
std::fs::remove_file(&dst)?;
}
std::fs::rename(&src, &dst)?;
Ok(dst)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File, create_dir_all, remove_dir_all};
use std::io::Write;
const HELLO_WORLD_SHA256: &str =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ZERO_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[test]
fn create_temp_dir_returns_existing_path() {
let parent = "./test_create_temp_dir_returns_existing_path";
create_dir_all(parent).unwrap();
let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
assert!(temp_dir.path().exists());
remove_dir_all(parent).unwrap();
}
#[test]
fn create_temp_dir_cleanup_on_drop() {
let parent = "./test_create_temp_dir_cleanup_on_drop";
create_dir_all(parent).unwrap();
let temp_dir = create_temp_dir(Path::new(parent)).unwrap();
let temp_path = temp_dir.path().to_path_buf();
assert!(temp_path.exists());
drop(temp_dir);
assert!(!temp_path.exists());
remove_dir_all(parent).unwrap();
}
#[test]
fn create_temp_dir_nonexistent_parent_errors() {
let result = create_temp_dir(Path::new("./nonexistent_parent_xyz_abc_123"));
assert!(result.is_err());
}
#[test]
fn file_sha256_matches_correct_hash() {
let dir = "./test_file_sha256_matches_correct_hash";
create_dir_all(dir).unwrap();
let path = Path::new(dir).join("f.txt");
File::create(&path)
.unwrap()
.write_all(b"hello world")
.unwrap();
assert!(file_sha256_matches(&path, HELLO_WORLD_SHA256).unwrap());
remove_dir_all(dir).unwrap();
}
#[test]
fn file_sha256_matches_uppercase_hash() {
let dir = "./test_file_sha256_matches_uppercase_hash";
create_dir_all(dir).unwrap();
let path = Path::new(dir).join("f.txt");
File::create(&path)
.unwrap()
.write_all(b"hello world")
.unwrap();
assert!(file_sha256_matches(&path, &HELLO_WORLD_SHA256.to_uppercase()).unwrap());
remove_dir_all(dir).unwrap();
}
#[test]
fn file_sha256_matches_wrong_hash_returns_false() {
let dir = "./test_file_sha256_matches_wrong_hash_returns_false";
create_dir_all(dir).unwrap();
let path = Path::new(dir).join("f.txt");
File::create(&path)
.unwrap()
.write_all(b"hello world")
.unwrap();
assert!(!file_sha256_matches(&path, ZERO_SHA256).unwrap());
remove_dir_all(dir).unwrap();
}
#[test]
fn file_sha256_matches_empty_file() {
let dir = "./test_file_sha256_matches_empty_file";
create_dir_all(dir).unwrap();
let path = Path::new(dir).join("empty.txt");
File::create(&path).unwrap();
assert!(file_sha256_matches(&path, EMPTY_SHA256).unwrap());
remove_dir_all(dir).unwrap();
}
#[test]
fn file_sha256_matches_nonexistent_file_errors() {
let result = file_sha256_matches(Path::new("./no_such_file_sha256_test.txt"), ZERO_SHA256);
assert!(result.is_err());
}
#[test]
fn filename_from_url_plain_segment() {
assert_eq!(
filename_from_url("https://x.test/a/iris.csv"),
Some("iris.csv")
);
}
#[test]
fn filename_from_url_strips_query_and_fragment() {
assert_eq!(
filename_from_url("https://x.test/a/iris.csv?raw=1"),
Some("iris.csv")
);
assert_eq!(
filename_from_url("https://x.test/a/iris.csv#section"),
Some("iris.csv")
);
}
#[test]
fn filename_from_url_trailing_slash_is_none() {
assert_eq!(filename_from_url("https://x.test/a/"), None);
}
#[test]
fn filename_from_url_no_slash() {
assert_eq!(filename_from_url("iris.csv"), Some("iris.csv"));
}
#[test]
fn inspect_cache_missing_then_fresh_and_stale() {
let dir = "./test_inspect_cache_states";
let dir_path = Path::new(dir);
let dst = dir_path.join("data.txt");
let _ = remove_dir_all(dir);
assert!(matches!(
inspect_cache(dir_path, &dst, None).unwrap(),
CacheState::Missing
));
assert!(dir_path.exists());
fs::write(&dst, b"hello world").unwrap();
assert!(matches!(
inspect_cache(dir_path, &dst, Some(HELLO_WORLD_SHA256)).unwrap(),
CacheState::Fresh
));
assert!(matches!(
inspect_cache(dir_path, &dst, None).unwrap(),
CacheState::Fresh
));
assert!(matches!(
inspect_cache(dir_path, &dst, Some(ZERO_SHA256)).unwrap(),
CacheState::Stale
));
remove_dir_all(dir).unwrap();
}
}