1use 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 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 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 #[serde(skip_serializing_if = "Option::is_none")]
274 pub similarity_score: Option<f64>,
275 #[serde(skip)]
283 #[schemars(skip)]
284 pub mode: Option<FileMode>,
285 #[serde(skip)]
290 #[schemars(skip)]
291 pub old_mode: Option<FileMode>,
292 #[serde(skip)]
293 #[schemars(skip)]
294 pub binary: bool,
295 #[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 #[serde(skip)]
314 #[schemars(skip)]
315 pub line_counts: Option<LineCounts>,
316 #[serde(skip)]
324 #[schemars(skip)]
325 pub eol: FileEolState,
326}
327
328#[derive(Clone, Debug, Default)]
336pub struct SymlinkChange {
337 pub old: Option<Vec<u8>>,
338 pub new: Option<Vec<u8>>,
339}
340
341#[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 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}