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
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 #[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 pub fn new<P: AsRef<Path>>(dir: P) -> Self {
69 let dir = dir.as_ref().to_path_buf();
70
71 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 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 if let Some(parent) = golden_path.parent() {
93 fs::create_dir_all(parent)?;
94 }
95
96 if let Some(ref tempdir) = self.tempdir {
97 let temp_path = tempdir.join(name);
99
100 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 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 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 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 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
194pub fn create_diff(expected: &str, actual: &str) -> String {
196 let mut output = String::new();
197
198 let expected_lines: Vec<&str> = expected.lines().collect();
200 let actual_lines: Vec<&str> = actual.lines().collect();
201
202 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 differences.is_empty() {
217 output.push_str(&format!("{}\n", "Files are identical but binary comparison failed.".yellow()));
218 return output;
219 }
220
221 output.clear();
223
224 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 let start = diff_line.saturating_sub(context_lines);
234 current_hunk = Some((start, diff_line + 1));
235 }
236 Some((start, end)) => {
237 if diff_line <= end + context_lines {
240 current_hunk = Some((start, diff_line + 1));
242 } else {
243 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 if let Some((start, end)) = current_hunk {
255 hunks.push((start, (end + context_lines).min(max_lines)));
256 }
257
258 let hunks_to_show = hunks.iter().take(20).cloned().collect::<Vec<_>>();
260 let remaining_hunks = hunks.len().saturating_sub(20);
261
262 for (hunk_start, hunk_end) in &hunks_to_show {
264 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 for i in *hunk_start..*hunk_end {
282 let line_num = i + 1; 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 output.push_str(&format!(
291 "{} {}\n",
292 format!("{:04}", line_num).bright_black(),
293 e
294 ));
295 }
296 (Some(e), Some(a)) => {
297 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 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 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 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 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}