Skip to main content

regression_test/
lib.rs

1//! Regression testing utilities
2
3use serde::{Deserialize, Serialize};
4use std::fmt::{Debug, Display};
5use std::fs::OpenOptions;
6use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8
9#[derive(Serialize, Deserialize, Debug, PartialEq)]
10#[serde(rename_all = "lowercase")]
11enum RegType {
12    Display,
13    Debug,
14}
15
16#[derive(Serialize, Deserialize, Debug)]
17struct RegEntry {
18    #[serde(rename = "type")]
19    reg_type: RegType,
20    message: String,
21}
22
23/// Regression test mode
24enum Mode {
25    /// We are currently generating the regression test data, and writing it on
26    /// disk when appropriate.
27    Write,
28    /// We are curently comparing previously generated regression test data with
29    /// current output, to determine delta.
30    Read,
31}
32
33/// `RegTest` is a utility for regression testing by recording and comparing test outputs.
34///
35/// This struct manages regression test data in two modes:
36/// - **Write mode**: Captures and stores test output data to a file for future regression runs.
37/// - **Read mode**: Loads previously recorded regression data and compares it with current test output,
38///   reporting any mismatches or differences.
39///
40/// # Usage
41/// - Use [`RegTest::new`] to create a new instance, specifying the file path for regression data.
42/// - Use [`regtest`] and [`regtest_dbg`] methods to record or compare values in Display or Debug format.
43/// - When dropped, if in write mode, the struct writes all buffered entries to the specified file.
44///
45/// # Example
46/// ```rust
47/// use regression_test::RegTest;
48///
49/// let mut regtest = RegTest::new("./regtest_data/regression.json").unwrap();
50/// regtest.regtest("some output");
51/// regtest.regtest_dbg(vec![1, 2, 3]);
52/// // Data is written to file when regtest goes out of scope.
53/// ```
54pub struct RegTest {
55    /// File path to the regression test output
56    file_path: PathBuf,
57    /// Test mode -- if we are currently generating the regression test data, or
58    /// comparing it.
59    mode: Mode,
60    /// In [Mode::Write]. Caches the entries when generating regression test
61    /// data, and written only when this structure goes out of scope or is
62    /// manually dropped.
63    ///
64    /// In [Mode::Read], contains all previously generated regression test data,
65    /// and is used to compare with current output.
66    buffer: Vec<RegEntry>,
67    /// Used in [Mode::Read]. Next regression test to process.
68    read_index: usize,
69}
70
71impl RegTest {
72    pub fn new<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
73        let file_path = path.as_ref().to_path_buf();
74
75        if file_path.exists() {
76            // Store all entries in memory
77            let file = OpenOptions::new().read(true).open(&file_path)?;
78
79            let mut reader = std::io::BufReader::new(file);
80
81            let buffer = match serde_json::from_reader(&mut reader) {
82                Ok(entries) => entries,
83                Err(e) => {
84                    eprintln!(
85                        "Failed to read regression test file {}: {}",
86                        file_path.display(),
87                        e
88                    );
89                    return Err(e.into());
90                }
91            };
92
93            Ok(RegTest {
94                file_path,
95                mode: Mode::Read,
96                buffer,
97                read_index: 0,
98            })
99        } else {
100            Ok(RegTest {
101                file_path,
102                mode: Mode::Write,
103                buffer: Vec::new(),
104                read_index: 0,
105            })
106        }
107    }
108
109    fn regtest_internal(&mut self, message: String, reg_type: RegType) {
110        match self.mode {
111            Mode::Write => {
112                self.buffer.push(RegEntry { reg_type, message });
113            }
114            Mode::Read => {
115                if self.read_index >= self.buffer.len() {
116                    panic!("No more regression entries in file, but test expected more.");
117                }
118
119                let expected = &self.buffer[self.read_index];
120                self.read_index += 1;
121
122                if expected.reg_type != reg_type {
123                    panic!(
124                        "Regression data generated in different ways: expected {:?}, got {:?}",
125                        expected.reg_type, reg_type
126                    );
127                }
128
129                if expected.message != message {
130                    panic!(
131                        "Regression message mismatch:\nExpected: {}\nActual:   {}\n\nDiff:\n{}",
132                        expected.message,
133                        message,
134                        diff_lines(&expected.message, &message)
135                    );
136                }
137            }
138        }
139    }
140
141    pub fn regtest<T: Display>(&mut self, value: T) {
142        self.regtest_internal(format!("{}", value), RegType::Display);
143    }
144
145    pub fn regtest_dbg<T: Debug>(&mut self, value: T) {
146        self.regtest_internal(format!("{:?}", value), RegType::Debug);
147    }
148}
149
150impl Drop for RegTest {
151    fn drop(&mut self) {
152        if let Mode::Write = self.mode {
153            // Only create/write the file here
154            if let Ok(file) = OpenOptions::new()
155                .write(true)
156                .create(true)
157                .truncate(true)
158                .open(&self.file_path)
159            {
160                let mut writer = BufWriter::new(file);
161                if serde_json::to_writer_pretty(&mut writer, &self.buffer).is_ok() {
162                    let _ = writer.flush();
163                }
164            }
165        }
166    }
167}
168
169fn diff_lines(expected: &str, actual: &str) -> String {
170    let exp_lines: Vec<_> = expected.lines().collect();
171    let act_lines: Vec<_> = actual.lines().collect();
172    let max = exp_lines.len().max(act_lines.len());
173
174    let mut diff = String::new();
175    let mut minus_block = Vec::new();
176    let mut plus_block = Vec::new();
177
178    for i in 0..max {
179        let exp = exp_lines.get(i).unwrap_or(&"");
180        let act = act_lines.get(i).unwrap_or(&"");
181
182        if exp != act {
183            if !exp.is_empty() {
184                minus_block.push(exp);
185            }
186            if !act.is_empty() {
187                plus_block.push(act);
188            }
189        } else {
190            if !minus_block.is_empty() || !plus_block.is_empty() {
191                if !minus_block.is_empty() {
192                    for line in &minus_block {
193                        diff.push_str(&format!("- {}\n", line));
194                    }
195                    minus_block.clear();
196                }
197                if !plus_block.is_empty() {
198                    for line in &plus_block {
199                        diff.push_str(&format!("+ {}\n", line));
200                    }
201                    plus_block.clear();
202                }
203            } else {
204                diff.push_str(&format!("  {}\n", exp));
205            }
206        }
207    }
208
209    // Flush any remaining blocks
210    if !minus_block.is_empty() {
211        for line in &minus_block {
212            diff.push_str(&format!("- {}\n", line));
213        }
214    }
215    if !plus_block.is_empty() {
216        for line in &plus_block {
217            diff.push_str(&format!("+ {}\n", line));
218        }
219    }
220
221    diff
222}