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/// One review comment, anchored to a diff line.
29///
30/// Anchor identity is `(file, side, line)` where `file` is the post-image
31/// path ([`crate::diff::FileDiff::anchor_path`]). Comment identity is `id` —
32/// anchors may collide (several comments on one line), ids may not.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Comment {
36  /// Caller-supplied unique id — the comment's identity for upsert, delete,
37  /// and import merging.
38  pub id: String,
39  /// Post-image path of the file the comment anchors to.
40  pub file: String,
41  /// Which side of the diff `line` counts in.
42  pub side: Side,
43  /// 1-based line number on `side`.
44  pub line: u32,
45  /// The comment body, verbatim; may span multiple lines.
46  pub text: String,
47  /// Creation time, RFC 3339 UTC, caller-supplied; part of the stable sort
48  /// order so threads on one line read chronologically.
49  pub created_at: String,
50  /// Last-edit time, RFC 3339 UTC, caller-supplied; drives merge conflict
51  /// resolution (newer wins).
52  pub updated_at: String,
53}
54
55impl Comment {
56  /// Validation applied on every entry into the document.
57  pub fn validate(&self) -> Result<(), ModelError> {
58    fn need(cond: bool, msg: &str) -> Result<(), ModelError> {
59      if cond {
60        Ok(())
61      } else {
62        Err(ModelError::InvalidComment(msg.to_string()))
63      }
64    }
65    need(!self.id.trim().is_empty(), "id must be non-empty")?;
66    need(!self.file.trim().is_empty(), "file must be non-empty")?;
67    need(self.line >= 1, "line must be >= 1")?;
68    need(!self.text.trim().is_empty(), "text must be non-empty")?;
69    need(!self.created_at.trim().is_empty(), "created_at must be non-empty")?;
70    need(!self.updated_at.trim().is_empty(), "updated_at must be non-empty")?;
71    Ok(())
72  }
73
74  /// Deterministic document order: by file, then line, then side (old
75  /// before new), then creation time, then id as the final tiebreaker.
76  fn sort_key(&self) -> (&str, u32, Side, &str, &str) {
77    (&self.file, self.line, self.side, &self.created_at, &self.id)
78  }
79}
80
81/// The mutable review state for one `(repo, base_sha, head_sha)` diff.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ReviewDocument {
85  /// Schema generation this document was written with; readers reject
86  /// documents newer than they understand ([`SCHEMA_VERSION`]).
87  pub schema_version: u32,
88  /// Producing tool identifier (`"packdiff"`); distinguishes exported files
89  /// from look-alike JSON of other origins.
90  pub tool: String,
91  /// Repository directory name the review belongs to.
92  pub repo: String,
93  /// The reviewed diff's base ref, pinned to its SHA.
94  pub base: RefInfo,
95  /// The reviewed diff's head ref, pinned to its SHA.
96  pub head: RefInfo,
97  /// The comments, always in the canonical order ([`ReviewDocument::sort`]).
98  pub comments: Vec<Comment>,
99}
100
101impl ReviewDocument {
102  pub fn new(repo: String, base: RefInfo, head: RefInfo) -> Self {
103    Self { schema_version: SCHEMA_VERSION, tool: TOOL.to_string(), repo, base, head, comments: Vec::new() }
104  }
105
106  /// Parse and validate a document. Rejects documents from a newer schema
107  /// and any unknown field (strict deserialization); re-validates and
108  /// re-sorts every comment so untrusted input (an imported file, a
109  /// hand-edited store) is normalized on the way in.
110  pub fn parse(json: &str) -> Result<Self, ModelError> {
111    let mut doc: ReviewDocument = serde_json::from_str(json).map_err(|e| ModelError::Json(e.to_string()))?;
112    if doc.schema_version > SCHEMA_VERSION {
113      return Err(ModelError::UnsupportedSchema { found: doc.schema_version, supported: SCHEMA_VERSION });
114    }
115    for c in &doc.comments {
116      c.validate()?;
117    }
118    doc.sort();
119    Ok(doc)
120  }
121
122  /// Canonical serialized form (pretty, trailing newline) — what exports
123  /// and the localStorage round-trip use.
124  pub fn to_json_pretty(&self) -> String {
125    let mut s = serde_json::to_string_pretty(self).expect("document serializes: no non-string keys, no fallible types");
126    s.push('\n');
127    s
128  }
129
130  /// Insert a new comment or replace the one with the same `id`.
131  pub fn upsert(&mut self, comment: Comment) -> Result<(), ModelError> {
132    comment.validate()?;
133    match self.comments.iter_mut().find(|c| c.id == comment.id) {
134      Some(existing) => *existing = comment,
135      None => self.comments.push(comment),
136    }
137    self.sort();
138    Ok(())
139  }
140
141  /// Remove a comment by id. Returns whether anything was removed.
142  pub fn delete(&mut self, id: &str) -> bool {
143    let before = self.comments.len();
144    self.comments.retain(|c| c.id != id);
145    let removed = self.comments.len() != before;
146    if removed {
147      self.sort();
148    }
149    removed
150  }
151
152  /// Merge another document's comments into this one (the import path).
153  ///
154  /// Semantics: union by `id`; on an id collision the comment with the
155  /// **later `updated_at`** wins (RFC 3339 UTC strings compare correctly
156  /// lexicographically); an exact tie keeps the existing comment. Metadata
157  /// (repo/refs) of `self` is kept — importing does not re-target a store.
158  pub fn merge(&mut self, incoming: &ReviewDocument) {
159    for inc in &incoming.comments {
160      match self.comments.iter_mut().find(|c| c.id == inc.id) {
161        Some(existing) => {
162          if inc.updated_at > existing.updated_at {
163            *existing = inc.clone();
164          }
165        }
166        None => self.comments.push(inc.clone()),
167      }
168    }
169    self.sort();
170  }
171
172  /// Restore the canonical deterministic order.
173  pub fn sort(&mut self) {
174    self.comments.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
175  }
176}
177
178#[cfg(test)]
179mod tests {
180  use super::*;
181
182  fn refinfo(name: &str, sha_char: char) -> RefInfo {
183    RefInfo { name: name.into(), sha: std::iter::repeat(sha_char).take(40).collect() }
184  }
185
186  fn doc() -> ReviewDocument {
187    ReviewDocument::new("repo".into(), refinfo("main", 'a'), refinfo("feat", 'b'))
188  }
189
190  fn comment(id: &str, file: &str, line: u32, updated: &str) -> Comment {
191    Comment {
192      id: id.into(),
193      file: file.into(),
194      side: Side::New,
195      line,
196      text: format!("note {id}"),
197      created_at: "2026-07-03T10:00:00Z".into(),
198      updated_at: updated.into(),
199    }
200  }
201
202  #[test]
203  fn side_serializes_as_camelcase_variant_name() {
204    assert_eq!(serde_json::to_value(Side::Old).unwrap(), serde_json::json!("Old"));
205    assert_eq!(serde_json::to_value(Side::New).unwrap(), serde_json::json!("New"));
206  }
207
208  #[test]
209  fn unknown_fields_are_rejected() {
210    let mut v = serde_json::to_value(comment("c1", "a.rs", 5, "t")).unwrap();
211    v.as_object_mut().unwrap().insert("sneaky".into(), serde_json::json!(true));
212    assert!(serde_json::from_value::<Comment>(v).is_err());
213  }
214
215  #[test]
216  fn upsert_inserts_then_replaces() {
217    let mut d = doc();
218    d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
219    let mut edited = comment("c1", "a.rs", 5, "2026-07-03T11:00:00Z");
220    edited.text = "edited".into();
221    d.upsert(edited).unwrap();
222    assert_eq!(d.comments.len(), 1);
223    assert_eq!(d.comments[0].text, "edited");
224  }
225
226  #[test]
227  fn upsert_rejects_invalid() {
228    let mut d = doc();
229    let mut bad = comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z");
230    bad.text = "   ".into();
231    assert!(matches!(d.upsert(bad), Err(ModelError::InvalidComment(_))));
232    let mut bad = comment("c2", "a.rs", 5, "2026-07-03T10:00:00Z");
233    bad.line = 0;
234    assert!(d.upsert(bad).is_err());
235    assert!(d.comments.is_empty());
236  }
237
238  #[test]
239  fn delete_by_id() {
240    let mut d = doc();
241    d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
242    assert!(d.delete("c1"));
243    assert!(!d.delete("c1"));
244    assert!(d.comments.is_empty());
245  }
246
247  #[test]
248  fn ordering_is_file_line_side_created_id() {
249    let mut d = doc();
250    d.upsert(comment("z", "b.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
251    d.upsert(comment("y", "a.rs", 9, "2026-07-03T10:00:00Z")).unwrap();
252    d.upsert(comment("x", "a.rs", 2, "2026-07-03T10:00:00Z")).unwrap();
253    let mut old_side = comment("w", "a.rs", 2, "2026-07-03T10:00:00Z");
254    old_side.side = Side::Old;
255    d.upsert(old_side).unwrap();
256    let order: Vec<&str> = d.comments.iter().map(|c| c.id.as_str()).collect();
257    assert_eq!(order, vec!["w", "x", "y", "z"]); // old before new on the same line
258  }
259
260  #[test]
261  fn merge_unions_and_newer_updated_at_wins() {
262    let mut ours = doc();
263    ours.upsert(comment("shared", "a.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
264    ours.upsert(comment("only-ours", "a.rs", 2, "2026-07-03T10:00:00Z")).unwrap();
265
266    let mut theirs = doc();
267    let mut newer = comment("shared", "a.rs", 1, "2026-07-03T12:00:00Z");
268    newer.text = "their newer edit".into();
269    theirs.upsert(newer).unwrap();
270    theirs.upsert(comment("only-theirs", "a.rs", 3, "2026-07-03T10:00:00Z")).unwrap();
271
272    ours.merge(&theirs);
273    assert_eq!(ours.comments.len(), 3);
274    let shared = ours.comments.iter().find(|c| c.id == "shared").unwrap();
275    assert_eq!(shared.text, "their newer edit");
276  }
277
278  #[test]
279  fn merge_older_incoming_loses() {
280    let mut ours = doc();
281    let mut mine = comment("shared", "a.rs", 1, "2026-07-03T12:00:00Z");
282    mine.text = "my newer edit".into();
283    ours.upsert(mine).unwrap();
284
285    let mut theirs = doc();
286    theirs.upsert(comment("shared", "a.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
287
288    ours.merge(&theirs);
289    assert_eq!(ours.comments[0].text, "my newer edit");
290  }
291
292  #[test]
293  fn parse_rejects_newer_schema() {
294    let mut d = doc();
295    d.schema_version = SCHEMA_VERSION + 1;
296    let json = serde_json::to_string(&d).unwrap();
297    assert_eq!(
298      ReviewDocument::parse(&json),
299      Err(ModelError::UnsupportedSchema { found: SCHEMA_VERSION + 1, supported: SCHEMA_VERSION })
300    );
301  }
302
303  #[test]
304  fn parse_rejects_garbage_and_invalid_comments() {
305    assert!(matches!(ReviewDocument::parse("not json"), Err(ModelError::Json(_))));
306    let mut d = doc();
307    d.comments.push(Comment {
308      id: "".into(),
309      file: "a.rs".into(),
310      side: Side::New,
311      line: 1,
312      text: "x".into(),
313      created_at: "t".into(),
314      updated_at: "t".into(),
315    });
316    let json = serde_json::to_string(&d).unwrap();
317    assert!(matches!(ReviewDocument::parse(&json), Err(ModelError::InvalidComment(_))));
318  }
319
320  #[test]
321  fn parse_normalizes_order() {
322    let mut d = doc();
323    d.comments.push(comment("b", "z.rs", 9, "2026-07-03T10:00:00Z"));
324    d.comments.push(comment("a", "a.rs", 1, "2026-07-03T10:00:00Z"));
325    let parsed = ReviewDocument::parse(&serde_json::to_string(&d).unwrap()).unwrap();
326    assert_eq!(parsed.comments[0].id, "a");
327  }
328
329  #[test]
330  fn canonical_json_roundtrips() {
331    let mut d = doc();
332    d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
333    let json = d.to_json_pretty();
334    assert!(json.ends_with('\n'));
335    let back = ReviewDocument::parse(&json).unwrap();
336    assert_eq!(back, d);
337  }
338}