use serde::{Deserialize, Serialize};
use crate::pairing::Pairing;
pub(crate) mod project;
pub(crate) mod stream;
pub const VERSION: u32 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Annotation {
pub region_id: u32,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant)]
pub enum Event {
Start {
version: u32,
lhs: Snapshot,
rhs: Snapshot,
files: Vec<FileChange>,
},
File {
file: Pairing<FileRef>,
#[serde(default, skip_serializing_if = "Visibility::is_unset")]
visibility: Visibility,
#[serde(flatten)]
outcome: Outcome,
},
Annotations {
file: Pairing<FileRef>,
annotations: Vec<Annotation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<Problem>,
},
Complete {
succeeded: u32,
failed: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
aborted: Option<Problem>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Outcome {
Diff { diff: Diff },
Error { error: Problem },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Snapshot {
Revision {
rev: String,
},
Index,
WorkingTree,
EmptyTree,
Path {
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Problem {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileChange {
pub file: Pairing<FileRef>,
pub status: FileStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileStatus {
Added,
Deleted,
Modified,
Renamed,
Copied,
TypeChanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
pub path: String,
pub oid: String,
pub mode: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Visibility {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub collapsed: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
}
impl Visibility {
pub fn is_unset(&self) -> bool {
!self.collapsed && self.label.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Diff {
Text {
#[serde(flatten)]
sides: Pairing<Source>,
stats: Stats,
structural_changes: StructuralChanges,
},
Binary {
#[serde(flatten)]
sides: Pairing<BinaryRef>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Source {
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub syntax: Vec<SyntaxSpan>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub regions: Vec<Region>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryRef {
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyntaxSpan {
pub line: u32,
pub start_column: u32,
pub end_column: u32,
pub capture: String,
}
pub const ROOT: u32 = 0;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Region {
pub id: u32,
pub fold_state_id: u32,
#[serde(flatten)]
pub range: SourceRange,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Visibility::is_unset")]
pub visibility: Visibility,
#[serde(flatten)]
pub node: Node,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Node {
Leaf {
alignment_id: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
changed: Vec<Span>,
},
Fold { children: Vec<Region> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Span {
pub line: u32,
pub start_column: u32,
pub end_column: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRange {
pub start: SourcePos,
pub end: SourcePos,
}
impl SourceRange {
pub fn lines(&self) -> std::ops::Range<u32> {
let end = if self.end.column == 0 {
self.end.line
} else {
self.end.line + 1
};
self.start.line..end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourcePos {
pub line: u32,
pub column: u32,
}
pub type LineRange = [u32; 2];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuralChanges {
pub base: Vec<LineRange>,
pub head: Vec<LineRange>,
}
impl StructuralChanges {
pub fn counts(&self) -> LineCounts {
let count = |ranges: &[LineRange]| ranges.iter().map(|[start, end]| end - start).sum();
LineCounts {
added: count(&self.head),
removed: count(&self.base),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stats {
pub textual: LineCounts,
pub visible: LineCounts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback: Option<Problem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineCounts {
pub added: u32,
pub removed: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn pos(line: u32, column: u32) -> SourcePos {
SourcePos { line, column }
}
fn leaf(first: u32, index: u32, start: u32, end: u32, changed: Vec<Span>) -> Region {
Region {
id: first + 1 + index,
fold_state_id: 2 + index,
range: SourceRange {
start: pos(start, 0),
end: pos(end, 0),
},
tags: vec![],
visibility: Visibility::default(),
node: Node::Leaf {
alignment_id: index,
changed,
},
}
}
fn example_file() -> Event {
let file_ref = |oid: &str| FileRef {
path: "src/lib.rs".to_owned(),
oid: oid.to_owned(),
mode: "100644".to_owned(),
};
let side = |first: u32, text: &str, changed: Vec<Span>| Source {
text: text.to_owned(),
syntax: vec![],
regions: vec![Region {
id: first,
fold_state_id: 1,
range: SourceRange {
start: pos(0, 0),
end: pos(3, 0),
},
tags: vec!["deleted-bodies:function".to_owned()],
visibility: Visibility::default(),
node: Node::Fold {
children: vec![
leaf(first, 0, 0, 1, vec![]),
leaf(first, 1, 1, 2, changed),
leaf(first, 2, 2, 3, vec![]),
],
},
}],
};
Event::File {
file: Pairing::Both {
lhs: file_ref("3b18e5"),
rhs: file_ref("9be2c1"),
},
visibility: Visibility::default(),
outcome: Outcome::Diff {
diff: Diff::Text {
sides: Pairing::Both {
lhs: side(1, "fn f() {\n 1\n}\n", vec![]),
rhs: side(
5,
"fn f() {\n 1 + 2\n}\n",
vec![Span {
line: 1,
start_column: 5,
end_column: 9,
}],
),
},
structural_changes: StructuralChanges {
base: vec![],
head: vec![[1, 2]],
},
stats: Stats {
textual: LineCounts {
added: 1,
removed: 1,
},
visible: LineCounts {
added: 1,
removed: 1,
},
fallback: None,
},
},
},
}
}
#[test]
fn file_record_serializes_to_the_documented_shape() {
let region = |first: u32, changed: serde_json::Value| {
let mut middle = json!({"id": first + 2, "fold_state_id": 3, "kind": "leaf", "alignment_id": 1, "start": {"line": 1, "column": 0}, "end": {"line": 2, "column": 0}});
if let Some(spans) = changed.as_array().filter(|spans| !spans.is_empty()) {
middle["changed"] = json!(spans);
}
json!({
"id": first, "fold_state_id": 1, "kind": "fold", "tags": ["deleted-bodies:function"],
"start": {"line": 0, "column": 0}, "end": {"line": 3, "column": 0},
"children": [
{"id": first + 1, "fold_state_id": 2, "kind": "leaf", "alignment_id": 0, "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}},
middle,
{"id": first + 3, "fold_state_id": 4, "kind": "leaf", "alignment_id": 2, "start": {"line": 2, "column": 0}, "end": {"line": 3, "column": 0}},
],
})
};
let expected = json!({
"type": "file",
"file": {
"lhs": {"path": "src/lib.rs", "oid": "3b18e5", "mode": "100644"},
"rhs": {"path": "src/lib.rs", "oid": "9be2c1", "mode": "100644"},
},
"diff": {
"type": "text",
"lhs": {"text": "fn f() {\n 1\n}\n", "regions": [region(1, json!([]))]},
"rhs": {"text": "fn f() {\n 1 + 2\n}\n",
"regions": [region(5, json!([{"line": 1, "start_column": 5, "end_column": 9}]))]},
"stats": {"textual": {"added": 1, "removed": 1}, "visible": {"added": 1, "removed": 1}},
"structural_changes": {"base": [], "head": [[1, 2]]},
},
});
assert_eq!(serde_json::to_value(example_file()).unwrap(), expected);
}
#[test]
fn every_event_round_trips() {
let events = vec![
Event::Start {
version: VERSION,
lhs: Snapshot::Revision {
rev: "main".to_owned(),
},
rhs: Snapshot::WorkingTree,
files: vec![FileChange {
file: Pairing::RightOnly {
rhs: FileRef {
path: "gen/schema.json".to_owned(),
oid: "0e1f2a".to_owned(),
mode: "100644".to_owned(),
},
},
status: FileStatus::Added,
tags: vec!["generated".to_owned()],
}],
},
example_file(),
Event::File {
file: Pairing::LeftOnly {
lhs: FileRef {
path: "old.bin".to_owned(),
oid: "aaaaaa".to_owned(),
mode: "100644".to_owned(),
},
},
visibility: Visibility {
collapsed: true,
label: "Deleted file · hidden by default".to_owned(),
},
outcome: Outcome::Diff {
diff: Diff::Binary {
sides: Pairing::LeftOnly {
lhs: BinaryRef { size: 4096 },
},
},
},
},
Event::File {
file: Pairing::Both {
lhs: FileRef {
path: "a.txt".to_owned(),
oid: "bbbbbb".to_owned(),
mode: "100644".to_owned(),
},
rhs: FileRef {
path: "a.txt".to_owned(),
oid: "cccccc".to_owned(),
mode: "100644".to_owned(),
},
},
visibility: Visibility::default(),
outcome: Outcome::Error {
error: Problem {
code: "not_utf8".to_owned(),
message: "a.txt is not valid UTF-8".to_owned(),
},
},
},
Event::Complete {
succeeded: 2,
failed: 1,
aborted: Some(Problem {
code: "mutation_failed".to_owned(),
message: "mutation summarize: summarizer: gemini-3.8-flash: HTTP 503 Service Unavailable after 4 attempts".to_owned(),
}),
},
];
for event in events {
let line = serde_json::to_string(&event).unwrap();
assert_eq!(
serde_json::from_str::<Event>(&line).unwrap(),
event,
"{line}"
);
}
}
#[test]
fn defaults_are_omitted() {
let line = serde_json::to_string(&example_file()).unwrap();
for absent in [
"null",
"visibility",
"collapsed",
"syntax",
"fallback",
"\"changed\":[]",
] {
assert!(!line.contains(absent), "{absent} appeared in {line}");
}
let untagged = FileChange {
file: Pairing::RightOnly {
rhs: FileRef {
path: "a.rs".to_owned(),
oid: "0e1f2a".to_owned(),
mode: "100644".to_owned(),
},
},
status: FileStatus::Added,
tags: Vec::new(),
};
let line = serde_json::to_string(&untagged).unwrap();
assert!(!line.contains("tags"), "{line}");
}
}