Skip to main content

xtask/
test_siblings.rs

1use anyhow::{Result, bail};
2use std::path::{Path, PathBuf};
3use walkdir::WalkDir;
4
5const EXEMPT: &[&str] = &["main.rs", "lib.rs"];
6/// `_tests.rs` files that legitimately have no same-named source sibling:
7/// shared test *fixtures* (in-process servers, stub impls) that several tests
8/// in the same tree consume. They are named `_tests.rs` so they stay test-only
9/// code rather than source requiring siblings of their own.
10const ORPHAN_EXEMPT: &[&str] = &[
11    "cli_tests.rs",
12    "mcp_tests.rs",
13    "http_oauth_stubs_tests.rs",
14    "live_servers_tests.rs",
15];
16
17pub(crate) fn check() -> Result<()> {
18    let missing = missing_siblings();
19    let orphans = orphaned_test_files();
20    let ok = missing.is_empty() && orphans.is_empty();
21
22    if !missing.is_empty() {
23        println!(
24            "==> check-test-siblings: missing _tests.rs siblings ({}):",
25            missing.len()
26        );
27        for path in &missing {
28            let stem = path.file_stem().unwrap().to_string_lossy();
29            println!(
30                "  MISSING  {}  (expected {}_tests.rs)",
31                path.display(),
32                stem
33            );
34        }
35    }
36    if !orphans.is_empty() {
37        println!(
38            "==> check-test-siblings: orphaned _tests.rs files ({}):",
39            orphans.len()
40        );
41        for path in &orphans {
42            println!("  ORPHAN   {}  (no matching source file)", path.display());
43        }
44    }
45    if ok {
46        // Name the scope, not just the verdict. This used to print "all source
47        // files have a _tests.rs sibling" while silently skipping every tree
48        // missing from the roots list - a claim much broader than what it had
49        // actually looked at. Printing the skipped trees and why keeps the
50        // pass honest, and puts the exemptions in front of whoever runs the
51        // command rather than only in the source.
52        println!(
53            "==> check-test-siblings: all source files have a _tests.rs sibling ({} tree(s) \
54             checked)",
55            CHECKED_SRC_ROOTS.len()
56        );
57        println!(
58            "    not checked ({} tree(s), by design - see UNCHECKED_SRC_ROOTS):",
59            UNCHECKED_SRC_ROOTS.len()
60        );
61        for (path, reason) in UNCHECKED_SRC_ROOTS {
62            println!("      {path}: {reason}");
63        }
64        return Ok(());
65    }
66    bail!("{} missing, {} orphaned", missing.len(), orphans.len());
67}
68
69fn missing_siblings() -> Vec<PathBuf> {
70    crate_src_roots()
71        .into_iter()
72        .flat_map(source_files_requiring_siblings)
73        .filter(|path| !expected_test_sibling(path).exists())
74        .collect()
75}
76
77fn orphaned_test_files() -> Vec<PathBuf> {
78    crate_src_roots()
79        .into_iter()
80        .flat_map(test_files)
81        .filter(|path| !matching_source(path).exists())
82        .collect()
83}
84
85fn source_files_requiring_siblings(root: PathBuf) -> Vec<PathBuf> {
86    rust_files(root)
87        .into_iter()
88        .filter(|path| {
89            let name = filename(path);
90            !name.ends_with("_tests.rs") && !EXEMPT.contains(&name.as_str())
91        })
92        .collect()
93}
94
95fn test_files(root: PathBuf) -> Vec<PathBuf> {
96    rust_files(root)
97        .into_iter()
98        .filter(|path| {
99            let name = filename(path);
100            name.ends_with("_tests.rs") && !ORPHAN_EXEMPT.contains(&name.as_str())
101        })
102        .collect()
103}
104
105fn rust_files(root: PathBuf) -> Vec<PathBuf> {
106    WalkDir::new(root)
107        .into_iter()
108        .filter_map(|entry| entry.ok())
109        .filter(|entry| entry.file_type().is_file())
110        .map(|entry| entry.into_path())
111        .filter(|path| filename(path).ends_with(".rs"))
112        .collect()
113}
114
115fn expected_test_sibling(path: &Path) -> PathBuf {
116    let stem = path.file_stem().unwrap().to_string_lossy();
117    path.parent().unwrap().join(format!("{stem}_tests.rs"))
118}
119
120fn matching_source(path: &Path) -> PathBuf {
121    let stem = filename(path).trim_end_matches("_tests.rs").to_owned();
122    path.parent().unwrap().join(format!("{stem}.rs"))
123}
124
125fn filename(path: &Path) -> String {
126    path.file_name()
127        .and_then(|name| name.to_str())
128        .unwrap_or_default()
129        .to_owned()
130}
131
132/// Source trees that follow the `foo.rs` + `foo_tests.rs` sibling convention
133/// and are therefore checked by this command.
134///
135/// Every workspace member's `src/` must appear either here or in
136/// [`UNCHECKED_SRC_ROOTS`]; `every_workspace_member_src_root_is_classified`
137/// fails the build otherwise. That is the point of splitting the two lists:
138/// this used to be a bare allowlist, so a crate that was simply never added
139/// to it was silently unchecked, and the command still reported "all source
140/// files have a _tests.rs sibling" - a pass that only meant it had not
141/// looked.
142const CHECKED_SRC_ROOTS: &[&str] = &[
143    "apps/soma/src",
144    "crates/shared/codemode/src",
145    "crates/shared/cortex/ingest-core/src",
146    "crates/shared/incus-client/src",
147    "crates/shared/mcp/client/src",
148    "crates/shared/mcp/gateway/src",
149    "crates/shared/mcp/proxy/src",
150    "crates/shared/mcp/server/src",
151    "crates/shared/observability/src",
152    "crates/shared/openapi/src",
153    "crates/shared/operations/fleet/src",
154    "crates/shared/operations/infra/src",
155    "crates/shared/operations/ops/src",
156    "crates/shared/provider-guest/src",
157    "crates/soma/api/src",
158    "crates/soma/cli/src",
159    "crates/soma/client/src",
160    "crates/soma/config/src",
161    "crates/soma/integrations/src",
162    "crates/soma/mcp/src",
163    "crates/soma/palette/src",
164    "crates/soma/web/src",
165    "crates/synapse/application/src",
166];
167
168/// Source trees deliberately *not* checked, each with the reason.
169///
170/// Three test layouts genuinely coexist in this repo. The sibling convention
171/// is the default for Soma's own crates; some trees use inline
172/// `#[cfg(test)] mod tests`; and at least one tests entirely through its
173/// public API from `tests/`. Forcing siblings on the latter two would be
174/// mechanical churn that buys nothing, or would defeat the point outright. An
175/// entry here is a decision, not an oversight - which is exactly what the old
176/// bare allowlist could not express.
177const UNCHECKED_SRC_ROOTS: &[(&str, &str)] = &[
178    (
179        "crates/integrations/gotify/src",
180        "inline #[cfg(test)] mod tests throughout, plus tests/client.rs \
181         exercising the HTTP layer through the public API. Same convention \
182         as crates/integrations/unifi/src - see crates/integrations/README.md.",
183    ),
184    (
185        "crates/shared/auth/src",
186        "predominantly inline #[cfg(test)] mod tests, with a few siblings \
187         (token_client_auth_tests.rs, sqlite_tests.rs, sqlite_assertions_tests.rs, \
188         sqlite_migration_tests.rs). Mixed by module rather than by crate, so \
189         checking the tree wholesale would fail on the inline majority.",
190    ),
191    (
192        "crates/shared/cli-core/src",
193        "extracted from soma-cli with its tests still inline - predates the \
194         sibling convention. Tracked separately; move this entry to \
195         CHECKED_SRC_ROOTS once it gets siblings.",
196    ),
197    (
198        "crates/shared/codex-app-server-client/src",
199        "inline #[cfg(test)] mod tests throughout. This crate is designed to be \
200         lifted wholesale into another repo (see its README.md), so its tests \
201         travel inside the files they cover rather than depending on this \
202         repo's sibling layout.",
203    ),
204    (
205        "crates/shared/http-api/src",
206        "extracted from soma-api with its tests still inline - predates the \
207         sibling convention. Tracked separately; move this entry to \
208         CHECKED_SRC_ROOTS once it gets siblings.",
209    ),
210    (
211        "crates/shared/http-server/src",
212        "extracted from apps/soma with its tests still inline - predates the \
213         sibling convention. Tracked separately; move this entry to \
214         CHECKED_SRC_ROOTS once it gets siblings.",
215    ),
216    (
217        "crates/shared/provider-adapters/src",
218        "follows the sibling convention but does not satisfy it yet - error.rs \
219         has no sibling. Tracked separately; move this entry to \
220         CHECKED_SRC_ROOTS once it does.",
221    ),
222    (
223        "crates/shared/provider-core/src",
224        "tests exclusively through the public API from tests/ (22 tests across \
225         7 files) - no inline modules and no siblings anywhere in the crate. \
226         That is the point: the crate is the provider contract, so its tests \
227         exercise it the way a provider author would rather than reaching into \
228         private internals. Siblings here would invite the opposite.",
229    ),
230    (
231        "crates/shared/self-update/src",
232        "behavior, portability, and dependency boundaries are exercised through \
233         the public API from tests/. Private crash failpoints have one focused \
234         transaction_tests.rs sibling because they cannot be exposed publicly.",
235    ),
236    (
237        "crates/shared/tauri-shell/src",
238        "extracted with window.rs/app.rs/tray.rs still untested (Tauri desktop \
239         windowing needs a display to exercise) - no siblings for those three \
240         yet. Tracked separately; move this entry to CHECKED_SRC_ROOTS once \
241         they do.",
242    ),
243    (
244        "crates/shared/traces/src",
245        "inline #[cfg(test)] mod tests throughout",
246    ),
247    (
248        "crates/soma/application/src",
249        "follows the sibling convention but does not satisfy it yet - types.rs, \
250         context.rs, ports.rs and error.rs have no sibling. Tracked \
251         separately; move this entry to CHECKED_SRC_ROOTS once they do.",
252    ),
253    (
254        "crates/soma/domain/src",
255        "follows the sibling convention but does not satisfy it yet - \
256         execution.rs and principal.rs have no sibling. Tracked separately; \
257         move this entry to CHECKED_SRC_ROOTS once they do.",
258    ),
259    (
260        "crates/soma/runtime/src",
261        "follows the sibling convention except test_support.rs, a \
262         `#![cfg(test)]` dev-dependency-only helper module - it IS test \
263         infrastructure, not source under test, so it has no _tests.rs \
264         sibling by design.",
265    ),
266    (
267        "crates/soma/test-support/src",
268        "test-support code is exercised by the crates that consume it",
269    ),
270    (
271        "xtask/src",
272        "mixed by module: xtask/src/codex_schema/ uses siblings, most other \
273         modules use inline tests. Split per-module rather than per-crate before \
274         checking this tree.",
275    ),
276    (
277        "crates/integrations/unifi/src",
278        "inline #[cfg(test)] mod tests throughout (63 as of writing), plus \
279         tests/client.rs and tests/action_dispatch.rs exercising the HTTP \
280         and dynamic-dispatch layers through the public API. This is the \
281         crates/integrations/* reference template's convention - see \
282         crates/integrations/README.md.",
283    ),
284    (
285        "packages/python/src",
286        "single-file PyO3 extension (lib.rs) that only translates Python \
287         inputs and errors at the package boundary - the reusable behavior it \
288         wraps is tested in the PyO3-free crates. It has no Rust test target \
289         at all (`test = false`, so workspace test runs do not need libpython); \
290         what it adds is covered by the Python-side suite in \
291         packages/python/tests/ and apps/soma/tests/python_provider.rs.",
292    ),
293];
294
295fn crate_src_roots() -> Vec<PathBuf> {
296    CHECKED_SRC_ROOTS.iter().map(PathBuf::from).collect()
297}
298
299#[cfg(test)]
300#[path = "test_siblings_tests.rs"]
301mod tests;