1use std::borrow::Cow;
2use std::{path::Path, time::Duration};
3
4use similar::{Algorithm, ChangeTag, TextDiff};
5
6use crate::content::yaml;
7use crate::snapshot::{MetaData, Snapshot, SnapshotContents};
8use crate::utils::{format_rust_expression, style, term_width};
9
10pub struct SnapshotPrinter<'a> {
12 workspace_root: &'a Path,
13 old_snapshot: Option<&'a Snapshot>,
14 new_snapshot: &'a Snapshot,
15 old_snapshot_hint: &'a str,
16 new_snapshot_hint: &'a str,
17 show_info: bool,
18 show_diff: bool,
19 title: Option<&'a str>,
20 line: Option<u32>,
21 snapshot_file: Option<&'a Path>,
22}
23
24impl<'a> SnapshotPrinter<'a> {
25 pub fn new(
26 workspace_root: &'a Path,
27 old_snapshot: Option<&'a Snapshot>,
28 new_snapshot: &'a Snapshot,
29 ) -> SnapshotPrinter<'a> {
30 SnapshotPrinter {
31 workspace_root,
32 old_snapshot,
33 new_snapshot,
34 old_snapshot_hint: "old snapshot",
35 new_snapshot_hint: "new results",
36 show_info: false,
37 show_diff: false,
38 title: None,
39 line: None,
40 snapshot_file: None,
41 }
42 }
43
44 pub fn set_snapshot_hints(&mut self, old: &'a str, new: &'a str) {
45 self.old_snapshot_hint = old;
46 self.new_snapshot_hint = new;
47 }
48
49 pub fn set_show_info(&mut self, yes: bool) {
50 self.show_info = yes;
51 }
52
53 pub fn set_show_diff(&mut self, yes: bool) {
54 self.show_diff = yes;
55 }
56
57 pub fn set_title(&mut self, title: Option<&'a str>) {
58 self.title = title;
59 }
60
61 pub fn set_line(&mut self, line: Option<u32>) {
62 self.line = line;
63 }
64
65 pub fn set_snapshot_file(&mut self, file: Option<&'a Path>) {
66 self.snapshot_file = file;
67 }
68
69 pub fn print(&self) {
70 if let Some(title) = self.title {
71 let width = term_width();
72 println!(
73 "{title:━^width$}",
74 title = style(format!(" {} ", title)).bold(),
75 width = width
76 );
77 }
78 self.print_snapshot_diff();
79 }
80
81 fn print_snapshot_diff(&self) {
82 self.print_snapshot_summary();
83 if self.show_diff {
84 self.print_changeset();
85 } else {
86 self.print_snapshot();
87 }
88 }
89
90 fn print_snapshot_summary(&self) {
91 print_snapshot_summary(
92 self.workspace_root,
93 self.new_snapshot,
94 self.snapshot_file,
95 self.line,
96 );
97 }
98
99 fn print_info(&self) {
100 print_info(self.new_snapshot.metadata());
101 }
102
103 fn print_snapshot(&self) {
104 print_line(term_width());
105
106 let width = term_width();
107 if self.show_info {
108 self.print_info();
109 }
110 println!("Snapshot Contents:");
111
112 match self.new_snapshot.contents() {
113 SnapshotContents::Text(new_contents) => {
114 let new_contents = new_contents.to_string();
115
116 println!("──────┬{:─^1$}", "", width.saturating_sub(7));
117 for (idx, line) in new_contents.lines().enumerate() {
118 println!("{:>5} │ {}", style(idx + 1).cyan().dim().bold(), line);
119 }
120 println!("──────┴{:─^1$}", "", width.saturating_sub(7));
121 }
122 SnapshotContents::Binary(_) => {
123 println!(
124 "{}",
125 encode_file_link_escape(
126 &self
127 .new_snapshot
128 .build_binary_path(
129 self.snapshot_file.unwrap().with_extension("snap.new")
130 )
131 .unwrap()
132 )
133 );
134 }
135 }
136 }
137
138 fn print_changeset(&self) {
139 let width = term_width();
140 print_line(width);
141
142 if self.show_info {
143 self.print_info();
144 }
145
146 if let Some(old_snapshot) = self.old_snapshot {
147 if old_snapshot.contents().is_binary() {
148 println!(
149 "{}",
150 style(format_args!(
151 "-{}: {}",
152 self.old_snapshot_hint,
153 encode_file_link_escape(
154 &old_snapshot
155 .build_binary_path(self.snapshot_file.unwrap())
156 .unwrap()
157 ),
158 ))
159 .red()
160 );
161 }
162 }
163
164 if self.new_snapshot.contents().is_binary() {
165 println!(
166 "{}",
167 style(format_args!(
168 "+{}: {}",
169 self.new_snapshot_hint,
170 encode_file_link_escape(
171 &self
172 .new_snapshot
173 .build_binary_path(
174 self.snapshot_file.unwrap().with_extension("snap.new")
175 )
176 .unwrap()
177 ),
178 ))
179 .green()
180 );
181 }
182
183 if let Some((old, new)) = match (
184 self.old_snapshot.as_ref().map(|o| o.contents()),
185 self.new_snapshot.contents(),
186 ) {
187 (Some(SnapshotContents::Binary(_)) | None, SnapshotContents::Text(new)) => {
188 Some((None, Some(new.to_string())))
189 }
190 (Some(SnapshotContents::Text(old)), SnapshotContents::Binary { .. }) => {
191 Some((Some(old.to_string()), None))
192 }
193 (Some(SnapshotContents::Text(old)), SnapshotContents::Text(new)) => {
194 Some((Some(old.to_string()), Some(new.to_string())))
195 }
196 _ => None,
197 } {
198 let old_text = old.as_deref().unwrap_or("");
199 let new_text = new.as_deref().unwrap_or("");
200
201 let newlines_matter = newlines_matter(old_text, new_text);
202 let diff = TextDiff::configure()
203 .algorithm(Algorithm::Patience)
204 .timeout(Duration::from_millis(500))
205 .diff_lines(old_text, new_text);
206
207 if old.is_some() {
208 println!(
209 "{}",
210 style(format_args!("-{}", self.old_snapshot_hint)).red()
211 );
212 }
213
214 if new.is_some() {
215 println!(
216 "{}",
217 style(format_args!("+{}", self.new_snapshot_hint)).green()
218 );
219 }
220
221 println!("────────────┬{:─^1$}", "", width.saturating_sub(13));
222
223 let mut has_changes = old.is_none() || new.is_none();
226
227 for (idx, group) in diff.grouped_ops(4).iter().enumerate() {
228 if idx > 0 {
229 println!("┈┈┈┈┈┈┈┈┈┈┈┈┼{:┈^1$}", "", width.saturating_sub(13));
230 }
231 for op in group {
232 for change in diff.iter_inline_changes(op) {
233 match change.tag() {
234 ChangeTag::Insert => {
235 has_changes = true;
236 print!(
237 "{:>5} {:>5} │{}",
238 "",
239 style(change.new_index().unwrap()).cyan().dim().bold(),
240 style("+").green(),
241 );
242 for &(emphasized, change) in change.values() {
243 let change = render_invisible(change, newlines_matter);
244 if emphasized {
245 print!("{}", style(change).green().underlined());
246 } else {
247 print!("{}", style(change).green());
248 }
249 }
250 }
251 ChangeTag::Delete => {
252 has_changes = true;
253 print!(
254 "{:>5} {:>5} │{}",
255 style(change.old_index().unwrap()).cyan().dim(),
256 "",
257 style("-").red(),
258 );
259 for &(emphasized, change) in change.values() {
260 let change = render_invisible(change, newlines_matter);
261 if emphasized {
262 print!("{}", style(change).red().underlined());
263 } else {
264 print!("{}", style(change).red());
265 }
266 }
267 }
268 ChangeTag::Equal => {
269 print!(
270 "{:>5} {:>5} │ ",
271 style(change.old_index().unwrap()).cyan().dim(),
272 style(change.new_index().unwrap()).cyan().dim().bold(),
273 );
274 for &(_, change) in change.values() {
275 let change = render_invisible(change, newlines_matter);
276 print!("{}", style(change).dim());
277 }
278 }
279 }
280 if change.missing_newline() {
281 println!();
282 }
283 }
284 }
285 }
286
287 if !has_changes {
288 println!(
289 "{:>5} {:>5} │{}",
290 "",
291 style("-").dim(),
292 style(" snapshots are matching").cyan(),
293 );
294 }
295
296 println!("────────────┴{:─^1$}", "", width.saturating_sub(13));
297 }
298 }
299}
300
301pub fn print_snapshot_summary(
303 workspace_root: &Path,
304 snapshot: &Snapshot,
305 snapshot_file: Option<&Path>,
306 line: Option<u32>,
307) {
308 if let Some(snapshot_file) = snapshot_file {
309 let snapshot_file = workspace_root
310 .join(snapshot_file)
311 .strip_prefix(workspace_root)
312 .ok()
313 .map(|x| x.to_path_buf())
314 .unwrap_or_else(|| snapshot_file.to_path_buf());
315 println!(
316 "Snapshot file: {}",
317 style(snapshot_file.display()).cyan().underlined()
318 );
319 }
320 if let Some(name) = snapshot.snapshot_name() {
321 println!("Snapshot: {}", style(name).yellow());
322 } else {
323 println!("Snapshot: {}", style("<inline>").dim());
324 }
325
326 if let Some(ref value) = snapshot.metadata().get_relative_source(workspace_root) {
327 println!(
328 "Source: {}{}",
329 style(value.display()).cyan(),
330 line.or(
331 snapshot.metadata().assertion_line()
333 )
334 .map(|line| format!(":{}", style(line).bold()))
335 .unwrap_or_default()
336 );
337 }
338
339 if let Some(ref value) = snapshot.metadata().input_file() {
340 println!("Input file: {}", style(value).cyan());
341 }
342}
343
344fn print_line(width: usize) {
345 println!("{:─^1$}", "", width);
346}
347
348fn trailing_newline(s: &str) -> &str {
349 if s.ends_with("\r\n") {
350 "\r\n"
351 } else if s.ends_with('\r') {
352 "\r"
353 } else if s.ends_with('\n') {
354 "\n"
355 } else {
356 ""
357 }
358}
359
360fn detect_newlines(s: &str) -> (bool, bool, bool) {
361 let mut last_char = None;
362 let mut detected_crlf = false;
363 let mut detected_cr = false;
364 let mut detected_lf = false;
365
366 for c in s.chars() {
367 if c == '\n' {
368 if last_char.take() == Some('\r') {
369 detected_crlf = true;
370 } else {
371 detected_lf = true;
372 }
373 }
374 if last_char == Some('\r') {
375 detected_cr = true;
376 }
377 last_char = Some(c);
378 }
379 if last_char == Some('\r') {
380 detected_cr = true;
381 }
382
383 (detected_cr, detected_crlf, detected_lf)
384}
385
386fn newlines_matter(left: &str, right: &str) -> bool {
387 if trailing_newline(left) != trailing_newline(right) {
388 return true;
389 }
390
391 let (cr1, crlf1, lf1) = detect_newlines(left);
392 let (cr2, crlf2, lf2) = detect_newlines(right);
393
394 !matches!(
395 (cr1 || cr2, crlf1 || crlf2, lf1 || lf2),
396 (false, false, false) | (true, false, false) | (false, true, false) | (false, false, true)
397 )
398}
399
400fn render_invisible(s: &str, newlines_matter: bool) -> Cow<'_, str> {
401 if newlines_matter || s.find(&['\x1b', '\x07', '\x08', '\x7f'][..]).is_some() {
402 Cow::Owned(
403 s.replace('\r', "␍\r")
404 .replace('\n', "␊\n")
405 .replace("␍\r␊\n", "␍␊\r\n")
406 .replace('\x07', "␇")
407 .replace('\x08', "␈")
408 .replace('\x1b', "␛")
409 .replace('\x7f', "␡"),
410 )
411 } else {
412 Cow::Borrowed(s)
413 }
414}
415
416fn print_info(metadata: &MetaData) {
417 let width = term_width();
418 if let Some(expr) = metadata.expression() {
419 println!("Expression: {}", style(format_rust_expression(expr)));
420 print_line(width);
421 }
422 if let Some(descr) = metadata.description() {
423 println!("{}", descr);
424 print_line(width);
425 }
426 if let Some(info) = metadata.private_info() {
427 let out = yaml::to_string(info);
428 println!("{}", out.trim().strip_prefix("---").unwrap().trim_start());
430 print_line(width);
431 }
432}
433
434fn encode_file_link_escape(path: &Path) -> String {
437 assert!(path.is_absolute());
438 format!(
439 "\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\",
440 path.display(),
441 path.display()
442 )
443}
444
445#[test]
446fn test_invisible() {
447 assert_eq!(
448 render_invisible("\r\n\x1b\r\x07\x08\x7f\n", true),
449 "␍␊\r\n␛␍\r␇␈␡␊\n"
450 );
451}