#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Pattern {
Ubiquitous,
Event,
State,
Optional,
Unwanted,
}
impl Pattern {
pub fn as_str(self) -> &'static str {
match self {
Pattern::Ubiquitous => "ubiquitous",
Pattern::Event => "event",
Pattern::State => "state",
Pattern::Optional => "optional",
Pattern::Unwanted => "unwanted",
}
}
}
const SHALL: &str = "THE SYSTEM SHALL ";
pub fn classify(text: &str) -> Option<Pattern> {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
let upper = normalized.to_uppercase();
let marker = upper.find(SHALL)?;
if marker == 0 {
return has_response(&upper, marker).then_some(Pattern::Ubiquitous);
}
let (keyword, pattern) = [
("WHEN ", Pattern::Event),
("WHILE ", Pattern::State),
("WHERE ", Pattern::Optional),
("IF ", Pattern::Unwanted),
]
.into_iter()
.find(|(keyword, _)| upper.starts_with(keyword))?;
let condition = upper[keyword.len()..marker]
.trim()
.trim_end_matches(',')
.trim()
.trim_end_matches("THEN")
.trim()
.trim_end_matches(',')
.trim()
.to_string();
(!condition.is_empty() && has_response(&upper, marker)).then_some(pattern)
}
fn has_response(upper: &str, marker: usize) -> bool {
!upper[marker + SHALL.len()..].trim().is_empty()
}
pub const ISO25010: [&str; 9] = [
"Functional Suitability",
"Performance Efficiency",
"Compatibility",
"Interaction Capability",
"Reliability",
"Security",
"Maintainability",
"Flexibility",
"Safety",
];
pub fn normalize_iso(tag: &str) -> Option<&'static str> {
let key = |s: &str| {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect::<String>()
};
let wanted = key(tag);
ISO25010.into_iter().find(|c| key(c) == wanted)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_the_five_patterns() {
let cases = [
(
"THE SYSTEM SHALL retain a cart for 7 days.",
Pattern::Ubiquitous,
),
(
"WHEN a cart is abandoned, THE SYSTEM SHALL retain it for 7 days.",
Pattern::Event,
),
(
"WHILE a sync is running, THE SYSTEM SHALL reject a second sync.",
Pattern::State,
),
(
"WHERE the mirror is enabled, THE SYSTEM SHALL update the issue.",
Pattern::Optional,
),
(
"IF gh is not installed, THEN THE SYSTEM SHALL complete the write.",
Pattern::Unwanted,
),
];
for (text, expected) in cases {
assert_eq!(classify(text), Some(expected), "{text}");
}
assert_eq!(
classify("when a cart is abandoned, the system shall retain it."),
Some(Pattern::Event)
);
}
#[test]
fn rejects_text_that_cannot_be_tested() {
assert_eq!(classify("The cart should be user-friendly."), None);
assert_eq!(classify("THE SYSTEM SHALL"), None);
assert_eq!(
classify("WHEN a cart is abandoned, THE SYSTEM SHALL "),
None
);
assert_eq!(classify("WHEN, THE SYSTEM SHALL retain the cart."), None);
assert_eq!(classify("IF THEN THE SYSTEM SHALL retain the cart."), None);
assert_eq!(
classify("AFTER a cart is abandoned, THE SYSTEM SHALL retain it."),
None
);
}
#[test]
fn iso_tags_are_canonicalized_and_unknown_ones_are_reported() {
assert_eq!(
normalize_iso("performance-efficiency"),
Some("Performance Efficiency")
);
assert_eq!(normalize_iso(" SECURITY "), Some("Security"));
assert_eq!(normalize_iso("Reliability"), Some("Reliability"));
assert_eq!(normalize_iso("Usability"), None);
assert_eq!(normalize_iso("Fastness"), None);
}
}
#[cfg(test)]
mod readme {
use super::*;
#[test]
fn the_readme_example_is_true() {
assert_eq!(
classify("WHEN the archive is downloaded, THE SYSTEM SHALL verify its checksum"),
Some(Pattern::Event)
);
assert_eq!(classify("it should probably be fast"), None);
assert_eq!(
normalize_iso("performance-efficiency"),
Some("Performance Efficiency")
);
assert_eq!(normalize_iso("Speed"), None);
assert_eq!(ISO25010.len(), 9);
}
}