Skip to main content

xtask/
test_siblings.rs

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