Skip to main content

parley/domain/
review.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
4#[serde(rename_all = "snake_case")]
5pub enum ReviewState {
6    Open,
7    UnderReview,
8    Done,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "snake_case")]
13pub enum Author {
14    User,
15    Ai,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum CommentStatus {
21    Open,
22    Pending,
23    Addressed,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum DiffSide {
29    Left,
30    Right,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct CommentReply {
35    pub id: u64,
36    pub author: Author,
37    pub body: String,
38    pub created_at_ms: u64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct LineComment {
43    pub id: u64,
44    pub file_path: String,
45    pub old_line: Option<u32>,
46    pub new_line: Option<u32>,
47    pub side: DiffSide,
48    pub body: String,
49    pub author: Author,
50    pub status: CommentStatus,
51    pub replies: Vec<CommentReply>,
52    pub created_at_ms: u64,
53    pub updated_at_ms: u64,
54    pub addressed_at_ms: Option<u64>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ReviewSession {
59    pub name: String,
60    pub state: ReviewState,
61    pub created_at_ms: u64,
62    pub updated_at_ms: u64,
63    pub done_at_ms: Option<u64>,
64    pub comments: Vec<LineComment>,
65    pub next_comment_id: u64,
66    pub next_reply_id: u64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct NewLineComment {
71    pub file_path: String,
72    pub old_line: Option<u32>,
73    pub new_line: Option<u32>,
74    pub side: DiffSide,
75    pub body: String,
76    pub author: Author,
77}
78
79impl ReviewSession {
80    pub fn new(name: String, now_ms: u64) -> Self {
81        Self {
82            name,
83            state: ReviewState::Open,
84            created_at_ms: now_ms,
85            updated_at_ms: now_ms,
86            done_at_ms: None,
87            comments: Vec::new(),
88            next_comment_id: 1,
89            next_reply_id: 1,
90        }
91    }
92
93    pub fn set_state(&mut self, next: ReviewState, now_ms: u64) -> Result<(), String> {
94        self.set_state_with_options(next, now_ms, false)
95    }
96
97    pub fn set_state_force(&mut self, next: ReviewState, now_ms: u64) -> Result<(), String> {
98        self.set_state_with_options(next, now_ms, true)
99    }
100
101    fn set_state_with_options(
102        &mut self,
103        next: ReviewState,
104        now_ms: u64,
105        force_done: bool,
106    ) -> Result<(), String> {
107        if matches!(next, ReviewState::Done) {
108            let unresolved_threads = self
109                .comments
110                .iter()
111                .filter(|comment| !matches!(comment.status, CommentStatus::Addressed))
112                .count();
113            if unresolved_threads > 0 && !force_done {
114                return Err(format!(
115                    "cannot set review to done: {unresolved_threads} unresolved thread(s)"
116                ));
117            }
118        }
119
120        if matches!(next, ReviewState::Done) {
121            self.done_at_ms = Some(now_ms);
122        } else if matches!(self.state, ReviewState::Done) {
123            self.done_at_ms = None;
124        }
125        self.state = next;
126        self.updated_at_ms = now_ms;
127        Ok(())
128    }
129
130    pub fn add_comment(&mut self, new_comment: NewLineComment, now_ms: u64) -> u64 {
131        let id = self.next_comment_id;
132        self.next_comment_id += 1;
133
134        let comment = LineComment {
135            id,
136            file_path: new_comment.file_path,
137            old_line: new_comment.old_line,
138            new_line: new_comment.new_line,
139            side: new_comment.side,
140            body: new_comment.body,
141            author: new_comment.author,
142            status: CommentStatus::Open,
143            replies: Vec::new(),
144            created_at_ms: now_ms,
145            updated_at_ms: now_ms,
146            addressed_at_ms: None,
147        };
148
149        self.comments.push(comment);
150        self.reconcile_review_state_from_threads();
151        self.updated_at_ms = now_ms;
152        id
153    }
154
155    pub fn add_reply(
156        &mut self,
157        comment_id: u64,
158        author: Author,
159        body: String,
160        now_ms: u64,
161    ) -> Result<u64, String> {
162        let id = self.next_reply_id;
163        self.next_reply_id += 1;
164
165        let comment = self
166            .comments
167            .iter_mut()
168            .find(|comment| comment.id == comment_id)
169            .ok_or_else(|| format!("comment_id {comment_id} not found"))?;
170
171        comment.replies.push(CommentReply {
172            id,
173            author: author.clone(),
174            body,
175            created_at_ms: now_ms,
176        });
177        comment.updated_at_ms = now_ms;
178        if author == comment.author {
179            comment.status = CommentStatus::Open;
180            comment.addressed_at_ms = None;
181        } else {
182            comment.status = CommentStatus::Pending;
183            comment.addressed_at_ms = None;
184        }
185        self.reconcile_review_state_from_threads();
186        self.updated_at_ms = now_ms;
187        Ok(id)
188    }
189
190    pub fn set_comment_status(
191        &mut self,
192        comment_id: u64,
193        status: CommentStatus,
194        actor: Author,
195        now_ms: u64,
196    ) -> Result<(), String> {
197        self.set_comment_status_with_actor(comment_id, status, now_ms, Some(actor))
198    }
199
200    pub fn set_comment_status_force(
201        &mut self,
202        comment_id: u64,
203        status: CommentStatus,
204        now_ms: u64,
205    ) -> Result<(), String> {
206        self.set_comment_status_with_actor(comment_id, status, now_ms, None)
207    }
208
209    fn set_comment_status_with_actor(
210        &mut self,
211        comment_id: u64,
212        status: CommentStatus,
213        now_ms: u64,
214        actor: Option<Author>,
215    ) -> Result<(), String> {
216        let comment = self
217            .comments
218            .iter_mut()
219            .find(|comment| comment.id == comment_id)
220            .ok_or_else(|| format!("comment_id {comment_id} not found"))?;
221
222        if let Some(actor) = actor {
223            match status {
224                CommentStatus::Addressed => {
225                    if comment.author != actor {
226                        return Err(
227                            "only the original commenter can mark a comment addressed".to_string()
228                        );
229                    }
230                }
231                CommentStatus::Open | CommentStatus::Pending => {
232                    if comment.author != actor {
233                        return Err(
234                            "only the original commenter can change thread status".to_string()
235                        );
236                    }
237                }
238            }
239        }
240
241        comment.status = status.clone();
242        comment.updated_at_ms = now_ms;
243        comment.addressed_at_ms = if matches!(status, CommentStatus::Addressed) {
244            Some(now_ms)
245        } else {
246            None
247        };
248
249        self.reconcile_review_state_from_threads();
250        self.updated_at_ms = now_ms;
251        Ok(())
252    }
253
254    fn reconcile_review_state_from_threads(&mut self) {
255        let has_open = self
256            .comments
257            .iter()
258            .any(|comment| matches!(comment.status, CommentStatus::Open));
259        let has_pending = self
260            .comments
261            .iter()
262            .any(|comment| matches!(comment.status, CommentStatus::Pending));
263        let has_unresolved = has_open || has_pending;
264
265        if matches!(self.state, ReviewState::Done) && has_unresolved {
266            self.state = ReviewState::Open;
267            self.done_at_ms = None;
268            return;
269        }
270        if matches!(self.state, ReviewState::Done) {
271            return;
272        }
273
274        self.state = if has_open {
275            ReviewState::Open
276        } else {
277            ReviewState::UnderReview
278        };
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::{Author, CommentStatus, DiffSide, NewLineComment, ReviewSession, ReviewState};
285
286    #[test]
287    fn set_state_should_allow_reopen_after_done() {
288        let mut session = ReviewSession::new("r1".into(), 1);
289        session
290            .set_state(ReviewState::Done, 2)
291            .expect("state should move to done");
292        assert_eq!(session.done_at_ms, Some(2));
293
294        session
295            .set_state(ReviewState::UnderReview, 3)
296            .expect("state should reopen");
297        assert_eq!(session.state, ReviewState::UnderReview);
298        assert_eq!(session.done_at_ms, None);
299    }
300
301    #[test]
302    fn set_state_done_should_require_no_unresolved_threads() {
303        let mut session = ReviewSession::new("r1".into(), 1);
304        session.add_comment(
305            NewLineComment {
306                file_path: "src/lib.rs".into(),
307                old_line: None,
308                new_line: Some(1),
309                side: DiffSide::Right,
310                body: "needs refactor".into(),
311                author: Author::User,
312            },
313            2,
314        );
315
316        let result = session.set_state(ReviewState::Done, 3);
317        assert!(result.is_err());
318    }
319
320    #[test]
321    fn set_state_force_done_should_allow_unresolved_threads() {
322        let mut session = ReviewSession::new("r1".into(), 1);
323        session.add_comment(
324            NewLineComment {
325                file_path: "src/lib.rs".into(),
326                old_line: None,
327                new_line: Some(1),
328                side: DiffSide::Right,
329                body: "needs refactor".into(),
330                author: Author::User,
331            },
332            2,
333        );
334
335        session
336            .set_state_force(ReviewState::Done, 3)
337            .expect("force done should bypass unresolved checks");
338        assert_eq!(session.state, ReviewState::Done);
339    }
340
341    #[test]
342    fn add_comment_should_reopen_done_review() {
343        let mut session = ReviewSession::new("r1".into(), 1);
344        session
345            .set_state(ReviewState::Done, 2)
346            .expect("state should move to done");
347
348        session.add_comment(
349            NewLineComment {
350                file_path: "src/lib.rs".into(),
351                old_line: None,
352                new_line: Some(1),
353                side: DiffSide::Right,
354                body: "new thread".into(),
355                author: Author::User,
356            },
357            3,
358        );
359
360        assert_eq!(session.state, ReviewState::Open);
361        assert_eq!(session.done_at_ms, None);
362    }
363
364    #[test]
365    fn add_reply_from_ai_should_set_pending_and_under_review() {
366        let mut session = ReviewSession::new("r1".into(), 1);
367        let comment_id = session.add_comment(
368            NewLineComment {
369                file_path: "src/lib.rs".into(),
370                old_line: None,
371                new_line: Some(1),
372                side: DiffSide::Right,
373                body: "needs refactor".into(),
374                author: Author::User,
375            },
376            2,
377        );
378
379        session
380            .add_reply(comment_id, Author::Ai, "fixed".into(), 3)
381            .expect("ai reply should be added");
382
383        assert_eq!(session.comments[0].status, CommentStatus::Pending);
384        assert_eq!(session.state, ReviewState::UnderReview);
385    }
386
387    #[test]
388    fn add_reply_from_original_commenter_should_reopen_thread() {
389        let mut session = ReviewSession::new("r1".into(), 1);
390        let comment_id = session.add_comment(
391            NewLineComment {
392                file_path: "src/lib.rs".into(),
393                old_line: None,
394                new_line: Some(1),
395                side: DiffSide::Right,
396                body: "needs refactor".into(),
397                author: Author::User,
398            },
399            2,
400        );
401        session
402            .add_reply(comment_id, Author::Ai, "proposal".into(), 3)
403            .expect("ai reply should be added");
404
405        session
406            .add_reply(comment_id, Author::User, "please revise".into(), 4)
407            .expect("user reply should be added");
408
409        assert_eq!(session.comments[0].status, CommentStatus::Open);
410        assert_eq!(session.state, ReviewState::Open);
411    }
412
413    #[test]
414    fn set_comment_status_should_require_original_commenter() {
415        let mut session = ReviewSession::new("r1".into(), 1);
416        let comment_id = session.add_comment(
417            NewLineComment {
418                file_path: "src/lib.rs".into(),
419                old_line: None,
420                new_line: Some(1),
421                side: DiffSide::Right,
422                body: "needs refactor".into(),
423                author: Author::User,
424            },
425            2,
426        );
427
428        let result =
429            session.set_comment_status(comment_id, CommentStatus::Addressed, Author::Ai, 3);
430        assert!(result.is_err());
431    }
432
433    #[test]
434    fn set_comment_status_force_should_bypass_original_commenter_check() {
435        let mut session = ReviewSession::new("r1".into(), 1);
436        let comment_id = session.add_comment(
437            NewLineComment {
438                file_path: "src/lib.rs".into(),
439                old_line: None,
440                new_line: Some(1),
441                side: DiffSide::Right,
442                body: "needs refactor".into(),
443                author: Author::User,
444            },
445            2,
446        );
447
448        session
449            .set_comment_status_force(comment_id, CommentStatus::Addressed, 3)
450            .expect("force close should bypass author ownership");
451        assert_eq!(session.comments[0].status, CommentStatus::Addressed);
452    }
453
454    #[test]
455    fn all_addressed_should_reconcile_to_under_review() {
456        let mut session = ReviewSession::new("r1".into(), 1);
457        let comment_id = session.add_comment(
458            NewLineComment {
459                file_path: "src/lib.rs".into(),
460                old_line: None,
461                new_line: Some(1),
462                side: DiffSide::Right,
463                body: "needs refactor".into(),
464                author: Author::User,
465            },
466            2,
467        );
468        session
469            .set_comment_status(comment_id, CommentStatus::Addressed, Author::User, 3)
470            .expect("status should update");
471
472        assert_eq!(session.state, ReviewState::UnderReview);
473    }
474}