Skip to main content

parsnip_core/
entity.rs

1//! Entity (node) types and operations
2
3use crate::observation::Observation;
4use crate::project::ProjectId;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use ulid::Ulid;
9
10/// Unique identifier for an entity
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct EntityId(pub Ulid);
13
14impl EntityId {
15    pub fn new() -> Self {
16        Self(Ulid::new())
17    }
18
19    pub fn from_string(s: &str) -> Result<Self, ulid::DecodeError> {
20        Ok(Self(Ulid::from_string(s)?))
21    }
22}
23
24impl Default for EntityId {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl std::fmt::Display for EntityId {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "{}", self.0)
33    }
34}
35
36/// Entity type classification
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct EntityType(pub String);
39
40impl EntityType {
41    pub fn new(s: impl Into<String>) -> Self {
42        Self(s.into())
43    }
44
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48}
49
50impl From<&str> for EntityType {
51    fn from(s: &str) -> Self {
52        Self(s.to_string())
53    }
54}
55
56impl From<String> for EntityType {
57    fn from(s: String) -> Self {
58        Self(s)
59    }
60}
61
62impl From<&String> for EntityType {
63    fn from(s: &String) -> Self {
64        Self(s.clone())
65    }
66}
67
68/// An entity in the knowledge graph (a node)
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Entity {
71    /// Unique identifier
72    pub id: EntityId,
73
74    /// Project this entity belongs to
75    pub project_id: ProjectId,
76
77    /// Entity name (unique within project)
78    pub name: String,
79
80    /// Entity type/category
81    pub entity_type: EntityType,
82
83    /// Observations (facts) about this entity
84    pub observations: Vec<Observation>,
85
86    /// Tags for categorization
87    pub tags: Vec<String>,
88
89    /// Arbitrary metadata
90    #[serde(default)]
91    pub metadata: HashMap<String, serde_json::Value>,
92
93    /// Creation timestamp
94    pub created_at: DateTime<Utc>,
95
96    /// Last update timestamp
97    pub updated_at: DateTime<Utc>,
98
99    /// Optional vector embedding for semantic search
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub embedding: Option<Vec<f32>>,
102}
103
104impl Entity {
105    /// Create a new entity
106    pub fn new(
107        project_id: ProjectId,
108        name: impl Into<String>,
109        entity_type: impl Into<EntityType>,
110    ) -> Self {
111        let now = Utc::now();
112        Self {
113            id: EntityId::new(),
114            project_id,
115            name: name.into(),
116            entity_type: entity_type.into(),
117            observations: Vec::new(),
118            tags: Vec::new(),
119            metadata: HashMap::new(),
120            created_at: now,
121            updated_at: now,
122            embedding: None,
123        }
124    }
125
126    /// Add an observation to this entity
127    pub fn add_observation(&mut self, content: impl Into<String>) -> &Observation {
128        let obs = Observation::new(content);
129        self.observations.push(obs);
130        self.updated_at = Utc::now();
131        // Safe: we just pushed an element, so last() is guaranteed to be Some
132        self.observations.last().expect("observations cannot be empty after push")
133    }
134
135    /// Add a tag to this entity
136    pub fn add_tag(&mut self, tag: impl Into<String>) {
137        let tag = tag.into();
138        if !self.tags.contains(&tag) {
139            self.tags.push(tag);
140            self.updated_at = Utc::now();
141        }
142    }
143
144    /// Remove a tag from this entity
145    pub fn remove_tag(&mut self, tag: &str) -> bool {
146        if let Some(pos) = self.tags.iter().position(|t| t == tag) {
147            self.tags.remove(pos);
148            self.updated_at = Utc::now();
149            true
150        } else {
151            false
152        }
153    }
154
155    /// Check if entity has a specific tag
156    pub fn has_tag(&self, tag: &str) -> bool {
157        self.tags.iter().any(|t| t == tag)
158    }
159}
160
161/// Data for creating a new entity
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct NewEntity {
164    pub name: String,
165    pub entity_type: String,
166    pub observations: Vec<String>,
167    #[serde(default)]
168    pub tags: Vec<String>,
169    #[serde(default)]
170    pub metadata: HashMap<String, serde_json::Value>,
171}
172
173impl NewEntity {
174    pub fn new(name: impl Into<String>, entity_type: impl Into<String>) -> Self {
175        Self {
176            name: name.into(),
177            entity_type: entity_type.into(),
178            observations: Vec::new(),
179            tags: Vec::new(),
180            metadata: HashMap::new(),
181        }
182    }
183
184    pub fn with_observation(mut self, obs: impl Into<String>) -> Self {
185        self.observations.push(obs.into());
186        self
187    }
188
189    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
190        self.tags.push(tag.into());
191        self
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_entity_creation() {
201        let project_id = ProjectId::new();
202        let entity = Entity::new(project_id, "John_Smith", "person");
203
204        assert_eq!(entity.name, "John_Smith");
205        assert_eq!(entity.entity_type.as_str(), "person");
206        assert!(entity.observations.is_empty());
207        assert!(entity.tags.is_empty());
208    }
209
210    #[test]
211    fn test_add_observation() {
212        let project_id = ProjectId::new();
213        let mut entity = Entity::new(project_id, "John_Smith", "person");
214
215        entity.add_observation("Works at Google");
216        assert_eq!(entity.observations.len(), 1);
217        assert_eq!(entity.observations[0].content, "Works at Google");
218    }
219
220    #[test]
221    fn test_tags() {
222        let project_id = ProjectId::new();
223        let mut entity = Entity::new(project_id, "John_Smith", "person");
224
225        entity.add_tag("technical");
226        entity.add_tag("mentor");
227        entity.add_tag("technical"); // Duplicate, should not add
228
229        assert_eq!(entity.tags.len(), 2);
230        assert!(entity.has_tag("technical"));
231        assert!(entity.has_tag("mentor"));
232
233        entity.remove_tag("mentor");
234        assert!(!entity.has_tag("mentor"));
235    }
236}