tech-lessons.in
Background by Dana Tentis on Pexels
January 24, 2024

Diving into Rust by building an assertions crate

Posted on January 24, 2024  •  22 minutes  • 4501 words
Table of contents

As Rust projects grow in size and complexity, the need for sophisticated error handling tools becomes ever more pressing. Traditional methods like panics and asserts, while useful, can be limited and cumbersome.

Let’s build an assertions crate that offers elegant and powerful assertions, while simultaneously diving into the diverse landscape of Rust features.

Introduction

Let’s define some requirements for our crate. The assertions crate should:

  1. Offer Fluent API: chain assertions for a natural and readable experience.
  2. Have extensive assertions: variety of assertions covering common validation needs.
  3. Be customizable: extend with custom assertions for specific domain requirements.
  4. Be type-safe: leverage Rust’s type system for reliable assertions.

Consider this password validation example as a glimpse into the crate’s ability to create meaningful and expressive assertions.

let pass_phrase = "P@@sw0rd1 zebra alpha";
pass_phrase.should_not_be_empty()
    .should_have_at_least_length(10)
    .should_contain_all_characters(vec!['@', ' '])
    .should_contain_a_digit()
    .should_not_contain_ignoring_case("pass")
    .should_not_contain_ignoring_case("word");

Understanding String, &str and str types

It is important to understand various string types in Rust before we get started.

The below code and the visual highlights the types: String, and &str.

let value: String = String::from("RUST");
let value_ref: &str = &value[1..];
let literal: &str = "TRUST";
String vs &str
Visual from the book: Programming Rust

In Rust, the string literals are of type &str and the actual literals are allocated on pre-allocated read-only memory.

Beginning with extension methods

The first thing that we need is the ability to invoke custom methods like should_contain_all_characters and should_be_empty on the built-in types. In the password validation example, we have methods like should_contain_all_characters and should_contain_a_digit over string (or &str) types.

Such methods are called “extension” methods and Wikipedia defines extension method as “a method added to an object after the original object was compiled”. In Rust, extension methods are added using traits.

Let’s start by defining a trait.

pub trait MembershipAssertion {
    fn should_contain_a_digit(&self) -> &Self;
    fn should_not_contain_a_digit(&self) -> &Self;
    fn should_not_be_empty(&self) -> &Self;
}

MembershipAssertion defines methods: should_contain_a_digit, should_not_contain_a_digit and should_not_be_empty, all of which returns reference to Self to allow chaining.

Let’s implement the trait for a string slice (&str). (I will only implement should_contain_a_digit at this stage).

impl MembershipAssertion for &str {
    fn should_contain_a_digit(&self) -> &Self {
        let contains_a_digit = self.chars().any(|ch| ch.is_numeric());
        if !contains_a_digit {
            panic!("assertion failed: {:?} should contain a digit", self);
        }
        self
    }
}

Time to add a few tests. (I will only show a couple of tests.).

#[cfg(test)]
mod tests {
    use crate::MembershipAssertion;

    #[test]
    fn should_contain_a_digit() {
        let password = "P@@sw0rd1 zebra alpha";
        password.should_contain_a_digit();
    }

    #[test]
    fn should_not_be_empty_and_contain_a_digit() {
        let password = "P@@sw0rd1 zebra alpha";
        password.should_not_be_empty()
                .should_contain_a_digit();
    }
}

It is a decent start, and we can make our first commit.

We have implemented the MembershipAssertion trait for the &str type. We also need the methods of the MembershipAssertion trait for the String type.

Let’s implement the trait for the String type.

impl MembershipAssertion for String {
    fn should_contain_a_digit(&self) -> &Self {
        let contains_a_digit = self.chars().any(|ch| ch.is_numeric());
        if !contains_a_digit {
            panic!("assertion failed: {:?} should contain a digit", self);
        }
        self
    }
}

#[cfg(test)]
mod string_tests {
    use crate::MembershipAssertion;

