cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::infra::serde_support::{RawEvent, RawEventAction};

/// Typed observations produced by `enrich_record` / `enrich_dr_record` on
/// a successfully parsed entry. The scanner promotes each variant to the
/// corresponding `LoadDefect` so the rendering layer never re-derives the
/// shape from a free-form string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EnrichWarning {
    /// The frontmatter `status:` does not match the journal's terminal
    /// projection. `frontmatter` is the raw frontmatter value; `terminal`
    /// is what the event log projects.
    StatusJournalMismatch {
        frontmatter: String,
        terminal: String,
    },
}

/// Convert a [`RawEventAction`] to a typed [`EventAction`] by parsing status
/// names into `State` values.
///
/// Unknown action variants are silently dropped (returns `None`). Status
/// names that don't pass the `State` validator (e.g. uppercase, spaces) are
/// dropped silently as well — they cannot land in the journal.
pub(crate) fn enrich_event_action(
    raw: RawEventAction,
) -> Option<crate::domain::model::event::EventAction> {
    use crate::domain::model::event::{EventAction, State};
    match raw {
        RawEventAction::Created { status } => {
            // Preserve v1 events that lacked a status field — fall back to
            // the domain's `unknown` sentinel so the event still appears in
            // the log and `validate_event_chain` can flag the divergence.
            let state = State::new(&status).unwrap_or_else(|_| State::unknown());
            Some(EventAction::Created { state })
        }
        RawEventAction::StatusChanged { from, to } => {
            let from_state = State::new(&from).unwrap_or_else(|_| State::unknown());
            let to_state = State::new(&to).unwrap_or_else(|_| State::unknown());
            Some(EventAction::StatusChanged {
                from: from_state,
                to: to_state,
            })
        }
        RawEventAction::Other(_) => None,
    }
}

/// Enrich a record's status and event log from raw deserialized events.
///
/// Steps (ADR-0017 projection):
/// 1. Resolve the frontmatter status name through `statuses`.
/// 2. Convert each `RawEvent` to a typed `Event` via `enrich_event_action` —
///    state names land in the journal as `State` strings without semantic
///    projection.
/// 3. Project the current status from the event log's latest state.
/// 4. Emit a divergence warning if frontmatter and projection disagree.
pub(crate) fn enrich_record(
    status: &mut crate::domain::model::status::Status,
    events: &mut crate::domain::model::event::EventLog,
    raw_events: Vec<RawEvent>,
    statuses: &crate::domain::model::status::StatusesConfig,
) -> Vec<EnrichWarning> {
    let fm_status_name = status.name.clone();

    if let Ok(s) = statuses.resolve(&fm_status_name) {
        *status = s;
    }

    for raw in raw_events {
        if let Some(action) = enrich_event_action(raw.action) {
            events.push(crate::domain::model::event::Event {
                timestamp: raw.timestamp,
                action,
            });
        }
    }

    if let Some(latest) = events.latest_state() {
        *status = statuses
            .resolve(latest.as_str())
            .unwrap_or_else(|_| crate::domain::model::status::Status::unresolved(latest.as_str()));
    }

    let mut warnings = Vec::new();
    if status.as_str() != fm_status_name {
        warnings.push(EnrichWarning::StatusJournalMismatch {
            frontmatter: fm_status_name,
            terminal: status.as_str().to_string(),
        });
    }
    warnings
}

/// DR-specific enrichment: project the current status from the event log
/// using the typed `DrStatus` enum (DDR-018QWJVHRH35B). The frontmatter
/// status has already been parsed as `DrStatus` upstream — no
/// `StatusesConfig` is consulted.
pub(crate) fn enrich_dr_record(
    status: &mut crate::domain::model::decision_record::DrStatus,
    events: &mut crate::domain::model::event::EventLog,
    raw_events: Vec<RawEvent>,
) -> Vec<EnrichWarning> {
    use std::str::FromStr;
    let fm_status = *status;

    for raw in raw_events {
        if let Some(action) = enrich_event_action(raw.action) {
            events.push(crate::domain::model::event::Event {
                timestamp: raw.timestamp,
                action,
            });
        }
    }

    if let Some(latest) = events.latest_state() {
        if let Ok(projected) =
            crate::domain::model::decision_record::DrStatus::from_str(latest.as_str())
        {
            *status = projected;
        }
    }

    let mut warnings = Vec::new();
    if *status != fm_status {
        warnings.push(EnrichWarning::StatusJournalMismatch {
            frontmatter: fm_status.to_string(),
            terminal: status.to_string(),
        });
    }
    warnings
}