#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Scenario {
pub name: String,
pub body: String,
pub line: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Requirement {
pub name: String,
pub text: Option<String>,
pub scenarios: Vec<Scenario>,
pub raw: String,
pub line: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rename {
pub from: String,
pub to: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SectionPresence {
pub added: bool,
pub modified: bool,
pub removed: bool,
pub renamed: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DeltaSpecification {
pub added: Vec<Requirement>,
pub modified: Vec<Requirement>,
pub removed: Vec<String>,
pub renamed: Vec<Rename>,
pub presence: SectionPresence,
}
pub fn parse_delta_specification(content: &str) -> DeltaSpecification {
let normalized = normalize_line_endings(content);
let lines: Vec<&str> = normalized.split('\n').collect();
let sections = split_top_level_sections(&lines);
let added_section = find_section(§ions, "added requirements");
let modified_section = find_section(§ions, "modified requirements");
let removed_section = find_section(§ions, "removed requirements");
let renamed_section = find_section(§ions, "renamed requirements");
DeltaSpecification {
added: added_section
.map(|section| parse_requirement_blocks(&lines, section.body_start, section.body_end))
.unwrap_or_default(),
modified: modified_section
.map(|section| parse_requirement_blocks(&lines, section.body_start, section.body_end))
.unwrap_or_default(),
removed: removed_section
.map(|section| parse_removed_names(&lines[section.body_start..section.body_end]))
.unwrap_or_default(),
renamed: renamed_section
.map(|section| parse_renamed_pairs(&lines[section.body_start..section.body_end]))
.unwrap_or_default(),
presence: SectionPresence {
added: added_section.is_some(),
modified: modified_section.is_some(),
removed: removed_section.is_some(),
renamed: renamed_section.is_some(),
},
}
}
struct SectionSpan {
title_lowercase: String,
body_start: usize,
body_end: usize,
}
fn normalize_line_endings(content: &str) -> String {
content.replace("\r\n", "\n").replace('\r', "\n")
}
fn section_title(line: &str) -> Option<&str> {
let rest = line.strip_prefix("##")?;
if rest.starts_with('#') || !rest.starts_with(char::is_whitespace) {
return None;
}
let title = rest.trim();
if title.is_empty() { None } else { Some(title) }
}
fn requirement_header_name(line: &str) -> Option<String> {
header_name(line, "###", "requirement:")
}
fn scenario_header_name(line: &str) -> Option<String> {
header_name(line, "####", "scenario:")
}
fn header_name(line: &str, marker: &str, keyword: &str) -> Option<String> {
let rest = line.strip_prefix(marker)?;
if rest.starts_with('#') {
return None;
}
let rest = rest.trim_start();
let rest = strip_prefix_ignore_ascii_case(rest, keyword)?;
if rest.is_empty() {
return None;
}
Some(rest.trim().to_string())
}
fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
let head = text.get(..prefix.len())?;
if head.eq_ignore_ascii_case(prefix) {
Some(&text[prefix.len()..])
} else {
None
}
}
fn split_top_level_sections(lines: &[&str]) -> Vec<SectionSpan> {
let mut headers: Vec<(usize, String)> = Vec::new();
for (index, line) in lines.iter().enumerate() {
if let Some(title) = section_title(line) {
headers.push((index, title.to_ascii_lowercase()));
}
}
let mut sections = Vec::with_capacity(headers.len());
for (position, (index, title_lowercase)) in headers.iter().enumerate() {
let body_end = headers
.get(position + 1)
.map_or(lines.len(), |(next_index, _)| *next_index);
sections.push(SectionSpan {
title_lowercase: title_lowercase.clone(),
body_start: index + 1,
body_end,
});
}
sections
}
fn find_section<'a>(sections: &'a [SectionSpan], title_lowercase: &str) -> Option<&'a SectionSpan> {
sections
.iter()
.find(|section| section.title_lowercase == title_lowercase)
}
fn parse_requirement_blocks(lines: &[&str], start: usize, end: usize) -> Vec<Requirement> {
let mut requirements = Vec::new();
let mut cursor = start;
while cursor < end {
let Some(name) = requirement_header_name(lines[cursor]) else {
cursor += 1;
continue;
};
let header_index = cursor;
cursor += 1;
let body_start = cursor;
while cursor < end
&& requirement_header_name(lines[cursor]).is_none()
&& section_title(lines[cursor]).is_none()
{
cursor += 1;
}
let body = &lines[body_start..cursor];
let raw = std::iter::once(lines[header_index])
.chain(body.iter().copied())
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string();
requirements.push(Requirement {
name,
text: requirement_text(body),
scenarios: parse_scenarios(body, body_start),
raw,
line: header_index + 1,
});
}
requirements
}
fn requirement_text(body: &[&str]) -> Option<String> {
for line in body {
if line.trim_start().starts_with('#') {
return None;
}
let trimmed = line.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
}
fn parse_scenarios(body: &[&str], body_start: usize) -> Vec<Scenario> {
let mut scenarios = Vec::new();
let mut cursor = 0;
while cursor < body.len() {
let Some(name) = scenario_header_name(body[cursor]) else {
cursor += 1;
continue;
};
let header_index = cursor;
cursor += 1;
let content_start = cursor;
while cursor < body.len() && !ends_scenario_body(body[cursor]) {
cursor += 1;
}
let content = body[content_start..cursor].join("\n").trim().to_string();
scenarios.push(Scenario {
name,
body: content,
line: body_start + header_index + 1,
});
}
scenarios
}
fn ends_scenario_body(line: &str) -> bool {
if scenario_header_name(line).is_some() {
return true;
}
let hashes = line.bytes().take_while(|&byte| byte == b'#').count();
(1..=4).contains(&hashes) && line[hashes..].starts_with(char::is_whitespace)
}
fn parse_removed_names(lines: &[&str]) -> Vec<String> {
let mut names = Vec::new();
for line in lines {
if let Some(name) = requirement_header_name(line) {
names.push(name);
continue;
}
if let Some(name) = bullet_requirement_name(line) {
names.push(name);
}
}
names
}
fn bullet_requirement_name(line: &str) -> Option<String> {
let rest = line.trim_start().strip_prefix('-')?.trim_start();
let rest = rest.strip_prefix('`').unwrap_or(rest);
let name = requirement_header_name(rest.trim_end())?;
Some(name.trim_end_matches('`').trim().to_string())
}
fn parse_renamed_pairs(lines: &[&str]) -> Vec<Rename> {
let mut pairs = Vec::new();
let mut pending_from: Option<String> = None;
for line in lines {
if let Some(name) = labeled_requirement_name(line, "FROM:") {
pending_from = Some(name);
} else if let Some(name) = labeled_requirement_name(line, "TO:")
&& let Some(from) = pending_from.take()
{
pairs.push(Rename { from, to: name });
}
}
pairs
}
fn labeled_requirement_name(line: &str, label: &str) -> Option<String> {
let rest = line.trim_start();
let rest = rest.strip_prefix('-').map_or(rest, str::trim_start);
let rest = rest.strip_prefix(label)?.trim_start();
let rest = rest.strip_prefix('`').unwrap_or(rest);
let name = requirement_header_name(rest.trim_end())?;
Some(name.trim_end_matches('`').trim().to_string())
}