    #[test]
    fn should_contain_a_digit() {
        let password = String::from("P@@sw0rd1 zebra alpha");
        password.should_contain_a_digit();
    }

    #[test]
    fn should_not_be_empty_and_contain_a_digit() {
        let password = String::from("P@@sw0rd1 zebra alpha");
        password.should_not_be_empty().should_contain_a_digit();
    }
}

We have simply duplicated the method should_contain_a_digit for &str and String types and verified that duplicated code works for both the types.

Let’s make an attempt to remove the duplication.

We can convert the String type to a string slice -> &str and invoke the respective methods on &str type. With this approach, the implementation of MembershipAssertion on String looks like the following:

impl MembershipAssertion for String {
    fn should_contain_a_digit(&self) -> &Self {
        (self as &str).should_contain_a_digit();
        self
    }

    fn should_not_be_empty(&self) -> &Self {
        (self as &str).should_not_be_empty();
        self
    }
}

This approach removes the duplication, but it is still unnecessary delegation - all the methods in the String implementation delegate to the respective implementation in the &str type. This might not look like a huge thing but if we add a few more methods in the MembershipAssertion trait, the delegation will become obvious. So the question is, can we do better?

Understanding AsRef

Let’s revisit our MembershipAssertion trait.

pub trait MembershipAssertion {
    fn should_contain_a_digit(&self) -> &Self;
    fn should_not_contain_a_digit(&self) -> &Self;
    fn should_not_be_empty(&self) -> &Self;
}

The idea is to provide an implementation of MembershipAssertion for all the types which can be represented as string.

It will be great to implement MembershipAssertion for any generic type T, where T that it can be represented as String (or &str). The article explains how generics are processed in Rust here .

Let’s give this concept a try.

impl<T> MembershipAssertion for T
    where T: AsRef<str> + Debug {
    fn should_contain_a_digit(&self) -> &Self {
        let contains_a_digit = self.as_ref().chars().any(|ch| ch.is_numeric());
        if !contains_a_digit {
            panic!("assertion failed: {:?} should contain a digit", self);
        }
        self
    }
}

We are implementing MembershipAssertion for any T where T implements AsRef<str> and Debug trait.

Jim Blandy, Jason Orendorff and Leonora F.S Tindall in the book Programming Rust says: when a type implements AsRef<U>, it means we can borrow a reference of U from the type. This means, if a type implements AsRef<str> we can borrow a reference of str from that type.

In Rust, both String and &str type implements AsRef<str> which means all the methods of MembershipAssertion are now available on String and &str. Please find the reference here .

We need the T to implement Debug trait because we are formatting it in the panic macro.

Quick revisit:

// open method from Rust's file type.
pub fn open<P: AsRef<Path>>(path: P) -> Result<File> {..}

With the introduction of AsRef<str> change, we have taken care of the duplication in the implementation of MembershipAssertion, and we can now remove our original implementation of MembershipAssertion for String and &str types.

Leveraging blanket trait implementation

Rust allows implementing a trait for any generic type T. Consider a trait BoxWrap as shown below:

pub trait BoxWrap {
    fn boxed(self) -> Box<Self>;
}

This trait provides boxed method to create a Box representation of self. Please note the boxed method takes the ownership of self.

In Rust, Box is a pointer type that uniquely owns a heap allocation of type T. Box::new(x: T) allocates memory on heap and then places x into it.

We can use blanket trait implementation to implement the method boxed over any generic type T.

impl<T> BoxWrap for T {
    fn boxed(self) -> Box<Self> {
        Box::new(self)
    }
}

That’s it, the method boxed is now available on all the types T.

fn main() {
    let value: &str = "clearcheck";
    println!("{:?}", value.boxed());

    let id: i32 = 120;
    println!("{:?}", id.boxed());
}

This concept is very useful for our crate. Let’s see how.

Our crate will also offer assertions related to ordered comparisons (greater than, less than, greater than equal to, less than equal to) for various types. Let’s provide such a trait.

