Skip to main content

kernel/plan/
node.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Plan nodes: the types a plan is made of, without the tree that hangs them.
7//! The part `crates/kernel/spec/Plan.lean` specifies this module with the
8//! rest of `kernel::plan`.
9
10use serde::{Deserialize, Serialize};
11
12use crate::locator::Locator;
13use crate::node_id::NodeId;
14use crate::share::Share;
15use crate::spine::{RoadmapRow, RoadmapStatus};
16
17/// Why a held node was put down without evidence.
18///
19/// Exhaustive, and the whole point of the enum is the division it draws:
20/// a resident that hands work back leaves it ready for somebody else,
21/// while every other cause leaves it red.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum StopCause {
25    /// A resident says this node cannot proceed, and why.
26    Blocked { note: String },
27    /// A resident is not the one to do this after all. Not red: the node
28    /// goes back where another run can take it.
29    HandedBack { note: String },
30    /// The run holding it ended without citing anything.
31    FrozeWithoutEvidence,
32    /// `crate::stall` found the run going nowhere.
33    Stalled { repeats: u32 },
34    /// A door was escalated to a person and nobody has answered.
35    GateOverdue { waited_ms: u64 },
36}
37
38impl StopCause {
39    /// What the status column says afterwards.
40    #[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    /// Whether this stop is one the plan reports as red.
52    #[must_use]
53    pub fn is_red(&self) -> bool {
54        !matches!(self, StopCause::HandedBack { .. })
55    }
56
57    /// One line, for a person reading the first screen.
58    #[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/// A node this run holds.
76///
77/// **There is no third way to put it down.** The type has one private
78/// field, so it is minted only by [`super::PlanTree::claim`], and it is
79/// consumed only by [`Held::finish`] and [`Held::stop`] — one takes
80/// evidence, the other takes a cause. A run that simply stops working
81/// leaves the value with its owner, and the owner's freeze path spends
82/// it on [`StopCause::FrozeWithoutEvidence`], which is where the red in
83/// `crate::blockage` comes from.
84#[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    /// Green: the work is done and here is where to look.
99    pub fn finish(self, evidence: Locator) -> PlanExit {
100        PlanExit::Finished {
101            id: self.0,
102            evidence,
103        }
104    }
105
106    /// Red, or back in the pool: either way it says why.
107    pub fn stop(self, why: StopCause) -> PlanExit {
108        PlanExit::Stopped { id: self.0, why }
109    }
110}
111
112/// How a held node left. Exhaustive, and every arm carries its reason —
113/// which is the whole of the plan gate.
114#[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    /// The status the row carries after this exit.
129    #[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    /// The evidence cell after this exit: an exit without evidence
138    /// clears it rather than leaving the last run's citation on a row
139    /// that is no longer done.
140    #[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/// One node, placed: the row as written plus what the tree worked out.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PlanNode {
152    pub row: RoadmapRow,
153    /// This node's part of the whole plan.
154    pub share: Share,
155    /// Its children, in table order. Empty means a leaf, and only
156    /// leaves carry progress.
157    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    /// The plan gate: a held node leaves green with evidence or stopped
195    /// with a cause, and handing back is the one stop that is not red.
196    #[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}