Skip to main content

packdiff_dto/
review.rs

1//! The review document: the comments a reviewer leaves on a diff, and every
2//! operation that may change them.
3//!
4//! This is the state that lives in the browser's localStorage. The page's
5//! JavaScript never edits it directly — it calls into the WASM build of this
6//! crate for every mutation, so upsert/delete/merge/ordering semantics are
7//! defined exactly once, here, and are identical in tests, in the CLI, and in
8//! the browser.
9//!
10//! Purity contract: the model has no clock and no entropy. Comment `id`s and
11//! RFC 3339 timestamps are supplied by the caller; the model validates and
12//! orders them.
13
14use serde::{Deserialize, Serialize};
15
16use crate::{ModelError, RefInfo, SCHEMA_VERSION, TOOL};
17
18/// Which side of the diff a comment anchors to. Serialized as the bare
19/// `CamelCase` variant name (`"Old"` / `"New"`).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
21pub enum Side {
22  /// Pre-image (a deleted line): `line` is an old-file line number.
23  Old,
24  /// Post-image (an added or context line): `line` is a new-file line number.
25  New,
26}
27
28/// The reviewer's verdict on the whole change, GitHub-style. Encoded as a
29/// single-key union — `{ "Approved": { "at": "…" } }` — with no
30/// discriminator field. The timestamp is the payload on purpose: it is what
31/// lets two documents merge deterministically (newer verdict wins).
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub enum Verdict {
35  /// The change is approved as it stands.
36  Approved {
37    /// When the verdict was given, RFC 3339 UTC, caller-supplied.
38    at: String,
39  },
40  /// The change needs work before it can land.
41  ChangesRequired {
42    /// When the verdict was given, RFC 3339 UTC, caller-supplied.
43    at: String,
44  },
45}
46
47impl Verdict {
48  /// When the verdict was given — the merge tiebreaker.
49  pub fn at(&self) -> &str {
50    match self {
51      Verdict::Approved { at } | Verdict::ChangesRequired { at } => at,
52    }
53  }
54
55  /// Human label for exports: `approved` / `changes required`.
56  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/// One review comment, anchored to a diff line.
72///
73/// Anchor identity is `(file, side, line)` where `file` is the post-image
74/// path ([`crate::diff::FileDiff::anchor_path`]). Comment identity is `id` —
75/// anchors may collide (several comments on one line), ids may not.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Comment {
79  /// Caller-supplied unique id — the comment's identity for upsert, delete,
80  /// and import merging.
81  pub id: String,
82  /// Post-image path of the file the comment anchors to.
83  pub file: String,
84  /// Which side of the diff `line` counts in.
85  pub side: Side,
86  /// 1-based line number on `side`.
87  pub line: u32,
88  /// The comment body, verbatim; may span multiple lines.
89  pub text: String,
90  /// Creation time, RFC 3339 UTC, caller-supplied; part of the stable sort
91  /// order so threads on one line read chronologically.
92  pub created_at: String,
93  /// Last-edit time, RFC 3339 UTC, caller-supplied; drives merge conflict
94  /// resolution (newer wins).
95  pub updated_at: String,
96  /// When the comment was resolved, RFC 3339 UTC, caller-supplied.
97  /// Present = resolved; absent (and omitted from JSON) = open. Reopening
98  /// removes it. Absent from v1 documents, which parse as all-open.
99  #[serde(default, skip_serializing_if = "Option::is_none")]
100  pub resolved_at: Option<String>,
101}
102
103impl Comment {
104  /// Validation applied on every entry into the document.
105  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  /// Deterministic document order: by file, then line, then side (old
126  /// before new), then creation time, then id as the final tiebreaker.
127  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/// The mutable review state for one `(repo, base_sha, head_sha)` diff.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct ReviewDocument {
136  /// Schema generation this document was written with; readers reject
137  /// documents newer than they understand ([`SCHEMA_VERSION`]).
138  pub schema_version: u32,
139  /// Producing tool identifier (`"packdiff"`); distinguishes exported files
140  /// from look-alike JSON of other origins.
141  pub tool: String,
142  /// Repository directory name the review belongs to.
143  pub repo: String,
144  /// The reviewed diff's base ref, pinned to its SHA.
145  pub base: RefInfo,
146  /// The reviewed diff's head ref, pinned to its SHA.
147  pub head: RefInfo,
148  /// The comments, always in the canonical order ([`ReviewDocument::sort`]).
149  pub comments: Vec<Comment>,
150  /// The reviewer's verdict on the whole change; `None` (and omitted from
151  /// JSON) while the review is in progress — and in v1 documents, which
152  /// parse as verdict-less.
153  #[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  /// Parse and validate a document. Rejects documents from a newer schema
171  /// and any unknown field (strict deserialization); re-validates and
172  /// re-sorts every comment so untrusted input (an imported file, a
173  /// hand-edited store) is normalized on the way in.
174  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  /// Canonical serialized form (pretty, trailing newline) — what exports
190  /// and the localStorage round-trip use.
191  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  /// Insert a new comment or replace the one with the same `id`.
198  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  /// Set, replace, or (with `None`) clear the review verdict.
209  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  /// Remove a comment by id. Returns whether anything was removed.
218  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  /// Merge another document's comments and verdict into this one (the
229  /// import path).
230  ///
231  /// Semantics: union by `id`; on an id collision the comment with the
232  /// **later `updated_at`** wins (RFC 3339 UTC strings compare correctly
233  /// lexicographically); an exact tie keeps the existing comment. The
234  /// verdict follows the same rule on its `at`: the later decision wins, a
235  /// decision beats no decision, a tie keeps the existing one. Metadata
236  /// (repo/refs) of `self` is kept — importing does not re-target a store.
237  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  /// Restore the canonical deterministic order.
257  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"]); // old before new on the same line
347  }
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    // A decision beats no decision.
445    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    // A later decision beats an earlier one.
451    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    // An earlier decision loses; no decision never clears one.
456    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    // Present-but-blank is rejected; absent stays absent in the JSON.
471    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    // A document exactly as a v1 page wrote it: no verdict, no resolved_at.
482    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}