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 enum Verdict {
Approved {
at: String,
},
ChangesRequired {
at: String,
},
}
impl Verdict {
pub fn at(&self) -> &str {
match self {
Verdict::Approved { at } | Verdict::ChangesRequired { at } => at,
}
}
pub fn label(&self) -> &'static str {
match self {
Verdict::Approved { .. } => "approved",
Verdict::ChangesRequired { .. } => "changes required",
}
}
fn validate(&self) -> Result<(), ModelError> {
if self.at().trim().is_empty() {
return Err(ModelError::InvalidVerdict("at must be non-empty".to_string()));
}
Ok(())
}
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<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")?;
if let Some(at) = &self.resolved_at {
need(!at.trim().is_empty(), "resolved_at, when present, 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<Verdict>,
}
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(),
verdict: None,
}
}
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()?;
}
if let Some(v) = &doc.verdict {
v.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 set_verdict(&mut self, verdict: Option<Verdict>) -> Result<(), ModelError> {
if let Some(v) = &verdict {
v.validate()?;
}
self.verdict = verdict;
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()),
}
}
if let Some(inc) = &incoming.verdict {
if self.verdict.as_ref().map_or(true, |cur| inc.at() > cur.at()) {
self.verdict = Some(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(),
resolved_at: None,
}
}
fn verdict_approved(at: &str) -> Verdict {
Verdict::Approved { at: at.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(),
resolved_at: None,
});
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 verdict_serializes_as_single_key_union_with_timestamp() {
assert_eq!(
serde_json::to_value(verdict_approved("2026-07-13T10:00:00Z")).unwrap(),
serde_json::json!({ "Approved": { "at": "2026-07-13T10:00:00Z" } })
);
assert_eq!(
serde_json::to_value(Verdict::ChangesRequired { at: "t".into() }).unwrap(),
serde_json::json!({ "ChangesRequired": { "at": "t" } })
);
}
#[test]
fn set_verdict_validates_and_clears() {
let mut d = doc();
assert!(matches!(d.set_verdict(Some(verdict_approved(" "))), Err(ModelError::InvalidVerdict(_))));
assert_eq!(d.verdict, None, "a rejected verdict leaves the document untouched");
d.set_verdict(Some(verdict_approved("2026-07-13T10:00:00Z"))).unwrap();
assert_eq!(d.verdict.as_ref().unwrap().label(), "approved");
d.set_verdict(None).unwrap();
assert_eq!(d.verdict, None, "clearing returns the review to in-progress");
}
#[test]
fn merge_verdict_later_decision_wins() {
let mut ours = doc();
let mut theirs = doc();
theirs.set_verdict(Some(verdict_approved("2026-07-13T10:00:00Z"))).unwrap();
ours.merge(&theirs);
assert_eq!(ours.verdict, theirs.verdict);
let mut newer = doc();
newer.set_verdict(Some(Verdict::ChangesRequired { at: "2026-07-13T12:00:00Z".into() })).unwrap();
ours.merge(&newer);
assert_eq!(ours.verdict.as_ref().unwrap().label(), "changes required");
ours.merge(&theirs);
assert_eq!(ours.verdict, newer.verdict);
ours.merge(&doc());
assert_eq!(ours.verdict, newer.verdict);
}
#[test]
fn resolved_comments_validate_and_roundtrip() {
let mut d = doc();
let mut resolved = comment("c1", "a.rs", 5, "2026-07-13T11:00:00Z");
resolved.resolved_at = Some("2026-07-13T11:00:00Z".into());
d.upsert(resolved).unwrap();
let back = ReviewDocument::parse(&d.to_json_pretty()).unwrap();
assert_eq!(back.comments[0].resolved_at.as_deref(), Some("2026-07-13T11:00:00Z"));
let mut blank = comment("c2", "a.rs", 6, "t");
blank.resolved_at = Some(" ".into());
assert!(matches!(d.upsert(blank), Err(ModelError::InvalidComment(_))));
let open = comment("c3", "a.rs", 7, "t");
d.upsert(open).unwrap();
assert!(!serde_json::to_string(&d.comments[1]).unwrap().contains("resolved_at"));
}
#[test]
fn v1_documents_parse_with_the_new_fields_defaulted() {
let v1 = r#"{
"schema_version": 1, "tool": "packdiff", "repo": "repo",
"base": { "name": "main", "sha": "a" }, "head": { "name": "feat", "sha": "b" },
"comments": [ { "id": "c1", "file": "a.rs", "side": "New", "line": 5,
"text": "note", "created_at": "t", "updated_at": "t" } ]
}"#;
let doc = ReviewDocument::parse(v1).unwrap();
assert_eq!(doc.verdict, None);
assert_eq!(doc.comments[0].resolved_at, None);
}
#[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);
}
}