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 {
126                title,
127                body,
128                labels,
129            } => Ok(Self::new(
130                event.issue_id,
131                title.clone(),
132                body.clone(),
133                labels.clone(),
134                event.ts_unix_ms,
135                event.actor,
136                event.event_id,
137            )),
138            _ => Err(GriteError::Internal(
139                "Expected IssueCreated event".to_string(),
140            )),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::hash::compute_event_id;
149    use crate::types::event::IssueState;
150    use crate::types::ids::generate_issue_id;
151
152    fn make_event(issue_id: [u8; 16], actor: [u8; 16], ts: u64, kind: EventKind) -> Event {
153        let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
154        Event::new(event_id, issue_id, actor, ts, None, kind)
155    }
156
157    #[test]
158    fn test_apply_issue_updated_title() {
159        let issue_id = generate_issue_id();
160        let actor = [1u8; 16];
161
162        let create_event = make_event(
163            issue_id,
164            actor,
165            1000,
166            EventKind::IssueCreated {
167                title: "Original".to_string(),
168                body: "Body".to_string(),
169                labels: vec![],
170            },
171        );
172
173        let mut proj = IssueProjection::from_event(&create_event).unwrap();
174        assert_eq!(proj.title, "Original");
175
176        let update_event = make_event(
177            issue_id,
178            actor,
179            2000,
180            EventKind::IssueUpdated {
181                title: Some("Updated".to_string()),
182                body: None,
183            },
184        );
185
186        proj.apply(&update_event).unwrap();
187        assert_eq!(proj.title, "Updated");
188        assert_eq!(proj.body, "Body"); // Unchanged
189    }
190
191    #[test]
192    fn test_apply_lww_older_update_ignored() {
193        let issue_id = generate_issue_id();
194        let actor = [1u8; 16];
195
196        let create_event = make_event(
197            issue_id,
198            actor,
199            2000, // Later timestamp
200            EventKind::IssueCreated {
201                title: "Original".to_string(),
202                body: "Body".to_string(),
203                labels: vec![],
204            },
205        );
206
207        let mut proj = IssueProjection::from_event(&create_event).unwrap();
208
209        // Try to apply an older update - should be ignored
210        let old_update = make_event(
211            issue_id,
212            actor,
213            1000, // Earlier timestamp
214            EventKind::IssueUpdated {
215                title: Some("Old".to_string()),
216                body: None,
217            },
218        );
219
220        proj.apply(&old_update).unwrap();
221        assert_eq!(proj.title, "Original"); // Unchanged because update was older
222    }
223
224    #[test]
225    fn test_apply_comment_added() {
226        let issue_id = generate_issue_id();
227        let actor = [1u8; 16];
228
229        let create_event = make_event(
230            issue_id,
231            actor,
232            1000,
233            EventKind::IssueCreated {
234                title: "Test".to_string(),
235                body: "Body".to_string(),
236                labels: vec![],
237            },
238        );
239
240        let mut proj = IssueProjection::from_event(&create_event).unwrap();
241        assert_eq!(proj.comments.len(), 0);
242
243        let comment_event = make_event(
244            issue_id,
245            actor,
246            2000,
247            EventKind::CommentAdded {
248                body: "Nice work!".to_string(),
249            },
250        );
251
252        proj.apply(&comment_event).unwrap();
253        assert_eq!(proj.comments.len(), 1);
254        assert_eq!(proj.comments[0].body, "Nice work!");
255    }
256
257    #[test]
258    fn test_apply_labels_commutative() {
259        let issue_id = generate_issue_id();
260        let actor = [1u8; 16];
261
262        let create_event = make_event(
263            issue_id,
264            actor,
265            1000,
266            EventKind::IssueCreated {
267                title: "Test".to_string(),
268                body: "Body".to_string(),
269                labels: vec!["initial".to_string()],
270            },
271        );
272
273        let mut proj = IssueProjection::from_event(&create_event).unwrap();
274        assert!(proj.labels.contains("initial"));
275
276        // Add a label
277        let add_event = make_event(
278            issue_id,
279            actor,
280            2000,
281            EventKind::LabelAdded {
282                label: "bug".to_string(),
283            },
284        );
285        proj.apply(&add_event).unwrap();
286        assert!(proj.labels.contains("bug"));
287
288        // Remove the initial label
289        let remove_event = make_event(
290            issue_id,
291            actor,
292            3000,
293            EventKind::LabelRemoved {
294                label: "initial".to_string(),
295            },
296        );
297        proj.apply(&remove_event).unwrap();
298        assert!(!proj.labels.contains("initial"));
299        assert!(proj.labels.contains("bug"));
300    }
301
302    #[test]
303    fn test_apply_state_changed() {
304        let issue_id = generate_issue_id();
305        let actor = [1u8; 16];
306
307        let create_event = make_event(
308            issue_id,
309            actor,
310            1000,
311            EventKind::IssueCreated {
312                title: "Test".to_string(),
313                body: "Body".to_string(),
314                labels: vec![],
315            },
316        );
317
318        let mut proj = IssueProjection::from_event(&create_event).unwrap();
319        assert_eq!(proj.state, IssueState::Open);
320
321        let close_event = make_event(
322            issue_id,
323            actor,
324            2000,
325            EventKind::StateChanged {
326                state: IssueState::Closed,
327            },
328        );
329
330        proj.apply(&close_event).unwrap();
331        assert_eq!(proj.state, IssueState::Closed);
332    }
333
334    #[test]
335    fn test_deterministic_rebuild() {
336        let issue_id = generate_issue_id();
337        let actor1 = [1u8; 16];
338        let actor2 = [2u8; 16];
339
340        // Create a sequence of events
341        let events = [
342            make_event(
343                issue_id,
344                actor1,
345                1000,
346                EventKind::IssueCreated {
347                    title: "Test".to_string(),
348                    body: "Body".to_string(),
349                    labels: vec!["bug".to_string()],
350                },
351            ),
352            make_event(
353                issue_id,
354                actor2,
355                2000,
356                EventKind::CommentAdded {
357                    body: "Comment 1".to_string(),
358                },
359            ),
360            make_event(
361                issue_id,
362                actor1,
363                3000,
364                EventKind::LabelAdded {
365                    label: "p0".to_string(),
366                },
367            ),
368            make_event(
369                issue_id,
370                actor2,
371                4000,
372                EventKind::IssueUpdated {
373                    title: Some("Updated Title".to_string()),
374                    body: None,
375                },
376            ),
377        ];
378
379        // Build projection incrementally
380        let mut proj1 = IssueProjection::from_event(&events[0]).unwrap();
381        for event in &events[1..] {
382            proj1.apply(event).unwrap();
383        }
384
385        // Build projection from scratch (simulating rebuild)
386        let mut proj2 = IssueProjection::from_event(&events[0]).unwrap();
387        for event in &events[1..] {
388            proj2.apply(event).unwrap();
389        }
390
391        // Projections should be identical
392        assert_eq!(proj1.title, proj2.title);
393        assert_eq!(proj1.body, proj2.body);
394        assert_eq!(proj1.state, proj2.state);
395        assert_eq!(proj1.labels, proj2.labels);
396        assert_eq!(proj1.comments.len(), proj2.comments.len());
397    }
398}