use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpecLevel {
Org,
Project,
Capability,
Task,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lang {
Zh,
En,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecMeta {
pub level: SpecLevel,
pub name: String,
pub inherits: Option<String>,
pub lang: Vec<Lang>,
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LintAck {
pub code: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecDocument {
pub meta: SpecMeta,
pub sections: Vec<Section>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lint_acks: Vec<LintAck>,
#[serde(skip)]
pub source_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Section {
Intent {
content: String,
span: Span,
},
Constraints {
items: Vec<Constraint>,
span: Span,
},
Decisions {
items: Vec<String>,
span: Span,
},
Boundaries {
items: Vec<Boundary>,
span: Span,
},
AcceptanceCriteria {
scenarios: Vec<Scenario>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rules: Vec<BehaviorRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
malformed_rules: Vec<MalformedRule>,
span: Span,
},
OutOfScope {
items: Vec<String>,
span: Span,
},
Questions {
items: Vec<String>,
span: Span,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Constraint {
pub text: String,
pub category: ConstraintCategory,
pub span: Span,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstraintCategory {
Must,
MustNot,
Decided,
General,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Boundary {
pub text: String,
pub category: BoundaryCategory,
pub span: Span,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryCategory {
Allow,
Deny,
General,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ScenarioMode {
#[default]
Standard,
Optimize,
}
impl ScenarioMode {
pub fn is_standard(&self) -> bool {
*self == Self::Standard
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReviewMode {
#[default]
Auto,
Human,
}
impl ReviewMode {
pub fn is_auto(&self) -> bool {
*self == Self::Auto
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleScope {
Task(String),
Capability(String),
Project,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuleKey {
pub scope: RuleScope,
pub id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleEventKind {
Created,
Promoted,
Affirmed,
Deprecated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuleEvent {
pub kind: RuleEventKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorRule {
pub key: RuleKey,
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scenario_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<RuleEvent>,
pub span: Span,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MalformedRule {
pub raw: String,
pub span: Span,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scenario {
pub name: String,
pub steps: Vec<Step>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_selector: Option<TestSelector>,
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "ReviewMode::is_auto")]
pub review: ReviewMode,
#[serde(default, skip_serializing_if = "ScenarioMode::is_standard")]
pub mode: ScenarioMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rule: Option<String>,
pub span: Span,
}
impl Scenario {
pub fn is_critical(&self) -> bool {
let has_tag = self.tags.iter().any(|t| t.eq_ignore_ascii_case("critical"));
if has_tag {
return true;
}
let lower = self.name.to_lowercase();
lower.ends_with("(critical)") || lower.ends_with("(critical)")
}
pub fn display_name(&self) -> &str {
let name = self.name.trim_end();
if let Some(idx) = name.rfind('(') {
let suffix = &name[idx..];
if suffix.to_lowercase() == "(critical)" {
return name[..idx].trim_end();
}
}
if let Some(idx) = name.rfind('(') {
let suffix = &name[idx..];
if suffix.to_lowercase() == "(critical)" {
return name[..idx].trim_end();
}
}
name
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TestSelector {
pub filter: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_double: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub targets: Option<String>,
}
impl TestSelector {
pub fn filter_only(filter: impl Into<String>) -> Self {
Self {
filter: filter.into(),
package: None,
level: None,
test_double: None,
targets: None,
}
}
pub fn label(&self) -> String {
match &self.package {
Some(package) => format!("{package}::{}", self.filter),
None => self.filter.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Probe {
Test(TestSelector),
Static(String),
Benchmark {
runner: String,
filter: String,
threshold: String,
},
External {
runner: String,
args: Vec<String>,
},
Inferential,
}
impl Probe {
pub fn from_scenario(scenario: &Scenario) -> Option<Probe> {
scenario.test_selector.clone().map(Probe::Test)
}
pub fn kind_label(&self) -> &'static str {
match self {
Probe::Test(_) => "test",
Probe::Static(_) => "static",
Probe::Benchmark { .. } => "benchmark",
Probe::External { .. } => "external",
Probe::Inferential => "inferential",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepKind {
Given,
When,
Then,
And,
But,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub kind: StepKind,
pub text: String,
pub params: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub table: Vec<Vec<String>>,
pub span: Span,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Span {
pub start_line: usize,
pub end_line: usize,
pub start_col: usize,
pub end_col: usize,
}
impl Span {
pub fn new(start_line: usize, start_col: usize, end_line: usize, end_col: usize) -> Self {
Self {
start_line,
end_line,
start_col,
end_col,
}
}
pub fn line(line: usize) -> Self {
Self {
start_line: line,
end_line: line,
start_col: 0,
end_col: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedSpec {
pub task: SpecDocument,
pub inherited_constraints: Vec<Constraint>,
pub inherited_decisions: Vec<String>,
pub all_scenarios: Vec<Scenario>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod probe_tests {
use super::*;
#[test]
fn test_probe_kind_label_test() {
let p = Probe::Test(TestSelector::filter_only("test_x"));
assert_eq!(p.kind_label(), "test");
}
#[test]
fn test_probe_kind_label_reserved_variants() {
assert_eq!(Probe::Static("x".into()).kind_label(), "static");
assert_eq!(
Probe::Benchmark {
runner: "criterion".into(),
filter: "b".into(),
threshold: "p95<2s".into()
}
.kind_label(),
"benchmark"
);
assert_eq!(
Probe::External {
runner: "curl".into(),
args: vec![]
}
.kind_label(),
"external"
);
assert_eq!(Probe::Inferential.kind_label(), "inferential");
}
#[test]
fn test_probe_roundtrips() {
let p = Probe::Benchmark {
runner: "criterion".into(),
filter: "bench_x".into(),
threshold: "p95<2000ms".into(),
};
let json = serde_json::to_string(&p).unwrap();
let back: Probe = serde_json::from_str(&json).unwrap();
assert_eq!(back, p);
}
}