pub trait OrderedAssertion<T: PartialOrd> {
    fn should_be_greater_than(&self, other: T) -> &Self;
    fn should_not_be_greater_than(&self, other: T) -> &Self;
    // other methods
}

The trait OrderedAssertion is generic over T: PartialOrd. PartialOrd trait allows ordered comparisons (>, <, >=, <=) between types.

We have an option of implementing OrderedAssertion for various types like i8, i16, i32, &str etc. or we can implement OrderedAssertion for a constrained T.

Let’s make an attempt to implement OrderedAssertion for any type T that implements PartialOrd.

impl<T> OrderedAssertion<T> for T
    where T: PartialOrd + Debug {
    fn should_be_greater_than(&self, other: T) -> &Self {
        if self <= &other {
            panic!("{:?} should be greater than {:?}", self, other)
        }
        self
    }

    fn should_not_be_greater_than(&self, other: T) -> &Self {
        if self > &other {
            panic!("{:?} should not be greater than {:?}", self, other)
        }
        self
    }
}

The methods should_be_greater_than and should_not_be_greater_than are now available on any T that implements PartialOrd trait.

Note, the operators (>=, <) translate to a method call partial_cmp inside the PartialOrd trait. The method partial_cmp takes parameters by reference for comparison. Hence, we are doing the comparisons using references (self <= &other).

#[cfg(test)]
mod ordering_tests {
    use crate::OrderedAssertion;

    #[test]
    fn should_be_greater_than_other() {
        let rank = 12.90;
        rank.should_be_greater_than(10.23);
    }

    #[test]
    fn should_be_greater_than_other_with_string() {
        let name = "junit";
        name.should_be_greater_than("assert");
    }
}

The advantage of implementing a trait for any T (with or without constraint) is obvious. It removes duplication!!!

Time to revisit the implementation of our assertion.

Introducing Matchers

Let’s take a look at the implementation of OrderedAssertion again.

impl<T> OrderedAssertion<T> for T
    where T: PartialOrd + Debug {
    fn should_be_greater_than(&self, other: T) -> &Self {
        if self <= &other {
            panic!("{:?} should be greater than {:?}", self, other)
        }
        self
    }

    fn should_not_be_greater_than(&self, other: T) -> &Self {
        if self > &other {
            panic!("{:?} should not be greater than {:?}", self, other)
        }
        self
    }
}

There are two issues with this implementation.

Subtle duplication

There is still subtle duplication between the implementations of should_be_greater_than and should_not_be_greater_than.

Let’s take a closer look to understand the duplication:

The same form of duplication will be observed between method pairs like:

This duplication is caused because of the change in the condition for examining the data: self must be less than other, self must be greater than other.

The lack of ability to compose assertions

Assertions are defining the contract, ensuring that each data type (/data structure) adheres to its intended behavior. They don’t provide us with an ability to compose them using operators like and, or.

We can deal with both these issues by introducing an abstraction that:

The question is what would be the name of such an abstraction? In the world of assertions, such an object is called matcher. I asked bard to define Matcher? It gave me the following definition:

Matchers provide the granular tools for carrying out the assertions. They examine the data and verify that the data conforms to specific criteria.

Let’s introduce matchers in the code. We can introduce a set of diverse matchers, each implementing the Matcher trait to work with specific data types. The important part is that we will only be implementing matchers to deal with positive assertions.

pub trait Matcher<T> {
    fn test(&self, value: &T) -> MatcherResult;
}

Matcher is the base trait that works on any generic type T. It defines a method test that returns an instance of MatcherResult.

pub struct MatcherResult {
    passed: bool,
    failure_message: String,
    inverted_failure_message: String,
}

MatcherResult which will play a crucial role in inverting a matcher.

Let’s implement MembershipMatcher for the String type. It should be capable of asserting:

pub enum MembershipMatcher {
    ADigit,
    Char(char),
}

