reifydb_testing/goldenfile/
mod.rs1use 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}