Skip to main content

cargo_crap/
complexity.rs

1//! Extract cyclomatic complexity per function, with source spans.
2//!
3//! We use [`syn`] for two reasons beyond just getting a CC number: it gives
4//! us the typed Rust AST with precise line spans for every function, and it
5//! handles free functions, impl methods, and nested scopes uniformly via its
6//! [`Visit`] trait. LCOV's `FN:line,name` record only gives us the starting
7//! line — the span has to come from the AST.
8
9use anyhow::{Context, Result};
10use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
11use rayon::prelude::*;
12use serde::Serialize;
13use std::path::{Path, PathBuf};
14use syn::{
15    BinOp, ImplItemFn, ItemFn, ItemImpl,
16    visit::{self, Visit},
17};
18
19/// One function's complexity, with enough location info to join against a
20/// coverage report later.
21#[derive(Debug, Clone, Serialize)]
22pub struct FunctionComplexity {
23    /// Path to the source file, exactly as produced by the walk: absolute
24    /// when the analysis root was absolute, relative otherwise (e.g. under
25    /// the CLI default `--path .`). Never canonicalized here — path
26    /// resolution against coverage data is `merge`'s job.
27    pub file: PathBuf,
28    /// Function name. Closures are not extracted as separate entries.
29    pub name: String,
30    /// 1-indexed first line of the function (inclusive).
31    pub start_line: usize,
32    /// 1-indexed last line of the function (inclusive).
33    pub end_line: usize,
34    /// `McCabe` cyclomatic complexity, minimum 1.0.
35    pub cyclomatic: f64,
36}
37
38/// Analyze a single Rust source file and return every function found.
39///
40/// Top-level module scope (the file itself) is intentionally excluded —
41/// CRAP is a per-function metric, and rolling up file-level CC into the
42/// formula produces misleading scores on large files.
43pub fn analyze_file(path: &Path) -> Result<Vec<FunctionComplexity>> {
44    let source = std::fs::read_to_string(path)
45        .with_context(|| format!("reading source file {}", path.display()))?;
46
47    let syntax = syn::parse_file(&source).with_context(|| format!("parsing {}", path.display()))?;
48
49    let mut visitor = FunctionVisitor {
50        file: path,
51        out: Vec::new(),
52        impl_type: None,
53    };
54    visitor.visit_file(&syntax);
55    Ok(visitor.out)
56}
57
58/// Returns `true` if `attrs` contains an attribute with the given simple name,
59/// e.g. `has_attr(attrs, "test")` matches `#[test]`.
60fn has_attr(
61    attrs: &[syn::Attribute],
62    name: &str,
63) -> bool {
64    attrs.iter().any(|a| a.path().is_ident(name))
65}
66
67/// Returns `true` if `attrs` contains `#[cfg(test)]` exactly.
68///
69/// More complex forms (`#[cfg(not(test))]`, `#[cfg(any(test, ...))]`) are not
70/// matched — we only skip the common, unambiguous case.
71fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
72    attrs.iter().any(|a| {
73        a.path().is_ident("cfg") && a.parse_args::<syn::Ident>().is_ok_and(|id| id == "test")
74    })
75}
76
77/// Extract a simple type name from an `impl` self-type for use as a prefix.
78///
79/// `impl Foo` and `impl Trait for Foo` both yield `Some("Foo")`.
80/// Exotic cases like `impl dyn Trait` yield `None`.
81fn impl_type_name(ty: &syn::Type) -> Option<String> {
82    if let syn::Type::Path(tp) = ty {
83        tp.path.segments.last().map(|s| s.ident.to_string())
84    } else {
85        None
86    }
87}
88
89/// syn visitor that collects one [`FunctionComplexity`] per function item.
90struct FunctionVisitor<'a> {
91    file: &'a Path,
92    out: Vec<FunctionComplexity>,
93    /// Type name of the enclosing `impl` block, if any.
94    impl_type: Option<String>,
95}
96
97impl<'ast> Visit<'ast> for FunctionVisitor<'_> {
98    fn visit_item_fn(
99        &mut self,
100        node: &'ast ItemFn,
101    ) {
102        // Skip test functions — they are never in LCOV output and would
103        // always score as 0% covered, producing misleading CRAP scores.
104        if has_attr(&node.attrs, "test") {
105            return;
106        }
107        let name = node.sig.ident.to_string();
108        let start_line = node.sig.fn_token.span.start().line;
109        let end_line = node.block.brace_token.span.close().end().line;
110        let cyclomatic = count_cyclomatic(&node.block) as f64;
111        self.out.push(FunctionComplexity {
112            file: self.file.to_path_buf(),
113            name,
114            start_line,
115            end_line,
116            cyclomatic,
117        });
118        // Do NOT recurse: skip nested fn items inside function bodies.
119    }
120
121    fn visit_item_impl(
122        &mut self,
123        node: &'ast ItemImpl,
124    ) {
125        // Set the self-type for the duration of this impl block so that
126        // visit_impl_item_fn can prefix method names with it.
127        let prev = self.impl_type.take();
128        self.impl_type = impl_type_name(&node.self_ty);
129        visit::visit_item_impl(self, node);
130        self.impl_type = prev;
131    }
132
133    fn visit_impl_item_fn(
134        &mut self,
135        node: &'ast ImplItemFn,
136    ) {
137        if has_attr(&node.attrs, "test") {
138            return;
139        }
140        let method = node.sig.ident.to_string();
141        let name = match &self.impl_type {
142            Some(ty) => format!("{ty}::{method}"),
143            None => method,
144        };
145        let start_line = node.sig.fn_token.span.start().line;
146        let end_line = node.block.brace_token.span.close().end().line;
147        let cyclomatic = count_cyclomatic(&node.block) as f64;
148        self.out.push(FunctionComplexity {
149            file: self.file.to_path_buf(),
150            name,
151            start_line,
152            end_line,
153            cyclomatic,
154        });
155    }
156
157    fn visit_item_mod(
158        &mut self,
159        node: &'ast syn::ItemMod,
160    ) {
161        // Skip the entire #[cfg(test)] module — functions inside it will
162        // never appear in coverage reports and would all score pessimistically.
163        if !is_cfg_test(&node.attrs) {
164            visit::visit_item_mod(self, node);
165        }
166    }
167}
168
169/// Compute cyclomatic complexity for a function body.
170///
171/// Base count is 1 (the single straight-line path). Each decision point adds 1.
172fn count_cyclomatic(body: &syn::Block) -> usize {
173    let mut counter = CcCounter { count: 1 };
174    counter.visit_block(body);
175    counter.count
176}
177
178/// Visitor that counts decision points to compute cyclomatic complexity.
179struct CcCounter {
180    count: usize,
181}
182
183impl<'ast> Visit<'ast> for CcCounter {
184    fn visit_expr_if(
185        &mut self,
186        node: &'ast syn::ExprIf,
187    ) {
188        self.count += 1;
189        visit::visit_expr_if(self, node); // recurse to catch else-if chains
190    }
191
192    fn visit_expr_for_loop(
193        &mut self,
194        node: &'ast syn::ExprForLoop,
195    ) {
196        self.count += 1;
197        visit::visit_expr_for_loop(self, node);
198    }
199
200    fn visit_expr_while(
201        &mut self,
202        node: &'ast syn::ExprWhile,
203    ) {
204        self.count += 1;
205        visit::visit_expr_while(self, node);
206    }
207
208    fn visit_expr_loop(
209        &mut self,
210        node: &'ast syn::ExprLoop,
211    ) {
212        self.count += 1;
213        visit::visit_expr_loop(self, node);
214    }
215
216    fn visit_arm(
217        &mut self,
218        node: &'ast syn::Arm,
219    ) {
220        self.count += 1;
221        visit::visit_arm(self, node);
222    }
223
224    fn visit_expr_binary(
225        &mut self,
226        node: &'ast syn::ExprBinary,
227    ) {
228        if matches!(node.op, BinOp::And(_) | BinOp::Or(_)) {
229            self.count += 1;
230        }
231        visit::visit_expr_binary(self, node);
232    }
233
234    fn visit_expr_try(
235        &mut self,
236        node: &'ast syn::ExprTry,
237    ) {
238        self.count += 1;
239        visit::visit_expr_try(self, node);
240    }
241
242    fn visit_expr_closure(
243        &mut self,
244        _node: &'ast syn::ExprClosure,
245    ) {
246        // Do not recurse into closures: their decision points belong to their
247        // own logical scope, not to the enclosing function's CC.
248    }
249
250    fn visit_item(
251        &mut self,
252        _node: &'ast syn::Item,
253    ) {
254        // Do not recurse into items nested in the function body (a local
255        // `fn`, `impl`, `mod`, `trait`, `const`, …): like closures, they are
256        // their own logical scope. Without this stop, syn's default visitor
257        // walks `Stmt::Item` and a helper fn defined inside the body would
258        // silently inflate the enclosing function's CC while never being
259        // reported itself.
260    }
261}
262
263/// Build a `GlobSet` from a slice of glob pattern strings.
264fn build_exclude_set<S: AsRef<str>>(patterns: &[S]) -> Result<GlobSet> {
265    let mut builder = GlobSetBuilder::new();
266    for pat in patterns {
267        let glob = GlobBuilder::new(pat.as_ref())
268            .literal_separator(true) // `*` stays within one component; `**` crosses
269            .build()
270            .with_context(|| format!("invalid exclude pattern: {:?}", pat.as_ref()))?;
271        builder.add(glob);
272    }
273    builder.build().context("building exclude glob set")
274}
275
276/// Walk a directory tree and analyze every `.rs` file, honoring `.gitignore`.
277///
278/// `excludes` is a list of glob patterns (relative to `root`) for paths that
279/// should be skipped. Use `**` to cross directory boundaries:
280/// `"tests/**"` excludes all files under `tests/`.
281///
282/// Files that fail to parse are logged to stderr but do not abort the whole
283/// run — one corrupt file in a 10k-file workspace shouldn't break CI.
284pub fn analyze_tree<S: AsRef<str>>(
285    root: &Path,
286    excludes: &[S],
287) -> Result<Vec<FunctionComplexity>> {
288    let exclude_set = build_exclude_set(excludes)?;
289
290    // Phase 1: collect eligible paths (single-threaded walk — the filesystem
291    // is inherently sequential and the ignore crate is not Send).
292    let paths: Vec<PathBuf> = {
293        let walker = ignore::WalkBuilder::new(root)
294            .standard_filters(true)
295            .build();
296
297        walker
298            .filter_map(|result| {
299                let entry = match result {
300                    Ok(e) => e,
301                    Err(err) => {
302                        eprintln!("warning: walk error: {err}");
303                        return None;
304                    },
305                };
306                if !entry.file_type().is_some_and(|t| t.is_file()) {
307                    return None;
308                }
309                if entry.path().extension().and_then(|e| e.to_str()) != Some("rs") {
310                    return None;
311                }
312                if !exclude_set.is_empty()
313                    && let Ok(rel) = entry.path().strip_prefix(root)
314                    && exclude_set.is_match(rel)
315                {
316                    return None;
317                }
318                Some(entry.path().to_path_buf())
319            })
320            .collect()
321    };
322
323    // Phase 2: analyze files in parallel. Each file is independent so rayon
324    // can schedule them across all available cores with no synchronization.
325    let all: Vec<FunctionComplexity> = paths
326        .par_iter()
327        .flat_map_iter(|path| match analyze_file(path) {
328            Ok(fns) => fns,
329            Err(err) => {
330                eprintln!("warning: could not analyze {}: {err}", path.display());
331                vec![]
332            },
333        })
334        .collect();
335
336    Ok(all)
337}
338
339#[cfg(test)]
340#[expect(
341    clippy::float_cmp,
342    reason = "CC counter increments by integer steps stored as f64; exact equality is the right comparison"
343)]
344mod tests {
345    use super::*;
346    use std::io::Write;
347
348    fn write_temp(source: &str) -> tempfile::NamedTempFile {
349        let mut f = tempfile::Builder::new()
350            .suffix(".rs")
351            .tempfile()
352            .expect("tempfile");
353        f.write_all(source.as_bytes()).expect("write");
354        f
355    }
356
357    #[test]
358    fn trivial_function_has_cc_one() {
359        let f = write_temp("fn hello() -> i32 { 42 }");
360        let fns = analyze_file(f.path()).expect("analyze");
361        assert_eq!(fns.len(), 1);
362        assert_eq!(fns[0].name, "hello");
363        assert_eq!(fns[0].cyclomatic, 1.0);
364    }
365
366    #[test]
367    fn branching_increases_cc() {
368        let f = write_temp(
369            r#"
370fn check(x: i32) -> &'static str {
371    if x < 0 {
372        "neg"
373    } else if x == 0 {
374        "zero"
375    } else {
376        "pos"
377    }
378}
379"#,
380        );
381        let fns = analyze_file(f.path()).expect("analyze");
382        assert_eq!(fns.len(), 1);
383        assert!(
384            fns[0].cyclomatic >= 3.0,
385            "expected CC ≥ 3 for two-branch if/else, got {}",
386            fns[0].cyclomatic
387        );
388    }
389
390    #[test]
391    fn nested_fn_does_not_inflate_enclosing_cc() {
392        // A local helper fn is its own scope, exactly like a closure: its
393        // decision points must not leak into the outer function's count.
394        let f = write_temp(
395            r"
396fn outer() -> i32 {
397    fn inner(y: i32) -> i32 {
398        if y > 0 { y } else { -y }
399    }
400    inner(1)
401}
402",
403        );
404        let fns = analyze_file(f.path()).expect("analyze");
405        assert_eq!(fns.len(), 1, "nested fns are not extracted as entries");
406        assert_eq!(fns[0].name, "outer");
407        assert_eq!(
408            fns[0].cyclomatic, 1.0,
409            "inner's `if` must not count toward outer"
410        );
411    }
412
413    #[test]
414    fn nested_impl_and_mod_do_not_inflate_enclosing_cc() {
415        let f = write_temp(
416            r"
417fn outer() -> u32 {
418    struct S;
419    impl S {
420        fn branchy(x: u32) -> u32 {
421            match x {
422                0 => 1,
423                1 => 2,
424                _ => 3,
425            }
426        }
427    }
428    mod local {
429        pub fn helper(b: bool) -> bool {
430            b && !b || b
431        }
432    }
433    S::branchy(local::helper(true) as u32)
434}
435",
436        );
437        let fns = analyze_file(f.path()).expect("analyze");
438        assert_eq!(fns.len(), 1);
439        assert_eq!(
440            fns[0].cyclomatic, 1.0,
441            "match arms and boolean operators inside nested impl/mod items \
442             must not count toward outer"
443        );
444    }
445
446    #[test]
447    fn code_after_a_nested_item_still_counts() {
448        // The item stop must not swallow the rest of the enclosing body:
449        // decision points after the nested fn still belong to outer.
450        let f = write_temp(
451            r"
452fn outer(x: i32) -> i32 {
453    fn inner() -> i32 { 1 }
454    if x > 0 { inner() } else { 0 }
455}
456",
457        );
458        let fns = analyze_file(f.path()).expect("analyze");
459        assert_eq!(fns.len(), 1);
460        assert_eq!(
461            fns[0].cyclomatic, 2.0,
462            "outer's own `if` after the nested item must still count"
463        );
464    }
465
466    #[test]
467    fn multiple_functions_are_all_found() {
468        let f = write_temp(
469            r"
470fn a() {}
471fn b() {}
472fn c() {}
473",
474        );
475        let fns = analyze_file(f.path()).expect("analyze");
476        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
477        assert!(names.contains(&"a"));
478        assert!(names.contains(&"b"));
479        assert!(names.contains(&"c"));
480    }
481
482    #[test]
483    fn for_loop_adds_one_to_cc() {
484        // Kills: visit_expr_for_loop replaced with (), += with -=, += with *=
485        let f = write_temp("fn foo(n: i32) -> i32 { let mut s = 0; for _i in 0..n { s += 1; } s }");
486        let fns = analyze_file(f.path()).expect("analyze");
487        assert_eq!(
488            fns[0].cyclomatic, 2.0,
489            "for loop must add exactly 1 to base CC"
490        );
491    }
492
493    #[test]
494    fn while_loop_adds_one_to_cc() {
495        // Kills: visit_expr_while replaced with (), += with -=, += with *=
496        let f = write_temp("fn foo(mut n: i32) -> i32 { while n > 0 { n -= 1; } n }");
497        let fns = analyze_file(f.path()).expect("analyze");
498        assert_eq!(
499            fns[0].cyclomatic, 2.0,
500            "while loop must add exactly 1 to base CC"
501        );
502    }
503
504    #[test]
505    fn loop_expr_adds_one_to_cc() {
506        // Kills: visit_expr_loop replaced with (), += with -=, += with *=
507        let f = write_temp("fn foo() { loop { break; } }");
508        let fns = analyze_file(f.path()).expect("analyze");
509        assert_eq!(fns[0].cyclomatic, 2.0, "loop must add exactly 1 to base CC");
510    }
511
512    #[test]
513    fn match_arms_each_add_one_to_cc() {
514        // Kills: visit_arm replaced with (), += with -=, += with *=
515        let f = write_temp("fn foo(x: u8) -> u8 { match x { 0 => 1, 1 => 2, _ => 3 } }");
516        let fns = analyze_file(f.path()).expect("analyze");
517        assert_eq!(fns[0].cyclomatic, 4.0, "3-arm match must add 3 to base CC");
518    }
519
520    #[test]
521    fn logical_and_adds_one_to_cc() {
522        // Kills: visit_expr_binary replaced with (), += with -=, += with *=
523        let f = write_temp("fn foo(a: bool, b: bool) -> bool { a && b }");
524        let fns = analyze_file(f.path()).expect("analyze");
525        assert_eq!(fns[0].cyclomatic, 2.0, "&& must add exactly 1 to base CC");
526    }
527
528    #[test]
529    fn logical_or_adds_one_to_cc() {
530        // Kills: visit_expr_binary for || case
531        let f = write_temp("fn foo(a: bool, b: bool) -> bool { a || b }");
532        let fns = analyze_file(f.path()).expect("analyze");
533        assert_eq!(fns[0].cyclomatic, 2.0, "|| must add exactly 1 to base CC");
534    }
535
536    #[test]
537    fn bitwise_ops_do_not_increase_cc() {
538        // & and | are not control flow — they must NOT add to CC.
539        let f = write_temp("fn foo(a: u8, b: u8) -> u8 { a & b | a }");
540        let fns = analyze_file(f.path()).expect("analyze");
541        assert_eq!(fns[0].cyclomatic, 1.0, "bitwise ops must not affect CC");
542    }
543
544    #[test]
545    fn try_operator_adds_one_to_cc() {
546        // Kills: visit_expr_try replaced with (), += with -=, += with *=
547        let f = write_temp("fn foo() -> Option<i32> { let x: Option<i32> = Some(1); Some(x?) }");
548        let fns = analyze_file(f.path()).expect("analyze");
549        assert_eq!(
550            fns[0].cyclomatic, 2.0,
551            "? operator must add exactly 1 to base CC"
552        );
553    }
554
555    #[test]
556    fn closure_decisions_not_counted_in_enclosing_fn() {
557        // A closure with branches must not inflate the outer function's CC.
558        let f = write_temp("fn foo() -> i32 { let f = |x: i32| if x > 0 { x } else { -x }; f(1) }");
559        let fns = analyze_file(f.path()).expect("analyze");
560        assert_eq!(
561            fns[0].cyclomatic, 1.0,
562            "closure branches must not leak into outer CC"
563        );
564    }
565
566    #[test]
567    fn impl_methods_are_found() {
568        let f = write_temp(
569            r"
570struct Foo;
571impl Foo {
572    fn bar(&self) -> i32 { 1 }
573    fn baz(&self, x: i32) -> i32 {
574        if x > 0 { x } else { -x }
575    }
576}
577",
578        );
579        let fns = analyze_file(f.path()).expect("analyze");
580        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
581        assert!(
582            names.contains(&"Foo::bar"),
583            "expected Foo::bar, got {names:?}"
584        );
585        assert!(
586            names.contains(&"Foo::baz"),
587            "expected Foo::baz, got {names:?}"
588        );
589        let baz = fns.iter().find(|f| f.name == "Foo::baz").unwrap();
590        assert!(
591            baz.cyclomatic >= 2.0,
592            "baz should have CC >= 2, got {}",
593            baz.cyclomatic
594        );
595    }
596
597    // --- #[test] / #[cfg(test)] filtering ---
598
599    #[test]
600    fn test_functions_are_excluded() {
601        // Kills: removing the `has_attr(&node.attrs, "test")` early return.
602        let f = write_temp(
603            r"
604fn real() -> i32 { 42 }
605
606#[test]
607fn test_real() {
608    assert_eq!(real(), 42);
609}
610",
611        );
612        let fns = analyze_file(f.path()).expect("analyze");
613        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
614        assert!(names.contains(&"real"), "production fn must be present");
615        assert!(
616            !names.contains(&"test_real"),
617            "#[test] fn must be excluded, got: {names:?}"
618        );
619    }
620
621    #[test]
622    fn cfg_test_module_is_fully_excluded() {
623        // Kills: removing the visit_item_mod override (all three functions
624        // inside the module would otherwise appear).
625        let f = write_temp(
626            r"
627fn real() -> i32 { 42 }
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    fn helper(x: i32) -> i32 { x + 1 }
634
635    #[test]
636    fn test_real() {
637        assert_eq!(real(), 42);
638    }
639}
640",
641        );
642        let fns = analyze_file(f.path()).expect("analyze");
643        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
644        assert!(names.contains(&"real"), "production fn must be present");
645        assert!(
646            !names.contains(&"helper"),
647            "fn inside #[cfg(test)] mod must be excluded, got: {names:?}"
648        );
649        assert!(
650            !names.contains(&"test_real"),
651            "#[test] fn inside #[cfg(test)] mod must be excluded, got: {names:?}"
652        );
653    }
654
655    #[test]
656    fn non_cfg_test_module_functions_are_included() {
657        // Kills: replacing visit_item_mod with () — a no-op body would skip
658        // ALL module traversal, not just #[cfg(test)] ones.
659        // Also kills: replacing is_cfg_test with `true` — everything would
660        // look like a test module and be skipped.
661        let f = write_temp(
662            r"
663mod inner {
664    pub fn in_module() -> i32 { 1 }
665}
666",
667        );
668        let fns = analyze_file(f.path()).expect("analyze");
669        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
670        assert!(
671            names.contains(&"in_module"),
672            "fn inside a plain mod must be included, got: {names:?}"
673        );
674    }
675
676    #[test]
677    fn cfg_feature_module_is_not_skipped() {
678        // Kills: replacing `&&` with `||` in is_cfg_test — that mutation
679        // would make any `#[cfg(...)]` attribute look like #[cfg(test)],
680        // causing #[cfg(feature = "...")] modules to be wrongly excluded.
681        let f = write_temp(
682            r#"
683#[cfg(feature = "extra")]
684mod extra {
685    pub fn feature_fn() -> i32 { 1 }
686}
687"#,
688        );
689        let fns = analyze_file(f.path()).expect("analyze");
690        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
691        assert!(
692            names.contains(&"feature_fn"),
693            "#[cfg(feature = ...)] mod must not be skipped, got: {names:?}"
694        );
695    }
696
697    #[test]
698    fn only_test_attribute_is_filtered_not_other_attributes() {
699        // A fn with an unrelated attribute (#[allow(...)]) must NOT be excluded.
700        let f = write_temp(
701            r"
702#[allow(dead_code)]
703fn allowed() -> i32 { 42 }
704",
705        );
706        let fns = analyze_file(f.path()).expect("analyze");
707        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
708        assert!(
709            names.contains(&"allowed"),
710            "#[allow(...)] fn must not be excluded, got: {names:?}"
711        );
712    }
713
714    // --- --exclude glob patterns ---
715
716    #[test]
717    fn analyze_tree_excludes_matching_files() {
718        use std::fs;
719        let dir = tempfile::tempdir().expect("tempdir");
720
721        // File that should be kept.
722        let src = dir.path().join("src");
723        fs::create_dir(&src).expect("mkdir src");
724        fs::write(src.join("lib.rs"), "fn kept() -> i32 { 42 }").expect("write lib.rs");
725
726        // File that should be excluded by the glob.
727        let generated = dir.path().join("generated");
728        fs::create_dir(&generated).expect("mkdir generated");
729        fs::write(generated.join("proto.rs"), "fn excluded() -> i32 { 1 }")
730            .expect("write proto.rs");
731
732        let results = analyze_tree(dir.path(), &["generated/**"]).expect("analyze_tree");
733        let names: Vec<_> = results.iter().map(|f| f.name.as_str()).collect();
734        assert!(names.contains(&"kept"), "src/lib.rs fn must appear");
735        assert!(
736            !names.contains(&"excluded"),
737            "generated/proto.rs fn must be excluded, got: {names:?}"
738        );
739    }
740
741    #[test]
742    fn analyze_tree_with_empty_excludes_keeps_all_files() {
743        // Kills: accidentally filtering everything when excludes is empty.
744        use std::fs;
745        let dir = tempfile::tempdir().expect("tempdir");
746        fs::write(dir.path().join("lib.rs"), "fn foo() -> i32 { 1 }").expect("write");
747
748        let results = analyze_tree(dir.path(), &[] as &[&str]).expect("analyze_tree");
749        assert!(!results.is_empty(), "no excludes must keep all files");
750    }
751
752    #[test]
753    fn invalid_exclude_pattern_returns_error() {
754        // Kills: silently ignoring invalid patterns.
755        let dir = tempfile::tempdir().expect("tempdir");
756        let result = analyze_tree(dir.path(), &["[invalid"]);
757        assert!(result.is_err(), "invalid glob must return an error");
758    }
759}