use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureCase {
pub seed: u64,
pub input: String,
pub error_message: String,
pub timestamp: SystemTime,
pub shrink_steps: usize,
pub metadata: HashMap<String, String>,
}
impl FailureCase {
pub fn new(seed: u64, input: String, error_message: String, shrink_steps: usize) -> Self {
Self {
seed,
input,
error_message,
timestamp: SystemTime::now(),
shrink_steps,
metadata: HashMap::new(),
}
}
pub fn with_metadata(mut self, key: String, value: String) -> Self {
self.metadata.insert(key, value);
self
}
pub fn deserialize_input<T>(&self) -> Option<T>
where
T: for<'de> Deserialize<'de>,
{
serde_json::from_str(&self.input).ok()
}
}
pub struct FailureSnapshot {
root_dir: PathBuf,
}
impl FailureSnapshot {
pub fn new<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let root_dir = path.as_ref().to_path_buf();
fs::create_dir_all(&root_dir)?;
Ok(Self { root_dir })
}
pub fn save_failure(&self, test_name: &str, failure: &FailureCase) -> io::Result<PathBuf> {
let test_dir = self.root_dir.join(test_name);
fs::create_dir_all(&test_dir)?;
let filename = format!("failure_seed_{}.json", failure.seed);
let path = test_dir.join(filename);
let json = serde_json::to_string_pretty(failure)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let mut file = File::create(&path)?;
file.write_all(json.as_bytes())?;
Ok(path)
}
pub fn load_failures(&self, test_name: &str) -> io::Result<Vec<FailureCase>> {
let test_dir = self.root_dir.join(test_name);
if !test_dir.exists() {
return Ok(Vec::new());
}
let mut failures = Vec::new();
for entry in fs::read_dir(&test_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let mut file = File::open(&path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
if let Ok(failure) = serde_json::from_str::<FailureCase>(&contents) {
failures.push(failure);
}
}
}
Ok(failures)
}
pub fn delete_failure(&self, test_name: &str, seed: u64) -> io::Result<()> {
let filename = format!("failure_seed_{}.json", seed);
let path = self.root_dir.join(test_name).join(filename);
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn list_tests_with_failures(&self) -> io::Result<Vec<String>> {
let mut tests = Vec::new();
for entry in fs::read_dir(&self.root_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir()
&& let Some(name) = path.file_name().and_then(|s| s.to_str())
{
tests.push(name.to_string());
}
}
Ok(tests)
}
pub fn clear_test_failures(&self, test_name: &str) -> io::Result<()> {
let test_dir = self.root_dir.join(test_name);
if test_dir.exists() {
fs::remove_dir_all(test_dir)?;
}
Ok(())
}
}
pub struct TestCorpus {
corpus_dir: PathBuf,
cached_cases: Vec<CorpusCase>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorpusCase {
pub input: String,
pub reason: String,
pub timestamp: SystemTime,
pub tags: Vec<String>,
}
impl TestCorpus {
pub fn new<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let corpus_dir = path.as_ref().to_path_buf();
fs::create_dir_all(&corpus_dir)?;
Ok(Self {
corpus_dir,
cached_cases: Vec::new(),
})
}
pub fn add_case(&mut self, input: String, reason: String) -> io::Result<()> {
let case = CorpusCase {
input,
reason,
timestamp: SystemTime::now(),
tags: Vec::new(),
};
self.add_corpus_case(case)
}
pub fn add_corpus_case(&mut self, case: CorpusCase) -> io::Result<()> {
let nanos = case
.timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let filename = format!("corpus_{}.json", nanos);
let path = self.corpus_dir.join(filename);
let json = serde_json::to_string_pretty(&case)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let mut file = File::create(&path)?;
file.write_all(json.as_bytes())?;
self.cached_cases.push(case);
Ok(())
}
pub fn load_all(&mut self) -> io::Result<Vec<CorpusCase>> {
self.cached_cases.clear();
for entry in fs::read_dir(&self.corpus_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let mut file = File::open(&path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
if let Ok(case) = serde_json::from_str::<CorpusCase>(&contents) {
self.cached_cases.push(case);
}
}
}
Ok(self.cached_cases.clone())
}
pub fn cases(&self) -> &[CorpusCase] {
&self.cached_cases
}
}
#[derive(Debug, Clone)]
pub struct PersistenceConfig {
pub persist_failures: bool,
pub failure_dir: PathBuf,
pub use_corpus: bool,
pub corpus_dir: Option<PathBuf>,
pub replay_failures: bool,
}
impl Default for PersistenceConfig {
fn default() -> Self {
Self {
persist_failures: false,
failure_dir: PathBuf::from(".protest/failures"),
use_corpus: false,
corpus_dir: None,
replay_failures: true,
}
}
}
impl PersistenceConfig {
pub fn enabled() -> Self {
Self {
persist_failures: true,
failure_dir: PathBuf::from(".protest/failures"),
use_corpus: true,
corpus_dir: Some(PathBuf::from(".protest/corpus")),
replay_failures: true,
}
}
pub fn with_failure_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.failure_dir = dir.into();
self
}
pub fn with_corpus_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.corpus_dir = Some(dir.into());
self
}
pub fn enable_persistence(mut self) -> Self {
self.persist_failures = true;
self
}
pub fn enable_corpus(mut self) -> Self {
self.use_corpus = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_failure_snapshot_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let snapshot = FailureSnapshot::new(temp_dir.path()).unwrap();
let failure =
FailureCase::new(12345, "test input".to_string(), "test error".to_string(), 5);
snapshot.save_failure("test_function", &failure).unwrap();
let loaded = snapshot.load_failures("test_function").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].seed, 12345);
assert_eq!(loaded[0].input, "test input");
}
#[test]
fn test_failure_snapshot_delete() {
let temp_dir = TempDir::new().unwrap();
let snapshot = FailureSnapshot::new(temp_dir.path()).unwrap();
let failure =
FailureCase::new(12345, "test input".to_string(), "test error".to_string(), 5);
snapshot.save_failure("test_function", &failure).unwrap();
snapshot.delete_failure("test_function", 12345).unwrap();
let loaded = snapshot.load_failures("test_function").unwrap();
assert_eq!(loaded.len(), 0);
}
#[test]
fn test_corpus_add_and_load() {
let temp_dir = TempDir::new().unwrap();
let mut corpus = TestCorpus::new(temp_dir.path()).unwrap();
corpus
.add_case(
"interesting input".to_string(),
"found edge case".to_string(),
)
.unwrap();
let cases = corpus.load_all().unwrap();
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].input, "interesting input");
}
#[test]
fn test_list_tests_with_failures() {
let temp_dir = TempDir::new().unwrap();
let snapshot = FailureSnapshot::new(temp_dir.path()).unwrap();
let failure =
FailureCase::new(12345, "test input".to_string(), "test error".to_string(), 5);
snapshot.save_failure("test1", &failure).unwrap();
snapshot.save_failure("test2", &failure).unwrap();
let tests = snapshot.list_tests_with_failures().unwrap();
assert_eq!(tests.len(), 2);
assert!(tests.contains(&"test1".to_string()));
assert!(tests.contains(&"test2".to_string()));
}
}