Skip to main content

cargo_context_core/collect/
tests_rs.rs

1//! Related-test discovery.
2//!
3//! Given a set of paths the user is changing, find the tests that plausibly
4//! cover those paths so the LLM can reason about regressions.
5//!
6//! Two sources:
7//! - **Integration tests** — `<crate>/tests/*.rs` files whose content
8//!   textually references the stem of any changed path. This is a
9//!   heuristic string match against the file's content; it is noisy by
10//!   design (prefer false positives over false negatives — a missed test
11//!   is worse than an extra one).
12//! - **Inline unit tests** — `#[cfg(test)] mod tests { ... }` blocks
13//!   inside the changed source files themselves. These cover the exact
14//!   code being modified.
15//!
16//! Test function discovery uses `syn`: any top-level fn whose attribute
17//! path ends in `test` (so `#[test]`, `#[tokio::test]`,
18//! `#[async_std::test]`, etc.) counts.
19
20use 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    /// Rendered signature (attributes + fn line), useful to distinguish
39    /// `#[test]` from `#[tokio::test]` without parsing again downstream.
40    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    /// Which changed-path stems caused this file to be included. Empty for
50    /// unit-inline kind (the changed source file is the reason itself).
51    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
65/// Collect tests related to `changed_paths`.
66///
67/// `changed_paths` should be the set of paths surfaced by `collect::git_diff`
68/// (callers typically pass `diff.files.iter().map(|f| f.path.clone())`).
69pub fn related_tests(root: &Path, changed_paths: &[PathBuf]) -> Result<RelatedTests> {
70    if changed_paths.is_empty() {
71        return Ok(RelatedTests::default());
72    }
73
74    // Stems are only used to match *integration* tests by string-search.
75    // Inline tests are scanned via full path, so don't early-exit on empty
76    // stems — a diff touching only `mod.rs`/`lib.rs` files still produces
77    // valid inline-test matches.
78    let stems = path_stems(changed_paths);
79
80    let meta = cargo_metadata(root)?;
81    let mut files: Vec<TestFile> = Vec::new();
82
83    // Build the set of changed paths rooted from the workspace so we can
84    // match against member manifest parents robustly.
85    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        // Integration tests in <crate>/tests/.
103        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        // Inline unit tests in changed source files that belong to this member.
139        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            // Skip overly generic stems that would match everything.
172            match stem {
173                "mod" | "lib" | "main" | "Cargo" | "README" => continue,
174                _ => {}
175            }
176            // At least 3 chars to avoid absurd matches like "x".
177            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
191/// Whole-word match for a stem in `haystack` — tolerates `::stem`, `mod stem`,
192/// etc. without false-matching `mystemfoo`.
193fn 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
218/// Extract `#[test]` / `#[*::test]` fns from an integration test file's
219/// top level.
220fn 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
227/// Extract `#[test]` / `#[*::test]` fns from `#[cfg(test)] mod { ... }`
228/// blocks inside a source file.
229fn 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        // Partial matches are rejected.
333        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        // Our own scrub/mod.rs has an inline `#[cfg(test)] mod tests` block.
387        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}