impl<T> Matcher<T> for MembershipMatcher
    where T: AsRef<str>
{
    fn test(&self, value: &T) -> MatcherResult {
        match self {
            MembershipMatcher::ADigit => MatcherResult::formatted(
                value.as_ref().chars().any(|ch| ch.is_numeric()),
                format!("{:?} should contain a digit", value.as_ref()),
                format!("{:?} should not contain a digit", value.as_ref()),
            ),
            MembershipMatcher::Char(ch) => MatcherResult::formatted(
                value.as_ref().chars().any(|source| &source == ch),
                format!("{:?} should contain the character {:?}", value.as_ref(), ch),
                format!("{:?} should not contain the character {:?}", value.as_ref(), ch),
            ),
        }
    }
}

pub fn contain_a_digit() -> MembershipMatcher {
    MembershipMatcher::ADigit
}

pub fn contain_a_character(ch: char) -> MembershipMatcher {
    MembershipMatcher::Char(ch)
}

A few things to observe:

Time to add a few tests.

#[cfg(test)]
mod tests {
    use crate::{contain_a_character, contain_a_digit, Matcher};

    #[test]
    fn should_contain_a_digit() {
        let matcher = contain_a_digit();
        assert!(matcher.test(&"password@1").passed);
    }

    #[test]
    fn should_contain_a_char() {
        let matcher = contain_a_character('@');
        assert!(matcher.test(&"password@1").passed);
    }
}

It is a decent start to matchers but we still need to answer a few questions:

Connecting assertions and matchers - using blanket trait and trait object

We have assertions which serve as the cornerstone of the test cases, defining the exact expectations the code must fulfill. They act as a contract, ensuring that each data type (/data structure) adheres to its intended behavior.

We also have matchers which provide the granular tools for carrying out the assertions. They examine the data and verify that the data conforms to specific criteria.

Let’s take a look at the relationship between assertions and matchers.

relationship between assertions and matchers

We will have positive and negative assertions both of which use positive matchers. The bridge (which is yet to be built) will connect matchers with assertions and invert the matcher if a negative assertion like should_not_contain_a_digit is invoked.

Let’s build the bridge using blanket trait .

pub trait Should<T> {
    fn should(&self, matcher: &dyn Matcher<T>);
}

impl<T> Should<T> for T {
    fn should(&self, matcher: &dyn Matcher<T>) {
        let matcher_result = matcher.test(self);
        if !matcher_result.passed {
            panic!("assertion failed: {}", matcher_result.failure_message);
        }
    }
}

We provide a trait Should which is implemented for any T.

We can also implement the trait Should for any T: Assertion where Assertion can be a base marker trait for all the assertions.

The method should takes a matcher as a parameter, invokes the test method passing self as the argument and panics if the matcher fails.

The method should takes a parameter matcher: &dyn Matcher<T> which is a trait object in Rust. The trait object points to both an instance of a type implementing a trait and a table that is used to look up trait methods on that type at runtime. We create a trait object by specifying either & reference or a Box<T> smart pointer, then the dyn keyword, and then specifying the relevant trait.

When we use trait objects, Rust must use dynamic dispatch .

The expression dyn Matcher<T> refers to any Matcher of type T and is unsized. Rust compiler needs all the method parameters to have a fixed size, hence we use reference to any Matcher<T> -> matcher: &dyn Matcher<T>.

Similarly, we can define a trait that inverts the given matcher.

pub trait ShouldNot<T> {
    fn should_not(&self, matcher: &dyn Matcher<T>);
}

impl<T> ShouldNot<T> for T {
    fn should_not(&self, matcher: &dyn Matcher<T>) {
        let matcher_result = matcher.test(self);
        let passed = !matcher_result.passed;
        if !passed {
            panic!(
                "assertion failed: {}",
                matcher_result.inverted_failure_message
            );
        }
    }
}

We can now refactor the methods should_contain_a_digit and should_not_contain_a_digit of the MembershipAssertion to use the MembershipMatcher.

