test_better_matchers/
strings.rs1use std::fmt;
9
10use crate::description::Description;
11use crate::matcher::{MatchResult, Matcher, Mismatch};
12
13#[cfg(feature = "diff")]
17fn multi_line_str_diff(expected: &str, actual: &str) -> Option<String> {
18 if expected.contains('\n') || actual.contains('\n') {
19 Some(crate::diff::diff_lines(expected, actual))
20 } else {
21 None
22 }
23}
24
25#[cfg(not(feature = "diff"))]
26fn multi_line_str_diff(_expected: &str, _actual: &str) -> Option<String> {
27 None
28}
29
30macro_rules! str_predicate_matcher {
35 ($matcher:ident, $method:ident, $describe:literal) => {
36 struct $matcher {
37 needle: String,
38 }
39
40 impl $matcher {
41 fn str_description(&self) -> Description {
42 Description::text(format!(concat!($describe, " {:?}"), self.needle))
43 }
44 }
45
46 impl<T> Matcher<T> for $matcher
47 where
48 T: AsRef<str> + fmt::Debug + ?Sized,
49 {
50 fn check(&self, actual: &T) -> MatchResult {
51 let haystack = actual.as_ref();
52 if haystack.$method(self.needle.as_str()) {
53 MatchResult::pass()
54 } else {
55 let mut mismatch = Mismatch::new(self.str_description(), format!("{actual:?}"));
56 if let Some(diff) = multi_line_str_diff(&self.needle, haystack) {
57 mismatch = mismatch.with_diff(diff);
58 }
59 MatchResult::fail(mismatch)
60 }
61 }
62
63 fn description(&self) -> Description {
64 self.str_description()
65 }
66 }
67 };
68}
69
70str_predicate_matcher!(ContainsStrMatcher, contains, "a string containing");
71str_predicate_matcher!(StartsWithMatcher, starts_with, "a string starting with");
72str_predicate_matcher!(EndsWithMatcher, ends_with, "a string ending with");
73
74#[must_use]
87pub fn contains_str<T>(needle: impl Into<String>) -> impl Matcher<T>
88where
89 T: AsRef<str> + fmt::Debug + ?Sized,
90{
91 ContainsStrMatcher {
92 needle: needle.into(),
93 }
94}
95
96#[must_use]
108pub fn starts_with<T>(prefix: impl Into<String>) -> impl Matcher<T>
109where
110 T: AsRef<str> + fmt::Debug + ?Sized,
111{
112 StartsWithMatcher {
113 needle: prefix.into(),
114 }
115}
116
117#[must_use]
129pub fn ends_with<T>(suffix: impl Into<String>) -> impl Matcher<T>
130where
131 T: AsRef<str> + fmt::Debug + ?Sized,
132{
133 EndsWithMatcher {
134 needle: suffix.into(),
135 }
136}
137
138#[cfg(feature = "regex")]
143struct RegexMatcher {
144 pattern: String,
145 compiled: Result<regex::Regex, regex::Error>,
146}
147
148#[cfg(feature = "regex")]
149impl RegexMatcher {
150 fn regex_description(&self) -> Description {
151 Description::text(format!("a string matching the regex {:?}", self.pattern))
152 }
153}
154
155#[cfg(feature = "regex")]
156impl<T> Matcher<T> for RegexMatcher
157where
158 T: AsRef<str> + fmt::Debug + ?Sized,
159{
160 fn check(&self, actual: &T) -> MatchResult {
161 match &self.compiled {
162 Err(error) => MatchResult::fail(Mismatch::new(
163 self.regex_description(),
164 format!("<invalid regex {:?}: {error}>", self.pattern),
165 )),
166 Ok(regex) => {
167 if regex.is_match(actual.as_ref()) {
168 MatchResult::pass()
169 } else {
170 MatchResult::fail(Mismatch::new(
171 self.regex_description(),
172 format!("{actual:?}"),
173 ))
174 }
175 }
176 }
177 }
178
179 fn description(&self) -> Description {
180 self.regex_description()
181 }
182}
183
184#[cfg(feature = "regex")]
201#[must_use]
202pub fn matches_regex<T>(pattern: impl Into<String>) -> impl Matcher<T>
203where
204 T: AsRef<str> + fmt::Debug + ?Sized,
205{
206 let pattern = pattern.into();
207 let compiled = regex::Regex::new(&pattern);
208 RegexMatcher { pattern, compiled }
209}
210
211#[cfg(test)]
212mod tests {
213 use test_better_core::{OrFail, TestResult};
214
215 use super::*;
216 use crate::{check, eq, is_false, is_true};
217
218 #[test]
219 fn contains_str_matches_a_substring() -> TestResult {
220 check!(contains_str("ell").check("hello").matched).satisfies(is_true())?;
221 check!(contains_str("xyz").check("hello").matched).satisfies(is_false())?;
222 check!(contains_str("ell").check(&String::from("hello")).matched).satisfies(is_true())?;
224 Ok(())
225 }
226
227 #[test]
228 fn starts_with_and_ends_with_check_the_ends() -> TestResult {
229 check!(starts_with("he").check("hello").matched).satisfies(is_true())?;
230 check!(starts_with("lo").check("hello").matched).satisfies(is_false())?;
231 check!(ends_with("lo").check("hello").matched).satisfies(is_true())?;
232 check!(ends_with("he").check("hello").matched).satisfies(is_false())?;
233 Ok(())
234 }
235
236 #[test]
237 fn contains_str_failure_describes_the_needle_and_renders_the_actual() -> TestResult {
238 let failure = contains_str("xyz")
239 .check("hello")
240 .failure
241 .or_fail_with("hello does not contain xyz")?;
242 check!(failure.expected.to_string())
243 .satisfies(eq("a string containing \"xyz\"".to_string()))?;
244 check!(failure.actual).satisfies(eq("\"hello\"".to_string()))?;
245 Ok(())
246 }
247
248 #[cfg(feature = "diff")]
249 #[test]
250 fn multi_line_string_mismatch_carries_a_diff() -> TestResult {
251 let actual = "line one\nline two\nline three";
252 let failure = starts_with("line one\nline 2")
253 .check(actual)
254 .failure
255 .or_fail_with("the multi-line prefix does not match")?;
256 let diff = failure
257 .diff
258 .or_fail_with("a multi-line string mismatch should carry a diff")?;
259 check!(diff.contains("line 2")).satisfies(is_true())?;
260 check!(diff.contains("line two")).satisfies(is_true())?;
261 Ok(())
262 }
263
264 #[cfg(feature = "regex")]
265 #[test]
266 fn matches_regex_matches_and_reports() -> TestResult {
267 check!(matches_regex(r"\d+").check("abc123").matched).satisfies(is_true())?;
268 check!(matches_regex(r"^\d+$").check("abc123").matched).satisfies(is_false())?;
269 Ok(())
270 }
271
272 #[cfg(feature = "regex")]
273 #[test]
274 fn matches_regex_reports_an_invalid_pattern_as_a_failure() -> TestResult {
275 let failure = matches_regex(r"(unclosed")
276 .check("anything")
277 .failure
278 .or_fail_with("an invalid pattern fails the match")?;
279 check!(failure.actual.contains("invalid regex")).satisfies(is_true())?;
280 Ok(())
281 }
282}