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}