made-core 0.7.4

Domain core of MADE: entities, value objects, events, ports. No IO.
Documentation
use crate::error::DomainError;
use crate::value_objects::{
    CeremonyStep, CeremonyTransition, CeremonyValidationFinding, CeremonyValidationLocus,
    GuardCondition, StateExecution,
};

use super::CeremonyDefinitionParts;

pub(super) fn collect(
    parts: &CeremonyDefinitionParts<'_>,
    findings: &mut Vec<CeremonyValidationFinding>,
) {
    for step in parts
        .steps
        .values()
        .filter(|step| step.aggregation().is_some())
    {
        let locus = CeremonyValidationLocus::step(step.id().clone());
        let Some(destination) = parts.states.get(step.state_id()) else {
            continue;
        };
        if destination.is_initial() {
            push_error(
                findings,
                &locus,
                "aggregate step cannot belong to the initial state",
            );
        }
        if destination.execution() != StateExecution::Sequential {
            push_error(
                findings,
                &locus,
                "aggregate step destination state must be sequential",
            );
        }
        let first_in_state = parts.step_order.iter().find(|candidate| {
            parts
                .steps
                .get(*candidate)
                .is_some_and(|candidate| candidate.state_id() == step.state_id())
        });
        if first_in_state != Some(step.id()) {
            push_error(
                findings,
                &locus,
                "aggregate step must be first in its destination state",
            );
        }
        if step
            .handler_config()
            .attributes()
            .get("see_prior")
            .and_then(serde_json::Value::as_bool)
            != Some(true)
        {
            push_error(findings, &locus, "aggregate step requires see_prior: true");
        }

        let incoming = parts
            .transitions
            .iter()
            .filter(|transition| transition.to() == step.state_id())
            .collect::<Vec<_>>();
        if incoming.len() != 1 {
            push_error(
                findings,
                &locus,
                "aggregate step requires exactly one predecessor state",
            );
            continue;
        }
        let transition = incoming[0];
        if transition.from() == transition.to() {
            push_error(
                findings,
                &locus,
                "aggregate step predecessor must differ from its destination",
            );
            continue;
        }
        let Some(predecessor) = parts.states.get(transition.from()) else {
            continue;
        };
        if predecessor.execution() != StateExecution::Concurrent {
            push_error(
                findings,
                &locus,
                "aggregate step requires a concurrent predecessor state",
            );
            continue;
        }
        let siblings = parts
            .steps
            .values()
            .filter(|candidate| candidate.state_id() == predecessor.id())
            .collect::<Vec<_>>();
        if !requires_every_sibling(parts, transition, &siblings) {
            push_error(
                findings,
                &locus,
                "aggregate step requires an all-siblings predecessor join",
            );
        }
    }
}

fn requires_every_sibling(
    parts: &CeremonyDefinitionParts<'_>,
    transition: &CeremonyTransition,
    steps: &[&CeremonyStep],
) -> bool {
    let required = transition
        .required_guards()
        .iter()
        .filter_map(|name| parts.guards.get(name))
        .collect::<Vec<_>>();
    if required.iter().any(|guard| {
        matches!(
            guard.condition(),
            GuardCondition::StepsCompleted(count) if count.get() as usize == steps.len()
        )
    }) {
        return true;
    }
    steps.iter().all(|step| {
        required.iter().any(|guard| {
            matches!(
                guard.condition(),
                GuardCondition::StepStatus { step_id, status }
                    if step_id == step.id() && status.is_success()
            )
        })
    })
}

fn push_error(
    findings: &mut Vec<CeremonyValidationFinding>,
    locus: &CeremonyValidationLocus,
    reason: &'static str,
) {
    findings.push(CeremonyValidationFinding::error(
        locus.clone(),
        DomainError::InvariantViolated { reason },
    ));
}