dpm-model 0.1.0

Domain model for DPM execution planning
Documentation
use super::{ExternalLinkRole, ExternalObjectKind, ExternalReference, ExternalState, credentials};
use crate::{Plan, ValidationError, validation::invalid};
use std::collections::{BTreeMap, BTreeSet};

pub(crate) fn validate(plan: &Plan) -> Result<(), ValidationError> {
    let mut identities = BTreeMap::new();
    for (id, reference) in &plan.external_references {
        if *id != reference.id {
            return Err(invalid(
                "external reference",
                id,
                format!("map key differs from value id {}", reference.id),
            ));
        }
        reference.identity.validate(*id)?;
        if let Some(previous) = identities.insert(reference.identity.object_key(), id) {
            return Err(invalid(
                "external reference",
                id,
                format!(
                    "identity {} is already recorded as {previous}",
                    reference.identity
                ),
            ));
        }
        validate_display(reference)?;
        validate_links(plan, reference)?;
    }
    Ok(())
}

fn validate_display(reference: &ExternalReference) -> Result<(), ValidationError> {
    let id = reference.id;
    if reference.label.trim().is_empty() {
        return Err(invalid("external reference", id, "label must not be empty"));
    }
    credentials::check_text(&reference.label)
        .map_err(|reason| invalid("external reference label", id, reason))?;
    if let Some(url) = &reference.url {
        credentials::check_url(url, &reference.identity)
            .map_err(|reason| invalid("external reference url", id, reason))?;
    }
    if let Some(observation) = &reference.observation {
        if observation.observed_by.name.trim().is_empty() {
            return Err(invalid("external observation", id, "actor is required"));
        }
        if observation.state == ExternalState::Merged
            && reference.identity.kind != ExternalObjectKind::PullRequest
        {
            return Err(invalid(
                "external observation",
                id,
                "only pull requests can be merged",
            ));
        }
    }
    Ok(())
}

fn validate_links(plan: &Plan, reference: &ExternalReference) -> Result<(), ValidationError> {
    let id = reference.id;
    if reference.links.is_empty() {
        return Err(invalid(
            "external reference",
            id,
            "a reference needs at least one work link; unlinking the last link removes it",
        ));
    }
    let mut works = BTreeSet::new();
    for link in &reference.links {
        if !plan.work_items.contains_key(&link.work) {
            return Err(invalid(
                "external link",
                id,
                format!("missing work {}", link.work),
            ));
        }
        if !works.insert(link.work) {
            return Err(invalid(
                "external link",
                id,
                format!("work {} is linked more than once", link.work),
            ));
        }
    }
    let trackers = reference
        .links
        .iter()
        .filter(|link| link.role == ExternalLinkRole::Tracks)
        .count();
    if trackers > 1 {
        return Err(invalid(
            "external link",
            id,
            format!(
                "{} has {trackers} tracking owners; one work item may track it",
                reference.identity
            ),
        ));
    }
    Ok(())
}