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 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
123const 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
153const 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;