use std::str::FromStr;
use super::EventLogIssue;
use crate::domain::model::decision_record::DrStatus;
use crate::domain::model::event::{Event, EventAction, EventLog};
pub fn validate_dr_event_chain(events: &EventLog, current_status: &str) -> Vec<EventLogIssue> {
let mut v = Vec::new();
let initial_status = if let Some(first) = events.first() {
match &first.action {
EventAction::Created { state } => {
let name = state.as_str();
match DrStatus::from_str(name) {
Err(_) => v.push(EventLogIssue::CreatedStatusUnknown {
status: name.to_string(),
}),
Ok(s) if s != DrStatus::INITIAL => {
v.push(EventLogIssue::CreatedStatusMismatch {
status: name.to_string(),
expected: DrStatus::INITIAL.as_str().to_string(),
})
}
Ok(_) => {}
}
name
}
_ => {
v.push(EventLogIssue::FirstActionNotCreated {
found: first.action.to_string(),
});
current_status
}
}
} else {
current_status
};
let status_events: Vec<&Event> = events
.iter()
.filter(|e| matches!(e.action, EventAction::StatusChanged { .. }))
.collect();
let mut prev_status = initial_status;
for event in &status_events {
let (from, to) = match &event.action {
EventAction::StatusChanged { from, to } => (from.as_str(), to.as_str()),
_ => unreachable!("filter above guarantees StatusChanged"),
};
let from_dr = DrStatus::from_str(from);
let to_dr = DrStatus::from_str(to);
if from_dr.is_err() {
v.push(EventLogIssue::StatusChangeFromUnknown {
from: from.to_string(),
});
}
if to_dr.is_err() {
v.push(EventLogIssue::StatusChangeToUnknown { to: to.to_string() });
}
if from != prev_status {
v.push(EventLogIssue::EventChainBroken {
from: from.to_string(),
prev_status: prev_status.to_string(),
});
}
if let (Ok(f), Ok(t)) = (from_dr, to_dr) {
if f.is_terminal() {
v.push(EventLogIssue::TerminalStatusOutbound {
from: from.to_string(),
});
} else if !f.allows(t) {
v.push(EventLogIssue::InvalidTransition {
from: from.to_string(),
to: to.to_string(),
});
}
}
prev_status = to;
}
if let Some(last) = status_events.last() {
if let EventAction::StatusChanged { to, .. } = &last.action {
if to.as_str() != current_status {
v.push(EventLogIssue::FinalStatusMismatch {
last_to: to.as_str().to_string(),
current_status: current_status.to_string(),
});
}
}
}
v
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::event::{Event, EventLog, State};
use crate::domain::model::temporal::timestamp::Timestamp;
fn ts(s: &str) -> Timestamp {
Timestamp::new(s).unwrap()
}
fn created(state: &str) -> Event {
Event {
timestamp: ts("2026-01-01T00:00:00Z"),
action: EventAction::Created {
state: State::new(state).unwrap(),
},
}
}
fn changed(from: &str, to: &str) -> Event {
Event {
timestamp: ts("2026-01-02T00:00:00Z"),
action: EventAction::StatusChanged {
from: State::new(from).unwrap(),
to: State::new(to).unwrap(),
},
}
}
fn log(events: Vec<Event>) -> EventLog {
EventLog::from_iter(events)
}
use proptest::prelude::*;
proptest! {
#[test]
fn empty_log_always_passes(status in "[a-z]{1,12}") {
let v = validate_dr_event_chain(&log(vec![]), &status);
prop_assert!(v.is_empty());
}
}
#[test]
fn empty_log_under_initial_status_passes() {
let v = validate_dr_event_chain(&log(vec![]), "proposed");
assert!(v.is_empty());
}
#[test]
fn valid_chain_proposed_to_accepted_passes() {
let v = validate_dr_event_chain(
&log(vec![created("proposed"), changed("proposed", "accepted")]),
"accepted",
);
assert!(v.is_empty(), "{v:?}");
}
#[test]
fn created_with_non_initial_is_an_error() {
let v = validate_dr_event_chain(&log(vec![created("accepted")]), "accepted");
assert!(!v.is_empty());
}
#[test]
fn illegal_transition_is_flagged() {
let v = validate_dr_event_chain(
&log(vec![created("proposed"), changed("proposed", "deprecated")]),
"deprecated",
);
assert!(!v.is_empty());
}
#[test]
fn transition_from_terminal_is_flagged() {
let v = validate_dr_event_chain(
&log(vec![
created("proposed"),
changed("proposed", "rejected"),
changed("rejected", "accepted"),
]),
"accepted",
);
assert!(!v.is_empty());
}
}