Skip to main content

reifydb_testing/goldenfile/
mod.rs

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