1use 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40pub struct RevisionDiff {
41 pub added: Vec<String>,
43 pub removed: Vec<String>,
45 pub changed: Vec<String>,
47 pub reopened: Vec<String>,
49 pub released_locks: Vec<String>,
51}
52
53fn 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
72pub(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 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 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 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 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 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 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 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 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 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}