Skip to main content

verbs/diff/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Types used by diff command output.
3
4use std::borrow::Cow;
5
6use objects::object::{FileMode, SemanticChange};
7use schemars::{JsonSchema, Schema, SchemaGenerator};
8use serde::{Serialize, Serializer};
9
10use crate::{
11    HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract, schema_for_report,
12};
13
14#[derive(Clone, Debug)]
15pub struct DiffReport {
16    pub output_kind: &'static str,
17    pub status: &'static str,
18    pub base: Option<&'static str>,
19    pub from_state: Option<String>,
20    pub to_state: Option<String>,
21    pub changed_path_count: usize,
22    pub stats: DiffStats,
23    pub changes: Vec<FileChange>,
24    pub semantic_changes: Option<Vec<SemanticChangeEntry>>,
25    pub context: Option<Vec<FileContextEntry>>,
26    pub broader_guidance: Option<Vec<ContextSnippet>>,
27    /// Rendered unified-diff text, targeting a clean `git apply`
28    /// round-trip (`patch(1)` compatibility is best-effort). Populated
29    /// whenever line-level hunks exist regardless of the `--patch` flag,
30    /// so JSON consumers always see a parseable diff.
31    pub patch: Option<String>,
32    pub worktree_mode: bool,
33}
34
35impl Serialize for DiffReport {
36    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
37    where
38        S: Serializer,
39    {
40        #[derive(Serialize)]
41        struct DiffReportView<'a> {
42            output_kind: &'static str,
43            status: &'static str,
44            #[serde(skip_serializing_if = "Option::is_none")]
45            base: Option<&'static str>,
46            from_state: &'a Option<String>,
47            to_state: &'a Option<String>,
48            changed_path_count: usize,
49            stats: &'a DiffStats,
50            changes: DiffChangesValue<'a>,
51            #[serde(skip_serializing_if = "Option::is_none")]
52            semantic_changes: Option<&'a Vec<SemanticChangeEntry>>,
53            #[serde(skip_serializing_if = "Option::is_none")]
54            context: Option<&'a Vec<FileContextEntry>>,
55            #[serde(skip_serializing_if = "Option::is_none")]
56            broader_guidance: Option<&'a Vec<ContextSnippet>>,
57            #[serde(skip_serializing_if = "Option::is_none")]
58            patch: Option<&'a String>,
59        }
60
61        DiffReportView {
62            output_kind: self.output_kind,
63            status: self.status,
64            base: self.base,
65            from_state: &self.from_state,
66            to_state: &self.to_state,
67            changed_path_count: self.changed_path_count,
68            stats: &self.stats,
69            changes: diff_changes_value(self),
70            semantic_changes: self.semantic_changes.as_ref(),
71            context: self.context.as_ref(),
72            broader_guidance: self.broader_guidance.as_ref(),
73            patch: self.patch.as_ref(),
74        }
75        .serialize(serializer)
76    }
77}
78
79#[derive(Serialize)]
80#[serde(untagged)]
81enum DiffChangesValue<'a> {
82    Grouped(DiffChangesGroupedRefs<'a>),
83    Flat(&'a [FileChange]),
84}
85
86#[derive(Serialize)]
87struct DiffChangesGroupedRefs<'a> {
88    modified: Vec<&'a FileChange>,
89    added: Vec<&'a FileChange>,
90    deleted: Vec<&'a FileChange>,
91}
92
93fn diff_changes_value(output: &DiffReport) -> DiffChangesValue<'_> {
94    if !output.worktree_mode {
95        return DiffChangesValue::Flat(&output.changes);
96    }
97
98    let mut grouped = DiffChangesGroupedRefs {
99        modified: Vec::new(),
100        added: Vec::new(),
101        deleted: Vec::new(),
102    };
103    for change in &output.changes {
104        match change.kind.as_str() {
105            "added" => grouped.added.push(change),
106            "deleted" => grouped.deleted.push(change),
107            _ => grouped.modified.push(change),
108        }
109    }
110    DiffChangesValue::Grouped(grouped)
111}
112
113impl DiffReport {
114    pub const CONTRACT: ReportContract = ReportContract {
115        schema_name: "diff",
116        machine_output_kind: MachineOutputKind::Json,
117        output_discriminator: Some(OutputDiscriminator {
118            field: "output_kind",
119            value: "diff",
120        }),
121        schema: schema_for_report::<DiffReport>,
122    };
123
124    pub fn new(
125        from_state: Option<String>,
126        to_state: Option<String>,
127        changes: Vec<FileChange>,
128        semantic_changes: Option<Vec<SemanticChangeEntry>>,
129        context: Option<Vec<FileContextEntry>>,
130        broader_guidance: Option<Vec<ContextSnippet>>,
131    ) -> Self {
132        let stats = DiffStats::from_changes(&changes, semantic_changes.as_deref());
133        Self::with_stats(
134            from_state,
135            to_state,
136            changes,
137            semantic_changes,
138            context,
139            broader_guidance,
140            stats,
141        )
142    }
143
144    pub fn with_stats(
145        from_state: Option<String>,
146        to_state: Option<String>,
147        changes: Vec<FileChange>,
148        semantic_changes: Option<Vec<SemanticChangeEntry>>,
149        context: Option<Vec<FileContextEntry>>,
150        broader_guidance: Option<Vec<ContextSnippet>>,
151        stats: DiffStats,
152    ) -> Self {
153        Self {
154            output_kind: "diff",
155            status: "completed",
156            base: None,
157            changed_path_count: changes.len(),
158            from_state,
159            to_state,
160            stats,
161            changes,
162            semantic_changes,
163            context,
164            broader_guidance,
165            patch: None,
166            worktree_mode: false,
167        }
168    }
169}
170
171impl JsonSchema for DiffReport {
172    fn schema_name() -> Cow<'static, str> {
173        Cow::Borrowed("DiffReport")
174    }
175
176    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
177        DiffReportSchema::json_schema(generator)
178    }
179}
180
181impl HeddleReport for DiffReport {
182    const CONTRACT: ReportContract = DiffReport::CONTRACT;
183}
184
185#[derive(Debug, JsonSchema)]
186#[allow(dead_code)]
187struct DiffReportSchema {
188    pub output_kind: String,
189    pub status: String,
190    pub base: Option<String>,
191    pub from_state: Option<String>,
192    pub to_state: Option<String>,
193    pub changed_path_count: usize,
194    pub stats: DiffStats,
195    pub changes: DiffChangesSchema,
196    pub semantic_changes: Option<Vec<SemanticChangeEntry>>,
197    pub context: Option<Vec<FileContextEntry>>,
198    pub broader_guidance: Option<Vec<ContextSnippet>>,
199    pub patch: Option<String>,
200}
201
202#[derive(Debug, JsonSchema)]
203#[allow(dead_code)]
204#[serde(untagged)]
205enum DiffChangesSchema {
206    Grouped(DiffChangesGroupedSchema),
207    Flat(Vec<FileChange>),
208}
209
210#[derive(Debug, JsonSchema)]
211#[allow(dead_code)]
212struct DiffChangesGroupedSchema {
213    pub modified: Vec<FileChange>,
214    pub added: Vec<FileChange>,
215    pub deleted: Vec<FileChange>,
216}
217
218#[derive(Clone, Debug, Default, Serialize, JsonSchema)]
219pub struct DiffStats {
220    pub files_changed: usize,
221    pub additions: usize,
222    pub modifications: usize,
223    pub deletions: usize,
224    pub renames: usize,
225}
226
227impl DiffStats {
228    pub fn from_changes(
229        changes: &[FileChange],
230        semantic_changes: Option<&[SemanticChangeEntry]>,
231    ) -> Self {
232        let mut stats = Self {
233            files_changed: changes.len(),
234            ..Self::default()
235        };
236        for change in changes {
237            // The `--stat` path runs the source-pair diff but drops the
238            // hunk vector immediately; `line_counts` carries the tally
239            // it computed before discarding. Prefer it so the summary
240            // stays line-accurate without the per-file RAM cost.
241            let counts = change
242                .line_counts
243                .clone()
244                .unwrap_or_else(|| change_line_counts(change.lines.as_deref()));
245            stats.additions += counts.added;
246            stats.modifications += counts.modified;
247            stats.deletions += counts.deleted;
248
249            let has_detail = change.line_counts.is_some() || change.lines.is_some();
250            match change.kind.as_str() {
251                "added" if !has_detail => stats.additions += 1,
252                "modified" if !has_detail => stats.modifications += 1,
253                "deleted" if !has_detail => stats.deletions += 1,
254                "renamed" => stats.renames += 1,
255                _ => {}
256            }
257        }
258        if let Some(semantic) = semantic_changes {
259            stats.renames += semantic
260                .iter()
261                .filter(|change| change.change_type == "file_renamed")
262                .count();
263        }
264        stats
265    }
266}
267
268#[derive(Clone, Debug, Default, Serialize, JsonSchema)]
269pub struct FileChange {
270    pub path: String,
271    pub kind: String,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub old_path: Option<String>,
274    /// Rename-detector score (0.0–1.0) for `kind == "renamed"` entries.
275    /// The patch renderer emits this as `similarity index N%` in the
276    /// extended diff header; without it `git apply` rejects rename
277    /// patches because there's no signal that `b/new` shouldn't already
278    /// exist on the target side.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub similarity_score: Option<f64>,
281    /// Git file mode of the content side, used by the patch renderer to
282    /// emit `new file mode <mode>` (adds) / `deleted file mode <mode>`
283    /// (deletes). `None` falls back to `100644` (a regular file). For an
284    /// executable the renderer emits `100755`; for a symlink `120000`
285    /// (and the hunk body is the link target, matching git's blob
286    /// representation of a symlink). For a `modified` change it is the
287    /// new (post-change) mode, paired with `old_mode`.
288    #[serde(skip)]
289    #[schemars(skip)]
290    pub mode: Option<FileMode>,
291    /// Old (pre-change) git file mode for a `modified` change. When it
292    /// differs from `mode` the renderer emits `old mode`/`new mode`
293    /// extended headers so a chmod (e.g. exec-bit flip) round-trips
294    /// through `git apply` even when the file's content is unchanged.
295    #[serde(skip)]
296    #[schemars(skip)]
297    pub old_mode: Option<FileMode>,
298    #[serde(skip)]
299    #[schemars(skip)]
300    pub binary: bool,
301    /// Raw symlink target bytes for each side of a change that touches a
302    /// symlink. Git stores a symlink's blob as the raw bytes of its target,
303    /// which on Unix need not be valid UTF-8 — so they can never flow through
304    /// `content_str()`/`diff_blobs` (which require UTF-8) or be binary-marked
305    /// (a `120000` placeholder-binary stanza is rejected by `git apply`).
306    /// When `Some`, the patch renderer reconstructs a byte-exact target hunk
307    /// from these bytes — the single byte-preserving symlink path across every
308    /// surface (add/delete/edit/rename) and both backends. `None` means the
309    /// change does not involve a symlink and renders as ordinary text.
310    #[serde(skip)]
311    #[schemars(skip)]
312    pub symlink: Option<SymlinkChange>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub lines: Option<Vec<LineDiff>>,
315    /// Pre-computed line tally for paths where we counted before
316    /// dropping the hunk vector (the `--stat` path). When present
317    /// `DiffStats` reads it instead of walking `lines`, so the
318    /// summary remains accurate without us retaining the hunks.
319    #[serde(skip)]
320    #[schemars(skip)]
321    pub line_counts: Option<LineCounts>,
322    /// Trailing-newline state and total line counts per side. The
323    /// patch renderer uses these to emit the unified-diff
324    /// `\ No newline at end of file` marker; `diff_blobs` strips
325    /// line terminators before the renderer ever sees them, so the
326    /// state must be plumbed alongside the hunk vector. Defaults
327    /// (`true` / `0`) mean "no marker needed", which is what
328    /// status-only fast paths fall back to.
329    #[serde(skip)]
330    #[schemars(skip)]
331    pub eol: FileEolState,
332}
333
334/// The raw symlink target bytes for each side of a symlink change. A
335/// symlink's git blob is exactly its target bytes (no trailing newline), so
336/// these are the authoritative content the patch renderer emits. `old` is
337/// `None` on an add, `new` is `None` on a delete, and both are `Some` on a
338/// target-edit or rename-with-edit. The bytes come from the same loaders the
339/// hunk path uses (`symlink_target_bytes` for the worktree, the stored blob
340/// for a tree side), so a non-UTF-8 target survives without lossy conversion.
341#[derive(Clone, Debug, Default)]
342pub struct SymlinkChange {
343    pub old: Option<Vec<u8>>,
344    pub new: Option<Vec<u8>>,
345}
346
347/// Trailing-newline state for both sides of a file change, plus the
348/// total line count per side. The patch renderer reads these to decide
349/// whether to emit `\ No newline at end of file` and where.
350#[derive(Clone, Copy, Debug)]
351pub struct FileEolState {
352    pub old_has_final_newline: bool,
353    pub new_has_final_newline: bool,
354    pub old_line_count: usize,
355    pub new_line_count: usize,
356}
357
358impl Default for FileEolState {
359    fn default() -> Self {
360        Self {
361            old_has_final_newline: true,
362            new_has_final_newline: true,
363            old_line_count: 0,
364            new_line_count: 0,
365        }
366    }
367}
368
369#[derive(Clone, Debug, Serialize, JsonSchema)]
370pub struct LineDiff {
371    pub prefix: String,
372    pub content: String,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub old_line: Option<usize>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub new_line: Option<usize>,
377}
378
379impl LineDiff {
380    pub fn new(prefix: impl Into<String>, content: impl Into<String>) -> Self {
381        Self {
382            prefix: prefix.into(),
383            content: content.into(),
384            old_line: None,
385            new_line: None,
386        }
387    }
388
389    pub fn with_lines(
390        prefix: impl Into<String>,
391        content: impl Into<String>,
392        old_line: Option<usize>,
393        new_line: Option<usize>,
394    ) -> Self {
395        Self {
396            prefix: prefix.into(),
397            content: content.into(),
398            old_line,
399            new_line,
400        }
401    }
402}
403
404#[derive(Clone, Debug, Serialize, JsonSchema)]
405pub struct FileContextEntry {
406    pub path: String,
407    pub annotations: Vec<ContextSnippet>,
408}
409
410#[derive(Clone, Debug, Serialize, JsonSchema)]
411pub struct ContextSnippet {
412    pub annotation_id: String,
413    pub kind: String,
414    pub content: String,
415    pub revision_count: usize,
416}
417
418#[derive(Clone, Debug, Default)]
419pub struct LineCounts {
420    pub added: usize,
421    pub modified: usize,
422    pub deleted: usize,
423}
424
425pub fn change_line_counts(lines: Option<&[LineDiff]>) -> LineCounts {
426    let mut counts = LineCounts::default();
427    let mut index = 0usize;
428    let lines = lines.unwrap_or_default();
429    while index < lines.len() {
430        let line = &lines[index];
431        if line.prefix == "-"
432            && let Some(next) = lines.get(index + 1)
433            && next.prefix == "+"
434            && should_render_modified_pair(&line.content, &next.content)
435        {
436            counts.modified += 1;
437            index += 2;
438            continue;
439        }
440        match line.prefix.as_str() {
441            "+" => counts.added += 1,
442            "-" => counts.deleted += 1,
443            _ => {}
444        }
445        index += 1;
446    }
447    counts
448}
449
450#[derive(Clone, Debug, Serialize, JsonSchema)]
451pub struct SemanticChangeEntry {
452    pub change_type: String,
453    pub description: String,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub path: Option<String>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub from_path: Option<String>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub to_path: Option<String>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub old_name: Option<String>,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub new_name: Option<String>,
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub importance: Option<String>,
466}
467
468impl From<SemanticChange> for SemanticChangeEntry {
469    fn from(change: SemanticChange) -> Self {
470        semantic_change_entry_fields(change).into()
471    }
472}
473
474impl From<SemanticChangeEntryFields> for SemanticChangeEntry {
475    fn from(fields: SemanticChangeEntryFields) -> Self {
476        Self {
477            change_type: fields.change_type,
478            description: fields.description,
479            path: fields.path,
480            from_path: fields.from_path,
481            to_path: fields.to_path,
482            old_name: fields.old_name,
483            new_name: fields.new_name,
484            importance: fields.importance,
485        }
486    }
487}
488
489pub fn should_render_modified_pair(removed: &str, added: &str) -> bool {
490    let prefix_len = common_prefix_boundary(removed, added);
491    let suffix_len = common_suffix_boundary(&removed[prefix_len..], &added[prefix_len..]);
492    let shared_len = prefix_len + suffix_len;
493    let max_len = removed.len().max(added.len());
494
495    // The `~` row is a review affordance for one logical line edit.
496    // If two adjacent delete/add lines barely overlap, keeping the
497    // normal two-line patch shape is clearer and avoids visually
498    // gluing unrelated code together.
499    shared_len >= 4 && shared_len * 3 >= max_len
500}
501
502fn common_prefix_boundary(left: &str, right: &str) -> usize {
503    let mut boundary = 0;
504    for ((left_index, left_char), (_, right_char)) in left.char_indices().zip(right.char_indices())
505    {
506        if left_char != right_char {
507            break;
508        }
509        boundary = left_index + left_char.len_utf8();
510    }
511    boundary
512}
513
514fn common_suffix_boundary(left_tail: &str, right_tail: &str) -> usize {
515    let mut boundary = 0;
516    for ((left_index, left_char), (_, right_char)) in left_tail
517        .char_indices()
518        .rev()
519        .zip(right_tail.char_indices().rev())
520    {
521        if left_char != right_char {
522            break;
523        }
524        boundary = left_tail.len() - left_index;
525    }
526    boundary
527}
528
529struct SemanticChangeEntryFields {
530    pub change_type: String,
531    pub description: String,
532    pub path: Option<String>,
533    pub from_path: Option<String>,
534    pub to_path: Option<String>,
535    pub old_name: Option<String>,
536    pub new_name: Option<String>,
537    pub importance: Option<String>,
538}
539
540fn semantic_change_entry_fields(change: SemanticChange) -> SemanticChangeEntryFields {
541    match change {
542        SemanticChange::FileAdded { path } => SemanticChangeEntryFields {
543            change_type: "file_added".to_string(),
544            description: format!("File added: {}", path.display()),
545            path: Some(path.display().to_string()),
546            from_path: None,
547            to_path: None,
548            old_name: None,
549            new_name: None,
550            importance: None,
551        },
552        SemanticChange::FileDeleted { path } => SemanticChangeEntryFields {
553            change_type: "file_deleted".to_string(),
554            description: format!("File deleted: {}", path.display()),
555            path: Some(path.display().to_string()),
556            from_path: None,
557            to_path: None,
558            old_name: None,
559            new_name: None,
560            importance: None,
561        },
562        SemanticChange::FileModified {
563            path,
564            classification,
565            importance,
566            ..
567        } => SemanticChangeEntryFields {
568            change_type: if let Some(cls) = classification {
569                format!("file_modified:{:?}", cls).to_lowercase()
570            } else {
571                "file_modified".to_string()
572            },
573            description: if let Some(cls) = classification {
574                format!("File modified ({:?}): {}", cls, path.display())
575            } else {
576                format!("File modified: {}", path.display())
577            },
578            path: Some(path.display().to_string()),
579            from_path: None,
580            to_path: None,
581            old_name: None,
582            new_name: None,
583            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
584        },
585        SemanticChange::FunctionDeleted {
586            file,
587            name,
588            importance,
589        } => SemanticChangeEntryFields {
590            change_type: "function_deleted".to_string(),
591            description: format!("Function deleted: {} in {}", name, file.display()),
592            path: Some(file.display().to_string()),
593            from_path: None,
594            to_path: None,
595            old_name: Some(name),
596            new_name: None,
597            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
598        },
599        SemanticChange::SignatureChanged {
600            file,
601            name,
602            old_signature,
603            new_signature,
604            importance,
605        } => SemanticChangeEntryFields {
606            change_type: "signature_changed".to_string(),
607            description: format!("Signature changed: {} in {}", name, file.display()),
608            path: Some(file.display().to_string()),
609            from_path: None,
610            to_path: None,
611            old_name: Some(old_signature),
612            new_name: Some(new_signature),
613            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
614        },
615        SemanticChange::FileRenamed { from, to } => SemanticChangeEntryFields {
616            change_type: "file_renamed".to_string(),
617            description: format!("File renamed: {} -> {}", from.display(), to.display()),
618            path: None,
619            from_path: Some(from.display().to_string()),
620            to_path: Some(to.display().to_string()),
621            old_name: None,
622            new_name: None,
623            importance: None,
624        },
625        SemanticChange::FunctionAdded {
626            file,
627            name,
628            importance,
629        } => SemanticChangeEntryFields {
630            change_type: "function_added".to_string(),
631            description: format!("Function added: {} in {}", name, file.display()),
632            path: Some(file.display().to_string()),
633            from_path: None,
634            to_path: None,
635            old_name: None,
636            new_name: Some(name),
637            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
638        },
639        SemanticChange::FunctionExtracted {
640            file,
641            name,
642            source_file,
643            source_name,
644            importance,
645        } => SemanticChangeEntryFields {
646            change_type: "function_extracted".to_string(),
647            description: if let Some(source_name) = &source_name {
648                let source_file = source_file.as_ref().unwrap_or(&file);
649                format!(
650                    "Function extracted: {} from {} in {}",
651                    name,
652                    source_name,
653                    source_file.display()
654                )
655            } else {
656                format!("Function extracted: {} in {}", name, file.display())
657            },
658            path: Some(file.display().to_string()),
659            from_path: source_file.map(|path| path.display().to_string()),
660            to_path: None,
661            old_name: source_name,
662            new_name: Some(name),
663            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
664        },
665        SemanticChange::FunctionRenamed {
666            file,
667            old_name,
668            new_name,
669            importance,
670        } => SemanticChangeEntryFields {
671            change_type: "function_renamed".to_string(),
672            description: format!(
673                "Function renamed: {} -> {} in {}",
674                old_name,
675                new_name,
676                file.display()
677            ),
678            path: Some(file.display().to_string()),
679            from_path: None,
680            to_path: None,
681            old_name: Some(old_name),
682            new_name: Some(new_name),
683            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
684        },
685        SemanticChange::FunctionModified {
686            file,
687            name,
688            importance,
689        } => SemanticChangeEntryFields {
690            change_type: "function_modified".to_string(),
691            description: format!("Function modified: {} in {}", name, file.display()),
692            path: Some(file.display().to_string()),
693            from_path: None,
694            to_path: None,
695            old_name: Some(name),
696            new_name: None,
697            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
698        },
699        SemanticChange::FunctionMoved {
700            file,
701            name,
702            old_start_line,
703            new_start_line,
704            importance,
705        } => SemanticChangeEntryFields {
706            change_type: "function_moved".to_string(),
707            description: format!(
708                "Function moved: {} in {} ({} -> {})",
709                name,
710                file.display(),
711                old_start_line + 1,
712                new_start_line + 1
713            ),
714            path: Some(file.display().to_string()),
715            from_path: None,
716            to_path: None,
717            old_name: Some(name),
718            new_name: None,
719            importance: importance.map(|i| format!("{i:?}").to_lowercase()),
720        },
721        SemanticChange::DependencyAdded { name, version } => SemanticChangeEntryFields {
722            change_type: "dependency_added".to_string(),
723            description: format!("Dependency added: {}@{}", name, version),
724            path: None,
725            from_path: None,
726            to_path: None,
727            old_name: None,
728            new_name: Some(name),
729            importance: None,
730        },
731        SemanticChange::DependencyRemoved { name } => SemanticChangeEntryFields {
732            change_type: "dependency_removed".to_string(),
733            description: format!("Dependency removed: {}", name),
734            path: None,
735            from_path: None,
736            to_path: None,
737            old_name: Some(name),
738            new_name: None,
739            importance: None,
740        },
741        SemanticChange::Custom { change_type, .. } => SemanticChangeEntryFields {
742            change_type: format!("custom:{}", change_type),
743            description: format!("Custom change: {}", change_type),
744            path: None,
745            from_path: None,
746            to_path: None,
747            old_name: None,
748            new_name: None,
749            importance: None,
750        },
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use std::path::PathBuf;
757
758    use objects::object::{ChangeImportance, SemanticChange};
759    use serde_json::Value;
760
761    use super::SemanticChangeEntry;
762
763    #[test]
764    fn semantic_change_json_uses_importance_field_not_old_name() {
765        let entry = SemanticChangeEntry::from(SemanticChange::FileModified {
766            path: PathBuf::from("src/lib.rs"),
767            classification: None,
768            importance: Some(ChangeImportance::Medium),
769            confidence: None,
770        });
771        let json = serde_json::to_value(entry).expect("semantic entry serializes");
772
773        assert_eq!(json["importance"], "medium");
774        assert!(json.get("old_name").is_none(), "{json}");
775    }
776
777    #[test]
778    fn semantic_rename_json_uses_path_fields() {
779        let entry = SemanticChangeEntry::from(SemanticChange::FileRenamed {
780            from: PathBuf::from("src/old.rs"),
781            to: PathBuf::from("src/new.rs"),
782        });
783        let json = serde_json::to_value(entry).expect("semantic rename serializes");
784
785        assert_eq!(json["change_type"], "file_renamed");
786        assert_eq!(json["from_path"], "src/old.rs");
787        assert_eq!(json["to_path"], "src/new.rs");
788        assert!(json.get("old_name").is_none(), "{json}");
789        assert!(matches!(json["from_path"], Value::String(_)));
790        assert!(matches!(json["to_path"], Value::String(_)));
791    }
792}