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    /// Every task done before the story had an oracle: nothing ran its
171    /// scenarios, and nothing is still to turn them.
172    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173    pub before_oracle: bool,
174}
175
176/// Where a story's oracle was written, and who froze it.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
178pub struct OracleMark {
179    /// The `Oracle:` commit (short).
180    pub commit: String,
181    /// `speccoder run`, or the person who approved it at review.
182    pub decided_by: String,
183}
184
185/// One scenario of a story.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
187pub struct ScenarioMark {
188    /// Its id (`US3-1`).
189    pub id: String,
190    /// Its state at the branch's tip (red before the oracle exists).
191    pub state: ScenarioState,
192    /// The task whose tick turned it green, worded as it is now.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub turned_by: Option<String>,
195    /// What must hold, as the oracle states it (given → must hold).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub contract: Option<String>,
198}
199
200/// One task of the story, in document order.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct StoryTask<'a> {
203    /// Its id.
204    pub id: &'a str,
205    /// Judged by the checks alone — planning work, or a step only a person
206    /// takes (`[YOU]`): never a checkpoint.
207    pub checks_only: bool,
208    /// Ticked (or skipped, or accepted): it judges nothing any more.
209    pub done: bool,
210    /// The scenarios it names.
211    pub links: &'a [String],
212}
213
214/// The story's checkpoint: its last open task that is judged by the
215/// oracle. Whatever order the tasks run in, the one that closes the story
216/// judges all of it; none once every such task is done.
217#[must_use]
218pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
219    tasks
220        .iter()
221        .rev()
222        .find(|t| !t.checks_only && !t.done)
223        .map(|t| t.id)
224}
225
226/// The story's first open code task: where its oracle is written.
227#[must_use]
228pub fn first<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
229    tasks
230        .iter()
231        .find(|t| !t.checks_only && !t.done)
232        .map(|t| t.id)
233}
234
235/// What judges `task` of the story `tasks`, whose scenarios are
236/// `scenarios`. `None`: the story has no scenario, or the task is judged by
237/// its checks alone. Links naming no scenario of the story are ignored
238/// (the plan reports them); the checkpoint comes first.
239#[must_use]
240pub fn judged(tasks: &[StoryTask<'_>], task: &str, scenarios: &[String]) -> Option<Judged> {
241    let me = tasks.iter().find(|t| t.id == task)?;
242    if scenarios.is_empty() || me.checks_only {
243        return None;
244    }
245    if checkpoint(tasks) == Some(task) {
246        return Some(Judged::Checkpoint);
247    }
248    let links: Vec<String> = me
249        .links
250        .iter()
251        .filter(|l| scenarios.contains(l))
252        .cloned()
253        .collect();
254    Some(if links.is_empty() {
255        Judged::Progress
256    } else {
257        Judged::Linked { scenarios: links }
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use ScenarioState::{Green, Red, Untested};
265    use proptest::prelude::*;
266
267    fn ids(n: usize) -> Vec<String> {
268        (1..=n).map(|k| format!("US3-{k}")).collect()
269    }
270
271    fn states(s: &[ScenarioState]) -> BTreeMap<String, ScenarioState> {
272        ids(s.len()).into_iter().zip(s.iter().copied()).collect()
273    }
274
275    const NONE: &[String] = &[];
276
277    fn open(id: &str) -> StoryTask<'_> {
278        StoryTask {
279            id,
280            checks_only: false,
281            done: false,
282            links: NONE,
283        }
284    }
285
286    #[test]
287    fn a_task_claims_what_it_turned_and_nothing_else() {
288        let o = ids(3);
289        assert_eq!(
290            progress(
291                &o,
292                &states(&[Red, Red, Red]),
293                &states(&[Green, Red, Green]),
294                &[],
295                false,
296                &[],
297            ),
298            Progress::Verified {
299                turned: vec!["US3-1".into(), "US3-3".into()]
300            }
301        );
302        assert_eq!(
303            progress(
304                &o,
305                &states(&[Green, Red, Red]),
306                &states(&[Green, Red, Red]),
307                &[],
308                false,
309                &[],
310            ),
311            Progress::InProgress
312        );
313        assert_eq!(
314            progress(
315                &o,
316                &states(&[Green, Red, Red]),
317                &states(&[Red, Green, Green]),
318                &[],
319                false,
320                &[],
321            ),
322            Progress::Regressed {
323                scenarios: vec!["US3-1".into()]
324            },
325            "a regression fails whatever else turned"
326        );
327    }
328
329    #[test]
330    fn links_and_the_checkpoint_must_be_green_after() {
331        let o = ids(2);
332        let link = vec!["US3-2".to_owned()];
333        assert_eq!(
334            progress(
335                &o,
336                &states(&[Red, Red]),
337                &states(&[Green, Red]),
338                &link,
339                false,
340                &[],
341            ),
342            Progress::LinksOpen {
343                scenarios: link.clone()
344            }
345        );
346        assert_eq!(
347            progress(
348                &o,
349                &states(&[Red, Red]),
350                &states(&[Green, Untested]),
351                &[],
352                true,
353                &[],
354            ),
355            Progress::StoryOpen {
356                scenarios: vec!["US3-2".into()]
357            },
358            "untested is not green"
359        );
360        assert_eq!(
361            progress(
362                &o,
363                &states(&[Green, Green]),
364                &states(&[Green, Green]),
365                &[],
366                true,
367                &[],
368            ),
369            Progress::Closed
370        );
371    }
372
373    #[test]
374    fn a_scenario_its_author_could_not_test_never_blocks_the_story() {
375        let o = ids(2);
376        let untestable = vec!["US3-2".to_owned()];
377        assert_eq!(
378            progress(
379                &o,
380                &states(&[Red, Untested]),
381                &states(&[Green, Untested]),
382                &[],
383                true,
384                &untestable
385            ),
386            Progress::Verified {
387                turned: vec!["US3-1".into()]
388            }
389        );
390    }
391
392    #[test]
393    fn the_checkpoint_is_the_last_open_code_task_and_the_oracle_goes_first() {
394        let mut docs = open("T022");
395        docs.checks_only = true;
396        let mut done = open("T018");
397        done.done = true;
398        let link = vec!["US3-2".to_owned()];
399        let mut linked = open("T020");
400        linked.links = &link;
401        let tasks = [done, open("T019"), linked, open("T021"), docs];
402        let sc = ids(2);
403        assert_eq!(checkpoint(&tasks), Some("T021"));
404        assert_eq!(first(&tasks), Some("T019"));
405        assert_eq!(judged(&tasks, "T019", &sc), Some(Judged::Progress));
406        assert_eq!(
407            judged(&tasks, "T020", &sc),
408            Some(Judged::Linked {
409                scenarios: link.clone()
410            })
411        );
412        assert_eq!(judged(&tasks, "T021", &sc), Some(Judged::Checkpoint));
413        assert_eq!(judged(&tasks, "T022", &sc), None, "documentation");
414        assert_eq!(judged(&tasks, "T019", &[]), None, "no scenario");
415    }
416
417    fn state() -> impl Strategy<Value = ScenarioState> {
418        prop_oneof![Just(Green), Just(Red), Just(Untested)]
419    }
420
421    proptest! {
422        /// The rule is a function of its inputs; a verified tick names what
423        /// it turned and only that; a checkpoint never passes with a scenario
424        /// not green; a green scenario never goes red on a passing tick.
425        #[test]
426        fn progress_is_honest(
427            before in proptest::collection::vec(state(), 1..6),
428            after_seed in proptest::collection::vec(state(), 6),
429            link_mask in proptest::collection::vec(any::<bool>(), 6),
430            checkpoint in any::<bool>(),
431        ) {
432            let n = before.len();
433            let o = ids(n);
434            let b = states(&before);
435            let a = states(&after_seed[..n]);
436            let links: Vec<String> = o.iter().zip(&link_mask).filter(|(_, m)| **m).map(|(id, _)| id.clone()).collect();
437            let p = progress(&o, &b, &a, &links, checkpoint, &[]);
438            prop_assert_eq!(&p, &progress(&o, &b, &a, &links, checkpoint, &[]));
439            if let Progress::Verified { turned } = &p {
440                prop_assert!(!turned.is_empty());
441                for id in turned {
442                    prop_assert!(b[id] != Green && a[id] == Green);
443                }
444            }
445            if p.passes() {
446                for id in &o {
447                    prop_assert!(!(b[id] == Green && a[id] != Green));
448                }
449                for id in &links {
450                    prop_assert_eq!(a[id], Green);
451                }
452                if checkpoint {
453                    prop_assert!(o.iter().all(|id| a[id] == Green));
454                }
455            }
456        }
457
458        /// Ticks applied in sequence never claim a scenario twice: once
459        /// green it stays green on every later passing tick.
460        #[test]
461        fn a_scenario_is_claimed_once(
462            steps in proptest::collection::vec(proptest::collection::vec(state(), 4), 1..6),
463        ) {
464            let o = ids(4);
465            let mut now = states(&[Red, Red, Red, Red]);
466            let mut claimed: BTreeMap<String, usize> = BTreeMap::new();
467            for (i, s) in steps.iter().enumerate() {
468                let next = states(s);
469                let p = progress(&o, &now, &next, &[], false, &[]);
470                if p.passes() {
471                    if let Progress::Verified { turned } = &p {
472                        for id in turned {
473                            prop_assert!(claimed.insert(id.clone(), i).is_none());
474                        }
475                    }
476                    now = next;
477                }
478            }
479        }
480    }
481}