Skip to main content

libgrite_core/
projection.rs

1use crate::error::GriteError;
2use crate::types::event::{Event, EventKind};
3use crate::types::issue::{Attachment, Comment, Dependency, IssueProjection, Link, Version};
4
5impl IssueProjection {
6    /// Apply an event to update this projection
7    pub fn apply(&mut self, event: &Event) -> Result<(), GriteError> {
8        let new_version = Version::new(event.ts_unix_ms, event.actor, event.event_id);
9
10        match &event.kind {
11            EventKind::IssueCreated { .. } => {
12                // IssueCreated should only be used to create a new projection
13                return Err(GriteError::Internal(
14                    "Cannot apply IssueCreated to existing projection".to_string(),
15                ));
16            }
17
18            EventKind::IssueUpdated { title, body } => {
19                // LWW for title
20                if let Some(new_title) = title {
21                    if new_version.is_newer_than(&self.title_version) {
22                        self.title = new_title.clone();
23                        self.title_version = new_version.clone();
24                    }
25                }
26                // LWW for body
27                if let Some(new_body) = body {
28                    if new_version.is_newer_than(&self.body_version) {
29                        self.body = new_body.clone();
30                        self.body_version = new_version.clone();
31                    }
32                }
33            }
34
35            EventKind::CommentAdded { body } => {
36                // Append-only
37                self.comments.push(Comment {
38                    event_id: event.event_id,
39                    actor: event.actor,
40                    ts_unix_ms: event.ts_unix_ms,
41                    body: body.clone(),
42                });
43            }
44
45            EventKind::LabelAdded { label } => {
46                // Commutative add
47                self.labels.insert(label.clone());
48            }
49
50            EventKind::LabelRemoved { label } => {
51                // Commutative remove
52                self.labels.remove(label);
53            }
54
55            EventKind::StateChanged { state } => {
56                // LWW for state
57                if new_version.is_newer_than(&self.state_version) {
58                    self.state = *state;
59                    self.state_version = new_version.clone();
60                }
61            }
62
63            EventKind::LinkAdded { url, note } => {
64                // Append-only
65                self.links.push(Link {
66                    event_id: event.event_id,
67                    url: url.clone(),
68                    note: note.clone(),
69                });
70            }
71
72            EventKind::AssigneeAdded { user } => {
73                // Commutative add
74                self.assignees.insert(user.clone());
75            }
76
77            EventKind::AssigneeRemoved { user } => {
78                // Commutative remove
79                self.assignees.remove(user);
80            }
81
82            EventKind::AttachmentAdded { name, sha256, mime } => {
83                // Append-only
84                self.attachments.push(Attachment {
85                    event_id: event.event_id,
86                    name: name.clone(),
87                    sha256: *sha256,
88                    mime: mime.clone(),
89                });
90            }
91
92            EventKind::DependencyAdded { target, dep_type } => {
93                // Commutative add to dependency set
94                self.dependencies.insert(Dependency {
95                    target: *target,
96                    dep_type: *dep_type,
97                });
98            }
99
100            EventKind::DependencyRemoved { target, dep_type } => {
101                // Commutative remove from dependency set
102                self.dependencies.remove(&Dependency {
103                    target: *target,
104                    dep_type: *dep_type,
105                });
106            }
107
108            EventKind::ContextUpdated { .. } | EventKind::ProjectContextUpdated { .. } => {
109                // Context events are handled by the context store, not issue projections
110                return Ok(());
111            }
112        }
113
114        // Update the updated_ts to the latest event timestamp
115        if event.ts_unix_ms > self.updated_ts {
116            self.updated_ts = event.ts_unix_ms;
117        }
118
119        Ok(())
120    }
121
122    /// Create a projection from an IssueCreated event
123    pub fn from_event(event: &Event) -> Result<Self, GriteError> {
124        match &event.kind {
125            EventKind::IssueCreated { title, body, labels } => {
126                Ok(Self::new(
127                    event.issue_id,
128                    title.clone(),
129                    body.clone(),
130                    labels.clone(),
131                    event.ts_unix_ms,
132                    event.actor,
133                    event.event_id,
134                ))
135            }
136            _ => Err(GriteError::Internal(
137                "Expected IssueCreated event".to_string(),
138            )),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::hash::compute_event_id;
147    use crate::types::event::IssueState;
148    use crate::types::ids::generate_issue_id;
149
150    fn make_event(
151        issue_id: [u8; 16],
152        actor: [u8; 16],
153        ts: u64,
154        kind: EventKind,
155    ) -> Event {
156        let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
157        Event::new(event_id, issue_id, actor, ts, None, kind)
158    }
159
160    #[test]
161    fn test_apply_issue_updated_title() {
162        let issue_id = generate_issue_id();
163        let actor = [1u8; 16];
164
165        let create_event = make_event(
166            issue_id,
167            actor,
168            1000,
169            EventKind::IssueCreated {
170                title: "Original".to_string(),
171                body: "Body".to_string(),
172                labels: vec![],
173            },
174        );
175
176        let mut proj = IssueProjection::from_event(&create_event).unwrap();
177        assert_eq!(proj.title, "Original");
178
179        let update_event = make_event(
180            issue_id,
181            actor,
182            2000,
183            EventKind::IssueUpdated {
184                title: Some("Updated".to_string()),
185                body: None,
186            },
187        );
188
189        proj.apply(&update_event).unwrap();
190        assert_eq!(proj.title, "Updated");
191        assert_eq!(proj.body, "Body"); // Unchanged
192    }
193
194    #[test]
195    fn test_apply_lww_older_update_ignored() {
196        let issue_id = generate_issue_id();
197        let actor = [1u8; 16];
198
199        let create_event = make_event(
200            issue_id,
201            actor,
202            2000, // Later timestamp
203            EventKind::IssueCreated {
204                title: "Original".to_string(),
205                body: "Body".to_string(),
206                labels: vec![],
207            },
208        );
209
210        let mut proj = IssueProjection::from_event(&create_event).unwrap();
211
212        // Try to apply an older update - should be ignored
213        let old_update = make_event(
214            issue_id,
215            actor,
216            1000, // Earlier timestamp
217            EventKind::IssueUpdated {
218                title: Some("Old".to_string()),
219                body: None,
220            },
221        );
222
223        proj.apply(&old_update).unwrap();
224        assert_eq!(proj.title, "Original"); // Unchanged because update was older
225    }
226
227    #[test]
228    fn test_apply_comment_added() {
229        let issue_id = generate_issue_id();
230        let actor = [1u8; 16];
231
232        let create_event = make_event(
233            issue_id,
234            actor,
235            1000,
236            EventKind::IssueCreated {
237                title: "Test".to_string(),
238                body: "Body".to_string(),
239                labels: vec![],
240            },
241        );
242
243        let mut proj = IssueProjection::from_event(&create_event).unwrap();
244        assert_eq!(proj.comments.len(), 0);
245
246        let comment_event = make_event(
247            issue_id,
248            actor,
249            2000,
250            EventKind::CommentAdded {
251                body: "Nice work!".to_string(),
252            },
253        );
254
255        proj.apply(&comment_event).unwrap();
256        assert_eq!(proj.comments.len(), 1);
257        assert_eq!(proj.comments[0].body, "Nice work!");
258    }
259
260    #[test]
261    fn test_apply_labels_commutative() {
262        let issue_id = generate_issue_id();
263        let actor = [1u8; 16];
264
265        let create_event = make_event(
266            issue_id,
267            actor,
268            1000,
269            EventKind::IssueCreated {
270                title: "Test".to_string(),
271                body: "Body".to_string(),
272                labels: vec!["initial".to_string()],
273            },
274        );
275
276        let mut proj = IssueProjection::from_event(&create_event).unwrap();
277        assert!(proj.labels.contains("initial"));
278
279        // Add a label
280        let add_event = make_event(
281            issue_id,
282            actor,
283            2000,
284            EventKind::LabelAdded {
285                label: "bug".to_string(),
286            },
287        );
288        proj.apply(&add_event).unwrap();
289        assert!(proj.labels.contains("bug"));
290
291        // Remove the initial label
292        let remove_event = make_event(
293            issue_id,
294            actor,
295            3000,
296            EventKind::LabelRemoved {
297                label: "initial".to_string(),
298            },
299        );
300        proj.apply(&remove_event).unwrap();
301        assert!(!proj.labels.contains("initial"));
302        assert!(proj.labels.contains("bug"));
303    }
304
305    #[test]
306    fn test_apply_state_changed() {
307        let issue_id = generate_issue_id();
308        let actor = [1u8; 16];
309
310        let create_event = make_event(
311            issue_id,
312            actor,
313            1000,
314            EventKind::IssueCreated {
315                title: "Test".to_string(),
316                body: "Body".to_string(),
317                labels: vec![],
318            },
319        );
320
321        let mut proj = IssueProjection::from_event(&create_event).unwrap();
322        assert_eq!(proj.state, IssueState::Open);
323
324        let close_event = make_event(
325            issue_id,
326            actor,
327            2000,
328            EventKind::StateChanged {
329                state: IssueState::Closed,
330            },
331        );
332
333        proj.apply(&close_event).unwrap();
334        assert_eq!(proj.state, IssueState::Closed);
335    }
336
337    #[test]
338    fn test_deterministic_rebuild() {
339        let issue_id = generate_issue_id();
340        let actor1 = [1u8; 16];
341        let actor2 = [2u8; 16];
342
343        // Create a sequence of events
344        let events = vec![
345            make_event(
346                issue_id,
347                actor1,
348                1000,
349                EventKind::IssueCreated {
350                    title: "Test".to_string(),
351                    body: "Body".to_string(),
352                    labels: vec!["bug".to_string()],
353                },
354            ),
355            make_event(
356                issue_id,
357                actor2,
358                2000,
359                EventKind::CommentAdded {
360                    body: "Comment 1".to_string(),
361                },
362            ),
363            make_event(
364                issue_id,
365                actor1,
366                3000,
367                EventKind::LabelAdded {
368                    label: "p0".to_string(),
369                },
370            ),
371            make_event(
372                issue_id,
373                actor2,
374                4000,
375                EventKind::IssueUpdated {
376                    title: Some("Updated Title".to_string()),
377                    body: None,
378                },
379            ),
380        ];
381
382        // Build projection incrementally
383        let mut proj1 = IssueProjection::from_event(&events[0]).unwrap();
384        for event in &events[1..] {
385            proj1.apply(event).unwrap();
386        }
387
388        // Build projection from scratch (simulating rebuild)
389        let mut proj2 = IssueProjection::from_event(&events[0]).unwrap();
390        for event in &events[1..] {
391            proj2.apply(event).unwrap();
392        }
393
394        // Projections should be identical
395        assert_eq!(proj1.title, proj2.title);
396        assert_eq!(proj1.body, proj2.body);
397        assert_eq!(proj1.state, proj2.state);
398        assert_eq!(proj1.labels, proj2.labels);
399        assert_eq!(proj1.comments.len(), proj2.comments.len());
400    }
401}