Skip to main content

do_memory_core/episode/
relationships.rs

1//! Episode relationship types and data structures.
2//!
3//! This module provides types for modeling relationships between episodes,
4//! enabling hierarchical organization, dependency tracking, and workflow modeling.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use uuid::Uuid;
10
11#[cfg(feature = "proptest-arbitrary")]
12use proptest::prelude::{Arbitrary, BoxedStrategy, Just, Strategy, prop_oneof};
13
14/// Type of relationship between two episodes.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RelationshipType {
18    /// Parent-child hierarchical relationship (e.g., epic → story → subtask)
19    ParentChild,
20    /// Dependency relationship (from depends on to)
21    DependsOn,
22    /// Sequential relationship (from follows to)
23    Follows,
24    /// Loose association between related episodes
25    RelatedTo,
26    /// Blocking relationship (from blocks to)
27    Blocks,
28    /// Marks episodes as duplicates
29    Duplicates,
30    /// General cross-reference
31    References,
32}
33
34impl RelationshipType {
35    /// Check if this relationship type implies directionality
36    #[must_use]
37    pub fn is_directional(&self) -> bool {
38        matches!(
39            self,
40            Self::ParentChild | Self::DependsOn | Self::Follows | Self::Blocks
41        )
42    }
43
44    /// Get the inverse relationship type (for bidirectional tracking)
45    #[must_use]
46    pub fn inverse(&self) -> Option<Self> {
47        match self {
48            Self::ParentChild => Some(Self::ParentChild), // Child knows parent
49            Self::DependsOn => Some(Self::DependsOn),     // Reverse dependency
50            Self::Follows => Some(Self::Follows),         // Preceded by
51            Self::Blocks => Some(Self::Blocks),           // Blocked by
52            Self::RelatedTo => None,                      // Symmetric
53            Self::Duplicates => None,                     // Symmetric
54            Self::References => None,                     // Symmetric
55        }
56    }
57
58    /// Check if this relationship type should prevent cycles
59    #[must_use]
60    pub fn requires_acyclic(&self) -> bool {
61        matches!(self, Self::DependsOn | Self::ParentChild | Self::Blocks)
62    }
63
64    /// Convert to string representation for storage
65    #[must_use]
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            Self::ParentChild => "parent_child",
69            Self::DependsOn => "depends_on",
70            Self::Follows => "follows",
71            Self::RelatedTo => "related_to",
72            Self::Blocks => "blocks",
73            Self::Duplicates => "duplicates",
74            Self::References => "references",
75        }
76    }
77
78    /// Parse from string representation
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the string doesn't match a known relationship type.
83    pub fn parse(s: &str) -> Result<Self, String> {
84        match s {
85            "parent_child" => Ok(Self::ParentChild),
86            "depends_on" => Ok(Self::DependsOn),
87            "follows" => Ok(Self::Follows),
88            "related_to" => Ok(Self::RelatedTo),
89            "blocks" => Ok(Self::Blocks),
90            "duplicates" => Ok(Self::Duplicates),
91            "references" => Ok(Self::References),
92            _ => Err(format!("Unknown relationship type: {s}")),
93        }
94    }
95
96    /// Get all relationship types
97    #[must_use]
98    pub fn all() -> Vec<Self> {
99        vec![
100            Self::ParentChild,
101            Self::DependsOn,
102            Self::Follows,
103            Self::RelatedTo,
104            Self::Blocks,
105            Self::Duplicates,
106            Self::References,
107        ]
108    }
109}
110
111impl std::fmt::Display for RelationshipType {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "{}", self.as_str())
114    }
115}
116
117#[cfg(feature = "proptest-arbitrary")]
118impl Arbitrary for RelationshipType {
119    type Parameters = ();
120    type Strategy = BoxedStrategy<Self>;
121
122    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
123        prop_oneof![
124            Just(Self::ParentChild),
125            Just(Self::DependsOn),
126            Just(Self::Follows),
127            Just(Self::RelatedTo),
128            Just(Self::Blocks),
129            Just(Self::Duplicates),
130            Just(Self::References),
131        ]
132        .boxed()
133    }
134}
135
136/// Additional metadata for a relationship.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
138pub struct RelationshipMetadata {
139    /// Human-readable reason for the relationship
140    pub reason: Option<String>,
141    /// Who created this relationship
142    pub created_by: Option<String>,
143    /// Priority/importance (1-10, higher is more important)
144    pub priority: Option<u8>,
145    /// Custom fields for extensibility
146    #[serde(default)]
147    pub custom_fields: HashMap<String, String>,
148}
149
150impl RelationshipMetadata {
151    /// Create new empty metadata
152    #[must_use]
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Create metadata with a reason
158    #[must_use]
159    pub fn with_reason(reason: String) -> Self {
160        Self {
161            reason: Some(reason),
162            ..Default::default()
163        }
164    }
165
166    /// Create metadata with reason and `created_by`
167    #[must_use]
168    pub fn with_creator(reason: String, created_by: String) -> Self {
169        Self {
170            reason: Some(reason),
171            created_by: Some(created_by),
172            ..Default::default()
173        }
174    }
175
176    /// Add or update a custom field
177    pub fn set_field(&mut self, key: String, value: String) {
178        self.custom_fields.insert(key, value);
179    }
180
181    /// Get a custom field value
182    #[must_use]
183    pub fn get_field(&self, key: &str) -> Option<&String> {
184        self.custom_fields.get(key)
185    }
186}
187
188/// A relationship between two episodes.
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct EpisodeRelationship {
191    /// Unique identifier for this relationship
192    pub id: Uuid,
193    /// Source episode ID
194    pub from_episode_id: Uuid,
195    /// Target episode ID
196    pub to_episode_id: Uuid,
197    /// Type of relationship
198    pub relationship_type: RelationshipType,
199    /// Additional metadata
200    pub metadata: RelationshipMetadata,
201    /// When this relationship was created
202    pub created_at: DateTime<Utc>,
203}
204
205impl EpisodeRelationship {
206    /// Create a new relationship
207    #[must_use]
208    pub fn new(
209        from_episode_id: Uuid,
210        to_episode_id: Uuid,
211        relationship_type: RelationshipType,
212        metadata: RelationshipMetadata,
213    ) -> Self {
214        Self {
215            id: Uuid::new_v4(),
216            from_episode_id,
217            to_episode_id,
218            relationship_type,
219            metadata,
220            created_at: Utc::now(),
221        }
222    }
223
224    /// Create a simple relationship with just a reason
225    #[must_use]
226    pub fn with_reason(
227        from_episode_id: Uuid,
228        to_episode_id: Uuid,
229        relationship_type: RelationshipType,
230        reason: String,
231    ) -> Self {
232        Self::new(
233            from_episode_id,
234            to_episode_id,
235            relationship_type,
236            RelationshipMetadata::with_reason(reason),
237        )
238    }
239
240    /// Check if this relationship is directional
241    #[must_use]
242    pub fn is_directional(&self) -> bool {
243        self.relationship_type.is_directional()
244    }
245
246    /// Get the inverse of this relationship (swap from/to)
247    #[must_use]
248    pub fn inverse(&self) -> Option<Self> {
249        self.relationship_type.inverse().map(|inv_type| Self {
250            id: Uuid::new_v4(),
251            from_episode_id: self.to_episode_id,
252            to_episode_id: self.from_episode_id,
253            relationship_type: inv_type,
254            metadata: self.metadata.clone(),
255            created_at: self.created_at,
256        })
257    }
258}
259
260/// Direction for querying relationships.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum Direction {
263    /// Outgoing relationships (this episode → others)
264    Outgoing,
265    /// Incoming relationships (others → this episode)
266    Incoming,
267    /// Both directions
268    Both,
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_relationship_type_directional() {
277        assert!(RelationshipType::ParentChild.is_directional());
278        assert!(RelationshipType::DependsOn.is_directional());
279        assert!(RelationshipType::Follows.is_directional());
280        assert!(RelationshipType::Blocks.is_directional());
281        assert!(!RelationshipType::RelatedTo.is_directional());
282        assert!(!RelationshipType::Duplicates.is_directional());
283        assert!(!RelationshipType::References.is_directional());
284    }
285
286    #[test]
287    fn test_relationship_type_acyclic() {
288        assert!(RelationshipType::DependsOn.requires_acyclic());
289        assert!(RelationshipType::ParentChild.requires_acyclic());
290        assert!(RelationshipType::Blocks.requires_acyclic());
291        assert!(!RelationshipType::Follows.requires_acyclic());
292        assert!(!RelationshipType::RelatedTo.requires_acyclic());
293    }
294
295    #[test]
296    fn test_relationship_type_str_conversion() {
297        for rel_type in RelationshipType::all() {
298            let s = rel_type.as_str();
299            let parsed = RelationshipType::parse(s).unwrap();
300            assert_eq!(rel_type, parsed);
301        }
302    }
303
304    #[test]
305    fn test_relationship_type_from_str_invalid() {
306        let result = RelationshipType::parse("invalid_type");
307        assert!(result.is_err());
308        assert!(result.unwrap_err().contains("Unknown relationship type"));
309    }
310
311    #[test]
312    fn test_relationship_creation() {
313        let from_id = Uuid::new_v4();
314        let to_id = Uuid::new_v4();
315        let metadata = RelationshipMetadata::with_reason("Subtask of parent".to_string());
316
317        let rel = EpisodeRelationship::new(
318            from_id,
319            to_id,
320            RelationshipType::ParentChild,
321            metadata.clone(),
322        );
323
324        assert_eq!(rel.from_episode_id, from_id);
325        assert_eq!(rel.to_episode_id, to_id);
326        assert_eq!(rel.relationship_type, RelationshipType::ParentChild);
327        assert_eq!(rel.metadata.reason, Some("Subtask of parent".to_string()));
328    }
329
330    #[test]
331    fn test_relationship_with_reason() {
332        let from_id = Uuid::new_v4();
333        let to_id = Uuid::new_v4();
334
335        let rel = EpisodeRelationship::with_reason(
336            from_id,
337            to_id,
338            RelationshipType::DependsOn,
339            "Requires API design".to_string(),
340        );
341
342        assert_eq!(rel.from_episode_id, from_id);
343        assert_eq!(rel.to_episode_id, to_id);
344        assert_eq!(rel.metadata.reason, Some("Requires API design".to_string()));
345    }
346
347    #[test]
348    fn test_relationship_inverse() {
349        let from_id = Uuid::new_v4();
350        let to_id = Uuid::new_v4();
351
352        let rel = EpisodeRelationship::with_reason(
353            from_id,
354            to_id,
355            RelationshipType::ParentChild,
356            "Child task".to_string(),
357        );
358
359        let inverse = rel.inverse().expect("Should have inverse");
360        assert_eq!(inverse.from_episode_id, to_id);
361        assert_eq!(inverse.to_episode_id, from_id);
362        assert_eq!(inverse.relationship_type, RelationshipType::ParentChild);
363    }
364
365    #[test]
366    fn test_relationship_metadata() {
367        let mut metadata = RelationshipMetadata::new();
368        assert!(metadata.reason.is_none());
369        assert!(metadata.created_by.is_none());
370        assert!(metadata.priority.is_none());
371
372        metadata.set_field("project".to_string(), "memory-system".to_string());
373        assert_eq!(
374            metadata.get_field("project"),
375            Some(&"memory-system".to_string())
376        );
377    }
378
379    #[test]
380    fn test_relationship_metadata_with_creator() {
381        let metadata =
382            RelationshipMetadata::with_creator("Bug fix".to_string(), "alice".to_string());
383
384        assert_eq!(metadata.reason, Some("Bug fix".to_string()));
385        assert_eq!(metadata.created_by, Some("alice".to_string()));
386    }
387
388    #[test]
389    fn test_relationship_serialization() {
390        let from_id = Uuid::new_v4();
391        let to_id = Uuid::new_v4();
392        let rel = EpisodeRelationship::with_reason(
393            from_id,
394            to_id,
395            RelationshipType::DependsOn,
396            "Test reason".to_string(),
397        );
398
399        let json = serde_json::to_string(&rel).unwrap();
400        let deserialized: EpisodeRelationship = serde_json::from_str(&json).unwrap();
401
402        assert_eq!(rel.from_episode_id, deserialized.from_episode_id);
403        assert_eq!(rel.to_episode_id, deserialized.to_episode_id);
404        assert_eq!(rel.relationship_type, deserialized.relationship_type);
405        assert_eq!(rel.metadata.reason, deserialized.metadata.reason);
406    }
407
408    #[test]
409    fn test_direction_enum() {
410        // Just ensure the Direction enum variants compile
411        let _ = Direction::Outgoing;
412        let _ = Direction::Incoming;
413        let _ = Direction::Both;
414    }
415}