trycall 0.1.0

An utility similar to trybuild but for testing functions that takes a string as input and returns a string as output.
Documentation
//! An utility for testing a function that takes in a string and returns a string.
//!
//! # How it works
//!
//! The [`trycall`] macro takes in a path to a directory. The directory should contain
//! two files for each test case:
//!
//! - **source** - The source file with extension `.txt`
//! - **expected** - The expected output file with extension `.out`
//!
//! The [`trycall`] macro will then run the function on each **source** file and compare
//! the output to the **expected** file.
//!
//! The below example will run the `add_one` function on each **source** file in the
//! `tests/add_one` directory (relative to the crate's root) and compare the output to
//! the respective **expected** file (e.g. `tests/add_one/word.txt` will be compared to
//! `tests/add_one/word.out`).
//!
//! * If the output does not match the **expected** file, the test will fail.
//! * If the **expected** file does not exist, it will be treated as an empty string.
//! * If the function panics, the test will fail.
//!
//! # Example
//!
//! ```ignore
//! pub fn add_one(s: &str) -> String {
//!     s.parse::<i32>()
//!         .map(|n| (n + 1).to_string())
//!         .unwrap()
//! }
//!
//! #[cfg(test)]
//! mod tests {
//!     #[test]
//!     fn add_one() {
//!         trycall::trycall!("tests/add_one").with(super::add_one);
//!     }
//! }
//! ```
//!
//! # Updating the expected output
//!
//! If you want to update the expected output, you can pass in the `UPDATE_EXPECT` environment
//! variable. For example:
//!
//! ```text
//! UPDATE_EXPECT=1 cargo test
//! ```
//!
//! # Filtering
//!
//! You can filter the test cases by passing in the `trycall=` prefix followed by
//! a string which will be checked against the **source** file name. For example,
//! if you want to run the test on the `negative_number` test case, you can run any
//! of the following commands:
//!
//! ```text
//! cargo test -- trycall=negative_number
//! cargo test -- trycall=negative
//! cargo test -- trycall=number
//! ```
//!
//! You can also pass in the test case **source** file path directly to the [`trycall`] macro:
//!
//! ```ignore
//! #[test]
//! fn add_one() {
//!    trycall::trycall!("tests/add_one/negative_number.txt").with(super::add_one);
//! }
//! ```
use std::{
    env::args_os,
    ffi::OsString,
    fs::{read_dir, read_to_string},
    panic::{catch_unwind, resume_unwind, RefUnwindSafe},
    path::{Path, PathBuf},
};

use expect_test::expect_file;

const PREFIX: &str = "trycall=";

#[macro_export]
macro_rules! trycall {
    [$path:expr] => {{
        let crate_root_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let path = crate_root_dir.join($path);

        $crate::TestCases::new(path)
    }}
}

struct TestCase {
    src: PathBuf,
    expected: PathBuf,
}

impl TestCase {
    fn with<F>(&self, f: F)
    where
        F: Fn(&str) -> String,
    {
        let src = read_to_string(&self.src)
            .unwrap_or_else(|err| panic!("Failed to read file {:?}: {err}", self.src));

        expect_file![&self.expected].assert_eq(&f(&src));
    }
}

pub struct TestCases {
    cases: Vec<TestCase>,
}

impl TestCases {
    pub fn new<P>(path: P) -> Self
    where
        P: AsRef<Path>,
    {
        let path = path.as_ref();

        let mut cases = Vec::new();

        let metadata = path
            .metadata()
            .unwrap_or_else(|err| panic!("Failed to get metadata for {path:?}: {err}"));

        let (filters, entries) = if metadata.is_dir() {
            let filters = args_os()
                .flat_map(OsString::into_string)
                .filter_map(|mut arg| {
                    if arg.starts_with(PREFIX) && arg != PREFIX {
                        Some(arg.split_off(PREFIX.len()))
                    } else {
                        None
                    }
                })
                .collect();

            let entries = read_dir(path)
                .unwrap_or_else(|err| panic!("Failed to read dir {path:?}: {err}"))
                .map(|entry| entry.unwrap().path())
                .filter(|p| p.extension().unwrap() == "txt")
                .collect();

            (filters, entries)
        } else if metadata.is_file() && path.extension().unwrap() == "txt" {
            (vec![], vec![path.to_path_buf()])
        } else {
            panic!("Invalid path: {path:?}");
        };

        for path in entries {
            let mut expected = path.clone();
            expected.set_extension("out");

            if !filters.is_empty() {
                let name = path.file_stem().unwrap().to_string_lossy();

                if !filters.iter().any(|filter| name.contains(filter)) {
                    continue;
                }
            }

            cases.push(TestCase {
                src: path,
                expected,
            });
        }

        Self { cases }
    }

    pub fn with<F>(&self, f: F)
    where
        F: Fn(&str) -> String + RefUnwindSafe,
    {
        let mut failed = vec![];

        for case in &self.cases {
            if let Err(err) = catch_unwind(|| case.with(&f)) {
                failed.push((case.src.to_string_lossy(), err.downcast::<String>()));
            }
        }

        if !failed.is_empty() {
            for (src, err) in failed {
                if let Ok(err) = err {
                    println!(
                        "\n\x1b[1m\x1b[91merror\x1b[97m: expect test panicked\x1b[0m\x1b[1m\x1b[34m\n   -->\x1b[0m {src}\n\n{err}\n",
                    );
                }
            }

            resume_unwind(Box::new(()));
        }
    }
}