use crate::error::Result;
use crate::models::{FlakinessRecord, TestOutcome};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::debug;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FlakinessTracker {
records: HashMap<String, FlakinessRecord>,
max_outcomes: usize,
storage_path: Option<PathBuf>,
#[serde(skip)]
dirty: bool,
}
impl FlakinessTracker {
pub fn new(storage_path: Option<PathBuf>) -> Self {
let mut tracker = FlakinessTracker {
records: HashMap::new(),
max_outcomes: 20,
storage_path,
dirty: false,
};
if let Some(ref path) = tracker.storage_path {
if path.exists() {
let _ = tracker.load();
}
}
tracker
}
pub fn record_outcome(&mut self, node_id: &str, outcome: TestOutcome, message: Option<&str>) {
let record = self
.records
.entry(node_id.to_string())
.or_insert_with(|| FlakinessRecord {
node_id: node_id.to_string(),
outcomes: Vec::new(),
consecutive_failures: 0,
consecutive_passes: 0,
flaky_streak: 0,
total_runs: 0,
last_failure_message: None,
});
let prev_outcome = record.outcomes.last().cloned();
record.outcomes.push(outcome.clone().into());
if record.outcomes.len() > self.max_outcomes {
record.outcomes = record.outcomes[record.outcomes.len() - self.max_outcomes..].to_vec();
}
record.total_runs += 1;
match outcome {
TestOutcome::Failed | TestOutcome::Error => {
record.consecutive_failures += 1;
record.consecutive_passes = 0;
record.last_failure_message = message.map(|s| s.to_string());
}
TestOutcome::Passed => {
record.consecutive_passes += 1;
record.consecutive_failures = 0;
}
_ => {
record.consecutive_failures = 0;
record.consecutive_passes = 0;
}
}
if let Some(prev) = prev_outcome {
let prev_str = prev.as_str();
let prev_is_fail = prev_str == "failed" || prev_str == "error";
let curr_is_fail = matches!(outcome, TestOutcome::Failed | TestOutcome::Error);
if prev_is_fail != curr_is_fail {
record.flaky_streak += 1;
}
}
self.dirty = true;
}
pub fn get_record(&self, node_id: &str) -> Option<&FlakinessRecord> {
self.records.get(node_id)
}
pub fn is_flaky(&self, node_id: &str) -> bool {
if let Some(record) = self.records.get(node_id) {
Self::check_is_flaky(record)
} else {
false
}
}
fn check_is_flaky(record: &FlakinessRecord) -> bool {
if record.outcomes.len() < 3 {
return false;
}
let has_pass = record.outcomes.iter().any(|o| o == "passed");
let has_fail = record
.outcomes
.iter()
.any(|o| o == "failed" || o == "error");
has_pass && has_fail && record.flaky_streak >= 2
}
pub fn get_failure_rate(&self, node_id: &str) -> f64 {
if let Some(record) = self.records.get(node_id) {
if record.outcomes.is_empty() {
return 0.0;
}
let failures: usize = record
.outcomes
.iter()
.filter(|o| o.as_str() == "failed" || o.as_str() == "error")
.count();
failures as f64 / record.outcomes.len() as f64
} else {
0.0
}
}
pub fn get_flaky_tests(&self) -> Vec<&FlakinessRecord> {
self.records
.values()
.filter(|r| Self::check_is_flaky(r))
.collect()
}
pub fn get_unstable_tests(&self) -> Vec<&FlakinessRecord> {
self.records
.values()
.filter(|r| {
let has_fail = r
.outcomes
.iter()
.any(|o| o.as_str() == "failed" || o.as_str() == "error");
has_fail && !Self::check_is_flaky(r)
})
.collect()
}
pub fn stable_count(&self) -> usize {
self.records
.values()
.filter(|r| {
!Self::check_is_flaky(r)
&& !r
.outcomes
.iter()
.any(|o| o.as_str() == "failed" || o.as_str() == "error")
})
.count()
}
pub fn total_tracked(&self) -> usize {
self.records.len()
}
pub fn save(&self) -> Result<()> {
if let Some(ref path) = self.storage_path {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data: Vec<_> = self.records.values().collect();
let json = serde_json::to_string_pretty(&data)?;
std::fs::write(path, json)?;
debug!("Saved {} flakiness records", self.records.len());
}
Ok(())
}
pub fn flush_if_dirty(&mut self) -> Result<()> {
if self.dirty {
self.save()?;
self.dirty = false;
}
Ok(())
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn load(&mut self) -> Result<()> {
if let Some(ref path) = self.storage_path {
if !path.exists() {
return Ok(());
}
let json = std::fs::read_to_string(path)?;
let data: Vec<FlakinessRecord> = serde_json::from_str(&json)?;
self.records.clear();
for record in data {
self.records.insert(record.node_id.clone(), record);
}
debug!("Loaded {} flakiness records", self.records.len());
}
Ok(())
}
pub fn clear(&mut self) {
self.records.clear();
if let Some(ref path) = self.storage_path {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_record_outcome() {
let dir = TempDir::new().unwrap();
let storage_path = dir.path().join("flakiness.json");
let mut tracker = FlakinessTracker::new(Some(storage_path));
tracker.record_outcome("test_a", TestOutcome::Passed, None);
tracker.record_outcome("test_a", TestOutcome::Failed, Some("error"));
assert_eq!(tracker.total_tracked(), 1);
assert!(!tracker.is_flaky("test_a")); }
#[test]
fn test_flaky_detection() {
let dir = TempDir::new().unwrap();
let storage_path = dir.path().join("flakiness.json");
let mut tracker = FlakinessTracker::new(Some(storage_path));
tracker.record_outcome("test_a", TestOutcome::Passed, None);
tracker.record_outcome("test_a", TestOutcome::Failed, None);
tracker.record_outcome("test_a", TestOutcome::Passed, None);
tracker.record_outcome("test_a", TestOutcome::Failed, None);
tracker.record_outcome("test_a", TestOutcome::Passed, None);
assert!(tracker.is_flaky("test_a"));
assert_eq!(tracker.get_failure_rate("test_a"), 0.4);
}
#[test]
fn test_get_flaky_tests() {
let dir = TempDir::new().unwrap();
let storage_path = dir.path().join("flakiness.json");
let mut tracker = FlakinessTracker::new(Some(storage_path));
tracker.record_outcome("test_flaky", TestOutcome::Passed, None);
tracker.record_outcome("test_flaky", TestOutcome::Failed, None);
tracker.record_outcome("test_flaky", TestOutcome::Passed, None);
tracker.record_outcome("test_stable", TestOutcome::Passed, None);
tracker.record_outcome("test_stable", TestOutcome::Passed, None);
let flaky = tracker.get_flaky_tests();
assert_eq!(flaky.len(), 1);
assert_eq!(flaky[0].node_id, "test_flaky");
}
}