Skip to main content

rich_ext/diff/
assert.rs

1//! Assertions that fail with a rendered diff: [`assert_rich_eq!`],
2//! [`assert_rich_json_eq!`], [`assert_render_eq!`] and
3//! [`assert_snapshot_eq!`].
4//!
5//! The diff is unified unless `RICH_ASSERT_LAYOUT=side-by-side`. It is
6//! coloured only when `RICH_ASSERT_COLOR=1`, or when stdout is a terminal and
7//! `CI` is unset (`RICH_ASSERT_COLOR=0` forces it off). Without colour the
8//! output is plain ASCII: `-`/`+`/`~` markers and a `|` divider.
9//!
10//! [`assert_rich_eq!`]: crate::assert_rich_eq
11//! [`assert_rich_json_eq!`]: crate::assert_rich_json_eq
12//! [`assert_render_eq!`]: crate::assert_render_eq
13//! [`assert_snapshot_eq!`]: crate::assert_snapshot_eq
14
15use std::io::IsTerminal;
16
17use rich::protocol::{Support, TargetCapabilities};
18use rich::{ColorSystem, Console, Renderable};
19use serde::Serialize;
20
21use super::{DiffView, Layout};
22use crate::target::{RenderTarget, TargetKind};
23use crate::testing::RenderSnapshot;
24
25/// How a failing assertion renders its diff.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct Report {
28    pub layout: Layout,
29    pub color: bool,
30    pub width: usize,
31}
32
33impl Report {
34    /// From `RICH_ASSERT_LAYOUT`, `RICH_ASSERT_COLOR`, `CI` and whether
35    /// stdout is a terminal; 100 columns.
36    pub fn from_env() -> Self {
37        let var = |name: &str| std::env::var(name).ok();
38        let layout = match var("RICH_ASSERT_LAYOUT").as_deref() {
39            Some("side-by-side" | "side_by_side" | "sbs") => Layout::SideBySide,
40            _ => Layout::Unified,
41        };
42        let color = match var("RICH_ASSERT_COLOR").as_deref() {
43            Some("1") => true,
44            Some("0") => false,
45            _ => std::io::stdout().is_terminal() && var("CI").is_none(),
46        };
47        Report {
48            layout,
49            color,
50            width: 100,
51        }
52    }
53
54    fn console(&self) -> Console {
55        let builder = Console::builder().width(self.width).height(10_000);
56        if self.color {
57            builder
58                .force_terminal(true)
59                .color_system(Some(ColorSystem::Standard))
60                .theme(crate::theme::extended_theme())
61                .build()
62        } else {
63            builder
64                .force_terminal(false)
65                .no_color(true)
66                .ascii_only(true)
67                .build()
68        }
69    }
70
71    /// Render `view` for a failure message.
72    pub fn render(&self, view: &DiffView) -> String {
73        let console = self.console();
74        let view = view.clone().layout(self.layout);
75        let segments = view.rich_render(&console, &console.options());
76        console.segments_to_string(&segments)
77    }
78}
79
80#[track_caller]
81fn fail(what: &str, view: DiffView, message: Option<String>) -> ! {
82    let rendered = Report::from_env().render(&view);
83    match message {
84        Some(message) => panic!("assertion `{what}` failed: {message}\n{rendered}"),
85        None => panic!("assertion `{what}` failed\n{rendered}"),
86    }
87}
88
89/// [`assert_rich_eq!`](crate::assert_rich_eq)'s body.
90#[track_caller]
91pub fn assert_str_eq(left: &str, right: &str, message: Option<String>) {
92    if left != right {
93        fail(
94            "left == right",
95            DiffView::new(left, right).titles("left", "right"),
96            message,
97        );
98    }
99}
100
101/// [`assert_rich_json_eq!`](crate::assert_rich_json_eq)'s body: both values
102/// as pretty JSON.
103#[track_caller]
104pub fn assert_json_eq<L: Serialize + ?Sized, R: Serialize + ?Sized>(
105    left: &L,
106    right: &R,
107    message: Option<String>,
108) {
109    let json =
110        |v: serde_json::Result<String>| v.unwrap_or_else(|e| format!("<not serializable: {e}>"));
111    let (l, r) = (
112        serde_json::to_value(left).and_then(|v| serde_json::to_string_pretty(&v)),
113        serde_json::to_value(right).and_then(|v| serde_json::to_string_pretty(&v)),
114    );
115    let (l, r) = (json(l) + "\n", json(r) + "\n");
116    if l != r {
117        fail(
118            "left == right",
119            DiffView::new(&l, &r).titles("left", "right"),
120            message,
121        );
122    }
123}
124
125/// Render `renderable` at `width` through a plain, deterministic target.
126/// Trailing whitespace on each line and trailing blank lines are dropped.
127pub fn render_plain(renderable: &dyn Renderable, width: usize) -> String {
128    let target = RenderTarget::new(
129        TargetKind::PlainStream,
130        TargetCapabilities {
131            width,
132            height: 10_000,
133            color_system: None,
134            interactive: false,
135            unicode: true,
136            hyperlinks: false,
137            sixel: Support::Unsupported,
138        },
139        crate::theme::extended_theme(),
140    );
141    normalize(&target.console().render_to_string(renderable))
142}
143
144fn normalize(text: &str) -> String {
145    let lines: Vec<&str> = text.lines().map(str::trim_end).collect();
146    let end = lines
147        .iter()
148        .rposition(|l| !l.is_empty())
149        .map_or(0, |i| i + 1);
150    let mut out = lines[..end].join("\n");
151    if !out.is_empty() {
152        out.push('\n');
153    }
154    out
155}
156
157/// [`assert_render_eq!`](crate::assert_render_eq)'s body.
158#[track_caller]
159pub fn assert_render_eq(
160    renderable: &dyn Renderable,
161    expected: &str,
162    width: usize,
163    message: Option<String>,
164) {
165    let actual = render_plain(renderable, width);
166    let expected = normalize(expected);
167    if actual != expected {
168        fail(
169            "rendered == expected",
170            DiffView::new(&expected, &actual).titles("expected", "rendered"),
171            message,
172        );
173    }
174}
175
176/// [`assert_snapshot_eq!`](crate::assert_snapshot_eq)'s body: the ANSI
177/// diff (style-only lines marked `~`) and the first differing field.
178#[track_caller]
179pub fn assert_snapshot_eq(left: &RenderSnapshot, right: &RenderSnapshot, message: Option<String>) {
180    if let Some(difference) = left.diff(right) {
181        let message = match message {
182            Some(m) => format!("{m}\n{difference}"),
183            None => difference,
184        };
185        fail(
186            "left == right",
187            DiffView::snapshots(left, right).titles("left", "right"),
188            Some(message),
189        );
190    }
191}
192
193/// Assert two strings are equal; on failure, panic with a rendered line diff.
194///
195/// ```should_panic
196/// rich_ext::assert_rich_eq!("a\nb\n", "a\nc\n", "values for {}", "case 1");
197/// ```
198#[macro_export]
199macro_rules! assert_rich_eq {
200    ($left:expr, $right:expr $(,)?) => {
201        $crate::diff::assert::assert_str_eq(
202            ::core::convert::AsRef::<str>::as_ref(&$left),
203            ::core::convert::AsRef::<str>::as_ref(&$right),
204            ::core::option::Option::None,
205        )
206    };
207    ($left:expr, $right:expr, $($arg:tt)+) => {
208        $crate::diff::assert::assert_str_eq(
209            ::core::convert::AsRef::<str>::as_ref(&$left),
210            ::core::convert::AsRef::<str>::as_ref(&$right),
211            ::core::option::Option::Some(::std::format!($($arg)+)),
212        )
213    };
214}
215
216/// Assert two `Serialize` values are equal as JSON; on failure, panic with a
217/// diff of both pretty-printed.
218///
219/// ```
220/// rich_ext::assert_rich_json_eq!(vec![1, 2], [1, 2]);
221/// ```
222#[macro_export]
223macro_rules! assert_rich_json_eq {
224    ($left:expr, $right:expr $(,)?) => {
225        $crate::diff::assert::assert_json_eq(&$left, &$right, ::core::option::Option::None)
226    };
227    ($left:expr, $right:expr, $($arg:tt)+) => {
228        $crate::diff::assert::assert_json_eq(
229            &$left,
230            &$right,
231            ::core::option::Option::Some(::std::format!($($arg)+)),
232        )
233    };
234}
235
236/// Assert a renderable renders as `expected` at `width` (default 80)
237/// through a plain, deterministic target; trailing whitespace is ignored.
238///
239/// ```
240/// rich_ext::assert_render_eq!(rich::Text::new("hello world"), "hello\nworld\n", width = 6);
241/// ```
242#[macro_export]
243macro_rules! assert_render_eq {
244    ($renderable:expr, $expected:expr, width = $width:expr $(,)?) => {
245        $crate::diff::assert::assert_render_eq(
246            &$renderable,
247            ::core::convert::AsRef::<str>::as_ref(&$expected),
248            $width,
249            ::core::option::Option::None,
250        )
251    };
252    ($renderable:expr, $expected:expr, width = $width:expr, $($arg:tt)+) => {
253        $crate::diff::assert::assert_render_eq(
254            &$renderable,
255            ::core::convert::AsRef::<str>::as_ref(&$expected),
256            $width,
257            ::core::option::Option::Some(::std::format!($($arg)+)),
258        )
259    };
260    ($renderable:expr, $expected:expr $(,)?) => {
261        $crate::assert_render_eq!($renderable, $expected, width = 80)
262    };
263    ($renderable:expr, $expected:expr, $($arg:tt)+) => {
264        $crate::diff::assert::assert_render_eq(
265            &$renderable,
266            ::core::convert::AsRef::<str>::as_ref(&$expected),
267            80,
268            ::core::option::Option::Some(::std::format!($($arg)+)),
269        )
270    };
271}
272
273/// Assert two [`RenderSnapshot`]s are equal; on failure, panic with their
274/// ANSI diff (style-only changes marked `~`) and the first differing field.
275#[macro_export]
276macro_rules! assert_snapshot_eq {
277    ($left:expr, $right:expr $(,)?) => {
278        $crate::diff::assert::assert_snapshot_eq(&$left, &$right, ::core::option::Option::None)
279    };
280    ($left:expr, $right:expr, $($arg:tt)+) => {
281        $crate::diff::assert::assert_snapshot_eq(
282            &$left,
283            &$right,
284            ::core::option::Option::Some(::std::format!($($arg)+)),
285        )
286    };
287}