1use std::collections::{BTreeMap, BTreeSet};
31
32use crate::node_id::NodeId;
33use crate::plan::{PlanTree, StopCause};
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct RedNode {
38 pub at: NodeId,
39 pub why: StopCause,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Blockage {
49 pub source: NodeId,
50 pub why: StopCause,
51 pub reaches: Vec<NodeId>,
53}
54
55impl Blockage {
56 #[must_use]
59 pub fn line(&self) -> String {
60 let branch = self
61 .source
62 .ancestors()
63 .last()
64 .map_or_else(|| self.source.to_string(), |top| top.to_string());
65 format!(
66 "branch {branch} is stuck at {}: {}",
67 self.source,
68 self.why.line()
69 )
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Notice {
80 pub to: String,
82 pub about: NodeId,
83 pub line: String,
84}
85
86#[must_use]
93pub fn spread(tree: &PlanTree, red: &[RedNode]) -> Vec<Blockage> {
94 let sources: BTreeSet<NodeId> = red.iter().map(|held| held.at.clone()).collect();
95 let dependents = reverse_edges(tree);
96 let mut found: BTreeMap<NodeId, Blockage> = BTreeMap::new();
97 for held in red {
98 if tree.get(&held.at).is_none() {
99 continue;
100 }
101 let mut reaches: BTreeSet<NodeId> = BTreeSet::new();
102 walk(&held.at, &dependents, &mut reaches);
103 reaches.remove(&held.at);
104 for other in &sources {
105 reaches.remove(other);
106 }
107 found.insert(
108 held.at.clone(),
109 Blockage {
110 source: held.at.clone(),
111 why: held.why.clone(),
112 reaches: reaches.into_iter().collect(),
113 },
114 );
115 }
116 found.into_values().collect()
117}
118
119#[must_use]
126pub fn notices(blocked: &[Blockage], holders: &BTreeMap<NodeId, String>) -> Vec<Notice> {
127 let mut out = Vec::new();
128 for blockage in blocked {
129 let source_holder = holders.get(&blockage.source);
130 let mut told: BTreeSet<&String> = BTreeSet::new();
131 for node in &blockage.reaches {
132 let Some(who) = holders.get(node) else {
133 continue;
134 };
135 if Some(who) == source_holder || !told.insert(who) {
136 continue;
137 }
138 out.push(Notice {
139 to: who.clone(),
140 about: blockage.source.clone(),
141 line: blockage.line(),
142 });
143 }
144 }
145 out
146}
147
148fn walk(from: &NodeId, dependents: &BTreeMap<NodeId, Vec<NodeId>>, seen: &mut BTreeSet<NodeId>) {
153 if !seen.insert(from.clone()) {
154 return;
155 }
156 for parent in from.ancestors() {
157 walk(&parent, dependents, seen);
158 }
159 for waiting in dependents.get(from).into_iter().flatten() {
160 walk(waiting, dependents, seen);
161 }
162}
163
164fn reverse_edges(tree: &PlanTree) -> BTreeMap<NodeId, Vec<NodeId>> {
170 let mut edges: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
171 for node in tree.nodes() {
172 for need in tree.needs_of(&node.row.id) {
173 edges.entry(need).or_default().push(node.row.id.clone());
174 }
175 }
176 edges
177}
178
179#[cfg(test)]
180#[allow(
181 clippy::unwrap_used,
182 clippy::expect_used,
183 clippy::panic,
184 clippy::indexing_slicing,
185 reason = "test code"
186)]
187mod tests {
188 use super::*;
189 use crate::spine::{RoadmapShape, check_roadmap_shape};
190
191 fn tree(text: &str) -> PlanTree {
192 let RoadmapShape::WellFormed { rows } = check_roadmap_shape(text) else {
193 panic!("the fixture parses");
194 };
195 PlanTree::build(rows).expect("the fixture is a tree")
196 }
197
198 fn node(raw: &str) -> NodeId {
199 NodeId::parse(raw).unwrap()
200 }
201
202 fn stuck(at: &str) -> RedNode {
203 RedNode {
204 at: node(at),
205 why: StopCause::Blocked {
206 note: "the kiln is cold".to_owned(),
207 },
208 }
209 }
210
211 const PLAN: &str = "\
212| # | Item | Weight | Needs | Status | Evidence |
213|---|------|--------|-------|--------|----------|
214| 1 | groundwork | 1 | | Not started | |
215| 2 | build | 1 | | Not started | |
216| 2.1 | frame | 1 | 1 | Not started | |
217| 2.2 | roof | 1 | 2.1 | Not started | |
218| 2.3 | door | 1 | 2.2 | Not started | |
219| 3 | ship | 1 | 2 | Not started | |
220";
221
222 #[test]
223 fn red_climbs_the_branch_and_runs_down_the_dependencies() {
224 let blocked = spread(&tree(PLAN), &[stuck("2.1")]);
225 assert_eq!(blocked.len(), 1);
226 let reached: Vec<&str> = blocked[0].reaches.iter().map(NodeId::as_str).collect();
227 assert_eq!(
228 reached,
229 ["2", "2.2", "2.3", "3"],
230 "the branch above, what waits for it, and what waits for those"
231 );
232 assert!(!blocked[0].reaches.contains(&node("1")), "1 is upstream");
233 assert!(!blocked[0].reaches.contains(&node("2.1")), "not itself");
234 }
235
236 #[test]
239 fn the_line_names_the_branch_and_the_node_it_is_stuck_at() {
240 let blocked = spread(&tree(PLAN), &[stuck("2.1")]);
241 assert_eq!(
242 blocked[0].line(),
243 "branch 2 is stuck at 2.1: the kiln is cold"
244 );
245 }
246
247 #[test]
248 fn a_node_red_on_its_own_account_is_not_listed_as_somebody_elses_consequence() {
249 let blocked = spread(&tree(PLAN), &[stuck("2.1"), stuck("2.3")]);
250 assert_eq!(blocked.len(), 2);
251 assert!(
252 !blocked[0].reaches.contains(&node("2.3")),
253 "2.3 has its own entry"
254 );
255 assert_eq!(blocked[0].source, node("2.1"));
256 assert_eq!(blocked[1].source, node("2.3"));
257 }
258
259 #[test]
260 fn a_red_node_the_plan_does_not_carry_is_dropped_rather_than_invented() {
261 assert!(spread(&tree(PLAN), &[stuck("9.9")]).is_empty());
262 }
263
264 #[test]
265 fn everyone_waiting_behind_it_is_told_once_and_the_reporter_is_not() {
266 let blocked = spread(&tree(PLAN), &[stuck("2.1")]);
267 let mut holders = BTreeMap::new();
268 holders.insert(node("2.1"), "mason@yard.1".to_owned());
269 holders.insert(node("2.2"), "roofer@yard.1".to_owned());
270 holders.insert(node("2.3"), "roofer@yard.1".to_owned());
271 holders.insert(node("3"), "carter@yard.1".to_owned());
272 let told = notices(&blocked, &holders);
273 let who: Vec<&str> = told.iter().map(|notice| notice.to.as_str()).collect();
274 assert_eq!(
275 who,
276 ["roofer@yard.1", "carter@yard.1"],
277 "the roofer holds two of them and hears once; the mason reported it"
278 );
279 assert_eq!(told[0].about, node("2.1"));
280 }
281
282 #[test]
283 fn nothing_red_tells_nobody() {
284 let blocked = spread(&tree(PLAN), &[]);
285 assert!(blocked.is_empty());
286 assert!(notices(&blocked, &BTreeMap::new()).is_empty());
287 }
288}