use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VersioningError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Version not found: {0}")]
VersionNotFound(String),
#[error("Invalid version format: {0}")]
InvalidVersion(String),
#[error("Checksum mismatch: expected {expected}, got {actual}")]
ChecksumMismatch { expected: String, actual: String },
}
pub type VersioningResult<T> = Result<T, VersioningError>;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DatasetVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub prerelease: Option<String>,
}
impl DatasetVersion {
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
prerelease: None,
}
}
pub fn with_prerelease(mut self, prerelease: String) -> Self {
self.prerelease = Some(prerelease);
self
}
pub fn from_string(s: &str) -> VersioningResult<Self> {
let parts: Vec<&str> = s.split('-').collect();
let version_parts: Vec<&str> = parts[0].split('.').collect();
if version_parts.len() != 3 {
return Err(VersioningError::InvalidVersion(s.to_string()));
}
let major = version_parts[0]
.parse()
.map_err(|_| VersioningError::InvalidVersion(s.to_string()))?;
let minor = version_parts[1]
.parse()
.map_err(|_| VersioningError::InvalidVersion(s.to_string()))?;
let patch = version_parts[2]
.parse()
.map_err(|_| VersioningError::InvalidVersion(s.to_string()))?;
let prerelease = if parts.len() > 1 {
Some(parts[1].to_string())
} else {
None
};
Ok(Self {
major,
minor,
patch,
prerelease,
})
}
}
impl fmt::Display for DatasetVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref pre) = self.prerelease {
write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre)
} else {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvenanceInfo {
pub dataset_id: String,
pub version: DatasetVersion,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub creator: String,
pub description: String,
pub sources: Vec<String>,
pub transformations: Vec<TransformationStep>,
pub checksums: HashMap<String, String>,
pub metadata: HashMap<String, String>,
}
impl ProvenanceInfo {
pub fn new(dataset_id: String, version: DatasetVersion, creator: String) -> Self {
let now = Utc::now();
Self {
dataset_id,
version,
created_at: now,
modified_at: now,
creator,
description: String::new(),
sources: Vec::new(),
transformations: Vec::new(),
checksums: HashMap::new(),
metadata: HashMap::new(),
}
}
pub fn with_description(mut self, description: String) -> Self {
self.description = description;
self
}
pub fn add_source(&mut self, source_id: String) {
self.sources.push(source_id);
self.modified_at = Utc::now();
}
pub fn add_transformation(&mut self, transformation: TransformationStep) {
self.transformations.push(transformation);
self.modified_at = Utc::now();
}
pub fn add_checksum(&mut self, name: String, checksum: String) {
self.checksums.insert(name, checksum);
self.modified_at = Utc::now();
}
pub fn add_metadata(&mut self, key: String, value: String) {
self.metadata.insert(key, value);
self.modified_at = Utc::now();
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> VersioningResult<()> {
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> VersioningResult<Self> {
let json = std::fs::read_to_string(path)?;
let provenance: ProvenanceInfo = serde_json::from_str(&json)?;
Ok(provenance)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformationStep {
pub timestamp: DateTime<Utc>,
pub transformation_type: String,
pub description: String,
pub parameters: HashMap<String, String>,
pub performed_by: String,
}
impl TransformationStep {
pub fn new(transformation_type: String, description: String, performed_by: String) -> Self {
Self {
timestamp: Utc::now(),
transformation_type,
description,
parameters: HashMap::new(),
performed_by,
}
}
pub fn with_parameter(mut self, key: String, value: String) -> Self {
self.parameters.insert(key, value);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionRegistry {
pub dataset_name: String,
pub versions: HashMap<String, ProvenanceInfo>,
pub current_version: Option<String>,
}
impl VersionRegistry {
pub fn new(dataset_name: String) -> Self {
Self {
dataset_name,
versions: HashMap::new(),
current_version: None,
}
}
pub fn register_version(&mut self, provenance: ProvenanceInfo) {
let version_str = provenance.version.to_string();
self.versions.insert(version_str.clone(), provenance);
self.current_version = Some(version_str);
}
pub fn get_version(&self, version: &str) -> Option<&ProvenanceInfo> {
self.versions.get(version)
}
pub fn get_current(&self) -> Option<&ProvenanceInfo> {
self.current_version
.as_ref()
.and_then(|v| self.versions.get(v))
}
pub fn set_current(&mut self, version: &str) -> VersioningResult<()> {
if !self.versions.contains_key(version) {
return Err(VersioningError::VersionNotFound(version.to_string()));
}
self.current_version = Some(version.to_string());
Ok(())
}
pub fn list_versions(&self) -> Vec<&ProvenanceInfo> {
let mut versions: Vec<&ProvenanceInfo> = self.versions.values().collect();
versions.sort_by_key(|p| &p.created_at);
versions
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> VersioningResult<()> {
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> VersioningResult<Self> {
let json = std::fs::read_to_string(path)?;
let registry: VersionRegistry = serde_json::from_str(&json)?;
Ok(registry)
}
}
pub fn calculate_checksum(data: &[u8]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn verify_checksum(data: &[u8], expected: &str) -> VersioningResult<()> {
let actual = calculate_checksum(data);
if actual != expected {
Err(VersioningError::ChecksumMismatch {
expected: expected.to_string(),
actual,
})
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dataset_version() {
let version = DatasetVersion::new(1, 2, 3);
assert_eq!(version.to_string(), "1.2.3");
let version_pre = DatasetVersion::new(1, 2, 3).with_prerelease("alpha".to_string());
assert_eq!(version_pre.to_string(), "1.2.3-alpha");
}
#[test]
fn test_version_parsing() {
let version = DatasetVersion::from_string("1.2.3").expect("operation should succeed");
assert_eq!(version.major, 1);
assert_eq!(version.minor, 2);
assert_eq!(version.patch, 3);
assert_eq!(version.prerelease, None);
let version_pre =
DatasetVersion::from_string("1.2.3-beta").expect("operation should succeed");
assert_eq!(version_pre.prerelease, Some("beta".to_string()));
}
#[test]
fn test_provenance_info() {
let mut provenance = ProvenanceInfo::new(
"test-dataset".to_string(),
DatasetVersion::new(1, 0, 0),
"test-user".to_string(),
);
provenance.add_source("source-dataset-1".to_string());
provenance.add_metadata("key1".to_string(), "value1".to_string());
assert_eq!(provenance.sources.len(), 1);
assert_eq!(provenance.metadata.len(), 1);
}
#[test]
fn test_transformation_step() {
let step = TransformationStep::new(
"normalization".to_string(),
"StandardScaler normalization".to_string(),
"test-user".to_string(),
)
.with_parameter("mean".to_string(), "0.0".to_string())
.with_parameter("std".to_string(), "1.0".to_string());
assert_eq!(step.parameters.len(), 2);
}
#[test]
fn test_version_registry() {
let mut registry = VersionRegistry::new("test-dataset".to_string());
let prov1 = ProvenanceInfo::new(
"test-dataset".to_string(),
DatasetVersion::new(1, 0, 0),
"user1".to_string(),
);
registry.register_version(prov1);
let prov2 = ProvenanceInfo::new(
"test-dataset".to_string(),
DatasetVersion::new(1, 1, 0),
"user1".to_string(),
);
registry.register_version(prov2);
assert_eq!(registry.versions.len(), 2);
assert!(registry.get_version("1.0.0").is_some());
assert!(registry.get_version("1.1.0").is_some());
}
#[test]
fn test_checksum() {
let data = b"test data";
let checksum = calculate_checksum(data);
assert!(verify_checksum(data, &checksum).is_ok());
assert!(verify_checksum(data, "invalid").is_err());
}
#[test]
fn test_provenance_serialization() {
use std::env::temp_dir;
let provenance = ProvenanceInfo::new(
"test-dataset".to_string(),
DatasetVersion::new(1, 0, 0),
"test-user".to_string(),
)
.with_description("Test dataset".to_string());
let temp_path = temp_dir().join("test_provenance.json");
provenance
.save_to_file(&temp_path)
.expect("operation should succeed");
let loaded = ProvenanceInfo::load_from_file(&temp_path).expect("operation should succeed");
assert_eq!(loaded.dataset_id, "test-dataset");
assert_eq!(loaded.version.major, 1);
std::fs::remove_file(temp_path).ok();
}
}