use crate::error::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub id: String,
pub parent_id: Option<String>,
pub timestamp: DateTime<Utc>,
pub description: Option<String>,
pub metadata: CheckpointMetadata,
pub state_hash: String,
pub content_merkle_root: String,
pub signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointMetadata {
pub file_count: usize,
pub total_size: u64,
pub compressed_size: u64,
pub files_changed: usize,
pub bytes_changed: u64,
pub tags: Vec<String>,
pub custom: HashMap<String, String>,
pub titor_version: String,
pub host_info: HostInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostInfo {
pub hostname: String,
pub os: String,
pub arch: String,
pub username: Option<String>,
}
impl Default for HostInfo {
fn default() -> Self {
Self {
hostname: hostname::get()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string()),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
username: std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok(),
}
}
}
impl Checkpoint {
pub fn new(
parent_id: Option<String>,
description: Option<String>,
metadata: CheckpointMetadata,
content_merkle_root: String,
) -> Self {
let id = uuid::Uuid::new_v4().to_string();
let timestamp = Utc::now();
let mut checkpoint = Self {
id,
parent_id,
timestamp,
description,
metadata,
state_hash: String::new(),
content_merkle_root,
signature: None,
};
checkpoint.state_hash = checkpoint.compute_state_hash();
checkpoint
}
pub fn compute_state_hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.id);
hasher.update(self.parent_id.as_deref().unwrap_or(""));
hasher.update(self.timestamp.to_rfc3339());
hasher.update(self.description.as_deref().unwrap_or(""));
if let Ok(metadata_bytes) = serde_json::to_vec(&self.metadata) {
hasher.update(&metadata_bytes);
}
hasher.update(&self.content_merkle_root);
hex::encode(hasher.finalize())
}
pub fn verify_integrity(&self) -> Result<bool> {
let computed_hash = self.compute_state_hash();
if computed_hash != self.state_hash {
return Ok(false);
}
Ok(true)
}
pub fn is_descendant_of(&self, ancestor_id: &str, checkpoint_map: &HashMap<String, Checkpoint>) -> bool {
let mut current_id = self.parent_id.as_ref();
while let Some(id) = current_id {
if id == ancestor_id {
return true;
}
current_id = checkpoint_map
.get(id)
.and_then(|c| c.parent_id.as_ref());
}
false
}
pub fn get_parent_chain(&self, checkpoint_map: &HashMap<String, Checkpoint>) -> Vec<String> {
let mut chain = Vec::new();
let mut current_id = self.parent_id.as_ref();
while let Some(id) = current_id {
chain.push(id.clone());
current_id = checkpoint_map
.get(id)
.and_then(|c| c.parent_id.as_ref());
}
chain.reverse();
chain
}
pub fn short_id(&self) -> &str {
&self.id[..8.min(self.id.len())]
}
pub fn display_format(&self) -> String {
format!(
"[{}] {} - {} files, {} bytes{}",
self.short_id(),
self.timestamp.format("%Y-%m-%d %H:%M:%S"),
self.metadata.file_count,
human_bytes(self.metadata.total_size),
self.description
.as_ref()
.map(|d| format!(" - {}", d))
.unwrap_or_default()
)
}
}
fn human_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_idx = 0;
while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
size /= 1024.0;
unit_idx += 1;
}
if unit_idx == 0 {
format!("{} {}", size as u64, UNITS[unit_idx])
} else {
format!("{:.2} {}", size, UNITS[unit_idx])
}
}
#[derive(Debug, Default)]
pub struct CheckpointMetadataBuilder {
file_count: usize,
total_size: u64,
compressed_size: u64,
files_changed: usize,
bytes_changed: u64,
tags: Vec<String>,
custom: HashMap<String, String>,
}
impl CheckpointMetadataBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn file_count(mut self, count: usize) -> Self {
self.file_count = count;
self
}
pub fn total_size(mut self, size: u64) -> Self {
self.total_size = size;
self
}
pub fn compressed_size(mut self, size: u64) -> Self {
self.compressed_size = size;
self
}
pub fn files_changed(mut self, count: usize) -> Self {
self.files_changed = count;
self
}
pub fn bytes_changed(mut self, bytes: u64) -> Self {
self.bytes_changed = bytes;
self
}
pub fn add_tag(mut self, tag: String) -> Self {
self.tags.push(tag);
self
}
pub fn add_custom(mut self, key: String, value: String) -> Self {
self.custom.insert(key, value);
self
}
pub fn build(self) -> CheckpointMetadata {
CheckpointMetadata {
file_count: self.file_count,
total_size: self.total_size,
compressed_size: self.compressed_size,
files_changed: self.files_changed,
bytes_changed: self.bytes_changed,
tags: self.tags,
custom: self.custom,
titor_version: env!("CARGO_PKG_VERSION").to_string(),
host_info: HostInfo::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collections::HashMapExt;
#[test]
fn test_checkpoint_creation() {
let metadata = CheckpointMetadataBuilder::new()
.file_count(100)
.total_size(1_000_000)
.build();
let checkpoint = Checkpoint::new(
None,
Some("Initial checkpoint".to_string()),
metadata,
"merkle_root_hash".to_string(),
);
assert!(checkpoint.parent_id.is_none());
assert_eq!(checkpoint.description, Some("Initial checkpoint".to_string()));
assert_eq!(checkpoint.metadata.file_count, 100);
assert!(!checkpoint.state_hash.is_empty());
}
#[test]
fn test_checkpoint_integrity() {
let metadata = CheckpointMetadataBuilder::new().build();
let checkpoint = Checkpoint::new(
None,
None,
metadata,
"merkle_root".to_string(),
);
assert!(checkpoint.verify_integrity().unwrap());
let mut tampered = checkpoint.clone();
tampered.content_merkle_root = "tampered".to_string();
assert!(!tampered.verify_integrity().unwrap());
}
#[test]
fn test_human_bytes() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(1023), "1023 B");
assert_eq!(human_bytes(1024), "1.00 KB");
assert_eq!(human_bytes(1536), "1.50 KB");
assert_eq!(human_bytes(1_048_576), "1.00 MB");
assert_eq!(human_bytes(1_073_741_824), "1.00 GB");
}
#[test]
fn test_parent_chain() {
let mut checkpoints = HashMap::new();
let root = Checkpoint::new(
None,
Some("Root".to_string()),
CheckpointMetadataBuilder::new().build(),
"root_merkle".to_string(),
);
let root_id = root.id.clone();
checkpoints.insert(root_id.clone(), root);
let checkpoint1 = Checkpoint::new(
Some(root_id.clone()),
Some("Checkpoint 1".to_string()),
CheckpointMetadataBuilder::new().build(),
"merkle1".to_string(),
);
let checkpoint1_id = checkpoint1.id.clone();
checkpoints.insert(checkpoint1_id.clone(), checkpoint1);
let checkpoint2 = Checkpoint::new(
Some(checkpoint1_id.clone()),
Some("Checkpoint 2".to_string()),
CheckpointMetadataBuilder::new().build(),
"merkle2".to_string(),
);
let chain = checkpoint2.get_parent_chain(&checkpoints);
assert_eq!(chain, vec![root_id.clone(), checkpoint1_id.clone()]);
assert!(checkpoint2.is_descendant_of(&root_id, &checkpoints));
assert!(checkpoint2.is_descendant_of(&checkpoint1_id, &checkpoints));
assert!(!checkpoint2.is_descendant_of(&checkpoint2.id, &checkpoints));
}
}