Skip to main content

majordomus_cli/devtask/
readiness.rs

1//! Task readiness: a derived projection of the canonical status, and not a second
2//! vocabulary for it.
3//!
4//! # Why there is one at all
5//!
6//! [`crate::plan::ISSUE_STATUSES`] is the canonical vocabulary — `READY`, `BLOCKED`,
7//! `ACTIVE`, `VERIFY`, `DONE`, `CANCELLED` — and it is right for what it is: the six words
8//! `lib/project.awk` derives, held to byte equality with the shell engine by
9//! `test/cases/99_plan_capabilities.sh`. Nothing here changes, replaces or renames any of
10//! them; every [`TaskReadiness`] carries the canonical status it was derived from, so a caller
11//! that wants the canonical word has it on the same object.
12//!
13//! What it adds is exactly the distinctions the canonical six *already make in the data*
14//! and cannot express in the word:
15//!
16//! * **`BLOCKED` is two situations.** The plan's own derivation appends
17//!   `milestone:<id>` to an issue's `blocked_by` when the milestone gate holds it, on top of
18//!   whatever issue dependencies it has. An issue with every dependency `DONE`, held back
19//!   only because the outcome it belongs to waits on another outcome, is not the same
20//!   problem as an issue waiting on a peer: the first is resolved somewhere else entirely.
21//!   [`TaskReadiness::Waiting`] is that case; [`TaskReadiness::Blocked`] is the other.
22//! * **`VERIFY` is two situations.** The plan reaches `VERIFY` when `verified_at` or
23//!   `completed_at` is set, and it emits the `evidence_missing` finding for the second of
24//!   those when the required evidence is not all there. So `VERIFY` covers both "the work
25//!   is done and someone must confirm it" and "someone already declared it complete and the
26//!   evidence does not back that up" — the second is a defect a person has to act on, the
27//!   first is a queue. [`TaskReadiness::Review`] and [`TaskReadiness::CompletionBlocked`].
28//! * **an id the model does not have.** `plan.issues` cannot answer for one at all, and
29//!   answering `BLOCKED` or an empty object for a typo is the one answer a work surface must
30//!   never give. [`TaskReadiness::Undeclared`].
31//!
32//! and nothing else. There is no `in_review`, no `needs_triage`, no `wontfix`: three words
33//! that no field of any canonical record could distinguish, which is what makes them
34//! frontend strings rather than workflow.
35//!
36//! # The mapping is total and one-way
37//!
38//! Every canonical status maps to exactly one readiness, and [`TaskReadiness::canonical`] maps
39//! back. The three added distinctions are refinements *inside* a canonical status, never
40//! across two, so the round trip through `canonical` is lossless in the direction that
41//! matters: no readiness can claim a canonical status the plan did not derive.
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46/// Where a development task stands, as work.
47///
48/// ```
49/// use majordomus_cli::devtask::TaskReadiness;
50///
51/// // every state names the canonical status it refines, and refines only one
52/// assert_eq!(TaskReadiness::Ready.canonical(), "READY");
53/// assert_eq!(TaskReadiness::Waiting.canonical(), "BLOCKED");
54/// assert_eq!(TaskReadiness::Blocked.canonical(), "BLOCKED");
55/// assert_eq!(TaskReadiness::Review.canonical(), "VERIFY");
56/// assert_eq!(TaskReadiness::CompletionBlocked.canonical(), "VERIFY");
57///
58/// // and one state is the absence of a record rather than a status of one
59/// assert!(TaskReadiness::Undeclared.canonical().is_empty());
60/// ```
61#[derive(
62    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
63)]
64#[serde(rename_all = "snake_case")]
65pub enum TaskReadiness {
66    /// Nothing holds it back and nobody has started it. Canonical `READY`.
67    Ready,
68    /// At least one issue it depends on is not `DONE`. Canonical `BLOCKED`.
69    Blocked,
70    /// Every issue dependency is satisfied; the milestone gate holds it. Canonical
71    /// `BLOCKED`, and a distinct situation because it is resolved in another milestone.
72    Waiting,
73    /// Execution began: the record carries `started_at`. Canonical `ACTIVE`.
74    InProgress,
75    /// Implementation is declared finished and completion has not been recorded: the record
76    /// carries `verified_at`. Canonical `VERIFY`.
77    Review,
78    /// Completion *was* recorded and the evidence the record itself requires is not all
79    /// there, so the plan refuses to call it `DONE`. Canonical `VERIFY`, and the case the
80    /// `evidence_missing` finding is about.
81    CompletionBlocked,
82    /// Finished on its own terms: not cancelled, completion recorded, every required
83    /// evidence token present. Canonical `DONE`.
84    Complete,
85    /// Withdrawn. Canonical `CANCELLED`.
86    Cancelled,
87    /// The canonical model does not declare this id. Not a status: the absence of a record.
88    Undeclared,
89}
90
91impl TaskReadiness {
92    /// The canonical status this refines, or the empty string for [`TaskReadiness::Undeclared`],
93    /// which refines none.
94    pub fn canonical(self) -> &'static str {
95        match self {
96            TaskReadiness::Ready => "READY",
97            TaskReadiness::Blocked | TaskReadiness::Waiting => "BLOCKED",
98            TaskReadiness::InProgress => "ACTIVE",
99            TaskReadiness::Review | TaskReadiness::CompletionBlocked => "VERIFY",
100            TaskReadiness::Complete => "DONE",
101            TaskReadiness::Cancelled => "CANCELLED",
102            TaskReadiness::Undeclared => "",
103        }
104    }
105
106    /// The word a projection prints.
107    pub fn as_str(self) -> &'static str {
108        match self {
109            TaskReadiness::Ready => "ready",
110            TaskReadiness::Blocked => "blocked",
111            TaskReadiness::Waiting => "waiting",
112            TaskReadiness::InProgress => "in_progress",
113            TaskReadiness::Review => "review",
114            TaskReadiness::CompletionBlocked => "completion_blocked",
115            TaskReadiness::Complete => "complete",
116            TaskReadiness::Cancelled => "cancelled",
117            TaskReadiness::Undeclared => "undeclared",
118        }
119    }
120
121    /// May a worker pick this up right now?
122    ///
123    /// ```
124    /// use majordomus_cli::devtask::TaskReadiness;
125    /// assert!(TaskReadiness::Ready.is_startable());
126    /// assert!(!TaskReadiness::InProgress.is_startable(), "somebody already has it");
127    /// ```
128    pub fn is_startable(self) -> bool {
129        self == TaskReadiness::Ready
130    }
131
132    /// Is there nothing further to do, whatever happens elsewhere?
133    pub fn is_terminal(self) -> bool {
134        matches!(self, TaskReadiness::Complete | TaskReadiness::Cancelled)
135    }
136
137    /// Every state, in the order a board reads them: the executable ones first, the
138    /// finished ones last. Declared once so that no surface writes the sequence down.
139    pub const ALL: &'static [TaskReadiness] = &[
140        TaskReadiness::Ready,
141        TaskReadiness::InProgress,
142        TaskReadiness::Review,
143        TaskReadiness::CompletionBlocked,
144        TaskReadiness::Blocked,
145        TaskReadiness::Waiting,
146        TaskReadiness::Complete,
147        TaskReadiness::Cancelled,
148        TaskReadiness::Undeclared,
149    ];
150
151    /// Derive the readiness from the facts the plan already holds.
152    ///
153    /// The arguments are exactly what [`crate::plan`] derives and what the canonical record
154    /// declares; nothing is read here, and nothing is guessed. `status` is the canonical
155    /// status, `blockers` the plan's own `blocked_by` list — whose `milestone:` prefix is
156    /// what separates [`TaskReadiness::Waiting`] from [`TaskReadiness::Blocked`] — and
157    /// `completion_recorded` / `evidence_covered` are what separate
158    /// [`TaskReadiness::CompletionBlocked`] from [`TaskReadiness::Review`].
159    ///
160    /// ```
161    /// use majordomus_cli::devtask::TaskReadiness;
162    ///
163    /// // blocked only by its milestone is waiting, not blocked
164    /// let waiting = TaskReadiness::derive("BLOCKED", &["milestone:m1".to_string()], false, true);
165    /// assert_eq!(waiting, TaskReadiness::Waiting);
166    ///
167    /// // blocked by a peer, milestone gate or not, is blocked
168    /// let blocked = TaskReadiness::derive(
169    ///     "BLOCKED",
170    ///     &["I0002".to_string(), "milestone:m1".to_string()],
171    ///     false,
172    ///     true,
173    /// );
174    /// assert_eq!(blocked, TaskReadiness::Blocked);
175    ///
176    /// // completion recorded without the evidence it requires
177    /// let stuck = TaskReadiness::derive("VERIFY", &[], true, false);
178    /// assert_eq!(stuck, TaskReadiness::CompletionBlocked);
179    ///
180    /// // verified and waiting to be closed
181    /// assert_eq!(TaskReadiness::derive("VERIFY", &[], false, false), TaskReadiness::Review);
182    ///
183    /// // a status the plan never derives is not silently mapped onto one that looks fine
184    /// assert_eq!(TaskReadiness::derive("NONESUCH", &[], false, false), TaskReadiness::Undeclared);
185    /// ```
186    pub fn derive(
187        status: &str,
188        blockers: &[String],
189        completion_recorded: bool,
190        evidence_covered: bool,
191    ) -> TaskReadiness {
192        match status {
193            "READY" => TaskReadiness::Ready,
194            "BLOCKED" => {
195                let only_the_gate = !blockers.is_empty()
196                    && blockers.iter().all(|b| b.starts_with(MILESTONE_BLOCKER));
197                if only_the_gate {
198                    TaskReadiness::Waiting
199                } else {
200                    TaskReadiness::Blocked
201                }
202            }
203            "ACTIVE" => TaskReadiness::InProgress,
204            "VERIFY" => {
205                if completion_recorded && !evidence_covered {
206                    TaskReadiness::CompletionBlocked
207                } else {
208                    TaskReadiness::Review
209                }
210            }
211            "DONE" => TaskReadiness::Complete,
212            "CANCELLED" => TaskReadiness::Cancelled,
213            // A status the plan does not declare cannot be mapped onto one that reads as
214            // work. The plan itself reports it as an `unknown_status` finding; here it is
215            // the absence of a usable state, which is what `Undeclared` means.
216            _ => TaskReadiness::Undeclared,
217        }
218    }
219}
220
221/// The prefix the plan's own `blocked_by` uses for the milestone gate. Stated once, because
222/// two readers of the same token is how a prefix quietly changes on one side only.
223pub const MILESTONE_BLOCKER: &str = "milestone:";
224
225/// What kind of thing is holding a task back. The five are the five the canonical model can
226/// actually produce; there is no `other`.
227#[derive(
228    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
229)]
230#[serde(rename_all = "snake_case")]
231pub enum BlockerKind {
232    /// An issue this one depends on that is not `DONE`.
233    Issue,
234    /// The milestone gate: an outcome this one's milestone requires is not `DONE`.
235    Milestone,
236    /// A required evidence token the record does not carry, with completion recorded.
237    Evidence,
238    /// A dependency that resolves to nothing: the model has no such issue.
239    Reference,
240    /// A dependency cycle this task is in or downstream of, so it can never become ready.
241    Cycle,
242}
243
244impl BlockerKind {
245    /// The word a projection prints.
246    pub fn as_str(self) -> &'static str {
247        match self {
248            BlockerKind::Issue => "issue",
249            BlockerKind::Milestone => "milestone",
250            BlockerKind::Evidence => "evidence",
251            BlockerKind::Reference => "reference",
252            BlockerKind::Cycle => "cycle",
253        }
254    }
255
256    /// Is this a defect in the model rather than work still to be done? A reference that
257    /// resolves to nothing and a cycle are both bugs in the records; the other three are
258    /// the ordinary state of a plan being executed.
259    ///
260    /// ```
261    /// use majordomus_cli::devtask::BlockerKind;
262    /// assert!(BlockerKind::Reference.is_defect());
263    /// assert!(!BlockerKind::Issue.is_defect());
264    /// ```
265    pub fn is_defect(self) -> bool {
266        matches!(self, BlockerKind::Reference | BlockerKind::Cycle)
267    }
268}
269
270/// One thing standing between a task and being startable, with what to do about it.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
272pub struct Blocker {
273    /// What kind of thing it is.
274    pub kind: BlockerKind,
275    /// The thing: an issue id, a milestone id, an evidence token.
276    pub subject: String,
277    /// What is wrong, in one line a person can act on.
278    pub detail: String,
279}
280
281impl Blocker {
282    /// One blocker.
283    pub fn new(kind: BlockerKind, subject: impl Into<String>, detail: impl Into<String>) -> Self {
284        Self {
285            kind,
286            subject: subject.into(),
287            detail: detail.into(),
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::plan::ISSUE_STATUSES;
296
297    /// Every canonical status the plan can derive maps to a readiness that names it back.
298    /// The assertion that keeps this a projection rather than a parallel vocabulary: a
299    /// seventh canonical status added to `lib/project.awk` and mirrored into
300    /// `ISSUE_STATUSES` fails here rather than reaching a surface as `undeclared`.
301    #[test]
302    fn every_canonical_status_is_covered_and_maps_back_to_itself() {
303        for status in ISSUE_STATUSES {
304            let r = TaskReadiness::derive(status, &[], false, false);
305            assert_ne!(
306                r,
307                TaskReadiness::Undeclared,
308                "{status} reaches no readiness state"
309            );
310            assert_eq!(
311                r.canonical(),
312                *status,
313                "{status} maps to {} which names {} instead",
314                r.as_str(),
315                r.canonical()
316            );
317        }
318    }
319
320    /// The refinements refine: each added state names the same canonical status as the state
321    /// it was split from, so no readiness can claim a status the plan did not derive.
322    #[test]
323    fn no_readiness_claims_a_status_the_plan_does_not_declare() {
324        for r in TaskReadiness::ALL {
325            let c = r.canonical();
326            if c.is_empty() {
327                assert_eq!(*r, TaskReadiness::Undeclared);
328                continue;
329            }
330            assert!(
331                ISSUE_STATUSES.contains(&c),
332                "{} claims the status {c}, which the plan does not declare",
333                r.as_str()
334            );
335        }
336    }
337
338    /// `ALL` is the whole vocabulary: a variant added and not listed would be invisible to
339    /// every board that reads the order from here.
340    #[test]
341    fn the_declared_order_holds_every_state_once() {
342        let mut seen: Vec<&str> = TaskReadiness::ALL.iter().map(|r| r.as_str()).collect();
343        let n = seen.len();
344        seen.sort_unstable();
345        seen.dedup();
346        assert_eq!(seen.len(), n, "a state is listed twice");
347        // one per canonical status, plus the three refinements, plus the absence
348        assert_eq!(n, ISSUE_STATUSES.len() + 3);
349    }
350
351    /// A blocked issue with no blockers at all would be a contradiction in the plan; it is
352    /// reported as `blocked` rather than as `waiting`, because "only the gate holds it" is a
353    /// claim about a gate that is not there.
354    #[test]
355    fn blocked_with_no_blocker_named_is_not_waiting() {
356        assert_eq!(
357            TaskReadiness::derive("BLOCKED", &[], false, false),
358            TaskReadiness::Blocked
359        );
360    }
361
362    /// Only `Ready` is startable, and the two finished states are the only terminal ones.
363    #[test]
364    fn startable_and_terminal_are_disjoint_and_narrow() {
365        let startable: Vec<&str> = TaskReadiness::ALL
366            .iter()
367            .filter(|r| r.is_startable())
368            .map(|r| r.as_str())
369            .collect();
370        assert_eq!(startable, ["ready"]);
371        let terminal: Vec<&str> = TaskReadiness::ALL
372            .iter()
373            .filter(|r| r.is_terminal())
374            .map(|r| r.as_str())
375            .collect();
376        assert_eq!(terminal, ["complete", "cancelled"]);
377    }
378}