Skip to main content

speccoder_core/
spec.rs

1//! The format-neutral view of spec-driven work: which format
2//! a project is written in, and the requirements and acceptance scenarios a
3//! unit of work states. The Spec Kit and OpenSpec adapters produce it;
4//! the judge reads only it.
5
6use std::fmt;
7use std::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10
11/// A spec-driven format.
12#[derive(
13    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum FormatKind {
17    /// GitHub Spec Kit (`.specify/`, `specs/NNN-name/`).
18    #[default]
19    Speckit,
20    /// OpenSpec (`openspec/changes/<id>/`).
21    Openspec,
22    /// Kiro and cc-sdd (`.kiro/specs/<name>/`).
23    Kiro,
24}
25
26impl FormatKind {
27    /// Every format, in the order detection reports them.
28    pub const ALL: [Self; 3] = [Self::Speckit, Self::Openspec, Self::Kiro];
29
30    /// The name in `--format` and `[spec] format`.
31    #[must_use]
32    pub const fn name(self) -> &'static str {
33        match self {
34            Self::Speckit => "speckit",
35            Self::Openspec => "openspec",
36            Self::Kiro => "kiro",
37        }
38    }
39
40    /// The name a person reads.
41    #[must_use]
42    pub const fn title(self) -> &'static str {
43        match self {
44            Self::Speckit => "Spec Kit",
45            Self::Openspec => "OpenSpec",
46            Self::Kiro => "Kiro",
47        }
48    }
49}
50
51impl FormatKind {
52    /// Whether this is the reference format (Spec Kit).
53    #[must_use]
54    pub fn is_reference(&self) -> bool {
55        *self == Self::Speckit
56    }
57}
58
59impl fmt::Display for FormatKind {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(self.name())
62    }
63}
64
65impl FromStr for FormatKind {
66    type Err = String;
67
68    fn from_str(s: &str) -> Result<Self, Self::Err> {
69        Self::ALL
70            .into_iter()
71            .find(|k| k.name() == s)
72            .ok_or_else(|| format!("`{s}` is not a format (speckit, openspec or kiro)"))
73    }
74}
75
76/// Where a part of an artifact is: its file and byte range.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
78pub struct SourceRange {
79    /// Path relative to the project root.
80    pub path: String,
81    /// First byte.
82    pub start: usize,
83    /// One past the last byte.
84    pub end: usize,
85}
86
87/// One acceptance scenario of a requirement.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89pub struct Scenario {
90    /// Stable within the unit: `US1/AC2`, `greeting/Default greeting/No name given`, `1.2`.
91    pub key: String,
92    /// Its text as written.
93    pub raw: String,
94    /// Where it is.
95    pub source: SourceRange,
96}
97
98/// A scenario a task's acceptance tests cite: `US1-2` (Spec Kit), or
99/// `capability: requirement / scenario` (OpenSpec), with the spec's words.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
101pub struct ScenarioRef {
102    /// Stable within the unit.
103    pub id: String,
104    /// The scenario as written in the spec.
105    pub text: String,
106}
107
108/// One requirement (a Spec Kit user story, an OpenSpec requirement, a Kiro
109/// requirement) and its scenarios.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct Requirement {
112    /// Stable within the unit: `US1`, `greeting/Default greeting`, `3`.
113    pub key: String,
114    /// Its title.
115    pub title: String,
116    /// Its whole text.
117    pub text: String,
118    /// Its acceptance scenarios, in document order.
119    pub scenarios: Vec<Scenario>,
120    /// Where it is.
121    pub source: SourceRange,
122}
123
124/// What an OpenSpec project adds to its snapshot.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
126pub struct OpenSpecMeta {
127    /// The workflow schema (`spec-driven` is the one speccoder runs).
128    pub schema: String,
129    /// `context` of `openspec/config.yaml`, if any.
130    pub context: Option<String>,
131    /// What a unit of work is called.
132    pub unit_kind: String,
133    /// The selected change's delta requirements, in file order.
134    pub requirements: Vec<DeltaRequirement>,
135}
136
137/// One requirement of a change's spec delta.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
139pub struct DeltaRequirement {
140    /// The capability (`openspec/specs/<capability>/spec.md`).
141    pub capability: String,
142    /// `added`, `modified`, `removed` or `renamed`.
143    pub operation: String,
144    /// The requirement's name (the new name for a rename).
145    pub name: String,
146    /// Its scenarios' names.
147    pub scenarios: Vec<String>,
148    /// The delta file, relative to the project root.
149    pub path: String,
150    /// Its heading's line.
151    pub line: u32,
152}
153
154/// The scenario ids a task's line names — `(scenarios US3-1, US3-2)`, any
155/// case, separated by commas, spaces, `and` or `&`, or cited as Spec Kit's
156/// converge cites them, `US3/AC2` — upper-cased, in written order, once
157/// each. Inline code is quoted text, never a link.
158#[must_use]
159pub fn scenario_links(line: &str) -> Vec<String> {
160    let mut in_code = false;
161    let plain: String = line
162        .chars()
163        .map(|c| {
164            if c == '`' {
165                in_code = !in_code;
166                ' '
167            } else if in_code {
168                ' '
169            } else {
170                c
171            }
172        })
173        .collect();
174    // Matched on the text itself, ASCII case folded: lower-casing other
175    // characters changes their byte length (`İ`), so its offsets are not
176    // the text's.
177    const KEY: &[u8] = b"(scenarios";
178    let bytes = plain.as_bytes();
179    let starts = (0..bytes.len().saturating_sub(KEY.len() - 1))
180        .filter(|&i| bytes[i..i + KEY.len()].eq_ignore_ascii_case(KEY));
181    let mut found: Vec<(usize, String)> = Vec::new();
182    for i in starts {
183        let rest = &plain[i + KEY.len()..];
184        let Some(end) = rest.find(')') else { continue };
185        let mut at = i + KEY.len();
186        for word in
187            rest[..end].split(|c: char| c == ',' || c == '&' || c == ':' || c.is_whitespace())
188        {
189            let id = word.to_uppercase();
190            if is_scenario_id(&id) {
191                found.push((at, id));
192            }
193            at += word.len() + 1;
194        }
195    }
196    found.extend(cited(&plain));
197    found.sort_by_key(|(at, _)| *at);
198    let mut out: Vec<String> = Vec::new();
199    for (_, id) in found {
200        if !out.contains(&id) {
201            out.push(id);
202        }
203    }
204    out
205}
206
207/// `US3/AC2` citations, as `US3-2`, with where each starts: letters and
208/// digits, `/AC`, digits, standing apart from the words around them.
209fn cited(plain: &str) -> Vec<(usize, String)> {
210    let bytes = plain.as_bytes();
211    let mut out = Vec::new();
212    let mut i = 0;
213    while let Some(off) = plain[i..].find(['/']) {
214        let slash = i + off;
215        i = slash + 1;
216        let head_start = bytes[..slash]
217            .iter()
218            .rposition(|b| !b.is_ascii_alphanumeric())
219            .map_or(0, |p| p + 1);
220        let head = &plain[head_start..slash];
221        let tail = &bytes[slash + 1..];
222        if tail.len() < 3 || !tail[..2].eq_ignore_ascii_case(b"AC") {
223            continue;
224        }
225        let digits = tail[2..].iter().take_while(|b| b.is_ascii_digit()).count();
226        let after = tail.get(2 + digits);
227        if digits == 0 || after.is_some_and(u8::is_ascii_alphanumeric) {
228            continue;
229        }
230        let k = &plain[slash + 3..slash + 3 + digits];
231        let id = format!("{}-{k}", head.to_ascii_uppercase());
232        if is_scenario_id(&id) {
233            out.push((head_start, id));
234        }
235    }
236    out
237}
238
239/// `US3-1`: letters, digits, `-`, digits.
240fn is_scenario_id(s: &str) -> bool {
241    let Some((head, n)) = s.split_once('-') else {
242        return false;
243    };
244    let letters = head.chars().take_while(char::is_ascii_alphabetic).count();
245    letters > 0
246        && head.len() > letters
247        && head[letters..].chars().all(|c| c.is_ascii_digit())
248        && !n.is_empty()
249        && n.chars().all(|c| c.is_ascii_digit())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn a_task_names_its_scenarios_in_its_line() {
258        assert_eq!(
259            scenario_links("Add validation in src/p.py (scenarios US3-1, us3-2 and US3-1)"),
260            ["US3-1", "US3-2"]
261        );
262        assert_eq!(
263            scenario_links("Do X (Scenarios: US1-1 & US1-3)"),
264            ["US1-1", "US1-3"]
265        );
266        assert!(scenario_links("Write `(scenarios US1-1)` in the docs").is_empty());
267        assert!(scenario_links("Cover the scenarios US1-1").is_empty());
268        assert!(scenario_links("(scenarios US1, 3-1, US-1, US1-)").is_empty());
269        // Characters whose lower case is longer or shorter keep the offsets.
270        assert_eq!(
271            scenario_links("İİİ (scenarios US1-1, US1-2)"),
272            ["US1-1", "US1-2"]
273        );
274        assert_eq!(scenario_links("İİ (scenarios ü US1-1)"), ["US1-1"]);
275        assert_eq!(
276            scenario_links("\u{212A}\u{212A} (SCENARIOS us2-3)"),
277            ["US2-3"]
278        );
279    }
280
281    #[test]
282    fn a_converge_citation_names_a_scenario() {
283        assert_eq!(
284            scenario_links("Route tables per FR-008, US3/AC2, plan: single table (partial)"),
285            ["US3-2"]
286        );
287        assert_eq!(
288            scenario_links("Do it per us1/ac2 and (scenarios US1-1, US1-2)"),
289            ["US1-2", "US1-1"]
290        );
291        assert!(scenario_links("Read `US1/AC2` and src/AC2 and US1/ACK and /AC1").is_empty());
292        assert_eq!(scenario_links("İ US2/AC10."), ["US2-10"]);
293    }
294
295    proptest::proptest! {
296        #[test]
297        fn links_written_read_back(ids in proptest::collection::vec((1u8..20, 1u8..20), 1..6)) {
298            let mut want: Vec<String> = Vec::new();
299            for (s, k) in &ids {
300                let id = format!("US{s}-{k}");
301                if !want.contains(&id) {
302                    want.push(id);
303                }
304            }
305            let line = format!("Implement it in src/x.rs (scenarios {})", want.join(", "));
306            proptest::prop_assert_eq!(scenario_links(&line), want);
307        }
308    }
309
310    #[test]
311    fn formats_parse_and_serialize_by_name() {
312        for k in FormatKind::ALL {
313            assert_eq!(k.name().parse::<FormatKind>(), Ok(k));
314            assert_eq!(serde_json::to_value(k).unwrap_or_default(), k.name());
315        }
316        let err = "bogus".parse::<FormatKind>().unwrap_err();
317        assert_eq!(err, "`bogus` is not a format (speckit, openspec or kiro)");
318    }
319}