use speccoder_core::snapshot::{Diagnostic, Severity};
use speccoder_core::spec::{Requirement, Scenario, ScenarioRef, SourceRange};
#[must_use]
pub fn story_section(spec: &str, label: &str) -> String {
let Some(n) = label.strip_prefix("US") else {
return String::new();
};
let wanted = format!("user story {n}");
let mut out = Vec::new();
let mut level = None;
for line in spec.lines() {
let hashes = line.bytes().take_while(|b| *b == b'#').count();
let is_heading = hashes > 0 && line[hashes..].starts_with(' ');
if let Some(l) = level {
if is_heading && hashes <= l {
break;
}
out.push(line);
} else if is_heading {
let text = line[hashes..].trim().to_lowercase();
let names_it = text
.strip_prefix(&wanted)
.is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_digit()));
if names_it {
level = Some(hashes);
out.push(line);
}
}
}
out.join("\n")
}
#[must_use]
pub fn requirements(path: &str, text: &str) -> (Vec<Requirement>, Vec<Diagnostic>) {
let mut lines = Vec::new();
let mut at = 0;
for line in text.split_inclusive('\n') {
lines.push((at, line));
at += line.len();
}
let heading = |line: &str| {
let hashes = line.bytes().take_while(|b| *b == b'#').count();
(hashes > 0 && line[hashes..].starts_with(' ')).then_some(hashes)
};
let range = |start: usize, end: usize| SourceRange {
path: path.to_owned(),
start,
end,
};
let mut found: Vec<Requirement> = Vec::new();
let mut diags = Vec::new();
let mut i = 0;
while i < lines.len() {
let (start, line) = lines[i];
let Some(level) = heading(line) else {
i += 1;
continue;
};
let Some((n, title)) = story_heading(line[level..].trim()) else {
i += 1;
continue;
};
let mut j = i + 1;
while j < lines.len() && heading(lines[j].1).is_none_or(|l| l > level) {
j += 1;
}
let end = lines.get(j).map_or(text.len(), |(s, _)| *s);
let key = format!("US{n}");
let scenarios = lines[i + 1..j]
.iter()
.filter(|(_, l)| is_scenario(l))
.enumerate()
.map(|(m, (s, l))| Scenario {
key: format!("{key}/AC{}", m + 1),
raw: l.trim_end().to_owned(),
source: range(*s, s + l.trim_end().len()),
})
.collect();
if found.iter().any(|r| r.key == key) {
diags.push(
Diagnostic::new(
Severity::Warning,
"spec.duplicate-requirement",
format!("{key} appears more than once; the first is used"),
)
.at(path),
);
} else {
found.push(Requirement {
key,
title,
text: text[start..end].trim_end().to_owned(),
scenarios,
source: range(start, end),
});
}
i = j;
}
(found, diags)
}
#[must_use]
pub fn scenario_refs(spec: &str, label: Option<&str>) -> Vec<ScenarioRef> {
let (stories, _) = requirements("spec.md", spec);
let mut out = Vec::new();
for story in stories.iter().filter(|r| label.is_none_or(|l| l == r.key)) {
if story.scenarios.is_empty() {
if let Some(line) = story
.text
.lines()
.find(|l| l.to_lowercase().contains("**acceptance"))
{
out.push(ScenarioRef {
id: format!("{}-1", story.key),
text: line.trim().to_owned(),
});
}
continue;
}
for (k, sc) in story.scenarios.iter().enumerate() {
let text = sc.raw.trim_start();
let digits = text.bytes().take_while(u8::is_ascii_digit).count();
out.push(ScenarioRef {
id: format!("{}-{}", story.key, k + 1),
text: text[digits..].trim_start_matches('.').trim().to_owned(),
});
}
}
out
}
fn story_heading(text: &str) -> Option<(u32, String)> {
let rest = text
.get(..11)
.filter(|p| p.eq_ignore_ascii_case("user story "))
.map(|_| &text[11..])?;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let n = digits.parse().ok()?;
let rest = rest[digits.len()..].trim_start();
let title = rest
.strip_prefix(['-', '–', '—', ':'])
.unwrap_or(rest)
.trim();
let title = match title.rfind("(Priority") {
Some(p) => title[..p].trim_end(),
None => title,
};
Some((n, title.to_owned()))
}
fn is_scenario(line: &str) -> bool {
let l = line.trim_start();
let digits = l.bytes().take_while(u8::is_ascii_digit).count();
digits > 0 && l[digits..].starts_with(". ") && l.to_lowercase().contains("**given**")
}
#[cfg(test)]
mod tests {
use super::*;
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";
#[test]
fn story_section_stops_at_the_next_story_and_does_not_confuse_10_with_1() {
let s = story_section(SPEC, "US1");
assert!(s.starts_with("### User Story 1 - Login"));
assert!(s.contains("**Acceptance**: works"));
assert!(!s.contains("Other"));
let s2 = story_section(SPEC, "US2");
assert!(s2.contains("profile text") && !s2.contains("FR-001"));
assert_eq!(story_section(SPEC, "US3"), "");
}
#[test]
fn stories_become_requirements_with_their_scenarios() {
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";
let (reqs, diags) = requirements("specs/001-x/spec.md", text);
assert_eq!(reqs.len(), 2);
let us1 = &reqs[0];
assert_eq!(
(us1.key.as_str(), us1.title.as_str()),
("US1", "Search by name")
);
assert_eq!(us1.text, story_section(text, "US1").trim_end());
assert_eq!(us1.scenarios.len(), 2);
assert_eq!(us1.scenarios[1].key, "US1/AC2");
let s = &us1.scenarios[0].source;
assert_eq!(&text[s.start..s.end], us1.scenarios[0].raw);
assert!(us1.scenarios[0].raw.starts_with("1. **Given** a list"));
assert_eq!(
&text[us1.source.start..us1.source.end],
format!("{}\n\n", us1.text)
);
assert!(reqs[1].scenarios.is_empty() && reqs[1].title == "Export");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, "spec.duplicate-requirement");
}
}