1use serde::{Deserialize, Serialize};
11
12use crate::locator::Locator;
13use crate::node_id::NodeId;
14use crate::share::Share;
15use crate::spine::{RoadmapRow, RoadmapStatus};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum StopCause {
25 Blocked { note: String },
27 HandedBack { note: String },
30 FrozeWithoutEvidence,
32 Stalled { repeats: u32 },
34 GateOverdue { waited_ms: u64 },
36}
37
38impl StopCause {
39 #[must_use]
41 pub fn status(&self) -> RoadmapStatus {
42 match self {
43 StopCause::HandedBack { .. } => RoadmapStatus::NotStarted,
44 StopCause::Blocked { .. }
45 | StopCause::FrozeWithoutEvidence
46 | StopCause::Stalled { .. }
47 | StopCause::GateOverdue { .. } => RoadmapStatus::Blocked,
48 }
49 }
50
51 #[must_use]
53 pub fn is_red(&self) -> bool {
54 !matches!(self, StopCause::HandedBack { .. })
55 }
56
57 #[must_use]
59 pub fn line(&self) -> String {
60 match self {
61 StopCause::Blocked { note } | StopCause::HandedBack { note } => note.clone(),
62 StopCause::FrozeWithoutEvidence => {
63 "the run holding it ended without citing anything".to_owned()
64 }
65 StopCause::Stalled { repeats } => {
66 format!("the run repeated one action {repeats} times")
67 }
68 StopCause::GateOverdue { waited_ms } => {
69 format!("a door has waited {waited_ms} ms for the User")
70 }
71 }
72 }
73}
74
75#[derive(Debug)]
85#[must_use = "a held node has to be finished with evidence or stopped with a cause"]
86pub struct Held(NodeId);
87
88impl Held {
89 pub(crate) fn of(id: NodeId) -> Self {
90 Held(id)
91 }
92
93 #[must_use]
94 pub fn id(&self) -> &NodeId {
95 &self.0
96 }
97
98 pub fn finish(self, evidence: Locator) -> PlanExit {
100 PlanExit::Finished {
101 id: self.0,
102 evidence,
103 }
104 }
105
106 pub fn stop(self, why: StopCause) -> PlanExit {
108 PlanExit::Stopped { id: self.0, why }
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum PlanExit {
116 Finished { id: NodeId, evidence: Locator },
117 Stopped { id: NodeId, why: StopCause },
118}
119
120impl PlanExit {
121 #[must_use]
122 pub fn id(&self) -> &NodeId {
123 match self {
124 PlanExit::Finished { id, .. } | PlanExit::Stopped { id, .. } => id,
125 }
126 }
127
128 #[must_use]
130 pub fn status(&self) -> RoadmapStatus {
131 match self {
132 PlanExit::Finished { .. } => RoadmapStatus::Done,
133 PlanExit::Stopped { why, .. } => why.status(),
134 }
135 }
136
137 #[must_use]
141 pub fn evidence(&self) -> Option<&Locator> {
142 match self {
143 PlanExit::Finished { evidence, .. } => Some(evidence),
144 PlanExit::Stopped { .. } => None,
145 }
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PlanNode {
152 pub row: RoadmapRow,
153 pub share: Share,
155 pub children: Vec<NodeId>,
158}
159
160impl PlanNode {
161 #[must_use]
162 pub fn is_leaf(&self) -> bool {
163 self.children.is_empty()
164 }
165}
166
167#[cfg(test)]
168#[allow(
169 clippy::unwrap_used,
170 clippy::expect_used,
171 clippy::panic,
172 clippy::indexing_slicing,
173 clippy::arithmetic_side_effects,
174 reason = "test code"
175)]
176mod tests {
177 use super::super::PlanTree;
178 use super::*;
179 use crate::spine::{RoadmapShape, check_roadmap_shape};
180 fn tree(text: &str) -> PlanTree {
181 let RoadmapShape::WellFormed { rows } = check_roadmap_shape(text) else {
182 panic!("the fixture parses");
183 };
184 PlanTree::build(rows).expect("the fixture is a tree")
185 }
186 const HEAD: &str = "\
187| # | Item | Weight | Needs | Status | Evidence |
188|---|------|--------|-------|--------|----------|
189";
190 fn locator() -> Locator {
191 Locator::parse(&format!("cas:b3-{}", "ab".repeat(32))).unwrap()
192 }
193
194 #[test]
197 fn a_held_node_leaves_by_one_of_two_doors() {
198 let plan = tree(&format!(
199 "{HEAD}\
200| 1 | frame | 1 | | Not started | |
201| 2 | roof | 1 | | Not started | |
202| 3 | door | 1 | | Not started | |
203"
204 ));
205 let green = plan
206 .claim(&NodeId::parse("1").unwrap())
207 .unwrap()
208 .finish(locator());
209 assert_eq!(green.status(), RoadmapStatus::Done);
210 assert!(green.evidence().is_some());
211
212 let red = plan
213 .claim(&NodeId::parse("2").unwrap())
214 .unwrap()
215 .stop(StopCause::Blocked {
216 note: "the kiln is cold".to_owned(),
217 });
218 assert_eq!(red.status(), RoadmapStatus::Blocked);
219 assert!(red.evidence().is_none(), "a stop clears the evidence cell");
220
221 let back = plan
222 .claim(&NodeId::parse("3").unwrap())
223 .unwrap()
224 .stop(StopCause::HandedBack {
225 note: "not mine".to_owned(),
226 });
227 assert_eq!(
228 back.status(),
229 RoadmapStatus::NotStarted,
230 "handing back returns the node to the ready set"
231 );
232 let PlanExit::Stopped { why, .. } = &back else {
233 panic!("stopped")
234 };
235 assert!(!why.is_red(), "and it is not red");
236 }
237
238 #[test]
239 fn every_stop_cause_says_something_a_person_can_read() {
240 for cause in [
241 StopCause::Blocked {
242 note: "no key".to_owned(),
243 },
244 StopCause::HandedBack {
245 note: "not mine".to_owned(),
246 },
247 StopCause::FrozeWithoutEvidence,
248 StopCause::Stalled { repeats: 3 },
249 StopCause::GateOverdue { waited_ms: 60_000 },
250 ] {
251 assert!(!cause.line().is_empty(), "{cause:?}");
252 }
253 }
254}