impl<T> MembershipAssertion for T
    where T: AsRef<str> + Debug {
    fn should_contain_a_digit(&self) -> &Self {
        //the method `should` passes self (which in this case is &str) 
        //to the test method of the matcher.
        self.should(&contain_a_digit()); 
        self
    }
    
    fn should_not_contain_a_digit(&self) -> &Self {
        //the method `should_not` passes self (which in this case is &str) 
        //to the test method of the matcher.
        self.should_not(&contain_a_digit()); 
        self
    }
}

All the tests pass and we can now commit :).

There is a question on matchers that needs to be answered. Should our matchers take the ownership of the extra data that they may need or should they hold a reference?

Let’s understand this.

Remember our traits Should and ShouldNot pass &self to the test method of the matcher. This means if we invoke the method should_contain_a_digit on the String type, the test method of our MembershipMatcher will receive a reference to String.

Let’s consider that our string MembershipMatcher starts providing support for testing whether a string contains any of the given characters. Should the matcher now hold a reference to a slice of char or a vector of char?

Option1: MembershipMatcher provides an enum variant AnyChars which holds a reference to a slice of char.

pub enum MembershipMatcher<'a> {
    ADigit,
    Char(char),
    AnyChars(&'a [char])
}

Option2: MembershipMatcher provides an enum variant AnyChars which holds a vector of char.

pub enum MembershipMatcher {
    ADigit,
    Char(char),
    AnyChars(Vec<char>),
}

Time to discuss lifetimes.

Matchers and lifetimes

Every reference in Rust has a lifetime, which is the stretch of the program for which the reference is valid. A reference can be treated as a pointer managed by Rust. We have already seen a reference in the form of &str.

Rust does not have nil or dangling references.

Consider the below code:

fn main() { 
    let reference: &i32;                | --- 'a starts            
    {                                   |
        let value: i32 = 10;            |    | --- 'b starts
        reference = &value;             |    | --- 'b ends
    }                                   |
    println!("{}", reference);          | --- 'a ends      
}

The code above creates a variable reference that refers to an i32. We use the lifetime annotations 'a and 'b to denote the lifetimes of reference and value. Rust does not allow dangling references that means the variable reference can not outlive value. This can also be stated as: “the lifetime of the variable reference must be less than or equal to the lifetime of the variable value”.

The above code fails with the the following error:

    reference = &value;
                ^^^^^^ borrowed value does not live long enough
    
    Here, `value` is dropped as soon as the inner block ends, but `reference` lives 
    through the scope of the function. If this were permitted, Rust would end up with 
    dangling references.  

Lifetimes in Rust ensure that all the references and the container objects holding references are always valid.

Let’s get back to our question: Should our matchers hold a reference to the extra data or take the ownership?

We will an attempt to design matchers using references (Option1), which means our MembershipMatcher will now have lifetime annotation.

pub enum MembershipMatcher<'a> {
    ADigit,
    Char(char),
    AnyChars(&'a [char])
}

The MembershipMatcher holds a reference to a slice of char for some lifetime 'a. This means:

This decision requires us to make the following changes:

impl<'a, T> Matcher<T> for MembershipMatcher<'a>
    where T: AsRef<str>
{
    fn test(&self, value: &T) -> MatcherResult {
        match self {
            //skipping ADigit and Char variants
            MembershipMatcher::AnyChars(chars) => MatcherResult::formatted(
                chars.iter().any(|ch| value.as_ref().contains(*ch)),
                format!("{:?} should contain any of the characters {:?}", value.as_ref(), chars),
                format!("{:?} should not contain any of the characters {:?}", value.as_ref(), chars),
            )
        }
    }
}

pub fn contain_a_digit<'a>() -> MembershipMatcher<'a> {
    MembershipMatcher::ADigit
}

pub fn contain_a_character<'a>(ch: char) -> MembershipMatcher<'a> {
    MembershipMatcher::Char(ch)
}

pub fn contain_any_characters<'a>(chars: &'a [char]) -> MembershipMatcher<'a> {
    MembershipMatcher::AnyChars(chars)
}

