1use super::{EntityChange, invalid};
18use crate::{Command, EngineError, Operation};
19use chrono::{DateTime, Utc};
20use dpm_model::{ActorId, ActorKind, OperationId, Plan};
21use serde_json::{Map, Value};
22use std::collections::BTreeSet;
23
24pub(crate) fn authorize(actor: &ActorId, reason: &str) -> Result<(), EngineError> {
26 if reason.trim().is_empty() {
27 return Err(invalid("plan change", "reason must not be empty"));
28 }
29 if actor.kind == ActorKind::Agent {
30 return Err(EngineError::ActorNotAllowed {
31 actor: actor.clone(),
32 action: "approve plan scope",
33 });
34 }
35 Ok(())
36}
37
38pub fn plan_change(
43 current: &Plan,
44 proposed: &Plan,
45 reason: impl Into<String>,
46) -> Result<Command, EngineError> {
47 let preview = crate::propose_change(current, proposed)?;
48 Ok(Command::ApplyChange {
49 changes: preview.changes,
50 reason: reason.into(),
51 })
52}
53
54pub fn apply_plan_change(
59 plan: &mut Plan,
60 actor: ActorId,
61 proposed: &Plan,
62 reason: impl Into<String>,
63 timestamp: DateTime<Utc>,
64 id: OperationId,
65) -> Result<Operation, EngineError> {
66 let reason = reason.into();
67 authorize(&actor, &reason)?;
68 let command = plan_change(plan, proposed, reason)?;
69 crate::apply_command(plan, actor, command, timestamp, id)
70}
71
72pub(crate) fn canonical(current: &Plan, changes: &[EntityChange]) -> Option<Vec<EntityChange>> {
75 let candidate = patch(current, changes).ok()?;
76 let canonical = super::differences(current, &candidate).ok()?;
77 let matches = canonical.len() == changes.len()
78 && canonical.iter().zip(changes).all(|(a, b)| {
79 a.collection == b.collection
80 && a.id == b.id
81 && a.fields == b.fields
82 && same(&a.before, &b.before)
83 && same(&a.after, &b.after)
84 });
85 matches.then_some(canonical)
86}
87
88pub fn patch(current: &Plan, changes: &[EntityChange]) -> Result<Plan, EngineError> {
94 let mut value = serde_json::to_value(current)?;
95 let mut seen = BTreeSet::new();
96 for change in changes {
97 if !seen.insert((change.collection.as_str(), change.id.as_deref())) {
98 return Err(invalid(
99 describe(change),
100 "a plan change names each entity at most once",
101 ));
102 }
103 apply_one(&mut value, current, change)?;
104 }
105 Ok(serde_json::from_value(value)?)
106}
107
108fn apply_one(value: &mut Value, current: &Plan, change: &EntityChange) -> Result<(), EngineError> {
109 let root = value
110 .as_object_mut()
111 .ok_or_else(|| invalid("plan", "a plan serializes as an object"))?;
112 match (change.collection.as_str(), change.id.as_deref()) {
113 ("workspace", None) => {
114 let slot = root.entry("workspace").or_insert(Value::Null);
115 replace(slot, change)
116 }
117 ("links", None) => patch_links(root, current, change),
118 ("calendars", None) => {
119 let found = root.get("calendars").cloned().unwrap_or(Value::Null);
120 fresh(&found, change)?;
121 if change.after.is_null() {
122 root.remove("calendars");
123 } else {
124 root.insert("calendars".into(), change.after.clone());
125 }
126 Ok(())
127 }
128 ("dependencies", Some(id)) => patch_edges(root, id, change),
129 (collection, Some(id)) if super::KEYED_COLLECTIONS.contains(&collection) => {
130 let map = root
131 .entry(collection)
132 .or_insert_with(|| Value::Object(Map::new()))
133 .as_object_mut()
134 .ok_or_else(|| invalid(collection, "a keyed collection serializes as an object"))?;
135 let found = map.get(id).cloned().unwrap_or(Value::Null);
136 fresh(&found, change)?;
137 if change.after.is_null() {
138 map.remove(id);
139 } else {
140 map.insert(id.to_owned(), change.after.clone());
141 }
142 Ok(())
143 }
144 _ => Err(invalid(describe(change), "unknown plan change target")),
145 }
146}
147
148fn replace(slot: &mut Value, change: &EntityChange) -> Result<(), EngineError> {
149 fresh(slot, change)?;
150 if change.after.is_null() {
151 return Err(invalid(describe(change), "a workspace cannot be removed"));
152 }
153 *slot = change.after.clone();
154 Ok(())
155}
156
157fn patch_edges(
158 root: &mut Map<String, Value>,
159 id: &str,
160 change: &EntityChange,
161) -> Result<(), EngineError> {
162 let edges = root
163 .entry("dependencies")
164 .or_insert_with(|| Value::Array(Vec::new()))
165 .as_array_mut()
166 .ok_or_else(|| invalid("dependencies", "dependencies serialize as an array"))?;
167 let position = edges
168 .iter()
169 .position(|edge| edge.get("id").and_then(Value::as_str) == Some(id));
170 let found = position
171 .and_then(|index| edges.get(index))
172 .cloned()
173 .unwrap_or(Value::Null);
174 fresh(&found, change)?;
175 match (position, change.after.is_null()) {
176 (Some(index), true) => {
177 edges.remove(index);
178 }
179 (Some(index), false) => {
180 if let Some(slot) = edges.get_mut(index) {
181 *slot = change.after.clone();
182 }
183 }
184 (None, false) => edges.push(change.after.clone()),
185 (None, true) => {}
186 }
187 Ok(())
188}
189
190fn patch_links(
192 root: &mut Map<String, Value>,
193 current: &Plan,
194 change: &EntityChange,
195) -> Result<(), EngineError> {
196 fresh(&super::sorted_links(current)?, change)?;
197 let wanted = change.after.as_array().cloned().unwrap_or_default();
198 let existing = root
199 .get("links")
200 .and_then(Value::as_array)
201 .cloned()
202 .unwrap_or_default();
203 let mut links: Vec<Value> = existing
204 .into_iter()
205 .filter(|link| wanted.contains(link))
206 .collect();
207 let added: Vec<Value> = wanted
208 .into_iter()
209 .filter(|link| !links.contains(link))
210 .collect();
211 links.extend(added);
212 root.insert("links".into(), Value::Array(links));
213 Ok(())
214}
215
216fn fresh(found: &Value, change: &EntityChange) -> Result<(), EngineError> {
217 if same(found, &change.before) {
218 return Ok(());
219 }
220 Err(EngineError::StaleChange {
221 collection: change.collection.clone(),
222 id: change.id.clone(),
223 })
224}
225
226pub fn json_equivalent(left: &Value, right: &Value) -> bool {
229 same(left, right)
230}
231
232fn same(left: &Value, right: &Value) -> bool {
233 match (left, right) {
234 (Value::Number(a), Value::Number(b)) => match (a.as_f64(), b.as_f64()) {
235 (Some(x), Some(y)) if a.is_f64() || b.is_f64() => x == y,
236 _ => a == b,
237 },
238 (Value::Array(a), Value::Array(b)) => {
239 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| same(x, y))
240 }
241 (Value::Object(a), Value::Object(b)) => {
242 a.len() == b.len()
243 && a.iter()
244 .all(|(key, x)| b.get(key).is_some_and(|y| same(x, y)))
245 }
246 _ => left == right,
247 }
248}
249
250fn describe(change: &EntityChange) -> String {
251 match &change.id {
252 Some(id) => format!("{} {id}", change.collection),
253 None => change.collection.clone(),
254 }
255}
256
257#[cfg(test)]
258mod tests;