Skip to main content

dpm_engine/change/
revert.rs

1use super::{ChangePreview, EntityChange};
2use crate::{Command, EngineError};
3use dpm_model::{OperationId, Plan};
4
5/// Review the change that undoes a recorded plan change, against the current plan.
6///
7/// The inverse of a recorded change swaps each entity's `before` and `after`, in reverse order. It applies only
8/// where every entity it names is still as the recorded change left it; otherwise each later-changed entity is
9/// named. The inverse is then validated like any proposal, so protected execution and the graph's invariants
10/// refuse it the same way, and the preview's `changes` are what [`Command::RevertChange`] must carry.
11pub fn propose_revert(
12    current: &Plan,
13    operation: OperationId,
14    recorded: &Command,
15) -> Result<ChangePreview, EngineError> {
16    let changes = match recorded {
17        Command::ApplyChange { changes, .. } | Command::RevertChange { changes, .. } => changes,
18        _ => {
19            return Err(EngineError::InvalidCommand {
20                entity: format!("operation {operation}"),
21                reason: "only a recorded plan change can be reverted".into(),
22            });
23        }
24    };
25    let state = serde_json::to_value(current)?;
26    let inverse: Vec<EntityChange> = changes
27        .iter()
28        .rev()
29        .map(|change| EntityChange {
30            before: as_applied(&state, change),
31            after: change.before.clone(),
32            ..change.clone()
33        })
34        .collect();
35    let mut conflicts = Vec::new();
36    for change in &inverse {
37        match super::patch(current, std::slice::from_ref(change)) {
38            Err(EngineError::StaleChange { .. }) => conflicts.push(name(current, change)),
39            Err(other) => return Err(other),
40            Ok(_) => {}
41        }
42    }
43    if !conflicts.is_empty() {
44        return Err(EngineError::InvalidCommand {
45            entity: format!("revert of operation {operation}"),
46            reason: format!(
47                "{} changed after it, by a later plan change or a lifecycle event; revert later plan changes first, or propose a new change",
48                conflicts.join(", ")
49            ),
50        });
51    }
52    let candidate = super::patch(current, &inverse)?;
53    super::propose_change(current, &candidate)
54}
55
56/// The state a recorded change left an entity in. Applying a change stamps when each decision it adds was
57/// resolved, which its recorded `after` cannot carry, so that one field is read from the current plan.
58fn as_applied(state: &serde_json::Value, change: &EntityChange) -> serde_json::Value {
59    let mut after = change.after.clone();
60    if change.collection == "decisions" && change.before.is_null() {
61        let stamped = change
62            .id
63            .as_deref()
64            .and_then(|id| state.get("decisions")?.get(id)?.get("resolved_at"))
65            .cloned();
66        if let (Some(stamped), Some(fields)) = (stamped, after.as_object_mut()) {
67            fields.insert("resolved_at".into(), stamped);
68        }
69    }
70    after
71}
72
73/// The entity a change names, with its key where it has one.
74fn name(current: &Plan, change: &EntityChange) -> String {
75    let id = change.id.as_deref().unwrap_or("");
76    let key = (change.collection == "work_items")
77        .then(|| current.work_items.values().find(|w| w.id.to_string() == id))
78        .flatten()
79        .map(|w| format!(" ({})", w.key));
80    match &change.id {
81        Some(id) => format!("{} {id}{}", change.collection, key.unwrap_or_default()),
82        None => change.collection.clone(),
83    }
84}
85
86#[cfg(test)]
87mod tests;