dpm-model 0.1.0

Domain model for DPM execution planning
Documentation
use super::{ValidationError, invalid};
use crate::*;
use std::{collections::BTreeSet, fmt::Display};

pub(super) fn validate(plan: &Plan) -> Result<(), ValidationError> {
    nonempty("workspace", plan.workspace.id, &plan.workspace.name)?;
    entries(
        "project",
        plan.projects.iter().map(|(id, p)| (id, &p.id, &p.key)),
    )?;
    entries(
        "work",
        plan.work_items.iter().map(|(id, w)| (id, &w.id, &w.key)),
    )?;
    entries(
        "requirement",
        plan.requirements.iter().map(|(id, r)| (id, &r.id, &r.key)),
    )?;
    entries(
        "decision",
        plan.decisions.iter().map(|(id, d)| (id, &d.id, &d.key)),
    )?;
    entries("risk", plan.risks.iter().map(|(id, r)| (id, &r.id, &r.key)))?;
    for project in plan.projects.values() {
        nonempty("project title", project.id, &project.title)?;
    }
    for work in plan.work_items.values() {
        validate_work(plan, work)?;
    }
    validate_context(plan)
}

fn entries<'a, I: Display + PartialEq + 'a>(
    entity: &'static str,
    entries: impl Iterator<Item = (&'a I, &'a I, &'a Key)>,
) -> Result<(), ValidationError> {
    let mut keys = BTreeSet::new();
    for (id, value_id, key) in entries {
        if id != value_id {
            return Err(invalid(
                entity,
                id,
                format!("map key differs from value id {value_id}"),
            ));
        }
        nonempty(entity, id, &key.0)?;
        if key.0.trim() != key.0 || !keys.insert(key.0.as_str()) {
            return Err(invalid(
                entity,
                id,
                format!("key {key} has whitespace or is duplicated"),
            ));
        }
    }
    Ok(())
}

pub(super) fn nonempty(
    entity: &'static str,
    id: impl Display,
    text: &str,
) -> Result<(), ValidationError> {
    if text.trim().is_empty() {
        return Err(invalid(entity, id, "text must not be empty"));
    }
    Ok(())
}

fn validate_work(plan: &Plan, work: &WorkItem) -> Result<(), ValidationError> {
    nonempty("work title", work.id, &work.title)?;
    super::events::work(work)?;
    super::instructions::validate(work)?;
    validate_review(work)?;
    if work.execution.reported_progress_percent > 100
        || (work.execution.reported_progress_percent > 0
            && (!work.is_executable() || work.execution.owner.is_none()))
    {
        return Err(invalid(
            "work progress",
            work.id,
            "progress must be 0..100 and nonzero reports require an owned task",
        ));
    }
    project_reference(plan, work.project, work.id)?;
    if let Some(estimate) = work.schedule.estimate {
        estimate
            .validate()
            .map_err(|source| ValidationError::Estimate {
                work: work.id,
                source,
            })?;
    }
    for requirement in &work.contract.requirement_ids {
        if !plan.requirements.contains_key(requirement) {
            return Err(invalid(
                "work",
                work.id,
                format!("missing requirement {requirement}"),
            ));
        }
    }
    for artifact in &work.execution.artifact_ids {
        if !plan.artifacts.contains_key(artifact) {
            return Err(invalid(
                "work",
                work.id,
                format!("missing artifact {artifact}"),
            ));
        }
    }
    if let Some(owner) = &work.execution.owner {
        nonempty("actor", work.id, &owner.name)?;
    }
    if !work.is_executable() {
        if work.execution.owner.is_some()
            || work.execution.block_reason.is_some()
            || work.schedule.estimate.is_some()
            || !matches!(
                work.execution.status,
                WorkStatus::Planned | WorkStatus::Proposed
            )
        {
            return Err(invalid(
                "work",
                work.id,
                "aggregate work cannot carry task execution state",
            ));
        }
        return Ok(());
    }
    validate_lifecycle(work)
}

fn validate_lifecycle(work: &WorkItem) -> Result<(), ValidationError> {
    if work.execution.status != WorkStatus::Proposed {
        nonempty("work objective", work.id, &work.contract.objective)?;
        if work.contract.acceptance.is_empty()
            || work
                .contract
                .acceptance
                .iter()
                .any(|c| c.text.trim().is_empty())
        {
            return Err(invalid(
                "work",
                work.id,
                "executable work requires concrete acceptance criteria",
            ));
        }
    }
    if (work.execution.status == WorkStatus::Blocked) != work.execution.block_reason.is_some() {
        return Err(invalid(
            "work",
            work.id,
            "block reason and blocked lifecycle must agree",
        ));
    }
    if let Some(reason) = &work.execution.block_reason {
        nonempty("block reason", work.id, reason)?;
    }
    let requires_owner = matches!(
        work.execution.status,
        WorkStatus::Claimed
            | WorkStatus::InProgress
            | WorkStatus::Submitted
            | WorkStatus::Verified
            | WorkStatus::Done
    );
    let forbids_owner = matches!(
        work.execution.status,
        WorkStatus::Proposed | WorkStatus::Planned
    );
    if (requires_owner && work.execution.owner.is_none())
        || (forbids_owner && work.execution.owner.is_some())
    {
        return Err(invalid(
            "work",
            work.id,
            "ownership does not match lifecycle",
        ));
    }
    Ok(())
}

fn project_reference(
    plan: &Plan,
    project: ProjectId,
    entity: impl Display,
) -> Result<(), ValidationError> {
    if !plan.projects.contains_key(&project) {
        return Err(invalid(
            "project reference",
            entity,
            format!("missing project {project}"),
        ));
    }
    Ok(())
}

fn validate_context(plan: &Plan) -> Result<(), ValidationError> {
    for requirement in plan.requirements.values() {
        project_reference(plan, requirement.project, requirement.id)?;
        nonempty("requirement", requirement.id, &requirement.statement)?;
        nonempty("requirement title", requirement.id, &requirement.title)?;
    }
    for (id, artifact) in &plan.artifacts {
        if id != &artifact.id {
            return Err(invalid("artifact", id, "map key differs from artifact id"));
        }
        nonempty("artifact URI", id, &artifact.uri)?;
        nonempty("artifact creator", id, &artifact.created_by.name)?;
    }
    let mut replaced = BTreeSet::new();
    for decision in plan.decisions.values() {
        validate_decision(plan, decision)?;
        if let Some(target) = decision.supersedes {
            validate_replacement(plan, decision, target)?;
            if !replaced.insert(target) {
                return Err(invalid(
                    "decision",
                    decision.id,
                    format!("decision {target} already has a replacement"),
                ));
            }
        }
    }
    for risk in plan.risks.values() {
        project_reference(plan, risk.project, risk.id)?;
        nonempty("risk description", risk.id, &risk.description)?;
        if !risk.probability.is_finite() || !(0.0..=1.0).contains(&risk.probability) {
            return Err(invalid(
                "risk",
                risk.id,
                "probability must be finite and within [0, 1]",
            ));
        }
        for id in &risk.related_work {
            if !plan.work_items.contains_key(id) {
                return Err(invalid(
                    "risk",
                    risk.id,
                    format!("missing related work {id}"),
                ));
            }
        }
    }
    Ok(())
}

fn validate_decision(plan: &Plan, decision: &Decision) -> Result<(), ValidationError> {
    super::events::decision(decision)?;
    project_reference(plan, decision.project, decision.id)?;
    nonempty("decision", decision.id, &decision.question)?;
    if decision.status == DecisionStatus::Open && decision.outcome.is_some() {
        return Err(invalid(
            "decision",
            decision.id,
            "open decision cannot carry a resolution",
        ));
    }
    if decision.status == DecisionStatus::Decided
        && decision
            .outcome
            .as_ref()
            .is_none_or(|o| o.trim().is_empty())
    {
        return Err(invalid(
            "decision",
            decision.id,
            "resolved decision requires a non-empty outcome",
        ));
    }
    if let Some(rationale) = &decision.rationale {
        nonempty("decision rationale", decision.id, rationale)?;
    }
    for (relation, ids) in [
        ("blocked", &decision.blocks),
        ("related", &decision.related_work),
    ] {
        for id in ids {
            if !plan.work_items.contains_key(id) {
                return Err(invalid(
                    "decision",
                    decision.id,
                    format!("missing {relation} work {id}"),
                ));
            }
        }
    }
    for id in &decision.artifact_ids {
        if !plan.artifacts.contains_key(id) {
            return Err(invalid(
                "decision",
                decision.id,
                format!("missing source artifact {id}"),
            ));
        }
    }
    Ok(())
}

/// A replacement chain must end at a real superseded decision and never loop, so its prior
/// rationale and sources remain reachable from the current choice.
fn validate_replacement(
    plan: &Plan,
    decision: &Decision,
    target: DecisionId,
) -> Result<(), ValidationError> {
    let mut seen = BTreeSet::from([decision.id]);
    let mut next = Some(target);
    while let Some(id) = next {
        if !seen.insert(id) {
            return Err(invalid("decision", decision.id, "replacement cycle"));
        }
        let previous = plan.decisions.get(&id).ok_or_else(|| {
            invalid(
                "decision",
                decision.id,
                format!("missing replaced decision {id}"),
            )
        })?;
        if previous.status != DecisionStatus::Superseded {
            return Err(invalid(
                "decision",
                decision.id,
                format!("replaced decision {id} must be Superseded"),
            ));
        }
        next = previous.supersedes;
    }
    Ok(())
}

fn validate_review(work: &WorkItem) -> Result<(), ValidationError> {
    if let Some(review) = &work.execution.last_rejection {
        nonempty("review reason", work.id, &review.reason)?;
        nonempty("review actor", work.id, &review.actor.name)?;
        if !work.is_executable() || work.execution.owner.is_none() {
            return Err(invalid(
                "review",
                work.id,
                "rejection requires an owned task",
            ));
        }
        if let Some(holding) = work.holding_at(&review.actor, review.at) {
            return Err(invalid(
                "review",
                work.id,
                format!(
                    "rejection at {} is not independent: {} {holding}",
                    review.at, review.actor
                ),
            ));
        }
    }
    Ok(())
}