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 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 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 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 #[serde(skip_serializing_if = "Option::is_none")]
280 pub similarity_score: Option<f64>,
281 #[serde(skip)]
289 #[schemars(skip)]
290 pub mode: Option<FileMode>,
291 #[serde(skip)]
296 #[schemars(skip)]
297 pub old_mode: Option<FileMode>,
298 #[serde(skip)]
299 #[schemars(skip)]
300 pub binary: bool,
301 #[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 #[serde(skip)]
320 #[schemars(skip)]
321 pub line_counts: Option<LineCounts>,
322 #[serde(skip)]
330 #[schemars(skip)]
331 pub eol: FileEolState,
332}
333
334#[derive(Clone, Debug, Default)]
342pub struct SymlinkChange {
343 pub old: Option<Vec<u8>>,
344 pub new: Option<Vec<u8>>,
345}
346
347#[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 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}