1use std::path::{Path, PathBuf};
4
5use serde_json::{json, Map, Value};
6
7use super::super::terminal::cell::{display_width, truncate_to_columns, Attrs, Color, EmuCell};
8
9pub enum SnapshotStatus {
10 Passed,
11 Written,
12 Updated,
13 Failed { expected: String, actual: String },
14}
15
16fn snapshot_dir(base: &Path) -> PathBuf {
17 base.join("__snapshots__")
18}
19
20fn sanitize(name: &str) -> String {
21 name.chars()
22 .map(|c| if " /\\<>:\"'|?*".contains(c) { '-' } else { c })
23 .collect()
24}
25
26fn snapshot_path(base: &Path, name: &str) -> PathBuf {
27 snapshot_dir(base).join(format!("{}.snap", sanitize(name)))
28}
29
30fn color_value(c: Option<Color>) -> Value {
31 match c {
32 None => Value::String(crate::assert::color::DEFAULT.to_string()),
33 Some(Color::Rgb(r, g, b)) => Value::String(format!("#{r:02x}{g:02x}{b:02x}")),
34 Some(c) => json!(c.to_index()),
35 }
36}
37
38fn shift(prev: &EmuCell, cur: &EmuCell) -> Map<String, Value> {
39 let mut m = Map::new();
40 if prev.fg != cur.fg {
41 m.insert("fg".into(), color_value(cur.fg));
42 }
43 if prev.bg != cur.bg {
44 m.insert("bg".into(), color_value(cur.bg));
45 }
46 for (attr, key) in [
47 (Attrs::BOLD, "bold"),
48 (Attrs::DIM, "dim"),
49 (Attrs::ITALIC, "italic"),
50 (Attrs::INVERSE, "inverse"),
51 (Attrs::INVISIBLE, "invisible"),
52 (Attrs::STRIKE, "strike"),
53 (Attrs::BLINK, "blink"),
54 ] {
55 if prev.has(attr) != cur.has(attr) {
56 m.insert(key.into(), json!(cur.has(attr)));
57 }
58 }
59 if prev.underline != cur.underline {
63 m.insert("underline".into(), json!(cur.underline.name()));
64 }
65 m
66}
67
68fn baseline() -> EmuCell {
69 EmuCell::blank()
70}
71
72pub fn serialize(
79 rows: &[Vec<EmuCell>],
80 cols: u16,
81 include_colors: bool,
82 title: Option<&str>,
83) -> String {
84 let mut lines = Vec::with_capacity(rows.len());
85 let mut shifts = Map::new();
86 let mut prev = baseline();
87 for (y, row) in rows.iter().enumerate() {
88 let mut line = String::with_capacity(cols as usize);
89 for (x, cell) in row.iter().enumerate() {
90 line.push_str(&cell.ch);
94 let s = shift(&prev, cell);
95 if !s.is_empty() {
96 shifts.insert(format!("{x},{y}"), Value::Object(s));
97 }
98 prev = cell.clone();
99 }
100 lines.push(line);
101 }
102
103 let view = box_view(&lines.join("\n"), cols, title);
104 let mut attributes = Map::new();
105 if let Some(title) = title {
110 attributes.insert("title".to_string(), Value::String(title.to_string()));
111 }
112 if include_colors && !shifts.is_empty() {
113 attributes.insert("colors".to_string(), Value::Object(shifts));
114 }
115 if attributes.is_empty() {
116 view
117 } else {
118 format!(
119 "{view}\n{}",
120 serde_json::to_string_pretty(&Value::Object(attributes)).unwrap_or_default()
121 )
122 }
123}
124
125fn box_view(view: &str, width: u16, title: Option<&str>) -> String {
133 let width = width as usize;
134 let bar = "─".repeat(width);
135 let label = title.and_then(|title| {
139 let room = width.checked_sub(4).filter(|room| *room > 0)?;
140 Some(format!(" {} ", truncate_to_columns(title, room)))
141 });
142 let top = match label {
143 Some(label) => format!(
144 "╭─{label}{}╮",
145 "─".repeat(width - 1 - display_width(&label))
146 ),
147 None => format!("╭{bar}╮"),
148 };
149 let bottom = format!("╰{bar}╯");
150 let mut out = vec![top];
151 for line in view.split('\n') {
152 out.push(format!("│{line}│"));
153 }
154 out.push(bottom);
155 out.join("\n")
156}
157
158pub fn compare(
162 base: &Path,
163 name: &str,
164 content: &str,
165 update: bool,
166) -> std::io::Result<SnapshotStatus> {
167 let path = snapshot_path(base, name);
168 let trimmed = content.trim();
169 if !path.exists() {
170 std::fs::create_dir_all(snapshot_dir(base))?;
171 std::fs::write(&path, format!("{trimmed}\n"))?;
172 return Ok(SnapshotStatus::Written);
173 }
174 let existing = std::fs::read_to_string(&path)?;
175 let existing = existing.trim();
176 if existing == trimmed {
177 return Ok(SnapshotStatus::Passed);
178 }
179 if update {
180 std::fs::write(&path, format!("{trimmed}\n"))?;
181 return Ok(SnapshotStatus::Updated);
182 }
183 Ok(SnapshotStatus::Failed {
184 expected: existing.to_string(),
185 actual: trimmed.to_string(),
186 })
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::terminal::cell::CONTINUATION;
193
194 fn box_of(serialized: &str) -> String {
196 match serialized.split_once("╯\n") {
197 Some((frame, _)) => format!("{frame}╯"),
198 None => serialized.to_string(),
199 }
200 }
201
202 #[test]
210 fn a_snapshot_records_the_slot_rather_than_the_color() {
211 let colored = EmuCell {
212 ch: "x".into(),
213 fg: Some(Color::from_index(1)),
214 ..EmuCell::blank()
215 };
216 let out = serialize(&[vec![colored]], 1, true, None);
217 assert!(
218 out.contains("\"fg\": 1"),
219 "the slot is recorded, not an rgb value: {out}"
220 );
221 assert!(
222 !out.contains('#'),
223 "a palette color must not be resolved into the snapshot: {out}"
224 );
225 }
226
227 #[test]
230 fn a_true_color_cell_records_its_own_value() {
231 let rgb = EmuCell {
232 ch: "x".into(),
233 fg: Some(Color::Rgb(0x11, 0x22, 0x33)),
234 ..EmuCell::blank()
235 };
236 assert!(serialize(&[vec![rgb]], 1, true, None).contains("#112233"));
237 }
238
239 fn cell(s: &str) -> EmuCell {
240 EmuCell {
241 ch: s.into(),
242 ..EmuCell::blank()
243 }
244 }
245
246 #[test]
251 fn a_wide_char_does_not_overflow_the_frame() {
252 let rows = vec![vec![
253 cell("你"),
254 cell(CONTINUATION),
255 cell("b"),
256 cell(" "),
257 cell(" "),
258 cell(" "),
259 ]];
260 assert_eq!(
261 serialize(&rows, 6, false, None),
262 "╭──────╮\n│你b │\n╰──────╯"
263 );
264 }
265
266 #[test]
273 fn the_title_rides_in_the_top_border() {
274 let rows = vec![vec![cell("a"); 20]];
275 let bare = serialize(&rows, 20, false, None);
276 let titled = serialize(&rows, 20, false, Some("vim"));
277
278 assert!(
279 bare.starts_with("╭────────────────────╮"),
280 "no title leaves the border untouched: {bare}"
281 );
282 assert!(
283 titled.starts_with("╭─ vim ──────────────╮"),
284 "the title is set into the border: {titled}"
285 );
286 assert_eq!(
287 bare.lines().skip(1).collect::<Vec<_>>(),
288 box_of(&titled).lines().skip(1).collect::<Vec<_>>(),
289 "and nothing below the border changes"
290 );
291 }
292
293 #[test]
300 fn the_full_title_is_recorded_even_when_the_border_cannot_show_it() {
301 let rows = vec![vec![cell("a"); 12]];
302 let long = "building module A, step 3";
303 let other = "building module B, step 7";
304 let out = serialize(&rows, 12, false, Some(long));
305
306 assert!(
307 box_of(&out).contains('…'),
308 "the border shows a shortened title: {out}"
309 );
310 assert!(
311 out.contains(&format!(r#""title": "{long}""#)),
312 "and the exact one is recorded: {out}"
313 );
314 assert_ne!(
315 out,
316 serialize(&rows, 12, false, Some(other)),
317 "two titles that shorten alike still record differently"
318 );
319 }
320
321 #[test]
323 fn attributes_hold_the_title_and_the_colors_apart() {
324 let rows = vec![vec![
325 cell("a"),
326 EmuCell {
327 fg: Some(Color::from_index(1)),
328 ..EmuCell::blank()
329 },
330 ]];
331 let out = serialize(&rows, 2, true, Some("t"));
332 let attributes: Value =
333 serde_json::from_str(out.split_once("╯\n").expect("a frame then attributes").1)
334 .expect("attributes parse as json");
335 assert_eq!(attributes["title"], json!("t"));
336 assert!(
337 attributes["colors"].is_object(),
338 "colors stay under their own key: {out}"
339 );
340 }
341
342 #[test]
347 fn a_title_never_changes_the_frame_width() {
348 let rows = vec![vec![cell("a"); 10]];
349 for title in [
353 "",
354 "x",
355 "fits",
356 "a title far wider than the frame",
357 "你好世界你好世界",
358 "🚀 build",
359 "e\u{301}clair",
360 ] {
361 let out = serialize(&rows, 10, false, Some(title));
362 let widths: Vec<usize> = box_of(&out).lines().map(display_width).collect();
363 assert!(
364 widths.iter().all(|w| *w == 12),
365 "title {title:?} bent the frame: {widths:?}\n{out}"
366 );
367 }
368 }
369
370 #[test]
373 fn a_frame_too_narrow_for_a_title_stays_plain() {
374 let rows = vec![vec![cell("a"); 3]];
375 assert_eq!(
376 box_of(&serialize(&rows, 3, false, Some("title"))),
377 serialize(&rows, 3, false, None),
378 "three columns cannot hold a title, so none is drawn"
379 );
380 }
381
382 #[test]
385 fn a_shift_between_underline_styles_is_recorded() {
386 use crate::terminal::cell::UnderlineStyle;
387 let styled = |u| EmuCell {
388 underline: u,
389 ..EmuCell::blank()
390 };
391 let curly = shift(
392 &styled(UnderlineStyle::Single),
393 &styled(UnderlineStyle::Curly),
394 );
395 assert_eq!(curly.get("underline"), Some(&json!("curly")));
396 assert_eq!(
397 shift(
398 &styled(UnderlineStyle::Curly),
399 &styled(UnderlineStyle::None)
400 )
401 .get("underline"),
402 Some(&json!("none"))
403 );
404 assert!(shift(
405 &styled(UnderlineStyle::Curly),
406 &styled(UnderlineStyle::Curly)
407 )
408 .is_empty());
409 }
410}