cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use super::EventLogIssue;

/// Validate the event chain for any record kind. Returns the typed
/// failures; the textual rendering happens at the rule layer via
/// `CheckViolationKind::EventLogBroken`.
///
/// `current_status` is the `status:` field in the frontmatter (after
/// projection). `statuses` provides the configured known statuses,
/// allowed transitions and terminal flags. The initial status is read
/// from the `Created` event's `status` field.
pub fn validate_event_chain(
    events: &crate::domain::model::event::EventLog,
    current_status: &str,
    statuses: &crate::domain::model::status::StatusesConfig,
) -> Vec<EventLogIssue> {
    use crate::domain::model::event::EventAction;

    let mut v = Vec::new();

    let initial_status = if let Some(first) = events.first() {
        match &first.action {
            EventAction::Created { state: status } => {
                if !statuses.contains_name(status.as_str()) {
                    v.push(EventLogIssue::CreatedStatusUnknown {
                        status: status.as_str().to_string(),
                    });
                } else if status.as_str() != statuses.initial() {
                    v.push(EventLogIssue::CreatedStatusMismatch {
                        status: status.as_str().to_string(),
                        expected: statuses.initial().to_string(),
                    });
                }
                status.as_str()
            }
            _ => {
                v.push(EventLogIssue::FirstActionNotCreated {
                    found: first.action.to_string(),
                });
                current_status
            }
        }
    } else {
        current_status
    };

    let status_events: Vec<&crate::domain::model::event::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"),
        };

        if !statuses.contains_name(from) {
            v.push(EventLogIssue::StatusChangeFromUnknown {
                from: from.to_string(),
            });
        }
        if !statuses.contains_name(to) {
            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(),
            });
        }

        // The `next` list is the single source of truth for what
        // transitions a status allows. A status flagged `terminal: true`
        // with a non-empty `next` (e.g. issue `closed → open`) is a
        // legitimate reopen path — the previous redundant check on
        // `terminal` rejected it and contradicted the configuration.
        let transition_ok = statuses
            .resolve(from)
            .map(|s| {
                statuses
                    .next_for(&s)
                    .is_none_or(|next| next.iter().any(|n| n == to))
            })
            .unwrap_or(true); // unknown status already flagged above
        if !transition_ok {
            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)]
pub mod strategy {
    use crate::domain::model::event::{Event, EventAction, EventLog};
    use crate::domain::model::status::StatusesConfig;
    use crate::domain::model::temporal::timestamp::strategy::timestamp;
    use proptest::prelude::*;

    /// Generate a `Created`-only event log whose status matches `cfg.initial`.
    /// Useful for tests that need a valid baseline before exercising error paths.
    pub fn valid_creation_only_log(cfg: &StatusesConfig) -> impl Strategy<Value = EventLog> + '_ {
        let initial = crate::domain::model::event::State::new(cfg.initial())
            .expect("config initial must be valid State");
        timestamp().prop_map(move |ts| {
            EventLog::from_iter(std::iter::once(Event {
                timestamp: ts,
                action: EventAction::Created {
                    state: initial.clone(),
                },
            }))
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::status::StatusesConfig;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn creation_only_log_validates_under_initial_status(
            log in strategy::valid_creation_only_log(&StatusesConfig::default_issue())
        ) {
            let cfg = StatusesConfig::default_issue();
            let initial = cfg.initial().to_string();
            let v = validate_event_chain(&log, &initial, &cfg);
            prop_assert!(v.is_empty(), "expected clean log to validate, got {v:?}");
        }
    }
}