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 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 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 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 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 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}