Skip to main content

parley/domain/
review.rs

1use serde::{Deserialize, Serialize};
2use std::str::FromStr;
3use thiserror::Error;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "snake_case")]
7pub enum ReviewState {
8    Open,
9    UnderReview,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum Author {
15    User,
16    Ai,
17}
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum CommentStatus {
22    Open,
23    Pending,
24    Addressed,
25}
26
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum DiffSide {
30    Left,
31    Right,
32}
33
34#[derive(Debug, Clone, Error, PartialEq, Eq)]
35pub enum ReviewMutationError {
36    #[error("comment_id {comment_id} not found")]
37    CommentNotFound { comment_id: u64 },
38    #[error("only the original commenter can mark a comment addressed")]
39    OnlyOriginalCommenterCanAddress,
40    #[error("only the original commenter can change thread status")]
41    OnlyOriginalCommenterCanChangeStatus,
42}
43
44macro_rules! impl_string_enum {
45    ($name:ty, $($variant:ident => $value:literal),+ $(,)?) => {
46        impl $name {
47            #[must_use]
48            pub fn as_str(&self) -> &'static str {
49                match self {
50                    $(Self::$variant => $value,)+
51                }
52            }
53        }
54
55        impl FromStr for $name {
56            type Err = ();
57
58            fn from_str(value: &str) -> Result<Self, Self::Err> {
59                match value {
60                    $($value => Ok(Self::$variant),)+
61                    _ => Err(()),
62                }
63            }
64        }
65    };
66}
67
68impl_string_enum!(ReviewState, Open => "open", UnderReview => "under_review");
69impl_string_enum!(Author, User => "user", Ai => "ai");
70impl_string_enum!(
71    CommentStatus,
72    Open => "open",
73    Pending => "pending_human",
74    Addressed => "addressed",
75);
76impl_string_enum!(DiffSide, Left => "left", Right => "right");
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct CommentReply {
80    pub id: u64,
81    pub author: Author,
82    pub body: String,
83    pub created_at_ms: u64,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
87pub struct LineAnchorSnapshot {
88    pub target_code: String,
89    #[serde(default)]
90    pub before_context: Vec<String>,
91    #[serde(default)]
92    pub after_context: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct CommentLineRange {
97    pub start_old_line: Option<u32>,
98    pub start_new_line: Option<u32>,
99    pub end_old_line: Option<u32>,
100    pub end_new_line: Option<u32>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub struct DiffAnchorSnapshot {
105    pub hunk_header: String,
106    #[serde(default)]
107    pub hunk_lines: Vec<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
111pub struct SourceAnchorSnapshot {
112    pub file_content_hash: Option<String>,
113    pub selected_text_hash: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct StoredAnchorSnapshot {
118    pub file_path: String,
119    pub side: DiffSide,
120    pub old_line: Option<u32>,
121    pub new_line: Option<u32>,
122    #[serde(default)]
123    pub line_range: Option<CommentLineRange>,
124    #[serde(default)]
125    pub selected_text: String,
126    #[serde(default)]
127    pub before_context: Vec<String>,
128    #[serde(default)]
129    pub after_context: Vec<String>,
130    #[serde(default)]
131    pub diff: Option<DiffAnchorSnapshot>,
132    #[serde(default)]
133    pub source: Option<SourceAnchorSnapshot>,
134    #[serde(default)]
135    pub base_rev: Option<String>,
136    #[serde(default)]
137    pub head_rev: Option<String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct LineComment {
142    pub id: u64,
143    pub file_path: String,
144    pub old_line: Option<u32>,
145    pub new_line: Option<u32>,
146    #[serde(default)]
147    pub line_range: Option<CommentLineRange>,
148    pub side: DiffSide,
149    #[serde(default)]
150    pub line_anchor: Option<LineAnchorSnapshot>,
151    #[serde(default)]
152    pub original_anchor: Option<StoredAnchorSnapshot>,
153    #[serde(default)]
154    pub detached: bool,
155    pub body: String,
156    pub author: Author,
157    pub status: CommentStatus,
158    pub replies: Vec<CommentReply>,
159    pub created_at_ms: u64,
160    pub updated_at_ms: u64,
161    pub addressed_at_ms: Option<u64>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct ReviewSession {
166    pub name: String,
167    pub state: ReviewState,
168    pub created_at_ms: u64,
169    pub updated_at_ms: u64,
170    pub comments: Vec<LineComment>,
171    pub next_comment_id: u64,
172    pub next_reply_id: u64,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct NewLineComment {
177    pub file_path: String,
178    pub old_line: Option<u32>,
179    pub new_line: Option<u32>,
180    pub line_range: Option<CommentLineRange>,
181    pub side: DiffSide,
182    pub line_anchor: Option<LineAnchorSnapshot>,
183    #[serde(default)]
184    pub original_anchor: Option<StoredAnchorSnapshot>,
185    pub body: String,
186    pub author: Author,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct ReanchorLineComment {
191    pub file_path: String,
192    pub old_line: Option<u32>,
193    pub new_line: Option<u32>,
194    pub line_range: Option<CommentLineRange>,
195    pub side: DiffSide,
196    pub line_anchor: Option<LineAnchorSnapshot>,
197}
198
199impl ReviewSession {
200    #[must_use]
201    pub fn new(name: String, now_ms: u64) -> Self {
202        Self {
203            name,
204            state: ReviewState::Open,
205            created_at_ms: now_ms,
206            updated_at_ms: now_ms,
207            comments: Vec::new(),
208            next_comment_id: 1,
209            next_reply_id: 1,
210        }
211    }
212
213    /// # Errors
214    ///
215    /// Currently does not fail, but returns `Result` to match other state-transition APIs.
216    pub fn set_state(&mut self, next: ReviewState, now_ms: u64) -> Result<(), ReviewMutationError> {
217        self.state = next;
218        self.updated_at_ms = now_ms;
219        Ok(())
220    }
221
222    pub fn add_comment(&mut self, new_comment: NewLineComment, now_ms: u64) -> u64 {
223        let id = self.next_comment_id;
224        self.next_comment_id += 1;
225
226        let comment = LineComment {
227            id,
228            file_path: new_comment.file_path,
229            old_line: new_comment.old_line,
230            new_line: new_comment.new_line,
231            line_range: new_comment.line_range,
232            side: new_comment.side,
233            line_anchor: new_comment.line_anchor,
234            original_anchor: new_comment.original_anchor,
235            detached: false,
236            body: new_comment.body,
237            author: new_comment.author,
238            status: CommentStatus::Open,
239            replies: Vec::new(),
240            created_at_ms: now_ms,
241            updated_at_ms: now_ms,
242            addressed_at_ms: None,
243        };
244
245        self.comments.push(comment);
246        self.reconcile_review_state_from_threads();
247        self.updated_at_ms = now_ms;
248        id
249    }
250
251    /// # Errors
252    ///
253    /// Returns an error when `comment_id` does not identify an existing comment.
254    pub fn add_reply(
255        &mut self,
256        comment_id: u64,
257        author: Author,
258        body: String,
259        now_ms: u64,
260    ) -> Result<u64, ReviewMutationError> {
261        let id = self.next_reply_id;
262        self.next_reply_id += 1;
263
264        let comment = self
265            .comments
266            .iter_mut()
267            .find(|comment| comment.id == comment_id)
268            .ok_or(ReviewMutationError::CommentNotFound { comment_id })?;
269
270        comment.replies.push(CommentReply {
271            id,
272            author: author.clone(),
273            body,
274            created_at_ms: now_ms,
275        });
276        comment.updated_at_ms = now_ms;
277        if author == comment.author {
278            comment.status = CommentStatus::Open;
279            comment.addressed_at_ms = None;
280        } else {
281            comment.status = CommentStatus::Pending;
282            comment.addressed_at_ms = None;
283        }
284        self.reconcile_review_state_from_threads();
285        self.updated_at_ms = now_ms;
286        Ok(id)
287    }
288
289    /// # Errors
290    ///
291    /// Returns an error when `comment_id` does not identify an existing comment.
292    pub fn reanchor_comment(
293        &mut self,
294        comment_id: u64,
295        target: ReanchorLineComment,
296        now_ms: u64,
297    ) -> Result<(), ReviewMutationError> {
298        let comment = self
299            .comments
300            .iter_mut()
301            .find(|comment| comment.id == comment_id)
302            .ok_or(ReviewMutationError::CommentNotFound { comment_id })?;
303
304        comment.file_path = target.file_path;
305        comment.old_line = target.old_line;
306        comment.new_line = target.new_line;
307        comment.line_range = target.line_range;
308        comment.side = target.side;
309        comment.line_anchor = target.line_anchor;
310        comment.detached = false;
311        comment.updated_at_ms = now_ms;
312        self.updated_at_ms = now_ms;
313        Ok(())
314    }
315
316    /// # Errors
317    ///
318    /// Returns an error when the comment is missing or the actor may not apply the requested status.
319    pub fn set_comment_status(
320        &mut self,
321        comment_id: u64,
322        status: CommentStatus,
323        actor: Author,
324        now_ms: u64,
325    ) -> Result<(), ReviewMutationError> {
326        self.set_comment_status_with_actor(comment_id, status, now_ms, Some(actor))
327    }
328
329    /// # Errors
330    ///
331    /// Returns an error when `comment_id` does not identify an existing comment.
332    pub fn set_comment_status_force(
333        &mut self,
334        comment_id: u64,
335        status: CommentStatus,
336        now_ms: u64,
337    ) -> Result<(), ReviewMutationError> {
338        self.set_comment_status_with_actor(comment_id, status, now_ms, None)
339    }
340
341    fn set_comment_status_with_actor(
342        &mut self,
343        comment_id: u64,
344        status: CommentStatus,
345        now_ms: u64,
346        actor: Option<Author>,
347    ) -> Result<(), ReviewMutationError> {
348        let comment = self
349            .comments
350            .iter_mut()
351            .find(|comment| comment.id == comment_id)
352            .ok_or(ReviewMutationError::CommentNotFound { comment_id })?;
353
354        if let Some(actor) = actor {
355            match status {
356                CommentStatus::Addressed => {
357                    if comment.author != actor {
358                        return Err(ReviewMutationError::OnlyOriginalCommenterCanAddress);
359                    }
360                }
361                CommentStatus::Open | CommentStatus::Pending => {
362                    if comment.author != actor {
363                        return Err(ReviewMutationError::OnlyOriginalCommenterCanChangeStatus);
364                    }
365                }
366            }
367        }
368
369        comment.status = status;
370        comment.updated_at_ms = now_ms;
371        comment.addressed_at_ms = if matches!(status, CommentStatus::Addressed) {
372            Some(now_ms)
373        } else {
374            None
375        };
376
377        self.reconcile_review_state_from_threads();
378        self.updated_at_ms = now_ms;
379        Ok(())
380    }
381
382    fn reconcile_review_state_from_threads(&mut self) {
383        let has_open = self
384            .comments
385            .iter()
386            .any(|comment| matches!(comment.status, CommentStatus::Open));
387        self.state = if has_open {
388            ReviewState::Open
389        } else {
390            ReviewState::UnderReview
391        };
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::{
398        Author, CommentStatus, DiffSide, NewLineComment, ReviewMutationError, ReviewSession,
399        ReviewState, StoredAnchorSnapshot,
400    };
401    use anyhow::Result;
402
403    fn user_comment(new_line: u32, body: &str) -> NewLineComment {
404        NewLineComment {
405            file_path: "src/lib.rs".into(),
406            old_line: None,
407            new_line: Some(new_line),
408            line_range: None,
409            side: DiffSide::Right,
410            line_anchor: None,
411            original_anchor: None,
412            body: body.into(),
413            author: Author::User,
414        }
415    }
416
417    fn session_with_user_comment() -> (ReviewSession, u64) {
418        let mut session = ReviewSession::new("r1".into(), 1);
419        let comment_id = session.add_comment(user_comment(1, "needs refactor"), 2);
420        (session, comment_id)
421    }
422
423    #[test]
424    fn add_reply_from_ai_should_set_pending_and_under_review() -> Result<()> {
425        let (mut session, comment_id) = session_with_user_comment();
426
427        session.add_reply(comment_id, Author::Ai, "fixed".into(), 3)?;
428
429        assert_eq!(session.comments[0].status, CommentStatus::Pending);
430        assert_eq!(session.state, ReviewState::UnderReview);
431        Ok(())
432    }
433
434    #[test]
435    fn domain_string_representations_round_trip() -> Result<()> {
436        assert_eq!(ReviewState::Open.as_str(), "open");
437        assert_eq!(
438            "under_review"
439                .parse::<ReviewState>()
440                .expect("state should parse"),
441            ReviewState::UnderReview
442        );
443        assert_eq!(Author::Ai.as_str(), "ai");
444        assert_eq!(
445            "user".parse::<Author>().expect("author should parse"),
446            Author::User
447        );
448        assert_eq!(CommentStatus::Pending.as_str(), "pending_human");
449        assert_eq!(
450            "addressed"
451                .parse::<CommentStatus>()
452                .expect("status should parse"),
453            CommentStatus::Addressed
454        );
455        assert_eq!(DiffSide::Right.as_str(), "right");
456        assert_eq!(
457            "left".parse::<DiffSide>().expect("side should parse"),
458            DiffSide::Left
459        );
460        Ok(())
461    }
462
463    #[test]
464    fn add_reply_from_original_commenter_should_reopen_thread() -> Result<()> {
465        let (mut session, comment_id) = session_with_user_comment();
466        session.add_reply(comment_id, Author::Ai, "proposal".into(), 3)?;
467
468        session.add_reply(comment_id, Author::User, "please revise".into(), 4)?;
469
470        assert_eq!(session.comments[0].status, CommentStatus::Open);
471        assert_eq!(session.state, ReviewState::Open);
472        Ok(())
473    }
474
475    #[test]
476    fn set_comment_status_should_require_original_commenter() {
477        let (mut session, comment_id) = session_with_user_comment();
478
479        let error = session
480            .set_comment_status(comment_id, CommentStatus::Addressed, Author::Ai, 3)
481            .expect_err("non-original commenter should not address comment");
482
483        assert_eq!(error, ReviewMutationError::OnlyOriginalCommenterCanAddress);
484    }
485
486    #[test]
487    fn set_comment_status_should_reject_non_author_status_changes() {
488        let (mut session, comment_id) = session_with_user_comment();
489
490        let error = session
491            .set_comment_status(comment_id, CommentStatus::Pending, Author::Ai, 3)
492            .expect_err("non-original commenter should not change thread status");
493
494        assert_eq!(
495            error,
496            ReviewMutationError::OnlyOriginalCommenterCanChangeStatus
497        );
498    }
499
500    #[test]
501    fn set_comment_status_force_should_bypass_original_commenter_check() -> Result<()> {
502        let (mut session, comment_id) = session_with_user_comment();
503
504        session.set_comment_status_force(comment_id, CommentStatus::Addressed, 3)?;
505        assert_eq!(session.comments[0].status, CommentStatus::Addressed);
506        Ok(())
507    }
508
509    #[test]
510    fn all_addressed_should_reconcile_to_under_review() -> Result<()> {
511        let (mut session, comment_id) = session_with_user_comment();
512        session.set_comment_status(comment_id, CommentStatus::Addressed, Author::User, 3)?;
513
514        assert_eq!(session.state, ReviewState::UnderReview);
515        Ok(())
516    }
517
518    #[test]
519    fn missing_comment_returns_typed_mutation_error() {
520        let mut session = ReviewSession::new("r1".into(), 1);
521
522        let error = session
523            .set_comment_status(7, CommentStatus::Addressed, Author::User, 2)
524            .expect_err("missing comment should return a typed error");
525
526        assert_eq!(
527            error,
528            ReviewMutationError::CommentNotFound { comment_id: 7 }
529        );
530        assert_eq!(error.to_string(), "comment_id 7 not found");
531    }
532
533    #[test]
534    fn add_comment_should_store_original_anchor_snapshot() {
535        let mut session = ReviewSession::new("r1".into(), 1);
536        let original_anchor = StoredAnchorSnapshot {
537            file_path: "src/lib.rs".into(),
538            side: DiffSide::Right,
539            old_line: None,
540            new_line: Some(7),
541            line_range: None,
542            selected_text: "fn main() {}".into(),
543            before_context: vec!["mod cli;".into()],
544            after_context: vec!["mod tui;".into()],
545            diff: None,
546            source: None,
547            base_rev: Some("base".into()),
548            head_rev: Some("head".into()),
549        };
550
551        let mut comment = user_comment(7, "anchor");
552        comment.original_anchor = Some(original_anchor.clone());
553        session.add_comment(comment, 2);
554
555        assert_eq!(session.comments[0].original_anchor, Some(original_anchor));
556    }
557}