stern4rust/rules/layout/
paired_test_file_rule.rs1use std::collections::BTreeSet;
6
7use crate::reporting::offence::Offence;
8use crate::reporting::rule_explanation::RuleExplanation;
9use crate::rule::Rule;
10use crate::source_file::SourceFile;
11
12pub struct PairedTestFileRule;
36
37impl PairedTestFileRule {
38 pub const PROPTEST_POSTFIX: &'static str = "_proptest_tests.rs";
39 pub const REGISTRY: &'static str = "all_tests.rs";
40 pub const SOURCE_ROOT: &'static str = "src/";
41 pub const TESTS_POSTFIX: &'static str = "_tests.rs";
42 pub const TESTS_ROOT: &'static str = "tests/";
43
44 pub fn new() -> Self {
45 Self
46 }
47
48 fn is_test_file(path: &str) -> bool {
49 path.starts_with(Self::TESTS_ROOT)
50 && path.ends_with(Self::TESTS_POSTFIX)
51 && !path.ends_with(Self::PROPTEST_POSTFIX)
52 && Self::file_name(path) != Self::REGISTRY
53 }
54
55 fn file_name(path: &str) -> &str {
56 path.rsplit('/').next().unwrap_or(path)
57 }
58
59 fn source_of(path: &str) -> String {
63 let without_root = path.strip_prefix(Self::TESTS_ROOT).unwrap_or(path);
64 let stem = without_root
65 .strip_suffix(Self::TESTS_POSTFIX)
66 .unwrap_or(without_root);
67 format!("{}{stem}.rs", Self::SOURCE_ROOT)
68 }
69
70 fn present(files: &[SourceFile]) -> BTreeSet<&str> {
71 files
72 .iter()
73 .map(SourceFile::relative_path)
74 .filter(|path| path.starts_with(Self::SOURCE_ROOT))
75 .collect()
76 }
77
78 fn offence(&self, path: &str, expected: &str) -> Offence {
83 Offence::new(
84 path,
85 1,
86 self.name(),
87 format!("{path} is named for {expected}, which does not exist"),
88 "rename it after the source file it exercises, or delete it if that file is gone"
89 .to_string(),
90 )
91 .with_subject(path)
92 .with_expected(expected)
93 }
94}
95
96impl Default for PairedTestFileRule {
97 fn default() -> Self {
98 Self::new()
99 }
100}
101
102impl Rule for PairedTestFileRule {
103 fn name(&self) -> &'static str {
104 "paired-test-file"
105 }
106
107 fn check(&self, _file: &SourceFile) -> Vec<Offence> {
108 Vec::new()
109 }
110
111 fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
114 let present = Self::present(files);
115 files
116 .iter()
117 .map(SourceFile::relative_path)
118 .filter(|path| Self::is_test_file(path))
119 .filter_map(|path| {
120 let expected = Self::source_of(path);
121 (!present.contains(expected.as_str())).then(|| self.offence(path, &expected))
122 })
123 .collect()
124 }
125
126 fn requirement(&self) -> Option<&'static str> {
127 None
128 }
129
130 fn is_configured(&self) -> bool {
131 true
132 }
133
134 fn explanation(&self) -> RuleExplanation {
135 RuleExplanation::new(
136 self.name(),
137 "A <X>_tests.rs names the source file it exercises, and that file exists.",
138 "tests/widget_tests.rs -- with no src/widget.rs",
139 "tests/widget_tests.rs -- beside src/widget.rs",
140 )
141 }
142}