dpm_engine/change/
revert.rs1use super::{ChangePreview, EntityChange};
2use crate::{Command, EngineError};
3use dpm_model::{OperationId, Plan};
4
5pub 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
56fn 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
73fn 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;