1use serde::{Deserialize, Serialize};
15
16use crate::{ModelError, RefInfo, SCHEMA_VERSION, TOOL};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
21pub enum Side {
22 Old,
24 New,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub enum Verdict {
35 Approved {
37 at: String,
39 },
40 ChangesRequired {
42 at: String,
44 },
45}
46
47impl Verdict {
48 pub fn at(&self) -> &str {
50 match self {
51 Verdict::Approved { at } | Verdict::ChangesRequired { at } => at,
52 }
53 }
54
55 pub fn label(&self) -> &'static str {
57 match self {
58 Verdict::Approved { .. } => "approved",
59 Verdict::ChangesRequired { .. } => "changes required",
60 }
61 }
62
63 fn validate(&self) -> Result<(), ModelError> {
64 if self.at().trim().is_empty() {
65 return Err(ModelError::InvalidVerdict("at must be non-empty".to_string()));
66 }
67 Ok(())
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Comment {
79 pub id: String,
82 pub file: String,
84 pub side: Side,
86 pub line: u32,
88 pub text: String,
90 pub created_at: String,
93 pub updated_at: String,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub resolved_at: Option<String>,
101}
102
103impl Comment {
104 pub fn validate(&self) -> Result<(), ModelError> {
106 fn need(cond: bool, msg: &str) -> Result<(), ModelError> {
107 if cond {
108 Ok(())
109 } else {
110 Err(ModelError::InvalidComment(msg.to_string()))
111 }
112 }
113 need(!self.id.trim().is_empty(), "id must be non-empty")?;
114 need(!self.file.trim().is_empty(), "file must be non-empty")?;
115 need(self.line >= 1, "line must be >= 1")?;
116 need(!self.text.trim().is_empty(), "text must be non-empty")?;
117 need(!self.created_at.trim().is_empty(), "created_at must be non-empty")?;
118 need(!self.updated_at.trim().is_empty(), "updated_at must be non-empty")?;
119 if let Some(at) = &self.resolved_at {
120 need(!at.trim().is_empty(), "resolved_at, when present, must be non-empty")?;
121 }
122 Ok(())
123 }
124
125 fn sort_key(&self) -> (&str, u32, Side, &str, &str) {
128 (&self.file, self.line, self.side, &self.created_at, &self.id)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct ReviewDocument {
136 pub schema_version: u32,
139 pub tool: String,
142 pub repo: String,
144 pub base: RefInfo,
146 pub head: RefInfo,
148 pub comments: Vec<Comment>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub verdict: Option<Verdict>,
155}
156
157impl ReviewDocument {
158 pub fn new(repo: String, base: RefInfo, head: RefInfo) -> Self {
159 Self {
160 schema_version: SCHEMA_VERSION,
161 tool: TOOL.to_string(),
162 repo,
163 base,
164 head,
165 comments: Vec::new(),
166 verdict: None,
167 }
168 }
169
170 pub fn parse(json: &str) -> Result<Self, ModelError> {
175 let mut doc: ReviewDocument = serde_json::from_str(json).map_err(|e| ModelError::Json(e.to_string()))?;
176 if doc.schema_version > SCHEMA_VERSION {
177 return Err(ModelError::UnsupportedSchema { found: doc.schema_version, supported: SCHEMA_VERSION });
178 }
179 for c in &doc.comments {
180 c.validate()?;
181 }
182 if let Some(v) = &doc.verdict {
183 v.validate()?;
184 }
185 doc.sort();
186 Ok(doc)
187 }
188
189 pub fn to_json_pretty(&self) -> String {
192 let mut s = serde_json::to_string_pretty(self).expect("document serializes: no non-string keys, no fallible types");
193 s.push('\n');
194 s
195 }
196
197 pub fn upsert(&mut self, comment: Comment) -> Result<(), ModelError> {
199 comment.validate()?;
200 match self.comments.iter_mut().find(|c| c.id == comment.id) {
201 Some(existing) => *existing = comment,
202 None => self.comments.push(comment),
203 }
204 self.sort();
205 Ok(())
206 }
207
208 pub fn set_verdict(&mut self, verdict: Option<Verdict>) -> Result<(), ModelError> {
210 if let Some(v) = &verdict {
211 v.validate()?;
212 }
213 self.verdict = verdict;
214 Ok(())
215 }
216
217 pub fn delete(&mut self, id: &str) -> bool {
219 let before = self.comments.len();
220 self.comments.retain(|c| c.id != id);
221 let removed = self.comments.len() != before;
222 if removed {
223 self.sort();
224 }
225 removed
226 }
227
228 pub fn merge(&mut self, incoming: &ReviewDocument) {
238 for inc in &incoming.comments {
239 match self.comments.iter_mut().find(|c| c.id == inc.id) {
240 Some(existing) => {
241 if inc.updated_at > existing.updated_at {
242 *existing = inc.clone();
243 }
244 }
245 None => self.comments.push(inc.clone()),
246 }
247 }
248 if let Some(inc) = &incoming.verdict {
249 if self.verdict.as_ref().map_or(true, |cur| inc.at() > cur.at()) {
250 self.verdict = Some(inc.clone());
251 }
252 }
253 self.sort();
254 }
255
256 pub fn sort(&mut self) {
258 self.comments.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn refinfo(name: &str, sha_char: char) -> RefInfo {
267 RefInfo { name: name.into(), sha: std::iter::repeat(sha_char).take(40).collect() }
268 }
269
270 fn doc() -> ReviewDocument {
271 ReviewDocument::new("repo".into(), refinfo("main", 'a'), refinfo("feat", 'b'))
272 }
273
274 fn comment(id: &str, file: &str, line: u32, updated: &str) -> Comment {
275 Comment {
276 id: id.into(),
277 file: file.into(),
278 side: Side::New,
279 line,
280 text: format!("note {id}"),
281 created_at: "2026-07-03T10:00:00Z".into(),
282 updated_at: updated.into(),
283 resolved_at: None,
284 }
285 }
286
287 fn verdict_approved(at: &str) -> Verdict {
288 Verdict::Approved { at: at.into() }
289 }
290
291 #[test]
292 fn side_serializes_as_camelcase_variant_name() {
293 assert_eq!(serde_json::to_value(Side::Old).unwrap(), serde_json::json!("Old"));
294 assert_eq!(serde_json::to_value(Side::New).unwrap(), serde_json::json!("New"));
295 }
296
297 #[test]
298 fn unknown_fields_are_rejected() {
299 let mut v = serde_json::to_value(comment("c1", "a.rs", 5, "t")).unwrap();
300 v.as_object_mut().unwrap().insert("sneaky".into(), serde_json::json!(true));
301 assert!(serde_json::from_value::<Comment>(v).is_err());
302 }
303
304 #[test]
305 fn upsert_inserts_then_replaces() {
306 let mut d = doc();
307 d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
308 let mut edited = comment("c1", "a.rs", 5, "2026-07-03T11:00:00Z");
309 edited.text = "edited".into();
310 d.upsert(edited).unwrap();
311 assert_eq!(d.comments.len(), 1);
312 assert_eq!(d.comments[0].text, "edited");
313 }
314
315 #[test]
316 fn upsert_rejects_invalid() {
317 let mut d = doc();
318 let mut bad = comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z");
319 bad.text = " ".into();
320 assert!(matches!(d.upsert(bad), Err(ModelError::InvalidComment(_))));
321 let mut bad = comment("c2", "a.rs", 5, "2026-07-03T10:00:00Z");
322 bad.line = 0;
323 assert!(d.upsert(bad).is_err());
324 assert!(d.comments.is_empty());
325 }
326
327 #[test]
328 fn delete_by_id() {
329 let mut d = doc();
330 d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
331 assert!(d.delete("c1"));
332 assert!(!d.delete("c1"));
333 assert!(d.comments.is_empty());
334 }
335
336 #[test]
337 fn ordering_is_file_line_side_created_id() {
338 let mut d = doc();
339 d.upsert(comment("z", "b.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
340 d.upsert(comment("y", "a.rs", 9, "2026-07-03T10:00:00Z")).unwrap();
341 d.upsert(comment("x", "a.rs", 2, "2026-07-03T10:00:00Z")).unwrap();
342 let mut old_side = comment("w", "a.rs", 2, "2026-07-03T10:00:00Z");
343 old_side.side = Side::Old;
344 d.upsert(old_side).unwrap();
345 let order: Vec<&str> = d.comments.iter().map(|c| c.id.as_str()).collect();
346 assert_eq!(order, vec!["w", "x", "y", "z"]); }
348
349 #[test]
350 fn merge_unions_and_newer_updated_at_wins() {
351 let mut ours = doc();
352 ours.upsert(comment("shared", "a.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
353 ours.upsert(comment("only-ours", "a.rs", 2, "2026-07-03T10:00:00Z")).unwrap();
354
355 let mut theirs = doc();
356 let mut newer = comment("shared", "a.rs", 1, "2026-07-03T12:00:00Z");
357 newer.text = "their newer edit".into();
358 theirs.upsert(newer).unwrap();
359 theirs.upsert(comment("only-theirs", "a.rs", 3, "2026-07-03T10:00:00Z")).unwrap();
360
361 ours.merge(&theirs);
362 assert_eq!(ours.comments.len(), 3);
363 let shared = ours.comments.iter().find(|c| c.id == "shared").unwrap();
364 assert_eq!(shared.text, "their newer edit");
365 }
366
367 #[test]
368 fn merge_older_incoming_loses() {
369 let mut ours = doc();
370 let mut mine = comment("shared", "a.rs", 1, "2026-07-03T12:00:00Z");
371 mine.text = "my newer edit".into();
372 ours.upsert(mine).unwrap();
373
374 let mut theirs = doc();
375 theirs.upsert(comment("shared", "a.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
376
377 ours.merge(&theirs);
378 assert_eq!(ours.comments[0].text, "my newer edit");
379 }
380
381 #[test]
382 fn parse_rejects_newer_schema() {
383 let mut d = doc();
384 d.schema_version = SCHEMA_VERSION + 1;
385 let json = serde_json::to_string(&d).unwrap();
386 assert_eq!(
387 ReviewDocument::parse(&json),
388 Err(ModelError::UnsupportedSchema { found: SCHEMA_VERSION + 1, supported: SCHEMA_VERSION })
389 );
390 }
391
392 #[test]
393 fn parse_rejects_garbage_and_invalid_comments() {
394 assert!(matches!(ReviewDocument::parse("not json"), Err(ModelError::Json(_))));
395 let mut d = doc();
396 d.comments.push(Comment {
397 id: "".into(),
398 file: "a.rs".into(),
399 side: Side::New,
400 line: 1,
401 text: "x".into(),
402 created_at: "t".into(),
403 updated_at: "t".into(),
404 resolved_at: None,
405 });
406 let json = serde_json::to_string(&d).unwrap();
407 assert!(matches!(ReviewDocument::parse(&json), Err(ModelError::InvalidComment(_))));
408 }
409
410 #[test]
411 fn parse_normalizes_order() {
412 let mut d = doc();
413 d.comments.push(comment("b", "z.rs", 9, "2026-07-03T10:00:00Z"));
414 d.comments.push(comment("a", "a.rs", 1, "2026-07-03T10:00:00Z"));
415 let parsed = ReviewDocument::parse(&serde_json::to_string(&d).unwrap()).unwrap();
416 assert_eq!(parsed.comments[0].id, "a");
417 }
418
419 #[test]
420 fn verdict_serializes_as_single_key_union_with_timestamp() {
421 assert_eq!(
422 serde_json::to_value(verdict_approved("2026-07-13T10:00:00Z")).unwrap(),
423 serde_json::json!({ "Approved": { "at": "2026-07-13T10:00:00Z" } })
424 );
425 assert_eq!(
426 serde_json::to_value(Verdict::ChangesRequired { at: "t".into() }).unwrap(),
427 serde_json::json!({ "ChangesRequired": { "at": "t" } })
428 );
429 }
430
431 #[test]
432 fn set_verdict_validates_and_clears() {
433 let mut d = doc();
434 assert!(matches!(d.set_verdict(Some(verdict_approved(" "))), Err(ModelError::InvalidVerdict(_))));
435 assert_eq!(d.verdict, None, "a rejected verdict leaves the document untouched");
436 d.set_verdict(Some(verdict_approved("2026-07-13T10:00:00Z"))).unwrap();
437 assert_eq!(d.verdict.as_ref().unwrap().label(), "approved");
438 d.set_verdict(None).unwrap();
439 assert_eq!(d.verdict, None, "clearing returns the review to in-progress");
440 }
441
442 #[test]
443 fn merge_verdict_later_decision_wins() {
444 let mut ours = doc();
446 let mut theirs = doc();
447 theirs.set_verdict(Some(verdict_approved("2026-07-13T10:00:00Z"))).unwrap();
448 ours.merge(&theirs);
449 assert_eq!(ours.verdict, theirs.verdict);
450 let mut newer = doc();
452 newer.set_verdict(Some(Verdict::ChangesRequired { at: "2026-07-13T12:00:00Z".into() })).unwrap();
453 ours.merge(&newer);
454 assert_eq!(ours.verdict.as_ref().unwrap().label(), "changes required");
455 ours.merge(&theirs);
457 assert_eq!(ours.verdict, newer.verdict);
458 ours.merge(&doc());
459 assert_eq!(ours.verdict, newer.verdict);
460 }
461
462 #[test]
463 fn resolved_comments_validate_and_roundtrip() {
464 let mut d = doc();
465 let mut resolved = comment("c1", "a.rs", 5, "2026-07-13T11:00:00Z");
466 resolved.resolved_at = Some("2026-07-13T11:00:00Z".into());
467 d.upsert(resolved).unwrap();
468 let back = ReviewDocument::parse(&d.to_json_pretty()).unwrap();
469 assert_eq!(back.comments[0].resolved_at.as_deref(), Some("2026-07-13T11:00:00Z"));
470 let mut blank = comment("c2", "a.rs", 6, "t");
472 blank.resolved_at = Some(" ".into());
473 assert!(matches!(d.upsert(blank), Err(ModelError::InvalidComment(_))));
474 let open = comment("c3", "a.rs", 7, "t");
475 d.upsert(open).unwrap();
476 assert!(!serde_json::to_string(&d.comments[1]).unwrap().contains("resolved_at"));
477 }
478
479 #[test]
480 fn v1_documents_parse_with_the_new_fields_defaulted() {
481 let v1 = r#"{
483 "schema_version": 1, "tool": "packdiff", "repo": "repo",
484 "base": { "name": "main", "sha": "a" }, "head": { "name": "feat", "sha": "b" },
485 "comments": [ { "id": "c1", "file": "a.rs", "side": "New", "line": 5,
486 "text": "note", "created_at": "t", "updated_at": "t" } ]
487 }"#;
488 let doc = ReviewDocument::parse(v1).unwrap();
489 assert_eq!(doc.verdict, None);
490 assert_eq!(doc.comments[0].resolved_at, None);
491 }
492
493 #[test]
494 fn canonical_json_roundtrips() {
495 let mut d = doc();
496 d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
497 let json = d.to_json_pretty();
498 assert!(json.ends_with('\n'));
499 let back = ReviewDocument::parse(&json).unwrap();
500 assert_eq!(back, d);
501 }
502}