protest 1.1.0

An ergonomic, powerful, and feature-rich property testing library with minimal boilerplate.
Documentation
//! Automatic regression test generation from saved failures
//!
//! This module provides functionality to convert saved test failures into
//! permanent regression test code that can be committed to version control.

use crate::persistence::{FailureCase, FailureSnapshot};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

/// Configuration for regression test generation
#[derive(Debug, Clone)]
pub struct RegressionConfig {
    /// Directory to write generated test files
    pub output_dir: PathBuf,

    /// Module name for generated tests
    pub module_name: String,

    /// Include timestamp in generated code
    pub include_timestamp: bool,

    /// Include original error message as comment
    pub include_error_comment: bool,

    /// Generate seed-based tests (replay with seed)
    pub seed_based: bool,

    /// Generate value-based tests (test specific input value)
    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
    }
}

/// Generator for regression test code
pub struct RegressionGenerator {
    config: RegressionConfig,
}

impl RegressionGenerator {
    pub fn new(config: RegressionConfig) -> Self {
        Self { config }
    }

    /// Generate regression tests for all saved failures of a specific test
    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),
            ));
        }

        // Create output directory
        fs::create_dir_all(&self.config.output_dir)?;

        // Generate test file
        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)?;

        // Write file header
        self.write_header(&mut file, test_name, &failures)?;

        // Generate tests for each failure
        for (idx, failure) in failures.iter().enumerate() {
            writeln!(file)?;
            self.write_test(&mut file, test_name, idx, failure)?;
        }

        Ok(file_path)
    }

    /// Generate regression tests for all tests with failures
    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 => {
                    // Skip tests with no failures
                    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
        );

        // Write comment with context
        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 {
            // Generate seed-based test (replay with exact seed)
            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(())
    }
}

/// Sanitize a test name for use in Rust identifiers
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");

        // Create a test failure
        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();

        // Generate regression test
        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());

        // Verify file content
        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();

        // Create failures for multiple tests
        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());
        }
    }
}