1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RelationshipType {
18 ParentChild,
20 DependsOn,
22 Follows,
24 RelatedTo,
26 Blocks,
28 Duplicates,
30 References,
32}
33
34impl RelationshipType {
35 #[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 #[must_use]
46 pub fn inverse(&self) -> Option<Self> {
47 match self {
48 Self::ParentChild => Some(Self::ParentChild), Self::DependsOn => Some(Self::DependsOn), Self::Follows => Some(Self::Follows), Self::Blocks => Some(Self::Blocks), Self::RelatedTo => None, Self::Duplicates => None, Self::References => None, }
56 }
57
58 #[must_use]
60 pub fn requires_acyclic(&self) -> bool {
61 matches!(self, Self::DependsOn | Self::ParentChild | Self::Blocks)
62 }
63
64 #[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 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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
138pub struct RelationshipMetadata {
139 pub reason: Option<String>,
141 pub created_by: Option<String>,
143 pub priority: Option<u8>,
145 #[serde(default)]
147 pub custom_fields: HashMap<String, String>,
148}
149
150impl RelationshipMetadata {
151 #[must_use]
153 pub fn new() -> Self {
154 Self::default()
155 }
156
157 #[must_use]
159 pub fn with_reason(reason: String) -> Self {
160 Self {
161 reason: Some(reason),
162 ..Default::default()
163 }
164 }
165
166 #[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 pub fn set_field(&mut self, key: String, value: String) {
178 self.custom_fields.insert(key, value);
179 }
180
181 #[must_use]
183 pub fn get_field(&self, key: &str) -> Option<&String> {
184 self.custom_fields.get(key)
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct EpisodeRelationship {
191 pub id: Uuid,
193 pub from_episode_id: Uuid,
195 pub to_episode_id: Uuid,
197 pub relationship_type: RelationshipType,
199 pub metadata: RelationshipMetadata,
201 pub created_at: DateTime<Utc>,
203}
204
205impl EpisodeRelationship {
206 #[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 #[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 #[must_use]
242 pub fn is_directional(&self) -> bool {
243 self.relationship_type.is_directional()
244 }
245
246 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum Direction {
263 Outgoing,
265 Incoming,
267 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 let _ = Direction::Outgoing;
412 let _ = Direction::Incoming;
413 let _ = Direction::Both;
414 }
415}