use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::app::hash::Md5Hash;
use crate::errors::{ManifestError, ManifestResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum QualityControlVersion {
V0,
V1,
}
impl QualityControlVersion {
pub fn from_directory_name(name: &str) -> Option<Self> {
match name {
"qc-version-0" => Some(Self::V0),
"qc-version-1" => Some(Self::V1),
_ => None,
}
}
pub fn from_filename_format(name: &str) -> Option<Self> {
match name {
"qcv-0" => Some(Self::V0),
"qcv-1" => Some(Self::V1),
_ => None,
}
}
pub fn to_directory_name(&self) -> &'static str {
match self {
Self::V0 => "qc-version-0",
Self::V1 => "qc-version-1",
}
}
pub fn to_filename_format(&self) -> &'static str {
match self {
Self::V0 => "qcv-0",
Self::V1 => "qcv-1",
}
}
}
impl std::fmt::Display for QualityControlVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_filename_format())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DatasetFileInfo {
pub dataset_name: String,
pub version: String,
pub county: Option<String>,
pub station_id: Option<String>,
pub station_name: Option<String>,
pub quality_version: Option<QualityControlVersion>,
pub year: Option<String>,
pub file_type: Option<String>,
}
impl DatasetFileInfo {
pub fn from_path(path: &str) -> ManifestResult<Self> {
let normalized_path = path.strip_prefix("./").unwrap_or(path);
let components: Vec<&str> = normalized_path.split('/').collect();
if components.len() < 3 {
return Err(ManifestError::InvalidPath {
path: path.to_string(),
});
}
if let Some(filename) = components.last() {
if filename.contains("station-metadata") {
return Self::parse_special_file(components, "station-metadata".to_string());
}
if filename.contains("change_log") && filename.ends_with(".txt") {
return Self::parse_special_file(components, "change-log".to_string());
}
}
if components.iter().any(|c| *c == "change_log_station_files") {
return Self::parse_special_file(components, "station-log".to_string());
}
let mut dataset_name = String::new();
let mut version = String::new();
let mut county = None;
let mut station_id = None;
let mut station_name = None;
let mut quality_version = None;
let mut year = None;
let mut file_type = None;
for (i, component) in components.iter().enumerate() {
match i {
0 => {
if *component != "data" {
return Err(ManifestError::InvalidPath {
path: path.to_string(),
});
}
}
1 => {
dataset_name = component.to_string();
}
2 => {
if let Some(version_str) = component.strip_prefix("dataset-version-") {
version = version_str.to_string();
} else {
version = component.to_string();
}
}
3 => {
county = Some(component.to_string());
}
4 => {
if let Some((id, name)) = component.split_once('_') {
station_id = Some(id.to_string());
station_name = Some(name.to_string());
} else {
station_id = Some(component.to_string());
}
}
5 => {
if component.starts_with("qc-version-") {
quality_version = QualityControlVersion::from_directory_name(component);
} else if component.ends_with(".csv") {
let filename = *component;
if let Some(stem) = filename.strip_suffix(".csv") {
let parts: Vec<&str> = stem.split('_').collect();
for part in parts.iter().rev() {
if part.len() == 4 && part.chars().all(|c| c.is_ascii_digit()) {
year = Some(part.to_string());
break;
}
}
if filename.contains("capability") {
file_type = Some("capability".to_string());
} else if filename.contains("metadata") {
file_type = Some("metadata".to_string());
} else {
file_type = Some("data".to_string());
}
}
} else {
return Err(ManifestError::InvalidPath {
path: path.to_string(),
});
}
}
_ => {
if i == components.len() - 1 {
let filename = *component;
if let Some(stem) = filename.strip_suffix(".csv") {
let parts: Vec<&str> = stem.split('_').collect();
for part in parts.iter().rev() {
if part.len() == 4 && part.chars().all(|c| c.is_ascii_digit()) {
year = Some(part.to_string());
break;
}
}
if filename.contains("capability") {
file_type = Some("capability".to_string());
} else if filename.contains("metadata") {
file_type = Some("metadata".to_string());
} else {
file_type = Some("data".to_string());
}
}
}
}
}
}
if dataset_name.is_empty() {
return Err(ManifestError::InvalidPath {
path: path.to_string(),
});
}
Ok(DatasetFileInfo {
dataset_name,
version,
county,
station_id,
station_name,
quality_version,
year,
file_type,
})
}
fn parse_special_file(components: Vec<&str>, file_type: String) -> ManifestResult<Self> {
if components.len() < 3 {
return Err(ManifestError::InvalidPath {
path: components.join("/"),
});
}
let dataset_name = if components.len() > 1 {
components[1].to_string()
} else {
return Err(ManifestError::InvalidPath {
path: components.join("/"),
});
};
let version = if components.len() > 2 {
if let Some(version_str) = components[2].strip_prefix("dataset-version-") {
version_str.to_string()
} else {
components[2].to_string()
}
} else {
String::new()
};
Ok(DatasetFileInfo {
dataset_name,
version,
county: None,
station_id: None,
station_name: None,
quality_version: None,
year: None,
file_type: Some(file_type),
})
}
pub fn display_name(&self) -> String {
let mut parts = vec![self.dataset_name.clone()];
if !self.version.is_empty() {
parts.push(format!("v{}", self.version));
}
if let Some(ref county) = self.county {
parts.push(county.clone());
}
if let Some(ref station) = self.station_name {
parts.push(station.clone());
}
if let Some(ref year) = self.year {
parts.push(year.clone());
}
parts.join("-")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
pub hash: Md5Hash,
pub relative_path: String,
pub file_name: String,
pub dataset_info: DatasetFileInfo,
pub retry_count: u32,
#[serde(skip)]
pub last_attempt: Option<Instant>,
pub estimated_size: Option<u64>,
pub destination_path: PathBuf,
}
impl FileInfo {
pub fn new(
hash: Md5Hash,
relative_path: String,
destination_root: &Path,
) -> ManifestResult<Self> {
let dataset_info = DatasetFileInfo::from_path(&relative_path)?;
let file_name = Path::new(&relative_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown")
.to_string();
let normalized_path = relative_path.strip_prefix("./").unwrap_or(&relative_path);
let destination_path = destination_root.join(normalized_path);
Ok(FileInfo {
hash,
relative_path,
file_name,
dataset_info,
retry_count: 0,
last_attempt: None,
estimated_size: None,
destination_path,
})
}
pub fn should_retry(&self, max_retries: u32, retry_delay: std::time::Duration) -> bool {
if self.retry_count >= max_retries {
return false;
}
if let Some(last_attempt) = self.last_attempt {
last_attempt.elapsed() >= retry_delay
} else {
true
}
}
pub fn mark_retry_attempt(&mut self) {
self.retry_count += 1;
self.last_attempt = Some(Instant::now());
}
pub fn reset_retry_state(&mut self) {
self.retry_count = 0;
self.last_attempt = None;
}
pub fn download_url(&self, base_url: &str) -> String {
let base_url = base_url.trim_end_matches('/');
let relative_path = self
.relative_path
.strip_prefix("./")
.unwrap_or(&self.relative_path);
format!("{}/badc/ukmo-midas-open/{}", base_url, relative_path)
}
pub fn exists_at_destination(&self) -> bool {
self.destination_path.exists()
}
pub fn destination_directory(&self) -> Option<&Path> {
self.destination_path.parent()
}
}
impl Hash for FileInfo {
fn hash<H: Hasher>(&self, state: &mut H) {
self.hash.hash(state);
}
}
impl PartialEq for FileInfo {
fn eq(&self, other: &Self) -> bool {
self.hash == other.hash
}
}
impl Eq for FileInfo {}
pub fn parse_manifest_line(line: &str) -> ManifestResult<(Md5Hash, String)> {
let line = line.trim();
if line.is_empty() {
return Err(ManifestError::InvalidFormat {
line: 0,
content: "Empty line".to_string(),
});
}
let mut parts = line.split_whitespace();
let hash = parts.next().ok_or_else(|| ManifestError::InvalidFormat {
line: 0,
content: "Missing hash".to_string(),
})?;
let path = parts.next().ok_or_else(|| ManifestError::InvalidFormat {
line: 0,
content: "Missing path".to_string(),
})?;
if parts.next().is_some() {
return Err(ManifestError::InvalidFormat {
line: 0,
content: "Too many fields".to_string(),
});
}
let hash = Md5Hash::from_hex(hash)?;
if !path.starts_with("./") {
return Err(ManifestError::InvalidPath {
path: path.to_string(),
});
}
Ok((hash, path.to_string()))
}
pub fn generate_file_id(file_info: &FileInfo) -> u64 {
let mut hasher = DefaultHasher::new();
file_info.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_valid_md5_hash() {
assert!(Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").is_ok());
assert!(Md5Hash::from_hex("9734faa872681f96b144f60d29d52011").is_ok());
assert!(Md5Hash::from_hex("abcdef1234567890abcdef1234567890").is_ok());
assert!(Md5Hash::from_hex("").is_err());
assert!(Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4").is_err()); assert!(Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e5").is_err()); assert!(Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4g").is_err()); assert!(Md5Hash::from_hex("50C9D1C465F3CBFF652BE1509C2E2A4E").is_ok()); }
#[test]
fn test_manifest_line_parsing() {
let (hash, path) =
parse_manifest_line("50c9d1c465f3cbff652be1509c2e2a4e ./data/test.csv").unwrap();
assert_eq!(hash.to_hex(), "50c9d1c465f3cbff652be1509c2e2a4e");
assert_eq!(path, "./data/test.csv");
let (hash, path) =
parse_manifest_line("50c9d1c465f3cbff652be1509c2e2a4e ./data/test.csv").unwrap();
assert_eq!(hash.to_hex(), "50c9d1c465f3cbff652be1509c2e2a4e");
assert_eq!(path, "./data/test.csv");
assert!(parse_manifest_line("").is_err()); assert!(parse_manifest_line("50c9d1c465f3cbff652be1509c2e2a4e").is_err()); assert!(parse_manifest_line("invalid_hash ./data/test.csv").is_err()); assert!(parse_manifest_line("50c9d1c465f3cbff652be1509c2e2a4e data/test.csv").is_err()); assert!(
parse_manifest_line("50c9d1c465f3cbff652be1509c2e2a4e ./data/test.csv extra").is_err()
); }
#[test]
fn test_dataset_info_parsing() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/qc-version-1/midas-open_uk-daily-temperature-obs_dv-202407_devon_01381_twist_qcv-1_1980.csv";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.dataset_name, "uk-daily-temperature-obs");
assert_eq!(dataset_info.version, "202407");
assert_eq!(dataset_info.county, Some("devon".to_string()));
assert_eq!(dataset_info.station_id, Some("01381".to_string()));
assert_eq!(dataset_info.station_name, Some("twist".to_string()));
assert_eq!(
dataset_info.quality_version,
Some(QualityControlVersion::V1)
);
assert_eq!(dataset_info.year, Some("1980".to_string()));
assert_eq!(dataset_info.file_type, Some("data".to_string()));
}
#[test]
fn test_dataset_info_capability_file() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/midas-open_uk-daily-temperature-obs_dv-202407_devon_01381_twist_capability.csv";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.dataset_name, "uk-daily-temperature-obs");
assert_eq!(dataset_info.version, "202407");
assert_eq!(dataset_info.county, Some("devon".to_string()));
assert_eq!(dataset_info.station_id, Some("01381".to_string()));
assert_eq!(dataset_info.station_name, Some("twist".to_string()));
assert_eq!(dataset_info.quality_version, None);
assert_eq!(dataset_info.year, None);
assert_eq!(dataset_info.file_type, Some("capability".to_string()));
}
#[test]
fn test_invalid_dataset_paths() {
assert!(DatasetFileInfo::from_path("./data").is_err());
assert!(DatasetFileInfo::from_path("./invalid").is_err());
assert!(DatasetFileInfo::from_path("./other/dataset/file.csv").is_err());
}
#[test]
fn test_file_info_creation() {
let temp_dir = tempdir().unwrap();
let destination_root = temp_dir.path();
let hash = "50c9d1c465f3cbff652be1509c2e2a4e".to_string();
let path = "./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/qc-version-1/test_file.csv".to_string();
let hash_obj = Md5Hash::from_hex(&hash).unwrap();
let file_info = FileInfo::new(hash_obj, path.clone(), destination_root).unwrap();
assert_eq!(file_info.hash.to_hex(), hash);
assert_eq!(file_info.relative_path, path);
assert_eq!(file_info.file_name, "test_file.csv");
assert_eq!(file_info.retry_count, 0);
assert!(file_info.last_attempt.is_none());
let expected_dest = destination_root.join("data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/qc-version-1/test_file.csv");
assert_eq!(file_info.destination_path, expected_dest);
}
#[test]
fn test_file_info_invalid_hash() {
let temp_dir = tempdir().unwrap();
let _destination_root = temp_dir.path();
let result = Md5Hash::from_hex("invalid_hash");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ManifestError::InvalidHash { .. }
));
}
#[test]
fn test_file_info_retry_logic() {
let temp_dir = tempdir().unwrap();
let destination_root = temp_dir.path();
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let mut file_info = FileInfo::new(
hash,
"./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test.csv"
.to_string(),
destination_root,
)
.unwrap();
assert!(file_info.should_retry(3, std::time::Duration::from_millis(100)));
file_info.mark_retry_attempt();
assert_eq!(file_info.retry_count, 1);
assert!(file_info.last_attempt.is_some());
assert!(!file_info.should_retry(3, std::time::Duration::from_secs(60)));
std::thread::sleep(std::time::Duration::from_millis(2));
assert!(file_info.should_retry(3, std::time::Duration::from_millis(1)));
file_info.retry_count = 3;
assert!(!file_info.should_retry(3, std::time::Duration::from_millis(1)));
file_info.reset_retry_state();
assert_eq!(file_info.retry_count, 0);
assert!(file_info.last_attempt.is_none());
}
#[test]
fn test_file_info_download_url() {
let temp_dir = tempdir().unwrap();
let destination_root = temp_dir.path();
let hash = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let file_info = FileInfo::new(
hash,
"./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test.csv"
.to_string(),
destination_root,
)
.unwrap();
let url = file_info.download_url("https://data.ceda.ac.uk");
assert_eq!(
url,
"https://data.ceda.ac.uk/badc/ukmo-midas-open/data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test.csv"
);
let url = file_info.download_url("https://data.ceda.ac.uk/");
assert_eq!(
url,
"https://data.ceda.ac.uk/badc/ukmo-midas-open/data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test.csv"
);
}
#[test]
fn test_file_info_equality_and_hashing() {
let temp_dir = tempdir().unwrap();
let destination_root = temp_dir.path();
let hash1 = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let file1 = FileInfo::new(
hash1,
"./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test1.csv"
.to_string(),
destination_root,
)
.unwrap();
let hash2 = Md5Hash::from_hex("50c9d1c465f3cbff652be1509c2e2a4e").unwrap();
let file2 = FileInfo::new(
hash2,
"./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test2.csv"
.to_string(),
destination_root,
)
.unwrap();
let hash3 = Md5Hash::from_hex("9734faa872681f96b144f60d29d52011").unwrap();
let file3 = FileInfo::new(
hash3,
"./data/uk-daily-temperature-obs/dataset-version-202407/devon/01381_twist/test3.csv"
.to_string(),
destination_root,
)
.unwrap();
assert_eq!(file1, file2);
assert_ne!(file1, file3);
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(file1);
set.insert(file2); set.insert(file3);
assert_eq!(set.len(), 2); }
#[test]
fn test_dataset_info_display_name() {
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()),
};
let display_name = dataset_info.display_name();
assert_eq!(
display_name,
"uk-daily-temperature-obs-v202407-devon-twist-1980"
);
}
#[test]
fn test_quality_control_version_enum() {
assert_eq!(
QualityControlVersion::from_directory_name("qc-version-0"),
Some(QualityControlVersion::V0)
);
assert_eq!(
QualityControlVersion::from_directory_name("qc-version-1"),
Some(QualityControlVersion::V1)
);
assert_eq!(
QualityControlVersion::from_directory_name("qc-version-2"),
None
);
assert_eq!(
QualityControlVersion::from_filename_format("qcv-0"),
Some(QualityControlVersion::V0)
);
assert_eq!(
QualityControlVersion::from_filename_format("qcv-1"),
Some(QualityControlVersion::V1)
);
assert_eq!(QualityControlVersion::from_filename_format("qcv-2"), None);
assert_eq!(
QualityControlVersion::V0.to_directory_name(),
"qc-version-0"
);
assert_eq!(
QualityControlVersion::V1.to_directory_name(),
"qc-version-1"
);
assert_eq!(QualityControlVersion::V0.to_filename_format(), "qcv-0");
assert_eq!(QualityControlVersion::V1.to_filename_format(), "qcv-1");
assert_eq!(format!("{}", QualityControlVersion::V0), "qcv-0");
assert_eq!(format!("{}", QualityControlVersion::V1), "qcv-1");
}
#[test]
fn test_dataset_info_with_qcv0_parsing() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202407/devon/01373_clyst-honiton/qc-version-0/midas-open_uk-daily-temperature-obs_dv-202407_devon_01373_clyst-honiton_qcv-0_1997.csv";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.dataset_name, "uk-daily-temperature-obs");
assert_eq!(dataset_info.version, "202407");
assert_eq!(dataset_info.county, Some("devon".to_string()));
assert_eq!(dataset_info.station_id, Some("01373".to_string()));
assert_eq!(dataset_info.station_name, Some("clyst-honiton".to_string()));
assert_eq!(
dataset_info.quality_version,
Some(QualityControlVersion::V0)
);
assert_eq!(dataset_info.year, Some("1997".to_string()));
assert_eq!(dataset_info.file_type, Some("data".to_string()));
}
#[test]
fn test_station_metadata_file_detection() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202507/no-quality/midas-open_uk-daily-temperature-obs_dv-202507_station-metadata.csv";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.dataset_name, "uk-daily-temperature-obs");
assert_eq!(dataset_info.version, "202507");
assert_eq!(dataset_info.county, None);
assert_eq!(dataset_info.station_id, None);
assert_eq!(dataset_info.station_name, None);
assert_eq!(dataset_info.quality_version, None);
assert_eq!(dataset_info.year, None);
assert_eq!(dataset_info.file_type, Some("station-metadata".to_string()));
}
#[test]
fn test_change_log_file_detection() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202507/no-quality/midas-open_uk-daily-temperature-obs_dv-202507_change_log.txt";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.dataset_name, "uk-daily-temperature-obs");
assert_eq!(dataset_info.version, "202507");
assert_eq!(dataset_info.county, None);
assert_eq!(dataset_info.station_id, None);
assert_eq!(dataset_info.station_name, None);
assert_eq!(dataset_info.quality_version, None);
assert_eq!(dataset_info.year, None);
assert_eq!(dataset_info.file_type, Some("change-log".to_string()));
}
#[test]
fn test_station_log_file_detection() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202507/change_log_station_files/some-station-log.txt";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.dataset_name, "uk-daily-temperature-obs");
assert_eq!(dataset_info.version, "202507");
assert_eq!(dataset_info.county, None);
assert_eq!(dataset_info.station_id, None);
assert_eq!(dataset_info.station_name, None);
assert_eq!(dataset_info.quality_version, None);
assert_eq!(dataset_info.year, None);
assert_eq!(dataset_info.file_type, Some("station-log".to_string()));
}
#[test]
fn test_special_file_edge_cases() {
let path = "./data/uk-daily-temperature-obs/dataset-version-202507/nested/path/station-metadata-report.csv";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.file_type, Some("station-metadata".to_string()));
let path = "./data/uk-daily-temperature-obs/dataset-version-202507/another/path/system_change_log.txt";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.file_type, Some("change-log".to_string()));
let path = "./data/uk-daily-temperature-obs/dataset-version-202507/change_log_station_files/deep/nested/log.txt";
let dataset_info = DatasetFileInfo::from_path(path).unwrap();
assert_eq!(dataset_info.file_type, Some("station-log".to_string()));
}
}