Skip to main content

speccoder_speckit/
stories.rs

1//! User stories of `spec.md`: the section a task's `[USn]` label points at,
2//! and the stories as format-neutral requirements with their Given/When/Then
3//! acceptance scenarios.
4
5use speccoder_core::snapshot::{Diagnostic, Severity};
6use speccoder_core::spec::{Requirement, Scenario, ScenarioRef, SourceRange};
7
8/// The `### User Story N` section for label `USN`, up to the next heading of
9/// the same or a higher level.
10#[must_use]
11pub fn story_section(spec: &str, label: &str) -> String {
12    let Some(n) = label.strip_prefix("US") else {
13        return String::new();
14    };
15    let wanted = format!("user story {n}");
16    let mut out = Vec::new();
17    let mut level = None;
18    for line in spec.lines() {
19        let hashes = line.bytes().take_while(|b| *b == b'#').count();
20        let is_heading = hashes > 0 && line[hashes..].starts_with(' ');
21        if let Some(l) = level {
22            if is_heading && hashes <= l {
23                break;
24            }
25            out.push(line);
26        } else if is_heading {
27            let text = line[hashes..].trim().to_lowercase();
28            let names_it = text
29                .strip_prefix(&wanted)
30                .is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_digit()));
31            if names_it {
32                level = Some(hashes);
33                out.push(line);
34            }
35        }
36    }
37    out.join("\n")
38}
39
40/// Each `### User Story N - Title` of the spec at `path` as a requirement
41/// keyed `USN`, with the numbered `**Given** … **When** … **Then** …` items
42/// of its section as scenarios keyed `USN/ACm`. A key that appears twice is
43/// reported and the later story skipped.
44#[must_use]
45pub fn requirements(path: &str, text: &str) -> (Vec<Requirement>, Vec<Diagnostic>) {
46    let mut lines = Vec::new();
47    let mut at = 0;
48    for line in text.split_inclusive('\n') {
49        lines.push((at, line));
50        at += line.len();
51    }
52    let heading = |line: &str| {
53        let hashes = line.bytes().take_while(|b| *b == b'#').count();
54        (hashes > 0 && line[hashes..].starts_with(' ')).then_some(hashes)
55    };
56    let range = |start: usize, end: usize| SourceRange {
57        path: path.to_owned(),
58        start,
59        end,
60    };
61
62    let mut found: Vec<Requirement> = Vec::new();
63    let mut diags = Vec::new();
64    let mut i = 0;
65    while i < lines.len() {
66        let (start, line) = lines[i];
67        let Some(level) = heading(line) else {
68            i += 1;
69            continue;
70        };
71        let Some((n, title)) = story_heading(line[level..].trim()) else {
72            i += 1;
73            continue;
74        };
75        let mut j = i + 1;
76        while j < lines.len() && heading(lines[j].1).is_none_or(|l| l > level) {
77            j += 1;
78        }
79        let end = lines.get(j).map_or(text.len(), |(s, _)| *s);
80        let key = format!("US{n}");
81        let scenarios = lines[i + 1..j]
82            .iter()
83            .filter(|(_, l)| is_scenario(l))
84            .enumerate()
85            .map(|(m, (s, l))| Scenario {
86                key: format!("{key}/AC{}", m + 1),
87                raw: l.trim_end().to_owned(),
88                source: range(*s, s + l.trim_end().len()),
89            })
90            .collect();
91        if found.iter().any(|r| r.key == key) {
92            diags.push(
93                Diagnostic::new(
94                    Severity::Warning,
95                    "spec.duplicate-requirement",
96                    format!("{key} appears more than once; the first is used"),
97                )
98                .at(path),
99            );
100        } else {
101            found.push(Requirement {
102                key,
103                title,
104                text: text[start..end].trim_end().to_owned(),
105                scenarios,
106                source: range(start, end),
107            });
108        }
109        i = j;
110    }
111    (found, diags)
112}
113
114/// The acceptance scenarios a task's tests cite, as `US<n>-<k>` with the
115/// spec's own words: the scenarios of the task's story (`label`), or of
116/// every story for a convergence task (`label` = `None`). A story without
117/// numbered scenarios but with an `**Acceptance**` line has that line as
118/// `US<n>-1`.
119#[must_use]
120pub fn scenario_refs(spec: &str, label: Option<&str>) -> Vec<ScenarioRef> {
121    let (stories, _) = requirements("spec.md", spec);
122    let mut out = Vec::new();
123    for story in stories.iter().filter(|r| label.is_none_or(|l| l == r.key)) {
124        if story.scenarios.is_empty() {
125            if let Some(line) = story
126                .text
127                .lines()
128                .find(|l| l.to_lowercase().contains("**acceptance"))
129            {
130                out.push(ScenarioRef {
131                    id: format!("{}-1", story.key),
132                    text: line.trim().to_owned(),
133                });
134            }
135            continue;
136        }
137        for (k, sc) in story.scenarios.iter().enumerate() {
138            let text = sc.raw.trim_start();
139            let digits = text.bytes().take_while(u8::is_ascii_digit).count();
140            out.push(ScenarioRef {
141                id: format!("{}-{}", story.key, k + 1),
142                text: text[digits..].trim_start_matches('.').trim().to_owned(),
143            });
144        }
145    }
146    out
147}
148
149/// `User Story 3 - Title (Priority: P2)` → (3, "Title").
150fn story_heading(text: &str) -> Option<(u32, String)> {
151    let rest = text
152        .get(..11)
153        .filter(|p| p.eq_ignore_ascii_case("user story "))
154        .map(|_| &text[11..])?;
155    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
156    let n = digits.parse().ok()?;
157    let rest = rest[digits.len()..].trim_start();
158    let title = rest
159        .strip_prefix(['-', '–', '—', ':'])
160        .unwrap_or(rest)
161        .trim();
162    let title = match title.rfind("(Priority") {
163        Some(p) => title[..p].trim_end(),
164        None => title,
165    };
166    Some((n, title.to_owned()))
167}
168
169/// A numbered list item stating a Given/When/Then scenario.
170fn is_scenario(line: &str) -> bool {
171    let l = line.trim_start();
172    let digits = l.bytes().take_while(u8::is_ascii_digit).count();
173    digits > 0 && l[digits..].starts_with(". ") && l.to_lowercase().contains("**given**")
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    const SPEC: &str = "# Spec\n\n### User Story 1 - Login (Priority: P1)\n\nAs a user…\n\n**Acceptance**: works\n\n### User Story 10 - Other\n\nno\n\n### User Story 2 - Profile\n\nprofile text\n\n## Requirements\n\n- FR-001\n";
181
182    #[test]
183    fn story_section_stops_at_the_next_story_and_does_not_confuse_10_with_1() {
184        let s = story_section(SPEC, "US1");
185        assert!(s.starts_with("### User Story 1 - Login"));
186        assert!(s.contains("**Acceptance**: works"));
187        assert!(!s.contains("Other"));
188        let s2 = story_section(SPEC, "US2");
189        assert!(s2.contains("profile text") && !s2.contains("FR-001"));
190        assert_eq!(story_section(SPEC, "US3"), "");
191    }
192
193    #[test]
194    fn stories_become_requirements_with_their_scenarios() {
195        let text = "# S\n\n### User Story 1 - Search by name (Priority: P1)\n\nWhy.\n\n**Acceptance Scenarios**:\n\n1. **Given** a list, **When** I search, **Then** I see matches\n2. **Given** nothing, **When** I search, **Then** I see none\n\n### User Story 2 - Export\n\n1. Not a scenario\n\n## Requirements\n\n### User Story 1 - Again\n";
196        let (reqs, diags) = requirements("specs/001-x/spec.md", text);
197        assert_eq!(reqs.len(), 2);
198        let us1 = &reqs[0];
199        assert_eq!(
200            (us1.key.as_str(), us1.title.as_str()),
201            ("US1", "Search by name")
202        );
203        assert_eq!(us1.text, story_section(text, "US1").trim_end());
204        assert_eq!(us1.scenarios.len(), 2);
205        assert_eq!(us1.scenarios[1].key, "US1/AC2");
206        let s = &us1.scenarios[0].source;
207        assert_eq!(&text[s.start..s.end], us1.scenarios[0].raw);
208        assert!(us1.scenarios[0].raw.starts_with("1. **Given** a list"));
209        assert_eq!(
210            &text[us1.source.start..us1.source.end],
211            format!("{}\n\n", us1.text)
212        );
213        assert!(reqs[1].scenarios.is_empty() && reqs[1].title == "Export");
214        assert_eq!(diags.len(), 1);
215        assert_eq!(diags[0].code, "spec.duplicate-requirement");
216    }
217}