We have introduced lifetime annotation 'a in the impl, the public methods contain_a_digit, contain_a_character and contain_any_characters. This lifetime annotation tells the compiler that MembershipMatcher lives for the duration defined by 'a, which in turn is the lifetime of the character slice.

The lifetime defined in the last method contain_any_characters can be removed. Historically, all the rust methods with reference(s) as parameter(s) required the developers to specify lifetime annotation(s). With time, rust developers saw some some common patterns and created a set of lifetime elision rules. One of those rules states: if a function accepts a single reference parameter, then the lifetime of that reference will be assigned as the lifetime of the return value.

We can now add a test.

#[cfg(test)]
mod matcher_tests {

    #[test]
    fn should_contain_any_chars() {
        let matcher = contain_any_characters(&['@', '#', '.']);
        assert!(matcher.test(&"password@1").passed);
    }
}

This works fine.

Let’s play with matchers and lifetimes for a bit by adding a few tests.

#[cfg(test)]
mod tests {
    #[test]
    fn should_contain_any_chars() {
        let matcher;                                            
        {                                                       
            let chars = &['@', '#', '.'];                       
            matcher = contain_any_characters(chars);            
        }                                                           
        assert!(matcher.test(&"password@1").passed);            
    }
}

We declare a matcher variable in the outer block, which is initialized in the inner block. We would like the character slice to live at least as long as the matcher, otherwise matcher would point to a dropped character slice.

It might appear that chars will be dropped as soon as the inner block ends, and the above code will not compile. However, Rust extends the lifetime of chars to match the lifetime of the variable matcher which is till the end of the function. The above code compiles just fine.

Let’s take another example where Rust results in compilation error. It is a slight change in the test, but an important one.

#[cfg(test)]
mod tests {
    #[test]
    fn should_contain_any_chars_compiler_error() {
        let matcher;
        {
            let chars = ['@', '#', '.'];
            matcher = contain_any_characters(&chars);
                                             ^^^^^^ borrowed value does not live long enough
        }
        assert!(matcher.test(&"password@1").passed); - chars dropped here while still borrowed
    }
}

Here, we declare chars as an array, and its reference is passed to the function contain_any_characters. Rust will drop chars array at the end of the inner block, however matcher outlives the reference. Rust does not allow dangling references and thus it results in a compilation error.

Question, how do we decide if matchers should have a reference of the extra data or own the data?

We need to answer a few questions to take this decision:

  1. This crate will be used in testing (unit/integration/functional/any other). Is it ok if a matcher takes the ownership of extra data?
  2. Do you care about the consistency of ownership vs borrow concept? Do you want all the matchers to refer to extra data or take ownership?
  3. Are you going to build a concept that combines various matchers and can the lifetimes of various matchers cause any problems?

I recently finished an assertions crate called clearcheck and I decided to have matchers own their data. My decision was primarily based on point 1.

Matcher composition : using trait objects

We now have matcher as a citizen in the code. I think it is worth building a concept that allows composition of various matchers using operators like: and, or.

We can introduce an abstraction Matchers that contains a collection of objects which implement Matcher<T> trait.

enum Kind {
    And,
    Or,
}

pub struct Matchers<T, M: Matcher<T>> {
    matchers: Vec<M>,
    kind: Kind,
    inner: PhantomData<T>,
}

Our first attempt is to create a Matchers abstraction that holds a vector of M, where M is a Matcher that operates on a T.

We need to understand how generics are processed in Rust to understand an issue with this design. In Rust, generics undergo monomorphization which means the compiler produces a different copy of the generic code for each concrete type needed. This means if the generic type Option<T> is used with f64 and i32, the compiler will produce two copies of Option, which would be similar to Option_f64 and Option_i32.

Given our definition of Matchers, Rust will emit different copies of Matchers for each concrete type of Matcher. This also means that Rust will not allow us to combine different types of matcher objects like MembershipMatcher and SubstringMatcher using generics, even if all of them implement the Matcher<T> trait for the same T.

