1use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use uuid::Uuid;
21
22use crate::draft_package::ArtifactDisposition;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ReviewSession {
30 pub session_id: Uuid,
32 pub draft_package_id: Uuid,
34 pub reviewer: String,
36 pub created_at: DateTime<Utc>,
38 pub updated_at: DateTime<Utc>,
40 pub state: ReviewState,
42 pub artifact_reviews: HashMap<String, ArtifactReview>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub session_notes: Vec<SessionNote>,
47 #[serde(skip_serializing_if = "Option::is_none")]
50 pub current_focus: Option<String>,
51}
52
53impl ReviewSession {
54 pub fn new(draft_package_id: Uuid, reviewer: String) -> Self {
56 Self {
57 session_id: Uuid::new_v4(),
58 draft_package_id,
59 reviewer,
60 created_at: Utc::now(),
61 updated_at: Utc::now(),
62 state: ReviewState::Active,
63 artifact_reviews: HashMap::new(),
64 session_notes: Vec::new(),
65 current_focus: None,
66 }
67 }
68
69 pub fn touch(&mut self) {
71 self.updated_at = Utc::now();
72 }
73
74 pub fn add_comment(
76 &mut self,
77 artifact_uri: &str,
78 commenter: &str,
79 text: &str,
80 ) -> &CommentThread {
81 self.touch();
82 let review = self
83 .artifact_reviews
84 .entry(artifact_uri.to_string())
85 .or_insert_with(|| ArtifactReview {
86 resource_uri: artifact_uri.to_string(),
87 disposition: ArtifactDisposition::Pending,
88 comments: CommentThread::new(),
89 reviewed_at: None,
90 });
91 review.comments.add(commenter, text);
92 &review.comments
93 }
94
95 pub fn set_disposition(&mut self, artifact_uri: &str, disposition: ArtifactDisposition) {
97 self.touch();
98 let review = self
99 .artifact_reviews
100 .entry(artifact_uri.to_string())
101 .or_insert_with(|| ArtifactReview {
102 resource_uri: artifact_uri.to_string(),
103 disposition: ArtifactDisposition::Pending,
104 comments: CommentThread::new(),
105 reviewed_at: None,
106 });
107 review.disposition = disposition;
108 review.reviewed_at = Some(Utc::now());
109 }
110
111 pub fn add_session_note(&mut self, text: &str) {
113 self.touch();
114 self.session_notes.push(SessionNote {
115 text: text.to_string(),
116 created_at: Utc::now(),
117 });
118 }
119
120 pub fn get_disposition(&self, artifact_uri: &str) -> Option<ArtifactDisposition> {
122 self.artifact_reviews
123 .get(artifact_uri)
124 .map(|r| r.disposition.clone())
125 }
126
127 pub fn artifacts_with_disposition(
129 &self,
130 disposition: &ArtifactDisposition,
131 ) -> Vec<&ArtifactReview> {
132 self.artifact_reviews
133 .values()
134 .filter(|r| &r.disposition == disposition)
135 .collect()
136 }
137
138 pub fn disposition_counts(&self) -> DispositionCounts {
140 let mut counts = DispositionCounts::default();
141 for review in self.artifact_reviews.values() {
142 match review.disposition {
143 ArtifactDisposition::Pending => counts.pending += 1,
144 ArtifactDisposition::Approved => counts.approved += 1,
145 ArtifactDisposition::Rejected => counts.rejected += 1,
146 ArtifactDisposition::Discuss => counts.discuss += 1,
147 }
148 }
149 counts
150 }
151
152 pub fn finish(&mut self) -> DispositionCounts {
154 self.touch();
155 self.state = ReviewState::Completed;
156 self.disposition_counts()
157 }
158
159 pub fn has_unresolved_discuss(&self) -> bool {
161 self.artifact_reviews
162 .values()
163 .any(|r| r.disposition == ArtifactDisposition::Discuss)
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum ReviewState {
171 Active,
173 Paused,
175 Completed,
177 Abandoned,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ArtifactReview {
184 pub resource_uri: String,
186 pub disposition: ArtifactDisposition,
188 pub comments: CommentThread,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub reviewed_at: Option<DateTime<Utc>>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct CommentThread {
200 pub comments: Vec<Comment>,
201}
202
203impl CommentThread {
204 pub fn new() -> Self {
205 Self {
206 comments: Vec::new(),
207 }
208 }
209
210 pub fn add(&mut self, commenter: &str, text: &str) {
212 self.comments.push(Comment {
213 commenter: commenter.to_string(),
214 text: text.to_string(),
215 created_at: Utc::now(),
216 reasoning: None,
217 });
218 }
219
220 pub fn add_with_reasoning(&mut self, commenter: &str, text: &str, reasoning: ReviewReasoning) {
222 self.comments.push(Comment {
223 commenter: commenter.to_string(),
224 text: text.to_string(),
225 created_at: Utc::now(),
226 reasoning: Some(reasoning),
227 });
228 }
229
230 pub fn is_empty(&self) -> bool {
232 self.comments.is_empty()
233 }
234
235 pub fn len(&self) -> usize {
237 self.comments.len()
238 }
239}
240
241impl Default for CommentThread {
242 fn default() -> Self {
243 Self::new()
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct Comment {
250 pub commenter: String,
252 pub text: String,
254 pub created_at: DateTime<Utc>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub reasoning: Option<ReviewReasoning>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ReviewReasoning {
268 pub rationale: String,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub alternatives_considered: Vec<String>,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub applied_principles: Vec<String>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SessionNote {
281 pub text: String,
282 pub created_at: DateTime<Utc>,
283}
284
285#[derive(Debug, Clone, Default)]
287pub struct DispositionCounts {
288 pub pending: usize,
289 pub approved: usize,
290 pub rejected: usize,
291 pub discuss: usize,
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn new_session_has_active_state() {
300 let session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
301 assert_eq!(session.state, ReviewState::Active);
302 assert!(session.artifact_reviews.is_empty());
303 assert!(session.session_notes.is_empty());
304 assert!(session.current_focus.is_none());
305 }
306
307 #[test]
308 fn add_comment_creates_artifact_review() {
309 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
310 let uri = "fs://workspace/src/main.rs";
311
312 session.add_comment(uri, "reviewer-1", "Needs error handling");
313
314 assert_eq!(session.artifact_reviews.len(), 1);
315 let review = session.artifact_reviews.get(uri).unwrap();
316 assert_eq!(review.comments.len(), 1);
317 assert_eq!(review.comments.comments[0].text, "Needs error handling");
318 assert_eq!(review.disposition, ArtifactDisposition::Pending);
319 }
320
321 #[test]
322 fn set_disposition_updates_review() {
323 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
324 let uri = "fs://workspace/src/main.rs";
325
326 session.set_disposition(uri, ArtifactDisposition::Approved);
327
328 let review = session.artifact_reviews.get(uri).unwrap();
329 assert_eq!(review.disposition, ArtifactDisposition::Approved);
330 assert!(review.reviewed_at.is_some());
331 }
332
333 #[test]
334 fn disposition_counts_are_accurate() {
335 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
336
337 session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
338 session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Approved);
339 session.set_disposition("fs://workspace/c.rs", ArtifactDisposition::Rejected);
340 session.set_disposition("fs://workspace/d.rs", ArtifactDisposition::Discuss);
341
342 let counts = session.disposition_counts();
343 assert_eq!(counts.approved, 2);
344 assert_eq!(counts.rejected, 1);
345 assert_eq!(counts.discuss, 1);
346 assert_eq!(counts.pending, 0);
347 }
348
349 #[test]
350 fn has_unresolved_discuss_returns_true_when_discuss_items_exist() {
351 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
352
353 session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
354 session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Discuss);
355
356 assert!(session.has_unresolved_discuss());
357 }
358
359 #[test]
360 fn has_unresolved_discuss_returns_false_when_no_discuss_items() {
361 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
362
363 session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
364 session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Rejected);
365
366 assert!(!session.has_unresolved_discuss());
367 }
368
369 #[test]
370 fn finish_sets_state_to_completed() {
371 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
372 session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
373
374 let counts = session.finish();
375
376 assert_eq!(session.state, ReviewState::Completed);
377 assert_eq!(counts.approved, 1);
378 }
379
380 #[test]
381 fn session_serialization_round_trip() {
382 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
383 session.add_comment("fs://workspace/main.rs", "reviewer-1", "Looks good");
384 session.set_disposition("fs://workspace/main.rs", ArtifactDisposition::Approved);
385 session.add_session_note("Overall: well structured");
386
387 let json = serde_json::to_string(&session).unwrap();
388 let restored: ReviewSession = serde_json::from_str(&json).unwrap();
389
390 assert_eq!(restored.session_id, session.session_id);
391 assert_eq!(restored.reviewer, session.reviewer);
392 assert_eq!(restored.artifact_reviews.len(), 1);
393 assert_eq!(restored.session_notes.len(), 1);
394 }
395
396 #[test]
397 fn comment_thread_tracks_multiple_comments() {
398 let mut thread = CommentThread::new();
399 thread.add("reviewer-1", "First comment");
400 thread.add("agent-1", "Response from agent");
401 thread.add("reviewer-1", "Follow-up");
402
403 assert_eq!(thread.len(), 3);
404 assert_eq!(thread.comments[0].commenter, "reviewer-1");
405 assert_eq!(thread.comments[1].commenter, "agent-1");
406 assert_eq!(thread.comments[2].commenter, "reviewer-1");
407 }
408
409 #[test]
410 fn artifacts_with_disposition_filters_correctly() {
411 let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
412 session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
413 session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Approved);
414 session.set_disposition("fs://workspace/c.rs", ArtifactDisposition::Rejected);
415
416 let approved = session.artifacts_with_disposition(&ArtifactDisposition::Approved);
417 assert_eq!(approved.len(), 2);
418
419 let rejected = session.artifacts_with_disposition(&ArtifactDisposition::Rejected);
420 assert_eq!(rejected.len(), 1);
421 }
422
423 #[test]
426 fn comment_with_reasoning_round_trip() {
427 let reasoning = ReviewReasoning {
428 rationale: "Change is well-tested and follows conventions".to_string(),
429 alternatives_considered: vec!["Request rework with different approach".to_string()],
430 applied_principles: vec!["code-review-checklist".to_string()],
431 };
432
433 let mut thread = CommentThread::new();
434 thread.add_with_reasoning("reviewer-1", "Approved with minor note", reasoning);
435
436 assert_eq!(thread.len(), 1);
437 assert!(thread.comments[0].reasoning.is_some());
438
439 let r = thread.comments[0].reasoning.as_ref().unwrap();
440 assert!(r.rationale.contains("well-tested"));
441 assert_eq!(r.alternatives_considered.len(), 1);
442 assert_eq!(r.applied_principles.len(), 1);
443 }
444
445 #[test]
446 fn comment_without_reasoning_backward_compatible() {
447 let json = r#"{
449 "commenter": "reviewer-1",
450 "text": "Looks good",
451 "created_at": "2026-02-25T12:00:00Z"
452 }"#;
453 let comment: Comment = serde_json::from_str(json).unwrap();
454 assert!(comment.reasoning.is_none());
455 }
456
457 #[test]
458 fn review_reasoning_serialization() {
459 let reasoning = ReviewReasoning {
460 rationale: "Security fix verified".to_string(),
461 alternatives_considered: vec![],
462 applied_principles: vec!["security-first".to_string()],
463 };
464
465 let json = serde_json::to_string(&reasoning).unwrap();
466 let restored: ReviewReasoning = serde_json::from_str(&json).unwrap();
467
468 assert_eq!(restored.rationale, "Security fix verified");
469 assert!(!json.contains("alternatives_considered"));
471 }
472}