1use std::collections::{BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use rustpython_ast::Visitor;
8use rustpython_parser::lexer::lex;
9use rustpython_parser::{ast, Mode, Parse, Tok};
10use syn::visit::{self, Visit};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
14pub enum Language {
15 #[value(name = "python")]
17 Python,
18 #[value(name = "typescript")]
21 TypeScript,
22 #[value(name = "rust")]
27 Rust,
28}
29
30impl Language {
31 pub(crate) fn tracks(self, path: &Path) -> bool {
33 match self {
34 Language::Python => has_extension(path, &["py"]),
35 Language::TypeScript => {
36 has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
37 }
38 Language::Rust => false,
39 }
40 }
41
42 pub(crate) fn is_test(self, path: &Path) -> bool {
44 match self {
45 Language::Python => stem_of(path).ends_with("_test"),
46 Language::TypeScript => {
47 let name = file_name_of(path);
48 name.ends_with(".test.ts")
49 || name.ends_with(".test.tsx")
50 || name.ends_with(".test.mts")
51 || name.ends_with(".test.cts")
52 }
53 Language::Rust => false,
54 }
55 }
56
57 pub(crate) fn is_support(self, path: &Path) -> bool {
59 match self {
60 Language::Python => file_name_of(path) == "conftest.py",
61 Language::TypeScript | Language::Rust => false,
62 }
63 }
64
65 pub(crate) fn is_subject(self, source: &str, path: &Path) -> bool {
69 match self {
70 Language::Python => python_has_behavior(source),
71 Language::TypeScript => crate::ts::has_behavior(source, path),
72 Language::Rust => rust_has_behavior(source),
73 }
74 }
75
76 pub(crate) fn same_code(self, base: &str, head: &str, path: &Path) -> bool {
80 match self {
81 Language::Python => python_same_code(base, head),
82 Language::TypeScript => crate::ts::same_code(base, head, path),
83 Language::Rust => false,
85 }
86 }
87
88 pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
90 match self {
91 Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
92 Language::TypeScript => {
93 source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
94 }
95 Language::Rust => source.to_path_buf(),
97 }
98 }
99}
100
101pub fn missing_unit_tests(
105 root: impl AsRef<Path>,
106 language: Language,
107 exempt: &BTreeSet<String>,
108) -> Result<Vec<PathBuf>> {
109 let root = root.as_ref();
110 let mut files = Vec::new();
111 collect_files(root, language, &mut files)?;
112 let manifest = match language {
114 Language::Python => Some("pyproject.toml"),
115 Language::TypeScript => Some("package.json"),
116 Language::Rust => None,
117 };
118 if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
119 files.retain(|file| !file.starts_with(&tests));
120 }
121
122 let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
123
124 let mut orphans: Vec<PathBuf> = Vec::new();
125 for source in &files {
126 if language.is_test(source) || language.is_support(source) {
127 continue;
128 }
129 if present.contains(language.expected_test_path(source).as_path()) {
130 continue;
131 }
132 let contents = std::fs::read_to_string(source)
134 .with_context(|| format!("reading source file `{}`", source.display()))?;
135 if !language.is_subject(&contents, source) {
136 continue;
137 }
138 let relative = source
139 .strip_prefix(root)
140 .unwrap_or(source)
141 .to_string_lossy()
142 .replace('\\', "/");
143 if exempt.contains(&relative) {
144 continue;
145 }
146 orphans.push(source.clone());
147 }
148 orphans.sort();
149 Ok(orphans)
150}
151
152pub(crate) fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
154 let entries =
155 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
156 for entry in entries {
157 let path = crate::walk::dir_entry(entry, dir)?.path();
158 if path.is_dir() {
159 collect_files(&path, language, out)?;
160 } else if language.tracks(&path) {
161 out.push(path);
162 }
163 }
164 Ok(())
165}
166
167pub fn missing_inline_tests(
171 root: impl AsRef<Path>,
172 exempt: &BTreeSet<String>,
173) -> Result<Vec<PathBuf>> {
174 let root = root.as_ref();
175 let mut files = Vec::new();
176 collect_rust_source_files(root, &mut files)?;
177 files.sort();
178
179 let mut orphans = Vec::new();
180 for file in &files {
181 let source = std::fs::read_to_string(file)
182 .with_context(|| format!("reading source file `{}`", file.display()))?;
183 let ast = syn::parse_file(&source)
184 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
185 let mut visitor = PresenceVisitor::default();
186 visitor.visit_file(&ast);
187 if !visitor.has_testable_fn || visitor.has_test_module {
188 continue;
189 }
190 let relative = file
191 .strip_prefix(root)
192 .unwrap_or(file)
193 .to_string_lossy()
194 .replace('\\', "/");
195 if exempt.contains(&relative) {
196 continue;
197 }
198 orphans.push(file.clone());
199 }
200 Ok(orphans)
202}
203
204pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
207 let entries =
208 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
209 for entry in entries {
210 let path = crate::walk::dir_entry(entry, dir)?.path();
211 if path.is_dir() {
212 let skip = matches!(
213 path.file_name().and_then(|name| name.to_str()),
214 Some("tests" | "benches" | "examples" | "target")
215 );
216 if !skip {
217 collect_rust_source_files(&path, out)?;
218 }
219 } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
220 out.push(path);
221 }
222 }
223 Ok(())
224}
225
226#[derive(Default)]
229struct PresenceVisitor {
230 test_depth: usize,
231 has_testable_fn: bool,
232 has_test_module: bool,
233}
234
235impl<'ast> Visit<'ast> for PresenceVisitor {
236 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
237 let is_test = crate::isolation::has_cfg_test(&node.attrs);
238 if is_test {
239 self.has_test_module = true;
240 self.test_depth += 1;
241 }
242 visit::visit_item_mod(self, node);
243 if is_test {
244 self.test_depth -= 1;
245 }
246 }
247
248 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
249 if self.test_depth == 0
250 && !crate::isolation::has_cfg_test(&node.attrs)
251 && !is_logic_free_main(node)
252 {
253 self.has_testable_fn = true;
254 }
255 visit::visit_item_fn(self, node);
256 }
257
258 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
259 if self.test_depth == 0 {
260 self.has_testable_fn = true;
261 }
262 visit::visit_impl_item_fn(self, node);
263 }
264
265 fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
266 if self.test_depth == 0 && node.default.is_some() {
267 self.has_testable_fn = true;
268 }
269 visit::visit_trait_item_fn(self, node);
270 }
271}
272
273fn is_logic_free_main(node: &syn::ItemFn) -> bool {
283 if node.sig.ident != "main" {
284 return false;
285 }
286 let [syn::Stmt::Expr(syn::Expr::Call(call), _)] = &node.block.stmts[..] else {
287 return false;
288 };
289 call.args.is_empty() && matches!(*call.func, syn::Expr::Path(_))
290}
291
292fn rust_has_behavior(source: &str) -> bool {
296 let Ok(file) = syn::parse_file(source) else {
297 return true;
298 };
299 let mut visitor = BehaviorPresenceVisitor { found: false };
300 visitor.visit_file(&file);
301 visitor.found
302}
303
304struct BehaviorPresenceVisitor {
306 found: bool,
307}
308
309impl<'ast> Visit<'ast> for BehaviorPresenceVisitor {
310 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
311 self.found = true;
312 visit::visit_item_fn(self, node);
313 }
314
315 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
316 self.found = true;
317 visit::visit_impl_item_fn(self, node);
318 }
319
320 fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
321 if node.default.is_some() {
322 self.found = true;
323 }
324 visit::visit_trait_item_fn(self, node);
325 }
326
327 fn visit_expr_closure(&mut self, node: &'ast syn::ExprClosure) {
328 self.found = true;
329 visit::visit_expr_closure(self, node);
330 }
331}
332
333fn has_extension(path: &Path, extensions: &[&str]) -> bool {
335 path.extension()
336 .and_then(|ext| ext.to_str())
337 .is_some_and(|ext| extensions.contains(&ext))
338}
339
340fn is_declaration(path: &Path) -> bool {
342 let name = file_name_of(path);
343 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
344}
345
346fn python_same_code(base: &str, head: &str) -> bool {
350 match (python_tokens(base), python_tokens(head)) {
351 (Some(base), Some(head)) => base == head,
352 _ => false,
353 }
354}
355
356fn python_has_behavior(source: &str) -> bool {
359 let Ok(suite) = ast::Suite::parse(source, "<source>") else {
360 return true;
361 };
362 let mut visitor = BehaviorVisitor { found: false };
363 for stmt in suite {
364 visitor.visit_stmt(stmt);
365 }
366 visitor.found
367}
368
369struct BehaviorVisitor {
371 found: bool,
372}
373
374impl Visitor for BehaviorVisitor {
375 fn visit_stmt_function_def(&mut self, _: ast::StmtFunctionDef) {
376 self.found = true;
377 }
378
379 fn visit_stmt_async_function_def(&mut self, _: ast::StmtAsyncFunctionDef) {
380 self.found = true;
381 }
382
383 fn visit_expr_lambda(&mut self, _: ast::ExprLambda) {
384 self.found = true;
385 }
386
387 fn visit_stmt_if(&mut self, _: ast::StmtIf) {
388 self.found = true;
389 }
390
391 fn visit_stmt_for(&mut self, _: ast::StmtFor) {
392 self.found = true;
393 }
394
395 fn visit_stmt_async_for(&mut self, _: ast::StmtAsyncFor) {
396 self.found = true;
397 }
398
399 fn visit_stmt_while(&mut self, _: ast::StmtWhile) {
400 self.found = true;
401 }
402
403 fn visit_stmt_try(&mut self, _: ast::StmtTry) {
404 self.found = true;
405 }
406
407 fn visit_stmt_try_star(&mut self, _: ast::StmtTryStar) {
408 self.found = true;
409 }
410
411 fn visit_stmt_with(&mut self, _: ast::StmtWith) {
412 self.found = true;
413 }
414
415 fn visit_stmt_async_with(&mut self, _: ast::StmtAsyncWith) {
416 self.found = true;
417 }
418
419 fn visit_stmt_match(&mut self, _: ast::StmtMatch) {
420 self.found = true;
421 }
422
423 fn visit_expr_if_exp(&mut self, _: ast::ExprIfExp) {
424 self.found = true;
425 }
426
427 fn visit_comprehension(&mut self, node: ast::Comprehension) {
430 self.found |= !node.ifs.is_empty();
431 self.visit_expr(node.iter);
432 }
433
434 fn visit_keyword(&mut self, node: ast::Keyword) {
435 self.visit_expr(node.value);
436 }
437}
438
439fn python_tokens(source: &str) -> Option<Vec<Tok>> {
441 let tokens: Vec<Tok> = lex(source, Mode::Module)
442 .map(|token| token.ok().map(|(tok, _)| tok))
443 .collect::<Option<_>>()?;
444 ast::Suite::parse(source, "<source>").ok()?;
447 Some(tokens)
448}
449
450fn extension_of(path: &Path) -> String {
452 path.extension()
453 .map(|ext| ext.to_string_lossy().into_owned())
454 .unwrap_or_default()
455}
456
457fn file_name_of(path: &Path) -> String {
459 path.file_name()
460 .map(|name| name.to_string_lossy().into_owned())
461 .unwrap_or_default()
462}
463
464fn stem_of(path: &Path) -> String {
466 path.file_stem()
467 .map(|stem| stem.to_string_lossy().into_owned())
468 .unwrap_or_default()
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn python_tracks_py_files() {
477 assert!(Language::Python.tracks(Path::new("a.py")));
478 assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
479 assert!(!Language::Python.tracks(Path::new("a.pyi")));
480 assert!(!Language::Python.tracks(Path::new("a.txt")));
481 assert!(!Language::Python.tracks(Path::new("README")));
482 }
483
484 #[test]
485 fn python_recognizes_test_files_by_stem_suffix() {
486 assert!(Language::Python.is_test(Path::new("widget_test.py")));
487 assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
488 assert!(!Language::Python.is_test(Path::new("widget.py")));
489 }
490
491 #[test]
492 fn python_conftest_is_support_not_a_subject() {
493 assert!(Language::Python.is_support(Path::new("conftest.py")));
494 assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
495 assert!(!Language::Python.is_support(Path::new("widget.py")));
496 assert!(!Language::Python.is_support(Path::new("widget_test.py")));
497 assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
498 }
499
500 #[test]
501 fn python_expected_test_path_is_the_colocated_twin() {
502 assert_eq!(
503 Language::Python.expected_test_path(Path::new("pkg/widget.py")),
504 PathBuf::from("pkg/widget_test.py")
505 );
506 assert_eq!(
507 Language::Python.expected_test_path(Path::new("widget.py")),
508 PathBuf::from("widget_test.py")
509 );
510 }
511
512 #[test]
513 fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
514 assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
515 assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
516 assert!(Language::TypeScript.tracks(Path::new("service.mts")));
517 assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
518 assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
519 assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
520 assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
521 assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
522 assert!(!Language::TypeScript.tracks(Path::new("README")));
523 }
524
525 #[test]
526 fn typescript_recognizes_test_files_by_suffix() {
527 assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
528 assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
529 assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
530 assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
531 assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
532 assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
533 assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
534 }
535
536 #[test]
537 fn typescript_expected_test_path_keeps_the_extension() {
538 assert_eq!(
539 Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
540 PathBuf::from("pkg/widget.test.ts")
541 );
542 assert_eq!(
543 Language::TypeScript.expected_test_path(Path::new("button.tsx")),
544 PathBuf::from("button.test.tsx")
545 );
546 assert_eq!(
547 Language::TypeScript.expected_test_path(Path::new("service.mts")),
548 PathBuf::from("service.test.mts")
549 );
550 assert_eq!(
551 Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
552 PathBuf::from("legacy.test.cts")
553 );
554 }
555
556 #[test]
557 fn typescript_subject_skips_type_only_modules() {
558 let ts = Path::new("aliases.ts");
559 assert!(!Language::TypeScript.is_subject("export type Alias = string;\n", ts));
560 assert!(!Language::TypeScript.is_subject("export interface Shape { kind: string }\n", ts));
561 assert!(!Language::TypeScript.is_subject("import type { A } from './a';\n", ts));
562 }
563
564 #[test]
565 fn typescript_subject_needs_a_function_or_control_flow() {
566 let ts = Path::new("widget.ts");
567 assert!(Language::TypeScript.is_subject("export const x = () => 1;\n", ts));
568 assert!(Language::TypeScript.is_subject(
569 "export type Alias = string;\nexport const x = () => 1;\n",
570 ts
571 ));
572 assert!(!Language::TypeScript.is_subject("export const x = 1;\n", ts));
573 assert!(!Language::TypeScript.is_subject("", ts));
574 assert!(!Language::TypeScript.is_subject(" \n\t\n", ts));
575 assert!(!Language::TypeScript.is_subject("// nothing here\n", ts));
576 assert!(!Language::TypeScript.is_subject("/* a\n block\n comment */\n", ts));
577 }
578
579 fn py_subject(source: &str) -> bool {
580 Language::Python.is_subject(source, Path::new("widget.py"))
581 }
582
583 #[test]
584 fn python_declaration_only_module_is_not_a_subject() {
585 assert!(!py_subject("x = 1\n"));
586 assert!(!py_subject("Alias = str\n"));
587 assert!(!py_subject(
588 "\"\"\"pkg.\"\"\"\nfrom ._version import __version__\n__all__ = [\"__version__\"]\n"
589 ));
590 assert!(!py_subject("class Color(Enum):\n RED = 1\n"));
591 assert!(!py_subject(
592 "@dataclass\nclass Point:\n x: int\n y: int\n"
593 ));
594 assert!(!py_subject("logger = getLogger(__name__)\n"));
595 assert!(!py_subject("TIMEOUT = 30 * 60\nNAME = f\"{TIMEOUT}s\"\n"));
596 assert!(!py_subject("\"\"\"Package docstring.\"\"\"\n"));
597 assert!(!py_subject(""));
598 assert!(!py_subject("\n \n"));
599 assert!(!py_subject("# just a comment\n # another\n"));
600 }
601
602 #[test]
603 fn python_short_circuit_and_a_plain_comprehension_are_not_control_flow() {
604 assert!(!py_subject("DEBUG = os.environ.get(\"DEBUG\") or \"0\"\n"));
605 assert!(!py_subject("READY = a and b\n"));
606 assert!(!py_subject("NAMES = [c.name for c in Color]\n"));
607 assert!(!py_subject("BY_NAME = {c.name: c for c in Color}\n"));
608 }
609
610 #[test]
611 fn python_every_function_form_is_a_subject() {
612 assert!(py_subject("def f():\n return 1\n"));
613 assert!(py_subject("async def f():\n return 1\n"));
614 assert!(py_subject("HANDLERS = {\"id\": lambda x: x}\n"));
615 assert!(py_subject(
616 "class Widget:\n def run(self):\n return 1\n"
617 ));
618 assert!(py_subject(
619 "class Runner(Protocol):\n def run(self) -> int: ...\n"
620 ));
621 }
622
623 #[test]
624 fn python_every_control_flow_form_is_a_subject() {
625 assert!(py_subject("if DEBUG:\n LEVEL = 10\n"));
626 assert!(py_subject("for i in range(3):\n pass\n"));
627 assert!(py_subject("async for i in aiter():\n pass\n"));
628 assert!(py_subject("while True:\n pass\n"));
629 assert!(py_subject(
630 "try:\n import x\nexcept ImportError:\n x = None\n"
631 ));
632 assert!(py_subject(
633 "try:\n import x\nexcept* ImportError:\n x = None\n"
634 ));
635 assert!(py_subject("with open(p) as f:\n DATA = f.read()\n"));
636 assert!(py_subject("async with lock:\n pass\n"));
637 assert!(py_subject("match cmd:\n case \"go\":\n pass\n"));
638 assert!(py_subject("LEVEL = 10 if DEBUG else 20\n"));
639 assert!(py_subject("EVENS = [n for n in range(9) if n % 2 == 0]\n"));
640 }
641
642 #[test]
643 fn python_control_flow_nested_in_an_expression_is_a_subject() {
644 assert!(py_subject("CONFIG = {\"level\": (10 if DEBUG else 20)}\n"));
645 assert!(py_subject("run(callback=lambda: None)\n"));
646 assert!(py_subject(
647 "ORDERED = [x for x in sorted(xs, key=lambda x: x.id)]\n"
648 ));
649 }
650
651 #[test]
652 fn python_unparsable_content_stays_a_subject() {
653 assert!(py_subject("def f(:\n"));
654 }
655
656 const PY_WIDGET: &str = "def widget():\n return 1\n";
657
658 #[test]
659 fn python_same_code_ignores_comments_and_formatting() {
660 let py = Path::new("widget.py");
661 assert!(Language::Python.same_code(
662 "# widget helpers\ndef widget():\n return 1\n",
663 "# widget utilities\ndef widget():\n return 1\n",
664 py
665 ));
666 assert!(Language::Python.same_code(
667 "# widget helpers\ndef widget():\n return 1\n",
668 PY_WIDGET,
669 py
670 ));
671 assert!(Language::Python.same_code(PY_WIDGET, "def widget():\n\n return 1\n", py));
672 assert!(Language::Python.same_code("def widget(): \n return 1 \n", PY_WIDGET, py));
673 }
674
675 #[test]
676 fn python_same_code_sees_every_edit_the_interpreter_sees() {
677 let py = Path::new("widget.py");
678 assert!(!Language::Python.same_code(PY_WIDGET, "def widget():\n return 2\n", py));
679 assert!(!Language::Python.same_code(
680 "\"\"\"Widget helpers.\"\"\"\ndef widget():\n return 1\n",
681 "\"\"\"Widget utilities.\"\"\"\ndef widget():\n return 1\n",
682 py
683 ));
684 assert!(!Language::Python.same_code(
685 "def widget():\n return \"one\"\n",
686 "def widget():\n return \"two\"\n",
687 py
688 ));
689 assert!(!Language::Python.same_code(
690 "def widget(flag):\n if flag:\n count = 1\n return count\n",
691 "def widget(flag):\n if flag:\n count = 1\n return count\n",
692 py
693 ));
694 }
695
696 #[test]
697 fn python_same_code_holds_unparseable_content_apart() {
698 let py = Path::new("widget.py");
699 assert!(!Language::Python.same_code(
700 "def widget(:\n return 1\n",
701 "# note\ndef widget(:\n return 1\n",
702 py
703 ));
704 assert!(!Language::Python.same_code(
705 "def widget() return 1\n",
706 "# note\ndef widget() return 1\n",
707 py
708 ));
709 assert!(!Language::Python.same_code(PY_WIDGET, "def widget() return 1\n", py));
710 assert!(!Language::Python.same_code("def widget() return 1\n", PY_WIDGET, py));
711 }
712
713 #[test]
714 fn typescript_same_code_reads_the_emitted_module() {
715 let ts = Path::new("widget.ts");
716 assert!(Language::TypeScript.same_code(
717 "// widget factory\nexport const widget = () => 1;\n",
718 "export const widget = () => 1;\n",
719 ts
720 ));
721 assert!(!Language::TypeScript.same_code(
722 "export const widget = () => 1;\n",
723 "export const widget = () => 2;\n",
724 ts
725 ));
726 }
727
728 #[test]
729 fn rust_same_code_never_answers_equal() {
730 assert!(!Language::Rust.same_code("fn f() {}\n", "fn f() {}\n", Path::new("lib.rs")));
731 }
732
733 #[test]
734 fn rust_has_no_file_based_colocated_convention() {
735 assert!(!Language::Rust.tracks(Path::new("lib.rs")));
736 assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
737 assert!(Language::Rust.is_subject("fn main() {}\n", Path::new("main.rs")));
738 assert_eq!(
739 Language::Rust.expected_test_path(Path::new("src/lib.rs")),
740 PathBuf::from("src/lib.rs")
741 );
742 }
743
744 #[test]
745 fn rust_const_only_file_is_not_a_subject() {
746 assert!(!Language::Rust.is_subject(
747 "pub const TIMEOUT: u64 = 30 * 60;\n",
748 Path::new("settings.rs")
749 ));
750 }
751
752 #[test]
753 fn rust_unparseable_file_is_a_subject() {
754 assert!(Language::Rust.is_subject("fn (", Path::new("broken.rs")));
755 }
756
757 #[test]
758 fn rust_impl_method_is_a_subject() {
759 assert!(Language::Rust.is_subject(
760 "struct Widget;\nimpl Widget {\n fn run(&self) {}\n}\n",
761 Path::new("widget.rs")
762 ));
763 }
764
765 #[test]
766 fn rust_default_trait_method_is_a_subject() {
767 assert!(Language::Rust.is_subject(
768 "trait Greeter {\n fn greet(&self) {\n println!(\"hi\");\n }\n}\n",
769 Path::new("greeter.rs")
770 ));
771 }
772
773 #[test]
774 fn rust_trait_method_signature_without_a_default_is_not_a_subject() {
775 assert!(!Language::Rust.is_subject(
776 "trait Greeter {\n fn greet(&self);\n}\n",
777 Path::new("greeter.rs")
778 ));
779 }
780
781 #[test]
782 fn rust_closure_outside_any_fn_is_a_subject() {
783 assert!(Language::Rust.is_subject(
784 "static ADDER: fn(i32) -> i32 = |x| x + 1;\n",
785 Path::new("adder.rs")
786 ));
787 }
788
789 fn presence(src: &str) -> (bool, bool) {
791 let ast = syn::parse_file(src).expect("snippet parses");
792 let mut visitor = PresenceVisitor::default();
793 visitor.visit_file(&ast);
794 (visitor.has_testable_fn, visitor.has_test_module)
795 }
796
797 #[test]
798 fn rust_presence_free_fn_with_test_module_is_covered() {
799 assert_eq!(
800 presence(
801 "pub fn make(n: u8) -> u8 { n + 1 }\n\
802 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
803 ),
804 (true, true)
805 );
806 }
807
808 #[test]
809 fn rust_presence_free_fn_without_test_module_needs_one() {
810 assert_eq!(
811 presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
812 (true, false)
813 );
814 }
815
816 #[test]
817 fn rust_presence_type_only_file_is_not_a_subject() {
818 assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
819 }
820
821 #[test]
822 fn rust_presence_impl_method_is_testable() {
823 assert_eq!(
824 presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
825 (true, false)
826 );
827 }
828
829 #[test]
830 fn rust_presence_logic_free_main_is_a_declaration() {
831 assert_eq!(
832 presence("fn main() -> ExitCode { mycrate::entrypoint::main() }\n"),
833 (false, false)
834 );
835 assert_eq!(presence("fn main() { mycrate::run() }\n"), (false, false));
836 assert_eq!(presence("fn main() { mycrate::run(); }\n"), (false, false));
838 }
839
840 #[test]
841 fn rust_presence_a_main_that_could_hold_a_decision_is_a_subject() {
842 assert_eq!(
844 presence("fn main() { mycrate::run(std::env::args_os()) }\n"),
845 (true, false)
846 );
847 assert_eq!(
849 presence("fn main() { setup(); mycrate::run() }\n"),
850 (true, false)
851 );
852 assert_eq!(presence("fn main() { mycrate::run()? }\n"), (true, false));
854 assert_eq!(
855 presence("fn main() { mycrate::run().into() }\n"),
856 (true, false)
857 );
858 assert_eq!(
859 presence("fn main() { if x { a() } else { b() } }\n"),
860 (true, false)
861 );
862 assert_eq!(presence("fn main() {}\n"), (true, false));
863 }
864
865 #[test]
866 fn rust_presence_only_main_earns_the_declaration_reading() {
867 assert_eq!(presence("fn run() { mycrate::go() }\n"), (true, false));
868 }
869
870 #[test]
871 fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
872 assert_eq!(
873 presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
874 (true, false)
875 );
876 assert_eq!(
877 presence("pub trait T { fn s(&self) -> u8; }\n"),
878 (false, false)
879 );
880 }
881
882 #[test]
883 fn rust_presence_test_module_functions_are_not_subjects() {
884 assert_eq!(
885 presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
886 (false, true)
887 );
888 }
889
890 #[test]
891 fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
892 assert_eq!(
893 presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
894 (false, false)
895 );
896 }
897}