1use std::path::PathBuf;
4
5use deno_terminal::colors;
6use thiserror::Error;
7
8use crate::PathedIoError;
9
10use self::strategies::TestCollectionStrategy;
11
12pub mod strategies;
13
14#[derive(Debug, Clone)]
15pub enum CollectedCategoryOrTest<T = ()> {
16 Category(CollectedTestCategory<T>),
17 Test(CollectedTest<T>),
18}
19
20#[derive(Debug, Clone)]
21pub struct CollectedTestCategory<T = ()> {
22 pub name: String,
24 pub path: PathBuf,
27 pub children: Vec<CollectedCategoryOrTest<T>>,
29}
30
31impl<T> CollectedTestCategory<T> {
32 pub fn test_count(&self) -> usize {
33 self
34 .children
35 .iter()
36 .map(|child| match child {
37 CollectedCategoryOrTest::Category(c) => c.test_count(),
38 CollectedCategoryOrTest::Test(_) => 1,
39 })
40 .sum()
41 }
42
43 pub fn filter_children(&mut self, filter: &str) {
44 self.children.retain_mut(|mut child| match &mut child {
45 CollectedCategoryOrTest::Category(c) => {
46 c.filter_children(filter);
47 !c.is_empty()
48 }
49 CollectedCategoryOrTest::Test(t) => t.name.contains(filter),
50 });
51 }
52
53 pub fn is_empty(&self) -> bool {
54 for child in &self.children {
55 match child {
56 CollectedCategoryOrTest::Category(category) => {
57 if !category.is_empty() {
58 return false;
59 }
60 }
61 CollectedCategoryOrTest::Test(_) => {
62 return false;
63 }
64 }
65 }
66
67 true
68 }
69
70 pub fn into_flat_category(self) -> Self {
73 let mut flattened_tests = Vec::new();
74
75 fn collect_tests<T>(
76 children: Vec<CollectedCategoryOrTest<T>>,
77 output: &mut Vec<CollectedCategoryOrTest<T>>,
78 ) {
79 for child in children {
80 match child {
81 CollectedCategoryOrTest::Category(category) => {
82 collect_tests(category.children, output);
83 }
84 CollectedCategoryOrTest::Test(test) => {
85 output.push(CollectedCategoryOrTest::Test(test));
86 }
87 }
88 }
89 }
90
91 collect_tests(self.children, &mut flattened_tests);
92
93 CollectedTestCategory {
94 name: self.name,
95 path: self.path,
96 children: flattened_tests,
97 }
98 }
99
100 pub fn partition<F>(self, predicate: F) -> (Self, Self)
104 where
105 F: Fn(&CollectedTest<T>) -> bool + Copy,
106 {
107 let mut matching_children = Vec::new();
108 let mut non_matching_children = Vec::new();
109
110 for child in self.children {
111 match child {
112 CollectedCategoryOrTest::Category(category) => {
113 let (matching_cat, non_matching_cat) = category.partition(predicate);
114 if !matching_cat.is_empty() {
115 matching_children
116 .push(CollectedCategoryOrTest::Category(matching_cat));
117 }
118 if !non_matching_cat.is_empty() {
119 non_matching_children
120 .push(CollectedCategoryOrTest::Category(non_matching_cat));
121 }
122 }
123 CollectedCategoryOrTest::Test(test) => {
124 if predicate(&test) {
125 matching_children.push(CollectedCategoryOrTest::Test(test));
126 } else {
127 non_matching_children.push(CollectedCategoryOrTest::Test(test));
128 }
129 }
130 }
131 }
132
133 let matching = CollectedTestCategory {
134 name: self.name.clone(),
135 path: self.path.clone(),
136 children: matching_children,
137 };
138
139 let non_matching = CollectedTestCategory {
140 name: self.name,
141 path: self.path,
142 children: non_matching_children,
143 };
144
145 (matching, non_matching)
146 }
147}
148
149#[derive(Debug, Clone)]
150pub struct CollectedTest<T = ()> {
151 pub name: String,
153 pub path: PathBuf,
155 pub line_and_column: Option<(u32, u32)>,
157 pub data: T,
160}
161
162impl<T> CollectedTest<T> {
163 pub fn read_to_string(&self) -> Result<String, PathedIoError> {
165 std::fs::read_to_string(&self.path)
166 .map_err(|err| PathedIoError::new(&self.path, err))
167 }
168}
169
170pub struct CollectOptions<TData> {
171 pub base: PathBuf,
173 pub strategy: Box<dyn TestCollectionStrategy<TData>>,
175 pub filter_override: Option<String>,
179}
180
181pub fn collect_tests_or_exit<TData>(
183 options: CollectOptions<TData>,
184) -> CollectedTestCategory<TData> {
185 match collect_tests(options) {
186 Ok(category) => category,
187 Err(err) => {
188 eprintln!("{}: {}", colors::red_bold("error"), err);
189 std::process::exit(1);
190 }
191 }
192}
193
194#[derive(Debug, Error)]
195pub enum CollectTestsError {
196 #[error(transparent)]
197 InvalidTestName(#[from] InvalidTestNameError),
198 #[error(transparent)]
199 Io(#[from] PathedIoError),
200 #[error("No tests found")]
201 NoTestsFound,
202 #[error(transparent)]
203 Other(#[from] anyhow::Error),
204}
205
206pub fn collect_tests<TData>(
207 options: CollectOptions<TData>,
208) -> Result<CollectedTestCategory<TData>, CollectTestsError> {
209 let mut category = options.strategy.collect_tests(&options.base)?;
210
211 if category.is_empty() {
213 return Err(CollectTestsError::NoTestsFound);
214 }
215
216 ensure_valid_test_names(&category)?;
218
219 let maybe_filter = options.filter_override.or_else(parse_cli_arg_filter);
221 if let Some(filter) = &maybe_filter {
222 category.filter_children(filter);
223 }
224
225 Ok(category)
226}
227
228fn ensure_valid_test_names<TData>(
229 category: &CollectedTestCategory<TData>,
230) -> Result<(), InvalidTestNameError> {
231 for child in &category.children {
232 match child {
233 CollectedCategoryOrTest::Category(category) => {
234 ensure_valid_test_names(category)?;
235 }
236 CollectedCategoryOrTest::Test(test) => {
237 if !test
239 .name
240 .chars()
241 .all(|c| c.is_alphanumeric() || matches!(c, '_' | ':'))
242 {
243 return Err(InvalidTestNameError(test.name.clone()));
244 }
245 }
246 }
247 }
248 Ok(())
249}
250
251#[derive(Debug, Error)]
252#[error(
253 "Invalid test name ({0}). Use only alphanumeric and underscore characters so tests can be filtered via the command line."
254)]
255pub struct InvalidTestNameError(String);
256
257pub fn parse_cli_arg_filter() -> Option<String> {
260 std::env::args()
261 .nth(1)
262 .filter(|s| !s.starts_with('-') && !s.is_empty())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_partition() {
271 let category = CollectedTestCategory {
273 name: "root".to_string(),
274 path: PathBuf::from("/root"),
275 children: vec![
276 CollectedCategoryOrTest::Test(CollectedTest {
277 name: "test_foo".to_string(),
278 path: PathBuf::from("/root/foo.rs"),
279 line_and_column: None,
280 data: (),
281 }),
282 CollectedCategoryOrTest::Test(CollectedTest {
283 name: "test_bar".to_string(),
284 path: PathBuf::from("/root/bar.rs"),
285 line_and_column: None,
286 data: (),
287 }),
288 CollectedCategoryOrTest::Category(CollectedTestCategory {
289 name: "nested".to_string(),
290 path: PathBuf::from("/root/nested"),
291 children: vec![
292 CollectedCategoryOrTest::Test(CollectedTest {
293 name: "test_baz".to_string(),
294 path: PathBuf::from("/root/nested/baz.rs"),
295 line_and_column: None,
296 data: (),
297 }),
298 CollectedCategoryOrTest::Test(CollectedTest {
299 name: "test_qux".to_string(),
300 path: PathBuf::from("/root/nested/qux.rs"),
301 line_and_column: None,
302 data: (),
303 }),
304 ],
305 }),
306 ],
307 };
308
309 let (matching, non_matching) =
311 category.partition(|test| test.name.contains("ba"));
312
313 assert_eq!(matching.name, "root");
315 assert_eq!(matching.path, PathBuf::from("/root"));
316 assert_eq!(matching.test_count(), 2);
317
318 assert_eq!(matching.children.len(), 2);
320 match &matching.children[0] {
321 CollectedCategoryOrTest::Test(test) => assert_eq!(test.name, "test_bar"),
322 _ => panic!("Expected test"),
323 }
324 match &matching.children[1] {
325 CollectedCategoryOrTest::Category(cat) => {
326 assert_eq!(cat.name, "nested");
327 assert_eq!(cat.children.len(), 1);
328 match &cat.children[0] {
329 CollectedCategoryOrTest::Test(test) => {
330 assert_eq!(test.name, "test_baz")
331 }
332 _ => panic!("Expected test"),
333 }
334 }
335 _ => panic!("Expected category"),
336 }
337
338 assert_eq!(non_matching.name, "root");
340 assert_eq!(non_matching.path, PathBuf::from("/root"));
341 assert_eq!(non_matching.test_count(), 2);
342
343 assert_eq!(non_matching.children.len(), 2);
345 match &non_matching.children[0] {
346 CollectedCategoryOrTest::Test(test) => assert_eq!(test.name, "test_foo"),
347 _ => panic!("Expected test"),
348 }
349 match &non_matching.children[1] {
350 CollectedCategoryOrTest::Category(cat) => {
351 assert_eq!(cat.name, "nested");
352 assert_eq!(cat.children.len(), 1);
353 match &cat.children[0] {
354 CollectedCategoryOrTest::Test(test) => {
355 assert_eq!(test.name, "test_qux")
356 }
357 _ => panic!("Expected test"),
358 }
359 }
360 _ => panic!("Expected category"),
361 }
362 }
363
364 #[test]
365 fn test_partition_empty_categories_filtered() {
366 let category = CollectedTestCategory {
368 name: "root".to_string(),
369 path: PathBuf::from("/root"),
370 children: vec![
371 CollectedCategoryOrTest::Test(CollectedTest {
372 name: "test_match".to_string(),
373 path: PathBuf::from("/root/match.rs"),
374 line_and_column: None,
375 data: (),
376 }),
377 CollectedCategoryOrTest::Category(CollectedTestCategory {
378 name: "nested".to_string(),
379 path: PathBuf::from("/root/nested"),
380 children: vec![CollectedCategoryOrTest::Test(CollectedTest {
381 name: "test_match2".to_string(),
382 path: PathBuf::from("/root/nested/match2.rs"),
383 line_and_column: None,
384 data: (),
385 })],
386 }),
387 ],
388 };
389
390 let (matching, non_matching) =
391 category.partition(|test| test.name.contains("match"));
392
393 assert_eq!(matching.test_count(), 2);
395 assert_eq!(matching.children.len(), 2);
396
397 assert_eq!(non_matching.test_count(), 0);
399 assert_eq!(non_matching.children.len(), 0);
400 assert!(non_matching.is_empty());
401 }
402
403 #[test]
404 fn test_into_flat_category() {
405 let category = CollectedTestCategory {
407 name: "root".to_string(),
408 path: PathBuf::from("/root"),
409 children: vec![
410 CollectedCategoryOrTest::Test(CollectedTest {
411 name: "test_1".to_string(),
412 path: PathBuf::from("/root/test1.rs"),
413 line_and_column: None,
414 data: (),
415 }),
416 CollectedCategoryOrTest::Category(CollectedTestCategory {
417 name: "nested1".to_string(),
418 path: PathBuf::from("/root/nested1"),
419 children: vec![
420 CollectedCategoryOrTest::Test(CollectedTest {
421 name: "test_2".to_string(),
422 path: PathBuf::from("/root/nested1/test2.rs"),
423 line_and_column: None,
424 data: (),
425 }),
426 CollectedCategoryOrTest::Category(CollectedTestCategory {
427 name: "deeply_nested".to_string(),
428 path: PathBuf::from("/root/nested1/deeply"),
429 children: vec![CollectedCategoryOrTest::Test(CollectedTest {
430 name: "test_3".to_string(),
431 path: PathBuf::from("/root/nested1/deeply/test3.rs"),
432 line_and_column: None,
433 data: (),
434 })],
435 }),
436 ],
437 }),
438 CollectedCategoryOrTest::Category(CollectedTestCategory {
439 name: "nested2".to_string(),
440 path: PathBuf::from("/root/nested2"),
441 children: vec![CollectedCategoryOrTest::Test(CollectedTest {
442 name: "test_4".to_string(),
443 path: PathBuf::from("/root/nested2/test4.rs"),
444 line_and_column: None,
445 data: (),
446 })],
447 }),
448 ],
449 };
450
451 let flattened = category.into_flat_category();
452
453 assert_eq!(flattened.name, "root");
455 assert_eq!(flattened.path, PathBuf::from("/root"));
456
457 assert_eq!(flattened.children.len(), 4);
459 assert_eq!(flattened.test_count(), 4);
460
461 for child in &flattened.children {
463 assert!(matches!(child, CollectedCategoryOrTest::Test(_)));
464 }
465
466 let test_names: Vec<String> = flattened
468 .children
469 .iter()
470 .filter_map(|child| match child {
471 CollectedCategoryOrTest::Test(test) => Some(test.name.clone()),
472 _ => None,
473 })
474 .collect();
475
476 assert_eq!(test_names.len(), 4);
477 assert!(test_names.contains(&"test_1".to_string()));
478 assert!(test_names.contains(&"test_2".to_string()));
479 assert!(test_names.contains(&"test_3".to_string()));
480 assert!(test_names.contains(&"test_4".to_string()));
481 }
482}