scrut/renderers/
diff.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8use 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
23/// Renderer that uses the traditional Diff render format
24/// See: https://en.wikipedia.org/wiki/Diff
25pub 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    /// Render result contains a change for the last line of the output, which
86    /// would contain the exit code `[<exit-code>]`.
87    /// The rest of the output is *not* attended, even if it is wrong.
88    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    /// Renders the internal error, in a way that is NOT compatible with
120    /// the unified diff syntax and will be rejected by `patch`. This is
121    /// intentional - the error must be handled by a user.
122    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}