Skip to main content

reifydb_testing/goldenfile/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	env,
6	fs::{self, File, OpenOptions},
7	io::{self, Write},
8	path::{Path, PathBuf},
9	process::id,
10	thread, time,
11	time::SystemTime,
12};
13
14use fs::read;
15use reifydb_core::util::colored::Colorize;
16
17fn update_temp_path(golden_path: &Path) -> PathBuf {
18	#[allow(clippy::disallowed_methods)]
19	let nanos = SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos();
20	let file_name = golden_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
21	let temp_name = format!(".{}.tmp-{}-{}-{:?}", file_name, id(), nanos, thread::current().id());
22	match golden_path.parent() {
23		Some(parent) => parent.join(temp_name),
24		None => PathBuf::from(temp_name),
25	}
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Mode {
30	Update,
31
32	Compare,
33}
34
35pub struct Mint {
36	dir: PathBuf,
37	tempdir: Option<PathBuf>,
38}
39
40impl Mint {
41	pub fn new_with_mode<P: AsRef<Path>>(dir: P, mode: Mode) -> Self {
42		let dir = dir.as_ref().to_path_buf();
43
44		match mode {
45			Mode::Update => Self {
46				dir,
47				tempdir: None,
48			},
49			Mode::Compare => {
50				#[allow(clippy::disallowed_methods)]
51				let tempdir = env::temp_dir().join(format!(
52					"goldenfiles-{}-{}-{:?}",
53					id(),
54					SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos(),
55					thread::current().id()
56				));
57				fs::create_dir_all(&tempdir).ok();
58
59				Self {
60					dir,
61					tempdir: Some(tempdir),
62				}
63			}
64		}
65	}
66
67	pub fn new<P: AsRef<Path>>(dir: P) -> Self {
68		let dir = dir.as_ref().to_path_buf();
69
70		let should_update = env::var("UPDATE_TESTFILE").is_ok()
71			|| env::var("UPDATE_TESTFILES").is_ok()
72			|| env::var("UPDATE_GOLDENFILE").is_ok()
73			|| env::var("UPDATE_GOLDENFILES").is_ok();
74
75		let mode = if should_update {
76			Mode::Update
77		} else {
78			Mode::Compare
79		};
80
81		Self::new_with_mode(dir, mode)
82	}
83
84	pub fn new_goldenfile<P: AsRef<Path>>(&self, name: P) -> io::Result<GoldenFile> {
85		let name = name.as_ref();
86		let golden_path = self.dir.join(name);
87
88		if let Some(parent) = golden_path.parent() {
89			fs::create_dir_all(parent)?;
90		}
91
92		if let Some(ref tempdir) = self.tempdir {
93			let temp_path = tempdir.join(name);
94
95			if let Some(parent) = temp_path.parent() {
96				fs::create_dir_all(parent)?;
97			}
98
99			let file = OpenOptions::new().write(true).create(true).truncate(true).open(&temp_path)?;
100
101			Ok(GoldenFile {
102				file,
103				temp_path,
104				golden_path,
105				mode: Mode::Compare,
106			})
107		} else {
108			let temp_path = update_temp_path(&golden_path);
109			let file = OpenOptions::new().write(true).create(true).truncate(true).open(&temp_path)?;
110
111			Ok(GoldenFile {
112				file,
113				temp_path,
114				golden_path,
115				mode: Mode::Update,
116			})
117		}
118	}
119
120	pub fn new_golden_file<P: AsRef<Path>>(&self, name: P) -> io::Result<GoldenFile> {
121		self.new_goldenfile(name)
122	}
123}
124
125impl Drop for Mint {
126	fn drop(&mut self) {
127		if let Some(ref dir) = self.tempdir {
128			let _ = fs::remove_dir_all(dir);
129		}
130	}
131}
132
133pub struct GoldenFile {
134	file: File,
135	temp_path: PathBuf,
136	golden_path: PathBuf,
137	mode: Mode,
138}
139
140impl Write for GoldenFile {
141	fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
142		self.file.write(buf)
143	}
144
145	fn flush(&mut self) -> io::Result<()> {
146		self.file.flush()
147	}
148}
149
150impl Drop for GoldenFile {
151	fn drop(&mut self) {
152		let _ = self.file.flush();
153
154		match self.mode {
155			Mode::Compare => self.verify_against_golden(&self.temp_path),
156			Mode::Update => {
157				fs::rename(&self.temp_path, &self.golden_path).unwrap_or_else(|e| {
158					panic!("failed to finalize goldenfile '{}': {e}", self.golden_path.display())
159				});
160			}
161		}
162	}
163}
164
165impl GoldenFile {
166	#[inline]
167	fn verify_against_golden(&self, temp_path: &Path) {
168		self.assert_golden_exists();
169
170		let temp_content = read(temp_path).unwrap_or_default();
171		let golden_content = read(&self.golden_path).unwrap_or_default();
172
173		if temp_content != golden_content {
174			self.panic_with_diff(&golden_content, &temp_content);
175		}
176	}
177
178	#[inline]
179	fn assert_golden_exists(&self) {
180		if !self.golden_path.exists() {
181			panic!(
182				"{}\n{}\n\n{}",
183				format!("Golden file '{}' does not exist", self.golden_path.display()).red().bold(),
184				"Run with UPDATE_TESTFILES=1 to create it.".yellow(),
185				format!("Would create: {}", self.golden_path.display()).bright_black()
186			);
187		}
188	}
189
190	#[inline]
191	fn panic_with_diff(&self, golden_content: &[u8], temp_content: &[u8]) -> ! {
192		let temp_str = String::from_utf8_lossy(temp_content);
193		let golden_str = String::from_utf8_lossy(golden_content);
194
195		let diff_output = create_diff(&golden_str, &temp_str);
196
197		panic!(
198			"{}\n\n{}\n\n{}",
199			format!("Golden file test failed for '{}'", self.golden_path.display()).red().bold(),
200			diff_output,
201			"Run with UPDATE_TESTFILES=1 to update the goldenfile.".yellow()
202		);
203	}
204}
205
206pub fn create_diff(expected: &str, actual: &str) -> String {
207	let mut output = String::new();
208
209	let expected_lines: Vec<&str> = expected.lines().collect();
210	let actual_lines: Vec<&str> = actual.lines().collect();
211
212	let max_lines = expected_lines.len().max(actual_lines.len());
213	let differences = collect_differences(&expected_lines, &actual_lines, max_lines);
214
215	if differences.is_empty() {
216		output.push_str(&format!("{}\n", "Files are identical but binary comparison failed.".yellow()));
217		return output;
218	}
219
220	output.clear();
221
222	let hunks = group_into_hunks(&differences, max_lines);
223	let hunks_to_show = hunks.iter().take(20).cloned().collect::<Vec<_>>();
224	let remaining_hunks = hunks.len().saturating_sub(20);
225
226	render_hunks(&mut output, &hunks_to_show, &expected_lines, &actual_lines);
227	append_footers(&mut output, remaining_hunks, differences.len());
228
229	output
230}
231
232#[inline]
233fn collect_differences(expected_lines: &[&str], actual_lines: &[&str], max_lines: usize) -> Vec<usize> {
234	let mut differences = Vec::new();
235
236	for i in 0..max_lines {
237		let expected_line = expected_lines.get(i).copied();
238		let actual_line = actual_lines.get(i).copied();
239
240		if expected_line != actual_line {
241			differences.push(i);
242		}
243	}
244
245	differences
246}
247
248#[inline]
249fn group_into_hunks(differences: &[usize], max_lines: usize) -> Vec<(usize, usize)> {
250	let context_lines = 3;
251	let mut hunks = Vec::new();
252	let mut current_hunk: Option<(usize, usize)> = None;
253
254	for &diff_line in differences {
255		match current_hunk {
256			None => {
257				let start = diff_line.saturating_sub(context_lines);
258				current_hunk = Some((start, diff_line + 1));
259			}
260			Some((start, end)) => {
261				if diff_line <= end + context_lines {
262					current_hunk = Some((start, diff_line + 1));
263				} else {
264					hunks.push((start, (end + context_lines).min(max_lines)));
265					let new_start = diff_line.saturating_sub(context_lines);
266					current_hunk = Some((new_start, diff_line + 1));
267				}
268			}
269		}
270	}
271
272	if let Some((start, end)) = current_hunk {
273		hunks.push((start, (end + context_lines).min(max_lines)));
274	}
275
276	hunks
277}
278
279#[inline]
280fn render_hunks(output: &mut String, hunks_to_show: &[(usize, usize)], expected_lines: &[&str], actual_lines: &[&str]) {
281	for (hunk_start, hunk_end) in hunks_to_show {
282		let expected_start = hunk_start + 1;
283		let expected_count = expected_lines[*hunk_start..(*hunk_end).min(expected_lines.len())].len();
284		let actual_start = hunk_start + 1;
285		let actual_count = actual_lines[*hunk_start..(*hunk_end).min(actual_lines.len())].len();
286
287		output.push_str(&format!(
288			"{} -{},{} +{},{} {}\n",
289			"@@".bright_cyan(),
290			expected_start,
291			expected_count,
292			actual_start,
293			actual_count,
294			"@@".bright_cyan()
295		));
296
297		for i in *hunk_start..*hunk_end {
298			let line_num = i + 1;
299			let expected_line = expected_lines.get(i).copied();
300			let actual_line = actual_lines.get(i).copied();
301
302			match (expected_line, actual_line) {
303				(Some(e), Some(a)) if e == a => {
304					output.push_str(&format!(
305						"{}  {}\n",
306						format!("{:04}", line_num).bright_black(),
307						e
308					));
309				}
310				(Some(e), Some(a)) => {
311					output.push_str(&format!(
312						"{} {}{}\n",
313						format!("{:04}", line_num).bright_black(),
314						"-".red(),
315						e.red()
316					));
317					output.push_str(&format!("     {}{}\n", "+".green(), a.green()));
318				}
319				(Some(e), None) => {
320					output.push_str(&format!(
321						"{} {}{}\n",
322						format!("{:04}", line_num).bright_black(),
323						"-".red(),
324						e.red()
325					));
326				}
327				(None, Some(a)) => {
328					output.push_str(&format!(
329						"{} {}{}\n",
330						format!("{:04}", line_num).bright_black(),
331						"+".green(),
332						a.green()
333					));
334				}
335				(None, None) => unreachable!(),
336			}
337		}
338	}
339}
340
341#[inline]
342fn append_footers(output: &mut String, remaining_hunks: usize, total_diffs: usize) {
343	if remaining_hunks > 0 {
344		output.push_str(&format!(
345			"\n{}\n",
346			format!(
347				"... and {} more difference{}",
348				remaining_hunks,
349				if remaining_hunks == 1 {
350					""
351				} else {
352					"s"
353				}
354			)
355			.bright_black()
356		));
357	}
358
359	if total_diffs > 10 {
360		output.push_str(&format!(
361			"\n{}\n",
362			format!(
363				"Total: {} line{} differ",
364				total_diffs,
365				if total_diffs == 1 {
366					""
367				} else {
368					"s"
369				}
370			)
371			.bright_black()
372		));
373	}
374}