1#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct Scenario {
13 pub name: String,
15 pub body: String,
17 pub line: usize,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Requirement {
24 pub name: String,
26 pub text: Option<String>,
28 pub scenarios: Vec<Scenario>,
30 pub raw: String,
32 pub line: usize,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Rename {
39 pub from: String,
41 pub to: String,
43}
44
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub struct SectionPresence {
48 pub added: bool,
49 pub modified: bool,
50 pub removed: bool,
51 pub renamed: bool,
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
56pub struct DeltaSpecification {
57 pub added: Vec<Requirement>,
59 pub modified: Vec<Requirement>,
61 pub removed: Vec<String>,
63 pub renamed: Vec<Rename>,
65 pub presence: SectionPresence,
67}
68
69pub fn parse_delta_specification(content: &str) -> DeltaSpecification {
74 let normalized = normalize_line_endings(content);
75 let lines: Vec<&str> = normalized.split('\n').collect();
76 let sections = split_top_level_sections(&lines);
77
78 let added_section = find_section(§ions, "added requirements");
79 let modified_section = find_section(§ions, "modified requirements");
80 let removed_section = find_section(§ions, "removed requirements");
81 let renamed_section = find_section(§ions, "renamed requirements");
82
83 DeltaSpecification {
84 added: added_section
85 .map(|section| parse_requirement_blocks(&lines, section.body_start, section.body_end))
86 .unwrap_or_default(),
87 modified: modified_section
88 .map(|section| parse_requirement_blocks(&lines, section.body_start, section.body_end))
89 .unwrap_or_default(),
90 removed: removed_section
91 .map(|section| parse_removed_names(&lines[section.body_start..section.body_end]))
92 .unwrap_or_default(),
93 renamed: renamed_section
94 .map(|section| parse_renamed_pairs(&lines[section.body_start..section.body_end]))
95 .unwrap_or_default(),
96 presence: SectionPresence {
97 added: added_section.is_some(),
98 modified: modified_section.is_some(),
99 removed: removed_section.is_some(),
100 renamed: renamed_section.is_some(),
101 },
102 }
103}
104
105struct SectionSpan {
107 title_lowercase: String,
108 body_start: usize,
109 body_end: usize,
110}
111
112fn normalize_line_endings(content: &str) -> String {
113 content.replace("\r\n", "\n").replace('\r', "\n")
114}
115
116fn section_title(line: &str) -> Option<&str> {
119 let rest = line.strip_prefix("##")?;
120 if rest.starts_with('#') || !rest.starts_with(char::is_whitespace) {
121 return None;
122 }
123 let title = rest.trim();
124 if title.is_empty() { None } else { Some(title) }
125}
126
127fn requirement_header_name(line: &str) -> Option<String> {
130 header_name(line, "###", "requirement:")
131}
132
133fn scenario_header_name(line: &str) -> Option<String> {
136 header_name(line, "####", "scenario:")
137}
138
139fn header_name(line: &str, marker: &str, keyword: &str) -> Option<String> {
140 let rest = line.strip_prefix(marker)?;
141 if rest.starts_with('#') {
142 return None;
143 }
144 let rest = rest.trim_start();
145 let rest = strip_prefix_ignore_ascii_case(rest, keyword)?;
146 if rest.is_empty() {
147 return None;
148 }
149 Some(rest.trim().to_string())
150}
151
152fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
153 let head = text.get(..prefix.len())?;
154 if head.eq_ignore_ascii_case(prefix) {
155 Some(&text[prefix.len()..])
156 } else {
157 None
158 }
159}
160
161fn split_top_level_sections(lines: &[&str]) -> Vec<SectionSpan> {
162 let mut headers: Vec<(usize, String)> = Vec::new();
163 for (index, line) in lines.iter().enumerate() {
164 if let Some(title) = section_title(line) {
165 headers.push((index, title.to_ascii_lowercase()));
166 }
167 }
168 let mut sections = Vec::with_capacity(headers.len());
169 for (position, (index, title_lowercase)) in headers.iter().enumerate() {
170 let body_end = headers
171 .get(position + 1)
172 .map_or(lines.len(), |(next_index, _)| *next_index);
173 sections.push(SectionSpan {
174 title_lowercase: title_lowercase.clone(),
175 body_start: index + 1,
176 body_end,
177 });
178 }
179 sections
180}
181
182fn find_section<'a>(sections: &'a [SectionSpan], title_lowercase: &str) -> Option<&'a SectionSpan> {
183 sections
184 .iter()
185 .find(|section| section.title_lowercase == title_lowercase)
186}
187
188fn parse_requirement_blocks(lines: &[&str], start: usize, end: usize) -> Vec<Requirement> {
191 let mut requirements = Vec::new();
192 let mut cursor = start;
193 while cursor < end {
194 let Some(name) = requirement_header_name(lines[cursor]) else {
195 cursor += 1;
196 continue;
197 };
198 let header_index = cursor;
199 cursor += 1;
200 let body_start = cursor;
201 while cursor < end
202 && requirement_header_name(lines[cursor]).is_none()
203 && section_title(lines[cursor]).is_none()
204 {
205 cursor += 1;
206 }
207 let body = &lines[body_start..cursor];
208 let raw = std::iter::once(lines[header_index])
209 .chain(body.iter().copied())
210 .collect::<Vec<_>>()
211 .join("\n")
212 .trim_end()
213 .to_string();
214 requirements.push(Requirement {
215 name,
216 text: requirement_text(body),
217 scenarios: parse_scenarios(body, body_start),
218 raw,
219 line: header_index + 1,
220 });
221 }
222 requirements
223}
224
225fn requirement_text(body: &[&str]) -> Option<String> {
228 for line in body {
229 if line.trim_start().starts_with('#') {
230 return None;
231 }
232 let trimmed = line.trim();
233 if !trimmed.is_empty() {
234 return Some(trimmed.to_string());
235 }
236 }
237 None
238}
239
240fn parse_scenarios(body: &[&str], body_start: usize) -> Vec<Scenario> {
243 let mut scenarios = Vec::new();
244 let mut cursor = 0;
245 while cursor < body.len() {
246 let Some(name) = scenario_header_name(body[cursor]) else {
247 cursor += 1;
248 continue;
249 };
250 let header_index = cursor;
251 cursor += 1;
252 let content_start = cursor;
253 while cursor < body.len() && !ends_scenario_body(body[cursor]) {
254 cursor += 1;
255 }
256 let content = body[content_start..cursor].join("\n").trim().to_string();
257 scenarios.push(Scenario {
258 name,
259 body: content,
260 line: body_start + header_index + 1,
261 });
262 }
263 scenarios
264}
265
266fn ends_scenario_body(line: &str) -> bool {
272 if scenario_header_name(line).is_some() {
273 return true;
274 }
275 let hashes = line.bytes().take_while(|&byte| byte == b'#').count();
276 (1..=4).contains(&hashes) && line[hashes..].starts_with(char::is_whitespace)
277}
278
279fn parse_removed_names(lines: &[&str]) -> Vec<String> {
282 let mut names = Vec::new();
283 for line in lines {
284 if let Some(name) = requirement_header_name(line) {
285 names.push(name);
286 continue;
287 }
288 if let Some(name) = bullet_requirement_name(line) {
289 names.push(name);
290 }
291 }
292 names
293}
294
295fn bullet_requirement_name(line: &str) -> Option<String> {
296 let rest = line.trim_start().strip_prefix('-')?.trim_start();
297 let rest = rest.strip_prefix('`').unwrap_or(rest);
298 let name = requirement_header_name(rest.trim_end())?;
299 Some(name.trim_end_matches('`').trim().to_string())
300}
301
302fn parse_renamed_pairs(lines: &[&str]) -> Vec<Rename> {
306 let mut pairs = Vec::new();
307 let mut pending_from: Option<String> = None;
308 for line in lines {
309 if let Some(name) = labeled_requirement_name(line, "FROM:") {
310 pending_from = Some(name);
311 } else if let Some(name) = labeled_requirement_name(line, "TO:")
312 && let Some(from) = pending_from.take()
313 {
314 pairs.push(Rename { from, to: name });
315 }
316 }
317 pairs
318}
319
320fn labeled_requirement_name(line: &str, label: &str) -> Option<String> {
321 let rest = line.trim_start();
322 let rest = rest.strip_prefix('-').map_or(rest, str::trim_start);
323 let rest = rest.strip_prefix(label)?.trim_start();
324 let rest = rest.strip_prefix('`').unwrap_or(rest);
325 let name = requirement_header_name(rest.trim_end())?;
326 Some(name.trim_end_matches('`').trim().to_string())
327}