So, we need a different way to combine matchers. We have already seen trait objects , which point to both an instance of a type implementing a trait and a table that is used to look up trait methods on that type at runtime. We create a trait object by specifying either & reference or a Box<T> smart pointer, then the dyn keyword, and then specifying the relevant trait. Trait objects enable storing objects (inside a struct) regardless of their specific types, as long as they fulfill the required behavior specified by a trait.

We can now represent Matchers using a vector of trait objects, which implement the Matcher<T> trait.

pub struct Matchers<T> {
    matchers: Vec<Box<dyn Matcher<T>>>,
    kind: Kind,
}

Let’s now implement a builder to create a Matchers object.

(I am only going to describe the and operator, and positive assertions. You can refer the code here for composition with inverted assertions.)

pub struct MatchersBuilder<T> {
    matchers: Vec<Box<dyn Matcher<T>>>,
}

impl<T: Debug> MatchersBuilder<T> {
    pub fn start_building(matcher: Box<dyn Matcher<T>>) -> Self {
        MatchersBuilder {
            matchers: vec![matcher]
        }
    }

    pub fn push(mut self, matcher: Box<dyn Matcher<T>>) -> Self {
        self.matchers.push(matcher);
        self
    }

    pub fn combine_as_and(self) -> Matchers<T> {
        Matchers::and(self.matchers)
    }
}

MatchersBuilder allows composing multiple matchers and it can be used as following:

MatchersBuilder::start_building(Box::new(contain_a_digit()))
    .push(Box::new(contain_any_characters(&['#', '&'])))
    .push(Box::new(contain_a_character('0')))
    .combine_as_and()

Finally, Matchers is going to implement the Matcher<T> trait. This is what makes the crate really powerful. Anyone can create their custom matcher using composition and use it in the assertion(s) of their choice.

impl<T: Debug> Matcher<T> for Matchers<T> {
    fn test(&self, value: &T) -> MatcherResult {
        let results = self
            .matchers
            .iter()
            .map(|matcher| matcher.test(value))
            .collect::<Vec<_>>();

        match self.kind {
            Kind::And => MatcherResult::formatted(
                results.iter().all(|result| result.passed),
                messages(
                    &results,
                    |result| !result.passed,
                    |result| result.failure_message.clone(),
                ),
                messages(
                    &results,
                    |result| result.passed,
                    |result| result.inverted_failure_message.clone(),
                ),
            ),
            //Kind::Or skipped
        }
    }
}

fn messages<P, M>(results: &[MatcherResult], predicate: P, mapper: M) -> String
    where
        P: Fn(&&MatcherResult) -> bool,
        M: Fn(&MatcherResult) -> String,
{
    results
        .iter()
        .filter(predicate)
        .map(mapper)
        .collect::<Vec<_>>()
        .join("\n")
}

The test method runs all the matchers and collects MatcherResults. It then produces an instance of MatcherResult based on the Kind.

Let’s see the concept of custom matchers in action.

#[cfg(test)]
mod custom_string_matchers_tests {
    use std::fmt::Debug;

    use crate::{contain_a_character, contain_a_digit, contain_any_characters, Matchers, MatchersBuilder, Should};

    fn be_a_valid_password<T: AsRef<str> + Debug>() -> Matchers<T> {
        MatchersBuilder::start_building(Box::new(contain_a_digit()))
            .push(Box::new(contain_any_characters(&['#', '&', '@'])))
            .push(Box::new(contain_a_character('0')))
            .combine_as_and()
    }

    trait PasswordAssertion {
        fn should_be_a_valid_password(&self) -> &Self;
    }

    impl PasswordAssertion for &str {
        fn should_be_a_valid_password(&self) -> &Self {
            self.should(&be_a_valid_password());
            self
        }
    }
    
    #[test]
    fn should_be_a_valid_password() {
        let password = "P@@sw0rd9082";
        password.should_be_a_valid_password();
    }
}

This involves the following:

That’s it folks. We have arrived at the end of the article. I hope it was worth your time.

I have an assertions crate called clearcheck . Do take a look if the concepts that were discussed in the article excite you.

References