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