1use crate::parser::differ::{BinaryFileChange, DiffResult};
2use serde::ser::{Serialize, SerializeSeq, SerializeStruct, Serializer};
3use serde_json::Value;
4
5struct DiffJsonEnvelope<'a> {
6 result: &'a DiffResult,
7 binary_changes: &'a [BinaryFileChange],
8 include_binary_changes: bool,
9}
10
11impl Serialize for DiffJsonEnvelope<'_> {
12 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
13 where
14 S: Serializer,
15 {
16 let field_count = if self.include_binary_changes { 3 } else { 2 };
17 let mut fields = serializer.serialize_struct("DiffJsonEnvelope", field_count)?;
18 fields.serialize_field(
19 "summary",
20 &DiffJsonSummary {
21 result: self.result,
22 binary_count: self.binary_changes.len(),
23 include_binary_count: self.include_binary_changes,
24 },
25 )?;
26 fields.serialize_field("changes", &SemanticChangesJson(&self.result.changes))?;
27 if self.include_binary_changes {
28 fields.serialize_field("binaryChanges", &BinaryChangesJson(self.binary_changes))?;
29 }
30 fields.end()
31 }
32}
33
34struct DiffJsonSummary<'a> {
35 result: &'a DiffResult,
36 binary_count: usize,
37 include_binary_count: bool,
38}
39
40impl Serialize for DiffJsonSummary<'_> {
41 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
42 where
43 S: Serializer,
44 {
45 let field_count = if self.include_binary_count { 10 } else { 9 };
46 let mut fields = serializer.serialize_struct("DiffJsonSummary", field_count)?;
47 fields.serialize_field("fileCount", &(self.result.file_count + self.binary_count))?;
48 fields.serialize_field("added", &self.result.added_count)?;
49 fields.serialize_field("modified", &self.result.modified_count)?;
50 fields.serialize_field("deleted", &self.result.deleted_count)?;
51 fields.serialize_field("moved", &self.result.moved_count)?;
52 fields.serialize_field("renamed", &self.result.renamed_count)?;
53 fields.serialize_field("reordered", &self.result.reordered_count)?;
54 if self.include_binary_count {
55 fields.serialize_field("binary", &self.binary_count)?;
56 }
57 fields.serialize_field("orphan", &self.result.orphan_count)?;
58 fields.serialize_field("total", &(self.result.changes.len() + self.binary_count))?;
59 fields.end()
60 }
61}
62
63struct SemanticChangesJson<'a>(&'a [crate::model::change::SemanticChange]);
64
65impl Serialize for SemanticChangesJson<'_> {
66 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
67 where
68 S: Serializer,
69 {
70 let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
71 for change in self.0 {
72 sequence.serialize_element(&SemanticChangeJson(change))?;
73 }
74 sequence.end()
75 }
76}
77
78struct SemanticChangeJson<'a>(&'a crate::model::change::SemanticChange);
79
80impl Serialize for SemanticChangeJson<'_> {
81 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82 where
83 S: Serializer,
84 {
85 let change = self.0;
86 let mut fields = serializer.serialize_struct("SemanticChangeJson", 17)?;
87 fields.serialize_field("entityId", &change.entity_id)?;
88 fields.serialize_field("changeType", &change.change_type)?;
89 fields.serialize_field("entityType", &change.entity_type)?;
90 fields.serialize_field("entityName", &change.entity_name)?;
91 fields.serialize_field("startLine", &change.start_line)?;
92 fields.serialize_field("endLine", &change.end_line)?;
93 fields.serialize_field("oldStartLine", &change.old_start_line)?;
94 fields.serialize_field("oldEndLine", &change.old_end_line)?;
95 fields.serialize_field("oldEntityName", &change.old_entity_name)?;
96 fields.serialize_field("filePath", &change.file_path)?;
97 fields.serialize_field("oldFilePath", &change.old_file_path)?;
98 fields.serialize_field("oldParentId", &change.old_parent_id)?;
99 fields.serialize_field("beforeContent", &change.before_content)?;
100 fields.serialize_field("afterContent", &change.after_content)?;
101 fields.serialize_field("commitSha", &change.commit_sha)?;
102 fields.serialize_field("author", &change.author)?;
103 fields.serialize_field("structuralChange", &change.structural_change)?;
104 fields.end()
105 }
106}
107
108struct BinaryChangesJson<'a>(&'a [BinaryFileChange]);
109
110impl Serialize for BinaryChangesJson<'_> {
111 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
112 where
113 S: Serializer,
114 {
115 let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
116 for change in self.0 {
117 sequence.serialize_element(&BinaryChangeJson(change))?;
118 }
119 sequence.end()
120 }
121}
122
123struct BinaryChangeJson<'a>(&'a BinaryFileChange);
124
125impl Serialize for BinaryChangeJson<'_> {
126 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127 where
128 S: Serializer,
129 {
130 let change = self.0;
131 let mut fields = serializer.serialize_struct("BinaryChangeJson", 4)?;
132 fields.serialize_field("changeType", "binary")?;
133 fields.serialize_field("filePath", &change.file_path)?;
134 fields.serialize_field("oldFilePath", &change.old_file_path)?;
135 fields.serialize_field("fileStatus", &change.status)?;
136 fields.end()
137 }
138}
139
140fn estimate_json_capacity(result: &DiffResult, binary_changes: &[BinaryFileChange]) -> usize {
141 let content_len = result
142 .changes
143 .iter()
144 .map(|change| {
145 change.before_content.as_ref().map_or(0, String::len)
146 + change.after_content.as_ref().map_or(0, String::len)
147 })
148 .sum::<usize>();
149
150 256 + content_len + result.changes.len() * 256 + binary_changes.len() * 128
151}
152
153pub fn diff_json_value(result: &DiffResult) -> Value {
154 diff_json_value_inner(result, &[], false)
155}
156
157pub fn diff_json_value_with_binary_changes(
158 result: &DiffResult,
159 binary_changes: &[BinaryFileChange],
160) -> Value {
161 diff_json_value_inner(result, binary_changes, true)
162}
163
164fn diff_json_value_inner(
165 result: &DiffResult,
166 binary_changes: &[BinaryFileChange],
167 include_binary_changes: bool,
168) -> Value {
169 serde_json::to_value(DiffJsonEnvelope {
170 result,
171 binary_changes,
172 include_binary_changes,
173 })
174 .unwrap_or(Value::Null)
175}
176
177fn format_diff_json_inner(
178 result: &DiffResult,
179 binary_changes: &[BinaryFileChange],
180 include_binary_changes: bool,
181) -> String {
182 let mut output = Vec::with_capacity(estimate_json_capacity(result, binary_changes));
183 let envelope = DiffJsonEnvelope {
184 result,
185 binary_changes,
186 include_binary_changes,
187 };
188 if serde_json::to_writer(&mut output, &envelope).is_err() {
189 return String::new();
190 }
191 String::from_utf8(output).unwrap_or_default()
192}
193
194pub fn format_diff_json(result: &DiffResult) -> String {
195 format_diff_json_inner(result, &[], false)
196}
197
198pub fn format_diff_json_with_binary_changes(
199 result: &DiffResult,
200 binary_changes: &[BinaryFileChange],
201) -> String {
202 format_diff_json_inner(result, binary_changes, true)
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::model::change::{ChangeType, SemanticChange};
209 use serde_json::json;
210
211 #[test]
212 fn diff_json_value_preserves_public_schema() {
213 let result = DiffResult {
214 changes: vec![SemanticChange {
215 id: "internal-change-id".to_string(),
216 entity_id: "src/lib.rs::function::foo".to_string(),
217 change_type: ChangeType::Modified,
218 entity_type: "function".to_string(),
219 entity_name: "foo".to_string(),
220 entity_line: 12,
221 start_line: 12,
222 end_line: 12,
223 old_start_line: None,
224 old_end_line: None,
225 parent_name: Some("module".to_string()),
226 file_path: "src/lib.rs".to_string(),
227 old_entity_name: Some("bar".to_string()),
228 old_file_path: Some("src/old.rs".to_string()),
229 old_parent_id: Some("old-parent".to_string()),
230 before_content: Some("fn bar() {}".to_string()),
231 after_content: Some("fn foo() {}".to_string()),
232 commit_sha: Some("abc123".to_string()),
233 author: Some("Ada".to_string()),
234 timestamp: Some("2026-05-26".to_string()),
235 structural_change: Some(true),
236 }],
237 file_count: 1,
238 added_count: 0,
239 modified_count: 1,
240 deleted_count: 0,
241 moved_count: 0,
242 renamed_count: 0,
243 reordered_count: 0,
244 orphan_count: 0,
245 total_entities_before: 1,
246 total_entities_after: 1,
247 };
248
249 let value = diff_json_value(&result);
250 let formatted_value: Value =
251 serde_json::from_str(&format_diff_json(&result)).expect("format should be valid json");
252 assert_eq!(formatted_value, value);
253
254 assert_eq!(
255 value,
256 json!({
257 "summary": {
258 "fileCount": 1,
259 "added": 0,
260 "modified": 1,
261 "deleted": 0,
262 "moved": 0,
263 "renamed": 0,
264 "reordered": 0,
265 "orphan": 0,
266 "total": 1,
267 },
268 "changes": [{
269 "entityId": "src/lib.rs::function::foo",
270 "changeType": "modified",
271 "entityType": "function",
272 "entityName": "foo",
273 "startLine": 12,
274 "endLine": 12,
275 "oldStartLine": null,
276 "oldEndLine": null,
277 "oldEntityName": "bar",
278 "filePath": "src/lib.rs",
279 "oldFilePath": "src/old.rs",
280 "oldParentId": "old-parent",
281 "beforeContent": "fn bar() {}",
282 "afterContent": "fn foo() {}",
283 "commitSha": "abc123",
284 "author": "Ada",
285 "structuralChange": true,
286 }],
287 })
288 );
289 }
290
291 #[test]
292 fn diff_json_value_with_binary_changes_matches_cli_envelope() {
293 let result = DiffResult {
294 changes: Vec::new(),
295 file_count: 0,
296 added_count: 0,
297 modified_count: 0,
298 deleted_count: 0,
299 moved_count: 0,
300 renamed_count: 0,
301 reordered_count: 0,
302 orphan_count: 0,
303 total_entities_before: 0,
304 total_entities_after: 0,
305 };
306 let binary_changes = vec![BinaryFileChange {
307 file_path: "pic.png".to_string(),
308 status: crate::git::types::FileStatus::Modified,
309 old_file_path: None,
310 }];
311
312 let value = diff_json_value_with_binary_changes(&result, &binary_changes);
313 let formatted_value: Value = serde_json::from_str(&format_diff_json_with_binary_changes(
314 &result,
315 &binary_changes,
316 ))
317 .expect("format should be valid json");
318 assert_eq!(formatted_value, value);
319
320 assert_eq!(
321 value,
322 json!({
323 "summary": {
324 "fileCount": 1,
325 "added": 0,
326 "modified": 0,
327 "deleted": 0,
328 "moved": 0,
329 "renamed": 0,
330 "reordered": 0,
331 "binary": 1,
332 "orphan": 0,
333 "total": 1,
334 },
335 "changes": [],
336 "binaryChanges": [{
337 "changeType": "binary",
338 "filePath": "pic.png",
339 "oldFilePath": null,
340 "fileStatus": "modified",
341 }],
342 })
343 );
344 }
345}