Skip to main content

testing_conventions/
colocated_test.rs

1//! The unit `colocated-test` check.
2
3use std::collections::{BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use rustpython_parser::lexer::lex;
8use rustpython_parser::{ast, Mode, Parse, Tok};
9use syn::visit::{self, Visit};
10
11/// A language whose colocated unit-test convention can be checked.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
13pub enum Language {
14    /// `foo.py` → colocated `foo_test.py`.
15    #[value(name = "python")]
16    Python,
17    /// `foo-bar.ts` → colocated `foo-bar.test.ts`, across `.ts`/`.tsx`/`.mts`/`.cts`;
18    /// declaration files (`.d.ts`/`.d.mts`/`.d.cts`) are ignored.
19    #[value(name = "typescript")]
20    TypeScript,
21    /// Rust units are inline `#[cfg(test)]` modules, not separate files, so the
22    /// file-pairing walk below does not apply to Rust; its arm of the rule checks
23    /// inline-`#[cfg(test)]` *presence* instead ([`missing_inline_tests`]). The
24    /// variant is also accepted by the other `--language` rules (e.g. `packaging`).
25    #[value(name = "rust")]
26    Rust,
27}
28
29impl Language {
30    /// `true` for a file this language's check tracks (source *or* test).
31    pub(crate) fn tracks(self, path: &Path) -> bool {
32        match self {
33            Language::Python => has_extension(path, &["py"]),
34            Language::TypeScript => {
35                has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
36            }
37            Language::Rust => false,
38        }
39    }
40
41    /// `true` when `path` is itself a unit test, never a subject.
42    pub(crate) fn is_test(self, path: &Path) -> bool {
43        match self {
44            Language::Python => stem_of(path).ends_with("_test"),
45            Language::TypeScript => {
46                let name = file_name_of(path);
47                name.ends_with(".test.ts")
48                    || name.ends_with(".test.tsx")
49                    || name.ends_with(".test.mts")
50                    || name.ends_with(".test.cts")
51            }
52            Language::Rust => false,
53        }
54    }
55
56    /// `true` when `path` is test *support* — Python's `conftest.py`, never a subject.
57    pub(crate) fn is_support(self, path: &Path) -> bool {
58        match self {
59            Language::Python => file_name_of(path) == "conftest.py",
60            Language::TypeScript | Language::Rust => false,
61        }
62    }
63
64    /// `true` when `source` holds at least one line of code — anything beyond blank
65    /// lines and comments.
66    pub(crate) fn has_code(self, source: &str) -> bool {
67        match self {
68            Language::Python => python_has_code(source),
69            Language::TypeScript => typescript_has_code(source),
70            Language::Rust => false,
71        }
72    }
73
74    /// `true` when `source` at `path` declares behavior a unit test can exercise. Presence
75    /// and the commit-scoped co-change check both decide subjecthood here, so they cannot
76    /// disagree about what has behavior.
77    pub(crate) fn is_subject(self, source: &str, path: &Path) -> bool {
78        if !self.has_code(source) {
79            return false;
80        }
81        match self {
82            Language::TypeScript => !crate::ts::is_type_only_module(source, path),
83            Language::Python | Language::Rust => true,
84        }
85    }
86
87    /// `true` when `base` and `head` — the file at `path` before and after an edit — hold
88    /// the same code once comments and formatting whitespace are normalized away. Content
89    /// that fails to parse on either side is **not** equal.
90    pub(crate) fn same_code(self, base: &str, head: &str, path: &Path) -> bool {
91        match self {
92            Language::Python => python_same_code(base, head),
93            Language::TypeScript => crate::ts::same_code(base, head, path),
94            // Unreachable for Rust; `false` keeps any caller that arrives flagged.
95            Language::Rust => false,
96        }
97    }
98
99    /// The colocated test `source` is expected to have.
100    pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
101        match self {
102            Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
103            Language::TypeScript => {
104                source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
105            }
106            // Unreachable for Rust (nothing is tracked); a harmless identity.
107            Language::Rust => source.to_path_buf(),
108        }
109    }
110}
111
112/// Every source file under `root` (for `language`) with no colocated unit test, sorted.
113/// `exempt` holds the rule's `root`-relative paths resolved from config
114/// ([`crate::config::resolve_exempt`]).
115pub fn missing_unit_tests(
116    root: impl AsRef<Path>,
117    language: Language,
118    exempt: &BTreeSet<String>,
119) -> Result<Vec<PathBuf>> {
120    let root = root.as_ref();
121    let mut files = Vec::new();
122    collect_files(root, language, &mut files)?;
123    // `<package root>/tests/` belongs to the suite tiers, so nothing under it is a subject.
124    let manifest = match language {
125        Language::Python => Some("pyproject.toml"),
126        Language::TypeScript => Some("package.json"),
127        Language::Rust => None,
128    };
129    if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
130        files.retain(|file| !file.starts_with(&tests));
131    }
132
133    let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
134
135    let mut orphans: Vec<PathBuf> = Vec::new();
136    for source in &files {
137        if language.is_test(source) || language.is_support(source) {
138            continue;
139        }
140        if present.contains(language.expected_test_path(source).as_path()) {
141            continue;
142        }
143        // Read only for a file that lacks a twin, so the common case costs no file read.
144        let contents = std::fs::read_to_string(source)
145            .with_context(|| format!("reading source file `{}`", source.display()))?;
146        if !language.is_subject(&contents, source) {
147            continue;
148        }
149        let relative = source
150            .strip_prefix(root)
151            .unwrap_or(source)
152            .to_string_lossy()
153            .replace('\\', "/");
154        if exempt.contains(&relative) {
155            continue;
156        }
157        orphans.push(source.clone());
158    }
159    orphans.sort();
160    Ok(orphans)
161}
162
163/// Recursively collect every file `language` tracks under `dir` into `out`.
164pub(crate) fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
165    let entries =
166        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
167    for entry in entries {
168        let path = entry
169            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
170            .path();
171        if path.is_dir() {
172            collect_files(&path, language, out)?;
173        } else if language.tracks(&path) {
174            out.push(path);
175        }
176    }
177    Ok(())
178}
179
180/// Every Rust source file under `root` that defines testable behavior — a function with a
181/// body, outside any `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module,
182/// sorted. `exempt` holds the rule's `root`-relative paths resolved from config.
183pub fn missing_inline_tests(
184    root: impl AsRef<Path>,
185    exempt: &BTreeSet<String>,
186) -> Result<Vec<PathBuf>> {
187    let root = root.as_ref();
188    let mut files = Vec::new();
189    collect_rust_source_files(root, &mut files)?;
190    files.sort();
191
192    let mut orphans = Vec::new();
193    for file in &files {
194        let source = std::fs::read_to_string(file)
195            .with_context(|| format!("reading source file `{}`", file.display()))?;
196        let ast = syn::parse_file(&source)
197            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
198        let mut visitor = PresenceVisitor::default();
199        visitor.visit_file(&ast);
200        if !visitor.has_testable_fn || visitor.has_test_module {
201            continue;
202        }
203        let relative = file
204            .strip_prefix(root)
205            .unwrap_or(file)
206            .to_string_lossy()
207            .replace('\\', "/");
208        if exempt.contains(&relative) {
209            continue;
210        }
211        orphans.push(file.clone());
212    }
213    // `files` is already sorted, so `orphans` is in order.
214    Ok(orphans)
215}
216
217/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping the
218/// non-unit trees — `tests/`, `benches/`, `examples/`, `target/` — and `build.rs`.
219pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
220    let entries =
221        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
222    for entry in entries {
223        let path = entry
224            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
225            .path();
226        if path.is_dir() {
227            let skip = matches!(
228                path.file_name().and_then(|name| name.to_str()),
229                Some("tests" | "benches" | "examples" | "target")
230            );
231            if !skip {
232                collect_rust_source_files(&path, out)?;
233            }
234        } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
235            out.push(path);
236        }
237    }
238    Ok(())
239}
240
241/// Answers, for a parsed Rust file, whether it defines testable behavior outside any
242/// `#[cfg(test)]` module and whether it carries an inline `#[cfg(test)]` module.
243#[derive(Default)]
244struct PresenceVisitor {
245    test_depth: usize,
246    has_testable_fn: bool,
247    has_test_module: bool,
248}
249
250impl<'ast> Visit<'ast> for PresenceVisitor {
251    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
252        let is_test = crate::isolation::has_cfg_test(&node.attrs);
253        if is_test {
254            self.has_test_module = true;
255            self.test_depth += 1;
256        }
257        visit::visit_item_mod(self, node);
258        if is_test {
259            self.test_depth -= 1;
260        }
261    }
262
263    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
264        if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
265            self.has_testable_fn = true;
266        }
267        visit::visit_item_fn(self, node);
268    }
269
270    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
271        if self.test_depth == 0 {
272            self.has_testable_fn = true;
273        }
274        visit::visit_impl_item_fn(self, node);
275    }
276
277    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
278        if self.test_depth == 0 && node.default.is_some() {
279            self.has_testable_fn = true;
280        }
281        visit::visit_trait_item_fn(self, node);
282    }
283}
284
285/// `true` when the file's extension is one of `extensions`.
286fn has_extension(path: &Path, extensions: &[&str]) -> bool {
287    path.extension()
288        .and_then(|ext| ext.to_str())
289        .is_some_and(|ext| extensions.contains(&ext))
290}
291
292/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`).
293fn is_declaration(path: &Path) -> bool {
294    let name = file_name_of(path);
295    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
296}
297
298/// `true` when any line of Python `source` is neither blank nor a `#` comment.
299fn python_has_code(source: &str) -> bool {
300    source.lines().any(|line| {
301        let trimmed = line.trim_start();
302        !trimmed.is_empty() && !trimmed.starts_with('#')
303    })
304}
305
306/// `true` when Python `base` and `head` tokenize identically. Comments and blank lines
307/// never reach a token — the parser's `full-lexer` feature is off — while `Indent` /
308/// `Dedent` do, so re-indenting a statement is a code change.
309fn python_same_code(base: &str, head: &str) -> bool {
310    match (python_tokens(base), python_tokens(head)) {
311        (Some(base), Some(head)) => base == head,
312        _ => false,
313    }
314}
315
316/// The token stream of Python `source`, or `None` when `source` is not a valid module.
317fn python_tokens(source: &str) -> Option<Vec<Tok>> {
318    let tokens: Vec<Tok> = lex(source, Mode::Module)
319        .map(|token| token.ok().map(|(tok, _)| tok))
320        .collect::<Option<_>>()?;
321    // The lexer accepts token sequences the grammar rejects (`def f() return 1` lexes
322    // cleanly), so the parse decides validity while the tokens carry the comparison.
323    ast::Suite::parse(source, "<source>").ok()?;
324    Some(tokens)
325}
326
327/// `true` when TypeScript `source` holds anything beyond whitespace and `//` / `/* … */`
328/// comments. Any other character — including a string literal's quote — counts as code.
329fn typescript_has_code(source: &str) -> bool {
330    let mut chars = source.chars().peekable();
331    while let Some(c) = chars.next() {
332        match c {
333            c if c.is_whitespace() => {}
334            '/' if chars.peek() == Some(&'/') => {
335                while chars.peek().is_some_and(|&n| n != '\n') {
336                    chars.next();
337                }
338            }
339            '/' if chars.peek() == Some(&'*') => {
340                chars.next();
341                let mut prev = '\0';
342                for n in chars.by_ref() {
343                    if prev == '*' && n == '/' {
344                        break;
345                    }
346                    prev = n;
347                }
348            }
349            _ => return true,
350        }
351    }
352    false
353}
354
355/// The file extension, lossily decoded (empty if there is none).
356fn extension_of(path: &Path) -> String {
357    path.extension()
358        .map(|ext| ext.to_string_lossy().into_owned())
359        .unwrap_or_default()
360}
361
362/// The file name, lossily decoded.
363fn file_name_of(path: &Path) -> String {
364    path.file_name()
365        .map(|name| name.to_string_lossy().into_owned())
366        .unwrap_or_default()
367}
368
369/// The file stem (the name without its extension), lossily decoded.
370fn stem_of(path: &Path) -> String {
371    path.file_stem()
372        .map(|stem| stem.to_string_lossy().into_owned())
373        .unwrap_or_default()
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn python_tracks_py_files() {
382        assert!(Language::Python.tracks(Path::new("a.py")));
383        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
384        assert!(!Language::Python.tracks(Path::new("a.pyi")));
385        assert!(!Language::Python.tracks(Path::new("a.txt")));
386        assert!(!Language::Python.tracks(Path::new("README")));
387    }
388
389    #[test]
390    fn python_recognizes_test_files_by_stem_suffix() {
391        assert!(Language::Python.is_test(Path::new("widget_test.py")));
392        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
393        assert!(!Language::Python.is_test(Path::new("widget.py")));
394    }
395
396    #[test]
397    fn python_conftest_is_support_not_a_subject() {
398        assert!(Language::Python.is_support(Path::new("conftest.py")));
399        assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
400        assert!(!Language::Python.is_support(Path::new("widget.py")));
401        assert!(!Language::Python.is_support(Path::new("widget_test.py")));
402        assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
403    }
404
405    #[test]
406    fn python_expected_test_path_is_the_colocated_twin() {
407        assert_eq!(
408            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
409            PathBuf::from("pkg/widget_test.py")
410        );
411        assert_eq!(
412            Language::Python.expected_test_path(Path::new("widget.py")),
413            PathBuf::from("widget_test.py")
414        );
415    }
416
417    #[test]
418    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
419        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
420        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
421        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
422        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
423        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
424        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
425        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
426        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
427        assert!(!Language::TypeScript.tracks(Path::new("README")));
428    }
429
430    #[test]
431    fn typescript_recognizes_test_files_by_suffix() {
432        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
433        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
434        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
435        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
436        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
437        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
438        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
439    }
440
441    #[test]
442    fn typescript_expected_test_path_keeps_the_extension() {
443        assert_eq!(
444            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
445            PathBuf::from("pkg/widget.test.ts")
446        );
447        assert_eq!(
448            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
449            PathBuf::from("button.test.tsx")
450        );
451        assert_eq!(
452            Language::TypeScript.expected_test_path(Path::new("service.mts")),
453            PathBuf::from("service.test.mts")
454        );
455        assert_eq!(
456            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
457            PathBuf::from("legacy.test.cts")
458        );
459    }
460
461    #[test]
462    fn python_empty_or_comment_only_files_have_no_code() {
463        assert!(!Language::Python.has_code(""));
464        assert!(!Language::Python.has_code("\n   \n"));
465        assert!(!Language::Python.has_code("# just a comment\n   # another\n"));
466    }
467
468    #[test]
469    fn python_real_content_counts_as_code() {
470        assert!(Language::Python.has_code("x = 1\n"));
471        assert!(Language::Python.has_code("# header\nimport os\n"));
472        assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
473    }
474
475    #[test]
476    fn typescript_empty_or_comment_only_files_have_no_code() {
477        assert!(!Language::TypeScript.has_code(""));
478        assert!(!Language::TypeScript.has_code("   \n\t\n"));
479        assert!(!Language::TypeScript.has_code("// a line comment\n"));
480        assert!(!Language::TypeScript.has_code("/* a\n   block\n   comment */\n"));
481    }
482
483    #[test]
484    fn typescript_real_content_counts_as_code() {
485        assert!(Language::TypeScript.has_code("export const x = 1;\n"));
486        assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
487        assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
488        assert!(Language::TypeScript.has_code("const r = a / b;\n"));
489    }
490
491    #[test]
492    fn typescript_subject_skips_type_only_modules() {
493        let ts = Path::new("aliases.ts");
494        assert!(!Language::TypeScript.is_subject("export type Alias = string;\n", ts));
495        assert!(!Language::TypeScript.is_subject("export interface Shape { kind: string }\n", ts));
496        assert!(!Language::TypeScript.is_subject("import type { A } from './a';\n", ts));
497    }
498
499    #[test]
500    fn typescript_subject_keeps_anything_with_runtime_behavior() {
501        let ts = Path::new("widget.ts");
502        assert!(Language::TypeScript.is_subject("export const x = 1;\n", ts));
503        assert!(Language::TypeScript
504            .is_subject("export type Alias = string;\nexport const x = 1;\n", ts));
505        assert!(!Language::TypeScript.is_subject("", ts));
506        assert!(!Language::TypeScript.is_subject("// nothing here\n", ts));
507    }
508
509    #[test]
510    fn python_subject_is_decided_by_code_alone() {
511        let py = Path::new("widget.py");
512        assert!(Language::Python.is_subject("x = 1\n", py));
513        assert!(Language::Python.is_subject("Alias = str\n", py));
514        assert!(!Language::Python.is_subject("# just a comment\n", py));
515    }
516
517    const PY_WIDGET: &str = "def widget():\n    return 1\n";
518
519    #[test]
520    fn python_same_code_ignores_comments_and_formatting() {
521        let py = Path::new("widget.py");
522        assert!(Language::Python.same_code(
523            "# widget helpers\ndef widget():\n    return 1\n",
524            "# widget utilities\ndef widget():\n    return 1\n",
525            py
526        ));
527        assert!(Language::Python.same_code(
528            "# widget helpers\ndef widget():\n    return 1\n",
529            PY_WIDGET,
530            py
531        ));
532        assert!(Language::Python.same_code(PY_WIDGET, "def widget():\n\n    return 1\n", py));
533        assert!(Language::Python.same_code("def widget():   \n    return 1   \n", PY_WIDGET, py));
534    }
535
536    #[test]
537    fn python_same_code_sees_every_edit_the_interpreter_sees() {
538        let py = Path::new("widget.py");
539        assert!(!Language::Python.same_code(PY_WIDGET, "def widget():\n    return 2\n", py));
540        assert!(!Language::Python.same_code(
541            "\"\"\"Widget helpers.\"\"\"\ndef widget():\n    return 1\n",
542            "\"\"\"Widget utilities.\"\"\"\ndef widget():\n    return 1\n",
543            py
544        ));
545        assert!(!Language::Python.same_code(
546            "def widget():\n    return \"one\"\n",
547            "def widget():\n    return \"two\"\n",
548            py
549        ));
550        assert!(!Language::Python.same_code(
551            "def widget(flag):\n    if flag:\n        count = 1\n    return count\n",
552            "def widget(flag):\n    if flag:\n        count = 1\n        return count\n",
553            py
554        ));
555    }
556
557    #[test]
558    fn python_same_code_holds_unparseable_content_apart() {
559        let py = Path::new("widget.py");
560        assert!(!Language::Python.same_code(
561            "def widget(:\n    return 1\n",
562            "# note\ndef widget(:\n    return 1\n",
563            py
564        ));
565        assert!(!Language::Python.same_code(
566            "def widget() return 1\n",
567            "# note\ndef widget() return 1\n",
568            py
569        ));
570        assert!(!Language::Python.same_code(PY_WIDGET, "def widget() return 1\n", py));
571        assert!(!Language::Python.same_code("def widget() return 1\n", PY_WIDGET, py));
572    }
573
574    #[test]
575    fn typescript_same_code_reads_the_emitted_module() {
576        let ts = Path::new("widget.ts");
577        assert!(Language::TypeScript.same_code(
578            "// widget factory\nexport const widget = () => 1;\n",
579            "export const widget = () => 1;\n",
580            ts
581        ));
582        assert!(!Language::TypeScript.same_code(
583            "export const widget = () => 1;\n",
584            "export const widget = () => 2;\n",
585            ts
586        ));
587    }
588
589    #[test]
590    fn rust_same_code_never_answers_equal() {
591        assert!(!Language::Rust.same_code("fn f() {}\n", "fn f() {}\n", Path::new("lib.rs")));
592    }
593
594    #[test]
595    fn rust_has_no_file_based_colocated_convention() {
596        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
597        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
598        assert!(!Language::Rust.has_code("fn main() {}\n"));
599        assert_eq!(
600            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
601            PathBuf::from("src/lib.rs")
602        );
603    }
604
605    /// `(has_testable_fn, has_test_module)` for a Rust source snippet.
606    fn presence(src: &str) -> (bool, bool) {
607        let ast = syn::parse_file(src).expect("snippet parses");
608        let mut visitor = PresenceVisitor::default();
609        visitor.visit_file(&ast);
610        (visitor.has_testable_fn, visitor.has_test_module)
611    }
612
613    #[test]
614    fn rust_presence_free_fn_with_test_module_is_covered() {
615        assert_eq!(
616            presence(
617                "pub fn make(n: u8) -> u8 { n + 1 }\n\
618                 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
619            ),
620            (true, true)
621        );
622    }
623
624    #[test]
625    fn rust_presence_free_fn_without_test_module_needs_one() {
626        assert_eq!(
627            presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
628            (true, false)
629        );
630    }
631
632    #[test]
633    fn rust_presence_type_only_file_is_not_a_subject() {
634        assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
635    }
636
637    #[test]
638    fn rust_presence_impl_method_is_testable() {
639        assert_eq!(
640            presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
641            (true, false)
642        );
643    }
644
645    #[test]
646    fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
647        assert_eq!(
648            presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
649            (true, false)
650        );
651        assert_eq!(
652            presence("pub trait T { fn s(&self) -> u8; }\n"),
653            (false, false)
654        );
655    }
656
657    #[test]
658    fn rust_presence_test_module_functions_are_not_subjects() {
659        assert_eq!(
660            presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
661            (false, true)
662        );
663    }
664
665    #[test]
666    fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
667        assert_eq!(
668            presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
669            (false, false)
670        );
671    }
672}