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