use std::fmt;
use crate::description::Description;
use crate::matcher::{MatchResult, Matcher, Mismatch};
#[cfg(feature = "diff")]
fn multi_line_str_diff(expected: &str, actual: &str) -> Option<String> {
if expected.contains('\n') || actual.contains('\n') {
Some(crate::diff::diff_lines(expected, actual))
} else {
None
}
}
#[cfg(not(feature = "diff"))]
fn multi_line_str_diff(_expected: &str, _actual: &str) -> Option<String> {
None
}
macro_rules! str_predicate_matcher {
($matcher:ident, $method:ident, $describe:literal) => {
struct $matcher {
needle: String,
}
impl $matcher {
fn str_description(&self) -> Description {
Description::text(format!(concat!($describe, " {:?}"), self.needle))
}
}
impl<T> Matcher<T> for $matcher
where
T: AsRef<str> + fmt::Debug + ?Sized,
{
fn check(&self, actual: &T) -> MatchResult {
let haystack = actual.as_ref();
if haystack.$method(self.needle.as_str()) {
MatchResult::pass()
} else {
let mut mismatch = Mismatch::new(self.str_description(), format!("{actual:?}"));
if let Some(diff) = multi_line_str_diff(&self.needle, haystack) {
mismatch = mismatch.with_diff(diff);
}
MatchResult::fail(mismatch)
}
}
fn description(&self) -> Description {
self.str_description()
}
}
};
}
str_predicate_matcher!(ContainsStrMatcher, contains, "a string containing");
str_predicate_matcher!(StartsWithMatcher, starts_with, "a string starting with");
str_predicate_matcher!(EndsWithMatcher, ends_with, "a string ending with");
#[must_use]
pub fn contains_str<T>(needle: impl Into<String>) -> impl Matcher<T>
where
T: AsRef<str> + fmt::Debug + ?Sized,
{
ContainsStrMatcher {
needle: needle.into(),
}
}
#[must_use]
pub fn starts_with<T>(prefix: impl Into<String>) -> impl Matcher<T>
where
T: AsRef<str> + fmt::Debug + ?Sized,
{
StartsWithMatcher {
needle: prefix.into(),
}
}
#[must_use]
pub fn ends_with<T>(suffix: impl Into<String>) -> impl Matcher<T>
where
T: AsRef<str> + fmt::Debug + ?Sized,
{
EndsWithMatcher {
needle: suffix.into(),
}
}
#[cfg(feature = "regex")]
struct RegexMatcher {
pattern: String,
compiled: Result<regex::Regex, regex::Error>,
}
#[cfg(feature = "regex")]
impl RegexMatcher {
fn regex_description(&self) -> Description {
Description::text(format!("a string matching the regex {:?}", self.pattern))
}
}
#[cfg(feature = "regex")]
impl<T> Matcher<T> for RegexMatcher
where
T: AsRef<str> + fmt::Debug + ?Sized,
{
fn check(&self, actual: &T) -> MatchResult {
match &self.compiled {
Err(error) => MatchResult::fail(Mismatch::new(
self.regex_description(),
format!("<invalid regex {:?}: {error}>", self.pattern),
)),
Ok(regex) => {
if regex.is_match(actual.as_ref()) {
MatchResult::pass()
} else {
MatchResult::fail(Mismatch::new(
self.regex_description(),
format!("{actual:?}"),
))
}
}
}
}
fn description(&self) -> Description {
self.regex_description()
}
}
#[cfg(feature = "regex")]
#[must_use]
pub fn matches_regex<T>(pattern: impl Into<String>) -> impl Matcher<T>
where
T: AsRef<str> + fmt::Debug + ?Sized,
{
let pattern = pattern.into();
let compiled = regex::Regex::new(&pattern);
RegexMatcher { pattern, compiled }
}
#[cfg(test)]
mod tests {
use test_better_core::{OrFail, TestResult};
use super::*;
use crate::{check, eq, is_false, is_true};
#[test]
fn contains_str_matches_a_substring() -> TestResult {
check!(contains_str("ell").check("hello").matched).satisfies(is_true())?;
check!(contains_str("xyz").check("hello").matched).satisfies(is_false())?;
check!(contains_str("ell").check(&String::from("hello")).matched).satisfies(is_true())?;
Ok(())
}
#[test]
fn starts_with_and_ends_with_check_the_ends() -> TestResult {
check!(starts_with("he").check("hello").matched).satisfies(is_true())?;
check!(starts_with("lo").check("hello").matched).satisfies(is_false())?;
check!(ends_with("lo").check("hello").matched).satisfies(is_true())?;
check!(ends_with("he").check("hello").matched).satisfies(is_false())?;
Ok(())
}
#[test]
fn contains_str_failure_describes_the_needle_and_renders_the_actual() -> TestResult {
let failure = contains_str("xyz")
.check("hello")
.failure
.or_fail_with("hello does not contain xyz")?;
check!(failure.expected.to_string())
.satisfies(eq("a string containing \"xyz\"".to_string()))?;
check!(failure.actual).satisfies(eq("\"hello\"".to_string()))?;
Ok(())
}
#[cfg(feature = "diff")]
#[test]
fn multi_line_string_mismatch_carries_a_diff() -> TestResult {
let actual = "line one\nline two\nline three";
let failure = starts_with("line one\nline 2")
.check(actual)
.failure
.or_fail_with("the multi-line prefix does not match")?;
let diff = failure
.diff
.or_fail_with("a multi-line string mismatch should carry a diff")?;
check!(diff.contains("line 2")).satisfies(is_true())?;
check!(diff.contains("line two")).satisfies(is_true())?;
Ok(())
}
#[cfg(feature = "regex")]
#[test]
fn matches_regex_matches_and_reports() -> TestResult {
check!(matches_regex(r"\d+").check("abc123").matched).satisfies(is_true())?;
check!(matches_regex(r"^\d+$").check("abc123").matched).satisfies(is_false())?;
Ok(())
}
#[cfg(feature = "regex")]
#[test]
fn matches_regex_reports_an_invalid_pattern_as_a_failure() -> TestResult {
let failure = matches_regex(r"(unclosed")
.check("anything")
.failure
.or_fail_with("an invalid pattern fails the match")?;
check!(failure.actual.contains("invalid regex")).satisfies(is_true())?;
Ok(())
}
}