1use crate::control::ControlMetricsExt as _;
15use crate::decoration::{DecoratedExt as _, Decoration};
16use crate::motion::{acknowledged_state, disclosure_progress};
17use crate::surface::CardFrameExt as _;
18use crate::{
19 handlers::SharedHandler,
20 status::{StatusBadge, StatusTone},
21 surface::{icon_button_turned, meta},
22 theme::SemanticStyledExt as _,
23};
24use gpui::{
25 App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
26 ParentElement as _, Rems, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
27 StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
28};
29use gpui_component::{
30 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _,
31 button::{Button, ButtonVariants as _},
32 clipboard::Clipboard,
33 h_flex,
34 text::{TextView, TextViewStyle},
35 v_flex,
36};
37use std::rc::Rc;
38
39const LINE_HEIGHT: Rems = Rems(1.5);
41
42const NUMBER_GUTTER_WIDTH: Rems = Rems(2.25);
46
47const SIGN_GUTTER_WIDTH: Rems = Rems(0.75);
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum DiffLineKind {
53 Context,
55 Added,
57 Removed,
59}
60
61impl DiffLineKind {
62 fn sign(self) -> &'static str {
63 match self {
64 Self::Context => " ",
65 Self::Added => "+",
66 Self::Removed => "-",
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DiffLine {
74 kind: DiffLineKind,
75 old_number: Option<u32>,
76 new_number: Option<u32>,
77 text: SharedString,
78}
79
80impl DiffLine {
81 pub fn new(kind: DiffLineKind, text: impl Into<SharedString>) -> Self {
83 Self {
84 kind,
85 old_number: None,
86 new_number: None,
87 text: text.into(),
88 }
89 }
90
91 pub fn old_number(mut self, number: u32) -> Self {
93 self.old_number = Some(number);
94 self
95 }
96
97 pub fn new_number(mut self, number: u32) -> Self {
99 self.new_number = Some(number);
100 self
101 }
102
103 pub fn kind(&self) -> DiffLineKind {
105 self.kind
106 }
107
108 pub fn text(&self) -> &SharedString {
110 &self.text
111 }
112
113 pub fn old_line(&self) -> Option<u32> {
115 self.old_number
116 }
117
118 pub fn new_line(&self) -> Option<u32> {
120 self.new_number
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
126pub enum HunkReview {
127 #[default]
129 Pending,
130 Accepted,
132 Rejected,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct DiffHunk {
139 header: SharedString,
140 lines: Vec<DiffLine>,
141 review: HunkReview,
142}
143
144impl DiffHunk {
145 pub fn new(header: impl Into<SharedString>) -> Self {
147 Self {
148 header: header.into(),
149 lines: Vec::new(),
150 review: HunkReview::Pending,
151 }
152 }
153
154 pub fn lines(mut self, lines: impl IntoIterator<Item = DiffLine>) -> Self {
156 self.lines = lines.into_iter().collect();
157 self
158 }
159
160 pub fn review(mut self, review: HunkReview) -> Self {
162 self.review = review;
163 self
164 }
165
166 pub fn header(&self) -> &SharedString {
168 &self.header
169 }
170
171 pub fn line_refs(&self) -> &[DiffLine] {
173 &self.lines
174 }
175
176 pub fn review_state(&self) -> HunkReview {
178 self.review
179 }
180
181 pub fn stats(&self) -> DiffStats {
183 let mut stats = DiffStats::default();
184 for line in &self.lines {
185 match line.kind {
186 DiffLineKind::Added => stats.added += 1,
187 DiffLineKind::Removed => stats.removed += 1,
188 DiffLineKind::Context => {}
189 }
190 }
191 stats
192 }
193
194 fn code(&self) -> String {
195 let mut code = String::new();
196 for line in &self.lines {
197 code.push_str(&line.text);
198 code.push('\n');
199 }
200 code
201 }
202
203 fn patch_text(&self) -> String {
204 let mut patch = String::new();
205 patch.push_str(&self.header);
206 patch.push('\n');
207 for line in &self.lines {
208 patch.push_str(line.kind.sign());
209 patch.push_str(&line.text);
210 patch.push('\n');
211 }
212 patch
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
218pub struct DiffStats {
219 pub added: usize,
221 pub removed: usize,
223}
224
225impl DiffStats {
226 pub fn label(self) -> String {
228 format!("+{} \u{2212}{}", self.added, self.removed)
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct DiffFile {
235 path: SharedString,
236 old_path: Option<SharedString>,
237 language: Option<SharedString>,
238 hunks: Vec<DiffHunk>,
239}
240
241impl DiffFile {
242 pub fn new(path: impl Into<SharedString>) -> Self {
244 let path = path.into();
245 Self {
246 language: language_for(&path).map(SharedString::from),
247 path,
248 old_path: None,
249 hunks: Vec::new(),
250 }
251 }
252
253 pub fn renamed_from(mut self, old_path: impl Into<SharedString>) -> Self {
255 self.old_path = Some(old_path.into());
256 self
257 }
258
259 pub fn language(mut self, language: impl Into<SharedString>) -> Self {
261 self.language = Some(language.into());
262 self
263 }
264
265 pub fn hunks(mut self, hunks: impl IntoIterator<Item = DiffHunk>) -> Self {
267 self.hunks = hunks.into_iter().collect();
268 self
269 }
270
271 pub fn path(&self) -> &SharedString {
273 &self.path
274 }
275
276 pub fn old_path(&self) -> Option<&SharedString> {
278 self.old_path.as_ref()
279 }
280
281 pub fn language_name(&self) -> Option<&SharedString> {
283 self.language.as_ref()
284 }
285
286 pub fn hunk_refs(&self) -> &[DiffHunk] {
288 &self.hunks
289 }
290
291 pub fn stats(&self) -> DiffStats {
293 self.hunks.iter().fold(DiffStats::default(), |acc, hunk| {
294 let stats = hunk.stats();
295 DiffStats {
296 added: acc.added + stats.added,
297 removed: acc.removed + stats.removed,
298 }
299 })
300 }
301
302 pub fn accessibility_label(&self) -> String {
304 format!("Diff of {}, {}", self.path, self.stats().label())
305 }
306
307 pub fn to_unified(&self) -> String {
309 let old = self.old_path.as_ref().unwrap_or(&self.path);
310 let mut patch = format!("--- a/{old}\n+++ b/{}\n", self.path);
311 for hunk in &self.hunks {
312 patch.push_str(&hunk.patch_text());
313 }
314 patch
315 }
316
317 pub fn from_unified(patch: &str) -> Vec<DiffFile> {
321 let mut files: Vec<DiffFile> = Vec::new();
322 let mut pending_old: Option<String> = None;
323 let mut old_line = 0u32;
324 let mut new_line = 0u32;
325
326 for raw in patch.lines() {
327 if let Some(rest) = raw.strip_prefix("--- ") {
328 pending_old = Some(strip_prefix_path(rest));
329 continue;
330 }
331 if let Some(rest) = raw.strip_prefix("+++ ") {
332 let path = strip_prefix_path(rest);
333 let mut file = DiffFile::new(path.clone());
334 if let Some(old) = pending_old.take()
335 && old != path
336 && old != "/dev/null"
337 {
338 file = file.renamed_from(old);
339 }
340 files.push(file);
341 continue;
342 }
343 if let Some(rest) = raw.strip_prefix("@@") {
344 let Some(file) = files.last_mut() else {
345 continue;
346 };
347 let (old_start, new_start) = parse_hunk_ranges(rest);
348 old_line = old_start;
349 new_line = new_start;
350 file.hunks.push(DiffHunk::new(raw.to_owned()));
351 continue;
352 }
353 let Some(hunk) = files.last_mut().and_then(|file| file.hunks.last_mut()) else {
354 continue;
355 };
356 let (kind, text) = match raw.chars().next() {
357 Some('+') => (DiffLineKind::Added, &raw[1..]),
358 Some('-') => (DiffLineKind::Removed, &raw[1..]),
359 Some(' ') => (DiffLineKind::Context, &raw[1..]),
360 Some('\\') => (DiffLineKind::Context, raw),
361 None => (DiffLineKind::Context, ""),
362 _ => continue,
363 };
364 let mut line = DiffLine::new(kind, text.to_owned());
365 match kind {
366 DiffLineKind::Context => {
367 line = line.old_number(old_line).new_number(new_line);
368 old_line += 1;
369 new_line += 1;
370 }
371 DiffLineKind::Added => {
372 line = line.new_number(new_line);
373 new_line += 1;
374 }
375 DiffLineKind::Removed => {
376 line = line.old_number(old_line);
377 old_line += 1;
378 }
379 }
380 hunk.lines.push(line);
381 }
382 files
383 }
384}
385
386fn strip_prefix_path(rest: &str) -> String {
387 let path = rest.split('\t').next().unwrap_or(rest).trim();
388 path.strip_prefix("a/")
389 .or_else(|| path.strip_prefix("b/"))
390 .unwrap_or(path)
391 .to_owned()
392}
393
394fn parse_hunk_ranges(rest: &str) -> (u32, u32) {
395 let mut old_start = 1;
396 let mut new_start = 1;
397 for token in rest.split_whitespace().take(2) {
398 let (sign, range) = token.split_at(1);
399 let start = range
400 .split(',')
401 .next()
402 .and_then(|start| start.parse::<u32>().ok())
403 .unwrap_or(1);
404 match sign {
405 "-" => old_start = start,
406 "+" => new_start = start,
407 _ => {}
408 }
409 }
410 (old_start, new_start)
411}
412
413fn language_for(path: &str) -> Option<&'static str> {
414 let extension = path.rsplit_once('.').map(|(_, ext)| ext)?;
415 Some(match extension {
416 "rs" => "rust",
417 "ts" | "tsx" => "typescript",
418 "js" | "jsx" | "mjs" => "javascript",
419 "py" => "python",
420 "go" => "go",
421 "toml" => "toml",
422 "json" => "json",
423 "md" => "markdown",
424 "sh" | "zsh" | "bash" => "bash",
425 "css" => "css",
426 "html" => "html",
427 "yaml" | "yml" => "yaml",
428 "sql" => "sql",
429 "c" | "h" => "c",
430 "cpp" | "hpp" | "cc" => "cpp",
431 "java" => "java",
432 "kt" => "kotlin",
433 "swift" => "swift",
434 "rb" => "ruby",
435 _ => return None,
436 })
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum CodeDiffEvent {
442 Toggled {
444 path: SharedString,
446 },
447 HunkAccepted {
449 path: SharedString,
451 hunk: usize,
453 },
454 HunkRejected {
456 path: SharedString,
458 hunk: usize,
460 },
461}
462
463#[derive(IntoElement)]
479pub struct CodeDiff {
480 id: ElementId,
481 style: StyleRefinement,
482 decoration: Decoration,
483 file: DiffFile,
484 open: bool,
485 reviewable: bool,
486 on_event: Option<SharedHandler<CodeDiffEvent>>,
487}
488
489impl CodeDiff {
490 pub fn decoration(mut self, decoration: Decoration) -> Self {
496 self.decoration = decoration;
497 self
498 }
499
500 pub fn new(id: impl Into<ElementId>, file: &DiffFile) -> Self {
502 Self {
503 id: id.into(),
504 style: StyleRefinement::default(),
505 decoration: Decoration::default(),
506 file: file.clone(),
507 open: true,
508 reviewable: false,
509 on_event: None,
510 }
511 }
512
513 pub fn open(mut self, open: bool) -> Self {
516 self.open = open;
517 self
518 }
519
520 pub fn reviewable(mut self, reviewable: bool) -> Self {
522 self.reviewable = reviewable;
523 self
524 }
525
526 pub fn on_event(
528 mut self,
529 handler: impl Fn(&CodeDiffEvent, &mut Window, &mut App) + 'static,
530 ) -> Self {
531 self.on_event = Some(Rc::new(handler));
532 self
533 }
534}
535
536impl Styled for CodeDiff {
537 fn style(&mut self) -> &mut StyleRefinement {
538 &mut self.style
539 }
540}
541
542impl RenderOnce for CodeDiff {
543 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
544 let tokens = cx.theme().semantic_tokens();
545 let mut decoration = std::mem::take(&mut self.decoration);
548 let file = self.file;
549 let handler = self.on_event;
550 let path = file.path.clone();
551 let path_debug = path.clone();
552 let stats = file.stats();
553 let label: SharedString = file.accessibility_label().into();
554 let language: SharedString = file
555 .language
556 .clone()
557 .unwrap_or_else(|| SharedString::from("text"));
558 let root_id = self.id.clone();
559 let disclosure =
560 disclosure_progress((root_id.clone(), "disclosure"), self.open, window, cx);
561 let disclosure_opacity =
562 crate::motion::disclosure_fade((root_id.clone(), "disclosure"), self.open, window, cx);
563 let showing = self.open;
564
565 let toggle = handler.clone().map(|handler| {
566 let toggle_path = path.clone();
567 let toggle_debug = path.clone();
568 icon_button_turned(
573 (root_id.clone(), "toggle"),
574 IconName::ChevronRight,
575 0.25 * disclosure,
576 if self.open {
577 "Collapse diff"
578 } else {
579 "Expand diff"
580 },
581 window,
582 cx,
583 )
584 .debug_selector(move || format!("code-diff-toggle-{toggle_debug}"))
585 .on_click(move |_: &ClickEvent, window, cx| {
586 handler(
587 &CodeDiffEvent::Toggled {
588 path: toggle_path.clone(),
589 },
590 window,
591 cx,
592 )
593 })
594 });
595
596 let header = h_flex()
597 .items_center()
598 .gap(tokens.spacing.sm)
599 .px(tokens.spacing.md)
600 .py(tokens.spacing.xs)
601 .bg(cx.theme().muted.opacity(0.35))
602 .when(showing, |this| {
603 this.border_b_1().border_color(cx.theme().border)
604 })
605 .children(toggle)
606 .child(
607 Icon::new(IconName::File)
608 .xsmall()
609 .text_color(cx.theme().muted_foreground),
610 )
611 .child(
612 div()
613 .min_w_0()
614 .truncate()
615 .text_token(tokens.typography.sm)
616 .font_family(cx.theme().mono_font_family.clone())
617 .text_color(cx.theme().foreground)
618 .child(path.clone()),
619 )
620 .when_some(file.old_path.clone(), |this, old_path| {
621 this.child(meta(format!("renamed from {old_path}"), cx))
622 })
623 .child(div().flex_1())
624 .child(
625 h_flex()
626 .flex_none()
627 .gap(tokens.spacing.xxs)
628 .text_token(tokens.typography.xs)
629 .font_family(cx.theme().mono_font_family.clone())
630 .child(
631 div()
632 .text_color(cx.theme().success)
633 .child(format!("+{}", stats.added)),
634 )
635 .child(
636 div()
637 .text_color(cx.theme().danger)
638 .child(format!("\u{2212}{}", stats.removed)),
639 ),
640 )
641 .child(Clipboard::new((root_id.clone(), "copy")).value(file.to_unified()));
642
643 let mut hunks = Vec::with_capacity(file.hunks.len());
644 if showing {
645 for (index, hunk) in file.hunks.iter().enumerate() {
646 hunks.push(render_hunk(
647 &root_id,
648 &path,
649 index,
650 hunk,
651 &language,
652 self.reviewable,
653 handler.clone(),
654 window,
655 cx,
656 ));
657 }
658 }
659
660 v_flex()
661 .id(self.id)
662 .role(Role::Group)
663 .aria_label(label)
664 .debug_selector(move || format!("code-diff-{path_debug}"))
665 .w_full()
666 .min_w_0()
667 .card_frame(cx)
668 .decoration_under(&mut decoration)
669 .overflow_hidden()
670 .child(header)
671 .when(showing, |this| {
672 this.child(
674 div()
675 .w_full()
676 .min_w_0()
677 .opacity(disclosure_opacity)
678 .top(tokens.spacing.xxs * (1.0 - disclosure) * crate::motion::travel(cx))
679 .children(hunks),
680 )
681 })
682 .decoration_over(&mut decoration)
683 .refine_style(&self.style)
684 }
685}
686
687#[allow(clippy::too_many_arguments)]
688fn render_hunk(
689 root_id: &ElementId,
690 path: &SharedString,
691 index: usize,
692 hunk: &DiffHunk,
693 language: &SharedString,
694 reviewable: bool,
695 handler: Option<SharedHandler<CodeDiffEvent>>,
696 window: &mut Window,
697 cx: &mut App,
698) -> gpui::AnyElement {
699 let tokens = cx.theme().semantic_tokens();
700 let hunk_id = ElementId::from((root_id.clone(), format!("hunk-{index}")));
701 let hunk_debug = format!("{path}-{index}");
702 let gutter_debug = hunk_debug.clone();
703 let text_debug = hunk_debug.clone();
704 let stats = hunk.stats();
705 let hunk_label: SharedString =
706 format!("Hunk {} of {}: {}", index + 1, hunk.header, stats.label()).into();
707
708 let resolved = acknowledged_state(
713 ElementId::from((hunk_id.clone(), "resolved")),
714 hunk.review as u64,
715 window,
716 cx,
717 );
718 let review = match (hunk.review, reviewable, handler) {
719 (HunkReview::Accepted, _, _) => Some(
720 div()
721 .opacity(resolved)
722 .child(
723 StatusBadge::new((hunk_id.clone(), "review"), "Accepted")
724 .tone(StatusTone::Success),
725 )
726 .into_any_element(),
727 ),
728 (HunkReview::Rejected, _, _) => Some(
729 div()
730 .opacity(resolved)
731 .child(
732 StatusBadge::new((hunk_id.clone(), "review"), "Rejected")
733 .tone(StatusTone::Neutral),
734 )
735 .into_any_element(),
736 ),
737 (HunkReview::Pending, true, Some(handler)) => {
738 let accept_path = path.clone();
739 let reject_path = path.clone();
740 let accept_handler = handler.clone();
741 let accept_debug = hunk_debug.clone();
742 let reject_debug = hunk_debug.clone();
743 Some(
744 h_flex()
745 .flex_none()
746 .items_center()
747 .gap(tokens.spacing.xs)
748 .child(
749 div()
750 .debug_selector(move || format!("code-diff-reject-{reject_debug}"))
751 .child(
752 Button::new((hunk_id.clone(), "reject"))
753 .outline()
754 .xsmall()
755 .control_metrics(cx)
756 .accessibility_id(format!("{path}-reject-{index}"))
757 .label("Reject")
758 .on_click(move |_: &ClickEvent, window, cx| {
759 handler(
760 &CodeDiffEvent::HunkRejected {
761 path: reject_path.clone(),
762 hunk: index,
763 },
764 window,
765 cx,
766 )
767 }),
768 ),
769 )
770 .child(
771 div()
772 .debug_selector(move || format!("code-diff-accept-{accept_debug}"))
773 .child(
774 Button::new((hunk_id.clone(), "accept"))
775 .primary()
776 .xsmall()
777 .control_metrics(cx)
778 .accessibility_id(format!("{path}-accept-{index}"))
779 .label("Accept")
780 .on_click(move |_: &ClickEvent, window, cx| {
781 accept_handler(
782 &CodeDiffEvent::HunkAccepted {
783 path: accept_path.clone(),
784 hunk: index,
785 },
786 window,
787 cx,
788 )
789 }),
790 ),
791 )
792 .into_any_element(),
793 )
794 }
795 _ => None,
796 };
797
798 let header = h_flex()
799 .items_center()
800 .gap(tokens.spacing.sm)
801 .px(tokens.spacing.md)
802 .py(tokens.spacing.xxs)
803 .bg(cx.theme().accent.opacity(0.35))
804 .border_t_1()
805 .border_color(cx.theme().border)
806 .child(
807 div()
808 .min_w_0()
809 .truncate()
810 .text_token(tokens.typography.xs)
811 .font_family(cx.theme().mono_font_family.clone())
812 .text_color(cx.theme().muted_foreground)
813 .child(hunk.header.clone()),
814 )
815 .child(div().flex_1())
816 .children(review);
817
818 let tints = v_flex()
822 .absolute()
823 .inset_0()
824 .children(hunk.lines.iter().map(|line| {
825 div().w_full().h(LINE_HEIGHT).when_some(
826 match line.kind {
827 DiffLineKind::Added => Some(cx.theme().success.opacity(0.12)),
828 DiffLineKind::Removed => Some(cx.theme().danger.opacity(0.12)),
829 DiffLineKind::Context => None,
830 },
831 |this, color| this.bg(color),
832 )
833 }));
834 let gutter = v_flex()
835 .flex_none()
836 .debug_selector(move || format!("code-diff-gutter-{gutter_debug}"))
837 .px(tokens.spacing.sm)
838 .text_token(tokens.typography.xs)
839 .font_family(cx.theme().mono_font_family.clone())
840 .text_color(cx.theme().muted_foreground)
841 .children(hunk.lines.iter().map(|line| {
842 let sign_color = match line.kind {
843 DiffLineKind::Added => cx.theme().success,
844 DiffLineKind::Removed => cx.theme().danger,
845 DiffLineKind::Context => cx.theme().muted_foreground,
846 };
847 h_flex()
848 .h(LINE_HEIGHT)
849 .items_center()
850 .gap(tokens.spacing.xs)
851 .child(
852 div()
853 .w(NUMBER_GUTTER_WIDTH)
854 .text_right()
855 .child(line.old_number.map_or(String::new(), |n| n.to_string())),
856 )
857 .child(
858 div()
859 .w(NUMBER_GUTTER_WIDTH)
860 .text_right()
861 .child(line.new_number.map_or(String::new(), |n| n.to_string())),
862 )
863 .child(
864 div()
865 .w(SIGN_GUTTER_WIDTH)
866 .text_center()
867 .font_weight(FontWeight::SEMIBOLD)
868 .text_color(sign_color)
869 .child(line.kind.sign().trim().to_owned()),
870 )
871 }));
872 let fence = "`".repeat(
873 (hunk
874 .lines
875 .iter()
876 .flat_map(|line| line.text.split(|c| c != '`'))
877 .map(str::len)
878 .max()
879 .unwrap_or(0)
880 + 1)
881 .max(3),
882 );
883 let source = format!("{fence}{language}\n{}{fence}", hunk.code());
884 let code_style = StyleRefinement::default()
885 .p_0()
886 .m_0()
887 .rounded(gpui::Pixels::ZERO)
888 .bg(cx.theme().transparent)
889 .line_height(LINE_HEIGHT);
890 let text = div()
891 .id((hunk_id.clone(), "scroll"))
892 .debug_selector(move || format!("code-diff-text-{text_debug}"))
893 .flex_1()
894 .min_w_0()
895 .overflow_x_scroll()
896 .whitespace_nowrap()
897 .line_height(LINE_HEIGHT)
898 .pr(tokens.spacing.md)
899 .child(
900 TextView::markdown((hunk_id.clone(), "code"), source)
901 .style(TextViewStyle::default().code_block(code_style))
902 .selectable(true),
903 );
904
905 v_flex()
906 .id(hunk_id)
907 .role(Role::Group)
908 .aria_label(hunk_label)
909 .debug_selector(move || format!("code-diff-hunk-{hunk_debug}"))
910 .w_full()
911 .min_w_0()
912 .child(header)
913 .child(
914 h_flex()
915 .relative()
916 .w_full()
917 .min_w_0()
918 .items_start()
919 .line_height(LINE_HEIGHT)
920 .child(tints)
921 .child(gutter)
922 .child(text),
923 )
924 .into_any_element()
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 const PATCH: &str = "diff --git a/src/pricing.rs b/src/pricing.rs\n--- a/src/pricing.rs\n+++ b/src/pricing.rs\n@@ -1,4 +1,5 @@ fn unit_price\n fn unit_price(order: &Order) -> Money {\n- order.total / order.units\n+ let units = order.units.max(1);\n+ order.total / units\n }\n \n@@ -10,3 +11,3 @@\n fn discount() -> f32 {\n- 0.05\n+ 0.07\n }\n";
932
933 #[test]
934 fn unified_patches_parse_into_numbered_hunks() {
935 let files = DiffFile::from_unified(PATCH);
936 assert_eq!(files.len(), 1);
937 let file = &files[0];
938 assert_eq!(file.path().as_ref(), "src/pricing.rs");
939 assert_eq!(file.language_name().map(|l| l.as_ref()), Some("rust"));
940 assert_eq!(file.hunk_refs().len(), 2);
941 assert_eq!(
942 file.stats(),
943 DiffStats {
944 added: 3,
945 removed: 2
946 }
947 );
948 assert_eq!(
949 file.accessibility_label(),
950 "Diff of src/pricing.rs, +3 \u{2212}2"
951 );
952
953 let first = &file.hunk_refs()[0];
954 assert_eq!(first.header().as_ref(), "@@ -1,4 +1,5 @@ fn unit_price");
955 let lines = first.line_refs();
956 assert_eq!(lines[0].kind(), DiffLineKind::Context);
957 assert_eq!(
958 (lines[0].old_line(), lines[0].new_line()),
959 (Some(1), Some(1))
960 );
961 assert_eq!(lines[1].kind(), DiffLineKind::Removed);
962 assert_eq!((lines[1].old_line(), lines[1].new_line()), (Some(2), None));
963 assert_eq!(lines[2].kind(), DiffLineKind::Added);
964 assert_eq!((lines[2].old_line(), lines[2].new_line()), (None, Some(2)));
965 assert_eq!(lines[3].text().as_ref(), " order.total / units");
966 assert_eq!(
967 (lines[4].old_line(), lines[4].new_line()),
968 (Some(3), Some(4))
969 );
970
971 let second = &file.hunk_refs()[1];
972 assert_eq!(second.line_refs()[0].old_line(), Some(10));
973 assert_eq!(second.line_refs()[0].new_line(), Some(11));
974 }
975
976 #[test]
977 fn round_trips_to_a_unified_patch() {
978 let file = DiffFile::from_unified(PATCH).remove(0);
979 let unified = file.to_unified();
980 assert!(unified.starts_with(
981 "--- a/src/pricing.rs\n+++ b/src/pricing.rs\n@@ -1,4 +1,5 @@ fn unit_price\n"
982 ));
983 assert!(
984 unified
985 .contains("- order.total / order.units\n+ let units = order.units.max(1);\n")
986 );
987 let again = DiffFile::from_unified(&unified).remove(0);
988 assert_eq!(again, file);
989 }
990
991 #[test]
992 fn renames_and_unknown_lines_are_tolerated() {
993 let files = DiffFile::from_unified(
994 "--- a/old.md\n+++ b/new.md\nindex 1..2\n@@ -1 +1 @@\n-old\n+new\n",
995 );
996 assert_eq!(files[0].old_path().map(|p| p.as_ref()), Some("old.md"));
997 assert_eq!(files[0].path().as_ref(), "new.md");
998 assert_eq!(files[0].hunk_refs()[0].line_refs().len(), 2);
999 assert!(DiffFile::from_unified("not a diff").is_empty());
1000 }
1001}