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)]
18pub enum Mode {
19 Update,
20
21 Compare,
22}
23
24pub struct Mint {
25 dir: PathBuf,
26 tempdir: Option<PathBuf>,
27}
28
29impl Mint {
30 pub fn new_with_mode<P: AsRef<Path>>(dir: P, mode: Mode) -> Self {
31 let dir = dir.as_ref().to_path_buf();
32
33 match mode {
34 Mode::Update => Self {
35 dir,
36 tempdir: None,
37 },
38 Mode::Compare => {
39 #[allow(clippy::disallowed_methods)]
40 let tempdir = env::temp_dir().join(format!(
41 "goldenfiles-{}-{}-{:?}",
42 id(),
43 SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos(),
44 thread::current().id()
45 ));
46 fs::create_dir_all(&tempdir).ok();
47
48 Self {
49 dir,
50 tempdir: Some(tempdir),
51 }
52 }
53 }
54 }
55
56 pub fn new<P: AsRef<Path>>(dir: P) -> Self {
57 let dir = dir.as_ref().to_path_buf();
58
59 let should_update = env::var("UPDATE_TESTFILE").is_ok()
60 || env::var("UPDATE_TESTFILES").is_ok()
61 || env::var("UPDATE_GOLDENFILE").is_ok()
62 || env::var("UPDATE_GOLDENFILES").is_ok();
63
64 let mode = if should_update {
65 Mode::Update
66 } else {
67 Mode::Compare
68 };
69
70 Self::new_with_mode(dir, mode)
71 }
72
73 pub fn new_goldenfile<P: AsRef<Path>>(&self, name: P) -> io::Result<GoldenFile> {
74 let name = name.as_ref();
75 let golden_path = self.dir.join(name);
76
77 if let Some(parent) = golden_path.parent() {
78 fs::create_dir_all(parent)?;
79 }
80
81 if let Some(ref tempdir) = self.tempdir {
82 let temp_path = tempdir.join(name);
83
84 if let Some(parent) = temp_path.parent() {
85 fs::create_dir_all(parent)?;
86 }
87
88 let file = OpenOptions::new().write(true).create(true).truncate(true).open(&temp_path)?;
89
90 Ok(GoldenFile {
91 file,
92 temp_path: Some(temp_path),
93 golden_path,
94 })
95 } else {
96 let file = OpenOptions::new().write(true).create(true).truncate(true).open(&golden_path)?;
97
98 Ok(GoldenFile {
99 file,
100 temp_path: None,
101 golden_path,
102 })
103 }
104 }
105
106 pub fn new_golden_file<P: AsRef<Path>>(&self, name: P) -> io::Result<GoldenFile> {
107 self.new_goldenfile(name)
108 }
109}
110
111impl Drop for Mint {
112 fn drop(&mut self) {
113 if let Some(ref dir) = self.tempdir {
114 let _ = fs::remove_dir_all(dir);
115 }
116 }
117}
118
119pub struct GoldenFile {
120 file: File,
121 temp_path: Option<PathBuf>,
122 golden_path: PathBuf,
123}
124
125impl Write for GoldenFile {
126 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
127 self.file.write(buf)
128 }
129
130 fn flush(&mut self) -> io::Result<()> {
131 self.file.flush()
132 }
133}
134
135impl Drop for GoldenFile {
136 fn drop(&mut self) {
137 let _ = self.file.flush();
138
139 if let Some(ref temp_path) = self.temp_path {
140 if !self.golden_path.exists() {
141 panic!(
142 "{}\n{}\n\n{}",
143 format!("Golden file '{}' does not exist", self.golden_path.display())
144 .red()
145 .bold(),
146 "Run with UPDATE_TESTFILES=1 to create it.".yellow(),
147 format!("Would create: {}", self.golden_path.display()).bright_black()
148 );
149 }
150
151 let temp_content = read(temp_path).unwrap_or_default();
152 let golden_content = read(&self.golden_path).unwrap_or_default();
153
154 if temp_content != golden_content {
155 let temp_str = String::from_utf8_lossy(&temp_content);
156 let golden_str = String::from_utf8_lossy(&golden_content);
157
158 let diff_output = create_diff(&golden_str, &temp_str);
159
160 panic!(
161 "{}\n\n{}\n\n{}",
162 format!("Golden file test failed for '{}'", self.golden_path.display())
163 .red()
164 .bold(),
165 diff_output,
166 "Run with UPDATE_TESTFILES=1 to update the goldenfile.".yellow()
167 );
168 }
169 }
170 }
171}
172
173pub fn create_diff(expected: &str, actual: &str) -> String {
174 let mut output = String::new();
175
176 let expected_lines: Vec<&str> = expected.lines().collect();
177 let actual_lines: Vec<&str> = actual.lines().collect();
178
179 let mut differences = Vec::new();
180 let max_lines = expected_lines.len().max(actual_lines.len());
181
182 for i in 0..max_lines {
183 let expected_line = expected_lines.get(i).copied();
184 let actual_line = actual_lines.get(i).copied();
185
186 if expected_line != actual_line {
187 differences.push(i);
188 }
189 }
190
191 if differences.is_empty() {
192 output.push_str(&format!("{}\n", "Files are identical but binary comparison failed.".yellow()));
193 return output;
194 }
195
196 output.clear();
197
198 let context_lines = 3;
199 let mut hunks = Vec::new();
200 let mut current_hunk: Option<(usize, usize)> = None;
201
202 for &diff_line in &differences {
203 match current_hunk {
204 None => {
205 let start = diff_line.saturating_sub(context_lines);
206 current_hunk = Some((start, diff_line + 1));
207 }
208 Some((start, end)) => {
209 if diff_line <= end + context_lines {
210 current_hunk = Some((start, diff_line + 1));
211 } else {
212 hunks.push((start, (end + context_lines).min(max_lines)));
213 let new_start = diff_line.saturating_sub(context_lines);
214 current_hunk = Some((new_start, diff_line + 1));
215 }
216 }
217 }
218 }
219
220 if let Some((start, end)) = current_hunk {
221 hunks.push((start, (end + context_lines).min(max_lines)));
222 }
223
224 let hunks_to_show = hunks.iter().take(20).cloned().collect::<Vec<_>>();
225 let remaining_hunks = hunks.len().saturating_sub(20);
226
227 for (hunk_start, hunk_end) in &hunks_to_show {
228 let expected_start = hunk_start + 1;
229 let expected_count = expected_lines[*hunk_start..(*hunk_end).min(expected_lines.len())].len();
230 let actual_start = hunk_start + 1;
231 let actual_count = actual_lines[*hunk_start..(*hunk_end).min(actual_lines.len())].len();
232
233 output.push_str(&format!(
234 "{} -{},{} +{},{} {}\n",
235 "@@".bright_cyan(),
236 expected_start,
237 expected_count,
238 actual_start,
239 actual_count,
240 "@@".bright_cyan()
241 ));
242
243 for i in *hunk_start..*hunk_end {
244 let line_num = i + 1;
245 let expected_line = expected_lines.get(i).copied();
246 let actual_line = actual_lines.get(i).copied();
247
248 match (expected_line, actual_line) {
249 (Some(e), Some(a)) if e == a => {
250 output.push_str(&format!(
251 "{} {}\n",
252 format!("{:04}", line_num).bright_black(),
253 e
254 ));
255 }
256 (Some(e), Some(a)) => {
257 output.push_str(&format!(
258 "{} {}{}\n",
259 format!("{:04}", line_num).bright_black(),
260 "-".red(),
261 e.red()
262 ));
263 output.push_str(&format!(" {}{}\n", "+".green(), a.green()));
264 }
265 (Some(e), None) => {
266 output.push_str(&format!(
267 "{} {}{}\n",
268 format!("{:04}", line_num).bright_black(),
269 "-".red(),
270 e.red()
271 ));
272 }
273 (None, Some(a)) => {
274 output.push_str(&format!(
275 "{} {}{}\n",
276 format!("{:04}", line_num).bright_black(),
277 "+".green(),
278 a.green()
279 ));
280 }
281 (None, None) => unreachable!(),
282 }
283 }
284 }
285
286 if remaining_hunks > 0 {
287 output.push_str(&format!(
288 "\n{}\n",
289 format!(
290 "... and {} more difference{}",
291 remaining_hunks,
292 if remaining_hunks == 1 {
293 ""
294 } else {
295 "s"
296 }
297 )
298 .bright_black()
299 ));
300 }
301
302 let total_diffs = differences.len();
303 if total_diffs > 10 {
304 output.push_str(&format!(
305 "\n{}\n",
306 format!(
307 "Total: {} line{} differ",
308 total_diffs,
309 if total_diffs == 1 {
310 ""
311 } else {
312 "s"
313 }
314 )
315 .bright_black()
316 ));
317 }
318
319 output
320}