rstest_bdd_patterns/
specificity.rs

1//! Pattern specificity calculation for disambiguation.
2//!
3//! When multiple step patterns match the same step text, this module provides
4//! scoring to select the most specific match. More specific patterns have more
5//! literal text and fewer placeholders.
6
7use crate::PatternError;
8use crate::pattern::lexer::{Token, lex_pattern};
9use std::cmp::Ordering;
10
11/// Specificity score for a step pattern.
12///
13/// Used to rank patterns when multiple match the same step text. Higher scores
14/// indicate more specific patterns that should take precedence.
15///
16/// # Ordering
17///
18/// Patterns are compared by:
19/// 1. More literal characters → more specific
20/// 2. Fewer placeholders → more specific
21/// 3. More typed placeholders → more specific (tiebreaker)
22///
23/// # Examples
24///
25/// ```
26/// use rstest_bdd_patterns::SpecificityScore;
27///
28/// let specific = SpecificityScore::calculate("the output is foo")
29///     .expect("valid specific pattern");
30/// let generic = SpecificityScore::calculate("the output is {value}")
31///     .expect("valid generic pattern");
32/// assert!(specific > generic);
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub struct SpecificityScore {
36    /// Total number of literal characters in the pattern.
37    pub literal_chars: usize,
38    /// Number of placeholder tokens in the pattern.
39    pub placeholder_count: usize,
40    /// Number of placeholders with type hints (e.g., `{n:u32}`).
41    pub typed_placeholder_count: usize,
42}
43
44impl SpecificityScore {
45    /// Calculate the specificity score for a pattern string.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`PatternError`] if the pattern contains invalid syntax.
50    ///
51    /// # Examples
52    ///
53    /// ```
54    /// use rstest_bdd_patterns::SpecificityScore;
55    ///
56    /// let score = SpecificityScore::calculate("I have {count:u32} apples")
57    ///     .expect("valid pattern");
58    /// assert_eq!(score.literal_chars, 14); // "I have " + " apples"
59    /// assert_eq!(score.placeholder_count, 1);
60    /// assert_eq!(score.typed_placeholder_count, 1);
61    /// ```
62    pub fn calculate(pattern: &str) -> Result<Self, PatternError> {
63        let tokens = lex_pattern(pattern)?;
64
65        let mut literal_chars = 0usize;
66        let mut placeholder_count = 0usize;
67        let mut typed_placeholder_count = 0usize;
68
69        for token in tokens {
70            match token {
71                Token::Literal(text) => {
72                    literal_chars += text.chars().count();
73                }
74                Token::Placeholder { hint, .. } => {
75                    placeholder_count += 1;
76                    if hint.is_some() {
77                        typed_placeholder_count += 1;
78                    }
79                }
80                // Stray braces are treated as literal characters
81                Token::OpenBrace { .. } | Token::CloseBrace { .. } => {
82                    literal_chars += 1;
83                }
84            }
85        }
86
87        Ok(Self {
88            literal_chars,
89            placeholder_count,
90            typed_placeholder_count,
91        })
92    }
93}
94
95impl Ord for SpecificityScore {
96    fn cmp(&self, other: &Self) -> Ordering {
97        // More literal characters → more specific
98        match self.literal_chars.cmp(&other.literal_chars) {
99            Ordering::Equal => {}
100            ord => return ord,
101        }
102
103        // Fewer placeholders → more specific (reverse comparison)
104        match other.placeholder_count.cmp(&self.placeholder_count) {
105            Ordering::Equal => {}
106            ord => return ord,
107        }
108
109        // More typed placeholders → more specific (tiebreaker)
110        self.typed_placeholder_count
111            .cmp(&other.typed_placeholder_count)
112    }
113}
114
115impl PartialOrd for SpecificityScore {
116    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
117        Some(self.cmp(other))
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    /// Unwrap a result with a descriptive panic message on failure.
126    fn score(pattern: &str) -> SpecificityScore {
127        match SpecificityScore::calculate(pattern) {
128            Ok(s) => s,
129            Err(e) => panic!("pattern {pattern:?} should calculate successfully: {e}"),
130        }
131    }
132
133    #[test]
134    fn literal_only_pattern_has_highest_specificity() {
135        let literal = score("overlap apples");
136        let with_placeholder = score("overlap {item}");
137
138        assert!(literal > with_placeholder);
139        assert_eq!(literal.placeholder_count, 0);
140        assert_eq!(with_placeholder.placeholder_count, 1);
141    }
142
143    #[test]
144    fn more_literal_chars_wins() {
145        let more_literal = score("the stdlib output is the workspace executable {path}");
146        let less_literal = score("the stdlib output is {expected}");
147
148        assert!(more_literal > less_literal);
149    }
150
151    #[test]
152    fn fewer_placeholders_wins_with_equal_literals() {
153        // Patterns with equal literal char counts but different placeholder counts
154        let a = score("ab {x}");
155        let b = score("a {x} {y}");
156
157        assert_eq!(a.literal_chars, 3); // "ab "
158        assert_eq!(b.literal_chars, 3); // "a " + " "
159        assert!(a > b, "fewer placeholders should win when literals equal");
160    }
161
162    #[test]
163    fn typed_placeholder_wins_as_tiebreaker() {
164        let typed = score("count is {n:u32}");
165        let untyped = score("count is {n}");
166
167        assert_eq!(typed.literal_chars, untyped.literal_chars);
168        assert_eq!(typed.placeholder_count, untyped.placeholder_count);
169        assert!(
170            typed > untyped,
171            "typed placeholder should win as tiebreaker"
172        );
173    }
174
175    #[test]
176    fn empty_pattern_has_zero_specificity() {
177        let empty = score("");
178
179        assert_eq!(empty.literal_chars, 0);
180        assert_eq!(empty.placeholder_count, 0);
181        assert_eq!(empty.typed_placeholder_count, 0);
182    }
183
184    #[test]
185    fn all_placeholder_pattern_has_lowest_specificity() {
186        let all_placeholders = score("{a} {b} {c}");
187        let mixed = score("prefix {a}");
188
189        assert!(mixed > all_placeholders);
190        assert_eq!(all_placeholders.literal_chars, 2); // two spaces
191        assert_eq!(all_placeholders.placeholder_count, 3);
192    }
193
194    #[test]
195    fn stray_braces_count_as_literal_chars() {
196        let with_stray = score("{ literal }");
197
198        // "{ literal }" tokenizes as OpenBrace + Literal(" literal ") + CloseBrace
199        assert_eq!(with_stray.literal_chars, 11); // 1 + 9 + 1
200        assert_eq!(with_stray.placeholder_count, 0);
201    }
202
203    #[test]
204    fn escaped_braces_count_as_literals() {
205        let escaped = score("value is {{x}}");
206
207        // "{{" becomes literal "{" and "}}" becomes literal "}"
208        assert_eq!(escaped.literal_chars, 12); // "value is {x}"
209        assert_eq!(escaped.placeholder_count, 0);
210    }
211
212    #[test]
213    fn multibyte_characters_counted_correctly() {
214        let unicode = score("café {value}");
215
216        // "café " is 5 characters (not 6 bytes)
217        assert_eq!(unicode.literal_chars, 5);
218        assert_eq!(unicode.placeholder_count, 1);
219    }
220
221    #[test]
222    fn real_world_example_from_issue() {
223        // From issue #350: workspace executable pattern should beat generic
224        let specific = score("the stdlib output is the workspace executable {path}");
225        let generic = score("the stdlib output is {expected}");
226
227        assert!(
228            specific > generic,
229            "workspace executable pattern ({} literals) should beat generic ({} literals)",
230            specific.literal_chars,
231            generic.literal_chars
232        );
233    }
234}