use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Triplet {
pub id: Uuid,
pub source_entity_id: Uuid,
pub target_entity_id: Uuid,
pub relationship_name: String,
pub text: String,
pub source_name: Option<String>,
pub target_name: Option<String>,
}
impl Triplet {
pub fn new(
source_entity_id: Uuid,
target_entity_id: Uuid,
relationship_name: String,
text: String,
) -> Self {
let raw = format!("{source_entity_id}{relationship_name}{target_entity_id}");
let normalized = raw.to_lowercase().replace(' ', "_").replace('\'', "");
let id = Uuid::new_v5(&Uuid::NAMESPACE_OID, normalized.as_bytes());
Self {
id,
source_entity_id,
target_entity_id,
relationship_name,
text,
source_name: None,
target_name: None,
}
}
pub fn with_names(mut self, source_name: String, target_name: String) -> Self {
self.source_name = Some(source_name);
self.target_name = Some(target_name);
self
}
pub fn get_text(&self) -> &str {
&self.text
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code — panics are acceptable failures"
)]
mod tests {
use super::*;
#[test]
fn test_triplet_creation() {
let source_id = Uuid::new_v4();
let target_id = Uuid::new_v4();
let triplet = Triplet::new(
source_id,
target_id,
"founded".to_string(),
"Steve Jobs-›founded-›Apple Inc.".to_string(),
);
assert_eq!(triplet.source_entity_id, source_id);
assert_eq!(triplet.target_entity_id, target_id);
assert_eq!(triplet.relationship_name, "founded");
assert!(triplet.text.contains("-›"));
assert_eq!(triplet.source_name, None);
assert_eq!(triplet.target_name, None);
}
#[test]
fn test_triplet_with_names() {
let source_id = Uuid::new_v4();
let target_id = Uuid::new_v4();
let triplet = Triplet::new(
source_id,
target_id,
"works_at".to_string(),
"Alice-›works at-›TechCorp".to_string(),
)
.with_names("Alice".to_string(), "TechCorp".to_string());
assert_eq!(triplet.source_name, Some("Alice".to_string()));
assert_eq!(triplet.target_name, Some("TechCorp".to_string()));
}
#[test]
fn test_triplet_deterministic_id() {
let source_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let target_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
let triplet1 = Triplet::new(
source_id,
target_id,
"relates".to_string(),
"A-›relates-›B".to_string(),
);
let triplet2 = Triplet::new(
source_id,
target_id,
"relates".to_string(),
"A-›relates-›B".to_string(),
);
assert_eq!(triplet1.id, triplet2.id, "IDs should be deterministic");
}
#[test]
fn test_triplet_id_matches_python_generate_node_id() {
let source_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let target_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
let relationship = "founded";
let triplet = Triplet::new(
source_id,
target_id,
relationship.to_string(),
"test".to_string(),
);
let raw = format!("{source_id}{relationship}{target_id}");
let normalized = raw.to_lowercase().replace(' ', "_").replace('\'', "");
let expected_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, normalized.as_bytes());
assert_eq!(
triplet.id, expected_id,
"ID should match Python generate_node_id formula"
);
}
#[test]
fn test_triplet_get_text() {
let triplet = Triplet::new(
Uuid::new_v4(),
Uuid::new_v4(),
"test".to_string(),
"test text".to_string(),
);
assert_eq!(triplet.get_text(), "test text");
}
}