Skip to main content

sem_core/format/
json.rs

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}