1use anyhow::{Result, bail};
2use std::path::{Path, PathBuf};
3use walkdir::WalkDir;
4
5const EXEMPT: &[&str] = &["main.rs", "lib.rs"];
6const 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 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
132const 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
168const 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;