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 = crate::walk::dir_entry(entry, dir)?.path();
169        if path.is_dir() {
170            collect_files(&path, language, out)?;
171        } else if language.tracks(&path) {
172            out.push(path);
173        }
174    }
175    Ok(())
176}
177
178/// Every Rust source file under `root` that defines testable behavior — a function with a
179/// body, outside any `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module,
180/// sorted. `exempt` holds the rule's `root`-relative paths resolved from config.
181pub fn missing_inline_tests(
182    root: impl AsRef<Path>,
183    exempt: &BTreeSet<String>,
184) -> Result<Vec<PathBuf>> {
185    let root = root.as_ref();
186    let mut files = Vec::new();
187    collect_rust_source_files(root, &mut files)?;
188    files.sort();
189
190    let mut orphans = Vec::new();
191    for file in &files {
192        let source = std::fs::read_to_string(file)
193            .with_context(|| format!("reading source file `{}`", file.display()))?;
194        let ast = syn::parse_file(&source)
195            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
196        let mut visitor = PresenceVisitor::default();
197        visitor.visit_file(&ast);
198        if !visitor.has_testable_fn || visitor.has_test_module {
199            continue;
200        }
201        let relative = file
202            .strip_prefix(root)
203            .unwrap_or(file)
204            .to_string_lossy()
205            .replace('\\', "/");
206        if exempt.contains(&relative) {
207            continue;
208        }
209        orphans.push(file.clone());
210    }
211    // `files` is already sorted, so `orphans` is in order.
212    Ok(orphans)
213}
214
215/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping the
216/// non-unit trees — `tests/`, `benches/`, `examples/`, `target/` — and `build.rs`.
217pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
218    let entries =
219        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
220    for entry in entries {
221        let path = crate::walk::dir_entry(entry, dir)?.path();
222        if path.is_dir() {
223            let skip = matches!(
224                path.file_name().and_then(|name| name.to_str()),
225                Some("tests" | "benches" | "examples" | "target")
226            );
227            if !skip {
228                collect_rust_source_files(&path, out)?;
229            }
230        } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
231            out.push(path);
232        }
233    }
234    Ok(())
235}
236
237/// Answers, for a parsed Rust file, whether it defines testable behavior outside any
238/// `#[cfg(test)]` module and whether it carries an inline `#[cfg(test)]` module.
239#[derive(Default)]
240struct PresenceVisitor {
241    test_depth: usize,
242    has_testable_fn: bool,
243    has_test_module: bool,
244}
245
246impl<'ast> Visit<'ast> for PresenceVisitor {
247    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
248        let is_test = crate::isolation::has_cfg_test(&node.attrs);
249        if is_test {
250            self.has_test_module = true;
251            self.test_depth += 1;
252        }
253        visit::visit_item_mod(self, node);
254        if is_test {
255            self.test_depth -= 1;
256        }
257    }
258
259    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
260        if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
261            self.has_testable_fn = true;
262        }
263        visit::visit_item_fn(self, node);
264    }
265
266    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
267        if self.test_depth == 0 {
268            self.has_testable_fn = true;
269        }
270        visit::visit_impl_item_fn(self, node);
271    }
272
273    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
274        if self.test_depth == 0 && node.default.is_some() {
275            self.has_testable_fn = true;
276        }
277        visit::visit_trait_item_fn(self, node);
278    }
279}
280
281/// `true` when the file's extension is one of `extensions`.
282fn has_extension(path: &Path, extensions: &[&str]) -> bool {
283    path.extension()
284        .and_then(|ext| ext.to_str())
285        .is_some_and(|ext| extensions.contains(&ext))
286}
287
288/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`).
289fn is_declaration(path: &Path) -> bool {
290    let name = file_name_of(path);
291    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
292}
293
294/// `true` when any line of Python `source` is neither blank nor a `#` comment.
295fn python_has_code(source: &str) -> bool {
296    source.lines().any(|line| {
297        let trimmed = line.trim_start();
298        !trimmed.is_empty() && !trimmed.starts_with('#')
299    })
300}
301
302/// `true` when Python `base` and `head` tokenize identically. Comments and blank lines
303/// never reach a token — the parser's `full-lexer` feature is off — while `Indent` /
304/// `Dedent` do, so re-indenting a statement is a code change.
305fn python_same_code(base: &str, head: &str) -> bool {
306    match (python_tokens(base), python_tokens(head)) {
307        (Some(base), Some(head)) => base == head,
308        _ => false,
309    }
310}
311
312/// The token stream of Python `source`, or `None` when `source` is not a valid module.
313fn python_tokens(source: &str) -> Option<Vec<Tok>> {
314    let tokens: Vec<Tok> = lex(source, Mode::Module)
315        .map(|token| token.ok().map(|(tok, _)| tok))
316        .collect::<Option<_>>()?;
317    // The lexer accepts token sequences the grammar rejects (`def f() return 1` lexes
318    // cleanly), so the parse decides validity while the tokens carry the comparison.
319    ast::Suite::parse(source, "<source>").ok()?;
320    Some(tokens)
321}
322
323/// `true` when TypeScript `source` holds anything beyond whitespace and `//` / `/* … */`
324/// comments. Any other character — including a string literal's quote — counts as code.
325fn typescript_has_code(source: &str) -> bool {
326    let mut chars = source.chars().peekable();
327    while let Some(c) = chars.next() {
328        match c {
329            c if c.is_whitespace() => {}
330            '/' if chars.peek() == Some(&'/') => {
331                while chars.peek().is_some_and(|&n| n != '\n') {
332                    chars.next();
333                }
334            }
335            '/' if chars.peek() == Some(&'*') => {
336                chars.next();
337                let mut prev = '\0';
338                for n in chars.by_ref() {
339                    if prev == '*' && n == '/' {
340                        break;
341                    }
342                    prev = n;
343                }
344            }
345            _ => return true,
346        }
347    }
348    false
349}
350
351/// The file extension, lossily decoded (empty if there is none).
352fn extension_of(path: &Path) -> String {
353    path.extension()
354        .map(|ext| ext.to_string_lossy().into_owned())
355        .unwrap_or_default()
356}
357
358/// The file name, lossily decoded.
359fn file_name_of(path: &Path) -> String {
360    path.file_name()
361        .map(|name| name.to_string_lossy().into_owned())
362        .unwrap_or_default()
363}
364
365/// The file stem (the name without its extension), lossily decoded.
366fn stem_of(path: &Path) -> String {
367    path.file_stem()
368        .map(|stem| stem.to_string_lossy().into_owned())
369        .unwrap_or_default()
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn python_tracks_py_files() {
378        assert!(Language::Python.tracks(Path::new("a.py")));
379        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
380        assert!(!Language::Python.tracks(Path::new("a.pyi")));
381        assert!(!Language::Python.tracks(Path::new("a.txt")));
382        assert!(!Language::Python.tracks(Path::new("README")));
383    }
384
385    #[test]
386    fn python_recognizes_test_files_by_stem_suffix() {
387        assert!(Language::Python.is_test(Path::new("widget_test.py")));
388        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
389        assert!(!Language::Python.is_test(Path::new("widget.py")));
390    }
391
392    #[test]
393    fn python_conftest_is_support_not_a_subject() {
394        assert!(Language::Python.is_support(Path::new("conftest.py")));
395        assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
396        assert!(!Language::Python.is_support(Path::new("widget.py")));
397        assert!(!Language::Python.is_support(Path::new("widget_test.py")));
398        assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
399    }
400
401    #[test]
402    fn python_expected_test_path_is_the_colocated_twin() {
403        assert_eq!(
404            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
405            PathBuf::from("pkg/widget_test.py")
406        );
407        assert_eq!(
408            Language::Python.expected_test_path(Path::new("widget.py")),
409            PathBuf::from("widget_test.py")
410        );
411    }
412
413    #[test]
414    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
415        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
416        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
417        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
418        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
419        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
420        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
421        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
422        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
423        assert!(!Language::TypeScript.tracks(Path::new("README")));
424    }
425
426    #[test]
427    fn typescript_recognizes_test_files_by_suffix() {
428        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
429        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
430        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
431        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
432        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
433        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
434        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
435    }
436
437    #[test]
438    fn typescript_expected_test_path_keeps_the_extension() {
439        assert_eq!(
440            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
441            PathBuf::from("pkg/widget.test.ts")
442        );
443        assert_eq!(
444            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
445            PathBuf::from("button.test.tsx")
446        );
447        assert_eq!(
448            Language::TypeScript.expected_test_path(Path::new("service.mts")),
449            PathBuf::from("service.test.mts")
450        );
451        assert_eq!(
452            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
453            PathBuf::from("legacy.test.cts")
454        );
455    }
456
457    #[test]
458    fn python_empty_or_comment_only_files_have_no_code() {
459        assert!(!Language::Python.has_code(""));
460        assert!(!Language::Python.has_code("\n   \n"));
461        assert!(!Language::Python.has_code("# just a comment\n   # another\n"));
462    }
463
464    #[test]
465    fn python_real_content_counts_as_code() {
466        assert!(Language::Python.has_code("x = 1\n"));
467        assert!(Language::Python.has_code("# header\nimport os\n"));
468        assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
469    }
470
471    #[test]
472    fn typescript_empty_or_comment_only_files_have_no_code() {
473        assert!(!Language::TypeScript.has_code(""));
474        assert!(!Language::TypeScript.has_code("   \n\t\n"));
475        assert!(!Language::TypeScript.has_code("// a line comment\n"));
476        assert!(!Language::TypeScript.has_code("/* a\n   block\n   comment */\n"));
477    }
478
479    #[test]
480    fn typescript_real_content_counts_as_code() {
481        assert!(Language::TypeScript.has_code("export const x = 1;\n"));
482        assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
483        assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
484        assert!(Language::TypeScript.has_code("const r = a / b;\n"));
485    }
486
487    #[test]
488    fn typescript_subject_skips_type_only_modules() {
489        let ts = Path::new("aliases.ts");
490        assert!(!Language::TypeScript.is_subject("export type Alias = string;\n", ts));
491        assert!(!Language::TypeScript.is_subject("export interface Shape { kind: string }\n", ts));
492        assert!(!Language::TypeScript.is_subject("import type { A } from './a';\n", ts));
493    }
494
495    #[test]
496    fn typescript_subject_keeps_anything_with_runtime_behavior() {
497        let ts = Path::new("widget.ts");
498        assert!(Language::TypeScript.is_subject("export const x = 1;\n", ts));
499        assert!(Language::TypeScript
500            .is_subject("export type Alias = string;\nexport const x = 1;\n", ts));
501        assert!(!Language::TypeScript.is_subject("", ts));
502        assert!(!Language::TypeScript.is_subject("// nothing here\n", ts));
503    }
504
505    #[test]
506    fn python_subject_is_decided_by_code_alone() {
507        let py = Path::new("widget.py");
508        assert!(Language::Python.is_subject("x = 1\n", py));
509        assert!(Language::Python.is_subject("Alias = str\n", py));
510        assert!(!Language::Python.is_subject("# just a comment\n", py));
511    }
512
513    const PY_WIDGET: &str = "def widget():\n    return 1\n";
514
515    #[test]
516    fn python_same_code_ignores_comments_and_formatting() {
517        let py = Path::new("widget.py");
518        assert!(Language::Python.same_code(
519            "# widget helpers\ndef widget():\n    return 1\n",
520            "# widget utilities\ndef widget():\n    return 1\n",
521            py
522        ));
523        assert!(Language::Python.same_code(
524            "# widget helpers\ndef widget():\n    return 1\n",
525            PY_WIDGET,
526            py
527        ));
528        assert!(Language::Python.same_code(PY_WIDGET, "def widget():\n\n    return 1\n", py));
529        assert!(Language::Python.same_code("def widget():   \n    return 1   \n", PY_WIDGET, py));
530    }
531
532    #[test]
533    fn python_same_code_sees_every_edit_the_interpreter_sees() {
534        let py = Path::new("widget.py");
535        assert!(!Language::Python.same_code(PY_WIDGET, "def widget():\n    return 2\n", py));
536        assert!(!Language::Python.same_code(
537            "\"\"\"Widget helpers.\"\"\"\ndef widget():\n    return 1\n",
538            "\"\"\"Widget utilities.\"\"\"\ndef widget():\n    return 1\n",
539            py
540        ));
541        assert!(!Language::Python.same_code(
542            "def widget():\n    return \"one\"\n",
543            "def widget():\n    return \"two\"\n",
544            py
545        ));
546        assert!(!Language::Python.same_code(
547            "def widget(flag):\n    if flag:\n        count = 1\n    return count\n",
548            "def widget(flag):\n    if flag:\n        count = 1\n        return count\n",
549            py
550        ));
551    }
552
553    #[test]
554    fn python_same_code_holds_unparseable_content_apart() {
555        let py = Path::new("widget.py");
556        assert!(!Language::Python.same_code(
557            "def widget(:\n    return 1\n",
558            "# note\ndef widget(:\n    return 1\n",
559            py
560        ));
561        assert!(!Language::Python.same_code(
562            "def widget() return 1\n",
563            "# note\ndef widget() return 1\n",
564            py
565        ));
566        assert!(!Language::Python.same_code(PY_WIDGET, "def widget() return 1\n", py));
567        assert!(!Language::Python.same_code("def widget() return 1\n", PY_WIDGET, py));
568    }
569
570    #[test]
571    fn typescript_same_code_reads_the_emitted_module() {
572        let ts = Path::new("widget.ts");
573        assert!(Language::TypeScript.same_code(
574            "// widget factory\nexport const widget = () => 1;\n",
575            "export const widget = () => 1;\n",
576            ts
577        ));
578        assert!(!Language::TypeScript.same_code(
579            "export const widget = () => 1;\n",
580            "export const widget = () => 2;\n",
581            ts
582        ));
583    }
584
585    #[test]
586    fn rust_same_code_never_answers_equal() {
587        assert!(!Language::Rust.same_code("fn f() {}\n", "fn f() {}\n", Path::new("lib.rs")));
588    }
589
590    #[test]
591    fn rust_has_no_file_based_colocated_convention() {
592        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
593        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
594        assert!(!Language::Rust.has_code("fn main() {}\n"));
595        assert_eq!(
596            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
597            PathBuf::from("src/lib.rs")
598        );
599    }
600
601    /// `(has_testable_fn, has_test_module)` for a Rust source snippet.
602    fn presence(src: &str) -> (bool, bool) {
603        let ast = syn::parse_file(src).expect("snippet parses");
604        let mut visitor = PresenceVisitor::default();
605        visitor.visit_file(&ast);
606        (visitor.has_testable_fn, visitor.has_test_module)
607    }
608
609    #[test]
610    fn rust_presence_free_fn_with_test_module_is_covered() {
611        assert_eq!(
612            presence(
613                "pub fn make(n: u8) -> u8 { n + 1 }\n\
614                 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
615            ),
616            (true, true)
617        );
618    }
619
620    #[test]
621    fn rust_presence_free_fn_without_test_module_needs_one() {
622        assert_eq!(
623            presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
624            (true, false)
625        );
626    }
627
628    #[test]
629    fn rust_presence_type_only_file_is_not_a_subject() {
630        assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
631    }
632
633    #[test]
634    fn rust_presence_impl_method_is_testable() {
635        assert_eq!(
636            presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
637            (true, false)
638        );
639    }
640
641    #[test]
642    fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
643        assert_eq!(
644            presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
645            (true, false)
646        );
647        assert_eq!(
648            presence("pub trait T { fn s(&self) -> u8; }\n"),
649            (false, false)
650        );
651    }
652
653    #[test]
654    fn rust_presence_test_module_functions_are_not_subjects() {
655        assert_eq!(
656            presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
657            (false, true)
658        );
659    }
660
661    #[test]
662    fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
663        assert_eq!(
664            presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
665            (false, false)
666        );
667    }
668}