cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Decision-record event-chain validation, backed by the typed
//! [`DrStatus`] enum (DDR-018QWJVHRH35B).
//!
//! Mirrors `validate_event_chain` for the DR universe but consults the
//! model rather than a `StatusesConfig`: the workflow rules are part of
//! cartulary's vocabulary and the model is their single oracle.

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());
    }
}