cargo_context_core/collect/
tests_rs.rs1use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::collect::meta::cargo_metadata;
26use crate::error::Result;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum TestKind {
31 Integration,
32 UnitInline,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TestFunction {
37 pub name: String,
38 pub signature: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TestFile {
45 pub path: PathBuf,
46 pub crate_name: String,
47 pub kind: TestKind,
48 pub functions: Vec<TestFunction>,
49 pub matched_stems: Vec<String>,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct RelatedTests {
56 pub files: Vec<TestFile>,
57}
58
59impl RelatedTests {
60 pub fn is_empty(&self) -> bool {
61 self.files.is_empty()
62 }
63}
64
65pub fn related_tests(root: &Path, changed_paths: &[PathBuf]) -> Result<RelatedTests> {
70 if changed_paths.is_empty() {
71 return Ok(RelatedTests::default());
72 }
73
74 let stems = path_stems(changed_paths);
79
80 let meta = cargo_metadata(root)?;
81 let mut files: Vec<TestFile> = Vec::new();
82
83 let abs_changed: Vec<PathBuf> = changed_paths
86 .iter()
87 .map(|p| {
88 if p.is_absolute() {
89 p.clone()
90 } else {
91 meta.workspace_root.join(p)
92 }
93 })
94 .collect();
95
96 for member in &meta.members {
97 let manifest_dir = match member.manifest_path.parent() {
98 Some(d) => d,
99 None => continue,
100 };
101
102 let tests_dir = manifest_dir.join("tests");
104 if tests_dir.is_dir()
105 && let Ok(entries) = std::fs::read_dir(&tests_dir)
106 {
107 for entry in entries.flatten() {
108 let path = entry.path();
109 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
110 continue;
111 }
112 let source = match std::fs::read_to_string(&path) {
113 Ok(s) => s,
114 Err(_) => continue,
115 };
116 let matched = stems
117 .iter()
118 .filter(|stem| contains_stem(&source, stem))
119 .cloned()
120 .collect::<Vec<_>>();
121 if matched.is_empty() {
122 continue;
123 }
124 let functions = test_functions(&source);
125 if functions.is_empty() {
126 continue;
127 }
128 files.push(TestFile {
129 path,
130 crate_name: member.name.clone(),
131 kind: TestKind::Integration,
132 functions,
133 matched_stems: matched,
134 });
135 }
136 }
137
138 for changed in &abs_changed {
140 if !changed.starts_with(manifest_dir) {
141 continue;
142 }
143 if changed.extension().and_then(|e| e.to_str()) != Some("rs") {
144 continue;
145 }
146 let source = match std::fs::read_to_string(changed) {
147 Ok(s) => s,
148 Err(_) => continue,
149 };
150 let functions = inline_test_functions(&source);
151 if functions.is_empty() {
152 continue;
153 }
154 files.push(TestFile {
155 path: changed.clone(),
156 crate_name: member.name.clone(),
157 kind: TestKind::UnitInline,
158 functions,
159 matched_stems: Vec::new(),
160 });
161 }
162 }
163
164 Ok(RelatedTests { files })
165}
166
167fn path_stems(paths: &[PathBuf]) -> Vec<String> {
168 let mut out: HashSet<String> = HashSet::new();
169 for p in paths {
170 if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
171 match stem {
173 "mod" | "lib" | "main" | "Cargo" | "README" => continue,
174 _ => {}
175 }
176 if stem.len() >= 3 && is_rust_ident(stem) {
178 out.insert(stem.to_string());
179 }
180 }
181 }
182 out.into_iter().collect()
183}
184
185fn is_rust_ident(s: &str) -> bool {
186 let mut chars = s.chars();
187 matches!(chars.next(), Some(c) if c.is_alphabetic() || c == '_')
188 && chars.all(|c| c.is_alphanumeric() || c == '_')
189}
190
191fn contains_stem(haystack: &str, stem: &str) -> bool {
194 let bytes = haystack.as_bytes();
195 let needle = stem.as_bytes();
196 let n = needle.len();
197 if n == 0 || bytes.len() < n {
198 return false;
199 }
200 let mut i = 0;
201 while i + n <= bytes.len() {
202 if &bytes[i..i + n] == needle {
203 let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
204 let after_ok = i + n == bytes.len() || !is_ident_byte(bytes[i + n]);
205 if before_ok && after_ok {
206 return true;
207 }
208 }
209 i += 1;
210 }
211 false
212}
213
214fn is_ident_byte(b: u8) -> bool {
215 b.is_ascii_alphanumeric() || b == b'_'
216}
217
218fn test_functions(source: &str) -> Vec<TestFunction> {
221 match syn::parse_file(source) {
222 Ok(file) => collect_test_fns(&file.items),
223 Err(_) => Vec::new(),
224 }
225}
226
227fn inline_test_functions(source: &str) -> Vec<TestFunction> {
230 let file = match syn::parse_file(source) {
231 Ok(f) => f,
232 Err(_) => return Vec::new(),
233 };
234 let mut out = Vec::new();
235 for item in &file.items {
236 if let syn::Item::Mod(m) = item
237 && has_cfg_test(&m.attrs)
238 && let Some((_, items)) = &m.content
239 {
240 out.extend(collect_test_fns(items));
241 }
242 }
243 out
244}
245
246fn collect_test_fns(items: &[syn::Item]) -> Vec<TestFunction> {
247 items
248 .iter()
249 .filter_map(|item| match item {
250 syn::Item::Fn(f) if has_test_attr(&f.attrs) => Some(TestFunction {
251 name: f.sig.ident.to_string(),
252 signature: format!("{}fn {}(...)", render_attrs(&f.attrs), f.sig.ident),
253 }),
254 _ => None,
255 })
256 .collect()
257}
258
259fn has_test_attr(attrs: &[syn::Attribute]) -> bool {
260 attrs.iter().any(|a| {
261 a.path()
262 .segments
263 .last()
264 .map(|s| s.ident == "test")
265 .unwrap_or(false)
266 })
267}
268
269fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
270 attrs.iter().any(|a| {
271 if !a.path().is_ident("cfg") {
272 return false;
273 }
274 let mut found = false;
275 let _ = a.parse_nested_meta(|meta| {
276 if meta.path.is_ident("test") {
277 found = true;
278 }
279 Ok(())
280 });
281 found
282 })
283}
284
285fn render_attrs(attrs: &[syn::Attribute]) -> String {
286 let mut out = String::new();
287 for a in attrs {
288 if a.path()
289 .segments
290 .last()
291 .map(|s| s.ident == "test")
292 .unwrap_or(false)
293 {
294 let path = a
295 .path()
296 .segments
297 .iter()
298 .map(|s| s.ident.to_string())
299 .collect::<Vec<_>>()
300 .join("::");
301 out.push_str(&format!("#[{path}] "));
302 }
303 }
304 out
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn stems_skip_generic_filenames() {
313 let paths = vec![
314 PathBuf::from("src/mod.rs"),
315 PathBuf::from("src/lib.rs"),
316 PathBuf::from("src/main.rs"),
317 PathBuf::from("src/scrub.rs"),
318 PathBuf::from("Cargo.toml"),
319 ];
320 let stems = path_stems(&paths);
321 assert!(stems.contains(&"scrub".to_string()));
322 assert!(!stems.contains(&"mod".to_string()));
323 assert!(!stems.contains(&"lib".to_string()));
324 assert!(!stems.contains(&"main".to_string()));
325 }
326
327 #[test]
328 fn contains_stem_is_whole_word() {
329 assert!(contains_stem("use crate::scrub::Scrubber;", "scrub"));
330 assert!(contains_stem("mod scrub;", "scrub"));
331 assert!(contains_stem("let x = scrub();", "scrub"));
332 assert!(!contains_stem("mystemfoo", "stem"));
334 assert!(!contains_stem("scrubber_x", "scrub"));
335 }
336
337 #[test]
338 fn test_functions_extracts_test_attr() {
339 let src = r#"
340 #[test]
341 fn one() {}
342
343 #[tokio::test]
344 async fn two() {}
345
346 fn not_a_test() {}
347
348 #[should_panic]
349 fn also_not_a_test() {}
350 "#;
351 let fns = test_functions(src);
352 let names: Vec<_> = fns.iter().map(|f| f.name.as_str()).collect();
353 assert_eq!(names, vec!["one", "two"]);
354 }
355
356 #[test]
357 fn inline_test_functions_requires_cfg_test_mod() {
358 let src = r#"
359 pub fn regular() {}
360
361 #[cfg(test)]
362 mod tests {
363 #[test]
364 fn inside() {}
365 }
366
367 mod not_a_test_mod {
368 #[test]
369 fn outside_cfg_test() {}
370 }
371 "#;
372 let fns = inline_test_functions(src);
373 let names: Vec<_> = fns.iter().map(|f| f.name.as_str()).collect();
374 assert_eq!(names, vec!["inside"]);
375 }
376
377 #[test]
378 fn related_tests_empty_input_returns_empty() {
379 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
380 let out = related_tests(root, &[]).unwrap();
381 assert!(out.is_empty());
382 }
383
384 #[test]
385 fn related_tests_matches_inline_tests_in_changed_file() {
386 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
388 let changed = vec![PathBuf::from("crates/cargo-context-core/src/scrub/mod.rs")];
389 let out = related_tests(root, &changed).unwrap();
390 assert!(
391 out.files
392 .iter()
393 .any(|f| f.kind == TestKind::UnitInline && !f.functions.is_empty()),
394 "expected at least one unit-inline match, got: {out:?}",
395 );
396 }
397}