Skip to main content

ears_classifier/
lib.rs

1//! EARS requirement classification and the ISO/IEC 25010 quality vocabulary (FEAT-049).
2//!
3//! EARS (Easy Approach to Requirements Syntax) gives a requirement a shape that can be tested:
4//! one behaviour, one trigger, no compound sentences. This module *classifies* text — it never
5//! rejects it. Stored requirements keep whatever the author wrote; `doctor` reports what doesn't
6//! conform. That is deliberate: a write that fails because of prose style would push people to
7//! stop recording requirements at all, and the pattern is derived on demand so stored data can
8//! never disagree with the classifier.
9//!
10//! What is deliberately NOT checked: compound responses ("SHALL validate and reject"). A regex for
11//! that fires on legitimate single behaviours, and a check people learn to ignore is worse than no
12//! check.
13
14/// The five EARS patterns.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Pattern {
18    /// `THE SYSTEM SHALL <response>` — always true.
19    Ubiquitous,
20    /// `WHEN <trigger>, THE SYSTEM SHALL <response>` — event driven.
21    Event,
22    /// `WHILE <state>, THE SYSTEM SHALL <response>` — state driven.
23    State,
24    /// `WHERE <feature is included>, THE SYSTEM SHALL <response>` — optional feature.
25    Optional,
26    /// `IF <undesired condition>, THE SYSTEM SHALL <response>` — unwanted behaviour.
27    Unwanted,
28}
29
30impl Pattern {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Pattern::Ubiquitous => "ubiquitous",
34            Pattern::Event => "event",
35            Pattern::State => "state",
36            Pattern::Optional => "optional",
37            Pattern::Unwanted => "unwanted",
38        }
39    }
40}
41
42/// The marker every EARS requirement contains.
43const SHALL: &str = "THE SYSTEM SHALL ";
44
45/// Classify a requirement. `None` means it does not conform — a warning, never an error.
46///
47/// Case-insensitive (the skill asks for uppercase, but rejecting lowercase would only lose
48/// information), and each conditional pattern must carry a non-empty condition: `WHEN, THE SYSTEM
49/// SHALL …` states no trigger and so isn't testable.
50pub fn classify(text: &str) -> Option<Pattern> {
51    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
52    let upper = normalized.to_uppercase();
53    let marker = upper.find(SHALL)?;
54
55    // Ubiquitous: the requirement opens with the marker itself.
56    if marker == 0 {
57        return has_response(&upper, marker).then_some(Pattern::Ubiquitous);
58    }
59
60    let (keyword, pattern) = [
61        ("WHEN ", Pattern::Event),
62        ("WHILE ", Pattern::State),
63        ("WHERE ", Pattern::Optional),
64        ("IF ", Pattern::Unwanted),
65    ]
66    .into_iter()
67    .find(|(keyword, _)| upper.starts_with(keyword))?;
68
69    // The condition sits between the keyword and the marker, and must say something. A trailing
70    // `THEN` before the marker is the classic unwanted-behaviour spelling.
71    let condition = upper[keyword.len()..marker]
72        .trim()
73        .trim_end_matches(',')
74        .trim()
75        .trim_end_matches("THEN")
76        .trim()
77        .trim_end_matches(',')
78        .trim()
79        .to_string();
80    (!condition.is_empty() && has_response(&upper, marker)).then_some(pattern)
81}
82
83/// A requirement must say what the system does, not just that it shall.
84fn has_response(upper: &str, marker: usize) -> bool {
85    !upper[marker + SHALL.len()..].trim().is_empty()
86}
87
88/// The nine ISO/IEC 25010 (2023) quality characteristics.
89pub const ISO25010: [&str; 9] = [
90    "Functional Suitability",
91    "Performance Efficiency",
92    "Compatibility",
93    "Interaction Capability",
94    "Reliability",
95    "Security",
96    "Maintainability",
97    "Flexibility",
98    "Safety",
99];
100
101/// Canonical spelling of a quality tag, ignoring case, spacing and hyphens. `None` for a tag
102/// outside the standard — reported, never rejected, because a hard failure here would break a
103/// board load over a typo.
104pub fn normalize_iso(tag: &str) -> Option<&'static str> {
105    let key = |s: &str| {
106        s.chars()
107            .filter(|c| c.is_ascii_alphanumeric())
108            .map(|c| c.to_ascii_lowercase())
109            .collect::<String>()
110    };
111    let wanted = key(tag);
112    ISO25010.into_iter().find(|c| key(c) == wanted)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn classifies_the_five_patterns() {
121        let cases = [
122            (
123                "THE SYSTEM SHALL retain a cart for 7 days.",
124                Pattern::Ubiquitous,
125            ),
126            (
127                "WHEN a cart is abandoned, THE SYSTEM SHALL retain it for 7 days.",
128                Pattern::Event,
129            ),
130            (
131                "WHILE a sync is running, THE SYSTEM SHALL reject a second sync.",
132                Pattern::State,
133            ),
134            (
135                "WHERE the mirror is enabled, THE SYSTEM SHALL update the issue.",
136                Pattern::Optional,
137            ),
138            (
139                "IF gh is not installed, THEN THE SYSTEM SHALL complete the write.",
140                Pattern::Unwanted,
141            ),
142        ];
143        for (text, expected) in cases {
144            assert_eq!(classify(text), Some(expected), "{text}");
145        }
146        // Case and spacing are tolerated; the skill asks for uppercase but losing a lowercase
147        // requirement to a style rule helps nobody.
148        assert_eq!(
149            classify("when a cart is abandoned,  the system shall  retain it."),
150            Some(Pattern::Event)
151        );
152    }
153
154    #[test]
155    fn rejects_text_that_cannot_be_tested() {
156        // Prose, however sincere.
157        assert_eq!(classify("The cart should be user-friendly."), None);
158        // The marker, but no response.
159        assert_eq!(classify("THE SYSTEM SHALL"), None);
160        assert_eq!(
161            classify("WHEN a cart is abandoned, THE SYSTEM SHALL   "),
162            None
163        );
164        // A conditional with no condition states no trigger.
165        assert_eq!(classify("WHEN, THE SYSTEM SHALL retain the cart."), None);
166        assert_eq!(classify("IF THEN THE SYSTEM SHALL retain the cart."), None);
167        // A keyword that isn't an EARS opener.
168        assert_eq!(
169            classify("AFTER a cart is abandoned, THE SYSTEM SHALL retain it."),
170            None
171        );
172    }
173
174    #[test]
175    fn iso_tags_are_canonicalized_and_unknown_ones_are_reported() {
176        assert_eq!(
177            normalize_iso("performance-efficiency"),
178            Some("Performance Efficiency")
179        );
180        assert_eq!(normalize_iso("  SECURITY "), Some("Security"));
181        assert_eq!(normalize_iso("Reliability"), Some("Reliability"));
182        // Usability was renamed Interaction Capability in the 2023 revision; an old or invented
183        // tag is reported rather than silently accepted.
184        assert_eq!(normalize_iso("Usability"), None);
185        assert_eq!(normalize_iso("Fastness"), None);
186    }
187}
188
189/// The README's example is the crate's whole surface, so it is compiled and run rather than
190/// trusted — a README that does not work is worse than none, because it is believed.
191#[cfg(test)]
192mod readme {
193    use super::*;
194
195    #[test]
196    fn the_readme_example_is_true() {
197        assert_eq!(
198            classify("WHEN the archive is downloaded, THE SYSTEM SHALL verify its checksum"),
199            Some(Pattern::Event)
200        );
201        assert_eq!(classify("it should probably be fast"), None);
202        assert_eq!(
203            normalize_iso("performance-efficiency"),
204            Some("Performance Efficiency")
205        );
206        assert_eq!(normalize_iso("Speed"), None);
207        assert_eq!(ISO25010.len(), 9);
208    }
209}