use crate::core::types::CoverageLevel;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TestCoverageRecord {
pub resource_id: String,
pub level: CoverageLevel,
pub passed: bool,
pub timestamp: String,
pub config_hash: String,
}
impl TestCoverageRecord {
pub fn new(
resource_id: impl Into<String>,
level: CoverageLevel,
passed: bool,
config_hash: impl Into<String>,
) -> Self {
Self {
resource_id: resource_id.into(),
level,
passed,
timestamp: crate::tripwire::eventlog::now_iso8601(),
config_hash: config_hash.into(),
}
}
}
pub fn coverage_log_path(state_dir: &Path) -> PathBuf {
state_dir.join("test-coverage.jsonl")
}
pub fn append_record(state_dir: &Path, record: &TestCoverageRecord) -> Result<(), String> {
let path = coverage_log_path(state_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create state dir: {e}"))?;
}
let json = serde_json::to_string(record).map_err(|e| format!("JSON serialize error: {e}"))?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("cannot open coverage log {}: {}", path.display(), e))?;
writeln!(file, "{json}").map_err(|e| format!("write error: {e}"))?;
Ok(())
}
pub fn append_records(state_dir: &Path, records: &[TestCoverageRecord]) -> Result<(), String> {
for record in records {
append_record(state_dir, record)?;
}
Ok(())
}
pub fn load_records(state_dir: &Path) -> Vec<TestCoverageRecord> {
let path = coverage_log_path(state_dir);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| serde_json::from_str::<TestCoverageRecord>(line).ok())
.collect()
}
pub fn proven_level(
records: &[TestCoverageRecord],
resource_id: &str,
current_hash: &str,
) -> Option<CoverageLevel> {
let latest = records
.iter()
.filter(|r| r.resource_id == resource_id && r.config_hash == current_hash)
.max_by(|a, b| a.timestamp.cmp(&b.timestamp))?;
latest.passed.then_some(latest.level)
}
pub fn promote_level(
base: CoverageLevel,
records: &[TestCoverageRecord],
resource_id: &str,
current_hash: &str,
) -> CoverageLevel {
match proven_level(records, resource_id, current_hash) {
Some(proven) => base.max(proven),
None => base,
}
}
#[derive(Debug, Clone, Copy)]
pub struct ConvergenceOutcome {
pub converged: bool,
pub idempotent: bool,
pub preserved: bool,
pub errored: bool,
pub pairwise_enabled: bool,
}
impl ConvergenceOutcome {
pub fn proves_l3(&self) -> bool {
self.converged && self.idempotent && !self.errored
}
pub fn proves_l5(&self) -> bool {
self.pairwise_enabled && self.proves_l3() && self.preserved
}
pub fn proven_level(&self) -> Option<CoverageLevel> {
if self.proves_l5() {
Some(CoverageLevel::L5)
} else if self.proves_l3() {
Some(CoverageLevel::L3)
} else {
None
}
}
}
pub fn convergence_record(
resource_id: &str,
outcome: ConvergenceOutcome,
config_hash: &str,
) -> Option<TestCoverageRecord> {
match outcome.proven_level() {
Some(level) => Some(TestCoverageRecord::new(
resource_id,
level,
true,
config_hash,
)),
None => Some(TestCoverageRecord::new(
resource_id,
CoverageLevel::L3,
false,
config_hash,
)),
}
}
pub fn mutation_record(
resource_id: &str,
attempted: usize,
detected: usize,
errored: usize,
config_hash: &str,
) -> Option<TestCoverageRecord> {
if attempted == 0 {
return None;
}
let passed = errored == 0 && detected == attempted;
Some(TestCoverageRecord::new(
resource_id,
CoverageLevel::L4,
passed,
config_hash,
))
}
pub fn latest_hash_by_resource(records: &[TestCoverageRecord]) -> HashMap<String, String> {
let mut map: HashMap<String, String> = HashMap::new();
for r in records {
map.insert(r.resource_id.clone(), r.config_hash.clone());
}
map
}
#[cfg(test)]
#[path = "coverage_persist_tests.rs"]
mod tests;