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,
11 time::SystemTime,
12};
13
14use fs::read;
15use reifydb_core::util::colored::Colorize;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Mode {
20 Update,
22 Compare,
24}
25
26pub struct Mint {
28 dir: PathBuf,
29 tempdir: Option<PathBuf>,
30}
31
32impl Mint {
33 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 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 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()
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 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 if let Some(parent) = golden_path.parent() {
92 fs::create_dir_all(parent)?;
93 }
94
95 if let Some(ref tempdir) = self.tempdir {
96 let temp_path = tempdir.join(name);
98
99 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 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 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 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 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
193fn create_diff(expected: &str, actual: &str) -> String {
195 let mut output = String::new();
196
197 let expected_lines: Vec<&str> = expected.lines().collect();
199 let actual_lines: Vec<&str> = actual.lines().collect();
200
201 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 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();
222
223 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 let start = diff_line.saturating_sub(context_lines);
233 current_hunk = Some((start, diff_line + 1));
234 }
235 Some((start, end)) => {
236 if diff_line <= end + context_lines {
239 current_hunk = Some((start, diff_line + 1));
241 } else {
242 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 if let Some((start, end)) = current_hunk {
254 hunks.push((start, (end + context_lines).min(max_lines)));
255 }
256
257 let hunks_to_show = hunks.iter().take(20).cloned().collect::<Vec<_>>();
259 let remaining_hunks = hunks.len().saturating_sub(20);
260
261 for (hunk_start, hunk_end) in &hunks_to_show {
263 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 for i in *hunk_start..*hunk_end {
281 let line_num = i + 1; let expected_line = expected_lines.get(i).copied();
283 let actual_line = actual_lines.get(i).copied();
284
285 let truncate_line = |line: &str| -> String {
287 if line.len() > 100 {
288 let mut char_boundary = 97;
290 while !line.is_char_boundary(char_boundary) && char_boundary > 0 {
291 char_boundary -= 1;
292 }
293 format!("{}...", &line[..char_boundary])
294 } else {
295 line.to_string()
296 }
297 };
298
299 match (expected_line, actual_line) {
300 (Some(e), Some(a)) if e == a => {
301 output.push_str(&format!(
304 "{} {}\n",
305 format!("{:04}", line_num).bright_black(),
306 truncate_line(e)
307 ));
308 }
309 (Some(e), Some(a)) => {
310 output.push_str(&format!(
313 "{} {}{}\n",
314 format!("{:04}", line_num).bright_black(),
315 "-".red(),
316 truncate_line(e).red()
317 ));
318 output.push_str(&format!(" {}{}\n", "+".green(), truncate_line(a).green()));
319 }
320 (Some(e), None) => {
321 output.push_str(&format!(
323 "{} {}{}\n",
324 format!("{:04}", line_num).bright_black(),
325 "-".red(),
326 truncate_line(e).red()
327 ));
328 }
329 (None, Some(a)) => {
330 output.push_str(&format!(
332 "{} {}{}\n",
333 format!("{:04}", line_num).bright_black(),
334 "+".green(),
335 truncate_line(a).green()
336 ));
337 }
338 (None, None) => unreachable!(),
339 }
340 }
341 }
342
343 if remaining_hunks > 0 {
345 output.push_str(&format!(
346 "\n{}\n",
347 format!(
348 "... and {} more difference{}",
349 remaining_hunks,
350 if remaining_hunks == 1 {
351 ""
352 } else {
353 "s"
354 }
355 )
356 .bright_black()
357 ));
358 }
359
360 let total_diffs = differences.len();
362 if total_diffs > 10 {
363 output.push_str(&format!(
364 "\n{}\n",
365 format!(
366 "Total: {} line{} differ",
367 total_diffs,
368 if total_diffs == 1 {
369 ""
370 } else {
371 "s"
372 }
373 )
374 .bright_black()
375 ));
376 }
377
378 output
379}