1use std::cmp::Ordering;
9use std::fmt::Display;
10
11use anyhow::bail;
12use anyhow::Result;
13
14use super::renderer::ErrorRenderer;
15use super::renderer::Renderer;
16use crate::diff::Diff;
17use crate::diff::DiffLine;
18use crate::formatln;
19use crate::newline::BytesNewline;
20use crate::outcome::Outcome;
21use crate::parsers::parser::ParserType;
22
23pub struct DiffRenderer {}
26
27impl DiffRenderer {
28 pub fn new() -> Self {
29 Self {}
30 }
31}
32
33impl Default for DiffRenderer {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl Renderer for DiffRenderer {
40 fn render(&self, outcomes: &[&Outcome]) -> Result<String> {
41 let mut output = String::new();
42 let count_locations = outcomes
43 .iter()
44 .filter(|outcome| outcome.location.is_some())
45 .count();
46 let mut outcomes = outcomes.to_owned().to_vec();
47 if count_locations > 0 {
48 if count_locations != outcomes.len() {
49 bail!("cannot render diff with some outcomes providing locations, but not all")
50 }
51 outcomes.sort_by(|a, b| {
52 let result = a.location.cmp(&b.location);
53 if result == Ordering::Equal {
54 a.testcase.line_number.cmp(&b.testcase.line_number)
55 } else {
56 result
57 }
58 })
59 }
60 let mut last_location = None;
61 for outcome in outcomes {
62 match &outcome.result {
63 Ok(_) => continue,
64 Err(err) => {
65 if outcome.location != last_location {
66 if let Some(ref location) = outcome.location {
67 if last_location.is_some() {
68 output.push('\n');
69 }
70 last_location = outcome.location.to_owned();
71 output.push_str(&formatln!("--- {}", location));
72 output.push_str(&formatln!("+++ {}.new", location));
73 }
74 }
75 let rendered_error = self.render_error(err, outcome)?;
76 output.push_str(&rendered_error);
77 }
78 }
79 }
80 Ok(output)
81 }
82}
83
84impl ErrorRenderer for DiffRenderer {
85 fn render_invalid_exit_code(
89 &self,
90 outcome: &Outcome,
91 actual: i32,
92 _expected: i32,
93 ) -> Result<String> {
94 let line_number = outcome.testcase.line_number
95 + outcome.testcase.shell_expression_lines()
96 + outcome.testcase.expectations_lines();
97 let prefix = line_prefix(outcome);
98 let title = join_multiline(&outcome.testcase.title, " * ");
99 let mut output = String::new();
100 output.push_str(
101 &DiffHeader {
102 old_start: line_number,
103 old_length: outcome.testcase.exit_code.map_or(0, |_| 1),
104 new_start: line_number,
105 new_length: 1,
106 kind: DiffHeaderKind::InvalidExitCode,
107 title: &title,
108 }
109 .to_string(),
110 );
111
112 if let Some(exit_code) = outcome.testcase.exit_code {
113 output.push_str(&format!("-{prefix}[{exit_code}]\n"));
114 }
115 output.push_str(&format!("+{prefix}[{actual}]\n"));
116 Ok(output)
117 }
118
119 fn render_delegated_error(&self, outcome: &Outcome, err: &anyhow::Error) -> Result<String> {
123 let title = join_multiline(&outcome.testcase.title, " * ");
124 let mut output = String::new();
125 output.push_str("# ---- INTERNAL ERROR ----\n");
126 if let Some(ref location) = outcome.location {
127 output.push_str(&format!("# PATH: {location}\n"));
128 }
129 output.push_str(&format!("# TITLE: {title}\n"));
130 let output_err = err
131 .to_string()
132 .lines()
133 .map(|line| format!("# ERROR: {line}\n"))
134 .collect::<Vec<_>>()
135 .join("");
136 output.push_str(&output_err);
137 output.push_str("# ---- INTERNAL ERROR ----\n");
138 Ok(output)
139 }
140
141 fn render_malformed_output(&self, outcome: &Outcome, diff: &Diff) -> Result<String> {
142 UnifiedDiff::default().render(outcome, diff)
143 }
144
145 fn render_timeout(&self, _outcome: &Outcome) -> Result<String> {
146 Ok("".into())
147 }
148
149 fn render_skipped(&self, _outcome: &Outcome) -> Result<String> {
150 Ok("".into())
151 }
152}
153
154#[derive(Default)]
155struct UnifiedDiff {
156 unmatched_start: Option<usize>,
157 unmatched_lines: Vec<String>,
158 unexpected_start: Option<usize>,
159 unexpected_lines: Vec<(usize, String)>,
160}
161
162impl UnifiedDiff {
163 fn flush(&mut self) {
164 self.unmatched_start = None;
165 self.unexpected_start = None;
166 self.unmatched_lines = vec![];
167 self.unexpected_lines = vec![];
168 }
169
170 fn render(&mut self, outcome: &Outcome, diff: &Diff) -> Result<String> {
171 let line_number = outcome.testcase.line_number + outcome.testcase.shell_expression_lines();
172 let title = join_multiline(&outcome.testcase.title, " * ");
173 let prefix = line_prefix(outcome);
174 let mut output = String::new();
175
176 macro_rules! add_diff_hunk {
177 () => {
178 if self.unmatched_start.is_some() || self.unexpected_start.is_some() {
179 let (unmatched_start, unexpected_start) =
180 if let Some(unmatched_start) = self.unmatched_start {
181 (
182 unmatched_start,
183 self.unexpected_start.unwrap_or(unmatched_start),
184 )
185 } else {
186 let unexpected_start = self.unexpected_start.unwrap();
187 (unexpected_start, unexpected_start)
188 };
189 output.push_str(
190 &DiffHeader {
191 old_start: unmatched_start + line_number,
192 old_length: self.unmatched_lines.len(),
193 new_start: unexpected_start + line_number,
194 new_length: self.unexpected_lines.len(),
195 kind: DiffHeaderKind::MalformedOutput,
196 title: &title,
197 }
198 .to_string(),
199 );
200 self.unmatched_lines
201 .iter()
202 .for_each(|line| output.push_str(&format!("-{prefix}{line}\n")));
203 self.unexpected_lines
204 .iter()
205 .for_each(|line| output.push_str(&format!("+{}{}\n", prefix, &line.1)));
206 self.flush();
207 }
208 };
209 }
210
211 let mut expectation_index = 0;
212 for line in &diff.lines {
213 match line {
214 DiffLine::MatchedExpectation {
215 index,
216 expectation: _,
217 lines: _,
218 } => {
219 expectation_index = *index;
220 add_diff_hunk!();
221 }
222 DiffLine::UnmatchedExpectation { index, expectation } => {
223 expectation_index = *index;
224 if self.unmatched_start.is_none() {
225 self.unmatched_start = Some(*index);
226 }
227 self.unmatched_lines.push(expectation.original_string())
228 }
229 DiffLine::UnexpectedLines { lines } => {
230 if self.unexpected_start.is_none() {
231 self.unexpected_start = Some(expectation_index)
232 }
233 self.unexpected_lines.extend(
234 lines
235 .iter()
236 .map(|(i, l)| {
237 Ok((
238 *i,
239 String::from_utf8((l as &[u8]).trim_newlines().to_vec())?,
240 ))
241 })
242 .collect::<Result<Vec<_>>>()?,
243 );
244 if self.unmatched_start.is_some() {
245 add_diff_hunk!();
246 }
247 }
248 }
249 }
250 add_diff_hunk!();
251
252 Ok(output)
253 }
254}
255
256enum DiffHeaderKind {
257 InvalidExitCode,
258 MalformedOutput,
259}
260
261impl Display for DiffHeaderKind {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 write!(
264 f,
265 "{}",
266 match self {
267 DiffHeaderKind::InvalidExitCode => "invalid exit code",
268 DiffHeaderKind::MalformedOutput => "malformed output",
269 }
270 )
271 }
272}
273
274struct DiffHeader<'a> {
275 old_start: usize,
276 old_length: usize,
277 new_start: usize,
278 new_length: usize,
279 title: &'a str,
280 kind: DiffHeaderKind,
281}
282
283impl Display for DiffHeader<'_> {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 writeln!(
286 f,
287 "@@ -{}{} +{}{} @@ {}: {}",
288 self.old_start,
289 length_suffix(self.old_length),
290 self.new_start,
291 length_suffix(self.new_length),
292 self.kind,
293 self.title,
294 )
295 }
296}
297
298fn length_suffix(len: usize) -> String {
299 if len > 1 || len == 0 {
300 format!(",{len}")
301 } else {
302 "".into()
303 }
304}
305
306fn join_multiline(text: &str, sep: &str) -> String {
307 text.lines().collect::<Vec<_>>().join(sep)
308}
309
310fn line_prefix(outcome: &Outcome) -> &'static str {
311 match outcome.format {
312 ParserType::Markdown => "",
313 ParserType::Cram => " ",
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::DiffRenderer;
320 use crate::diff::Diff;
321 use crate::diff::DiffLine;
322 use crate::escaping::Escaper;
323 use crate::outcome::Outcome;
324 use crate::parsers::parser::ParserType;
325 use crate::renderers::renderer::Renderer;
326 use crate::test_expectation;
327 use crate::testcase::TestCase;
328 use crate::testcase::TestCaseError;
329
330 #[test]
331 fn test_render_success() {
332 let renderer = DiffRenderer::new();
333 let rendered = renderer
334 .render(&[&Outcome {
335 output: ("the stdout", "the stderr").into(),
336 testcase: TestCase {
337 title: "the title".to_string(),
338 shell_expression: "the command".to_string(),
339 expectations: vec![],
340 exit_code: None,
341 line_number: 234,
342 ..Default::default()
343 },
344 location: Some("the location".to_string()),
345 result: Ok(()),
346 escaping: Escaper::default(),
347 format: ParserType::Markdown,
348 }])
349 .expect("render succeeds");
350 assert_eq!("", &rendered, "success results are not rendered");
351 }
352
353 #[test]
354 fn test_internal_error() {
355 let renderer = DiffRenderer::new();
356 let rendered = renderer
357 .render(&[&Outcome {
358 output: ("the stdout", "the stderr", Some(222)).into(),
359 testcase: TestCase {
360 title: "the title".into(),
361 shell_expression: "the command".into(),
362 expectations: vec![],
363 exit_code: Some(111),
364 line_number: 234,
365 ..Default::default()
366 },
367 location: Some("the location".into()),
368 result: Err(TestCaseError::InternalError(anyhow::Error::msg(
369 "bad thing",
370 ))),
371 escaping: Escaper::default(),
372 format: ParserType::Markdown,
373 }])
374 .expect("render succeeds");
375 insta::assert_snapshot!(rendered);
376 }
377
378 #[test]
379 fn test_invalid_exit_code() {
380 let renderer = DiffRenderer::new();
381
382 [ParserType::Cram, ParserType::Markdown]
383 .iter()
384 .for_each(|parser_type| {
385 let rendered = renderer
386 .render(&[&Outcome {
387 output: ("the stdout\n", "the stderr\n", Some(222)).into(),
388 testcase: TestCase {
389 title: "the title".into(),
390 shell_expression: "the command".into(),
391 expectations: vec![test_expectation!("the stdout")],
392 exit_code: Some(111),
393 line_number: 234,
394 ..Default::default()
395 },
396 location: Some("the location".into()),
397 result: Err(TestCaseError::InvalidExitCode {
398 actual: 222,
399 expected: 111,
400 }),
401 escaping: Escaper::default(),
402 format: *parser_type,
403 }])
404 .expect("render succeeds");
405 insta::assert_snapshot!(format!("invalid_exit_code_{parser_type}"), rendered);
406 });
407 }
408
409 #[test]
410 fn test_malformed_output() {
411 let renderer = DiffRenderer::new();
412 let testcase = TestCase {
413 title: "the title".into(),
414 shell_expression: "the command".into(),
415 expectations: vec![
416 test_expectation!("expected line 1"),
417 test_expectation!("expected line 2"),
418 test_expectation!("expected line 3"),
419 ],
420 exit_code: None,
421 line_number: 234,
422 ..Default::default()
423 };
424
425 let tests = [
426 (
427 "missing",
428 Diff::new(vec![DiffLine::UnmatchedExpectation {
429 index: 1,
430 expectation: testcase.expectations[1].clone(),
431 }]),
432 ),
433 (
434 "unexpected",
435 Diff::new(vec![DiffLine::UnexpectedLines {
436 lines: vec![(2, "something else".as_bytes().to_vec())],
437 }]),
438 ),
439 (
440 "mismatch",
441 Diff::new(vec![
442 DiffLine::UnmatchedExpectation {
443 index: 1,
444 expectation: testcase.expectations[1].clone(),
445 },
446 DiffLine::UnmatchedExpectation {
447 index: 2,
448 expectation: testcase.expectations[2].clone(),
449 },
450 DiffLine::UnexpectedLines {
451 lines: vec![
452 (2, "something line 1".as_bytes().to_vec()),
453 (3, "something line 2".as_bytes().to_vec()),
454 ],
455 },
456 ]),
457 ),
458 ];
459
460 [ParserType::Cram, ParserType::Markdown]
461 .iter()
462 .for_each(|parser_type| {
463 tests.iter().for_each(|(name, diff)| {
464 let rendered = renderer
465 .render(&[&Outcome {
466 output: (
467 "expected line 1\nexpected line FAIL\nexpected line 3\n",
468 "the stderr",
469 )
470 .into(),
471 testcase: testcase.clone(),
472 location: Some("the location".into()),
473 result: Err(TestCaseError::MalformedOutput(diff.to_owned())),
474 escaping: Escaper::default(),
475 format: *parser_type,
476 }])
477 .expect("render succeeds");
478 insta::assert_snapshot!(
479 format!("malformed_output_{name}_{parser_type}"),
480 rendered
481 );
482 });
483 })
484 }
485
486 #[test]
487 fn test_render() {
488 let renderer = DiffRenderer::new();
489 let testcase = TestCase {
490 title: "the title".into(),
491 shell_expression: "the command".into(),
492 expectations: vec![
493 test_expectation!("expected line 1"),
494 test_expectation!("expected line 2"),
495 test_expectation!("expected line 3"),
496 ],
497 exit_code: None,
498 line_number: 234,
499 ..Default::default()
500 };
501
502 let tests = [
503 (
504 "missing",
505 Diff::new(vec![DiffLine::UnmatchedExpectation {
506 index: 1,
507 expectation: testcase.expectations[1].clone(),
508 }]),
509 ),
510 (
511 "unexpected",
512 Diff::new(vec![DiffLine::UnexpectedLines {
513 lines: vec![(2, "something else".as_bytes().to_vec())],
514 }]),
515 ),
516 (
517 "mismatch",
518 Diff::new(vec![
519 DiffLine::UnmatchedExpectation {
520 index: 1,
521 expectation: testcase.expectations[1].clone(),
522 },
523 DiffLine::UnmatchedExpectation {
524 index: 2,
525 expectation: testcase.expectations[2].clone(),
526 },
527 DiffLine::UnexpectedLines {
528 lines: vec![
529 (2, "something line 1".as_bytes().to_vec()),
530 (3, "something line 2".as_bytes().to_vec()),
531 ],
532 },
533 ]),
534 ),
535 ];
536
537 [ParserType::Cram, ParserType::Markdown]
538 .iter()
539 .for_each(|parser_type| {
540 tests.iter().for_each(|(name, diff)| {
541 let mut testcase1 = testcase.clone();
542 testcase1.line_number = 10;
543 let mut testcase2 = testcase.clone();
544 testcase2.line_number = 20;
545 let outcomes = vec![
546 Outcome {
547 output: (
548 "expected line 1\nexpected line FAIL\nexpected line 3\n",
549 "the stderr",
550 )
551 .into(),
552 testcase: testcase2,
553 location: Some("location2".into()),
554 result: Err(TestCaseError::MalformedOutput(diff.to_owned())),
555 format: *parser_type,
556 escaping: Escaper::default(),
557 },
558 Outcome {
559 output: (
560 "expected line 1\nexpected line FAIL\nexpected line 3\n",
561 "the stderr",
562 )
563 .into(),
564 testcase: testcase1,
565 location: Some("location1".into()),
566 result: Err(TestCaseError::MalformedOutput(diff.to_owned())),
567 format: *parser_type,
568 escaping: Escaper::default(),
569 },
570 ];
571 let rendered = renderer
572 .render(&outcomes.iter().collect::<Vec<_>>())
573 .expect("render succeeds");
574 insta::assert_snapshot!(format!("render_{name}_{parser_type}"), rendered);
575 });
576 })
577 }
578}