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]
158pub fn scenario_links(line: &str) -> Vec<String> {
159 let mut in_code = false;
160 let plain: String = line
161 .chars()
162 .map(|c| {
163 if c == '`' {
164 in_code = !in_code;
165 ' '
166 } else if in_code {
167 ' '
168 } else {
169 c
170 }
171 })
172 .collect();
173 const KEY: &[u8] = b"(scenarios";
177 let bytes = plain.as_bytes();
178 let starts = (0..bytes.len().saturating_sub(KEY.len() - 1))
179 .filter(|&i| bytes[i..i + KEY.len()].eq_ignore_ascii_case(KEY));
180 let mut out: Vec<String> = Vec::new();
181 for i in starts {
182 let rest = &plain[i + KEY.len()..];
183 let Some(end) = rest.find(')') else { continue };
184 for word in
185 rest[..end].split(|c: char| c == ',' || c == '&' || c == ':' || c.is_whitespace())
186 {
187 let id = word.to_uppercase();
188 if is_scenario_id(&id) && !out.contains(&id) {
189 out.push(id);
190 }
191 }
192 }
193 out
194}
195
196fn is_scenario_id(s: &str) -> bool {
198 let Some((head, n)) = s.split_once('-') else {
199 return false;
200 };
201 let letters = head.chars().take_while(char::is_ascii_alphabetic).count();
202 letters > 0
203 && head.len() > letters
204 && head[letters..].chars().all(|c| c.is_ascii_digit())
205 && !n.is_empty()
206 && n.chars().all(|c| c.is_ascii_digit())
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn a_task_names_its_scenarios_in_its_line() {
215 assert_eq!(
216 scenario_links("Add validation in src/p.py (scenarios US3-1, us3-2 and US3-1)"),
217 ["US3-1", "US3-2"]
218 );
219 assert_eq!(
220 scenario_links("Do X (Scenarios: US1-1 & US1-3)"),
221 ["US1-1", "US1-3"]
222 );
223 assert!(scenario_links("Write `(scenarios US1-1)` in the docs").is_empty());
224 assert!(scenario_links("Cover the scenarios US1-1").is_empty());
225 assert!(scenario_links("(scenarios US1, 3-1, US-1, US1-)").is_empty());
226 assert_eq!(
228 scenario_links("İİİ (scenarios US1-1, US1-2)"),
229 ["US1-1", "US1-2"]
230 );
231 assert_eq!(scenario_links("İİ (scenarios ü US1-1)"), ["US1-1"]);
232 assert_eq!(
233 scenario_links("\u{212A}\u{212A} (SCENARIOS us2-3)"),
234 ["US2-3"]
235 );
236 }
237
238 proptest::proptest! {
239 #[test]
240 fn links_written_read_back(ids in proptest::collection::vec((1u8..20, 1u8..20), 1..6)) {
241 let mut want: Vec<String> = Vec::new();
242 for (s, k) in &ids {
243 let id = format!("US{s}-{k}");
244 if !want.contains(&id) {
245 want.push(id);
246 }
247 }
248 let line = format!("Implement it in src/x.rs (scenarios {})", want.join(", "));
249 proptest::prop_assert_eq!(scenario_links(&line), want);
250 }
251 }
252
253 #[test]
254 fn formats_parse_and_serialize_by_name() {
255 for k in FormatKind::ALL {
256 assert_eq!(k.name().parse::<FormatKind>(), Ok(k));
257 assert_eq!(serde_json::to_value(k).unwrap_or_default(), k.name());
258 }
259 let err = "bogus".parse::<FormatKind>().unwrap_err();
260 assert_eq!(err, "`bogus` is not a format (speckit, openspec or kiro)");
261 }
262}