use super::{EntityChange, invalid};
use crate::{Command, EngineError, Operation};
use chrono::{DateTime, Utc};
use dpm_model::{ActorId, ActorKind, OperationId, Plan};
use serde_json::{Map, Value};
use std::collections::BTreeSet;
pub(crate) fn authorize(actor: &ActorId, reason: &str) -> Result<(), EngineError> {
if reason.trim().is_empty() {
return Err(invalid("plan change", "reason must not be empty"));
}
if actor.kind == ActorKind::Agent {
return Err(EngineError::ActorNotAllowed {
actor: actor.clone(),
action: "approve plan scope",
});
}
Ok(())
}
pub fn plan_change(
current: &Plan,
proposed: &Plan,
reason: impl Into<String>,
) -> Result<Command, EngineError> {
let preview = crate::propose_change(current, proposed)?;
Ok(Command::ApplyChange {
changes: preview.changes,
reason: reason.into(),
})
}
pub fn apply_plan_change(
plan: &mut Plan,
actor: ActorId,
proposed: &Plan,
reason: impl Into<String>,
timestamp: DateTime<Utc>,
id: OperationId,
) -> Result<Operation, EngineError> {
let reason = reason.into();
authorize(&actor, &reason)?;
let command = plan_change(plan, proposed, reason)?;
crate::apply_command(plan, actor, command, timestamp, id)
}
pub(crate) fn canonical(current: &Plan, changes: &[EntityChange]) -> Option<Vec<EntityChange>> {
let candidate = patch(current, changes).ok()?;
let canonical = super::differences(current, &candidate).ok()?;
let matches = canonical.len() == changes.len()
&& canonical.iter().zip(changes).all(|(a, b)| {
a.collection == b.collection
&& a.id == b.id
&& a.fields == b.fields
&& same(&a.before, &b.before)
&& same(&a.after, &b.after)
});
matches.then_some(canonical)
}
pub fn patch(current: &Plan, changes: &[EntityChange]) -> Result<Plan, EngineError> {
let mut value = serde_json::to_value(current)?;
let mut seen = BTreeSet::new();
for change in changes {
if !seen.insert((change.collection.as_str(), change.id.as_deref())) {
return Err(invalid(
describe(change),
"a plan change names each entity at most once",
));
}
apply_one(&mut value, current, change)?;
}
Ok(serde_json::from_value(value)?)
}
fn apply_one(value: &mut Value, current: &Plan, change: &EntityChange) -> Result<(), EngineError> {
let root = value
.as_object_mut()
.ok_or_else(|| invalid("plan", "a plan serializes as an object"))?;
match (change.collection.as_str(), change.id.as_deref()) {
("workspace", None) => {
let slot = root.entry("workspace").or_insert(Value::Null);
replace(slot, change)
}
("links", None) => patch_links(root, current, change),
("calendars", None) => {
let found = root.get("calendars").cloned().unwrap_or(Value::Null);
fresh(&found, change)?;
if change.after.is_null() {
root.remove("calendars");
} else {
root.insert("calendars".into(), change.after.clone());
}
Ok(())
}
("dependencies", Some(id)) => patch_edges(root, id, change),
(collection, Some(id)) if super::KEYED_COLLECTIONS.contains(&collection) => {
let map = root
.entry(collection)
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| invalid(collection, "a keyed collection serializes as an object"))?;
let found = map.get(id).cloned().unwrap_or(Value::Null);
fresh(&found, change)?;
if change.after.is_null() {
map.remove(id);
} else {
map.insert(id.to_owned(), change.after.clone());
}
Ok(())
}
_ => Err(invalid(describe(change), "unknown plan change target")),
}
}
fn replace(slot: &mut Value, change: &EntityChange) -> Result<(), EngineError> {
fresh(slot, change)?;
if change.after.is_null() {
return Err(invalid(describe(change), "a workspace cannot be removed"));
}
*slot = change.after.clone();
Ok(())
}
fn patch_edges(
root: &mut Map<String, Value>,
id: &str,
change: &EntityChange,
) -> Result<(), EngineError> {
let edges = root
.entry("dependencies")
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| invalid("dependencies", "dependencies serialize as an array"))?;
let position = edges
.iter()
.position(|edge| edge.get("id").and_then(Value::as_str) == Some(id));
let found = position
.and_then(|index| edges.get(index))
.cloned()
.unwrap_or(Value::Null);
fresh(&found, change)?;
match (position, change.after.is_null()) {
(Some(index), true) => {
edges.remove(index);
}
(Some(index), false) => {
if let Some(slot) = edges.get_mut(index) {
*slot = change.after.clone();
}
}
(None, false) => edges.push(change.after.clone()),
(None, true) => {}
}
Ok(())
}
fn patch_links(
root: &mut Map<String, Value>,
current: &Plan,
change: &EntityChange,
) -> Result<(), EngineError> {
fresh(&super::sorted_links(current)?, change)?;
let wanted = change.after.as_array().cloned().unwrap_or_default();
let existing = root
.get("links")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut links: Vec<Value> = existing
.into_iter()
.filter(|link| wanted.contains(link))
.collect();
let added: Vec<Value> = wanted
.into_iter()
.filter(|link| !links.contains(link))
.collect();
links.extend(added);
root.insert("links".into(), Value::Array(links));
Ok(())
}
fn fresh(found: &Value, change: &EntityChange) -> Result<(), EngineError> {
if same(found, &change.before) {
return Ok(());
}
Err(EngineError::StaleChange {
collection: change.collection.clone(),
id: change.id.clone(),
})
}
pub fn json_equivalent(left: &Value, right: &Value) -> bool {
same(left, right)
}
fn same(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(a), Value::Number(b)) => match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) if a.is_f64() || b.is_f64() => x == y,
_ => a == b,
},
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| same(x, y))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.all(|(key, x)| b.get(key).is_some_and(|y| same(x, y)))
}
_ => left == right,
}
}
fn describe(change: &EntityChange) -> String {
match &change.id {
Some(id) => format!("{} {id}", change.collection),
None => change.collection.clone(),
}
}
#[cfg(test)]
mod tests;