use crate::EngineError;
use dpm_model::Plan;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod applicability;
mod external;
pub use applicability::ApplicabilityChange;
mod independence;
mod patch;
pub(crate) use independence::refuse_own_relaxation;
pub use independence::{EdgeRelaxation, RelaxedConstraint};
pub(crate) use patch::authorize;
pub(crate) use patch::canonical;
pub use patch::{apply_plan_change, json_equivalent, patch, plan_change};
mod policy;
mod protection;
pub use protection::protected_work;
mod replacement;
pub use replacement::AffectedWork;
mod revert;
pub use revert::propose_revert;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EntityChange {
pub collection: String,
pub id: Option<String>,
pub fields: Vec<String>,
pub before: Value,
pub after: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChangePreview {
pub base_revision: u64,
pub changes: Vec<EntityChange>,
pub affected_work: Vec<AffectedWork>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applicability_changes: Vec<ApplicabilityChange>,
}
pub fn propose_change(current: &Plan, proposed: &Plan) -> Result<ChangePreview, EngineError> {
current.validate()?;
proposed.validate()?;
protection::validate(current, proposed)?;
let changes = differences(current, proposed)?;
Ok(ChangePreview {
base_revision: current.revision,
changes,
affected_work: replacement::affected_work(current, proposed),
applicability_changes: applicability::changes(current, proposed),
})
}
const KEYED_COLLECTIONS: [&str; 7] = [
"projects",
"assets",
"work_items",
"requirements",
"decisions",
"risks",
"external_references",
];
fn field<'a>(value: &'a Value, key: &str) -> &'a Value {
static ABSENT: Value = Value::Null;
value.get(key).unwrap_or(&ABSENT)
}
pub(crate) fn differences(
current: &Plan,
proposed: &Plan,
) -> Result<Vec<EntityChange>, EngineError> {
let before = serde_json::to_value(current)?;
let after = serde_json::to_value(proposed)?;
let mut changes = Vec::new();
append(
&mut changes,
"workspace",
None,
field(&before, "workspace"),
field(&after, "workspace"),
);
for collection in KEYED_COLLECTIONS {
let (old, new) = (field(&before, collection), field(&after, collection));
let ids: BTreeSet<_> = current_keys(old).chain(current_keys(new)).collect();
for id in ids {
append(
&mut changes,
collection,
Some(id.clone()),
field(old, &id),
field(new, &id),
);
}
}
let before_edges = edges_by_id(current)?;
let after_edges = edges_by_id(proposed)?;
for id in before_edges
.keys()
.chain(after_edges.keys())
.collect::<BTreeSet<_>>()
{
append(
&mut changes,
"dependencies",
Some(id.clone()),
before_edges.get(id).unwrap_or(&Value::Null),
after_edges.get(id).unwrap_or(&Value::Null),
);
}
append(
&mut changes,
"links",
None,
&sorted_links(current)?,
&sorted_links(proposed)?,
);
append(
&mut changes,
"calendars",
None,
field(&before, "calendars"),
field(&after, "calendars"),
);
Ok(changes)
}
fn current_keys(value: &Value) -> impl Iterator<Item = String> + '_ {
value
.as_object()
.into_iter()
.flat_map(|map| map.keys().cloned())
}
fn append(
changes: &mut Vec<EntityChange>,
collection: &str,
id: Option<String>,
before: &Value,
after: &Value,
) {
if before == after {
return;
}
let fields = if before.is_object() && after.is_object() {
current_keys(before)
.chain(current_keys(after))
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|field| before[field] != after[field])
.collect()
} else {
Vec::new()
};
changes.push(EntityChange {
collection: collection.into(),
id,
fields,
before: before.clone(),
after: after.clone(),
});
}
fn edges_by_id(plan: &Plan) -> Result<BTreeMap<String, Value>, EngineError> {
plan.dependencies
.iter()
.map(|edge| Ok((edge.id.to_string(), serde_json::to_value(edge)?)))
.collect()
}
fn sorted_links(plan: &Plan) -> Result<Value, EngineError> {
let mut links = plan.links.clone();
links.sort_by_key(|l| (l.kind, l.source, l.target));
Ok(serde_json::to_value(links)?)
}
pub(crate) fn invalid(entity: impl ToString, reason: &str) -> EngineError {
EngineError::InvalidCommand {
entity: entity.to_string(),
reason: reason.into(),
}
}
#[cfg(test)]
mod tests;