speccoder_speckit/
stories.rs1use speccoder_core::snapshot::{Diagnostic, Severity};
6use speccoder_core::spec::{Requirement, Scenario, ScenarioRef, SourceRange};
7
8#[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#[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#[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
149fn 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
169fn 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}