dpm-engine 0.1.0

Command/query execution engine for DPM
Documentation
use crate::EngineError;
use dpm_model::Plan;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};

mod applicability;
mod external;
pub use applicability::ApplicabilityChange;
mod independence;
mod patch;
pub(crate) use independence::refuse_own_relaxation;
pub use independence::{EdgeRelaxation, RelaxedConstraint};
pub(crate) use patch::authorize;
pub(crate) use patch::canonical;
pub use patch::{apply_plan_change, json_equivalent, patch, plan_change};
mod policy;
mod protection;
pub use protection::protected_work;
mod replacement;
pub use replacement::AffectedWork;
mod revert;
pub use revert::propose_revert;

/// One entity-level semantic difference, including explicit additions and removals.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EntityChange {
    /// Domain collection such as `external_references`, `dependencies` per edge, or the workspace-wide
    /// `workspace` / `links` / `calendars`.
    pub collection: String,
    /// Stable entity identity, absent for workspace-wide values.
    pub id: Option<String>,
    /// Changed field names; empty for an added or removed entity.
    pub fields: Vec<String>,
    /// Full previous entity; null means addition.
    pub before: Value,
    /// Full proposed entity; null means deletion.
    pub after: Value,
}

/// Validated, read-only review of a proposed plan against one revision.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChangePreview {
    /// Revision that must still be current when applying the proposal.
    pub base_revision: u64,
    /// Deterministically ordered semantic differences.
    pub changes: Vec<EntityChange>,
    /// Work to reassess because a decision in its context is replaced; never a gate.
    pub affected_work: Vec<AffectedWork>,
    /// Work entering or leaving the active graph, including in-flight work a choice change affects.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub applicability_changes: Vec<ApplicabilityChange>,
}

/// Validate a proposed plan without changing state, lifecycle, evidence or decision outcomes.
///
/// New tasks must be Proposed. Existing execution and its prerequisite/context basis are protected;
/// review is a prerequisite to applying a change, not permission for agents to approve scope.
pub fn propose_change(current: &Plan, proposed: &Plan) -> Result<ChangePreview, EngineError> {
    current.validate()?;
    proposed.validate()?;
    protection::validate(current, proposed)?;
    let changes = differences(current, proposed)?;
    Ok(ChangePreview {
        base_revision: current.revision,
        changes,
        affected_work: replacement::affected_work(current, proposed),
        applicability_changes: applicability::changes(current, proposed),
    })
}

/// Collections serialized as maps keyed by stable identity, compared entity by entity.
const KEYED_COLLECTIONS: [&str; 7] = [
    "projects",
    "assets",
    "work_items",
    "requirements",
    "decisions",
    "risks",
    "external_references",
];

/// The entity-level differences between two plans in canonical order, without validation.
///
/// Artifacts, the revision and the format version are not compared: protection refuses a proposal
/// that changes them, so they never appear in a reviewed change.
/// A member of a serialized entity, or null where it is absent, as a new or removed entity reads.
fn field<'a>(value: &'a Value, key: &str) -> &'a Value {
    static ABSENT: Value = Value::Null;
    value.get(key).unwrap_or(&ABSENT)
}

pub(crate) fn differences(
    current: &Plan,
    proposed: &Plan,
) -> Result<Vec<EntityChange>, EngineError> {
    let before = serde_json::to_value(current)?;
    let after = serde_json::to_value(proposed)?;
    let mut changes = Vec::new();
    append(
        &mut changes,
        "workspace",
        None,
        field(&before, "workspace"),
        field(&after, "workspace"),
    );
    for collection in KEYED_COLLECTIONS {
        let (old, new) = (field(&before, collection), field(&after, collection));
        let ids: BTreeSet<_> = current_keys(old).chain(current_keys(new)).collect();
        for id in ids {
            append(
                &mut changes,
                collection,
                Some(id.clone()),
                field(old, &id),
                field(new, &id),
            );
        }
    }
    let before_edges = edges_by_id(current)?;
    let after_edges = edges_by_id(proposed)?;
    for id in before_edges
        .keys()
        .chain(after_edges.keys())
        .collect::<BTreeSet<_>>()
    {
        append(
            &mut changes,
            "dependencies",
            Some(id.clone()),
            before_edges.get(id).unwrap_or(&Value::Null),
            after_edges.get(id).unwrap_or(&Value::Null),
        );
    }
    append(
        &mut changes,
        "links",
        None,
        &sorted_links(current)?,
        &sorted_links(proposed)?,
    );
    append(
        &mut changes,
        "calendars",
        None,
        field(&before, "calendars"),
        field(&after, "calendars"),
    );
    Ok(changes)
}

fn current_keys(value: &Value) -> impl Iterator<Item = String> + '_ {
    value
        .as_object()
        .into_iter()
        .flat_map(|map| map.keys().cloned())
}

fn append(
    changes: &mut Vec<EntityChange>,
    collection: &str,
    id: Option<String>,
    before: &Value,
    after: &Value,
) {
    if before == after {
        return;
    }
    let fields = if before.is_object() && after.is_object() {
        current_keys(before)
            .chain(current_keys(after))
            .collect::<BTreeSet<_>>()
            .into_iter()
            .filter(|field| before[field] != after[field])
            .collect()
    } else {
        Vec::new()
    };
    changes.push(EntityChange {
        collection: collection.into(),
        id,
        fields,
        before: before.clone(),
        after: after.clone(),
    });
}

fn edges_by_id(plan: &Plan) -> Result<BTreeMap<String, Value>, EngineError> {
    plan.dependencies
        .iter()
        .map(|edge| Ok((edge.id.to_string(), serde_json::to_value(edge)?)))
        .collect()
}

fn sorted_links(plan: &Plan) -> Result<Value, EngineError> {
    let mut links = plan.links.clone();
    links.sort_by_key(|l| (l.kind, l.source, l.target));
    Ok(serde_json::to_value(links)?)
}

pub(crate) fn invalid(entity: impl ToString, reason: &str) -> EngineError {
    EngineError::InvalidCommand {
        entity: entity.to_string(),
        reason: reason.into(),
    }
}

#[cfg(test)]
mod tests;