Skip to main content

speccoder_openspec/
delta.rs

1//! Spec deltas of a change (`openspec/changes/<id>/specs/<capability>/spec.md`)
2//! and the requirement names of the main specs (`openspec/specs/**/spec.md`).
3//!
4//! OpenSpec returns scenarios only as raw text; speccoder parses them itself
5//! because they are the acceptance examples.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use speccoder_core::snapshot::{Diagnostic, Severity};
10
11/// What a delta section does to the main spec.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Operation {
14    /// `## ADDED Requirements`.
15    Added,
16    /// `## MODIFIED Requirements`: replaces the whole requirement.
17    Modified,
18    /// `## REMOVED Requirements`.
19    Removed,
20    /// `## RENAMED Requirements`: `FROM:` / `TO:` pairs.
21    Renamed,
22}
23
24impl Operation {
25    /// Its lowercase name.
26    #[must_use]
27    pub const fn name(self) -> &'static str {
28        match self {
29            Self::Added => "added",
30            Self::Modified => "modified",
31            Self::Removed => "removed",
32            Self::Renamed => "renamed",
33        }
34    }
35}
36
37/// A `#### Scenario:` block.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Scenario {
40    /// Its name.
41    pub name: String,
42    /// `GIVEN` lines (an `AND` after one is added to it).
43    pub given: Vec<String>,
44    /// `WHEN` lines.
45    pub when: Vec<String>,
46    /// `THEN` lines.
47    pub then: Vec<String>,
48    /// The block as written.
49    pub raw: String,
50    /// 1-based line of its heading.
51    pub line: u32,
52    /// Byte range in the delta file.
53    pub range: [usize; 2],
54}
55
56/// A requirement of an ADDED, MODIFIED or REMOVED section.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Requirement {
59    /// The section it is in.
60    pub operation: Operation,
61    /// Its name as written.
62    pub name: String,
63    /// The block as written (heading through the last line before the next
64    /// heading of the same or a higher level).
65    pub text: String,
66    /// Its scenarios.
67    pub scenarios: Vec<Scenario>,
68    /// REMOVED: a `**Reason**` line is present.
69    pub reason: bool,
70    /// REMOVED: a `**Migration**` line is present.
71    pub migration: bool,
72    /// 1-based line of its heading.
73    pub line: u32,
74    /// Byte range in the delta file.
75    pub range: [usize; 2],
76}
77
78/// A RENAMED pair.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Rename {
81    /// The old name.
82    pub from: String,
83    /// The new name.
84    pub to: String,
85    /// 1-based line of `FROM:`.
86    pub line: u32,
87}
88
89/// One delta file.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Delta {
92    /// The capability, from the path between `specs/` and `/spec.md`.
93    pub capability: String,
94    /// The file, relative to the project root.
95    pub path: String,
96    /// The whole file.
97    pub text: String,
98    /// It has a `## Purpose` section.
99    pub purpose: bool,
100    /// ADDED, MODIFIED and REMOVED requirements, in file order.
101    pub requirements: Vec<Requirement>,
102    /// RENAMED pairs.
103    pub renames: Vec<Rename>,
104}
105
106/// OpenSpec's name matching: case-insensitive, whitespace collapsed.
107#[must_use]
108pub fn normalise(name: &str) -> String {
109    name.split_whitespace()
110        .collect::<Vec<_>>()
111        .join(" ")
112        .to_lowercase()
113}
114
115fn line_no(n: usize) -> u32 {
116    u32::try_from(n + 1).unwrap_or(u32::MAX)
117}
118
119/// `## MODIFIED Requirements` → Modified.
120fn section(title: &str) -> Option<Operation> {
121    let lower = normalise(title);
122    let op = lower.strip_suffix(" requirements")?;
123    match op {
124        "added" => Some(Operation::Added),
125        "modified" => Some(Operation::Modified),
126        "removed" => Some(Operation::Removed),
127        "renamed" => Some(Operation::Renamed),
128        _ => None,
129    }
130}
131
132/// `### Requirement: Name` → Name (case-insensitive keyword).
133fn requirement_name(rest: &str) -> Option<&str> {
134    let head = rest.get(..12)?;
135    head.eq_ignore_ascii_case("requirement:")
136        .then(|| rest[12..].trim())
137}
138
139/// `- **WHEN** x` / `- WHEN x` → ("when", "x").
140fn step(line: &str) -> Option<(String, String)> {
141    let item = line.trim_start().strip_prefix(['-', '*'])?.trim_start();
142    let item = item.strip_prefix("**").unwrap_or(item);
143    let word: String = item.chars().take_while(char::is_ascii_alphabetic).collect();
144    let kind = word.to_lowercase();
145    if !matches!(kind.as_str(), "given" | "when" | "then" | "and") {
146        return None;
147    }
148    let rest = item[word.len()..].trim_start_matches('*').trim();
149    Some((kind, rest.to_owned()))
150}
151
152/// `- FROM: \`### Requirement: Old\`` → ("from", "Old").
153fn rename_line(line: &str) -> Option<(&'static str, String)> {
154    let item = line
155        .trim_start()
156        .strip_prefix(['-', '*'])
157        .unwrap_or(line)
158        .trim();
159    let (key, rest) = item.split_once(':')?;
160    let key = match key.trim().trim_matches('*').to_lowercase().as_str() {
161        "from" => "from",
162        "to" => "to",
163        _ => return None,
164    };
165    let rest = rest.trim().trim_matches('`').trim();
166    let name = rest
167        .strip_prefix("###")
168        .map(str::trim)
169        .and_then(requirement_name)
170        .unwrap_or(rest);
171    Some((key, name.to_owned()))
172}
173
174/// Parses the delta at `path` (relative to the project root) for
175/// `capability`, with its problems.
176#[must_use]
177pub fn parse(capability: &str, path: &str, text: &str) -> (Delta, Vec<Diagnostic>) {
178    let mut diags = Vec::new();
179    let mut lines = Vec::new();
180    let mut at = 0;
181    for l in text.split_inclusive('\n') {
182        lines.push((at, l.trim_end_matches(['\n', '\r'])));
183        at += l.len();
184    }
185    let mut delta = Delta {
186        capability: capability.to_owned(),
187        path: path.to_owned(),
188        text: text.to_owned(),
189        purpose: false,
190        requirements: Vec::new(),
191        renames: Vec::new(),
192    };
193    let mut op: Option<Operation> = None;
194    let mut pending_from: Option<(String, u32)> = None;
195    // Where the open requirement and scenario started (line index).
196    let mut req_start: Option<usize> = None;
197    let mut sc_start: Option<usize> = None;
198    let mut last_kind = String::new();
199
200    let close_scenario = |delta: &mut Delta, from: usize, to: usize| {
201        let (start, _) = lines[from];
202        let end = lines.get(to).map_or(text.len(), |(s, _)| *s);
203        if let Some(sc) = delta
204            .requirements
205            .last_mut()
206            .and_then(|r| r.scenarios.last_mut())
207        {
208            sc.raw = text[start..end].trim_end().to_owned();
209            sc.range = [start, start + sc.raw.len()];
210        }
211    };
212    let close_requirement = |delta: &mut Delta, from: usize, to: usize| {
213        let (start, _) = lines[from];
214        let end = lines.get(to).map_or(text.len(), |(s, _)| *s);
215        if let Some(r) = delta.requirements.last_mut() {
216            r.text = text[start..end].trim_end().to_owned();
217            r.range = [start, start + r.text.len()];
218        }
219    };
220
221    for (i, (_, line)) in lines.iter().enumerate() {
222        let hashes = line.bytes().take_while(|b| *b == b'#').count();
223        let heading = hashes > 0 && line[hashes..].starts_with(' ');
224        let rest = if heading { line[hashes..].trim() } else { "" };
225        if heading
226            && hashes <= 4
227            && let Some(s) = sc_start.take()
228        {
229            close_scenario(&mut delta, s, i);
230        }
231        if heading
232            && hashes <= 3
233            && let Some(s) = req_start.take()
234        {
235            close_requirement(&mut delta, s, i);
236        }
237        if heading && hashes <= 2 {
238            op = section(rest);
239            if normalise(rest) == "purpose" {
240                delta.purpose = true;
241            }
242            continue;
243        }
244        if heading && hashes == 3 {
245            if let Some(name) = requirement_name(rest) {
246                match op {
247                    Some(Operation::Renamed) | None => {}
248                    Some(operation) => {
249                        delta.requirements.push(Requirement {
250                            operation,
251                            name: name.to_owned(),
252                            text: String::new(),
253                            scenarios: Vec::new(),
254                            reason: false,
255                            migration: false,
256                            line: line_no(i),
257                            range: [0, 0],
258                        });
259                        req_start = Some(i);
260                    }
261                }
262            } else if rest.to_lowercase().starts_with("scenario:") {
263                diags.push(
264                    Diagnostic::new(
265                        Severity::Error,
266                        "scenario_wrong_level",
267                        "a scenario heading needs four hashes (`#### Scenario:`)",
268                    )
269                    .at(path)
270                    .line(line_no(i))
271                    .hint("write `#### Scenario: …`; OpenSpec ignores it otherwise"),
272                );
273            }
274            continue;
275        }
276        if heading && hashes == 4 {
277            if let Some(name) = rest.strip_prefix("Scenario:")
278                && let Some(r) = delta
279                    .requirements
280                    .last_mut()
281                    .filter(|_| req_start.is_some())
282            {
283                r.scenarios.push(Scenario {
284                    name: name.trim().to_owned(),
285                    given: Vec::new(),
286                    when: Vec::new(),
287                    then: Vec::new(),
288                    raw: String::new(),
289                    line: line_no(i),
290                    range: [0, 0],
291                });
292                sc_start = Some(i);
293                last_kind.clear();
294            }
295            continue;
296        }
297        if op == Some(Operation::Renamed) {
298            match rename_line(line) {
299                Some(("from", name)) => pending_from = Some((name, line_no(i))),
300                Some((_, to)) => {
301                    if let Some((from, line)) = pending_from.take() {
302                        delta.renames.push(Rename { from, to, line });
303                    }
304                }
305                None => {}
306            }
307            continue;
308        }
309        if req_start.is_some()
310            && let Some(r) = delta.requirements.last_mut()
311        {
312            let lower = line.to_lowercase();
313            if lower.contains("**reason**") {
314                r.reason = true;
315            }
316            if lower.contains("**migration**") {
317                r.migration = true;
318            }
319        }
320        if sc_start.is_some()
321            && let Some((kind, text)) = step(line)
322        {
323            let kind = if kind == "and" {
324                last_kind.clone()
325            } else {
326                kind
327            };
328            if let Some(sc) = delta
329                .requirements
330                .last_mut()
331                .and_then(|r| r.scenarios.last_mut())
332            {
333                match kind.as_str() {
334                    "given" => sc.given.push(text),
335                    "when" => sc.when.push(text),
336                    "then" => sc.then.push(text),
337                    _ => {}
338                }
339            }
340            last_kind = kind;
341        }
342    }
343    if let Some(s) = sc_start {
344        close_scenario(&mut delta, s, lines.len());
345    }
346    if let Some(s) = req_start {
347        close_requirement(&mut delta, s, lines.len());
348    }
349    (delta, diags)
350}
351
352/// Requirement names (normalised) per capability of the main specs under
353/// `specs_dir` (`openspec/specs`), each file's text given by `read`.
354#[must_use]
355pub fn main_requirements(files: &[(String, String)]) -> BTreeMap<String, BTreeSet<String>> {
356    let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
357    for (capability, text) in files {
358        let names = out.entry(capability.clone()).or_default();
359        for line in text.lines() {
360            if let Some(rest) = line.strip_prefix("### ")
361                && let Some(name) = requirement_name(rest.trim())
362            {
363                names.insert(normalise(name));
364            }
365        }
366    }
367    out
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn steps_and_renames() {
376        assert_eq!(
377            step("- **GIVEN** `X=1`"),
378            Some(("given".to_owned(), "`X=1`".to_owned()))
379        );
380        assert_eq!(
381            step("- WHEN it runs"),
382            Some(("when".to_owned(), "it runs".to_owned()))
383        );
384        assert_eq!(step("- something else"), None);
385        assert_eq!(
386            rename_line("- FROM: `### Requirement: Greet a named person`"),
387            Some(("from", "Greet a named person".to_owned()))
388        );
389        assert_eq!(normalise("  Greet   By name "), "greet by name");
390    }
391}