use serde::{Deserialize, Serialize};
use crate::{ModelError, RefInfo, SCHEMA_VERSION, TOOL};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Side {
Old,
New,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Comment {
pub id: String,
pub file: String,
pub side: Side,
pub line: u32,
pub text: String,
pub created_at: String,
pub updated_at: String,
}
impl Comment {
pub fn validate(&self) -> Result<(), ModelError> {
fn need(cond: bool, msg: &str) -> Result<(), ModelError> {
if cond {
Ok(())
} else {
Err(ModelError::InvalidComment(msg.to_string()))
}
}
need(!self.id.trim().is_empty(), "id must be non-empty")?;
need(!self.file.trim().is_empty(), "file must be non-empty")?;
need(self.line >= 1, "line must be >= 1")?;
need(!self.text.trim().is_empty(), "text must be non-empty")?;
need(!self.created_at.trim().is_empty(), "created_at must be non-empty")?;
need(!self.updated_at.trim().is_empty(), "updated_at must be non-empty")?;
Ok(())
}
fn sort_key(&self) -> (&str, u32, Side, &str, &str) {
(&self.file, self.line, self.side, &self.created_at, &self.id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewDocument {
pub schema_version: u32,
pub tool: String,
pub repo: String,
pub base: RefInfo,
pub head: RefInfo,
pub comments: Vec<Comment>,
}
impl ReviewDocument {
pub fn new(repo: String, base: RefInfo, head: RefInfo) -> Self {
Self { schema_version: SCHEMA_VERSION, tool: TOOL.to_string(), repo, base, head, comments: Vec::new() }
}
pub fn parse(json: &str) -> Result<Self, ModelError> {
let mut doc: ReviewDocument = serde_json::from_str(json).map_err(|e| ModelError::Json(e.to_string()))?;
if doc.schema_version > SCHEMA_VERSION {
return Err(ModelError::UnsupportedSchema { found: doc.schema_version, supported: SCHEMA_VERSION });
}
for c in &doc.comments {
c.validate()?;
}
doc.sort();
Ok(doc)
}
pub fn to_json_pretty(&self) -> String {
let mut s = serde_json::to_string_pretty(self).expect("document serializes: no non-string keys, no fallible types");
s.push('\n');
s
}
pub fn upsert(&mut self, comment: Comment) -> Result<(), ModelError> {
comment.validate()?;
match self.comments.iter_mut().find(|c| c.id == comment.id) {
Some(existing) => *existing = comment,
None => self.comments.push(comment),
}
self.sort();
Ok(())
}
pub fn delete(&mut self, id: &str) -> bool {
let before = self.comments.len();
self.comments.retain(|c| c.id != id);
let removed = self.comments.len() != before;
if removed {
self.sort();
}
removed
}
pub fn merge(&mut self, incoming: &ReviewDocument) {
for inc in &incoming.comments {
match self.comments.iter_mut().find(|c| c.id == inc.id) {
Some(existing) => {
if inc.updated_at > existing.updated_at {
*existing = inc.clone();
}
}
None => self.comments.push(inc.clone()),
}
}
self.sort();
}
pub fn sort(&mut self) {
self.comments.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn refinfo(name: &str, sha_char: char) -> RefInfo {
RefInfo { name: name.into(), sha: std::iter::repeat(sha_char).take(40).collect() }
}
fn doc() -> ReviewDocument {
ReviewDocument::new("repo".into(), refinfo("main", 'a'), refinfo("feat", 'b'))
}
fn comment(id: &str, file: &str, line: u32, updated: &str) -> Comment {
Comment {
id: id.into(),
file: file.into(),
side: Side::New,
line,
text: format!("note {id}"),
created_at: "2026-07-03T10:00:00Z".into(),
updated_at: updated.into(),
}
}
#[test]
fn side_serializes_as_camelcase_variant_name() {
assert_eq!(serde_json::to_value(Side::Old).unwrap(), serde_json::json!("Old"));
assert_eq!(serde_json::to_value(Side::New).unwrap(), serde_json::json!("New"));
}
#[test]
fn unknown_fields_are_rejected() {
let mut v = serde_json::to_value(comment("c1", "a.rs", 5, "t")).unwrap();
v.as_object_mut().unwrap().insert("sneaky".into(), serde_json::json!(true));
assert!(serde_json::from_value::<Comment>(v).is_err());
}
#[test]
fn upsert_inserts_then_replaces() {
let mut d = doc();
d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
let mut edited = comment("c1", "a.rs", 5, "2026-07-03T11:00:00Z");
edited.text = "edited".into();
d.upsert(edited).unwrap();
assert_eq!(d.comments.len(), 1);
assert_eq!(d.comments[0].text, "edited");
}
#[test]
fn upsert_rejects_invalid() {
let mut d = doc();
let mut bad = comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z");
bad.text = " ".into();
assert!(matches!(d.upsert(bad), Err(ModelError::InvalidComment(_))));
let mut bad = comment("c2", "a.rs", 5, "2026-07-03T10:00:00Z");
bad.line = 0;
assert!(d.upsert(bad).is_err());
assert!(d.comments.is_empty());
}
#[test]
fn delete_by_id() {
let mut d = doc();
d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
assert!(d.delete("c1"));
assert!(!d.delete("c1"));
assert!(d.comments.is_empty());
}
#[test]
fn ordering_is_file_line_side_created_id() {
let mut d = doc();
d.upsert(comment("z", "b.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
d.upsert(comment("y", "a.rs", 9, "2026-07-03T10:00:00Z")).unwrap();
d.upsert(comment("x", "a.rs", 2, "2026-07-03T10:00:00Z")).unwrap();
let mut old_side = comment("w", "a.rs", 2, "2026-07-03T10:00:00Z");
old_side.side = Side::Old;
d.upsert(old_side).unwrap();
let order: Vec<&str> = d.comments.iter().map(|c| c.id.as_str()).collect();
assert_eq!(order, vec!["w", "x", "y", "z"]); }
#[test]
fn merge_unions_and_newer_updated_at_wins() {
let mut ours = doc();
ours.upsert(comment("shared", "a.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
ours.upsert(comment("only-ours", "a.rs", 2, "2026-07-03T10:00:00Z")).unwrap();
let mut theirs = doc();
let mut newer = comment("shared", "a.rs", 1, "2026-07-03T12:00:00Z");
newer.text = "their newer edit".into();
theirs.upsert(newer).unwrap();
theirs.upsert(comment("only-theirs", "a.rs", 3, "2026-07-03T10:00:00Z")).unwrap();
ours.merge(&theirs);
assert_eq!(ours.comments.len(), 3);
let shared = ours.comments.iter().find(|c| c.id == "shared").unwrap();
assert_eq!(shared.text, "their newer edit");
}
#[test]
fn merge_older_incoming_loses() {
let mut ours = doc();
let mut mine = comment("shared", "a.rs", 1, "2026-07-03T12:00:00Z");
mine.text = "my newer edit".into();
ours.upsert(mine).unwrap();
let mut theirs = doc();
theirs.upsert(comment("shared", "a.rs", 1, "2026-07-03T10:00:00Z")).unwrap();
ours.merge(&theirs);
assert_eq!(ours.comments[0].text, "my newer edit");
}
#[test]
fn parse_rejects_newer_schema() {
let mut d = doc();
d.schema_version = SCHEMA_VERSION + 1;
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
ReviewDocument::parse(&json),
Err(ModelError::UnsupportedSchema { found: SCHEMA_VERSION + 1, supported: SCHEMA_VERSION })
);
}
#[test]
fn parse_rejects_garbage_and_invalid_comments() {
assert!(matches!(ReviewDocument::parse("not json"), Err(ModelError::Json(_))));
let mut d = doc();
d.comments.push(Comment {
id: "".into(),
file: "a.rs".into(),
side: Side::New,
line: 1,
text: "x".into(),
created_at: "t".into(),
updated_at: "t".into(),
});
let json = serde_json::to_string(&d).unwrap();
assert!(matches!(ReviewDocument::parse(&json), Err(ModelError::InvalidComment(_))));
}
#[test]
fn parse_normalizes_order() {
let mut d = doc();
d.comments.push(comment("b", "z.rs", 9, "2026-07-03T10:00:00Z"));
d.comments.push(comment("a", "a.rs", 1, "2026-07-03T10:00:00Z"));
let parsed = ReviewDocument::parse(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(parsed.comments[0].id, "a");
}
#[test]
fn canonical_json_roundtrips() {
let mut d = doc();
d.upsert(comment("c1", "a.rs", 5, "2026-07-03T10:00:00Z")).unwrap();
let json = d.to_json_pretty();
assert!(json.ends_with('\n'));
let back = ReviewDocument::parse(&json).unwrap();
assert_eq!(back, d);
}
}