use crate::persistence::{FailureCase, FailureSnapshot};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RegressionConfig {
pub output_dir: PathBuf,
pub module_name: String,
pub include_timestamp: bool,
pub include_error_comment: bool,
pub seed_based: bool,
pub value_based: bool,
}
impl Default for RegressionConfig {
fn default() -> Self {
Self {
output_dir: PathBuf::from("tests/regressions"),
module_name: "regression_tests".to_string(),
include_timestamp: true,
include_error_comment: true,
seed_based: true,
value_based: false,
}
}
}
impl RegressionConfig {
pub fn new<P: AsRef<Path>>(output_dir: P) -> Self {
Self {
output_dir: output_dir.as_ref().to_path_buf(),
..Default::default()
}
}
pub fn with_module_name(mut self, name: String) -> Self {
self.module_name = name;
self
}
pub fn include_timestamp(mut self, include: bool) -> Self {
self.include_timestamp = include;
self
}
pub fn seed_based(mut self, enabled: bool) -> Self {
self.seed_based = enabled;
self
}
pub fn value_based(mut self, enabled: bool) -> Self {
self.value_based = enabled;
self
}
}
pub struct RegressionGenerator {
config: RegressionConfig,
}
impl RegressionGenerator {
pub fn new(config: RegressionConfig) -> Self {
Self { config }
}
pub fn generate_for_test(
&self,
snapshot: &FailureSnapshot,
test_name: &str,
) -> io::Result<PathBuf> {
let failures = snapshot.load_failures(test_name)?;
if failures.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("No failures found for test '{}'", test_name),
));
}
fs::create_dir_all(&self.config.output_dir)?;
let file_name = format!("{}_regressions.rs", sanitize_name(test_name));
let file_path = self.config.output_dir.join(&file_name);
let mut file = fs::File::create(&file_path)?;
self.write_header(&mut file, test_name, &failures)?;
for (idx, failure) in failures.iter().enumerate() {
writeln!(file)?;
self.write_test(&mut file, test_name, idx, failure)?;
}
Ok(file_path)
}
pub fn generate_all(&self, snapshot: &FailureSnapshot) -> io::Result<Vec<PathBuf>> {
let tests = snapshot.list_tests_with_failures()?;
let mut generated_files = Vec::new();
for test_name in tests {
match self.generate_for_test(snapshot, &test_name) {
Ok(path) => generated_files.push(path),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
continue;
}
Err(e) => return Err(e),
}
}
Ok(generated_files)
}
fn write_header(
&self,
file: &mut fs::File,
test_name: &str,
failures: &[FailureCase],
) -> io::Result<()> {
writeln!(file, "//! Regression tests for '{}' failures", test_name)?;
writeln!(file, "//!")?;
writeln!(
file,
"//! This file was automatically generated by Protest."
)?;
writeln!(
file,
"//! It contains {} regression test(s) to ensure previously",
failures.len()
)?;
writeln!(file, "//! discovered failures don't reoccur.")?;
writeln!(file)?;
writeln!(file, "#![allow(unused_imports)]")?;
writeln!(file)?;
writeln!(file, "use protest::*;")?;
writeln!(file)?;
Ok(())
}
fn write_test(
&self,
file: &mut fs::File,
test_name: &str,
_idx: usize,
failure: &FailureCase,
) -> io::Result<()> {
let test_fn_name = format!(
"regression_{}_seed_{}",
sanitize_name(test_name),
failure.seed
);
if self.config.include_error_comment {
writeln!(
file,
"/// Regression test for failure with seed {}",
failure.seed
)?;
writeln!(file, "///")?;
writeln!(file, "/// Original error: {}", failure.error_message)?;
writeln!(file, "/// Input: {}", failure.input)?;
writeln!(file, "/// Shrink steps: {}", failure.shrink_steps)?;
if self.config.include_timestamp {
use chrono::{DateTime, Utc};
let datetime = DateTime::<Utc>::from(failure.timestamp);
writeln!(
file,
"/// Discovered: {}",
datetime.format("%Y-%m-%d %H:%M:%S UTC")
)?;
}
}
writeln!(file, "#[test]")?;
writeln!(file, "fn {}() {{", test_fn_name)?;
if self.config.seed_based {
writeln!(
file,
" // This test replays the failure using the original seed"
)?;
writeln!(file, " // If the bug is fixed, this test should pass")?;
writeln!(file, " //")?;
writeln!(file, " // TODO: Replace with your actual test setup")?;
writeln!(file, " // Example:")?;
writeln!(file, " // PropertyTestBuilder::new()")?;
writeln!(file, " // .iterations(1)")?;
writeln!(file, " // .seed({})", failure.seed)?;
writeln!(file, " // .run(your_generator, your_property)")?;
writeln!(
file,
" // .expect(\"Regression: failure should not reoccur\");"
)?;
writeln!(file)?;
writeln!(
file,
" panic!(\"TODO: Implement regression test for seed {}\");",
failure.seed
)?;
}
writeln!(file, "}}")?;
Ok(())
}
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_sanitize_name() {
assert_eq!(sanitize_name("my_test"), "my_test");
assert_eq!(sanitize_name("my-test"), "my_test");
assert_eq!(sanitize_name("my::test"), "my__test");
assert_eq!(sanitize_name("my test!"), "my_test_");
}
#[test]
fn test_generate_regression_file() {
let temp_dir = TempDir::new().unwrap();
let failures_dir = temp_dir.path().join("failures");
let output_dir = temp_dir.path().join("output");
let snapshot = FailureSnapshot::new(&failures_dir).unwrap();
let failure = FailureCase::new(
12345,
"test_input".to_string(),
"Value too large".to_string(),
5,
);
snapshot.save_failure("my_test", &failure).unwrap();
let config = RegressionConfig::new(&output_dir);
let generator = RegressionGenerator::new(config);
let generated_file = generator.generate_for_test(&snapshot, "my_test").unwrap();
assert!(generated_file.exists());
let content = fs::read_to_string(&generated_file).unwrap();
assert!(content.contains("regression_my_test_seed_12345"));
assert!(content.contains("Value too large"));
assert!(content.contains(".seed(12345)"));
}
#[test]
fn test_generate_all_regressions() {
let temp_dir = TempDir::new().unwrap();
let failures_dir = temp_dir.path().join("failures");
let output_dir = temp_dir.path().join("output");
let snapshot = FailureSnapshot::new(&failures_dir).unwrap();
snapshot
.save_failure(
"test_a",
&FailureCase::new(111, "input_a".to_string(), "error_a".to_string(), 1),
)
.unwrap();
snapshot
.save_failure(
"test_b",
&FailureCase::new(222, "input_b".to_string(), "error_b".to_string(), 2),
)
.unwrap();
let config = RegressionConfig::new(&output_dir);
let generator = RegressionGenerator::new(config);
let files = generator.generate_all(&snapshot).unwrap();
assert_eq!(files.len(), 2);
for file in &files {
assert!(file.exists());
}
}
}