use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::config::FlakyDetectionConfig;
use super::error::CliError;
use super::parser::{TestOutcome, TestResult};
const HISTORY_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TestHistory {
pub version: u32,
pub tests: HashMap<String, TestEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[non_exhaustive]
pub struct TestEntry {
pub consecutive_passes: u32,
pub last_outcome: String,
pub source_hash: String,
}
impl TestEntry {
pub const fn new() -> Self {
Self {
consecutive_passes: 0,
last_outcome: String::new(),
source_hash: String::new(),
}
}
}
impl Default for TestEntry {
fn default() -> Self {
Self::new()
}
}
impl TestHistory {
pub fn new() -> Self {
Self {
version: HISTORY_VERSION,
tests: HashMap::new(),
}
}
}
impl Default for TestHistory {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct FlakyTest {
pub name: String,
pub consecutive_passes: u32,
}
impl FlakyTest {
pub const fn new(name: String, consecutive_passes: u32) -> Self {
Self {
name,
consecutive_passes,
}
}
}
pub fn load_history(path: &Path) -> Result<TestHistory, CliError> {
if !path.exists() {
return Ok(TestHistory::new());
}
let contents =
std::fs::read_to_string(path).map_err(|source| CliError::HistoryIo { source })?;
serde_json::from_str(&contents).map_err(|err| CliError::HistoryIo {
source: std::io::Error::new(std::io::ErrorKind::InvalidData, err),
})
}
pub fn save_history(path: &Path, history: &TestHistory) -> Result<(), CliError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| CliError::HistoryIo { source })?;
}
let json = serde_json::to_string_pretty(history).map_err(|err| CliError::HistoryIo {
source: std::io::Error::new(std::io::ErrorKind::InvalidData, err),
})?;
std::fs::write(path, json).map_err(|source| CliError::HistoryIo { source })
}
pub fn hash_source(content: &[u8]) -> String {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn update_and_detect(
history: &mut TestHistory,
results: &[TestResult],
config: &FlakyDetectionConfig,
current_source_hash: &str,
) -> Vec<FlakyTest> {
let mut flaky_tests = Vec::new();
for result in results {
let entry = history.tests.entry(result.full_name.clone()).or_default();
match result.outcome {
TestOutcome::Pass => {
entry.consecutive_passes += 1;
entry.last_outcome = "pass".to_string();
entry.source_hash = current_source_hash.to_string();
}
TestOutcome::Fail => {
if entry.consecutive_passes >= config.consecutive_passes
&& entry.source_hash == current_source_hash
{
flaky_tests.push(FlakyTest::new(
result.full_name.clone(),
entry.consecutive_passes,
));
}
entry.consecutive_passes = 0;
entry.last_outcome = "fail".to_string();
entry.source_hash = current_source_hash.to_string();
}
TestOutcome::Ignored | TestOutcome::Skipped | TestOutcome::Flaky => {
entry.last_outcome = "ignored".to_string();
}
}
}
flaky_tests
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::config::FlakyDetectionConfig;
use crate::cli::parser::{TestOutcome, TestResult};
#[test]
fn new_history_is_empty() {
let history = TestHistory::new();
assert_eq!(history.version, HISTORY_VERSION);
assert!(history.tests.is_empty());
}
#[test]
fn new_entry_defaults() {
let entry = TestEntry::new();
assert_eq!(entry.consecutive_passes, 0);
assert!(entry.last_outcome.is_empty());
assert!(entry.source_hash.is_empty());
}
#[test]
fn hash_source_deterministic() {
let h1 = hash_source(b"hello world");
let h2 = hash_source(b"hello world");
assert_eq!(h1, h2);
}
#[test]
fn hash_source_different_content() {
let h1 = hash_source(b"hello");
let h2 = hash_source(b"world");
assert_ne!(h1, h2);
}
#[test]
fn update_pass_increments_count() {
let mut history = TestHistory::new();
let results = vec![TestResult::new("test::a".to_string(), TestOutcome::Pass)];
let config = FlakyDetectionConfig::default();
let flaky = update_and_detect(&mut history, &results, &config, "hash1");
assert!(flaky.is_empty());
assert_eq!(history.tests["test::a"].consecutive_passes, 1);
assert_eq!(history.tests["test::a"].last_outcome, "pass");
}
#[test]
fn update_fail_resets_count() {
let mut history = TestHistory::new();
history.tests.insert(
"test::a".to_string(),
TestEntry {
consecutive_passes: 3,
last_outcome: "pass".to_string(),
source_hash: "hash1".to_string(),
},
);
let results = vec![TestResult::new("test::a".to_string(), TestOutcome::Fail)];
let config = FlakyDetectionConfig {
consecutive_passes: 10,
..FlakyDetectionConfig::default()
};
let flaky = update_and_detect(&mut history, &results, &config, "hash1");
assert!(flaky.is_empty());
assert_eq!(history.tests["test::a"].consecutive_passes, 0);
}
#[test]
fn detects_flaky_test() {
let mut history = TestHistory::new();
history.tests.insert(
"test::flaky".to_string(),
TestEntry {
consecutive_passes: 10,
last_outcome: "pass".to_string(),
source_hash: "same_hash".to_string(),
},
);
let results = vec![TestResult::new(
"test::flaky".to_string(),
TestOutcome::Fail,
)];
let config = FlakyDetectionConfig {
consecutive_passes: 5,
..FlakyDetectionConfig::default()
};
let flaky = update_and_detect(&mut history, &results, &config, "same_hash");
assert_eq!(flaky.len(), 1);
assert_eq!(flaky[0].name, "test::flaky");
assert_eq!(flaky[0].consecutive_passes, 10);
}
#[test]
fn no_flaky_when_source_changed() {
let mut history = TestHistory::new();
history.tests.insert(
"test::changed".to_string(),
TestEntry {
consecutive_passes: 10,
last_outcome: "pass".to_string(),
source_hash: "old_hash".to_string(),
},
);
let results = vec![TestResult::new(
"test::changed".to_string(),
TestOutcome::Fail,
)];
let config = FlakyDetectionConfig {
consecutive_passes: 5,
..FlakyDetectionConfig::default()
};
let flaky = update_and_detect(&mut history, &results, &config, "new_hash");
assert!(flaky.is_empty());
}
#[test]
fn no_flaky_below_threshold() {
let mut history = TestHistory::new();
history.tests.insert(
"test::below".to_string(),
TestEntry {
consecutive_passes: 3,
last_outcome: "pass".to_string(),
source_hash: "hash".to_string(),
},
);
let results = vec![TestResult::new(
"test::below".to_string(),
TestOutcome::Fail,
)];
let config = FlakyDetectionConfig {
consecutive_passes: 5,
..FlakyDetectionConfig::default()
};
let flaky = update_and_detect(&mut history, &results, &config, "hash");
assert!(flaky.is_empty());
}
#[test]
fn ignored_tests_unchanged() {
let mut history = TestHistory::new();
let results = vec![TestResult::new(
"test::ignored".to_string(),
TestOutcome::Ignored,
)];
let config = FlakyDetectionConfig::default();
let flaky = update_and_detect(&mut history, &results, &config, "hash");
assert!(flaky.is_empty());
assert_eq!(history.tests["test::ignored"].last_outcome, "ignored");
assert_eq!(history.tests["test::ignored"].consecutive_passes, 0);
}
#[test]
fn load_history_missing_file() {
let result = load_history(Path::new("/nonexistent/path/history.json"));
assert!(result.is_ok());
assert!(result.unwrap_or_default().tests.is_empty());
}
#[test]
fn roundtrip_serialize() {
let mut history = TestHistory::new();
history.tests.insert(
"test::a".to_string(),
TestEntry {
consecutive_passes: 5,
last_outcome: "pass".to_string(),
source_hash: "abc123".to_string(),
},
);
let json = serde_json::to_string(&history).unwrap_or_default();
let deserialized: TestHistory = serde_json::from_str(&json).unwrap_or_default();
assert_eq!(deserialized.tests.len(), 1);
assert_eq!(deserialized.tests["test::a"].consecutive_passes, 5);
}
#[test]
fn skipped_tests_treated_like_ignored() {
let mut history = TestHistory::new();
history.tests.insert(
"test::skip".to_string(),
TestEntry {
consecutive_passes: 5,
last_outcome: "pass".to_string(),
source_hash: "hash".to_string(),
},
);
let results = vec![TestResult::new(
"test::skip".to_string(),
TestOutcome::Skipped,
)];
let config = FlakyDetectionConfig::default();
let flaky = update_and_detect(&mut history, &results, &config, "hash");
assert!(flaky.is_empty());
assert_eq!(history.tests["test::skip"].last_outcome, "ignored");
assert_eq!(history.tests["test::skip"].consecutive_passes, 5);
}
#[test]
fn flaky_at_exact_threshold() {
let mut history = TestHistory::new();
history.tests.insert(
"test::edge".to_string(),
TestEntry {
consecutive_passes: 5,
last_outcome: "pass".to_string(),
source_hash: "hash".to_string(),
},
);
let results = vec![TestResult::new("test::edge".to_string(), TestOutcome::Fail)];
let config = FlakyDetectionConfig {
consecutive_passes: 5,
..FlakyDetectionConfig::default()
};
let flaky = update_and_detect(&mut history, &results, &config, "hash");
assert_eq!(flaky.len(), 1);
}
#[test]
fn multiple_passes_accumulate() {
let mut history = TestHistory::new();
let config = FlakyDetectionConfig::default();
for _ in 0..3 {
let results = vec![TestResult::new("test::a".to_string(), TestOutcome::Pass)];
update_and_detect(&mut history, &results, &config, "hash");
}
assert_eq!(history.tests["test::a"].consecutive_passes, 3);
}
}