print_test/
lib.rs

1#![feature(array_windows, decl_macro)]
2
3use std::{
4    env,
5    path::{Path, PathBuf},
6};
7
8mod diff;
9
10pub fn function_name<T>(_: T) -> String {
11    std::any::type_name::<T>().replace("::", "-")
12}
13
14struct Config {
15    cahce_dir: PathBuf,
16    write_changes: bool,
17}
18
19impl Config {
20    fn new() -> Self {
21        Self {
22            cahce_dir: env::var("PRINT_TEST_CACHE_DIR")
23                .map(PathBuf::from)
24                .unwrap_or_else(|_| PathBuf::from("print-test-cache")),
25            write_changes: env::var("PRINT_TEST_WRITE_CHANGES")
26                .map(|s| s == "1")
27                .unwrap_or(false),
28        }
29    }
30}
31
32fn get_config() -> &'static Config {
33    static CONFIG: std::sync::OnceLock<Config> = std::sync::OnceLock::new();
34    CONFIG.get_or_init(|| Config::new())
35}
36
37pub fn case(name: &str, body: impl FnOnce(&mut String)) {
38    struct Case<'a> {
39        name: &'a str,
40        buffer: String,
41    }
42
43    fn save_result(name: &str, source: &str) {
44        use std::fmt::Write;
45
46        struct Dump {
47            buffer: String,
48        }
49
50        impl Drop for Dump {
51            fn drop(&mut self) {
52                if self.buffer.is_empty() {
53                    return;
54                }
55                eprintln!("{}", self.buffer);
56            }
57        }
58
59        let mut dump = Dump {
60            buffer: String::new(),
61        };
62
63        macro log($dump:ident, $($arg:tt)*) {
64            writeln!($dump.buffer, "print-test: {}", format_args!($($arg)*)).unwrap();
65        }
66
67        fn usage_hint(dump: &mut Dump) {
68            log!(dump, "to save, run with 'PRINT_TEST_WRITE_CHANGES=1'");
69        }
70
71        let config = get_config();
72
73        let path = config.cahce_dir.join(name);
74
75        fn write_file(dump: &mut Dump, path: &Path, source: &str, should_run: bool) -> bool {
76            if !should_run {
77                usage_hint(dump);
78                return true;
79            }
80
81            if let Err(e) = std::fs::create_dir_all(path.parent().unwrap_or(Path::new("."))) {
82                log!(dump, "failed to create directory: {}", e);
83                return false;
84            }
85            if let Err(e) = std::fs::write(&path, source) {
86                log!(dump, "failed to write to file: {}", e);
87                return false;
88            }
89
90            true
91        }
92
93        if !path.exists() {
94            if !write_file(&mut dump, &path, source, config.write_changes) {
95                return;
96            }
97
98            log!(dump, "no previous result found, current form:");
99
100            for line in source.lines() {
101                log!(dump, "  {}", line);
102            }
103
104            if !std::thread::panicking() && !config.write_changes {
105                panic!("new test case detected");
106            }
107
108            return;
109        }
110
111        let prev = match std::fs::read_to_string(&path) {
112            Ok(prev) => prev,
113            Err(e) => {
114                log!(dump, "failed to read from file: {}", e);
115                return;
116            }
117        };
118
119        let diff = diff::lines(&prev, &source);
120
121        if diff.iter().all(|d| matches!(d, diff::Result::Both(..))) {
122            return;
123        }
124
125        log!(dump, "changes detected for test '{}':", name);
126        for line in diff.iter() {
127            let ansi_term = "\u{001b}[0m";
128            match line {
129                diff::Result::Both(line, ..) => {
130                    log!(dump, "  {}", line);
131                }
132                diff::Result::Left(line) => {
133                    let ansi_red = "\u{001b}[31m";
134                    log!(dump, "{ansi_red}- {line}{ansi_term}");
135                }
136                diff::Result::Right(line) => {
137                    let ansi_green = "\u{001b}[32m";
138                    log!(dump, "{ansi_green}+ {line}{ansi_term}");
139                }
140            }
141        }
142
143        write_file(&mut dump, &path, source, config.write_changes);
144
145        if !std::thread::panicking() && !config.write_changes {
146            panic!("test '{}' failed", name);
147        }
148    }
149
150    let mut case = Case {
151        name,
152        buffer: String::new(),
153    };
154
155    body(&mut case.buffer);
156
157    impl Drop for Case<'_> {
158        fn drop(&mut self) {
159            if std::thread::panicking() {
160                self.buffer.push_str("\n\n");
161                self.buffer
162                    .push_str("Panic occurred during test execution.");
163            }
164
165            save_result(self.name, &self.buffer);
166        }
167    }
168}
169
170#[macro_export]
171macro_rules! cases {
172    ($(fn $name:ident($ctx:ident) $body:block)*) => {$(
173        #[test]
174        fn $name() {
175            let fn_name = $crate::function_name($name);
176            let test_fn = |$ctx: &mut String| $body;
177            $crate::case(&fn_name, test_fn);
178        }
179    )*};
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    cases! {
187        fn test1(ctx) {
188            ctx.push_str("test1");
189        }
190
191        fn test2(ctx) {
192            ctx.push_str("test2");
193        }
194    }
195}