use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum FormatKind {
#[default]
Speckit,
Openspec,
Kiro,
}
impl FormatKind {
pub const ALL: [Self; 3] = [Self::Speckit, Self::Openspec, Self::Kiro];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Speckit => "speckit",
Self::Openspec => "openspec",
Self::Kiro => "kiro",
}
}
#[must_use]
pub const fn title(self) -> &'static str {
match self {
Self::Speckit => "Spec Kit",
Self::Openspec => "OpenSpec",
Self::Kiro => "Kiro",
}
}
}
impl FormatKind {
#[must_use]
pub fn is_reference(&self) -> bool {
*self == Self::Speckit
}
}
impl fmt::Display for FormatKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
impl FromStr for FormatKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::ALL
.into_iter()
.find(|k| k.name() == s)
.ok_or_else(|| format!("`{s}` is not a format (speckit, openspec or kiro)"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SourceRange {
pub path: String,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Scenario {
pub key: String,
pub raw: String,
pub source: SourceRange,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ScenarioRef {
pub id: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Requirement {
pub key: String,
pub title: String,
pub text: String,
pub scenarios: Vec<Scenario>,
pub source: SourceRange,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct OpenSpecMeta {
pub schema: String,
pub context: Option<String>,
pub unit_kind: String,
pub requirements: Vec<DeltaRequirement>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct DeltaRequirement {
pub capability: String,
pub operation: String,
pub name: String,
pub scenarios: Vec<String>,
pub path: String,
pub line: u32,
}
#[must_use]
pub fn scenario_links(line: &str) -> Vec<String> {
let mut in_code = false;
let plain: String = line
.chars()
.map(|c| {
if c == '`' {
in_code = !in_code;
' '
} else if in_code {
' '
} else {
c
}
})
.collect();
const KEY: &[u8] = b"(scenarios";
let bytes = plain.as_bytes();
let starts = (0..bytes.len().saturating_sub(KEY.len() - 1))
.filter(|&i| bytes[i..i + KEY.len()].eq_ignore_ascii_case(KEY));
let mut out: Vec<String> = Vec::new();
for i in starts {
let rest = &plain[i + KEY.len()..];
let Some(end) = rest.find(')') else { continue };
for word in
rest[..end].split(|c: char| c == ',' || c == '&' || c == ':' || c.is_whitespace())
{
let id = word.to_uppercase();
if is_scenario_id(&id) && !out.contains(&id) {
out.push(id);
}
}
}
out
}
fn is_scenario_id(s: &str) -> bool {
let Some((head, n)) = s.split_once('-') else {
return false;
};
let letters = head.chars().take_while(char::is_ascii_alphabetic).count();
letters > 0
&& head.len() > letters
&& head[letters..].chars().all(|c| c.is_ascii_digit())
&& !n.is_empty()
&& n.chars().all(|c| c.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_task_names_its_scenarios_in_its_line() {
assert_eq!(
scenario_links("Add validation in src/p.py (scenarios US3-1, us3-2 and US3-1)"),
["US3-1", "US3-2"]
);
assert_eq!(
scenario_links("Do X (Scenarios: US1-1 & US1-3)"),
["US1-1", "US1-3"]
);
assert!(scenario_links("Write `(scenarios US1-1)` in the docs").is_empty());
assert!(scenario_links("Cover the scenarios US1-1").is_empty());
assert!(scenario_links("(scenarios US1, 3-1, US-1, US1-)").is_empty());
assert_eq!(
scenario_links("İİİ (scenarios US1-1, US1-2)"),
["US1-1", "US1-2"]
);
assert_eq!(scenario_links("İİ (scenarios ü US1-1)"), ["US1-1"]);
assert_eq!(
scenario_links("\u{212A}\u{212A} (SCENARIOS us2-3)"),
["US2-3"]
);
}
proptest::proptest! {
#[test]
fn links_written_read_back(ids in proptest::collection::vec((1u8..20, 1u8..20), 1..6)) {
let mut want: Vec<String> = Vec::new();
for (s, k) in &ids {
let id = format!("US{s}-{k}");
if !want.contains(&id) {
want.push(id);
}
}
let line = format!("Implement it in src/x.rs (scenarios {})", want.join(", "));
proptest::prop_assert_eq!(scenario_links(&line), want);
}
}
#[test]
fn formats_parse_and_serialize_by_name() {
for k in FormatKind::ALL {
assert_eq!(k.name().parse::<FormatKind>(), Ok(k));
assert_eq!(serde_json::to_value(k).unwrap_or_default(), k.name());
}
let err = "bogus".parse::<FormatKind>().unwrap_err();
assert_eq!(err, "`bogus` is not a format (speckit, openspec or kiro)");
}
}