Skip to main content

cpm_planner/
revise.rs

1//! Pure plan revision: derive the runtime state of a revised graph from the
2//! state of the plan it replaces, carrying progress over.
3//!
4//! No store and no I/O: `plan_revision` maps `(old state, new graph)` to
5//! `(new state, diff)`. The store wiring and audit events live elsewhere.
6//!
7//! Carry-over rules:
8//! - an unchanged deliverable (same canonical definition) keeps its status and
9//!   counters;
10//! - a changed definition keeps its status unless its prerequisite id set
11//!   changed, in which case a `Complete`/`Ready`/`Pending` deliverable is
12//!   re-derived (`Ready` if every prerequisite is `Complete`, else `Pending`)
13//!   and listed in `reopened`;
14//! - dependents of a reopened deliverable are re-derived the same way,
15//!   transitively;
16//! - a deliverable with an `interface` edge to a changed deliverable whose
17//!   `metadata.contract == true` is reopened;
18//! - new deliverables start `Ready`/`Pending` by their prerequisites;
19//! - removed deliverables are dropped; one holding a live lock is refused with
20//!   `LOCK_HELD` unless `force`, in which case its lock is released and its id
21//!   listed in `released_locks`;
22//! - counters (attempts, failures, lapses) are kept for every surviving
23//!   deliverable, including re-derived ones;
24//! - `InProgress` (leased) deliverables keep their lease unless removed.
25//!   `Failed` deliverables are outside the reopen rule: they stay `Failed`
26//!   (re-derivation applies only to `Complete`/`Ready`/`Pending`).
27
28use std::collections::{BTreeSet, HashMap, HashSet};
29
30use serde::{Deserialize, Serialize};
31
32use crate::graph::prerequisite_ids;
33use crate::locks::{FileClaim, PlanState, add_file_claims, rederive_status, release_file_claims};
34use crate::plan::{Deliverable, DeliverableStatus, PlanGraph, PlannerError, PrerequisiteKind};
35use crate::planner::{canonical_deliverable, validate_graph};
36use chrono::{DateTime, Utc};
37
38/// What a revision changed. Every vector is sorted.
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40pub struct RevisionDiff {
41    /// Deliverable ids present only in the new graph.
42    pub added: Vec<String>,
43    /// Deliverable ids present only in the old graph.
44    pub removed: Vec<String>,
45    /// Surviving ids whose canonical definition differs.
46    pub changed: Vec<String>,
47    /// Surviving ids whose status was re-derived from their prerequisites.
48    pub reopened: Vec<String>,
49    /// Deliverable ids whose live lock was force-released by a removal.
50    pub released_locks: Vec<String>,
51}
52
53/// Whether a deliverable in `status` is subject to re-derivation.
54fn rederivable(status: &DeliverableStatus) -> bool {
55    matches!(
56        status,
57        DeliverableStatus::Complete | DeliverableStatus::Ready | DeliverableStatus::Pending
58    )
59}
60
61fn is_contract(d: &Deliverable) -> bool {
62    d.metadata
63        .get("contract")
64        .and_then(serde_json::Value::as_bool)
65        .unwrap_or(false)
66}
67
68fn prereq_set(d: &Deliverable) -> BTreeSet<&str> {
69    prerequisite_ids(d).collect()
70}
71
72/// Derive the runtime state of `new` from `old`, carrying progress over per
73/// the module rules. Pure: nothing is persisted and no audit is emitted.
74///
75/// The new graph is validated first (`validate_graph`: duplicate ids, unknown
76/// prerequisites, ...), so a bad graph is `INVALID_GRAPH`, never carried over.
77/// A lock with `expires_at < now` is not live: it never blocks removal, is not
78/// kept, and its deliverable is re-derived from its prerequisites.
79///
80/// Precondition: the caller has already reaped expired locks (so lapse
81/// accounting and the `lock expired` audit happened). Debug builds assert it;
82/// release builds defend by ignoring the expired lock and counting one lapse
83/// for a surviving deliverable that loses its lease here.
84///
85/// Surviving leases are re-claimed in graph order. If two now claim
86/// conflicting files: without `force` the revision is refused with
87/// `LOCK_HELD`; with `force` the lease of the deliverable whose `owned_files`
88/// changed in this revision is released (listed in `released_locks`, status
89/// re-derived), falling back to the later one in graph order when neither or
90/// both changed.
91///
92/// Every lease of `old` that does not survive (removed deliverable, forced
93/// release, lapsed) is credited with its hours up to `now` (up to its
94/// expiry when lapsed) in the new state's `leased_hours`.
95///
96/// Errors: `LOCK_HELD` when a removed deliverable holds a live lock, or two
97/// surviving leases would claim conflicting files, and `force` is false; any
98/// error from the CPM recomputation on `new`.
99pub(crate) fn plan_revision(
100    old: &PlanState,
101    new: &PlanGraph,
102    force: bool,
103    now: DateTime<Utc>,
104) -> Result<(PlanState, RevisionDiff), PlannerError> {
105    debug_assert!(
106        old.locks.values().all(|l| l.expires_at >= now),
107        "plan_revision requires expired locks to be reaped first"
108    );
109    validate_graph(new)?;
110    let cached_result = crate::schedule::compute_cpm(new)?;
111    let live = |id: &str| old.locks.get(id).filter(|l| l.expires_at >= now);
112
113    let old_by_id: HashMap<&str, &Deliverable> = old
114        .graph
115        .deliverables
116        .iter()
117        .map(|d| (d.id.as_str(), d))
118        .collect();
119    let new_by_id: HashMap<&str, &Deliverable> = new
120        .deliverables
121        .iter()
122        .map(|d| (d.id.as_str(), d))
123        .collect();
124
125    let mut diff = RevisionDiff::default();
126    for id in old_by_id.keys() {
127        if !new_by_id.contains_key(id) {
128            diff.removed.push((*id).to_string());
129        }
130    }
131    for d in &new.deliverables {
132        match old_by_id.get(d.id.as_str()) {
133            None => diff.added.push(d.id.clone()),
134            Some(o) if canonical_deliverable(o) != canonical_deliverable(d) => {
135                diff.changed.push(d.id.clone());
136            }
137            Some(_) => {}
138        }
139    }
140    diff.added.sort();
141    diff.removed.sort();
142    diff.changed.sort();
143
144    // Removal of a leased deliverable: refuse, or release when forced.
145    for id in &diff.removed {
146        if let Some(lock) = live(id) {
147            if !force {
148                return Err(PlannerError::LockHeld {
149                    plan_id: lock.plan_id.0.clone(),
150                    deliverable_id: id.clone(),
151                    holder: lock.caller_id.0.clone(),
152                });
153            }
154            diff.released_locks.push(id.clone());
155        }
156    }
157
158    // Changed deliverables whose contract flag is set in either revision.
159    let changed_contracts: HashSet<&str> = diff
160        .changed
161        .iter()
162        .filter(|id| is_contract(new_by_id[id.as_str()]) || is_contract(old_by_id[id.as_str()]))
163        .map(String::as_str)
164        .collect();
165
166    // Seeds: survivors whose prerequisite id set changed, or that consume an
167    // interface of a changed contract deliverable.
168    let carried = |id: &str| old.statuses.get(id).filter(|s| rederivable(s));
169    let mut rederive: BTreeSet<String> = BTreeSet::new();
170    for id in &diff.changed {
171        let (o, n) = (old_by_id[id.as_str()], new_by_id[id.as_str()]);
172        if carried(id).is_some() && prereq_set(o) != prereq_set(n) {
173            rederive.insert(id.clone());
174        }
175    }
176    for d in &new.deliverables {
177        let consumes_changed_contract = d.prerequisites.iter().any(|p| {
178            p.kind() == Some(PrerequisiteKind::Interface) && changed_contracts.contains(p.id())
179        });
180        if consumes_changed_contract
181            && old_by_id.contains_key(d.id.as_str())
182            && carried(&d.id).is_some()
183        {
184            rederive.insert(d.id.clone());
185        }
186    }
187
188    // Transitive dependents of everything reopened (leased/failed ones stop
189    // the walk: their status is not part of the reopen rule).
190    let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
191    for d in &new.deliverables {
192        for p in prereq_set(d) {
193            dependents.entry(p).or_default().push(d.id.as_str());
194        }
195    }
196    let mut queue: Vec<String> = rederive.iter().cloned().collect();
197    while let Some(id) = queue.pop() {
198        for dep in dependents.get(id.as_str()).into_iter().flatten() {
199            if old_by_id.contains_key(dep)
200                && carried(dep).is_some()
201                && rederive.insert((*dep).to_string())
202            {
203                queue.push((*dep).to_string());
204            }
205        }
206    }
207    diff.reopened = rederive.iter().cloned().collect();
208    // Survivors whose lease expired are re-derived too, but are not "reopened".
209    let expired: HashSet<&str> = old
210        .locks
211        .keys()
212        .map(String::as_str)
213        .filter(|id| new_by_id.contains_key(id) && live(id).is_none())
214        .collect();
215
216    // Final statuses in prerequisite order (Kahn); re-derived and new
217    // deliverables take Ready/Pending from their prerequisites.
218    let mut statuses: HashMap<String, DeliverableStatus> =
219        HashMap::with_capacity(new.deliverables.len());
220    let mut remaining: HashMap<&str, usize> = new
221        .deliverables
222        .iter()
223        .map(|d| (d.id.as_str(), prereq_set(d).len()))
224        .collect();
225    let mut ready: Vec<&str> = remaining
226        .iter()
227        .filter(|(_, n)| **n == 0)
228        .map(|(id, _)| *id)
229        .collect();
230    while let Some(id) = ready.pop() {
231        let d = new_by_id[id];
232        let derive = || {
233            if prereq_set(d)
234                .iter()
235                .all(|p| statuses.get(*p) == Some(&DeliverableStatus::Complete))
236            {
237                DeliverableStatus::Ready
238            } else {
239                DeliverableStatus::Pending
240            }
241        };
242        let status = match old.statuses.get(id) {
243            Some(s)
244                if old_by_id.contains_key(id)
245                    && !rederive.contains(id)
246                    && !expired.contains(id) =>
247            {
248                s.clone()
249            }
250            _ => derive(),
251        };
252        statuses.insert(id.to_string(), status);
253        for dep in dependents.get(id).into_iter().flatten() {
254            if let Some(n) = remaining.get_mut(dep) {
255                *n -= 1;
256                if *n == 0 {
257                    ready.push(dep);
258                }
259            }
260        }
261    }
262
263    let survives = |id: &String| new_by_id.contains_key(id.as_str());
264    let mut state = PlanState::new(new.clone(), statuses, cached_result);
265    state.attempt_counts = old
266        .attempt_counts
267        .iter()
268        .filter(|(k, _)| survives(k))
269        .map(|(k, v)| (k.clone(), *v))
270        .collect();
271    state.failure_counts = old
272        .failure_counts
273        .iter()
274        .filter(|(k, _)| survives(k))
275        .map(|(k, v)| (k.clone(), *v))
276        .collect();
277    state.lapse_counts = old
278        .lapse_counts
279        .iter()
280        .filter(|(k, _)| survives(k))
281        .map(|(k, v)| (k.clone(), *v))
282        .collect();
283    // Lockless provenance rides with a carried in_progress status; a
284    // re-derived one drops it when saved (`is_lockless_in_progress`).
285    state.lockless = old
286        .lockless
287        .iter()
288        .filter(|k| survives(k))
289        .cloned()
290        .collect();
291    for (id, l) in &old.locks {
292        if l.expires_at < now && survives(id) {
293            *state.lapse_counts.entry(id.clone()).or_insert(0) += 1;
294        }
295    }
296    state.locks = old
297        .locks
298        .iter()
299        .filter(|(k, l)| survives(k) && l.expires_at >= now)
300        .map(|(k, v)| (k.clone(), v.clone()))
301        .collect();
302    // Rebuild claims from the NEW definitions in graph order.
303    let files_changed = |id: &str| {
304        old_by_id.get(id).is_some_and(|o| {
305            canonical_deliverable(o)["owned_files"]
306                != canonical_deliverable(new_by_id[id])["owned_files"]
307        })
308    };
309    for d in &new.deliverables {
310        if !state.locks.contains_key(&d.id) {
311            continue;
312        }
313        loop {
314            let clash = d.owned_files.iter().find_map(|f| {
315                state
316                    .file_claims
317                    .get(f.path())
318                    .filter(|c| c.conflicts_with(f.mode()))
319            });
320            let Some(claim) = clash else {
321                add_file_claims(&mut state.file_claims, &d.id, &d.owned_files);
322                break;
323            };
324            let holder_id = match claim {
325                FileClaim::Exclusive(h) => h.clone(),
326                FileClaim::Append(set) => set.iter().next().cloned().unwrap_or_default(),
327            };
328            if !force {
329                let holder = state
330                    .locks
331                    .get(&holder_id)
332                    .map_or_else(|| holder_id.clone(), |l| l.caller_id.0.clone());
333                return Err(PlannerError::LockHeld {
334                    plan_id: state.locks[&d.id].plan_id.0.clone(),
335                    deliverable_id: d.id.clone(),
336                    holder,
337                });
338            }
339            let release_holder = files_changed(&holder_id) && !files_changed(&d.id);
340            let victim = if release_holder {
341                holder_id
342            } else {
343                d.id.clone()
344            };
345            let vd = new_by_id[victim.as_str()];
346            state.locks.remove(&victim);
347            release_file_claims(&mut state.file_claims, &victim, &vd.owned_files);
348            diff.released_locks.push(victim.clone());
349            let status = rederive_status(vd, &state.statuses);
350            state.statuses.insert(victim, status);
351            if !release_holder {
352                break;
353            }
354        }
355    }
356    diff.released_locks.sort();
357    // Every lease that did not survive ended now (or at its expiry, if it
358    // had lapsed); lease hours already ended on `old` carry over.
359    for (id, hours) in &old.leased_hours {
360        *state.leased_hours.entry(id.clone()).or_insert(0.0) += hours;
361    }
362    state.actuals_updated_at = old.actuals_updated_at;
363    for (id, l) in &old.locks {
364        if !state.locks.contains_key(id) {
365            state.record_lease_end(l, l.expires_at.min(now));
366        }
367    }
368    Ok((state, diff))
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::locks::{FileClaim, add_file_claims};
375    use crate::plan::{
376        CallerId, Deliverable, DeliverableStatus, LockInfo, OwnedFile, PlanId, Prerequisite,
377        PrerequisiteKind,
378    };
379    use serde_json::json;
380    use std::collections::HashMap;
381    use std::path::PathBuf;
382
383    fn dl(id: &str, prereqs: &[&str], effort: f32) -> Deliverable {
384        Deliverable {
385            id: id.to_string(),
386            owned_files: vec![],
387            prerequisites: prereqs
388                .iter()
389                .map(|p| Prerequisite::Id((*p).to_string()))
390                .collect(),
391            estimated_effort_hours: Some(effort),
392            duration_hours: None,
393            estimate: None,
394            metadata: json!({}),
395            milestone: false,
396            earning_rule: None,
397        }
398    }
399
400    fn iface(mut d: Deliverable, on: &str) -> Deliverable {
401        d.prerequisites.push(Prerequisite::Edge {
402            id: on.to_string(),
403            consumes: None,
404            kind: Some(PrerequisiteKind::Interface),
405            lag_hours: None,
406        });
407        d
408    }
409
410    fn graph(ds: Vec<Deliverable>) -> PlanGraph {
411        PlanGraph {
412            deliverables: ds,
413            max_chained_dispatch: None,
414        }
415    }
416
417    fn state(g: PlanGraph, st: &[(&str, DeliverableStatus)]) -> PlanState {
418        let cpm = crate::schedule::compute_cpm(&g).expect("valid graph");
419        let statuses = st
420            .iter()
421            .map(|(i, s)| ((*i).to_string(), s.clone()))
422            .collect();
423        PlanState::new(g, statuses, cpm)
424    }
425
426    fn lock(id: &str) -> LockInfo {
427        let now = chrono::Utc::now();
428        LockInfo {
429            plan_id: PlanId("p".into()),
430            deliverable_id: id.to_string(),
431            caller_id: CallerId("w1".into()),
432            acquired_at: now,
433            expires_at: now + chrono::Duration::hours(1),
434        }
435    }
436
437    fn revise(old: &PlanState, g: PlanGraph) -> (PlanState, RevisionDiff) {
438        plan_revision(old, &g, false, Utc::now()).expect("revision succeeds")
439    }
440
441    use DeliverableStatus::{Complete, InProgress, Pending, Ready};
442
443    fn ab(a: Deliverable, b: Deliverable) -> PlanState {
444        state(graph(vec![a, b]), &[("a", Complete), ("b", Ready)])
445    }
446
447    #[test]
448    fn unchanged_deliverables_keep_status() {
449        let old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
450        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("b", &["a"], 1.0)]));
451        assert_eq!(new.statuses["a"], Complete);
452    }
453
454    #[test]
455    fn unchanged_deliverables_keep_counters() {
456        let mut old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
457        old.attempt_counts.insert("a".into(), 3);
458        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("b", &["a"], 1.0)]));
459        assert_eq!(new.attempt_counts["a"], 3);
460    }
461
462    #[test]
463    fn added_deliverable_starts_pending_when_prerequisite_incomplete() {
464        let old = state(graph(vec![dl("a", &[], 1.0)]), &[("a", Ready)]);
465        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("c", &["a"], 1.0)]));
466        assert_eq!(new.statuses["c"], Pending);
467    }
468
469    #[test]
470    fn added_deliverable_starts_ready_when_prerequisites_complete() {
471        let old = state(graph(vec![dl("a", &[], 1.0)]), &[("a", Complete)]);
472        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("c", &["a"], 1.0)]));
473        assert_eq!(new.statuses["c"], Ready);
474    }
475
476    #[test]
477    fn added_deliverable_is_reported() {
478        let old = state(graph(vec![dl("a", &[], 1.0)]), &[("a", Ready)]);
479        let (_, diff) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("c", &[], 1.0)]));
480        assert_eq!(diff.added, vec!["c".to_string()]);
481    }
482
483    #[test]
484    fn removed_deliverable_is_dropped() {
485        let old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
486        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0)]));
487        assert!(!new.statuses.contains_key("b"));
488    }
489
490    #[test]
491    fn removed_deliverable_is_reported() {
492        let old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
493        let (_, diff) = revise(&old, graph(vec![dl("a", &[], 1.0)]));
494        assert_eq!(diff.removed, vec!["b".to_string()]);
495    }
496
497    #[test]
498    fn removed_deliverable_counters_are_dropped() {
499        let mut old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
500        old.failure_counts.insert("b".into(), 2);
501        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0)]));
502        assert!(!new.failure_counts.contains_key("b"));
503    }
504
505    fn leased() -> PlanState {
506        let mut old = state(
507            graph(vec![dl("a", &[], 1.0), dl("b", &[], 1.0)]),
508            &[("a", InProgress), ("b", Ready)],
509        );
510        old.locks.insert("a".into(), lock("a"));
511        old
512    }
513
514    #[test]
515    fn removing_leased_deliverable_requires_force() {
516        let err = plan_revision(
517            &leased(),
518            &graph(vec![dl("b", &[], 1.0)]),
519            false,
520            Utc::now(),
521        )
522        .err()
523        .expect("lock held");
524        assert!(matches!(err, PlannerError::LockHeld { .. }));
525    }
526
527    #[test]
528    fn forced_removal_releases_lock() {
529        let (_, diff) = plan_revision(&leased(), &graph(vec![dl("b", &[], 1.0)]), true, Utc::now())
530            .expect("forced");
531        assert_eq!(diff.released_locks, vec!["a".to_string()]);
532    }
533
534    #[test]
535    fn forced_removal_drops_lock_from_state() {
536        let (new, _) = plan_revision(&leased(), &graph(vec![dl("b", &[], 1.0)]), true, Utc::now())
537            .expect("forced");
538        assert!(new.locks.is_empty());
539    }
540
541    #[test]
542    fn leased_deliverable_survives_revision() {
543        let (new, _) = revise(&leased(), graph(vec![dl("a", &[], 1.0), dl("b", &[], 1.0)]));
544        assert!(new.locks.contains_key("a"));
545    }
546
547    #[test]
548    fn leased_deliverable_stays_in_progress() {
549        let (new, _) = revise(&leased(), graph(vec![dl("a", &[], 1.0), dl("b", &[], 1.0)]));
550        assert_eq!(new.statuses["a"], InProgress);
551    }
552
553    #[test]
554    fn leased_deliverable_keeps_status_when_prerequisites_change() {
555        let (new, _) = revise(
556            &leased(),
557            graph(vec![dl("a", &["b"], 1.0), dl("b", &[], 1.0)]),
558        );
559        assert_eq!(new.statuses["a"], InProgress);
560    }
561
562    #[test]
563    fn file_claims_are_rebuilt_from_surviving_locks() {
564        let mut a = dl("a", &[], 1.0);
565        a.owned_files = vec![OwnedFile::Path(PathBuf::from("x.rs"))];
566        let mut old = state(graph(vec![a.clone()]), &[("a", InProgress)]);
567        old.locks.insert("a".into(), lock("a"));
568        let mut expected: HashMap<PathBuf, FileClaim> = HashMap::new();
569        add_file_claims(&mut expected, "a", &a.owned_files);
570        let (new, _) = revise(&old, graph(vec![a]));
571        assert_eq!(new.file_claims, expected);
572    }
573
574    #[test]
575    fn changed_prerequisites_reopen_complete_deliverable() {
576        let old = state(
577            graph(vec![
578                dl("a", &[], 1.0),
579                dl("x", &[], 1.0),
580                dl("b", &["a"], 1.0),
581            ]),
582            &[("a", Complete), ("x", Ready), ("b", Complete)],
583        );
584        let (new, _) = revise(
585            &old,
586            graph(vec![
587                dl("a", &[], 1.0),
588                dl("x", &[], 1.0),
589                dl("b", &["a", "x"], 1.0),
590            ]),
591        );
592        assert_eq!(new.statuses["b"], Pending);
593    }
594
595    #[test]
596    fn changed_prerequisites_are_listed_as_reopened() {
597        let old = state(
598            graph(vec![
599                dl("a", &[], 1.0),
600                dl("x", &[], 1.0),
601                dl("b", &["a"], 1.0),
602            ]),
603            &[("a", Complete), ("x", Ready), ("b", Complete)],
604        );
605        let (_, diff) = revise(
606            &old,
607            graph(vec![
608                dl("a", &[], 1.0),
609                dl("x", &[], 1.0),
610                dl("b", &["a", "x"], 1.0),
611            ]),
612        );
613        assert_eq!(diff.reopened, vec!["b".to_string()]);
614    }
615
616    #[test]
617    fn reopened_deliverable_with_complete_prerequisites_becomes_ready() {
618        let old = state(
619            graph(vec![
620                dl("a", &[], 1.0),
621                dl("b", &["a"], 1.0),
622                dl("c", &[], 1.0),
623            ]),
624            &[("a", Complete), ("b", Complete), ("c", Complete)],
625        );
626        let (new, _) = revise(
627            &old,
628            graph(vec![
629                dl("a", &[], 1.0),
630                dl("b", &["a", "c"], 1.0),
631                dl("c", &[], 1.0),
632            ]),
633        );
634        assert_eq!(new.statuses["b"], Ready);
635    }
636
637    #[test]
638    fn reopen_propagates_to_dependents() {
639        let old = state(
640            graph(vec![
641                dl("a", &[], 1.0),
642                dl("x", &[], 1.0),
643                dl("b", &["a"], 1.0),
644                dl("c", &["b"], 1.0),
645            ]),
646            &[
647                ("a", Complete),
648                ("x", Ready),
649                ("b", Complete),
650                ("c", Complete),
651            ],
652        );
653        let (new, _) = revise(
654            &old,
655            graph(vec![
656                dl("a", &[], 1.0),
657                dl("x", &[], 1.0),
658                dl("b", &["a", "x"], 1.0),
659                dl("c", &["b"], 1.0),
660            ]),
661        );
662        assert_eq!(new.statuses["c"], Pending);
663    }
664
665    #[test]
666    fn dependent_of_removed_prerequisite_is_rederived() {
667        let old = state(
668            graph(vec![
669                dl("a", &[], 1.0),
670                dl("x", &[], 1.0),
671                dl("b", &["a", "x"], 1.0),
672            ]),
673            &[("a", Complete), ("x", Ready), ("b", Pending)],
674        );
675        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("b", &["a"], 1.0)]));
676        assert_eq!(new.statuses["b"], Ready);
677    }
678
679    fn contract_pair(contract: bool, v2: bool) -> (PlanState, PlanGraph) {
680        let mut p = dl("p", &[], 1.0);
681        p.metadata = json!({ "contract": contract });
682        let c = iface(dl("c", &[], 1.0), "p");
683        let old = state(
684            graph(vec![p.clone(), c.clone()]),
685            &[("p", Complete), ("c", Complete)],
686        );
687        let mut p2 = p;
688        if v2 {
689            p2.estimated_effort_hours = Some(9.0);
690        }
691        (old, graph(vec![p2, c]))
692    }
693
694    #[test]
695    fn interface_consumer_reopens_when_contract_changes() {
696        let (old, g) = contract_pair(true, true);
697        let (new, _) = revise(&old, g);
698        assert_eq!(new.statuses["c"], Ready);
699    }
700
701    #[test]
702    fn interface_consumer_keeps_status_when_non_contract_changes() {
703        let (old, g) = contract_pair(false, true);
704        let (new, _) = revise(&old, g);
705        assert_eq!(new.statuses["c"], Complete);
706    }
707
708    #[test]
709    fn interface_consumer_keeps_status_when_contract_unchanged() {
710        let (old, g) = contract_pair(true, false);
711        let (new, _) = revise(&old, g);
712        assert_eq!(new.statuses["c"], Complete);
713    }
714
715    #[test]
716    fn metadata_only_change_keeps_status() {
717        let old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
718        let mut a2 = dl("a", &[], 1.0);
719        a2.metadata = json!({ "note": "edited" });
720        let (new, _) = revise(&old, graph(vec![a2, dl("b", &["a"], 1.0)]));
721        assert_eq!(new.statuses["a"], Complete);
722    }
723
724    #[test]
725    fn metadata_only_change_is_reported_as_changed() {
726        let old = ab(dl("a", &[], 1.0), dl("b", &["a"], 1.0));
727        let mut a2 = dl("a", &[], 1.0);
728        a2.metadata = json!({ "note": "edited" });
729        let (_, diff) = revise(&old, graph(vec![a2, dl("b", &["a"], 1.0)]));
730        assert_eq!(diff.changed, vec!["a".to_string()]);
731    }
732
733    #[test]
734    fn failed_deliverable_stays_failed() {
735        let failed = DeliverableStatus::Failed { reason: "x".into() };
736        let old = state(
737            graph(vec![dl("a", &[], 1.0), dl("b", &[], 1.0)]),
738            &[("a", Complete), ("b", failed.clone())],
739        );
740        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("b", &["a"], 1.0)]));
741        assert_eq!(new.statuses["b"], failed);
742    }
743
744    #[test]
745    fn revision_recomputes_critical_path() {
746        let old = state(graph(vec![dl("a", &[], 1.0)]), &[("a", Ready)]);
747        let (new, _) = revise(&old, graph(vec![dl("a", &[], 1.0), dl("c", &["a"], 5.0)]));
748        assert!(new.cached_result.critical_path.contains(&"c".to_string()));
749    }
750
751    #[test]
752    fn diff_vectors_are_sorted() {
753        let old = state(graph(vec![dl("a", &[], 1.0)]), &[("a", Ready)]);
754        let (_, diff) = revise(
755            &old,
756            graph(vec![
757                dl("a", &[], 1.0),
758                dl("z", &[], 1.0),
759                dl("m", &[], 1.0),
760            ]),
761        );
762        assert_eq!(diff.added, vec!["m".to_string(), "z".to_string()]);
763    }
764
765    #[test]
766    fn duplicate_id_edges_revise_in_linear_time() {
767        let chain = || {
768            let mut ds = vec![dl("n0", &[], 1.0)];
769            for i in 1..200 {
770                let prev = format!("n{}", i - 1);
771                ds.push(iface(dl(&format!("n{i}"), &[&prev], 1.0), &prev));
772            }
773            graph(ds)
774        };
775        let old = state(chain(), &[("n0", Ready)]);
776        let start = std::time::Instant::now();
777        let ok = plan_revision(&old, &chain(), false, Utc::now()).is_ok();
778        assert!(ok && start.elapsed() < std::time::Duration::from_secs(2));
779    }
780
781    fn claiming(mut d: Deliverable, path: &str) -> Deliverable {
782        d.owned_files = vec![OwnedFile::Path(PathBuf::from(path))];
783        d
784    }
785
786    fn two_leases() -> (PlanState, PlanGraph) {
787        let mut old = state(
788            graph(vec![claiming(dl("x", &[], 1.0), "f.rs"), dl("y", &[], 1.0)]),
789            &[("x", InProgress), ("y", InProgress)],
790        );
791        old.locks.insert("x".into(), lock("x"));
792        old.locks.insert("y".into(), lock("y"));
793        let g = graph(vec![
794            claiming(dl("x", &[], 1.0), "f.rs"),
795            claiming(dl("y", &["x"], 1.0), "f.rs"),
796        ]);
797        (old, g)
798    }
799
800    #[test]
801    fn conflicting_claims_between_surviving_leases_are_refused() {
802        let (old, g) = two_leases();
803        let err = plan_revision(&old, &g, false, Utc::now())
804            .err()
805            .expect("conflict");
806        assert!(matches!(err, PlannerError::LockHeld { .. }));
807    }
808
809    #[test]
810    fn forced_revision_releases_conflicting_lease() {
811        let (old, g) = two_leases();
812        let (new, diff) = plan_revision(&old, &g, true, Utc::now()).expect("forced");
813        assert!(diff.released_locks == vec!["y".to_string()] && new.statuses["y"] == Pending);
814    }
815
816    #[test]
817    fn forced_revision_keeps_earlier_claimant_claim() {
818        let (old, g) = two_leases();
819        let (new, _) = plan_revision(&old, &g, true, Utc::now()).expect("forced");
820        assert_eq!(
821            new.file_claims.get(&PathBuf::from("f.rs")),
822            Some(&FileClaim::Exclusive("x".into()))
823        );
824    }
825
826    #[test]
827    fn revision_with_unknown_prerequisite_is_invalid_graph() {
828        let old = state(graph(vec![dl("a", &[], 1.0)]), &[("a", Ready)]);
829        let err = plan_revision(
830            &old,
831            &graph(vec![dl("a", &["ghost"], 1.0)]),
832            false,
833            Utc::now(),
834        )
835        .err()
836        .expect("invalid");
837        assert!(matches!(err, PlannerError::InvalidGraph { .. }));
838    }
839
840    fn reaped_expired() -> PlanState {
841        let mut old = leased();
842        let l = old.locks.get_mut("a").expect("lock");
843        l.expires_at = Utc::now() - chrono::Duration::hours(1);
844        old.reap_expired(Utc::now());
845        old
846    }
847
848    #[test]
849    fn expired_lease_does_not_block_removal() {
850        let res = plan_revision(
851            &reaped_expired(),
852            &graph(vec![dl("b", &[], 1.0)]),
853            false,
854            Utc::now(),
855        );
856        assert!(res.is_ok());
857    }
858
859    #[test]
860    fn expired_lease_is_not_kept_and_is_rederived() {
861        let (new, _) = revise(
862            &reaped_expired(),
863            graph(vec![dl("a", &[], 1.0), dl("b", &[], 1.0)]),
864        );
865        assert!(new.locks.is_empty() && new.statuses["a"] == Ready);
866    }
867
868    #[test]
869    fn expired_lease_is_counted_as_lapse_when_reaped_first() {
870        let (new, _) = revise(
871            &reaped_expired(),
872            graph(vec![dl("a", &[], 1.0), dl("b", &[], 1.0)]),
873        );
874        assert_eq!(new.lapse_count("a"), 1);
875    }
876
877    #[test]
878    #[cfg(debug_assertions)]
879    #[should_panic(expected = "reaped first")]
880    fn plan_revision_requires_reaped_locks() {
881        let mut old = leased();
882        let l = old.locks.get_mut("a").expect("lock");
883        l.expires_at = Utc::now() - chrono::Duration::hours(1);
884        let _ = plan_revision(&old, &graph(vec![dl("b", &[], 1.0)]), false, Utc::now());
885    }
886
887    #[test]
888    fn forced_conflict_releases_the_deliverable_whose_files_changed() {
889        let mut old = state(
890            graph(vec![
891                claiming(dl("x", &[], 1.0), "a.rs"),
892                claiming(dl("y", &[], 1.0), "f.rs"),
893            ]),
894            &[("x", InProgress), ("y", InProgress)],
895        );
896        old.locks.insert("x".into(), lock("x"));
897        old.locks.insert("y".into(), lock("y"));
898        let g = graph(vec![
899            claiming(dl("x", &[], 1.0), "f.rs"),
900            claiming(dl("y", &["x"], 1.0), "f.rs"),
901        ]);
902        let (new, diff) = plan_revision(&old, &g, true, Utc::now()).expect("forced");
903        assert!(
904            diff.released_locks == vec!["x".to_string()]
905                && new.file_claims.get(&PathBuf::from("f.rs"))
906                    == Some(&FileClaim::Exclusive("y".into()))
907        );
908    }
909
910    fn chain_abc(c_pre: &[&str], b_effort: f32, b_pre: &[&str]) -> PlanGraph {
911        graph(vec![
912            dl("a", &[], 1.0),
913            dl("x", &[], 1.0),
914            dl("b", b_pre, b_effort),
915            dl("c", c_pre, 1.0),
916        ])
917    }
918
919    #[test]
920    fn reopen_walk_stops_at_in_progress() {
921        let mut old = state(
922            chain_abc(&["b"], 1.0, &["a"]),
923            &[
924                ("a", Complete),
925                ("x", Ready),
926                ("b", InProgress),
927                ("c", Pending),
928            ],
929        );
930        old.locks.insert("b".into(), lock("b"));
931        let (new, _) = revise(&old, chain_abc(&["b", "x"], 2.0, &["a"]));
932        assert_eq!(new.statuses["b"], InProgress);
933    }
934
935    #[test]
936    fn reopen_walk_stops_at_failed() {
937        let failed = DeliverableStatus::Failed { reason: "x".into() };
938        let old = state(
939            chain_abc(&["b"], 1.0, &["a"]),
940            &[
941                ("a", Complete),
942                ("x", Ready),
943                ("b", Complete),
944                ("c", failed.clone()),
945            ],
946        );
947        let (new, _) = revise(&old, chain_abc(&["b"], 1.0, &["a", "x"]));
948        assert_eq!(new.statuses["c"], failed);
949    }
950
951    #[test]
952    fn old_only_contract_flag_reopens_interface_consumer() {
953        let (old, _) = contract_pair(true, false);
954        let mut p2 = dl("p", &[], 1.0);
955        p2.metadata = json!({ "contract": false });
956        let (new, _) = revise(&old, graph(vec![p2, iface(dl("c", &[], 1.0), "p")]));
957        assert_eq!(new.statuses["c"], Ready);
958    }
959}