Skip to main content

reifydb_testing/goldenfile/
mod.rs

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