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