Skip to main content

cpm_planner/
edits.rs

1//! Pure structured edits over a [`PlanGraph`].
2//!
3//! A [`GraphEdit`] is the wire-stable description of one mutation an
4//! operator (or an agent) may apply to a submitted graph. [`apply_edits`]
5//! applies a batch in order to a clone of the input, then enforces the same
6//! structural invariants `plan.submit` enforces via
7//! `crate::planner::validate_graph`. It never touches the persisted plan;
8//! callers decide whether to resubmit the result.
9
10use serde::{Deserialize, Serialize};
11
12use crate::plan::{Deliverable, Estimate, PlanGraph, PlannerError, Prerequisite};
13use crate::planner::validate_graph;
14
15/// One structured mutation of a [`PlanGraph`].
16///
17/// Serialised with an `op` tag (`snake_case` variant names) and strict
18/// unknown-field rejection so a typo in an edit fails loudly rather than
19/// silently no-op'ing.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
22pub enum GraphEdit {
23    /// Remove prerequisite `from` from deliverable `to`.
24    RemoveEdge { from: String, to: String },
25    /// Make deliverable `to` depend on deliverable `from`.
26    AddEdge {
27        from: String,
28        to: String,
29        #[serde(default)]
30        consumes: Option<String>,
31    },
32    /// Set a deliverable's estimated effort in hours.
33    SetEffort { id: String, hours: f32 },
34    /// Set (or clear) a deliverable's calendar duration in hours.
35    SetDuration { id: String, hours: Option<f32> },
36    /// Set (or clear) a deliverable's three-point estimate.
37    SetEstimate {
38        id: String,
39        estimate: Option<Estimate>,
40    },
41    /// Set one metadata key on a deliverable.
42    SetMetadata {
43        id: String,
44        key: String,
45        value: serde_json::Value,
46    },
47    /// Remove a deliverable entirely.
48    RemoveDeliverable { id: String },
49    /// Append a new deliverable.
50    AddDeliverable { deliverable: Deliverable },
51}
52
53/// Apply `edits` in order to a clone of `graph`, then validate the result
54/// with the same rules as `plan.submit`.
55///
56/// An edit that names a deliverable id absent from the graph being edited
57/// returns [`PlannerError::InvalidGraph`] whose `reason` starts with
58/// `edit <index>: ` and names the offending id. A failure of the final
59/// whole-graph validation is `INVALID_GRAPH` whose `reason` starts with
60/// `after applying <n> edits: `.
61pub fn apply_edits(graph: &PlanGraph, edits: &[GraphEdit]) -> Result<PlanGraph, PlannerError> {
62    let mut out = graph.clone();
63    for (index, edit) in edits.iter().enumerate() {
64        apply_one(&mut out, index, edit)?;
65    }
66    validate_graph(&out).map_err(|err| match err {
67        PlannerError::InvalidGraph { reason } => PlannerError::InvalidGraph {
68            reason: format!("after applying {} edits: {reason}", edits.len()),
69        },
70        other => other,
71    })?;
72    Ok(out)
73}
74
75fn unknown_id(index: usize, id: &str) -> PlannerError {
76    PlannerError::InvalidGraph {
77        reason: format!("edit {index}: unknown deliverable id '{id}'"),
78    }
79}
80
81fn contains_id(graph: &PlanGraph, id: &str) -> bool {
82    graph.deliverables.iter().any(|d| d.id == id)
83}
84
85fn find_mut<'a>(graph: &'a mut PlanGraph, id: &str) -> Option<&'a mut Deliverable> {
86    graph.deliverables.iter_mut().find(|d| d.id == id)
87}
88
89fn apply_one(graph: &mut PlanGraph, index: usize, edit: &GraphEdit) -> Result<(), PlannerError> {
90    match edit {
91        GraphEdit::RemoveEdge { from, to } => {
92            if !contains_id(graph, to) {
93                return Err(unknown_id(index, to));
94            }
95            if !contains_id(graph, from) {
96                return Err(unknown_id(index, from));
97            }
98            let target = find_mut(graph, to).expect("checked present above");
99            let before = target.prerequisites.len();
100            target.prerequisites.retain(|p| p.id() != from);
101            if target.prerequisites.len() == before {
102                return Err(PlannerError::InvalidGraph {
103                    reason: format!("edit {index}: no edge '{from}' -> '{to}'"),
104                });
105            }
106        }
107        GraphEdit::AddEdge { from, to, consumes } => {
108            if !contains_id(graph, to) {
109                return Err(unknown_id(index, to));
110            }
111            if !contains_id(graph, from) {
112                return Err(unknown_id(index, from));
113            }
114            let edge = match consumes {
115                Some(consumes) => Prerequisite::Edge {
116                    id: from.clone(),
117                    consumes: Some(consumes.clone()),
118                    kind: None,
119                    lag_hours: None,
120                },
121                None => Prerequisite::Id(from.clone()),
122            };
123            let target = find_mut(graph, to).expect("checked present above");
124            if target.prerequisites.iter().any(|p| p.id() == from) {
125                return Err(PlannerError::InvalidGraph {
126                    reason: format!("edit {index}: edge '{from}' -> '{to}' already exists"),
127                });
128            }
129            target.prerequisites.push(edge);
130        }
131        GraphEdit::SetEffort { id, hours } => {
132            let target = find_mut(graph, id).ok_or_else(|| unknown_id(index, id))?;
133            target.estimated_effort_hours = Some(*hours);
134        }
135        GraphEdit::SetDuration { id, hours } => {
136            let target = find_mut(graph, id).ok_or_else(|| unknown_id(index, id))?;
137            target.duration_hours = *hours;
138        }
139        GraphEdit::SetEstimate { id, estimate } => {
140            let target = find_mut(graph, id).ok_or_else(|| unknown_id(index, id))?;
141            target.estimate = *estimate;
142        }
143        GraphEdit::SetMetadata { id, key, value } => {
144            let target = find_mut(graph, id).ok_or_else(|| unknown_id(index, id))?;
145            if !target.metadata.is_null() && !target.metadata.is_object() {
146                return Err(PlannerError::InvalidGraph {
147                    reason: format!("edit {index}: metadata of '{id}' is not an object"),
148                });
149            }
150            if target.metadata.is_null() {
151                target.metadata = serde_json::json!({});
152            }
153            target
154                .metadata
155                .as_object_mut()
156                .expect("metadata coerced to an object above")
157                .insert(key.clone(), value.clone());
158        }
159        GraphEdit::RemoveDeliverable { id } => {
160            if !contains_id(graph, id) {
161                return Err(unknown_id(index, id));
162            }
163            let mut dependents: Vec<&str> = graph
164                .deliverables
165                .iter()
166                .filter(|d| d.id != *id && d.prerequisites.iter().any(|p| p.id() == id))
167                .map(|d| d.id.as_str())
168                .collect();
169            if !dependents.is_empty() {
170                dependents.sort_unstable();
171                return Err(PlannerError::InvalidGraph {
172                    reason: format!(
173                        "edit {index}: '{id}' is a prerequisite of [{}]; remove those edges first",
174                        dependents.join(", ")
175                    ),
176                });
177            }
178            graph.deliverables.retain(|d| d.id != *id);
179        }
180        GraphEdit::AddDeliverable { deliverable } => {
181            if contains_id(graph, &deliverable.id) {
182                return Err(PlannerError::InvalidGraph {
183                    reason: format!(
184                        "edit {index}: deliverable '{}' already exists",
185                        deliverable.id
186                    ),
187                });
188            }
189            for prereq in &deliverable.prerequisites {
190                if !contains_id(graph, prereq.id()) {
191                    return Err(unknown_id(index, prereq.id()));
192                }
193            }
194            graph.deliverables.push(deliverable.clone());
195        }
196    }
197    Ok(())
198}