1use std::fmt;
7use std::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(
13 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum FormatKind {
17 #[default]
19 Speckit,
20 Openspec,
22 Kiro,
24}
25
26impl FormatKind {
27 pub const ALL: [Self; 3] = [Self::Speckit, Self::Openspec, Self::Kiro];
29
30 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
78pub struct SourceRange {
79 pub path: String,
81 pub start: usize,
83 pub end: usize,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89pub struct Scenario {
90 pub key: String,
92 pub raw: String,
94 pub source: SourceRange,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
101pub struct ScenarioRef {
102 pub id: String,
104 pub text: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct Requirement {
112 pub key: String,
114 pub title: String,
116 pub text: String,
118 pub scenarios: Vec<Scenario>,
120 pub source: SourceRange,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
126pub struct OpenSpecMeta {
127 pub schema: String,
129 pub context: Option<String>,
131 pub unit_kind: String,
133 pub requirements: Vec<DeltaRequirement>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
139pub struct DeltaRequirement {
140 pub capability: String,
142 pub operation: String,
144 pub name: String,
146 pub scenarios: Vec<String>,
148 pub path: String,
150 pub line: u32,
152}
153
154#[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 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
207fn 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
239fn 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 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}