Skip to main content

speccoder_core/
story.rs

1//! What judges a task of a story — decided by code from the story's oracle,
2//! never by a test author's choice. A story's acceptance is written once,
3//! through the product, before its first code task (the oracle); every task
4//! of the story is then judged by the progress it makes on it: the
5//! scenarios it turns green. A task naming scenarios must leave them green;
6//! the story's checkpoint (its last open code task) must leave every one
7//! green. A tick claims exactly what it turned, so no scenario is claimed
8//! twice and none is left unproven when the story closes.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14/// A scenario's state on a tree: green when every oracle test citing it
15/// passes, red when one fails, untested when no kept test cites it.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum ScenarioState {
19    /// Every test citing it passes.
20    Green,
21    /// A test citing it fails.
22    Red,
23    /// No kept test cites it.
24    Untested,
25}
26
27/// What an attempt did to its story, by the oracle's states before and
28/// after it.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum Progress {
32    /// It turned these scenarios green (in the story's order).
33    Verified {
34        /// Red before, green after.
35        turned: Vec<String>,
36    },
37    /// It turned none and broke none; the story goes on.
38    InProgress,
39    /// The checkpoint, every scenario green before it already.
40    Closed,
41    /// Scenarios green before it are red after it.
42    Regressed {
43        /// Green before, not green after.
44        scenarios: Vec<String>,
45    },
46    /// Scenarios the task names are not green after it.
47    LinksOpen {
48        /// Its links still not green.
49        scenarios: Vec<String>,
50    },
51    /// The checkpoint left scenarios not green.
52    StoryOpen {
53        /// Not green after it.
54        scenarios: Vec<String>,
55    },
56}
57
58impl Progress {
59    /// Whether the attempt may tick.
60    #[must_use]
61    pub const fn passes(&self) -> bool {
62        matches!(
63            self,
64            Self::Verified { .. } | Self::InProgress | Self::Closed
65        )
66    }
67}
68
69/// What an attempt did, from the oracle's states `before` and `after` it
70/// (keyed by scenario id, iterated in the story's order by the caller's
71/// `order`), the task's `links`, and whether it is the story's checkpoint.
72/// A regression fails first; then the checkpoint's whole story — except
73/// the scenarios its author declared `untestable`, shown, never blocking —
74/// then the task's links.
75#[must_use]
76pub fn progress(
77    order: &[String],
78    before: &BTreeMap<String, ScenarioState>,
79    after: &BTreeMap<String, ScenarioState>,
80    links: &[String],
81    checkpoint: bool,
82    untestable: &[String],
83) -> Progress {
84    let state = |m: &BTreeMap<String, ScenarioState>, id: &str| {
85        m.get(id).copied().unwrap_or(ScenarioState::Untested)
86    };
87    let green =
88        |m: &BTreeMap<String, ScenarioState>, id: &str| state(m, id) == ScenarioState::Green;
89    let pick = |f: &dyn Fn(&str) -> bool| -> Vec<String> {
90        order.iter().filter(|id| f(id)).cloned().collect()
91    };
92    let regressed = pick(&|id| green(before, id) && !green(after, id));
93    if !regressed.is_empty() {
94        return Progress::Regressed {
95            scenarios: regressed,
96        };
97    }
98    if checkpoint {
99        let open = pick(&|id| !green(after, id) && !untestable.iter().any(|u| u == id));
100        if !open.is_empty() {
101            return Progress::StoryOpen { scenarios: open };
102        }
103    }
104    let open = pick(&|id| links.iter().any(|l| l == id) && !green(after, id));
105    if !open.is_empty() {
106        return Progress::LinksOpen { scenarios: open };
107    }
108    let turned = pick(&|id| !green(before, id) && green(after, id));
109    match (turned.is_empty(), checkpoint) {
110        (false, _) => Progress::Verified { turned },
111        (true, true) => Progress::Closed,
112        (true, false) => Progress::InProgress,
113    }
114}
115
116/// What judges one open task of a story.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum Judged {
120    /// The progress it makes on the story's oracle.
121    Progress,
122    /// The scenarios it names, which it must leave green.
123    Linked {
124        /// Their ids.
125        scenarios: Vec<String>,
126    },
127    /// The story's checkpoint: every scenario must be green after it.
128    Checkpoint,
129}
130
131impl Judged {
132    /// `progress`, `linked` or `checkpoint`.
133    #[must_use]
134    pub const fn kind(&self) -> &'static str {
135        match self {
136            Self::Progress => "progress",
137            Self::Linked { .. } => "linked",
138            Self::Checkpoint => "checkpoint",
139        }
140    }
141
142    /// The scenarios it must leave green: its links (none otherwise).
143    #[must_use]
144    pub fn links(&self) -> &[String] {
145        match self {
146            Self::Linked { scenarios } => scenarios,
147            _ => &[],
148        }
149    }
150}
151
152/// A story's scenarios as its oracle and ticks show them.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
154pub struct StoryScenarios {
155    /// The story (`US3`; for OpenSpec the change).
156    pub story: String,
157    /// Its scenarios in order.
158    pub scenarios: Vec<ScenarioMark>,
159    /// The task that closes it (none: every open task is planning work, or
160    /// all are done).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub checkpoint: Option<String>,
163    /// The story's oracle, once written.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub oracle: Option<OracleMark>,
166    /// Every task done and every scenario green: the spec shows it, the
167    /// report and the board leave it out.
168    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
169    pub proven: bool,
170}
171
172/// Where a story's oracle was written, and who froze it.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
174pub struct OracleMark {
175    /// The `Oracle:` commit (short).
176    pub commit: String,
177    /// `speccoder run`, or the person who approved it at review.
178    pub decided_by: String,
179}
180
181/// One scenario of a story.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
183pub struct ScenarioMark {
184    /// Its id (`US3-1`).
185    pub id: String,
186    /// Its state at the branch's tip (red before the oracle exists).
187    pub state: ScenarioState,
188    /// The task whose tick turned it green, worded as it is now.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub turned_by: Option<String>,
191    /// What must hold, as the oracle states it (given → must hold).
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub contract: Option<String>,
194}
195
196/// One task of the story, in document order.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct StoryTask<'a> {
199    /// Its id.
200    pub id: &'a str,
201    /// Judged by the checks alone — planning work, or a step only a person
202    /// takes (`[YOU]`): never a checkpoint.
203    pub checks_only: bool,
204    /// Ticked (or skipped, or accepted): it judges nothing any more.
205    pub done: bool,
206    /// The scenarios it names.
207    pub links: &'a [String],
208}
209
210/// The story's checkpoint: its last open task that is judged by the
211/// oracle. Whatever order the tasks run in, the one that closes the story
212/// judges all of it; none once every such task is done.
213#[must_use]
214pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
215    tasks
216        .iter()
217        .rev()
218        .find(|t| !t.checks_only && !t.done)
219        .map(|t| t.id)
220}
221
222/// The story's first open code task: where its oracle is written.
223#[must_use]
224pub fn first<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
225    tasks
226        .iter()
227        .find(|t| !t.checks_only && !t.done)
228        .map(|t| t.id)
229}
230
231/// What judges `task` of the story `tasks`, whose scenarios are
232/// `scenarios`. `None`: the story has no scenario, or the task is judged by
233/// its checks alone. Links naming no scenario of the story are ignored
234/// (the plan reports them); the checkpoint comes first.
235#[must_use]
236pub fn judged(tasks: &[StoryTask<'_>], task: &str, scenarios: &[String]) -> Option<Judged> {
237    let me = tasks.iter().find(|t| t.id == task)?;
238    if scenarios.is_empty() || me.checks_only {
239        return None;
240    }
241    if checkpoint(tasks) == Some(task) {
242        return Some(Judged::Checkpoint);
243    }
244    let links: Vec<String> = me
245        .links
246        .iter()
247        .filter(|l| scenarios.contains(l))
248        .cloned()
249        .collect();
250    Some(if links.is_empty() {
251        Judged::Progress
252    } else {
253        Judged::Linked { scenarios: links }
254    })
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use ScenarioState::{Green, Red, Untested};
261    use proptest::prelude::*;
262
263    fn ids(n: usize) -> Vec<String> {
264        (1..=n).map(|k| format!("US3-{k}")).collect()
265    }
266
267    fn states(s: &[ScenarioState]) -> BTreeMap<String, ScenarioState> {
268        ids(s.len()).into_iter().zip(s.iter().copied()).collect()
269    }
270
271    const NONE: &[String] = &[];
272
273    fn open(id: &str) -> StoryTask<'_> {
274        StoryTask {
275            id,
276            checks_only: false,
277            done: false,
278            links: NONE,
279        }
280    }
281
282    #[test]
283    fn a_task_claims_what_it_turned_and_nothing_else() {
284        let o = ids(3);
285        assert_eq!(
286            progress(
287                &o,
288                &states(&[Red, Red, Red]),
289                &states(&[Green, Red, Green]),
290                &[],
291                false,
292                &[],
293            ),
294            Progress::Verified {
295                turned: vec!["US3-1".into(), "US3-3".into()]
296            }
297        );
298        assert_eq!(
299            progress(
300                &o,
301                &states(&[Green, Red, Red]),
302                &states(&[Green, Red, Red]),
303                &[],
304                false,
305                &[],
306            ),
307            Progress::InProgress
308        );
309        assert_eq!(
310            progress(
311                &o,
312                &states(&[Green, Red, Red]),
313                &states(&[Red, Green, Green]),
314                &[],
315                false,
316                &[],
317            ),
318            Progress::Regressed {
319                scenarios: vec!["US3-1".into()]
320            },
321            "a regression fails whatever else turned"
322        );
323    }
324
325    #[test]
326    fn links_and_the_checkpoint_must_be_green_after() {
327        let o = ids(2);
328        let link = vec!["US3-2".to_owned()];
329        assert_eq!(
330            progress(
331                &o,
332                &states(&[Red, Red]),
333                &states(&[Green, Red]),
334                &link,
335                false,
336                &[],
337            ),
338            Progress::LinksOpen {
339                scenarios: link.clone()
340            }
341        );
342        assert_eq!(
343            progress(
344                &o,
345                &states(&[Red, Red]),
346                &states(&[Green, Untested]),
347                &[],
348                true,
349                &[],
350            ),
351            Progress::StoryOpen {
352                scenarios: vec!["US3-2".into()]
353            },
354            "untested is not green"
355        );
356        assert_eq!(
357            progress(
358                &o,
359                &states(&[Green, Green]),
360                &states(&[Green, Green]),
361                &[],
362                true,
363                &[],
364            ),
365            Progress::Closed
366        );
367    }
368
369    #[test]
370    fn a_scenario_its_author_could_not_test_never_blocks_the_story() {
371        let o = ids(2);
372        let untestable = vec!["US3-2".to_owned()];
373        assert_eq!(
374            progress(
375                &o,
376                &states(&[Red, Untested]),
377                &states(&[Green, Untested]),
378                &[],
379                true,
380                &untestable
381            ),
382            Progress::Verified {
383                turned: vec!["US3-1".into()]
384            }
385        );
386    }
387
388    #[test]
389    fn the_checkpoint_is_the_last_open_code_task_and_the_oracle_goes_first() {
390        let mut docs = open("T022");
391        docs.checks_only = true;
392        let mut done = open("T018");
393        done.done = true;
394        let link = vec!["US3-2".to_owned()];
395        let mut linked = open("T020");
396        linked.links = &link;
397        let tasks = [done, open("T019"), linked, open("T021"), docs];
398        let sc = ids(2);
399        assert_eq!(checkpoint(&tasks), Some("T021"));
400        assert_eq!(first(&tasks), Some("T019"));
401        assert_eq!(judged(&tasks, "T019", &sc), Some(Judged::Progress));
402        assert_eq!(
403            judged(&tasks, "T020", &sc),
404            Some(Judged::Linked {
405                scenarios: link.clone()
406            })
407        );
408        assert_eq!(judged(&tasks, "T021", &sc), Some(Judged::Checkpoint));
409        assert_eq!(judged(&tasks, "T022", &sc), None, "documentation");
410        assert_eq!(judged(&tasks, "T019", &[]), None, "no scenario");
411    }
412
413    fn state() -> impl Strategy<Value = ScenarioState> {
414        prop_oneof![Just(Green), Just(Red), Just(Untested)]
415    }
416
417    proptest! {
418        /// The rule is a function of its inputs; a verified tick names what
419        /// it turned and only that; a checkpoint never passes with a scenario
420        /// not green; a green scenario never goes red on a passing tick.
421        #[test]
422        fn progress_is_honest(
423            before in proptest::collection::vec(state(), 1..6),
424            after_seed in proptest::collection::vec(state(), 6),
425            link_mask in proptest::collection::vec(any::<bool>(), 6),
426            checkpoint in any::<bool>(),
427        ) {
428            let n = before.len();
429            let o = ids(n);
430            let b = states(&before);
431            let a = states(&after_seed[..n]);
432            let links: Vec<String> = o.iter().zip(&link_mask).filter(|(_, m)| **m).map(|(id, _)| id.clone()).collect();
433            let p = progress(&o, &b, &a, &links, checkpoint, &[]);
434            prop_assert_eq!(&p, &progress(&o, &b, &a, &links, checkpoint, &[]));
435            if let Progress::Verified { turned } = &p {
436                prop_assert!(!turned.is_empty());
437                for id in turned {
438                    prop_assert!(b[id] != Green && a[id] == Green);
439                }
440            }
441            if p.passes() {
442                for id in &o {
443                    prop_assert!(!(b[id] == Green && a[id] != Green));
444                }
445                for id in &links {
446                    prop_assert_eq!(a[id], Green);
447                }
448                if checkpoint {
449                    prop_assert!(o.iter().all(|id| a[id] == Green));
450                }
451            }
452        }
453
454        /// Ticks applied in sequence never claim a scenario twice: once
455        /// green it stays green on every later passing tick.
456        #[test]
457        fn a_scenario_is_claimed_once(
458            steps in proptest::collection::vec(proptest::collection::vec(state(), 4), 1..6),
459        ) {
460            let o = ids(4);
461            let mut now = states(&[Red, Red, Red, Red]);
462            let mut claimed: BTreeMap<String, usize> = BTreeMap::new();
463            for (i, s) in steps.iter().enumerate() {
464                let next = states(s);
465                let p = progress(&o, &now, &next, &[], false, &[]);
466                if p.passes() {
467                    if let Progress::Verified { turned } = &p {
468                        for id in turned {
469                            prop_assert!(claimed.insert(id.clone(), i).is_none());
470                        }
471                    }
472                    now = next;
473                }
474            }
475        }
476    }
477}