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/// Test mode for goldenfile operation
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Mode {
20	/// Update mode: write directly to golden files
21	Update,
22	/// Compare mode: write to temp files and compare with golden files
23	Compare,
24}
25
26/// Manages goldenfile creation and comparison for testing
27pub struct Mint {
28	dir: PathBuf,
29	tempdir: Option<PathBuf>,
30}
31
32impl Mint {
33	/// Creates a new Mint instance for the given directory with explicit
34	/// mode
35	pub fn new_with_mode<P: AsRef<Path>>(dir: P, mode: Mode) -> Self {
36		let dir = dir.as_ref().to_path_buf();
37
38		match mode {
39			Mode::Update => Self {
40				dir,
41				tempdir: None,
42			},
43			Mode::Compare => {
44				// In test mode, write to a temp directory first
45				// Use a more unique temp directory name to
46				// avoid conflicts Include thread ID for
47				// better uniqueness in concurrent scenarios
48				#[allow(clippy::disallowed_methods)]
49				let tempdir = env::temp_dir().join(format!(
50					"goldenfiles-{}-{}-{:?}",
51					id(),
52					SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos(),
53					thread::current().id()
54				));
55				fs::create_dir_all(&tempdir).ok();
56
57				Self {
58					dir,
59					tempdir: Some(tempdir),
60				}
61			}
62		}
63	}
64
65	/// Creates a new Mint instance for the given directory
66	/// This method preserves backward compatibility by checking environment
67	/// variables
68	pub fn new<P: AsRef<Path>>(dir: P) -> Self {
69		let dir = dir.as_ref().to_path_buf();
70
71		// Check if we should update goldenfiles
72		let should_update = env::var("UPDATE_TESTFILE").is_ok()
73			|| env::var("UPDATE_TESTFILES").is_ok()
74			|| env::var("UPDATE_GOLDENFILE").is_ok()
75			|| env::var("UPDATE_GOLDENFILES").is_ok();
76
77		let mode = if should_update {
78			Mode::Update
79		} else {
80			Mode::Compare
81		};
82
83		Self::new_with_mode(dir, mode)
84	}
85
86	/// Creates a new golden file with the given name
87	pub fn new_goldenfile<P: AsRef<Path>>(&self, name: P) -> io::Result<GoldenFile> {
88		let name = name.as_ref();
89		let golden_path = self.dir.join(name);
90
91		// Ensure parent directory exists
92		if let Some(parent) = golden_path.parent() {
93			fs::create_dir_all(parent)?;
94		}
95
96		if let Some(ref tempdir) = self.tempdir {
97			// Test mode: write to temp file and compare later
98			let temp_path = tempdir.join(name);
99
100			// Ensure temp parent directory exists
101			if let Some(parent) = temp_path.parent() {
102				fs::create_dir_all(parent)?;
103			}
104
105			let file = OpenOptions::new().write(true).create(true).truncate(true).open(&temp_path)?;
106
107			Ok(GoldenFile {
108				file,
109				temp_path: Some(temp_path),
110				golden_path,
111			})
112		} else {
113			// Update mode: write directly to golden file
114			let file = OpenOptions::new().write(true).create(true).truncate(true).open(&golden_path)?;
115
116			Ok(GoldenFile {
117				file,
118				temp_path: None,
119				golden_path,
120			})
121		}
122	}
123
124	/// Alias for new_goldenfile for compatibility
125	pub fn new_golden_file<P: AsRef<Path>>(&self, name: P) -> io::Result<GoldenFile> {
126		self.new_goldenfile(name)
127	}
128}
129
130impl Drop for Mint {
131	fn drop(&mut self) {
132		if let Some(ref dir) = self.tempdir {
133			let _ = fs::remove_dir_all(dir);
134		}
135	}
136}
137
138pub struct GoldenFile {
139	file: File,
140	temp_path: Option<PathBuf>,
141	golden_path: PathBuf,
142}
143
144impl Write for GoldenFile {
145	fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
146		self.file.write(buf)
147	}
148
149	fn flush(&mut self) -> io::Result<()> {
150		self.file.flush()
151	}
152}
153
154impl Drop for GoldenFile {
155	fn drop(&mut self) {
156		let _ = self.file.flush();
157
158		// If we have a temp path, compare with golden file
159		if let Some(ref temp_path) = self.temp_path {
160			if !self.golden_path.exists() {
161				panic!(
162					"{}\n{}\n\n{}",
163					format!("Golden file '{}' does not exist", self.golden_path.display())
164						.red()
165						.bold(),
166					"Run with UPDATE_TESTFILES=1 to create it.".yellow(),
167					format!("Would create: {}", self.golden_path.display()).bright_black()
168				);
169			}
170
171			let temp_content = read(temp_path).unwrap_or_default();
172			let golden_content = read(&self.golden_path).unwrap_or_default();
173
174			if temp_content != golden_content {
175				let temp_str = String::from_utf8_lossy(&temp_content);
176				let golden_str = String::from_utf8_lossy(&golden_content);
177
178				// Create a git-like diff
179				let diff_output = create_diff(&golden_str, &temp_str);
180
181				panic!(
182					"{}\n\n{}\n\n{}",
183					format!("Golden file test failed for '{}'", self.golden_path.display())
184						.red()
185						.bold(),
186					diff_output,
187					"Run with UPDATE_TESTFILES=1 to update the goldenfile.".yellow()
188				);
189			}
190		}
191	}
192}
193
194/// Creates a git-like unified diff between expected and actual content
195pub fn create_diff(expected: &str, actual: &str) -> String {
196	let mut output = String::new();
197
198	// Split into lines for comparison
199	let expected_lines: Vec<&str> = expected.lines().collect();
200	let actual_lines: Vec<&str> = actual.lines().collect();
201
202	// Find all differences
203	let mut differences = Vec::new();
204	let max_lines = expected_lines.len().max(actual_lines.len());
205
206	for i in 0..max_lines {
207		let expected_line = expected_lines.get(i).copied();
208		let actual_line = actual_lines.get(i).copied();
209
210		if expected_line != actual_line {
211			differences.push(i);
212		}
213	}
214
215	// If no differences found, return empty
216	if differences.is_empty() {
217		output.push_str(&format!("{}\n", "Files are identical but binary comparison failed.".yellow()));
218		return output;
219	}
220
221	// Clear output for clean diff
222	output.clear();
223
224	// Group differences into hunks with context
225	let context_lines = 3;
226	let mut hunks = Vec::new();
227	let mut current_hunk: Option<(usize, usize)> = None;
228
229	for &diff_line in &differences {
230		match current_hunk {
231			None => {
232				// Start a new hunk
233				let start = diff_line.saturating_sub(context_lines);
234				current_hunk = Some((start, diff_line + 1));
235			}
236			Some((start, end)) => {
237				// Check if this difference is close enough to
238				// extend the current hunk
239				if diff_line <= end + context_lines {
240					// Extend current hunk
241					current_hunk = Some((start, diff_line + 1));
242				} else {
243					// Finish current hunk and start a new
244					// one
245					hunks.push((start, (end + context_lines).min(max_lines)));
246					let new_start = diff_line.saturating_sub(context_lines);
247					current_hunk = Some((new_start, diff_line + 1));
248				}
249			}
250		}
251	}
252
253	// Add the last hunk
254	if let Some((start, end)) = current_hunk {
255		hunks.push((start, (end + context_lines).min(max_lines)));
256	}
257
258	// Limit to first 3 hunks to reduce noise
259	let hunks_to_show = hunks.iter().take(20).cloned().collect::<Vec<_>>();
260	let remaining_hunks = hunks.len().saturating_sub(20);
261
262	// Render hunks
263	for (hunk_start, hunk_end) in &hunks_to_show {
264		// Calculate line numbers for the hunk header
265		let expected_start = hunk_start + 1;
266		let expected_count = expected_lines[*hunk_start..(*hunk_end).min(expected_lines.len())].len();
267		let actual_start = hunk_start + 1;
268		let actual_count = actual_lines[*hunk_start..(*hunk_end).min(actual_lines.len())].len();
269
270		output.push_str(&format!(
271			"{} -{},{} +{},{} {}\n",
272			"@@".bright_cyan(),
273			expected_start,
274			expected_count,
275			actual_start,
276			actual_count,
277			"@@".bright_cyan()
278		));
279
280		// Render hunk content with line numbers
281		for i in *hunk_start..*hunk_end {
282			let line_num = i + 1; // 1-indexed line number
283			let expected_line = expected_lines.get(i).copied();
284			let actual_line = actual_lines.get(i).copied();
285
286			match (expected_line, actual_line) {
287				(Some(e), Some(a)) if e == a => {
288					// Context line - show line number in
289					// gray with 4 digits
290					output.push_str(&format!(
291						"{}  {}\n",
292						format!("{:04}", line_num).bright_black(),
293						e
294					));
295				}
296				(Some(e), Some(a)) => {
297					// Changed line - show line number for
298					// both
299					output.push_str(&format!(
300						"{} {}{}\n",
301						format!("{:04}", line_num).bright_black(),
302						"-".red(),
303						e.red()
304					));
305					output.push_str(&format!("     {}{}\n", "+".green(), a.green()));
306				}
307				(Some(e), None) => {
308					// Deleted line
309					output.push_str(&format!(
310						"{} {}{}\n",
311						format!("{:04}", line_num).bright_black(),
312						"-".red(),
313						e.red()
314					));
315				}
316				(None, Some(a)) => {
317					// Added line
318					output.push_str(&format!(
319						"{} {}{}\n",
320						format!("{:04}", line_num).bright_black(),
321						"+".green(),
322						a.green()
323					));
324				}
325				(None, None) => unreachable!(),
326			}
327		}
328	}
329
330	// If there are more hunks, indicate that
331	if remaining_hunks > 0 {
332		output.push_str(&format!(
333			"\n{}\n",
334			format!(
335				"... and {} more difference{}",
336				remaining_hunks,
337				if remaining_hunks == 1 {
338					""
339				} else {
340					"s"
341				}
342			)
343			.bright_black()
344		));
345	}
346
347	// Add summary
348	let total_diffs = differences.len();
349	if total_diffs > 10 {
350		output.push_str(&format!(
351			"\n{}\n",
352			format!(
353				"Total: {} line{} differ",
354				total_diffs,
355				if total_diffs == 1 {
356					""
357				} else {
358					"s"
359				}
360			)
361			.bright_black()
362		));
363	}
364
365	output
366}