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)]
34#[serde(deny_unknown_fields)]
35pub struct Comment {
36 pub id: String,
39 pub file: String,
41 pub side: Side,
43 pub line: u32,
45 pub text: String,
47 pub created_at: String,
50 pub updated_at: String,
53}
54
55impl Comment {
56 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ReviewDocument {
85 pub schema_version: u32,
88 pub tool: String,
91 pub repo: String,
93 pub base: RefInfo,
95 pub head: RefInfo,
97 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 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 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 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 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 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 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"]); }
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}