use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
use crate::app::hash::Md5Hash;
use crate::app::models::FileInfo;
use crate::constants::{cache, files};
use crate::errors::{CacheError, CacheResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub cache_root: Option<PathBuf>,
pub max_cache_size: u64,
pub fast_verification: bool,
pub reservation_timeout: Duration,
pub auto_cleanup: bool,
pub min_free_space: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
cache_root: None, max_cache_size: 0, fast_verification: cache::FAST_VERIFICATION,
reservation_timeout: Duration::from_secs(180), auto_cleanup: false,
min_free_space: 1024 * 1024 * 1024, }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReservationStatus {
AlreadyExists,
Reserved,
ReservedByOther { worker_id: u32 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReservationInfo {
pub worker_id: u32,
pub reserved_at: DateTime<Utc>,
pub status: ReservationState,
pub file_info: FileInfo,
pub retry_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReservationState {
Reserved,
Downloading,
Completed,
Failed { error: String },
}
impl ReservationInfo {
pub fn new(worker_id: u32, file_info: FileInfo) -> Self {
Self {
worker_id,
reserved_at: Utc::now(),
status: ReservationState::Reserved,
file_info,
retry_count: 0,
}
}
pub fn is_timed_out(&self, timeout: Duration) -> bool {
let elapsed = Utc::now()
.signed_duration_since(self.reserved_at)
.to_std()
.unwrap_or(Duration::ZERO);
elapsed > timeout
}
pub fn mark_downloading(&mut self) {
self.status = ReservationState::Downloading;
}
pub fn mark_completed(&mut self) {
self.status = ReservationState::Completed;
}
pub fn mark_failed(&mut self, error: String) {
self.status = ReservationState::Failed { error };
self.retry_count += 1;
}
}
#[derive(Debug, Clone, Default)]
pub struct VerificationReport {
pub files_checked: usize,
pub files_verified: usize,
pub files_failed: usize,
pub files_missing: usize,
pub verification_time: Duration,
pub failed_files: Vec<VerificationFailure>,
}
#[derive(Debug, Clone)]
pub struct VerificationFailure {
pub file_info: FileInfo,
pub reason: String,
pub expected_hash: Md5Hash,
pub actual_hash: Option<Md5Hash>,
}
impl VerificationReport {
pub fn success_rate(&self) -> f64 {
if self.files_checked == 0 {
0.0
} else {
(self.files_verified as f64 / self.files_checked as f64) * 100.0
}
}
pub fn is_successful(&self) -> bool {
self.files_failed == 0
}
}
#[derive(Debug)]
pub struct CacheManager {
config: CacheConfig,
cache_root: PathBuf,
reservations: Arc<RwLock<HashMap<Md5Hash, ReservationInfo>>>,
}
impl CacheManager {
pub async fn new(config: CacheConfig) -> CacheResult<Self> {
let cache_root = match &config.cache_root {
Some(path) => path.clone(),
None => Self::get_default_cache_dir()?,
};
Self::ensure_directory_exists(&cache_root).await?;
info!(
"Initialized cache manager with root: {}",
cache_root.display()
);
Ok(Self {
config,
cache_root,
reservations: Arc::new(RwLock::new(HashMap::new())),
})
}
pub fn cache_root(&self) -> &Path {
&self.cache_root
}
fn get_default_cache_dir() -> CacheResult<PathBuf> {
let cache_dir = dirs::config_dir()
.ok_or_else(|| CacheError::DirectoryNotAccessible {
path: PathBuf::from("system config directory"),
})?
.join("midas-fetcher")
.join("cache");
Ok(cache_dir)
}
async fn ensure_directory_exists(path: &Path) -> CacheResult<()> {
if !path.exists() {
fs::create_dir_all(path).await.map_err(|e| {
error!("Failed to create cache directory: {}", e);
CacheError::DirectoryNotAccessible {
path: path.to_path_buf(),
}
})?;
debug!("Created cache directory: {}", path.display());
}
Ok(())
}
pub fn get_file_path(&self, file_info: &FileInfo) -> PathBuf {
let dataset = &file_info.dataset_info;
let mut path = self.cache_root.clone();
path.push(&dataset.dataset_name);
if let Some(ref file_type) = dataset.file_type {
match file_type.as_str() {
"capability" | "metadata" => {
path.push("capability");
}
"station-metadata" => {
path.push("station-metadata");
path.push(&file_info.file_name);
return path;
}
"change-log" => {
path.push(&file_info.file_name);
return path;
}
"station-log" => {
path.push("station-log-files");
path.push(&file_info.file_name);
return path;
}
_ => {
if let Some(qv) = &dataset.quality_version {
path.push(qv.to_filename_format());
} else {
path.push("no-quality");
}
}
}
} else {
if let Some(qv) = &dataset.quality_version {
path.push(qv.to_filename_format());
} else {
path.push("no-quality");
}
}
if let Some(county) = &dataset.county {
path.push(county);
} else {
path.push("no-county");
}
if let Some(station_id) = &dataset.station_id {
if let Some(station_name) = &dataset.station_name {
path.push(format!("{}_{}", station_id, station_name));
} else {
path.push(station_id);
}
} else {
path.push("no-station");
}
path.push(&file_info.file_name);
path
}
pub async fn check_and_reserve(&self, file_info: &FileInfo) -> CacheResult<ReservationStatus> {
let file_path = self.get_file_path(file_info);
if file_path.exists() {
if self.verify_file_hash(&file_path, &file_info.hash).await? {
debug!("File already exists and verified: {}", file_path.display());
return Ok(ReservationStatus::AlreadyExists);
} else {
warn!(
"File exists but hash verification failed, will re-download: {}",
file_path.display()
);
if let Err(e) = fs::remove_file(&file_path).await {
error!("Failed to remove corrupted file: {}", e);
}
}
}
self.try_reserve_file(file_info).await
}
async fn try_reserve_file(&self, file_info: &FileInfo) -> CacheResult<ReservationStatus> {
let mut reservations = self.reservations.write().await;
if let Some(existing) = reservations.get(&file_info.hash) {
if existing.is_timed_out(self.config.reservation_timeout) {
warn!(
"Reservation timed out for worker {}, releasing",
existing.worker_id
);
reservations.remove(&file_info.hash);
} else {
return Ok(ReservationStatus::ReservedByOther {
worker_id: existing.worker_id,
});
}
}
let worker_id = Self::get_current_worker_id();
let reservation = ReservationInfo::new(worker_id, file_info.clone());
reservations.insert(file_info.hash, reservation);
debug!("Reserved file {} for worker {}", file_info.hash, worker_id);
Ok(ReservationStatus::Reserved)
}
fn get_current_worker_id() -> u32 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::thread::current()
.name()
.unwrap_or("worker")
.hash(&mut hasher);
hasher.finish() as u32
}
pub async fn save_file_atomic(&self, content: &[u8], file_info: &FileInfo) -> CacheResult<()> {
let final_path = self.get_file_path(file_info);
let temp_path = final_path.with_extension(format!(
"{}{}",
final_path.extension().unwrap_or_default().to_string_lossy(),
files::TEMP_FILE_SUFFIX
));
if let Some(parent) = final_path.parent() {
Self::ensure_directory_exists(parent).await?;
}
fs::write(&temp_path, content).await.map_err(|e| {
error!("Failed to write temporary file: {}", e);
CacheError::InvalidState {
reason: format!("Temporary file write failed: {}", e),
}
})?;
let actual_hash = self.calculate_file_hash(&temp_path).await?;
if actual_hash != file_info.hash {
let _ = fs::remove_file(&temp_path).await;
return Err(CacheError::VerificationFailed { files_failed: 1 });
}
fs::rename(&temp_path, &final_path).await.map_err(|e| {
error!("Failed to rename temporary file: {}", e);
CacheError::InvalidState {
reason: format!("Atomic rename failed: {}", e),
}
})?;
self.mark_reservation_completed(&file_info.hash).await?;
info!("Successfully saved file: {}", final_path.display());
Ok(())
}
async fn mark_reservation_completed(&self, hash: &Md5Hash) -> CacheResult<()> {
let mut reservations = self.reservations.write().await;
if let Some(reservation) = reservations.get_mut(hash) {
reservation.mark_completed();
debug!("Marked reservation as completed: {}", hash);
}
Ok(())
}
pub async fn mark_reservation_failed(&self, hash: &Md5Hash, error: String) -> CacheResult<()> {
let mut reservations = self.reservations.write().await;
if let Some(reservation) = reservations.get_mut(hash) {
reservation.mark_failed(error);
debug!("Marked reservation as failed: {}", hash);
}
Ok(())
}
pub async fn release_reservation(&self, hash: &Md5Hash) -> CacheResult<()> {
let mut reservations = self.reservations.write().await;
if reservations.remove(hash).is_some() {
debug!("Released reservation: {}", hash);
}
Ok(())
}
pub async fn verify_cache_integrity<I>(&self, files: I) -> CacheResult<VerificationReport>
where
I: IntoIterator<Item = FileInfo>,
{
let start_time = Instant::now();
let mut report = VerificationReport::default();
for file_info in files {
report.files_checked += 1;
let file_path = self.get_file_path(&file_info);
let expected_hash = file_info.hash;
if !file_path.exists() {
report.files_missing += 1;
report.failed_files.push(VerificationFailure {
file_info,
reason: "File not found".to_string(),
expected_hash,
actual_hash: None,
});
continue;
}
match self.verify_file_hash(&file_path, &expected_hash).await {
Ok(true) => {
report.files_verified += 1;
}
Ok(false) => {
report.files_failed += 1;
let actual_hash = self.calculate_file_hash(&file_path).await.ok();
report.failed_files.push(VerificationFailure {
file_info,
reason: "Hash mismatch".to_string(),
expected_hash,
actual_hash,
});
}
Err(e) => {
report.files_failed += 1;
report.failed_files.push(VerificationFailure {
file_info,
reason: format!("Verification error: {}", e),
expected_hash,
actual_hash: None,
});
}
}
if report.files_checked % 1000 == 0 {
debug!(
"Verified {} files, {} failures",
report.files_checked, report.files_failed
);
}
}
report.verification_time = start_time.elapsed();
info!(
"Cache verification completed: {}/{} files verified ({:.1}% success rate) in {:.2}s",
report.files_verified,
report.files_checked,
report.success_rate(),
report.verification_time.as_secs_f64()
);
Ok(report)
}
async fn verify_file_hash(
&self,
file_path: &Path,
expected_hash: &Md5Hash,
) -> CacheResult<bool> {
let actual_hash = self.calculate_file_hash(file_path).await?;
Ok(actual_hash == *expected_hash)
}
async fn calculate_file_hash(&self, file_path: &Path) -> CacheResult<Md5Hash> {
let content = fs::read(file_path)
.await
.map_err(|e| CacheError::InvalidState {
reason: format!("Failed to read file for hash calculation: {}", e),
})?;
let hash = md5::compute(&content);
let hash_bytes: [u8; 16] = hash.0;
Ok(Md5Hash::from_bytes(hash_bytes))
}
pub async fn get_cache_stats(&self) -> CacheStats {
let reservations = self.reservations.read().await;
let active_reservations = reservations.len();
let downloading = reservations
.values()
.filter(|r| matches!(r.status, ReservationState::Downloading))
.count();
let (cached_files_count, total_cache_size) = self.scan_cache_directory().await;
let available_space = self.get_available_disk_space().await;
CacheStats {
cache_root: self.cache_root.clone(),
active_reservations,
downloading_files: downloading,
cached_files_count,
total_cache_size,
available_space,
}
}
async fn scan_cache_directory(&self) -> (usize, u64) {
let cache_root = self.cache_root.clone();
tokio::task::spawn_blocking(move || Self::scan_directory_recursive(&cache_root))
.await
.unwrap_or_else(|e| {
warn!("Failed to scan cache directory: {}", e);
(0, 0)
})
}
fn scan_directory_recursive(dir: &Path) -> (usize, u64) {
let mut file_count = 0;
let mut total_size = 0u64;
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let (sub_count, sub_size) = Self::scan_directory_recursive(&path);
file_count += sub_count;
total_size += sub_size;
} else if path.is_file() {
if let Some(extension) = path.extension() {
if extension == "csv" {
file_count += 1;
if let Ok(metadata) = entry.metadata() {
total_size += metadata.len();
}
}
}
}
}
}
(file_count, total_size)
}
async fn get_available_disk_space(&self) -> u64 {
let cache_root = self.cache_root.clone();
tokio::task::spawn_blocking(move || Self::get_disk_space(&cache_root))
.await
.unwrap_or_else(|e| {
warn!("Failed to get available disk space: {}", e);
0
})
}
fn get_disk_space(_path: &Path) -> u64 {
0
}
pub async fn cleanup_stale_reservations(&self) -> CacheResult<usize> {
let mut reservations = self.reservations.write().await;
let initial_count = reservations.len();
reservations
.retain(|_, reservation| !reservation.is_timed_out(self.config.reservation_timeout));
let cleaned = initial_count - reservations.len();
if cleaned > 0 {
info!("Cleaned up {} stale reservations", cleaned);
}
Ok(cleaned)
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub cache_root: PathBuf,
pub active_reservations: usize,
pub downloading_files: usize,
pub cached_files_count: usize,
pub total_cache_size: u64,
pub available_space: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::models::{DatasetFileInfo, QualityControlVersion};
use tempfile::TempDir;
fn create_test_file_info() -> FileInfo {
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let dataset_info = DatasetFileInfo {
dataset_name: "uk-daily-temperature-obs".to_string(),
version: "202407".to_string(),
county: Some("devon".to_string()),
station_id: Some("01381".to_string()),
station_name: Some("twist".to_string()),
quality_version: Some(QualityControlVersion::V1),
year: Some("1980".to_string()),
file_type: Some("data".to_string()),
};
FileInfo {
hash,
relative_path: "./data/test.csv".to_string(),
file_name: "test.csv".to_string(),
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path: PathBuf::from("/tmp/test.csv"),
}
}
#[tokio::test]
async fn test_cache_directory_structure() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let file_info = create_test_file_info();
let path = cache.get_file_path(&file_info);
let expected_path = temp_dir
.path()
.join("uk-daily-temperature-obs")
.join("qcv-1")
.join("devon")
.join("01381_twist")
.join("test.csv");
assert_eq!(path, expected_path);
}
#[tokio::test]
async fn test_cache_directory_structure_capability_files() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let dataset_info = DatasetFileInfo {
dataset_name: "uk-daily-temperature-obs".to_string(),
version: "202407".to_string(),
county: Some("devon".to_string()),
station_id: Some("01381".to_string()),
station_name: Some("twist".to_string()),
quality_version: Some(QualityControlVersion::V1),
year: None, file_type: Some("capability".to_string()),
};
let file_info = FileInfo {
hash,
relative_path: "./data/capability.csv".to_string(),
file_name: "capability.csv".to_string(),
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path: PathBuf::from("/tmp/capability.csv"),
};
let path = cache.get_file_path(&file_info);
let expected_path = temp_dir
.path()
.join("uk-daily-temperature-obs")
.join("capability")
.join("devon")
.join("01381_twist")
.join("capability.csv");
assert_eq!(path, expected_path);
}
#[tokio::test]
async fn test_cache_directory_structure_metadata_files() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let hash = Md5Hash::from_hex("9734faa872681f96b144f60d29d52011").unwrap();
let dataset_info = DatasetFileInfo {
dataset_name: "uk-daily-temperature-obs".to_string(),
version: "202407".to_string(),
county: Some("devon".to_string()),
station_id: Some("01382".to_string()),
station_name: Some("exeter".to_string()),
quality_version: None, year: None, file_type: Some("metadata".to_string()),
};
let file_info = FileInfo {
hash,
relative_path: "./data/metadata.csv".to_string(),
file_name: "metadata.csv".to_string(),
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path: PathBuf::from("/tmp/metadata.csv"),
};
let path = cache.get_file_path(&file_info);
let expected_path = temp_dir
.path()
.join("uk-daily-temperature-obs")
.join("capability")
.join("devon")
.join("01382_exeter")
.join("metadata.csv");
assert_eq!(path, expected_path);
}
#[tokio::test]
async fn test_reservation_system() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let file_info = create_test_file_info();
let status1 = cache.check_and_reserve(&file_info).await.unwrap();
assert_eq!(status1, ReservationStatus::Reserved);
let status2 = cache.check_and_reserve(&file_info).await.unwrap();
assert!(matches!(status2, ReservationStatus::ReservedByOther { .. }));
cache.release_reservation(&file_info.hash).await.unwrap();
let status3 = cache.check_and_reserve(&file_info).await.unwrap();
assert_eq!(status3, ReservationStatus::Reserved);
}
#[tokio::test]
async fn test_atomic_file_operations() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let file_info = create_test_file_info();
let status = cache.check_and_reserve(&file_info).await.unwrap();
assert_eq!(status, ReservationStatus::Reserved);
let test_content = b"test content for hash verification";
let actual_hash = md5::compute(test_content);
let file_info_with_correct_hash = FileInfo {
hash: Md5Hash::from_bytes(actual_hash.0),
..file_info
};
cache
.save_file_atomic(test_content, &file_info_with_correct_hash)
.await
.unwrap();
let file_path = cache.get_file_path(&file_info_with_correct_hash);
assert!(file_path.exists());
let saved_content = fs::read(&file_path).await.unwrap();
assert_eq!(saved_content, test_content);
let verified = cache
.verify_file_hash(&file_path, &file_info_with_correct_hash.hash)
.await
.unwrap();
assert!(verified);
}
#[tokio::test]
async fn test_reservation_timeout() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
reservation_timeout: Duration::from_millis(10), ..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let file_info = create_test_file_info();
let status1 = cache.check_and_reserve(&file_info).await.unwrap();
assert_eq!(status1, ReservationStatus::Reserved);
tokio::time::sleep(Duration::from_millis(20)).await;
let status2 = cache.check_and_reserve(&file_info).await.unwrap();
assert_eq!(status2, ReservationStatus::Reserved);
}
#[tokio::test]
async fn test_cache_verification() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let file_info = create_test_file_info();
let test_content = b"verification test content";
let actual_hash = md5::compute(test_content);
let file_info_with_correct_hash = FileInfo {
hash: Md5Hash::from_bytes(actual_hash.0),
..file_info
};
cache
.check_and_reserve(&file_info_with_correct_hash)
.await
.unwrap();
cache
.save_file_atomic(test_content, &file_info_with_correct_hash)
.await
.unwrap();
let files = vec![file_info_with_correct_hash];
let report = cache.verify_cache_integrity(files).await.unwrap();
assert_eq!(report.files_checked, 1);
assert_eq!(report.files_verified, 1);
assert_eq!(report.files_failed, 0);
assert!(report.is_successful());
assert_eq!(report.success_rate(), 100.0);
}
#[tokio::test]
#[ignore = "Integration test - run explicitly to verify cache structure"]
async fn test_synthetic_manifest_full_integration() {
use crate::app::manifest::{ManifestConfig, ManifestStreamer};
use futures::StreamExt;
use std::io::Write;
use tempfile::NamedTempFile;
let temp_dir = TempDir::new().unwrap();
let cache_config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(cache_config).await.unwrap();
let manifest_content = generate_synthetic_manifest();
let mut manifest_file = NamedTempFile::new().unwrap();
manifest_file
.write_all(manifest_content.as_bytes())
.unwrap();
manifest_file.flush().unwrap();
let manifest_config = ManifestConfig {
destination_root: temp_dir.path().to_path_buf(),
..Default::default()
};
let mut streamer = ManifestStreamer::with_config(manifest_config);
let mut stream = streamer.stream(manifest_file.path()).await.unwrap();
let mut processed_files = Vec::new();
let mut cache_operations = 0;
while let Some(result) = stream.next().await {
match result {
Ok(file_info) => {
println!(
"Processing: {} -> {}",
file_info.file_name,
cache.get_file_path(&file_info).display()
);
match cache.check_and_reserve(&file_info).await.unwrap() {
ReservationStatus::Reserved => {
let synthetic_content = generate_file_content_for_hash(&file_info.hash);
match cache.save_file_atomic(&synthetic_content, &file_info).await {
Ok(_) => {
cache_operations += 1;
println!(" ✓ Cached successfully");
}
Err(e) => {
println!(
" ⚠ Cache failed (expected for hash mismatch): {}",
e
);
}
}
}
ReservationStatus::AlreadyExists => {
println!(" ✓ Already exists in cache");
}
ReservationStatus::ReservedByOther { worker_id } => {
println!(" ⚠ Reserved by worker {}", worker_id);
}
}
processed_files.push(file_info);
}
Err(e) => {
eprintln!("Error processing manifest line: {}", e);
}
}
}
println!("\n📊 Processing Summary:");
println!(" Total files processed: {}", processed_files.len());
println!(" Cache operations: {}", cache_operations);
verify_cache_structure(temp_dir.path(), &processed_files).await;
println!("\n🔍 Running cache verification...");
let verification_report = cache
.verify_cache_integrity(processed_files.clone())
.await
.unwrap();
println!(" Files checked: {}", verification_report.files_checked);
println!(" Files verified: {}", verification_report.files_verified);
println!(" Files missing: {}", verification_report.files_missing);
println!(" Files failed: {}", verification_report.files_failed);
println!(" Success rate: {:.1}%", verification_report.success_rate());
println!(
" Verification time: {:.2}s",
verification_report.verification_time.as_secs_f64()
);
if !verification_report.failed_files.is_empty() {
println!("\n Failed files (first 5):");
for (i, failure) in verification_report.failed_files.iter().take(5).enumerate() {
println!(
" {}. {} - {}",
i + 1,
failure.file_info.file_name,
failure.reason
);
}
}
assert!(
processed_files.len() >= 50,
"Should process at least 50 files"
);
assert!(
cache_operations > 0,
"Should have performed some cache operations"
);
assert_eq!(verification_report.files_checked, processed_files.len());
println!(
" Cache operations: {} / {} files",
cache_operations,
processed_files.len()
);
println!("\n✅ Integration test completed successfully!");
println!(
"📁 Cache structure created at: {}",
temp_dir.path().display()
);
println!(" You can inspect the directory structure manually if needed.");
}
#[tokio::test]
async fn test_station_metadata_cache_path() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let dataset_info = DatasetFileInfo {
dataset_name: "uk-daily-temperature-obs".to_string(),
version: "202507".to_string(),
county: None,
station_id: None,
station_name: None,
quality_version: None,
year: None,
file_type: Some("station-metadata".to_string()),
};
let file_info = FileInfo {
hash,
relative_path: "./data/station-metadata.csv".to_string(),
file_name: "midas-open_uk-daily-temperature-obs_dv-202507_station-metadata.csv"
.to_string(),
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path: PathBuf::from("/tmp/station-metadata.csv"),
};
let path = cache.get_file_path(&file_info);
let expected_path = temp_dir
.path()
.join("uk-daily-temperature-obs")
.join("station-metadata")
.join("midas-open_uk-daily-temperature-obs_dv-202507_station-metadata.csv");
assert_eq!(path, expected_path);
}
#[tokio::test]
async fn test_change_log_cache_path() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let dataset_info = DatasetFileInfo {
dataset_name: "uk-daily-temperature-obs".to_string(),
version: "202507".to_string(),
county: None,
station_id: None,
station_name: None,
quality_version: None,
year: None,
file_type: Some("change-log".to_string()),
};
let file_info = FileInfo {
hash,
relative_path: "./data/change-log.txt".to_string(),
file_name: "midas-open_uk-daily-temperature-obs_dv-202507_change_log.txt".to_string(),
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path: PathBuf::from("/tmp/change-log.txt"),
};
let path = cache.get_file_path(&file_info);
let expected_path = temp_dir
.path()
.join("uk-daily-temperature-obs")
.join("midas-open_uk-daily-temperature-obs_dv-202507_change_log.txt");
assert_eq!(path, expected_path);
}
#[tokio::test]
async fn test_station_log_cache_path() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
cache_root: Some(temp_dir.path().to_path_buf()),
..Default::default()
};
let cache = CacheManager::new(config).await.unwrap();
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let dataset_info = DatasetFileInfo {
dataset_name: "uk-daily-temperature-obs".to_string(),
version: "202507".to_string(),
county: None,
station_id: None,
station_name: None,
quality_version: None,
year: None,
file_type: Some("station-log".to_string()),
};
let file_info = FileInfo {
hash,
relative_path: "./data/station-log.txt".to_string(),
file_name: "some-station-log.txt".to_string(),
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path: PathBuf::from("/tmp/station-log.txt"),
};
let path = cache.get_file_path(&file_info);
let expected_path = temp_dir
.path()
.join("uk-daily-temperature-obs")
.join("station-log-files")
.join("some-station-log.txt");
assert_eq!(path, expected_path);
}
fn generate_synthetic_manifest() -> String {
let mut manifest = String::new();
let datasets = [
("uk-daily-temperature-obs", "202407"),
("uk-daily-rainfall-obs", "202407"),
("uk-daily-weather-obs", "202407"),
];
let locations = [
(
"devon",
[
("01381", "twist"),
("01382", "exeter"),
("01383", "plymouth"),
],
),
(
"cornwall",
[
("01234", "newquay"),
("01235", "truro"),
("01236", "falmouth"),
],
),
(
"london",
[("00123", "heathrow"), ("00124", "kew"), ("00125", "city")],
),
(
"yorkshire",
[
("02345", "leeds"),
("02346", "york"),
("02347", "sheffield"),
],
),
];
let quality_versions = ["qc-version-0", "qc-version-1"];
let years = ["1980", "1981", "1985", "2020", "2021", "2023"];
let mut hash_counter = 0u32;
for (dataset, version) in &datasets {
for (county, stations) in &locations {
for (station_id, station_name) in stations {
let capability_path = format!(
"./data/{}/dataset-version-{}/{}/{}_{}/midas-open_{}_dv-{}_{}_{}_{}_capability.csv",
dataset,
version,
county,
station_id,
station_name,
dataset,
version,
county,
station_id,
station_name
);
let capability_content =
generate_synthetic_file_content(hash_counter, "capability");
let capability_hash = compute_content_hash(&capability_content);
manifest.push_str(&format!("{} {}\n", capability_hash, capability_path));
hash_counter += 1;
let metadata_path = format!(
"./data/{}/dataset-version-{}/{}/{}_{}/midas-open_{}_dv-{}_{}_{}_{}_metadata.csv",
dataset,
version,
county,
station_id,
station_name,
dataset,
version,
county,
station_id,
station_name
);
let metadata_content =
generate_synthetic_file_content(hash_counter, "metadata");
let metadata_hash = compute_content_hash(&metadata_content);
manifest.push_str(&format!("{} {}\n", metadata_hash, metadata_path));
hash_counter += 1;
for quality_version in &quality_versions {
let qv_short = if quality_version.contains("0") {
"qcv-0"
} else {
"qcv-1"
};
for year in &years {
let data_path = format!(
"./data/{}/dataset-version-{}/{}/{}_{}/{}/midas-open_{}_dv-{}_{}_{}_{}_{}_{}.csv",
dataset,
version,
county,
station_id,
station_name,
quality_version,
dataset,
version,
county,
station_id,
station_name,
qv_short,
year
);
let data_content =
generate_synthetic_file_content(hash_counter, "data");
let data_hash = compute_content_hash(&data_content);
manifest.push_str(&format!("{} {}\n", data_hash, data_path));
hash_counter += 1;
}
}
}
}
}
let no_year_path = "./data/uk-daily-temperature-obs/dataset-version-202407/devon/01999_special/special_file.csv";
let no_year_content = generate_synthetic_file_content(hash_counter, "special");
let no_year_hash = compute_content_hash(&no_year_content);
manifest.push_str(&format!("{} {}\n", no_year_hash, no_year_path));
hash_counter += 1;
let no_county_path =
"./data/uk-daily-temperature-obs/dataset-version-202407/unknown_station.csv";
let no_county_content = generate_synthetic_file_content(hash_counter, "unknown");
let no_county_hash = compute_content_hash(&no_county_content);
manifest.push_str(&format!("{} {}\n", no_county_hash, no_county_path));
println!(
"Generated synthetic manifest with {} entries",
manifest.lines().count()
);
manifest
}
fn generate_synthetic_file_content(counter: u32, file_type: &str) -> Vec<u8> {
let content = match file_type {
"capability" => format!(
"# MIDAS Open Dataset Capability File\n# Generated for testing - ID: {}\n\nparameter,units,frequency,start_date,end_date\ntemperature,celsius,daily,1980-01-01,2023-12-31\nrainfall,mm,daily,1980-01-01,2023-12-31\nwind_speed,m/s,daily,1985-01-01,2023-12-31\n",
counter
),
"metadata" => format!(
"# MIDAS Open Dataset Metadata File\n# Generated for testing - ID: {}\n\nstation_id,name,latitude,longitude,elevation,county,start_date,end_date\n12345,Test Station,50.7236,-3.5275,120,Devon,1980-01-01,2023-12-31\n",
counter
),
"data" => format!(
"# MIDAS Open Dataset Data File\n# Generated for testing - ID: {}\n\nstation_id,date,temperature_max,temperature_min,rainfall\n12345,2023-01-01,15.5,8.2,2.1\n12345,2023-01-02,16.2,9.1,0.0\n12345,2023-01-03,14.8,7.5,5.3\n",
counter
),
_ => format!(
"# MIDAS Open Dataset File ({})\n# Generated for testing - ID: {}\n\ndata_column,value\ntest_data,{}\n",
file_type, counter, counter
),
};
content.into_bytes()
}
fn compute_content_hash(content: &[u8]) -> String {
let digest = md5::compute(content);
format!("{:x}", digest)
}
fn generate_file_content_for_hash(hash: &Md5Hash) -> Vec<u8> {
let hash_str = hash.to_hex();
for file_type in &["capability", "metadata", "data", "special", "unknown"] {
for counter in 0u32..1000 {
let test_content = generate_synthetic_file_content(counter, file_type);
let test_hash = compute_content_hash(&test_content);
if test_hash == hash_str {
return test_content;
}
}
}
let fallback_content = format!(
"# Synthetic MIDAS data file\n# Hash: {}\n# Generated for testing purposes\n\nstation_id,date,value\n12345,2023-01-01,15.5\n",
hash_str
);
fallback_content.into_bytes()
}
async fn verify_cache_structure(cache_root: &std::path::Path, files: &[FileInfo]) {
println!("\n🗂️ Cache Directory Structure:");
print_directory_tree(cache_root, 0);
let mut dataset_counts = std::collections::HashMap::new();
for file in files {
*dataset_counts
.entry(&file.dataset_info.dataset_name)
.or_insert(0) += 1;
}
println!("\n📈 Files by Dataset:");
for (dataset, count) in dataset_counts {
println!(" {}: {} files", dataset, count);
}
let expected_datasets = [
"uk-daily-temperature-obs",
"uk-daily-rainfall-obs",
"uk-daily-weather-obs",
];
for dataset in &expected_datasets {
let dataset_path = cache_root.join(dataset);
assert!(
dataset_path.exists(),
"Dataset directory should exist: {}",
dataset
);
let year_dirs = if let Ok(entries) = std::fs::read_dir(&dataset_path) {
entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().unwrap().is_dir())
.count()
} else {
0
};
println!(" {}: {} year directories", dataset, year_dirs);
assert!(
year_dirs > 0,
"Should have at least one year directory for {}",
dataset
);
}
let devon_temp_1980_qcv1 = cache_root
.join("uk-daily-temperature-obs")
.join("1980")
.join("qcv-1")
.join("devon");
if devon_temp_1980_qcv1.exists() {
let station_count = if let Ok(entries) = std::fs::read_dir(&devon_temp_1980_qcv1) {
entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().unwrap().is_dir())
.count()
} else {
0
};
println!(" devon/1980/qcv-1 stations: {}", station_count);
assert!(station_count > 0, "Should have station directories");
}
}
fn print_directory_tree(dir: &std::path::Path, indent: usize) {
let prefix = " ".repeat(indent);
if let Ok(entries) = std::fs::read_dir(dir) {
let mut entries: Vec<_> = entries.filter_map(|entry| entry.ok()).collect();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries.iter().take(10) {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if entry.file_type().unwrap().is_dir() {
println!("{}📁 {}/", prefix, file_name_str);
if indent < 4 {
print_directory_tree(&entry.path(), indent + 1);
}
} else {
println!("{}📄 {}", prefix, file_name_str);
}
}
if entries.len() > 10 {
println!("{}... ({} more items)", prefix, entries.len() - 10);
}
}
}
}