Skip to main content

stern4rust/rules/testing/
tested_public_api_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::collections::BTreeSet;
6
7use crate::finding::model::public_entry_point::PublicEntryPoint;
8use crate::finding::parsing::call_site_finder::CallSiteFinder;
9use crate::finding::parsing::public_entry_point_finder::PublicEntryPointFinder;
10use crate::reporting::offence::Offence;
11use crate::reporting::rule_explanation::RuleExplanation;
12use crate::rule::Rule;
13use crate::source_file::SourceFile;
14
15// Every public entry point is called by at least one test.
16//
17// This is the question `test-naming` gave up on, asked from the other end. That
18// rule tried to prove a test tests what its name claims, and every version of it
19// -- body, helpers, mirrored source -- eventually accused correct code. Starting
20// from the declared entry points instead needs no guess about intent: a
21// `pub fn` either appears at a call site under `tests/` or it does not.
22//
23// It also sidesteps derives entirely, which is what defeated the mirrored-source
24// approach. `Default::default` and a `ValueEnum`'s `from_str` are not `pub fn`
25// declarations, so they never enter the count and can never be reported.
26//
27// Matched on **name and arity**. Types and parameter order are not checked and
28// cannot be: at a call site `check(3, &paths)` offers two arguments and nothing
29// that says whether they fit `usize` and `&[&str]`. That is type inference, and
30// this tool reads syntax. Arity is free and separates `new()` from `new(a, b)`,
31// which is most of what a bare name confuses.
32//
33// The consequence to keep in mind is that the rule **under-reports**: two
34// entry points sharing a name and an arity are indistinguishable, so a test
35// calling one marks both. It errs toward silence rather than toward accusing
36// tested code, which is the same direction every other rule here leans.
37pub struct TestedPublicApiRule;
38
39impl TestedPublicApiRule {
40    pub const SOURCE_ROOT: &'static str = "src/";
41    pub const TESTS_ROOT: &'static str = "tests/";
42
43    pub fn new() -> Self {
44        Self
45    }
46
47    fn declared_in(files: &[SourceFile]) -> Vec<(String, PublicEntryPoint)> {
48        files
49            .iter()
50            .filter(|file| file.relative_path().starts_with(Self::SOURCE_ROOT))
51            .flat_map(|file| {
52                PublicEntryPointFinder::find(file)
53                    .unwrap_or_default()
54                    .into_iter()
55                    .map(|entry| (file.relative_path().to_string(), entry))
56            })
57            .collect()
58    }
59
60    fn called_in(files: &[SourceFile]) -> BTreeSet<PublicEntryPoint> {
61        files
62            .iter()
63            .filter(|file| file.relative_path().starts_with(Self::TESTS_ROOT))
64            .filter_map(CallSiteFinder::find)
65            .flatten()
66            .collect()
67    }
68
69    fn offence(&self, file: &str, entry: &PublicEntryPoint) -> Offence {
70        Offence::new(
71            file,
72            1,
73            self.name(),
74            format!(
75                "`{}` is public but no test calls it with {} argument(s)",
76                entry.name, entry.arity
77            ),
78            format!(
79                "call `{}` from a test, or stop exposing it if nothing outside needs it",
80                entry.name
81            ),
82        )
83        .with_subject(&entry.signature())
84    }
85}
86
87impl Default for TestedPublicApiRule {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Rule for TestedPublicApiRule {
94    fn name(&self) -> &'static str {
95        "tested-public-api"
96    }
97
98    fn check(&self, _file: &SourceFile) -> Vec<Offence> {
99        Vec::new()
100    }
101
102    // A fact about the whole package: the entry point is declared in one file
103    // and the call that would exercise it lives in another.
104    fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
105        let called = Self::called_in(files);
106        Self::declared_in(files)
107            .iter()
108            .filter(|(_, entry)| !called.contains(entry))
109            .map(|(file, entry)| self.offence(file, entry))
110            .collect()
111    }
112
113    fn requirement(&self) -> Option<&'static str> {
114        None
115    }
116
117    fn is_configured(&self) -> bool {
118        true
119    }
120
121    fn explanation(&self) -> RuleExplanation {
122        RuleExplanation::new(
123            self.name(),
124            "Every public entry point is called by at least one test.",
125            "pub fn commit(&self) -> bool   -- no test calls it",
126            "#[test]\nfn commit_without_a_quorum_returns_false() {\n    assert!(!store.commit());\n}",
127        )
128    }
129}