use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OperationLink {
pub forward_operation_id: String,
pub inverse_operation_id: String,
pub link_hash: String,
pub linked_at: DateTime<Utc>,
}
impl OperationLink {
pub fn create(forward_id: &str, inverse_id: &str) -> Result<Self, String> {
match uuid::Uuid::parse_str(forward_id) {
Ok(parsed) => {
if parsed.get_version_num() != 4 {
return Err(format!(
"forward_operation_id must be UUID v4, got version {}",
parsed.get_version_num()
));
}
}
Err(e) => {
return Err(format!("forward_operation_id is not a valid UUID: {}", e));
}
}
match uuid::Uuid::parse_str(inverse_id) {
Ok(parsed) => {
if parsed.get_version_num() != 4 {
return Err(format!(
"inverse_operation_id must be UUID v4, got version {}",
parsed.get_version_num()
));
}
}
Err(e) => {
return Err(format!("inverse_operation_id is not a valid UUID: {}", e));
}
}
let mut hasher = blake3::Hasher::new();
hasher.update(forward_id.as_bytes());
hasher.update(inverse_id.as_bytes());
let link_hash = hasher.finalize().to_hex().to_string();
Ok(Self {
forward_operation_id: forward_id.to_string(),
inverse_operation_id: inverse_id.to_string(),
link_hash,
linked_at: Utc::now(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const UUID_V4_PATTERN: &str =
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
fn is_uuid_v4(id: &str) -> bool {
regex::Regex::new(UUID_V4_PATTERN)
.map(|re| re.is_match(id))
.unwrap_or(false)
}
#[test]
fn test_operation_link_create_valid_uuids() {
let fwd_id = uuid::Uuid::new_v4().to_string();
let inv_id = uuid::Uuid::new_v4().to_string();
let link = OperationLink::create(&fwd_id, &inv_id).expect("should create valid link");
assert_eq!(link.forward_operation_id, fwd_id);
assert_eq!(link.inverse_operation_id, inv_id);
assert_eq!(link.link_hash.len(), 64); assert!(link.link_hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_operation_link_hash_deterministic() {
let fwd_id = uuid::Uuid::new_v4().to_string();
let inv_id = uuid::Uuid::new_v4().to_string();
let link1 = OperationLink::create(&fwd_id, &inv_id).expect("first link should succeed");
let link2 = OperationLink::create(&fwd_id, &inv_id).expect("second link should succeed");
assert_eq!(link1.link_hash, link2.link_hash);
}
#[test]
fn test_operation_link_hash_changes_with_different_ids() {
let fwd_id1 = uuid::Uuid::new_v4().to_string();
let fwd_id2 = uuid::Uuid::new_v4().to_string();
let inv_id = uuid::Uuid::new_v4().to_string();
let link1 = OperationLink::create(&fwd_id1, &inv_id).expect("first link should succeed");
let link2 = OperationLink::create(&fwd_id2, &inv_id).expect("second link should succeed");
assert_ne!(link1.link_hash, link2.link_hash);
}
#[test]
fn test_operation_link_rejects_invalid_forward_uuid() {
let inv_id = uuid::Uuid::new_v4().to_string();
let invalid = "not-a-uuid";
let result = OperationLink::create(invalid, &inv_id);
assert!(result.is_err());
assert!(result.unwrap_err().contains("forward_operation_id"));
}
#[test]
fn test_operation_link_rejects_invalid_inverse_uuid() {
let fwd_id = uuid::Uuid::new_v4().to_string();
let invalid = "also-not-a-uuid";
let result = OperationLink::create(&fwd_id, invalid);
assert!(result.is_err());
assert!(result.unwrap_err().contains("inverse_operation_id"));
}
#[test]
fn test_operation_link_rejects_non_v4_uuid() {
let v1_id = "c106a26a-21bb-11eb-adc1-0242ac120002".to_string();
let v4_id = uuid::Uuid::new_v4().to_string();
let result = OperationLink::create(&v1_id, &v4_id);
assert!(result.is_err());
assert!(result.unwrap_err().contains("UUID v4"));
}
#[test]
fn test_operation_link_timestamp_is_real() {
let fwd_id = uuid::Uuid::new_v4().to_string();
let inv_id = uuid::Uuid::new_v4().to_string();
let before = Utc::now();
let link = OperationLink::create(&fwd_id, &inv_id).expect("link creation should succeed");
let after = Utc::now();
assert!(link.linked_at >= before - chrono::Duration::seconds(1));
assert!(link.linked_at <= after + chrono::Duration::seconds(1));
}
}