dpm_engine/change/
replacement.rs1use super::invalid;
2use crate::EngineError;
3use crate::context::{applicable_decisions, lineage};
4use dpm_model::{Decision, DecisionStatus, Key, Plan, WorkItemId, WorkKind, WorkStatus};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AffectedWork {
13 pub decision: Key,
15 pub replacement: Key,
17 pub work: WorkItemId,
19 pub key: Key,
21 pub kind: WorkKind,
23 pub status: WorkStatus,
25}
26
27pub(super) fn validate(
32 current: &Plan,
33 proposed: &Plan,
34 locked: &BTreeSet<WorkItemId>,
35) -> Result<(), EngineError> {
36 for decision in current.decisions.values() {
37 match proposed.decisions.get(&decision.id) {
38 Some(next) if next == decision => {}
39 Some(next) if refines_open(decision, next) => {
40 let added: BTreeSet<_> = next.blocks.difference(&decision.blocks).collect();
41 if added.iter().any(|id| locked.contains(id)) {
42 return Err(invalid(
43 &decision.key,
44 "cannot add a gate to the basis of existing execution",
45 ));
46 }
47 }
48 Some(next) if supersedes_only(decision, next) => {
49 if !proposed
50 .decisions
51 .values()
52 .any(|d| d.supersedes == Some(decision.id))
53 {
54 return Err(invalid(
55 &decision.key,
56 "a superseded decision needs a linked replacement",
57 ));
58 }
59 }
60 _ => {
61 return Err(invalid(
62 &decision.key,
63 "existing decisions change only when a linked replacement supersedes a Decided choice; use decide for an open outcome",
64 ));
65 }
66 }
67 }
68 for decision in proposed
69 .decisions
70 .values()
71 .filter(|d| !current.decisions.contains_key(&d.id))
72 {
73 match decision.supersedes {
74 None => new_question(decision, locked)?,
75 Some(target) => replacement(current, decision, target)?,
76 }
77 }
78 Ok(())
79}
80
81fn refines_open(before: &Decision, after: &Decision) -> bool {
84 before.status == DecisionStatus::Open
85 && after.blocks.is_superset(&before.blocks)
86 && Decision {
87 question: before.question.clone(),
88 related_work: before.related_work.clone(),
89 blocks: before.blocks.clone(),
90 ..after.clone()
91 } == *before
92}
93
94fn supersedes_only(before: &Decision, after: &Decision) -> bool {
95 before.status == DecisionStatus::Decided
96 && after.status == DecisionStatus::Superseded
97 && Decision {
98 status: before.status,
99 ..after.clone()
100 } == *before
101}
102
103fn new_question(decision: &Decision, locked: &BTreeSet<WorkItemId>) -> Result<(), EngineError> {
104 if decision.status != DecisionStatus::Open || decision.outcome.is_some() {
105 return Err(invalid(
106 &decision.key,
107 "new decisions must be open unless they replace a Decided choice",
108 ));
109 }
110 if !decision.blocks.is_disjoint(locked) {
111 return Err(invalid(
112 &decision.key,
113 "cannot add a gate to the basis of existing execution",
114 ));
115 }
116 Ok(())
117}
118
119fn replacement(
120 current: &Plan,
121 decision: &Decision,
122 target: dpm_model::DecisionId,
123) -> Result<(), EngineError> {
124 if current
125 .decisions
126 .get(&target)
127 .is_none_or(|d| d.status != DecisionStatus::Decided)
128 {
129 return Err(invalid(
130 &decision.key,
131 "a replacement must supersede an existing Decided choice",
132 ));
133 }
134 if decision.status != DecisionStatus::Decided
135 || decision
136 .rationale
137 .as_ref()
138 .is_none_or(|r| r.trim().is_empty())
139 {
140 return Err(invalid(
141 &decision.key,
142 "a replacement records a Decided outcome with its rationale",
143 ));
144 }
145 if !decision.blocks.is_empty() {
146 return Err(invalid(
147 &decision.key,
148 "a replacement is context; it cannot add execution gates",
149 ));
150 }
151 Ok(())
152}
153
154pub(super) fn affected_work(current: &Plan, proposed: &Plan) -> Vec<AffectedWork> {
156 let mut affected = Vec::new();
157 for new in proposed
158 .decisions
159 .values()
160 .filter(|d| !current.decisions.contains_key(&d.id))
161 {
162 let Some(old) = new.supersedes.and_then(|id| proposed.decisions.get(&id)) else {
163 continue;
164 };
165 for work in proposed.work_items.values() {
166 let ids: BTreeSet<_> = lineage(proposed, work).iter().map(|w| w.id).collect();
167 let context = applicable_decisions(proposed, &ids);
169 if context.contains(&old.id) || context.contains(&new.id) {
170 affected.push(AffectedWork {
171 decision: old.key.clone(),
172 replacement: new.key.clone(),
173 work: work.id,
174 key: work.key.clone(),
175 kind: work.kind,
176 status: work.execution.status,
177 });
178 }
179 }
180 }
181 affected.sort_by(|a, b| (&a.decision, &a.key).cmp(&(&b.decision, &b.key)));
182 affected
183}
184
185#[cfg(test)]
186mod tests;