Skip to main content

exspec_lang_rust/
observe.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Query, QueryCursor};
7
8use exspec_core::observe::{
9    BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
10    ProductionFunction,
11};
12
13use super::RustExtractor;
14
15const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
16static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
17
18const CFG_TEST_QUERY: &str = include_str!("../queries/cfg_test.scm");
19static CFG_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
20
21const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
22static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
23
24fn rust_language() -> tree_sitter::Language {
25    tree_sitter_rust::LANGUAGE.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29    lock.get_or_init(|| Query::new(&rust_language(), source).expect("invalid query"))
30}
31
32// ---------------------------------------------------------------------------
33// Stem helpers
34// ---------------------------------------------------------------------------
35
36/// Extract stem from a Rust test file path.
37/// `tests/test_foo.rs` -> `Some("foo")`  (test_ prefix)
38/// `tests/foo_test.rs` -> `Some("foo")`  (_test suffix)
39/// `tests/integration.rs` -> `Some("integration")` (tests/ dir = integration test)
40/// `src/user.rs` -> `None`
41pub fn test_stem(path: &str) -> Option<&str> {
42    let file_name = Path::new(path).file_name()?.to_str()?;
43    let stem = file_name.strip_suffix(".rs")?;
44
45    // test_ prefix
46    if let Some(rest) = stem.strip_prefix("test_") {
47        if !rest.is_empty() {
48            return Some(rest);
49        }
50    }
51
52    // _test suffix
53    if let Some(rest) = stem.strip_suffix("_test") {
54        if !rest.is_empty() {
55            return Some(rest);
56        }
57    }
58
59    // Files under tests/ directory are integration tests
60    let normalized = path.replace('\\', "/");
61    if normalized.starts_with("tests/") || normalized.contains("/tests/") {
62        // Exclude mod.rs and main.rs in tests dir
63        if stem != "mod" && stem != "main" {
64            return Some(stem);
65        }
66    }
67
68    None
69}
70
71/// Extract stem from a Rust production file path.
72/// `src/user.rs` -> `Some("user")`
73/// `src/lib.rs` -> `None` (barrel)
74/// `src/mod.rs` -> `None` (barrel)
75/// `src/main.rs` -> `None` (entry point)
76/// `tests/test_foo.rs` -> `None` (test file)
77pub fn production_stem(path: &str) -> Option<&str> {
78    let file_name = Path::new(path).file_name()?.to_str()?;
79    let stem = file_name.strip_suffix(".rs")?;
80
81    // Exclude barrel and entry point files
82    if stem == "lib" || stem == "mod" || stem == "main" {
83        return None;
84    }
85
86    // Exclude test files
87    if test_stem(path).is_some() {
88        return None;
89    }
90
91    // Exclude build.rs
92    if file_name == "build.rs" {
93        return None;
94    }
95
96    Some(stem)
97}
98
99/// Check if a file is a non-SUT helper (not subject under test).
100pub fn is_non_sut_helper(file_path: &str, _is_known_production: bool) -> bool {
101    let normalized = file_path.replace('\\', "/");
102    let file_name = Path::new(&normalized)
103        .file_name()
104        .and_then(|f| f.to_str())
105        .unwrap_or("");
106
107    // build.rs
108    if file_name == "build.rs" {
109        return true;
110    }
111
112    // tests/common/mod.rs and tests/common/*.rs (test helpers)
113    if normalized.contains("/tests/common/") || normalized.starts_with("tests/common/") {
114        return true;
115    }
116
117    // benches/ directory
118    if normalized.starts_with("benches/") || normalized.contains("/benches/") {
119        return true;
120    }
121
122    // examples/ directory
123    if normalized.starts_with("examples/") || normalized.contains("/examples/") {
124        return true;
125    }
126
127    false
128}
129
130// ---------------------------------------------------------------------------
131// Inline test detection (Layer 0)
132// ---------------------------------------------------------------------------
133
134/// Detect #[cfg(test)] mod blocks in source code.
135pub fn detect_inline_tests(source: &str) -> bool {
136    let mut parser = RustExtractor::parser();
137    let tree = match parser.parse(source, None) {
138        Some(t) => t,
139        None => return false,
140    };
141    let source_bytes = source.as_bytes();
142    let query = cached_query(&CFG_TEST_QUERY_CACHE, CFG_TEST_QUERY);
143
144    let attr_name_idx = query.capture_index_for_name("attr_name");
145    let cfg_arg_idx = query.capture_index_for_name("cfg_arg");
146    let cfg_test_attr_idx = query.capture_index_for_name("cfg_test_attr");
147
148    let mut cursor = QueryCursor::new();
149    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
150
151    while let Some(m) = matches.next() {
152        let mut is_cfg = false;
153        let mut is_test = false;
154        let mut attr_node: Option<tree_sitter::Node> = None;
155
156        for cap in m.captures {
157            let text = cap.node.utf8_text(source_bytes).unwrap_or("");
158            if attr_name_idx == Some(cap.index) && text == "cfg" {
159                is_cfg = true;
160            }
161            if cfg_arg_idx == Some(cap.index) && text == "test" {
162                is_test = true;
163            }
164            if cfg_test_attr_idx == Some(cap.index) {
165                attr_node = Some(cap.node);
166            }
167        }
168
169        if is_cfg && is_test {
170            // Guard: skip if the full attribute contains `not(test)` (negated cfg).
171            if let Some(ref attr) = attr_node {
172                let attr_text = attr.utf8_text(source_bytes).unwrap_or("");
173                if attr_text.contains("not(test)") || attr_text.contains("not( test") {
174                    continue;
175                }
176            }
177            // Verify that the next sibling (skipping other attribute_items) is a mod_item
178            if let Some(attr) = attr_node {
179                let mut sibling = attr.next_sibling();
180                while let Some(s) = sibling {
181                    if s.kind() == "mod_item" {
182                        return true;
183                    }
184                    if s.kind() != "attribute_item" {
185                        break;
186                    }
187                    sibling = s.next_sibling();
188                }
189            }
190        }
191    }
192
193    false
194}
195
196// ---------------------------------------------------------------------------
197// ObserveExtractor impl
198// ---------------------------------------------------------------------------
199
200impl ObserveExtractor for RustExtractor {
201    fn extract_production_functions(
202        &self,
203        source: &str,
204        file_path: &str,
205    ) -> Vec<ProductionFunction> {
206        let mut parser = Self::parser();
207        let tree = match parser.parse(source, None) {
208            Some(t) => t,
209            None => return Vec::new(),
210        };
211        let source_bytes = source.as_bytes();
212        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
213
214        let name_idx = query.capture_index_for_name("name");
215        let class_name_idx = query.capture_index_for_name("class_name");
216        let method_name_idx = query.capture_index_for_name("method_name");
217        let function_idx = query.capture_index_for_name("function");
218        let method_idx = query.capture_index_for_name("method");
219
220        // Find byte ranges of #[cfg(test)] mod blocks to exclude
221        let cfg_test_ranges = find_cfg_test_ranges(source);
222
223        let mut cursor = QueryCursor::new();
224        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
225        let mut result = Vec::new();
226
227        while let Some(m) = matches.next() {
228            let mut fn_name: Option<String> = None;
229            let mut class_name: Option<String> = None;
230            let mut line: usize = 1;
231            let mut is_exported = false;
232            let mut fn_start_byte: usize = 0;
233
234            for cap in m.captures {
235                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
236                let node_line = cap.node.start_position().row + 1;
237
238                if name_idx == Some(cap.index) || method_name_idx == Some(cap.index) {
239                    fn_name = Some(text);
240                    line = node_line;
241                } else if class_name_idx == Some(cap.index) {
242                    class_name = Some(text);
243                }
244
245                // Check visibility for function/method nodes
246                if function_idx == Some(cap.index) || method_idx == Some(cap.index) {
247                    fn_start_byte = cap.node.start_byte();
248                    is_exported = has_pub_visibility(cap.node);
249                }
250            }
251
252            if let Some(name) = fn_name {
253                // Skip functions inside #[cfg(test)] blocks
254                if cfg_test_ranges
255                    .iter()
256                    .any(|(start, end)| fn_start_byte >= *start && fn_start_byte < *end)
257                {
258                    continue;
259                }
260
261                result.push(ProductionFunction {
262                    name,
263                    file: file_path.to_string(),
264                    line,
265                    class_name,
266                    is_exported,
267                });
268            }
269        }
270
271        // Deduplicate
272        let mut seen = HashSet::new();
273        result.retain(|f| seen.insert((f.name.clone(), f.class_name.clone())));
274
275        result
276    }
277
278    fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
279        // For Rust, extract_imports returns relative imports (use crate::... mapped to relative paths)
280        let all = self.extract_all_import_specifiers(source);
281        let mut result = Vec::new();
282        for (specifier, symbols) in all {
283            for sym in &symbols {
284                result.push(ImportMapping {
285                    symbol_name: sym.clone(),
286                    module_specifier: specifier.clone(),
287                    file: file_path.to_string(),
288                    line: 1,
289                    symbols: symbols.clone(),
290                });
291            }
292        }
293        result
294    }
295
296    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
297        extract_import_specifiers_with_crate_name(source, None)
298    }
299
300    fn extract_barrel_re_exports(&self, source: &str, file_path: &str) -> Vec<BarrelReExport> {
301        if !self.is_barrel_file(file_path) {
302            return Vec::new();
303        }
304
305        let mut parser = Self::parser();
306        let tree = match parser.parse(source, None) {
307            Some(t) => t,
308            None => return Vec::new(),
309        };
310        let source_bytes = source.as_bytes();
311        let root = tree.root_node();
312        let mut result = Vec::new();
313
314        for i in 0..root.child_count() {
315            let child = root.child(i).unwrap();
316
317            // pub mod foo; -> BarrelReExport { from_specifier: "./foo", wildcard: true }
318            if child.kind() == "mod_item" && has_pub_visibility(child) {
319                // Check it's a declaration (no body block)
320                let has_body = child.child_by_field_name("body").is_some();
321                if !has_body {
322                    if let Some(name_node) = child.child_by_field_name("name") {
323                        let mod_name = name_node.utf8_text(source_bytes).unwrap_or("");
324                        result.push(BarrelReExport {
325                            symbols: Vec::new(),
326                            from_specifier: format!("./{mod_name}"),
327                            wildcard: true,
328                            namespace_wildcard: false,
329                        });
330                    }
331                }
332            }
333
334            // pub use foo::*; or pub use foo::{Bar, Baz};
335            if child.kind() == "use_declaration" && has_pub_visibility(child) {
336                if let Some(arg) = child.child_by_field_name("argument") {
337                    extract_pub_use_re_exports(&arg, source_bytes, &mut result);
338                }
339            }
340
341            // cfg macro blocks: cfg_*! { pub mod ...; pub use ...; }
342            if child.kind() == "macro_invocation" {
343                for j in 0..child.child_count() {
344                    if let Some(tt) = child.child(j) {
345                        if tt.kind() == "token_tree" {
346                            let tt_text = tt.utf8_text(source_bytes).unwrap_or("");
347                            extract_re_exports_from_text(tt_text, &mut result);
348                        }
349                    }
350                }
351            }
352        }
353
354        result
355    }
356
357    fn source_extensions(&self) -> &[&str] {
358        &["rs"]
359    }
360
361    fn index_file_names(&self) -> &[&str] {
362        &["mod.rs", "lib.rs"]
363    }
364
365    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
366        production_stem(path)
367    }
368
369    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
370        test_stem(path)
371    }
372
373    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
374        is_non_sut_helper(file_path, is_known_production)
375    }
376
377    fn file_exports_any_symbol(&self, path: &Path, symbols: &[String]) -> bool {
378        if symbols.is_empty() {
379            return true;
380        }
381        // Optimistic fallback on read/parse failure (matches core default and Python).
382        // FN avoidance is preferred over FP avoidance here.
383        let source = match std::fs::read_to_string(path) {
384            Ok(s) => s,
385            Err(_) => return true,
386        };
387        let mut parser = Self::parser();
388        let tree = match parser.parse(&source, None) {
389            Some(t) => t,
390            None => return true,
391        };
392        let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
393        let symbol_idx = query
394            .capture_index_for_name("symbol_name")
395            .expect("@symbol_name capture not found in exported_symbol.scm");
396        let vis_idx = query.capture_index_for_name("vis");
397
398        let source_bytes = source.as_bytes();
399        let mut cursor = QueryCursor::new();
400        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
401        while let Some(m) = matches.next() {
402            for cap in m.captures {
403                if cap.index == symbol_idx {
404                    // Only consider items with exactly `pub` visibility (not `pub(crate)`, `pub(super)`)
405                    let is_pub_only = m.captures.iter().any(|c| {
406                        vis_idx == Some(c.index)
407                            && c.node.utf8_text(source_bytes).unwrap_or("") == "pub"
408                    });
409                    if !is_pub_only {
410                        continue;
411                    }
412                    let name = cap.node.utf8_text(source_bytes).unwrap_or("");
413                    if symbols.iter().any(|s| s == name) {
414                        return true;
415                    }
416                }
417            }
418        }
419        // Fallback: check for pub items inside cfg macros (token_tree is opaque to tree-sitter)
420        // Line-by-line scan to skip comments (avoids matching `// pub struct Foo`)
421        for symbol in symbols {
422            for keyword in &["struct", "fn", "type", "enum", "trait", "const", "static"] {
423                let pattern = format!("pub {keyword} {symbol}");
424                if source.lines().any(|line| {
425                    let trimmed = line.trim();
426                    !trimmed.starts_with("//") && trimmed.contains(&pattern)
427                }) {
428                    return true;
429                }
430            }
431        }
432        false
433    }
434}
435
436// ---------------------------------------------------------------------------
437// Helpers
438// ---------------------------------------------------------------------------
439
440/// Check if a tree-sitter node has `pub` visibility modifier.
441fn has_pub_visibility(node: tree_sitter::Node) -> bool {
442    for i in 0..node.child_count() {
443        if let Some(child) = node.child(i) {
444            if child.kind() == "visibility_modifier" {
445                return true;
446            }
447            // Stop at the first non-attribute, non-visibility child
448            if child.kind() != "attribute_item" && child.kind() != "visibility_modifier" {
449                break;
450            }
451        }
452    }
453    false
454}
455
456/// Find byte ranges of #[cfg(test)] mod blocks.
457/// In tree-sitter-rust, `#[cfg(test)]` is an attribute_item that is a sibling
458/// of the mod_item it annotates. We find the attribute, then look at the next
459/// sibling to get the mod_item range.
460fn find_cfg_test_ranges(source: &str) -> Vec<(usize, usize)> {
461    let mut parser = RustExtractor::parser();
462    let tree = match parser.parse(source, None) {
463        Some(t) => t,
464        None => return Vec::new(),
465    };
466    let source_bytes = source.as_bytes();
467    let query = cached_query(&CFG_TEST_QUERY_CACHE, CFG_TEST_QUERY);
468
469    let attr_name_idx = query.capture_index_for_name("attr_name");
470    let cfg_arg_idx = query.capture_index_for_name("cfg_arg");
471    let cfg_test_attr_idx = query.capture_index_for_name("cfg_test_attr");
472
473    let mut cursor = QueryCursor::new();
474    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
475    let mut ranges = Vec::new();
476
477    while let Some(m) = matches.next() {
478        let mut is_cfg = false;
479        let mut is_test = false;
480        let mut attr_node = None;
481
482        for cap in m.captures {
483            let text = cap.node.utf8_text(source_bytes).unwrap_or("");
484            if attr_name_idx == Some(cap.index) && text == "cfg" {
485                is_cfg = true;
486            }
487            if cfg_arg_idx == Some(cap.index) && text == "test" {
488                is_test = true;
489            }
490            if cfg_test_attr_idx == Some(cap.index) {
491                attr_node = Some(cap.node);
492            }
493        }
494
495        if is_cfg && is_test {
496            if let Some(attr) = attr_node {
497                // Find the next sibling which should be the mod_item
498                let mut sibling = attr.next_sibling();
499                while let Some(s) = sibling {
500                    if s.kind() == "mod_item" {
501                        ranges.push((s.start_byte(), s.end_byte()));
502                        break;
503                    }
504                    sibling = s.next_sibling();
505                }
506            }
507        }
508    }
509
510    ranges
511}
512
513/// Extract use declarations from a `use_declaration` node.
514/// Processes `use crate::...` imports and, if `crate_name` is provided,
515/// also `use {crate_name}::...` imports (for integration tests).
516fn extract_use_declaration(
517    node: &tree_sitter::Node,
518    source_bytes: &[u8],
519    result: &mut HashMap<String, Vec<String>>,
520    crate_name: Option<&str>,
521) {
522    let arg = match node.child_by_field_name("argument") {
523        Some(a) => a,
524        None => return,
525    };
526    let full_text = arg.utf8_text(source_bytes).unwrap_or("");
527
528    // Handle `crate::` prefix
529    if let Some(path_after_crate) = full_text.strip_prefix("crate::") {
530        parse_use_path(path_after_crate, result);
531        return;
532    }
533
534    // Handle `{crate_name}::` prefix for integration tests
535    if let Some(name) = crate_name {
536        let prefix = format!("{name}::");
537        if let Some(path_after_name) = full_text.strip_prefix(&prefix) {
538            parse_use_path(path_after_name, result);
539        }
540    }
541}
542
543/// Extract import specifiers with optional crate name support.
544/// When `crate_name` is `Some`, also resolves `use {crate_name}::...` imports
545/// in addition to the standard `use crate::...` imports.
546pub fn extract_import_specifiers_with_crate_name(
547    source: &str,
548    crate_name: Option<&str>,
549) -> Vec<(String, Vec<String>)> {
550    let mut parser = RustExtractor::parser();
551    let tree = match parser.parse(source, None) {
552        Some(t) => t,
553        None => return Vec::new(),
554    };
555    let source_bytes = source.as_bytes();
556
557    // Manual tree walking for Rust use statements, more reliable than queries
558    // for complex use trees
559    let root = tree.root_node();
560    let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
561
562    for i in 0..root.child_count() {
563        let child = root.child(i).unwrap();
564        if child.kind() == "use_declaration" {
565            extract_use_declaration(&child, source_bytes, &mut result_map, crate_name);
566        }
567    }
568
569    result_map.into_iter().collect()
570}
571
572/// Extract import specifiers with support for multiple crate names.
573///
574/// For each `use X::module::Symbol` statement in `source`:
575/// - If X is `crate`, returns `("crate", "module", ["Symbol"])`.
576/// - If X matches any of `crate_names`, returns `(X, "module", ["Symbol"])`.
577/// - Otherwise (e.g. `std::`, `serde::`) the statement is skipped.
578///
579/// Returns `Vec<(matched_crate_name, specifier, symbols)>`.
580pub fn extract_import_specifiers_with_crate_names(
581    source: &str,
582    crate_names: &[&str],
583) -> Vec<(String, String, Vec<String>)> {
584    let mut parser = RustExtractor::parser();
585    let tree = match parser.parse(source, None) {
586        Some(t) => t,
587        None => return Vec::new(),
588    };
589    let source_bytes = source.as_bytes();
590    let root = tree.root_node();
591    let mut results = Vec::new();
592
593    for i in 0..root.child_count() {
594        let child = root.child(i).unwrap();
595        if child.kind() != "use_declaration" {
596            continue;
597        }
598        let arg = match child.child_by_field_name("argument") {
599            Some(a) => a,
600            None => continue,
601        };
602        let full_text = arg.utf8_text(source_bytes).unwrap_or("");
603
604        // Handle `crate::` prefix
605        if let Some(path_after_crate) = full_text.strip_prefix("crate::") {
606            let mut map: HashMap<String, Vec<String>> = HashMap::new();
607            parse_use_path(path_after_crate, &mut map);
608            for (specifier, symbols) in map {
609                results.push(("crate".to_string(), specifier, symbols));
610            }
611            continue;
612        }
613
614        // Handle each crate name in the list
615        for &name in crate_names {
616            let prefix = format!("{name}::");
617            if let Some(path_after_name) = full_text.strip_prefix(&prefix) {
618                let mut map: HashMap<String, Vec<String>> = HashMap::new();
619                parse_use_path(path_after_name, &mut map);
620                for (specifier, symbols) in map {
621                    results.push((name.to_string(), specifier, symbols));
622                }
623                break; // A use statement can only match one crate name
624            }
625        }
626    }
627
628    results
629}
630
631// ---------------------------------------------------------------------------
632// Workspace support
633// ---------------------------------------------------------------------------
634
635/// A member crate in a Cargo workspace.
636#[derive(Debug)]
637pub struct WorkspaceMember {
638    /// Crate name (hyphens converted to underscores).
639    pub crate_name: String,
640    /// Absolute path to the member crate root (directory containing Cargo.toml).
641    pub member_root: std::path::PathBuf,
642}
643
644/// Directories to skip during workspace member traversal.
645const SKIP_DIRS: &[&str] = &["target", ".cargo", "vendor"];
646
647/// Maximum directory traversal depth when searching for workspace members.
648const MAX_TRAVERSE_DEPTH: usize = 4;
649
650/// Check whether a `Cargo.toml` at `scan_root` contains a `[workspace]` section.
651pub fn has_workspace_section(scan_root: &Path) -> bool {
652    let cargo_toml = scan_root.join("Cargo.toml");
653    let content = match std::fs::read_to_string(&cargo_toml) {
654        Ok(c) => c,
655        Err(_) => return false,
656    };
657    content.lines().any(|line| line.trim() == "[workspace]")
658}
659
660/// Find all member crates in a Cargo workspace rooted at `scan_root`.
661///
662/// Returns an empty `Vec` if `scan_root` does not have a `[workspace]` section
663/// in its `Cargo.toml`.
664///
665/// Supports both virtual workspaces (no `[package]`) and non-virtual workspaces
666/// (both `[workspace]` and `[package]`).
667///
668/// Directories named `target`, `.cargo`, `vendor`, or starting with `.` are
669/// skipped.  Traversal is limited to `MAX_TRAVERSE_DEPTH` levels.
670pub fn find_workspace_members(scan_root: &Path) -> Vec<WorkspaceMember> {
671    if !has_workspace_section(scan_root) {
672        return Vec::new();
673    }
674
675    let mut members = Vec::new();
676    find_members_recursive(scan_root, scan_root, 0, &mut members);
677    members
678}
679
680fn find_members_recursive(
681    scan_root: &Path,
682    dir: &Path,
683    depth: usize,
684    members: &mut Vec<WorkspaceMember>,
685) {
686    if depth > MAX_TRAVERSE_DEPTH {
687        return;
688    }
689
690    let read_dir = match std::fs::read_dir(dir) {
691        Ok(rd) => rd,
692        Err(_) => return,
693    };
694
695    for entry in read_dir.flatten() {
696        let path = entry.path();
697        if !path.is_dir() {
698            continue;
699        }
700
701        let dir_name = match path.file_name().and_then(|n| n.to_str()) {
702            Some(n) => n,
703            None => continue,
704        };
705
706        // Skip hidden directories and known non-source dirs
707        if dir_name.starts_with('.') || SKIP_DIRS.contains(&dir_name) {
708            continue;
709        }
710
711        // Skip the scan root itself (already checked above)
712        if path == scan_root {
713            continue;
714        }
715
716        // Check if this subdirectory has a member Cargo.toml with [package]
717        if let Some(crate_name) = parse_crate_name(&path) {
718            members.push(WorkspaceMember {
719                crate_name,
720                member_root: path.to_path_buf(),
721            });
722            // Don't recurse into member crates (avoids cross-crate confusion)
723            // However, nested workspaces / virtual manifests are rare; skip for now.
724            continue;
725        }
726
727        // Recurse into directories without their own [package]
728        find_members_recursive(scan_root, &path, depth + 1, members);
729    }
730}
731
732/// Find a workspace member by its crate name (exact match).
733///
734/// Returns `None` if no member has the given crate name.
735pub fn find_member_by_crate_name<'a>(
736    name: &str,
737    members: &'a [WorkspaceMember],
738) -> Option<&'a WorkspaceMember> {
739    members.iter().find(|m| m.crate_name == name)
740}
741
742/// Find the workspace member that owns `path` by longest prefix match.
743///
744/// Returns `None` if no member's `member_root` is a prefix of `path`.
745pub fn find_member_for_path<'a>(
746    path: &Path,
747    members: &'a [WorkspaceMember],
748) -> Option<&'a WorkspaceMember> {
749    members
750        .iter()
751        .filter(|m| path.starts_with(&m.member_root))
752        .max_by_key(|m| m.member_root.components().count())
753}
754
755/// Parse the `name = "..."` field from a Cargo.toml `[package]` section.
756/// Hyphens in the name are converted to underscores.
757/// Returns `None` if the file cannot be read or `[package]` section is absent.
758pub fn parse_crate_name(scan_root: &Path) -> Option<String> {
759    let cargo_toml = scan_root.join("Cargo.toml");
760    let content = std::fs::read_to_string(&cargo_toml).ok()?;
761
762    let mut in_package = false;
763    for line in content.lines() {
764        let trimmed = line.trim();
765
766        // Detect section headers
767        if trimmed.starts_with('[') {
768            if trimmed == "[package]" {
769                in_package = true;
770            } else {
771                // Once we hit another section, stop looking
772                if in_package {
773                    break;
774                }
775            }
776            continue;
777        }
778
779        if in_package {
780            // Parse `name = "..."` or `name = '...'`
781            if let Some(rest) = trimmed.strip_prefix("name") {
782                let rest = rest.trim();
783                if let Some(rest) = rest.strip_prefix('=') {
784                    let rest = rest.trim();
785                    // Strip surrounding quotes
786                    let name = if let Some(inner) =
787                        rest.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
788                    {
789                        inner
790                    } else if let Some(inner) =
791                        rest.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))
792                    {
793                        inner
794                    } else {
795                        continue;
796                    };
797                    return Some(name.replace('-', "_"));
798                }
799            }
800        }
801    }
802
803    None
804}
805
806/// Parse a use path after `crate::` has been stripped.
807/// e.g. "user::User" -> ("user", ["User"])
808///      "models::user::User" -> ("models/user", ["User"])
809///      "user::{User, Admin}" -> ("user", ["User", "Admin"])
810///      "user::*" -> ("user", [])
811fn parse_use_path(path: &str, result: &mut HashMap<String, Vec<String>>) {
812    // Handle use list: `module::{A, B}`
813    if let Some(brace_start) = path.find('{') {
814        let module_part = &path[..brace_start.saturating_sub(2)]; // strip trailing ::
815        let specifier = module_part.replace("::", "/");
816        if let Some(brace_end) = path.find('}') {
817            let list_content = &path[brace_start + 1..brace_end];
818            let symbols: Vec<String> = list_content
819                .split(',')
820                .map(|s| s.trim().to_string())
821                .filter(|s| !s.is_empty() && s != "*")
822                .collect();
823            result.entry(specifier).or_default().extend(symbols);
824        }
825        return;
826    }
827
828    // Handle wildcard: `module::*`
829    if let Some(module_part) = path.strip_suffix("::*") {
830        let specifier = module_part.replace("::", "/");
831        if !specifier.is_empty() {
832            result.entry(specifier).or_default();
833        }
834        return;
835    }
836
837    // Single-segment module import (e.g., `use crate::fs`)
838    if !path.contains("::") && !path.is_empty() {
839        result.entry(path.to_string()).or_default();
840        return;
841    }
842
843    // Simple path: `module::Symbol`
844    let parts: Vec<&str> = path.split("::").collect();
845    if parts.len() >= 2 {
846        let module_parts = &parts[..parts.len() - 1];
847        let symbol = parts[parts.len() - 1];
848        let specifier = module_parts.join("/");
849        result
850            .entry(specifier)
851            .or_default()
852            .push(symbol.to_string());
853    }
854}
855
856/// Extract `pub mod` and `pub use` re-exports from raw text (e.g., inside cfg macro token_tree).
857/// Uses text matching since tree-sitter token_tree content is not structured AST.
858fn extract_re_exports_from_text(text: &str, result: &mut Vec<BarrelReExport>) {
859    let joined = join_multiline_pub_use(text);
860    for line in joined.lines() {
861        let trimmed = line.trim();
862        // Skip token_tree boundary lines (bare `{` or `}`)
863        if trimmed == "{" || trimmed == "}" {
864            continue;
865        }
866        // Strip surrounding braces for single-line token_tree: "{ pub use ...; }"
867        let trimmed = trimmed
868            .strip_prefix('{')
869            .unwrap_or(trimmed)
870            .strip_suffix('}')
871            .unwrap_or(trimmed)
872            .trim();
873
874        // A single token_tree line may contain multiple statements: "pub mod tcp; pub use ...;"
875        // Split by ';' and process each statement individually.
876        // We do not split inside braces (e.g. pub use mod::{A, B}) — but such content
877        // never contains ';' inside the brace list, so a simple split is safe.
878        let statements: Vec<&str> = trimmed
879            .split(';')
880            .map(str::trim)
881            .filter(|s| !s.is_empty())
882            .collect();
883        for stmt in statements {
884            extract_single_re_export_stmt(stmt, result);
885        }
886    }
887}
888
889fn extract_single_re_export_stmt(trimmed: &str, result: &mut Vec<BarrelReExport>) {
890    // pub mod foo (input is already split by ';', so no trailing semicolon)
891    if trimmed.starts_with("pub mod ") || trimmed.starts_with("pub(crate) mod ") {
892        let mod_name = trimmed
893            .trim_start_matches("pub(crate) mod ")
894            .trim_start_matches("pub mod ")
895            .trim_end_matches(';')
896            .trim();
897        if !mod_name.is_empty() && !mod_name.contains(' ') {
898            result.push(BarrelReExport {
899                symbols: Vec::new(),
900                from_specifier: format!("./{mod_name}"),
901                wildcard: true,
902                namespace_wildcard: false,
903            });
904        }
905    }
906
907    // pub use module::{A, B}; or pub use module::*;
908    if trimmed.starts_with("pub use ") && trimmed.contains("::") {
909        let use_path = trimmed
910            .trim_start_matches("pub use ")
911            .trim_end_matches(';')
912            .trim();
913        let use_path = use_path.strip_prefix("self::").unwrap_or(use_path);
914        // pub use self::*; -> after strip, use_path = "*"
915        if use_path == "*" {
916            result.push(BarrelReExport {
917                symbols: Vec::new(),
918                from_specifier: "./".to_string(),
919                wildcard: true,
920                namespace_wildcard: false,
921            });
922            return;
923        }
924        // Delegate to the same text-based parsing used for tree-sitter nodes
925        if use_path.ends_with("::*") {
926            let module_part = use_path.strip_suffix("::*").unwrap_or("");
927            result.push(BarrelReExport {
928                symbols: Vec::new(),
929                from_specifier: format!("./{}", module_part.replace("::", "/")),
930                wildcard: true,
931                namespace_wildcard: false,
932            });
933        } else if let Some(brace_start) = use_path.find('{') {
934            let module_part = &use_path[..brace_start.saturating_sub(2)];
935            if let Some(brace_end) = use_path.find('}') {
936                let list_content = &use_path[brace_start + 1..brace_end];
937                let symbols: Vec<String> = list_content
938                    .split(',')
939                    .map(|s| s.trim().to_string())
940                    .filter(|s| !s.is_empty() && s != "*")
941                    .collect();
942                result.push(BarrelReExport {
943                    symbols,
944                    from_specifier: format!("./{}", module_part.replace("::", "/")),
945                    wildcard: false,
946                    namespace_wildcard: false,
947                });
948            }
949        } else {
950            // pub use module::Symbol;
951            let parts: Vec<&str> = use_path.split("::").collect();
952            if parts.len() >= 2 {
953                let module_parts = &parts[..parts.len() - 1];
954                let symbol = parts[parts.len() - 1];
955                result.push(BarrelReExport {
956                    symbols: vec![symbol.to_string()],
957                    from_specifier: format!("./{}", module_parts.join("/")),
958                    wildcard: false,
959                    namespace_wildcard: false,
960                });
961            }
962        }
963    }
964}
965
966/// Extract pub use re-exports for barrel files.
967fn extract_pub_use_re_exports(
968    arg: &tree_sitter::Node,
969    source_bytes: &[u8],
970    result: &mut Vec<BarrelReExport>,
971) {
972    let full_text = arg.utf8_text(source_bytes).unwrap_or("");
973    // Strip `self::` prefix (means "current module" in Rust)
974    let full_text = full_text.strip_prefix("self::").unwrap_or(full_text);
975
976    // pub use self::*; -> after strip, full_text = "*"
977    if full_text == "*" {
978        result.push(BarrelReExport {
979            symbols: Vec::new(),
980            from_specifier: "./".to_string(),
981            wildcard: true,
982            namespace_wildcard: false,
983        });
984        return;
985    }
986
987    // pub use module::*;
988    if full_text.ends_with("::*") {
989        let module_part = full_text.strip_suffix("::*").unwrap_or("");
990        result.push(BarrelReExport {
991            symbols: Vec::new(),
992            from_specifier: format!("./{}", module_part.replace("::", "/")),
993            wildcard: true,
994            namespace_wildcard: false,
995        });
996        return;
997    }
998
999    // pub use module::{A, B};
1000    if let Some(brace_start) = full_text.find('{') {
1001        let module_part = &full_text[..brace_start.saturating_sub(2)]; // strip trailing ::
1002        if let Some(brace_end) = full_text.find('}') {
1003            let list_content = &full_text[brace_start + 1..brace_end];
1004            let symbols: Vec<String> = list_content
1005                .split(',')
1006                .map(|s| s.trim().to_string())
1007                .filter(|s| !s.is_empty())
1008                .collect();
1009            result.push(BarrelReExport {
1010                symbols,
1011                from_specifier: format!("./{}", module_part.replace("::", "/")),
1012                wildcard: false,
1013                namespace_wildcard: false,
1014            });
1015        }
1016        return;
1017    }
1018
1019    // pub use module::Symbol;
1020    let parts: Vec<&str> = full_text.split("::").collect();
1021    if parts.len() >= 2 {
1022        let module_parts = &parts[..parts.len() - 1];
1023        let symbol = parts[parts.len() - 1];
1024        result.push(BarrelReExport {
1025            symbols: vec![symbol.to_string()],
1026            from_specifier: format!("./{}", module_parts.join("/")),
1027            wildcard: false,
1028            namespace_wildcard: false,
1029        });
1030    }
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Helper: extract subdir from test path
1035// ---------------------------------------------------------------------------
1036
1037/// Extract the subdirectory immediately under `tests/` from a normalized path.
1038///
1039/// Examples:
1040/// - `"tests/builder/action.rs"` → `Some("builder")`
1041/// - `"member/tests/builder/action.rs"` → `Some("builder")`
1042/// - `"tests/action.rs"` → `None` (file directly under tests/, no subdir)
1043fn extract_test_subdir(path: &str) -> Option<String> {
1044    let parts: Vec<&str> = path.split('/').collect();
1045    for (i, part) in parts.iter().enumerate() {
1046        if *part == "tests" && i + 2 < parts.len() {
1047            return Some(parts[i + 1].to_string());
1048        }
1049    }
1050    None
1051}
1052
1053// ---------------------------------------------------------------------------
1054// Concrete methods (not in trait)
1055// ---------------------------------------------------------------------------
1056
1057impl RustExtractor {
1058    /// Layer 0 + Layer 1 + Layer 2: Map test files to production files.
1059    ///
1060    /// Layer 0: Inline test self-mapping (#[cfg(test)] in production files)
1061    /// Layer 1: Filename convention matching
1062    /// Layer 2: Import tracing (use crate::...)
1063    pub fn map_test_files_with_imports(
1064        &self,
1065        production_files: &[String],
1066        test_sources: &HashMap<String, String>,
1067        scan_root: &Path,
1068        l1_exclusive: bool,
1069    ) -> Vec<FileMapping> {
1070        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
1071
1072        // Layer 1: filename convention
1073        let mut mappings =
1074            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
1075
1076        // Layer 0: Inline test self-mapping
1077        for (idx, prod_file) in production_files.iter().enumerate() {
1078            // Skip barrel/entry point files (mod.rs, lib.rs, main.rs, build.rs)
1079            if production_stem(prod_file).is_none() {
1080                continue;
1081            }
1082            if let Ok(source) = std::fs::read_to_string(prod_file) {
1083                if detect_inline_tests(&source) {
1084                    // Self-map: production file maps to itself
1085                    if !mappings[idx].test_files.contains(prod_file) {
1086                        mappings[idx].test_files.push(prod_file.clone());
1087                    }
1088                }
1089            }
1090        }
1091
1092        // Build canonical path -> production index lookup
1093        let canonical_root = match scan_root.canonicalize() {
1094            Ok(r) => r,
1095            Err(_) => return mappings,
1096        };
1097        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
1098        for (idx, prod) in production_files.iter().enumerate() {
1099            if let Ok(canonical) = Path::new(prod).canonicalize() {
1100                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
1101            }
1102        }
1103
1104        // Record Layer 1 matches per production file index
1105        let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
1106            .iter()
1107            .map(|m| m.test_files.iter().cloned().collect())
1108            .collect();
1109
1110        // Collect set of test files matched by L1 for l1_exclusive mode
1111        let mut layer1_matched: HashSet<String> = layer1_tests_per_prod
1112            .iter()
1113            .flat_map(|s| s.iter().cloned())
1114            .collect();
1115
1116        // Layer 1.5: underscore-to-path stem matching
1117        // e.g., "tests/sync_broadcast.rs" (stem "sync_broadcast") -> "src/sync/broadcast.rs"
1118        self.apply_l1_5_underscore_path_matching(
1119            &mut mappings,
1120            &test_file_list,
1121            &mut layer1_matched,
1122        );
1123
1124        // Layer 1.6: subdir stem matching
1125        // e.g., "tests/builder/action.rs" -> "src/builder/action.rs" or
1126        //       "member/src/builder/action.rs" (cross-crate)
1127        self.apply_l1_subdir_matching(&mut mappings, &test_file_list, &mut layer1_matched);
1128
1129        // Resolve crate name for integration test import matching
1130        let crate_name = parse_crate_name(scan_root);
1131        let members = find_workspace_members(scan_root);
1132
1133        // Layer 2: import tracing
1134        if let Some(ref name) = crate_name {
1135            // Root has a [package]: apply L2 for root crate itself
1136            self.apply_l2_imports(
1137                test_sources,
1138                name,
1139                scan_root,
1140                &canonical_root,
1141                &canonical_to_idx,
1142                &mut mappings,
1143                l1_exclusive,
1144                &layer1_matched,
1145            );
1146        }
1147
1148        if !members.is_empty() {
1149            // Workspace mode: apply L2 per member crate
1150            for member in &members {
1151                // Collect only the test files belonging to this member
1152                let member_test_sources: HashMap<String, String> = test_sources
1153                    .iter()
1154                    .filter(|(path, _)| {
1155                        find_member_for_path(Path::new(path.as_str()), &members)
1156                            .map(|m| std::ptr::eq(m, member))
1157                            .unwrap_or(false)
1158                    })
1159                    .map(|(k, v)| (k.clone(), v.clone()))
1160                    .collect();
1161
1162                self.apply_l2_imports(
1163                    &member_test_sources,
1164                    &member.crate_name,
1165                    &member.member_root,
1166                    &canonical_root,
1167                    &canonical_to_idx,
1168                    &mut mappings,
1169                    l1_exclusive,
1170                    &layer1_matched,
1171                );
1172            }
1173
1174            // Cross-crate fallback for root integration tests:
1175            // Tests not owned by any member (e.g., tests/ at workspace root) may import
1176            // workspace member crates directly (e.g., `use clap_builder::...`).
1177            // Try resolving these root tests against each member's src/.
1178            let root_test_sources: HashMap<String, String> = test_sources
1179                .iter()
1180                .filter(|(path, _)| {
1181                    find_member_for_path(Path::new(path.as_str()), &members).is_none()
1182                })
1183                .map(|(k, v)| (k.clone(), v.clone()))
1184                .collect();
1185
1186            if !root_test_sources.is_empty() {
1187                for member in &members {
1188                    // Try member's own crate name (e.g., `use clap_builder::`)
1189                    self.apply_l2_imports(
1190                        &root_test_sources,
1191                        &member.crate_name,
1192                        &member.member_root,
1193                        &canonical_root,
1194                        &canonical_to_idx,
1195                        &mut mappings,
1196                        l1_exclusive,
1197                        &layer1_matched,
1198                    );
1199                    // Also try root crate name resolved in member's src/
1200                    // (handles `use clap::builder::Arg` → resolves in clap_builder/src/)
1201                    if let Some(ref root_name) = crate_name {
1202                        if *root_name != member.crate_name {
1203                            self.apply_l2_imports(
1204                                &root_test_sources,
1205                                root_name,
1206                                &member.member_root,
1207                                &canonical_root,
1208                                &canonical_to_idx,
1209                                &mut mappings,
1210                                l1_exclusive,
1211                                &layer1_matched,
1212                            );
1213                        }
1214                    }
1215                }
1216            }
1217        } else if crate_name.is_none() {
1218            // Fallback: no [package] and no workspace members; apply L2 with "crate"
1219            // pseudo-name to handle `use crate::...` references
1220            self.apply_l2_imports(
1221                test_sources,
1222                "crate",
1223                scan_root,
1224                &canonical_root,
1225                &canonical_to_idx,
1226                &mut mappings,
1227                l1_exclusive,
1228                &layer1_matched,
1229            );
1230        }
1231
1232        // Layer 2.5: Cross-crate barrel resolution
1233        // For root tests with `use root_crate::Symbol`, resolve through root lib.rs
1234        // `pub use external_crate::*` to workspace member production files.
1235        if let Some(ref root_name) = crate_name {
1236            let root_lib = scan_root.join("src/lib.rs");
1237            if root_lib.exists() && !members.is_empty() {
1238                let root_test_sources: HashMap<String, String> = test_sources
1239                    .iter()
1240                    .filter(|(path, _)| {
1241                        find_member_for_path(Path::new(path.as_str()), &members).is_none()
1242                    })
1243                    .map(|(k, v)| (k.clone(), v.clone()))
1244                    .collect();
1245                if !root_test_sources.is_empty() {
1246                    self.apply_l2_cross_crate_barrel(
1247                        &root_test_sources,
1248                        root_name,
1249                        &root_lib,
1250                        &members,
1251                        &canonical_root,
1252                        &canonical_to_idx,
1253                        &mut mappings,
1254                        l1_exclusive,
1255                        &layer1_matched,
1256                    );
1257                }
1258            }
1259        }
1260
1261        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
1262        // set strategy to ImportTracing
1263        for (i, mapping) in mappings.iter_mut().enumerate() {
1264            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
1265            if !has_layer1 && !mapping.test_files.is_empty() {
1266                mapping.strategy = MappingStrategy::ImportTracing;
1267            }
1268        }
1269
1270        mappings
1271    }
1272
1273    /// Apply Layer 2 import tracing for a single crate root.
1274    ///
1275    /// `crate_name`: the crate name (underscored).
1276    /// `crate_root`: the crate root directory (contains `Cargo.toml` and `src/`).
1277    #[allow(clippy::too_many_arguments)]
1278    fn apply_l2_imports(
1279        &self,
1280        test_sources: &HashMap<String, String>,
1281        crate_name: &str,
1282        crate_root: &Path,
1283        canonical_root: &Path,
1284        canonical_to_idx: &HashMap<String, usize>,
1285        mappings: &mut [FileMapping],
1286        l1_exclusive: bool,
1287        layer1_matched: &HashSet<String>,
1288    ) {
1289        for (test_file, source) in test_sources {
1290            if l1_exclusive && layer1_matched.contains(test_file) {
1291                continue;
1292            }
1293            let imports = extract_import_specifiers_with_crate_name(source, Some(crate_name));
1294            let mut matched_indices = HashSet::<usize>::new();
1295
1296            for (specifier, symbols) in &imports {
1297                // Convert specifier to file path relative to member crate root (src/)
1298                let src_relative = crate_root.join("src").join(specifier);
1299
1300                if let Some(resolved) = exspec_core::observe::resolve_absolute_base_to_file(
1301                    self,
1302                    &src_relative,
1303                    canonical_root,
1304                ) {
1305                    let mut per_specifier_indices = HashSet::<usize>::new();
1306                    exspec_core::observe::collect_import_matches(
1307                        self,
1308                        &resolved,
1309                        symbols,
1310                        canonical_to_idx,
1311                        &mut per_specifier_indices,
1312                        canonical_root,
1313                    );
1314                    // Filter: if symbols are specified, only include files that export them
1315                    for idx in per_specifier_indices {
1316                        let prod_path = Path::new(&mappings[idx].production_file);
1317                        if self.file_exports_any_symbol(prod_path, symbols) {
1318                            matched_indices.insert(idx);
1319                        }
1320                    }
1321                }
1322            }
1323
1324            for idx in matched_indices {
1325                if !mappings[idx].test_files.contains(test_file) {
1326                    mappings[idx].test_files.push(test_file.clone());
1327                }
1328            }
1329        }
1330    }
1331
1332    /// Layer 2.5: Cross-crate barrel resolution.
1333    ///
1334    /// Resolves `use root_crate::Symbol` by looking up `pub use member_crate::*`
1335    /// (or named) re-exports in root `src/lib.rs`, then tracing through the
1336    /// matched workspace member's `src/lib.rs` barrel to the actual production files.
1337    ///
1338    /// This handles the common pattern where a workspace root re-exports all items
1339    /// from workspace member crates (e.g., `clap` re-exports `clap_builder::*`).
1340    #[allow(clippy::too_many_arguments)]
1341    fn apply_l2_cross_crate_barrel(
1342        &self,
1343        test_sources: &HashMap<String, String>,
1344        root_crate_name: &str,
1345        root_lib_path: &Path,
1346        members: &[WorkspaceMember],
1347        canonical_root: &Path,
1348        canonical_to_idx: &HashMap<String, usize>,
1349        mappings: &mut [FileMapping],
1350        l1_exclusive: bool,
1351        layer1_matched: &HashSet<String>,
1352    ) {
1353        // Read root lib.rs source
1354        let root_lib_source = match std::fs::read_to_string(root_lib_path) {
1355            Ok(s) => s,
1356            Err(_) => return,
1357        };
1358        let root_lib_str = root_lib_path.to_string_lossy();
1359
1360        // Extract barrel re-exports from root lib.rs
1361        let barrel_exports = self.extract_barrel_re_exports(&root_lib_source, &root_lib_str);
1362        if barrel_exports.is_empty() {
1363            return;
1364        }
1365
1366        for (test_file, source) in test_sources {
1367            if l1_exclusive && layer1_matched.contains(test_file) {
1368                continue;
1369            }
1370
1371            // Extract symbols imported at crate root level from test files.
1372            // Two patterns:
1373            //   1. `use clap::Arg`      → specifier="Arg", symbols=[]
1374            //   2. `use clap::{Arg, Command}` → specifier="", symbols=["Arg","Command"]
1375            let imports = extract_import_specifiers_with_crate_name(source, Some(root_crate_name));
1376            let root_symbols: Vec<String> = {
1377                let mut syms = Vec::new();
1378                for (specifier, symbols) in &imports {
1379                    if specifier.is_empty() && !symbols.is_empty() {
1380                        // Brace-list at crate root: `use clap::{Arg, Command, ...}`
1381                        syms.extend(symbols.clone());
1382                    } else if !specifier.is_empty()
1383                        && !specifier.contains('/')
1384                        && symbols.is_empty()
1385                    {
1386                        // Single symbol: `use clap::Arg`
1387                        syms.push(specifier.clone());
1388                    }
1389                }
1390                syms
1391            };
1392
1393            if root_symbols.is_empty() {
1394                continue;
1395            }
1396
1397            let mut matched_indices = HashSet::<usize>::new();
1398
1399            for barrel in &barrel_exports {
1400                // Normalize from_specifier: "./clap_builder" → "clap_builder"
1401                let crate_candidate = barrel
1402                    .from_specifier
1403                    .strip_prefix("./")
1404                    .unwrap_or(&barrel.from_specifier);
1405
1406                // Skip local module re-exports (contains '/' after stripping './')
1407                // e.g., "./builder/action" is a local path, not a crate name
1408                if crate_candidate.contains('/') {
1409                    continue;
1410                }
1411
1412                // Lookup workspace member by crate name
1413                let member = match find_member_by_crate_name(crate_candidate, members) {
1414                    Some(m) => m,
1415                    None => continue,
1416                };
1417
1418                // Check if any of the imported symbols match this barrel re-export
1419                let symbols_matched: Vec<String> = if barrel.wildcard {
1420                    // Wildcard: all root_symbols are potentially in this member
1421                    root_symbols.clone()
1422                } else {
1423                    // Named: only symbols explicitly listed in the re-export
1424                    root_symbols
1425                        .iter()
1426                        .filter(|sym| barrel.symbols.contains(sym))
1427                        .cloned()
1428                        .collect()
1429                };
1430
1431                if symbols_matched.is_empty() {
1432                    continue;
1433                }
1434
1435                // Resolve member lib.rs path and trace through its barrel
1436                let member_lib = member.member_root.join("src/lib.rs");
1437
1438                // For wildcard barrel re-exports, also scan all member production files
1439                // directly. This handles cases where `specific.rs` is NOT re-exported
1440                // from lib.rs but exists as a sibling file in the member crate.
1441                if barrel.wildcard {
1442                    let canonical_member_root = member
1443                        .member_root
1444                        .canonicalize()
1445                        .unwrap_or_else(|_| member.member_root.clone());
1446                    let canonical_member_str = canonical_member_root.to_string_lossy().into_owned();
1447                    for (prod_str, &idx) in canonical_to_idx.iter() {
1448                        if prod_str.starts_with(&canonical_member_str) {
1449                            let prod_path = Path::new(&mappings[idx].production_file);
1450                            if self.file_exports_any_symbol(prod_path, &symbols_matched) {
1451                                matched_indices.insert(idx);
1452                            }
1453                        }
1454                    }
1455                }
1456
1457                if let Some(resolved) = exspec_core::observe::resolve_absolute_base_to_file(
1458                    self,
1459                    &member_lib,
1460                    canonical_root,
1461                ) {
1462                    // First: check if member lib.rs itself (as a production file) exports
1463                    // any of the matched symbols. lib.rs can both be a barrel AND define
1464                    // symbols directly (e.g., `pub struct Symbol {}` in lib.rs).
1465                    if let Some(&idx) = canonical_to_idx.get(&resolved) {
1466                        let prod_path = Path::new(&mappings[idx].production_file);
1467                        if self.file_exports_any_symbol(prod_path, &symbols_matched) {
1468                            matched_indices.insert(idx);
1469                        }
1470                    }
1471
1472                    // Then: follow barrel re-exports from member lib.rs to other files
1473                    // (handles cases where lib.rs explicitly re-exports sub-modules)
1474                    let mut per_member_indices = HashSet::<usize>::new();
1475                    exspec_core::observe::collect_import_matches(
1476                        self,
1477                        &resolved,
1478                        &symbols_matched,
1479                        canonical_to_idx,
1480                        &mut per_member_indices,
1481                        canonical_root,
1482                    );
1483                    // Filter: only include production files that actually export matched symbols
1484                    for idx in per_member_indices {
1485                        let prod_path = Path::new(&mappings[idx].production_file);
1486                        if self.file_exports_any_symbol(prod_path, &symbols_matched) {
1487                            matched_indices.insert(idx);
1488                        }
1489                    }
1490                }
1491            }
1492
1493            for idx in matched_indices {
1494                if !mappings[idx].test_files.contains(test_file) {
1495                    mappings[idx].test_files.push(test_file.clone());
1496                }
1497            }
1498        }
1499    }
1500
1501    /// Layer 1.6: subdir stem matching.
1502    ///
1503    /// For each unmatched test file under `tests/<subdir>/`, extract the subdir
1504    /// (e.g., `tests/builder/action.rs` -> subdir="builder", stem="action") and
1505    /// match against production files where stem matches AND path contains `/{subdir}/`.
1506    ///
1507    /// Guard: skip if subdir is fewer than 3 characters (FP risk).
1508    ///
1509    fn apply_l1_subdir_matching(
1510        &self,
1511        mappings: &mut [FileMapping],
1512        test_paths: &[String],
1513        layer1_matched: &mut HashSet<String>,
1514    ) {
1515        for test_path in test_paths {
1516            if layer1_matched.contains(test_path) {
1517                continue;
1518            }
1519
1520            let test_stem = match self.test_stem(test_path) {
1521                Some(s) => s,
1522                None => continue,
1523            };
1524
1525            let normalized = test_path.replace('\\', "/");
1526            let test_subdir = extract_test_subdir(&normalized);
1527            if test_subdir.as_ref().is_none_or(|s| s.len() < 3) {
1528                continue;
1529            }
1530            let test_subdir = test_subdir.unwrap();
1531
1532            let subdir_lower = test_subdir.to_lowercase();
1533            let stem_lower = test_stem.to_lowercase();
1534            let dir_segment = format!("/{subdir_lower}/");
1535
1536            for mapping in mappings.iter_mut() {
1537                let prod_stem = match self.production_stem(&mapping.production_file) {
1538                    Some(s) => s,
1539                    None => continue,
1540                };
1541
1542                if prod_stem.to_lowercase() != stem_lower {
1543                    continue;
1544                }
1545
1546                let prod_path_lower = mapping.production_file.replace('\\', "/").to_lowercase();
1547                if prod_path_lower.contains(&dir_segment) {
1548                    if !mapping.test_files.contains(test_path) {
1549                        mapping.test_files.push(test_path.clone());
1550                    }
1551                    layer1_matched.insert(test_path.clone());
1552                    break;
1553                }
1554            }
1555        }
1556    }
1557
1558    /// Layer 1.5: underscore-to-path stem matching.
1559    ///
1560    /// For each unmatched test file whose stem contains `_`, split on the first `_`
1561    /// to obtain (prefix, suffix). If a production file has `prod_stem == suffix`
1562    /// and its path contains `/{prefix}/`, map the test file to that production file.
1563    ///
1564    /// Guard: skip if suffix is 2 characters or fewer (FP risk).
1565    fn apply_l1_5_underscore_path_matching(
1566        &self,
1567        mappings: &mut [FileMapping],
1568        test_paths: &[String],
1569        layer1_matched: &mut HashSet<String>,
1570    ) {
1571        for test_path in test_paths {
1572            if layer1_matched.contains(test_path) {
1573                continue;
1574            }
1575
1576            let test_stem = match self.test_stem(test_path) {
1577                Some(s) => s,
1578                None => continue,
1579            };
1580
1581            if !test_stem.contains('_') {
1582                continue;
1583            }
1584
1585            let underscore_pos = match test_stem.find('_') {
1586                Some(pos) => pos,
1587                None => continue,
1588            };
1589
1590            let prefix = &test_stem[..underscore_pos];
1591            let suffix = &test_stem[underscore_pos + 1..];
1592
1593            if suffix.len() <= 2 {
1594                continue;
1595            }
1596
1597            let prefix_lower = prefix.to_lowercase();
1598            let suffix_lower = suffix.to_lowercase();
1599            let dir_segment = format!("/{prefix_lower}/");
1600
1601            for mapping in mappings.iter_mut() {
1602                let prod_stem = match self.production_stem(&mapping.production_file) {
1603                    Some(s) => s,
1604                    None => continue,
1605                };
1606
1607                if prod_stem.to_lowercase() != suffix_lower {
1608                    continue;
1609                }
1610
1611                let prod_path_lower = mapping.production_file.replace('\\', "/").to_lowercase();
1612                // Crate boundary guard: in workspace with crate prefixes (e.g., tokio/tests/),
1613                // test and prod must share the same crate root
1614                let test_first = test_path.split('/').next().unwrap_or("");
1615                let prod_first = mapping.production_file.split('/').next().unwrap_or("");
1616                let test_has_crate_prefix = test_first != "tests" && test_first != "src";
1617                let prod_has_crate_prefix = prod_first != "tests" && prod_first != "src";
1618                if test_has_crate_prefix
1619                    && prod_has_crate_prefix
1620                    && !test_first.eq_ignore_ascii_case(prod_first)
1621                {
1622                    continue;
1623                }
1624                if prod_path_lower.contains(&dir_segment) {
1625                    mapping.test_files.push(test_path.clone());
1626                    layer1_matched.insert(test_path.clone());
1627                    break;
1628                }
1629            }
1630        }
1631    }
1632}
1633
1634// ---------------------------------------------------------------------------
1635// join_multiline_pub_use
1636// ---------------------------------------------------------------------------
1637
1638/// Join multi-line `pub use module::{\n  ...\n};` into single lines.
1639/// Uses brace depth tracking to handle nested use trees like `pub use a::{B::{C, D}, E};`.
1640pub(crate) fn join_multiline_pub_use(text: &str) -> String {
1641    let mut result = String::new();
1642    let mut accumulator: Option<String> = None;
1643    let mut brace_depth: usize = 0;
1644    for line in text.lines() {
1645        let trimmed = line.trim();
1646        if let Some(ref mut acc) = accumulator {
1647            acc.push(' ');
1648            acc.push_str(trimmed);
1649            for ch in trimmed.chars() {
1650                match ch {
1651                    '{' => brace_depth += 1,
1652                    '}' => brace_depth = brace_depth.saturating_sub(1),
1653                    _ => {}
1654                }
1655            }
1656            if brace_depth == 0 {
1657                result.push_str(acc);
1658                result.push('\n');
1659                accumulator = None;
1660            }
1661        } else if trimmed.starts_with("pub use ") && trimmed.contains('{') && !trimmed.contains('}')
1662        {
1663            brace_depth = trimmed.chars().filter(|&c| c == '{').count()
1664                - trimmed.chars().filter(|&c| c == '}').count();
1665            accumulator = Some(trimmed.to_string());
1666        } else {
1667            result.push_str(line);
1668            result.push('\n');
1669        }
1670    }
1671    if let Some(acc) = accumulator {
1672        result.push_str(&acc);
1673        result.push('\n');
1674    }
1675    result
1676}
1677
1678// ---------------------------------------------------------------------------
1679// Tests
1680// ---------------------------------------------------------------------------
1681
1682#[cfg(test)]
1683mod tests {
1684    use super::*;
1685    use std::path::PathBuf;
1686
1687    // -----------------------------------------------------------------------
1688    // RS-STEM-01: tests/test_foo.rs -> test_stem = Some("foo")
1689    // -----------------------------------------------------------------------
1690    #[test]
1691    fn rs_stem_01_test_prefix() {
1692        // Given: a file named tests/test_foo.rs
1693        // When: test_stem is called
1694        // Then: returns Some("foo")
1695        let extractor = RustExtractor::new();
1696        assert_eq!(extractor.test_stem("tests/test_foo.rs"), Some("foo"));
1697    }
1698
1699    // -----------------------------------------------------------------------
1700    // RS-STEM-02: tests/foo_test.rs -> test_stem = Some("foo")
1701    // -----------------------------------------------------------------------
1702    #[test]
1703    fn rs_stem_02_test_suffix() {
1704        // Given: a file named tests/foo_test.rs
1705        // When: test_stem is called
1706        // Then: returns Some("foo")
1707        let extractor = RustExtractor::new();
1708        assert_eq!(extractor.test_stem("tests/foo_test.rs"), Some("foo"));
1709    }
1710
1711    // -----------------------------------------------------------------------
1712    // RS-STEM-03: tests/integration.rs -> test_stem = Some("integration")
1713    // -----------------------------------------------------------------------
1714    #[test]
1715    fn rs_stem_03_tests_dir_integration() {
1716        // Given: a file in tests/ directory without test_ prefix or _test suffix
1717        // When: test_stem is called
1718        // Then: returns Some("integration") because tests/ directory files are integration tests
1719        let extractor = RustExtractor::new();
1720        assert_eq!(
1721            extractor.test_stem("tests/integration.rs"),
1722            Some("integration")
1723        );
1724    }
1725
1726    // -----------------------------------------------------------------------
1727    // RS-STEM-04: src/user.rs -> test_stem = None
1728    // -----------------------------------------------------------------------
1729    #[test]
1730    fn rs_stem_04_production_file_no_test_stem() {
1731        // Given: a production file in src/
1732        // When: test_stem is called
1733        // Then: returns None
1734        let extractor = RustExtractor::new();
1735        assert_eq!(extractor.test_stem("src/user.rs"), None);
1736    }
1737
1738    // -----------------------------------------------------------------------
1739    // RS-STEM-05: src/user.rs -> production_stem = Some("user")
1740    // -----------------------------------------------------------------------
1741    #[test]
1742    fn rs_stem_05_production_stem_regular() {
1743        // Given: a regular production file
1744        // When: production_stem is called
1745        // Then: returns Some("user")
1746        let extractor = RustExtractor::new();
1747        assert_eq!(extractor.production_stem("src/user.rs"), Some("user"));
1748    }
1749
1750    // -----------------------------------------------------------------------
1751    // RS-STEM-06: src/lib.rs -> production_stem = None
1752    // -----------------------------------------------------------------------
1753    #[test]
1754    fn rs_stem_06_production_stem_lib() {
1755        // Given: lib.rs (barrel file)
1756        // When: production_stem is called
1757        // Then: returns None
1758        let extractor = RustExtractor::new();
1759        assert_eq!(extractor.production_stem("src/lib.rs"), None);
1760    }
1761
1762    // -----------------------------------------------------------------------
1763    // RS-STEM-07: src/mod.rs -> production_stem = None
1764    // -----------------------------------------------------------------------
1765    #[test]
1766    fn rs_stem_07_production_stem_mod() {
1767        // Given: mod.rs (barrel file)
1768        // When: production_stem is called
1769        // Then: returns None
1770        let extractor = RustExtractor::new();
1771        assert_eq!(extractor.production_stem("src/mod.rs"), None);
1772    }
1773
1774    // -----------------------------------------------------------------------
1775    // RS-STEM-08: src/main.rs -> production_stem = None
1776    // -----------------------------------------------------------------------
1777    #[test]
1778    fn rs_stem_08_production_stem_main() {
1779        // Given: main.rs (entry point)
1780        // When: production_stem is called
1781        // Then: returns None
1782        let extractor = RustExtractor::new();
1783        assert_eq!(extractor.production_stem("src/main.rs"), None);
1784    }
1785
1786    // -----------------------------------------------------------------------
1787    // RS-STEM-09: tests/test_foo.rs -> production_stem = None
1788    // -----------------------------------------------------------------------
1789    #[test]
1790    fn rs_stem_09_production_stem_test_file() {
1791        // Given: a test file
1792        // When: production_stem is called
1793        // Then: returns None
1794        let extractor = RustExtractor::new();
1795        assert_eq!(extractor.production_stem("tests/test_foo.rs"), None);
1796    }
1797
1798    // -----------------------------------------------------------------------
1799    // RS-HELPER-01: build.rs -> is_non_sut_helper = true
1800    // -----------------------------------------------------------------------
1801    #[test]
1802    fn rs_helper_01_build_rs() {
1803        // Given: build.rs
1804        // When: is_non_sut_helper is called
1805        // Then: returns true
1806        let extractor = RustExtractor::new();
1807        assert!(extractor.is_non_sut_helper("build.rs", false));
1808    }
1809
1810    // -----------------------------------------------------------------------
1811    // RS-HELPER-02: tests/common/mod.rs -> is_non_sut_helper = true
1812    // -----------------------------------------------------------------------
1813    #[test]
1814    fn rs_helper_02_tests_common() {
1815        // Given: tests/common/mod.rs (test helper module)
1816        // When: is_non_sut_helper is called
1817        // Then: returns true
1818        let extractor = RustExtractor::new();
1819        assert!(extractor.is_non_sut_helper("tests/common/mod.rs", false));
1820    }
1821
1822    // -----------------------------------------------------------------------
1823    // RS-HELPER-03: src/user.rs -> is_non_sut_helper = false
1824    // -----------------------------------------------------------------------
1825    #[test]
1826    fn rs_helper_03_regular_production_file() {
1827        // Given: a regular production file
1828        // When: is_non_sut_helper is called
1829        // Then: returns false
1830        let extractor = RustExtractor::new();
1831        assert!(!extractor.is_non_sut_helper("src/user.rs", false));
1832    }
1833
1834    // -----------------------------------------------------------------------
1835    // RS-HELPER-04: benches/bench.rs -> is_non_sut_helper = true
1836    // -----------------------------------------------------------------------
1837    #[test]
1838    fn rs_helper_04_benches() {
1839        // Given: a benchmark file
1840        // When: is_non_sut_helper is called
1841        // Then: returns true
1842        let extractor = RustExtractor::new();
1843        assert!(extractor.is_non_sut_helper("benches/bench.rs", false));
1844    }
1845
1846    // -----------------------------------------------------------------------
1847    // RS-L0-01: #[cfg(test)] mod tests {} -> detect_inline_tests = true
1848    // -----------------------------------------------------------------------
1849    #[test]
1850    fn rs_l0_01_cfg_test_present() {
1851        // Given: source with #[cfg(test)] mod tests block
1852        let source = r#"
1853pub fn add(a: i32, b: i32) -> i32 { a + b }
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858
1859    #[test]
1860    fn test_add() {
1861        assert_eq!(add(1, 2), 3);
1862    }
1863}
1864"#;
1865        // When: detect_inline_tests is called
1866        // Then: returns true
1867        assert!(detect_inline_tests(source));
1868    }
1869
1870    // -----------------------------------------------------------------------
1871    // RS-L0-02: no #[cfg(test)] -> detect_inline_tests = false
1872    // -----------------------------------------------------------------------
1873    #[test]
1874    fn rs_l0_02_no_cfg_test() {
1875        // Given: source without #[cfg(test)]
1876        let source = r#"
1877pub fn add(a: i32, b: i32) -> i32 { a + b }
1878"#;
1879        // When: detect_inline_tests is called
1880        // Then: returns false
1881        assert!(!detect_inline_tests(source));
1882    }
1883
1884    // -----------------------------------------------------------------------
1885    // RS-L0-03: #[cfg(not(test))] only -> detect_inline_tests = false
1886    // -----------------------------------------------------------------------
1887    #[test]
1888    fn rs_l0_03_cfg_not_test() {
1889        // Given: source with #[cfg(not(test))] only (no #[cfg(test)])
1890        let source = r#"
1891#[cfg(not(test))]
1892mod production_only {
1893    pub fn real_thing() {}
1894}
1895"#;
1896        // When: detect_inline_tests is called
1897        // Then: returns false
1898        assert!(!detect_inline_tests(source));
1899    }
1900
1901    // -----------------------------------------------------------------------
1902    // RS-L0-04: #[cfg(all(test, not(loom)))] mod test {} -> true (compound cfg)
1903    // -----------------------------------------------------------------------
1904    #[test]
1905    fn rs_l0_04_cfg_all_test() {
1906        let source = r#"
1907pub(crate) fn do_work() {}
1908
1909#[cfg(all(test, not(loom)))]
1910pub(crate) mod test {
1911    use super::*;
1912
1913    #[test]
1914    fn test_do_work() {
1915        do_work();
1916    }
1917}
1918"#;
1919        assert!(detect_inline_tests(source));
1920    }
1921
1922    // -----------------------------------------------------------------------
1923    // RS-L0-05: #[cfg(any(test, fuzzing))] mod tests {} -> true (compound cfg)
1924    // -----------------------------------------------------------------------
1925    #[test]
1926    fn rs_l0_05_cfg_any_test() {
1927        let source = r#"
1928pub struct LinkedList;
1929
1930#[cfg(any(test, fuzzing))]
1931#[cfg(not(loom))]
1932pub(crate) mod tests {
1933    use super::*;
1934
1935    #[test]
1936    fn const_new() {
1937        let _ = LinkedList;
1938    }
1939}
1940"#;
1941        assert!(detect_inline_tests(source));
1942    }
1943
1944    // -----------------------------------------------------------------------
1945    // RS-FUNC-01: pub fn create_user() {} -> name="create_user", is_exported=true
1946    // -----------------------------------------------------------------------
1947    #[test]
1948    fn rs_func_01_pub_function() {
1949        // Given: source with a pub function
1950        let source = "pub fn create_user() {}\n";
1951
1952        // When: extract_production_functions is called
1953        let extractor = RustExtractor::new();
1954        let result = extractor.extract_production_functions(source, "src/user.rs");
1955
1956        // Then: name="create_user", is_exported=true
1957        let func = result.iter().find(|f| f.name == "create_user");
1958        assert!(func.is_some(), "create_user not found in {:?}", result);
1959        assert!(func.unwrap().is_exported);
1960    }
1961
1962    // -----------------------------------------------------------------------
1963    // RS-FUNC-02: fn private_fn() {} -> name="private_fn", is_exported=false
1964    // -----------------------------------------------------------------------
1965    #[test]
1966    fn rs_func_02_private_function() {
1967        // Given: source with a private function
1968        let source = "fn private_fn() {}\n";
1969
1970        // When: extract_production_functions is called
1971        let extractor = RustExtractor::new();
1972        let result = extractor.extract_production_functions(source, "src/internal.rs");
1973
1974        // Then: name="private_fn", is_exported=false
1975        let func = result.iter().find(|f| f.name == "private_fn");
1976        assert!(func.is_some(), "private_fn not found in {:?}", result);
1977        assert!(!func.unwrap().is_exported);
1978    }
1979
1980    // -----------------------------------------------------------------------
1981    // RS-FUNC-03: impl User { pub fn save() {} } -> name="save", class_name=Some("User")
1982    // -----------------------------------------------------------------------
1983    #[test]
1984    fn rs_func_03_impl_method() {
1985        // Given: source with an impl block
1986        let source = r#"
1987struct User;
1988
1989impl User {
1990    pub fn save(&self) {}
1991}
1992"#;
1993        // When: extract_production_functions is called
1994        let extractor = RustExtractor::new();
1995        let result = extractor.extract_production_functions(source, "src/user.rs");
1996
1997        // Then: name="save", class_name=Some("User")
1998        let method = result.iter().find(|f| f.name == "save");
1999        assert!(method.is_some(), "save not found in {:?}", result);
2000        let method = method.unwrap();
2001        assert_eq!(method.class_name, Some("User".to_string()));
2002        assert!(method.is_exported);
2003    }
2004
2005    // -----------------------------------------------------------------------
2006    // RS-FUNC-04: functions inside #[cfg(test)] mod tests are NOT extracted
2007    // -----------------------------------------------------------------------
2008    #[test]
2009    fn rs_func_04_cfg_test_excluded() {
2010        // Given: source with functions inside #[cfg(test)] mod
2011        let source = r#"
2012pub fn real_function() {}
2013
2014#[cfg(test)]
2015mod tests {
2016    use super::*;
2017
2018    #[test]
2019    fn test_real_function() {
2020        assert!(true);
2021    }
2022}
2023"#;
2024        // When: extract_production_functions is called
2025        let extractor = RustExtractor::new();
2026        let result = extractor.extract_production_functions(source, "src/lib.rs");
2027
2028        // Then: only real_function is extracted, not test_real_function
2029        assert_eq!(result.len(), 1);
2030        assert_eq!(result[0].name, "real_function");
2031    }
2032
2033    // -----------------------------------------------------------------------
2034    // RS-IMP-01: use crate::user::User -> ("user", ["User"])
2035    // -----------------------------------------------------------------------
2036    #[test]
2037    fn rs_imp_01_simple_crate_import() {
2038        // Given: source with a simple crate import
2039        let source = "use crate::user::User;\n";
2040
2041        // When: extract_all_import_specifiers is called
2042        let extractor = RustExtractor::new();
2043        let result = extractor.extract_all_import_specifiers(source);
2044
2045        // Then: ("user", ["User"])
2046        let entry = result.iter().find(|(spec, _)| spec == "user");
2047        assert!(entry.is_some(), "user not found in {:?}", result);
2048        let (_, symbols) = entry.unwrap();
2049        assert!(symbols.contains(&"User".to_string()));
2050    }
2051
2052    // -----------------------------------------------------------------------
2053    // RS-IMP-02: use crate::models::user::User -> ("models/user", ["User"])
2054    // -----------------------------------------------------------------------
2055    #[test]
2056    fn rs_imp_02_nested_crate_import() {
2057        // Given: source with a nested crate import
2058        let source = "use crate::models::user::User;\n";
2059
2060        // When: extract_all_import_specifiers is called
2061        let extractor = RustExtractor::new();
2062        let result = extractor.extract_all_import_specifiers(source);
2063
2064        // Then: ("models/user", ["User"])
2065        let entry = result.iter().find(|(spec, _)| spec == "models/user");
2066        assert!(entry.is_some(), "models/user not found in {:?}", result);
2067        let (_, symbols) = entry.unwrap();
2068        assert!(symbols.contains(&"User".to_string()));
2069    }
2070
2071    // -----------------------------------------------------------------------
2072    // RS-IMP-03: use crate::user::{User, Admin} -> ("user", ["User", "Admin"])
2073    // -----------------------------------------------------------------------
2074    #[test]
2075    fn rs_imp_03_use_list() {
2076        // Given: source with a use list
2077        let source = "use crate::user::{User, Admin};\n";
2078
2079        // When: extract_all_import_specifiers is called
2080        let extractor = RustExtractor::new();
2081        let result = extractor.extract_all_import_specifiers(source);
2082
2083        // Then: ("user", ["User", "Admin"])
2084        let entry = result.iter().find(|(spec, _)| spec == "user");
2085        assert!(entry.is_some(), "user not found in {:?}", result);
2086        let (_, symbols) = entry.unwrap();
2087        assert!(
2088            symbols.contains(&"User".to_string()),
2089            "User not in {:?}",
2090            symbols
2091        );
2092        assert!(
2093            symbols.contains(&"Admin".to_string()),
2094            "Admin not in {:?}",
2095            symbols
2096        );
2097    }
2098
2099    // -----------------------------------------------------------------------
2100    // RS-IMP-04: use std::collections::HashMap -> external crate -> skipped
2101    // -----------------------------------------------------------------------
2102    #[test]
2103    fn rs_imp_04_external_crate_skipped() {
2104        // Given: source with an external crate import
2105        let source = "use std::collections::HashMap;\n";
2106
2107        // When: extract_all_import_specifiers is called
2108        let extractor = RustExtractor::new();
2109        let result = extractor.extract_all_import_specifiers(source);
2110
2111        // Then: not included (only crate:: imports are tracked)
2112        assert!(
2113            result.is_empty(),
2114            "external imports should be skipped: {:?}",
2115            result
2116        );
2117    }
2118
2119    // -----------------------------------------------------------------------
2120    // RS-BARREL-01: mod.rs -> is_barrel_file = true
2121    // -----------------------------------------------------------------------
2122    #[test]
2123    fn rs_barrel_01_mod_rs() {
2124        // Given: mod.rs
2125        // When: is_barrel_file is called
2126        // Then: returns true
2127        let extractor = RustExtractor::new();
2128        assert!(extractor.is_barrel_file("src/models/mod.rs"));
2129    }
2130
2131    // -----------------------------------------------------------------------
2132    // RS-BARREL-02: lib.rs -> is_barrel_file = true
2133    // -----------------------------------------------------------------------
2134    #[test]
2135    fn rs_barrel_02_lib_rs() {
2136        // Given: lib.rs
2137        // When: is_barrel_file is called
2138        // Then: returns true
2139        let extractor = RustExtractor::new();
2140        assert!(extractor.is_barrel_file("src/lib.rs"));
2141    }
2142
2143    // -----------------------------------------------------------------------
2144    // RS-BARREL-03: pub mod user; in mod.rs -> extract_barrel_re_exports
2145    // -----------------------------------------------------------------------
2146    #[test]
2147    fn rs_barrel_03_pub_mod() {
2148        // Given: mod.rs with pub mod user;
2149        let source = "pub mod user;\n";
2150
2151        // When: extract_barrel_re_exports is called
2152        let extractor = RustExtractor::new();
2153        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
2154
2155        // Then: from_specifier="./user", wildcard=true
2156        let entry = result.iter().find(|e| e.from_specifier == "./user");
2157        assert!(entry.is_some(), "./user not found in {:?}", result);
2158        assert!(entry.unwrap().wildcard);
2159    }
2160
2161    // -----------------------------------------------------------------------
2162    // RS-BARREL-04: pub use user::*; in mod.rs -> extract_barrel_re_exports
2163    // -----------------------------------------------------------------------
2164    #[test]
2165    fn rs_barrel_04_pub_use_wildcard() {
2166        // Given: mod.rs with pub use user::*;
2167        let source = "pub use user::*;\n";
2168
2169        // When: extract_barrel_re_exports is called
2170        let extractor = RustExtractor::new();
2171        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
2172
2173        // Then: from_specifier="./user", wildcard=true
2174        let entry = result.iter().find(|e| e.from_specifier == "./user");
2175        assert!(entry.is_some(), "./user not found in {:?}", result);
2176        assert!(entry.unwrap().wildcard);
2177    }
2178
2179    // -----------------------------------------------------------------------
2180    // RS-E2E-01: inline tests -> Layer 0 self-map
2181    // -----------------------------------------------------------------------
2182    #[test]
2183    fn rs_e2e_01_inline_test_self_map() {
2184        // Given: a temp directory with a production file containing inline tests
2185        let tmp = tempfile::tempdir().unwrap();
2186        let src_dir = tmp.path().join("src");
2187        std::fs::create_dir_all(&src_dir).unwrap();
2188
2189        let user_rs = src_dir.join("user.rs");
2190        std::fs::write(
2191            &user_rs,
2192            r#"pub fn create_user() {}
2193
2194#[cfg(test)]
2195mod tests {
2196    use super::*;
2197    #[test]
2198    fn test_create_user() { assert!(true); }
2199}
2200"#,
2201        )
2202        .unwrap();
2203
2204        let extractor = RustExtractor::new();
2205        let prod_path = user_rs.to_string_lossy().into_owned();
2206        let production_files = vec![prod_path.clone()];
2207        let test_sources: HashMap<String, String> = HashMap::new();
2208
2209        // When: map_test_files_with_imports is called
2210        let result = extractor.map_test_files_with_imports(
2211            &production_files,
2212            &test_sources,
2213            tmp.path(),
2214            false,
2215        );
2216
2217        // Then: user.rs is self-mapped (Layer 0)
2218        let mapping = result.iter().find(|m| m.production_file == prod_path);
2219        assert!(mapping.is_some());
2220        assert!(
2221            mapping.unwrap().test_files.contains(&prod_path),
2222            "Expected self-map for inline tests: {:?}",
2223            mapping.unwrap().test_files
2224        );
2225    }
2226
2227    // -----------------------------------------------------------------------
2228    // RS-E2E-02: stem match -> Layer 1
2229    // -----------------------------------------------------------------------
2230    #[test]
2231    fn rs_e2e_02_layer1_stem_match() {
2232        // Given: production file and test file with matching stems
2233        let extractor = RustExtractor::new();
2234        let production_files = vec!["src/user.rs".to_string()];
2235        let test_sources: HashMap<String, String> =
2236            [("tests/test_user.rs".to_string(), String::new())]
2237                .into_iter()
2238                .collect();
2239
2240        // When: map_test_files_with_imports is called
2241        let scan_root = PathBuf::from(".");
2242        let result = extractor.map_test_files_with_imports(
2243            &production_files,
2244            &test_sources,
2245            &scan_root,
2246            false,
2247        );
2248
2249        // Then: Layer 1 stem match (same directory not required for test_stem)
2250        // Note: map_test_files requires same directory, but tests/ files have test_stem
2251        // that matches production_stem. However, core::map_test_files uses directory matching.
2252        // For cross-directory matching, we rely on Layer 2 (import tracing).
2253        // This test verifies the mapping structure is correct.
2254        let mapping = result.iter().find(|m| m.production_file == "src/user.rs");
2255        assert!(mapping.is_some());
2256    }
2257
2258    // -----------------------------------------------------------------------
2259    // RS-E2E-03: import match -> Layer 2
2260    // -----------------------------------------------------------------------
2261    #[test]
2262    fn rs_e2e_03_layer2_import_tracing() {
2263        // Given: a temp directory with production and test files
2264        let tmp = tempfile::tempdir().unwrap();
2265        let src_dir = tmp.path().join("src");
2266        let tests_dir = tmp.path().join("tests");
2267        std::fs::create_dir_all(&src_dir).unwrap();
2268        std::fs::create_dir_all(&tests_dir).unwrap();
2269
2270        let service_rs = src_dir.join("service.rs");
2271        std::fs::write(&service_rs, "pub struct Service;\n").unwrap();
2272
2273        let test_service_rs = tests_dir.join("test_service.rs");
2274        let test_source = "use crate::service::Service;\n\n#[test]\nfn test_it() {}\n";
2275        std::fs::write(&test_service_rs, test_source).unwrap();
2276
2277        let extractor = RustExtractor::new();
2278        let prod_path = service_rs.to_string_lossy().into_owned();
2279        let test_path = test_service_rs.to_string_lossy().into_owned();
2280        let production_files = vec![prod_path.clone()];
2281        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2282            .into_iter()
2283            .collect();
2284
2285        // When: map_test_files_with_imports is called
2286        let result = extractor.map_test_files_with_imports(
2287            &production_files,
2288            &test_sources,
2289            tmp.path(),
2290            false,
2291        );
2292
2293        // Then: service.rs is matched to test_service.rs via import tracing
2294        let mapping = result.iter().find(|m| m.production_file == prod_path);
2295        assert!(mapping.is_some());
2296        assert!(
2297            mapping.unwrap().test_files.contains(&test_path),
2298            "Expected import tracing match: {:?}",
2299            mapping.unwrap().test_files
2300        );
2301    }
2302
2303    // -----------------------------------------------------------------------
2304    // RS-E2E-04: tests/common/mod.rs -> helper excluded
2305    // -----------------------------------------------------------------------
2306    #[test]
2307    fn rs_e2e_04_helper_excluded() {
2308        // Given: tests/common/mod.rs alongside test files
2309        let extractor = RustExtractor::new();
2310        let production_files = vec!["src/user.rs".to_string()];
2311        let test_sources: HashMap<String, String> = [
2312            ("tests/test_user.rs".to_string(), String::new()),
2313            (
2314                "tests/common/mod.rs".to_string(),
2315                "pub fn setup() {}\n".to_string(),
2316            ),
2317        ]
2318        .into_iter()
2319        .collect();
2320
2321        // When: map_test_files_with_imports is called
2322        let scan_root = PathBuf::from(".");
2323        let result = extractor.map_test_files_with_imports(
2324            &production_files,
2325            &test_sources,
2326            &scan_root,
2327            false,
2328        );
2329
2330        // Then: tests/common/mod.rs is NOT in any mapping
2331        for mapping in &result {
2332            assert!(
2333                !mapping
2334                    .test_files
2335                    .iter()
2336                    .any(|f| f.contains("common/mod.rs")),
2337                "common/mod.rs should not appear: {:?}",
2338                mapping
2339            );
2340        }
2341    }
2342
2343    // -----------------------------------------------------------------------
2344    // RS-CRATE-01: parse_crate_name: 正常パース
2345    // -----------------------------------------------------------------------
2346    #[test]
2347    fn rs_crate_01_parse_crate_name_hyphen() {
2348        // Given: Cargo.toml に [package]\nname = "my-crate" を含む tempdir
2349        let tmp = tempfile::tempdir().unwrap();
2350        std::fs::write(
2351            tmp.path().join("Cargo.toml"),
2352            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2353        )
2354        .unwrap();
2355
2356        // When: parse_crate_name(dir) を呼ぶ
2357        let result = parse_crate_name(tmp.path());
2358
2359        // Then: Some("my_crate") を返す(ハイフン→アンダースコア変換)
2360        assert_eq!(result, Some("my_crate".to_string()));
2361    }
2362
2363    // -----------------------------------------------------------------------
2364    // RS-CRATE-02: parse_crate_name: ハイフンなし
2365    // -----------------------------------------------------------------------
2366    #[test]
2367    fn rs_crate_02_parse_crate_name_no_hyphen() {
2368        // Given: Cargo.toml に name = "tokio" を含む tempdir
2369        let tmp = tempfile::tempdir().unwrap();
2370        std::fs::write(
2371            tmp.path().join("Cargo.toml"),
2372            "[package]\nname = \"tokio\"\nversion = \"1.0.0\"\n",
2373        )
2374        .unwrap();
2375
2376        // When: parse_crate_name(dir)
2377        let result = parse_crate_name(tmp.path());
2378
2379        // Then: Some("tokio")
2380        assert_eq!(result, Some("tokio".to_string()));
2381    }
2382
2383    // -----------------------------------------------------------------------
2384    // RS-CRATE-03: parse_crate_name: ファイルなし
2385    // -----------------------------------------------------------------------
2386    #[test]
2387    fn rs_crate_03_parse_crate_name_no_file() {
2388        // Given: Cargo.toml が存在しない tempdir
2389        let tmp = tempfile::tempdir().unwrap();
2390
2391        // When: parse_crate_name(dir)
2392        let result = parse_crate_name(tmp.path());
2393
2394        // Then: None
2395        assert_eq!(result, None);
2396    }
2397
2398    // -----------------------------------------------------------------------
2399    // RS-CRATE-04: parse_crate_name: workspace (package なし)
2400    // -----------------------------------------------------------------------
2401    #[test]
2402    fn rs_crate_04_parse_crate_name_workspace() {
2403        // Given: [workspace]\nmembers = ["crate1"] のみの Cargo.toml
2404        let tmp = tempfile::tempdir().unwrap();
2405        std::fs::write(
2406            tmp.path().join("Cargo.toml"),
2407            "[workspace]\nmembers = [\"crate1\"]\n",
2408        )
2409        .unwrap();
2410
2411        // When: parse_crate_name(dir)
2412        let result = parse_crate_name(tmp.path());
2413
2414        // Then: None
2415        assert_eq!(result, None);
2416    }
2417
2418    // -----------------------------------------------------------------------
2419    // RS-IMP-05: crate_name simple import
2420    // -----------------------------------------------------------------------
2421    #[test]
2422    fn rs_imp_05_crate_name_simple_import() {
2423        // Given: source = "use my_crate::user::User;\n", crate_name = Some("my_crate")
2424        let source = "use my_crate::user::User;\n";
2425
2426        // When: extract_import_specifiers_with_crate_name(source, Some("my_crate"))
2427        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
2428
2429        // Then: [("user", ["User"])]
2430        let entry = result.iter().find(|(spec, _)| spec == "user");
2431        assert!(entry.is_some(), "user not found in {:?}", result);
2432        let (_, symbols) = entry.unwrap();
2433        assert!(
2434            symbols.contains(&"User".to_string()),
2435            "User not in {:?}",
2436            symbols
2437        );
2438    }
2439
2440    // -----------------------------------------------------------------------
2441    // RS-IMP-06: crate_name use list
2442    // -----------------------------------------------------------------------
2443    #[test]
2444    fn rs_imp_06_crate_name_use_list() {
2445        // Given: source = "use my_crate::user::{User, Admin};\n", crate_name = Some("my_crate")
2446        let source = "use my_crate::user::{User, Admin};\n";
2447
2448        // When: extract_import_specifiers_with_crate_name(source, Some("my_crate"))
2449        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
2450
2451        // Then: [("user", ["User", "Admin"])]
2452        let entry = result.iter().find(|(spec, _)| spec == "user");
2453        assert!(entry.is_some(), "user not found in {:?}", result);
2454        let (_, symbols) = entry.unwrap();
2455        assert!(
2456            symbols.contains(&"User".to_string()),
2457            "User not in {:?}",
2458            symbols
2459        );
2460        assert!(
2461            symbols.contains(&"Admin".to_string()),
2462            "Admin not in {:?}",
2463            symbols
2464        );
2465    }
2466
2467    // -----------------------------------------------------------------------
2468    // RS-IMP-07: crate_name=None ではスキップ
2469    // -----------------------------------------------------------------------
2470    #[test]
2471    fn rs_imp_07_crate_name_none_skips() {
2472        // Given: source = "use my_crate::user::User;\n", crate_name = None
2473        let source = "use my_crate::user::User;\n";
2474
2475        // When: extract_import_specifiers_with_crate_name(source, None)
2476        let result = extract_import_specifiers_with_crate_name(source, None);
2477
2478        // Then: [] (空)
2479        assert!(
2480            result.is_empty(),
2481            "Expected empty result when crate_name=None, got: {:?}",
2482            result
2483        );
2484    }
2485
2486    // -----------------------------------------------------------------------
2487    // RS-IMP-08: crate:: と crate_name:: 混在
2488    // -----------------------------------------------------------------------
2489    #[test]
2490    fn rs_imp_08_mixed_crate_and_crate_name() {
2491        // Given: source に `use crate::service::Service;` と `use my_crate::user::User;` の両方
2492        // crate_name = Some("my_crate")
2493        let source = "use crate::service::Service;\nuse my_crate::user::User;\n";
2494
2495        // When: extract_import_specifiers_with_crate_name(source, Some("my_crate"))
2496        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
2497
2498        // Then: [("service", ["Service"]), ("user", ["User"])] の両方が検出される
2499        let service_entry = result.iter().find(|(spec, _)| spec == "service");
2500        assert!(service_entry.is_some(), "service not found in {:?}", result);
2501        let (_, service_symbols) = service_entry.unwrap();
2502        assert!(
2503            service_symbols.contains(&"Service".to_string()),
2504            "Service not in {:?}",
2505            service_symbols
2506        );
2507
2508        let user_entry = result.iter().find(|(spec, _)| spec == "user");
2509        assert!(user_entry.is_some(), "user not found in {:?}", result);
2510        let (_, user_symbols) = user_entry.unwrap();
2511        assert!(
2512            user_symbols.contains(&"User".to_string()),
2513            "User not in {:?}",
2514            user_symbols
2515        );
2516    }
2517
2518    // -----------------------------------------------------------------------
2519    // RS-L2-INTEG: 統合テスト (tempdir)
2520    // -----------------------------------------------------------------------
2521    #[test]
2522    fn rs_l2_integ_crate_name_import_layer2() {
2523        // Given: tempdir に以下を作成
2524        //   - Cargo.toml: [package]\nname = "my-crate"\nversion = "0.1.0"\nedition = "2021"
2525        //   - src/user.rs: pub struct User;
2526        //   - tests/test_user.rs: use my_crate::user::User; (ソース)
2527        let tmp = tempfile::tempdir().unwrap();
2528        let src_dir = tmp.path().join("src");
2529        let tests_dir = tmp.path().join("tests");
2530        std::fs::create_dir_all(&src_dir).unwrap();
2531        std::fs::create_dir_all(&tests_dir).unwrap();
2532
2533        std::fs::write(
2534            tmp.path().join("Cargo.toml"),
2535            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
2536        )
2537        .unwrap();
2538
2539        let user_rs = src_dir.join("user.rs");
2540        std::fs::write(&user_rs, "pub struct User;\n").unwrap();
2541
2542        let test_user_rs = tests_dir.join("test_user.rs");
2543        let test_source = "use my_crate::user::User;\n\n#[test]\nfn test_user() {}\n";
2544        std::fs::write(&test_user_rs, test_source).unwrap();
2545
2546        let extractor = RustExtractor::new();
2547        let prod_path = user_rs.to_string_lossy().into_owned();
2548        let test_path = test_user_rs.to_string_lossy().into_owned();
2549        let production_files = vec![prod_path.clone()];
2550        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2551            .into_iter()
2552            .collect();
2553
2554        // When: map_test_files_with_imports を呼ぶ
2555        let result = extractor.map_test_files_with_imports(
2556            &production_files,
2557            &test_sources,
2558            tmp.path(),
2559            false,
2560        );
2561
2562        // Then: test_user.rs → user.rs が Layer 2 (ImportTracing) でマッチ
2563        let mapping = result.iter().find(|m| m.production_file == prod_path);
2564        assert!(mapping.is_some(), "production file mapping not found");
2565        let mapping = mapping.unwrap();
2566        assert!(
2567            mapping.test_files.contains(&test_path),
2568            "Expected test_user.rs to map to user.rs via Layer 2, got: {:?}",
2569            mapping.test_files
2570        );
2571        assert_eq!(
2572            mapping.strategy,
2573            MappingStrategy::ImportTracing,
2574            "Expected ImportTracing strategy, got: {:?}",
2575            mapping.strategy
2576        );
2577    }
2578
2579    // -----------------------------------------------------------------------
2580    // RS-DEEP-REEXPORT-01: 2段 re-export — src/models/mod.rs: pub mod user;
2581    // -----------------------------------------------------------------------
2582    #[test]
2583    fn rs_deep_reexport_01_two_hop() {
2584        // Given: tempdir に以下を作成
2585        //   Cargo.toml: [package]\nname = "my-crate"\n...
2586        //   src/models/mod.rs: pub mod user;
2587        //   src/models/user.rs: pub struct User;
2588        //   tests/test_models.rs: use my_crate::models::User;
2589        let tmp = tempfile::tempdir().unwrap();
2590        let src_models_dir = tmp.path().join("src").join("models");
2591        let tests_dir = tmp.path().join("tests");
2592        std::fs::create_dir_all(&src_models_dir).unwrap();
2593        std::fs::create_dir_all(&tests_dir).unwrap();
2594
2595        std::fs::write(
2596            tmp.path().join("Cargo.toml"),
2597            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
2598        )
2599        .unwrap();
2600
2601        let mod_rs = src_models_dir.join("mod.rs");
2602        std::fs::write(&mod_rs, "pub mod user;\n").unwrap();
2603
2604        let user_rs = src_models_dir.join("user.rs");
2605        std::fs::write(&user_rs, "pub struct User;\n").unwrap();
2606
2607        let test_models_rs = tests_dir.join("test_models.rs");
2608        let test_source = "use my_crate::models::User;\n\n#[test]\nfn test_user() {}\n";
2609        std::fs::write(&test_models_rs, test_source).unwrap();
2610
2611        let extractor = RustExtractor::new();
2612        let user_path = user_rs.to_string_lossy().into_owned();
2613        let test_path = test_models_rs.to_string_lossy().into_owned();
2614        let production_files = vec![user_path.clone()];
2615        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2616            .into_iter()
2617            .collect();
2618
2619        // When: map_test_files_with_imports を呼ぶ
2620        let result = extractor.map_test_files_with_imports(
2621            &production_files,
2622            &test_sources,
2623            tmp.path(),
2624            false,
2625        );
2626
2627        // Then: test_models.rs → user.rs が Layer 2 (ImportTracing) でマッチ
2628        let mapping = result.iter().find(|m| m.production_file == user_path);
2629        assert!(mapping.is_some(), "production file mapping not found");
2630        let mapping = mapping.unwrap();
2631        assert!(
2632            mapping.test_files.contains(&test_path),
2633            "Expected test_models.rs to map to user.rs via Layer 2 (pub mod chain), got: {:?}",
2634            mapping.test_files
2635        );
2636        assert_eq!(
2637            mapping.strategy,
2638            MappingStrategy::ImportTracing,
2639            "Expected ImportTracing strategy, got: {:?}",
2640            mapping.strategy
2641        );
2642    }
2643
2644    // -----------------------------------------------------------------------
2645    // RS-DEEP-REEXPORT-02: 3段 re-export — lib.rs → models/mod.rs → user.rs
2646    // テストが `use my_crate::models::User;` (user セグメントなし) のみを使うため
2647    // pub mod wildcard chain なしでは user.rs にマッチできない
2648    // -----------------------------------------------------------------------
2649    #[test]
2650    fn rs_deep_reexport_02_three_hop() {
2651        // Given: tempdir に以下を作成
2652        //   Cargo.toml: [package]\nname = "my-crate"\n...
2653        //   src/lib.rs: pub mod models;
2654        //   src/models/mod.rs: pub mod user;
2655        //   src/models/user.rs: pub struct User;
2656        //   tests/test_account.rs: use my_crate::models::User; (user セグメントなし)
2657        let tmp = tempfile::tempdir().unwrap();
2658        let src_dir = tmp.path().join("src");
2659        let src_models_dir = src_dir.join("models");
2660        let tests_dir = tmp.path().join("tests");
2661        std::fs::create_dir_all(&src_models_dir).unwrap();
2662        std::fs::create_dir_all(&tests_dir).unwrap();
2663
2664        std::fs::write(
2665            tmp.path().join("Cargo.toml"),
2666            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
2667        )
2668        .unwrap();
2669
2670        std::fs::write(src_dir.join("lib.rs"), "pub mod models;\n").unwrap();
2671
2672        let mod_rs = src_models_dir.join("mod.rs");
2673        std::fs::write(&mod_rs, "pub mod user;\n").unwrap();
2674
2675        let user_rs = src_models_dir.join("user.rs");
2676        std::fs::write(&user_rs, "pub struct User;\n").unwrap();
2677
2678        // test_account.rs: ファイル名は user と無関係 → Layer 1 ではマッチしない
2679        let test_account_rs = tests_dir.join("test_account.rs");
2680        let test_source = "use my_crate::models::User;\n\n#[test]\nfn test_account() {}\n";
2681        std::fs::write(&test_account_rs, test_source).unwrap();
2682
2683        let extractor = RustExtractor::new();
2684        let user_path = user_rs.to_string_lossy().into_owned();
2685        let test_path = test_account_rs.to_string_lossy().into_owned();
2686        let production_files = vec![user_path.clone()];
2687        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
2688            .into_iter()
2689            .collect();
2690
2691        // When: map_test_files_with_imports を呼ぶ
2692        let result = extractor.map_test_files_with_imports(
2693            &production_files,
2694            &test_sources,
2695            tmp.path(),
2696            false,
2697        );
2698
2699        // Then: test_account.rs → user.rs が Layer 2 (ImportTracing) でマッチ
2700        // (lib.rs → models/ → pub mod user; の wildcard chain を辿る必要がある)
2701        let mapping = result.iter().find(|m| m.production_file == user_path);
2702        assert!(mapping.is_some(), "production file mapping not found");
2703        let mapping = mapping.unwrap();
2704        assert!(
2705            mapping.test_files.contains(&test_path),
2706            "Expected test_account.rs to map to user.rs via Layer 2 (3-hop pub mod chain), got: {:?}",
2707            mapping.test_files
2708        );
2709        assert_eq!(
2710            mapping.strategy,
2711            MappingStrategy::ImportTracing,
2712            "Expected ImportTracing strategy, got: {:?}",
2713            mapping.strategy
2714        );
2715    }
2716
2717    // -----------------------------------------------------------------------
2718    // RS-DEEP-REEXPORT-03: pub use + pub mod 混在 → 両エントリ返す
2719    // -----------------------------------------------------------------------
2720    #[test]
2721    fn rs_deep_reexport_03_pub_use_and_pub_mod() {
2722        // Given: mod.rs with `pub mod internal;` and `pub use internal::Exported;`
2723        let source = "pub mod internal;\npub use internal::Exported;\n";
2724
2725        // When: extract_barrel_re_exports is called
2726        let extractor = RustExtractor::new();
2727        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
2728
2729        // Then: 2エントリ返す
2730        //   1. from_specifier="./internal", wildcard=true  (pub mod)
2731        //   2. from_specifier="./internal", symbols=["Exported"]  (pub use)
2732        let wildcard_entry = result
2733            .iter()
2734            .find(|e| e.from_specifier == "./internal" && e.wildcard);
2735        assert!(
2736            wildcard_entry.is_some(),
2737            "Expected wildcard=true entry for pub mod internal, got: {:?}",
2738            result
2739        );
2740
2741        let symbol_entry = result.iter().find(|e| {
2742            e.from_specifier == "./internal"
2743                && !e.wildcard
2744                && e.symbols.contains(&"Exported".to_string())
2745        });
2746        assert!(
2747            symbol_entry.is_some(),
2748            "Expected symbols=[\"Exported\"] entry for pub use internal::Exported, got: {:?}",
2749            result
2750        );
2751    }
2752
2753    // -----------------------------------------------------------------------
2754    // RS-EXPORT-01: pub fn match
2755    // -----------------------------------------------------------------------
2756    #[test]
2757    fn rs_export_01_pub_fn_match() {
2758        // Given: a file with pub fn create_user
2759        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2760            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
2761        let extractor = RustExtractor::new();
2762        let symbols = vec!["create_user".to_string()];
2763
2764        // When: file_exports_any_symbol is called
2765        let result = extractor.file_exports_any_symbol(&path, &symbols);
2766
2767        // Then: returns true
2768        assert!(result, "Expected true for pub fn create_user");
2769    }
2770
2771    // -----------------------------------------------------------------------
2772    // RS-EXPORT-02: pub struct match
2773    // -----------------------------------------------------------------------
2774    #[test]
2775    fn rs_export_02_pub_struct_match() {
2776        // Given: a file with pub struct User
2777        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2778            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
2779        let extractor = RustExtractor::new();
2780        let symbols = vec!["User".to_string()];
2781
2782        // When: file_exports_any_symbol is called
2783        let result = extractor.file_exports_any_symbol(&path, &symbols);
2784
2785        // Then: returns true
2786        assert!(result, "Expected true for pub struct User");
2787    }
2788
2789    // -----------------------------------------------------------------------
2790    // RS-EXPORT-03: non-existent symbol
2791    // -----------------------------------------------------------------------
2792    #[test]
2793    fn rs_export_03_nonexistent_symbol() {
2794        // Given: a file without NonExistent symbol
2795        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2796            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
2797        let extractor = RustExtractor::new();
2798        let symbols = vec!["NonExistent".to_string()];
2799
2800        // When: file_exports_any_symbol is called
2801        let result = extractor.file_exports_any_symbol(&path, &symbols);
2802
2803        // Then: returns false
2804        assert!(!result, "Expected false for NonExistent symbol");
2805    }
2806
2807    // -----------------------------------------------------------------------
2808    // RS-EXPORT-04: file with no pub symbols
2809    // -----------------------------------------------------------------------
2810    #[test]
2811    fn rs_export_04_no_pub_symbols() {
2812        // Given: a file with no pub items
2813        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2814            .join("../../tests/fixtures/rust/observe/no_pub_symbols.rs");
2815        let extractor = RustExtractor::new();
2816        let symbols = vec!["internal_only".to_string()];
2817
2818        // When: file_exports_any_symbol is called
2819        let result = extractor.file_exports_any_symbol(&path, &symbols);
2820
2821        // Then: returns false
2822        assert!(!result, "Expected false for file with no pub symbols");
2823    }
2824
2825    // -----------------------------------------------------------------------
2826    // RS-EXPORT-05: pub use/mod only (no direct pub definitions)
2827    // -----------------------------------------------------------------------
2828    #[test]
2829    fn rs_export_05_pub_use_mod_only() {
2830        // Given: a file with only pub use and pub mod (barrel re-exports)
2831        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2832            .join("../../tests/fixtures/rust/observe/pub_use_only.rs");
2833        let extractor = RustExtractor::new();
2834        let symbols = vec!["Foo".to_string()];
2835
2836        // When: file_exports_any_symbol is called
2837        let result = extractor.file_exports_any_symbol(&path, &symbols);
2838
2839        // Then: returns false (pub use/mod are handled by barrel resolution)
2840        assert!(
2841            !result,
2842            "Expected false for pub use/mod only file (barrel resolution handles these)"
2843        );
2844    }
2845
2846    // -----------------------------------------------------------------------
2847    // RS-EXPORT-06: empty symbol list
2848    // -----------------------------------------------------------------------
2849    #[test]
2850    fn rs_export_06_empty_symbols() {
2851        // Given: any file
2852        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2853            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
2854        let extractor = RustExtractor::new();
2855        let symbols: Vec<String> = vec![];
2856
2857        // When: file_exports_any_symbol is called with empty symbols
2858        let result = extractor.file_exports_any_symbol(&path, &symbols);
2859
2860        // Then: returns true (short-circuit)
2861        assert!(result, "Expected true for empty symbol list");
2862    }
2863
2864    // -----------------------------------------------------------------------
2865    // RS-EXPORT-07: non-existent file (optimistic fallback)
2866    // -----------------------------------------------------------------------
2867    #[test]
2868    fn rs_export_07_nonexistent_file() {
2869        // Given: a non-existent file path
2870        let path = PathBuf::from("/nonexistent/path/to/file.rs");
2871        let extractor = RustExtractor::new();
2872        let symbols = vec!["Foo".to_string()];
2873
2874        // When: file_exports_any_symbol is called
2875        // Then: returns true (optimistic fallback, matches core default and Python)
2876        let result = extractor.file_exports_any_symbol(&path, &symbols);
2877        assert!(
2878            result,
2879            "Expected true for non-existent file (optimistic fallback)"
2880        );
2881    }
2882
2883    // -----------------------------------------------------------------------
2884    // RS-EXPORT-PUB-ONLY-01: pub fn matches (regression)
2885    // -----------------------------------------------------------------------
2886    #[test]
2887    fn rs_export_pub_only_01_pub_fn_matches() {
2888        // Given: a file with `pub fn create_user() {}`
2889        let dir = tempfile::tempdir().unwrap();
2890        let file = dir.path().join("service.rs");
2891        std::fs::write(&file, "pub fn create_user() {}").unwrap();
2892        let extractor = RustExtractor::new();
2893        let symbols = vec!["create_user".to_string()];
2894
2895        // When: file_exports_any_symbol is called
2896        let result = extractor.file_exports_any_symbol(&file, &symbols);
2897
2898        // Then: returns true (pub fn is exported)
2899        assert!(result, "Expected true for pub fn create_user");
2900    }
2901
2902    // -----------------------------------------------------------------------
2903    // RS-EXPORT-PUB-ONLY-02: pub(crate) struct excluded
2904    // -----------------------------------------------------------------------
2905    #[test]
2906    fn rs_export_pub_only_02_pub_crate_excluded() {
2907        // Given: a file with `pub(crate) struct Handle {}`
2908        let dir = tempfile::tempdir().unwrap();
2909        let file = dir.path().join("driver.rs");
2910        std::fs::write(&file, "pub(crate) struct Handle {}").unwrap();
2911        let extractor = RustExtractor::new();
2912        let symbols = vec!["Handle".to_string()];
2913
2914        // When: file_exports_any_symbol is called
2915        let result = extractor.file_exports_any_symbol(&file, &symbols);
2916
2917        // Then: returns false (pub(crate) is NOT a public export)
2918        assert!(!result, "Expected false for pub(crate) struct Handle");
2919    }
2920
2921    // -----------------------------------------------------------------------
2922    // RS-EXPORT-PUB-ONLY-03: pub(super) fn excluded
2923    // -----------------------------------------------------------------------
2924    #[test]
2925    fn rs_export_pub_only_03_pub_super_excluded() {
2926        // Given: a file with `pub(super) fn helper() {}`
2927        let dir = tempfile::tempdir().unwrap();
2928        let file = dir.path().join("internal.rs");
2929        std::fs::write(&file, "pub(super) fn helper() {}").unwrap();
2930        let extractor = RustExtractor::new();
2931        let symbols = vec!["helper".to_string()];
2932
2933        // When: file_exports_any_symbol is called
2934        let result = extractor.file_exports_any_symbol(&file, &symbols);
2935
2936        // Then: returns false (pub(super) is NOT a public export)
2937        assert!(!result, "Expected false for pub(super) fn helper");
2938    }
2939
2940    // -----------------------------------------------------------------------
2941    // RS-EXPORT-PUB-ONLY-04: mixed visibility - pub struct matches, pub(crate) excluded
2942    // -----------------------------------------------------------------------
2943    #[test]
2944    fn rs_export_pub_only_04_mixed_visibility() {
2945        // Given: a file with `pub struct User {}` and `pub(crate) struct Inner {}`
2946        let dir = tempfile::tempdir().unwrap();
2947        let file = dir.path().join("models.rs");
2948        std::fs::write(&file, "pub struct User {}\npub(crate) struct Inner {}").unwrap();
2949        let extractor = RustExtractor::new();
2950        let symbols = vec!["User".to_string()];
2951
2952        // When: file_exports_any_symbol is called with "User"
2953        let result = extractor.file_exports_any_symbol(&file, &symbols);
2954
2955        // Then: returns true (pub struct User is exported)
2956        assert!(
2957            result,
2958            "Expected true for pub struct User in mixed visibility file"
2959        );
2960    }
2961
2962    // -----------------------------------------------------------------------
2963    // RS-WS-01: workspace with 2 members -> 2 members detected
2964    // -----------------------------------------------------------------------
2965    #[test]
2966    fn rs_ws_01_workspace_two_members() {
2967        // Given: a workspace with 2 member crates
2968        let tmp = tempfile::tempdir().unwrap();
2969        std::fs::write(
2970            tmp.path().join("Cargo.toml"),
2971            "[workspace]\nmembers = [\"crate_a\", \"crate_b\"]\n",
2972        )
2973        .unwrap();
2974        std::fs::create_dir_all(tmp.path().join("crate_a/src")).unwrap();
2975        std::fs::write(
2976            tmp.path().join("crate_a/Cargo.toml"),
2977            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
2978        )
2979        .unwrap();
2980        std::fs::create_dir_all(tmp.path().join("crate_b/src")).unwrap();
2981        std::fs::write(
2982            tmp.path().join("crate_b/Cargo.toml"),
2983            "[package]\nname = \"crate_b\"\nversion = \"0.1.0\"\n",
2984        )
2985        .unwrap();
2986
2987        // When: find_workspace_members is called
2988        let members = find_workspace_members(tmp.path());
2989
2990        // Then: 2 WorkspaceMembers detected
2991        assert_eq!(members.len(), 2, "Expected 2 members, got: {:?}", members);
2992        let names: Vec<&str> = members.iter().map(|m| m.crate_name.as_str()).collect();
2993        assert!(
2994            names.contains(&"crate_a"),
2995            "crate_a not found in {:?}",
2996            names
2997        );
2998        assert!(
2999            names.contains(&"crate_b"),
3000            "crate_b not found in {:?}",
3001            names
3002        );
3003    }
3004
3005    // -----------------------------------------------------------------------
3006    // RS-WS-02: single crate (non-workspace) returns empty
3007    // -----------------------------------------------------------------------
3008    #[test]
3009    fn rs_ws_02_single_crate_returns_empty() {
3010        // Given: a single crate (no [workspace] section, has [package])
3011        let tmp = tempfile::tempdir().unwrap();
3012        std::fs::write(
3013            tmp.path().join("Cargo.toml"),
3014            "[package]\nname = \"my_crate\"\nversion = \"0.1.0\"\n",
3015        )
3016        .unwrap();
3017        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
3018
3019        // When: find_workspace_members is called
3020        let members = find_workspace_members(tmp.path());
3021
3022        // Then: empty Vec (not a workspace root)
3023        assert!(members.is_empty(), "Expected empty, got: {:?}", members);
3024    }
3025
3026    // -----------------------------------------------------------------------
3027    // RS-WS-03: target/ directory is skipped
3028    // -----------------------------------------------------------------------
3029    #[test]
3030    fn rs_ws_03_target_dir_skipped() {
3031        // Given: a workspace where target/ contains a Cargo.toml
3032        let tmp = tempfile::tempdir().unwrap();
3033        std::fs::write(
3034            tmp.path().join("Cargo.toml"),
3035            "[workspace]\nmembers = [\"crate_a\"]\n",
3036        )
3037        .unwrap();
3038        std::fs::create_dir_all(tmp.path().join("crate_a/src")).unwrap();
3039        std::fs::write(
3040            tmp.path().join("crate_a/Cargo.toml"),
3041            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
3042        )
3043        .unwrap();
3044        // A Cargo.toml inside target/ (should be ignored)
3045        std::fs::create_dir_all(tmp.path().join("target/debug/build/fake")).unwrap();
3046        std::fs::write(
3047            tmp.path().join("target/debug/build/fake/Cargo.toml"),
3048            "[package]\nname = \"fake_crate\"\nversion = \"0.1.0\"\n",
3049        )
3050        .unwrap();
3051
3052        // When: find_workspace_members is called
3053        let members = find_workspace_members(tmp.path());
3054
3055        // Then: only crate_a detected (target/ is skipped)
3056        assert_eq!(members.len(), 1, "Expected 1 member, got: {:?}", members);
3057        assert_eq!(members[0].crate_name, "crate_a");
3058    }
3059
3060    // -----------------------------------------------------------------------
3061    // RS-WS-04: hyphenated crate name -> underscore conversion
3062    // -----------------------------------------------------------------------
3063    #[test]
3064    fn rs_ws_04_hyphenated_crate_name_converted() {
3065        // Given: a workspace with a member crate named "my-crate" (hyphenated)
3066        let tmp = tempfile::tempdir().unwrap();
3067        std::fs::write(
3068            tmp.path().join("Cargo.toml"),
3069            "[workspace]\nmembers = [\"my-crate\"]\n",
3070        )
3071        .unwrap();
3072        std::fs::create_dir_all(tmp.path().join("my-crate/src")).unwrap();
3073        std::fs::write(
3074            tmp.path().join("my-crate/Cargo.toml"),
3075            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
3076        )
3077        .unwrap();
3078
3079        // When: find_workspace_members is called
3080        let members = find_workspace_members(tmp.path());
3081
3082        // Then: crate_name = "my_crate" (hyphens converted to underscores)
3083        assert_eq!(members.len(), 1, "Expected 1 member, got: {:?}", members);
3084        assert_eq!(members[0].crate_name, "my_crate");
3085    }
3086
3087    // -----------------------------------------------------------------------
3088    // RS-WS-05: test file in member/tests/ -> Some(foo member)
3089    // -----------------------------------------------------------------------
3090    #[test]
3091    fn rs_ws_05_find_member_for_path_in_tests() {
3092        // Given: workspace members [crate_a at /tmp/ws/crate_a]
3093        let tmp = tempfile::tempdir().unwrap();
3094        let member_root = tmp.path().join("crate_a");
3095        std::fs::create_dir_all(&member_root).unwrap();
3096        let members = vec![WorkspaceMember {
3097            crate_name: "crate_a".to_string(),
3098            member_root: member_root.clone(),
3099        }];
3100
3101        // When: find_member_for_path with a test file inside crate_a/tests/
3102        let test_file = member_root.join("tests").join("integration.rs");
3103        let result = find_member_for_path(&test_file, &members);
3104
3105        // Then: returns Some(crate_a member)
3106        assert!(result.is_some(), "Expected Some(crate_a), got None");
3107        assert_eq!(result.unwrap().crate_name, "crate_a");
3108    }
3109
3110    // -----------------------------------------------------------------------
3111    // RS-WS-06: test file not in any member -> None
3112    // -----------------------------------------------------------------------
3113    #[test]
3114    fn rs_ws_06_find_member_for_path_not_in_any() {
3115        // Given: workspace members [crate_a]
3116        let tmp = tempfile::tempdir().unwrap();
3117        let member_root = tmp.path().join("crate_a");
3118        std::fs::create_dir_all(&member_root).unwrap();
3119        let members = vec![WorkspaceMember {
3120            crate_name: "crate_a".to_string(),
3121            member_root: member_root.clone(),
3122        }];
3123
3124        // When: find_member_for_path with a path outside any member
3125        let outside_path = tmp.path().join("other").join("test.rs");
3126        let result = find_member_for_path(&outside_path, &members);
3127
3128        // Then: returns None
3129        assert!(
3130            result.is_none(),
3131            "Expected None, got: {:?}",
3132            result.map(|m| &m.crate_name)
3133        );
3134    }
3135
3136    // -----------------------------------------------------------------------
3137    // RS-WS-07: longest prefix match for nested members
3138    // -----------------------------------------------------------------------
3139    #[test]
3140    fn rs_ws_07_find_member_longest_prefix() {
3141        // Given: workspace with nested members [ws/crates/foo, ws/crates/foo-extra]
3142        let tmp = tempfile::tempdir().unwrap();
3143        let foo_root = tmp.path().join("crates").join("foo");
3144        let foo_extra_root = tmp.path().join("crates").join("foo-extra");
3145        std::fs::create_dir_all(&foo_root).unwrap();
3146        std::fs::create_dir_all(&foo_extra_root).unwrap();
3147        let members = vec![
3148            WorkspaceMember {
3149                crate_name: "foo".to_string(),
3150                member_root: foo_root.clone(),
3151            },
3152            WorkspaceMember {
3153                crate_name: "foo_extra".to_string(),
3154                member_root: foo_extra_root.clone(),
3155            },
3156        ];
3157
3158        // When: find_member_for_path with a path inside foo-extra/
3159        let test_file = foo_extra_root.join("tests").join("test_bar.rs");
3160        let result = find_member_for_path(&test_file, &members);
3161
3162        // Then: returns foo-extra (longest prefix match)
3163        assert!(result.is_some(), "Expected Some(foo_extra), got None");
3164        assert_eq!(result.unwrap().crate_name, "foo_extra");
3165    }
3166
3167    // -----------------------------------------------------------------------
3168    // RS-WS-E2E-01: workspace L2 import tracing works
3169    // -----------------------------------------------------------------------
3170    #[test]
3171    fn rs_ws_e2e_01_workspace_l2_import_tracing() {
3172        // Given: a workspace with crate_a containing src/user.rs and tests/test_user.rs
3173        // that imports `use crate_a::user::create_user`
3174        let tmp = tempfile::tempdir().unwrap();
3175        std::fs::write(
3176            tmp.path().join("Cargo.toml"),
3177            "[workspace]\nmembers = [\"crate_a\"]\n",
3178        )
3179        .unwrap();
3180
3181        let member_dir = tmp.path().join("crate_a");
3182        std::fs::create_dir_all(member_dir.join("src")).unwrap();
3183        std::fs::create_dir_all(member_dir.join("tests")).unwrap();
3184        std::fs::write(
3185            member_dir.join("Cargo.toml"),
3186            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
3187        )
3188        .unwrap();
3189
3190        let user_rs = member_dir.join("src").join("user.rs");
3191        std::fs::write(&user_rs, "pub fn create_user() {}\n").unwrap();
3192
3193        let test_rs = member_dir.join("tests").join("test_user.rs");
3194        std::fs::write(
3195            &test_rs,
3196            "use crate_a::user::create_user;\n#[test]\nfn test_create_user() { create_user(); }\n",
3197        )
3198        .unwrap();
3199
3200        let extractor = RustExtractor::new();
3201        let prod_path = user_rs.to_string_lossy().into_owned();
3202        let test_path = test_rs.to_string_lossy().into_owned();
3203        let production_files = vec![prod_path.clone()];
3204        let test_sources: HashMap<String, String> = [(
3205            test_path.clone(),
3206            std::fs::read_to_string(&test_rs).unwrap(),
3207        )]
3208        .into_iter()
3209        .collect();
3210
3211        // When: map_test_files_with_imports is called at workspace root
3212        let result = extractor.map_test_files_with_imports(
3213            &production_files,
3214            &test_sources,
3215            tmp.path(),
3216            false,
3217        );
3218
3219        // Then: test_user.rs -> user.rs via Layer 2 (ImportTracing)
3220        let mapping = result.iter().find(|m| m.production_file == prod_path);
3221        assert!(mapping.is_some(), "No mapping for user.rs");
3222        let mapping = mapping.unwrap();
3223        assert!(
3224            mapping.test_files.contains(&test_path),
3225            "Expected test_user.rs in test_files, got: {:?}",
3226            mapping.test_files
3227        );
3228        assert_eq!(
3229            mapping.strategy,
3230            MappingStrategy::ImportTracing,
3231            "Expected ImportTracing strategy, got: {:?}",
3232            mapping.strategy
3233        );
3234    }
3235
3236    // -----------------------------------------------------------------------
3237    // RS-WS-E2E-02: L0/L1 still work at workspace level
3238    //
3239    // Layer 1 (FileNameConvention) matches within the same directory only.
3240    // Cross-directory matches (src/ vs tests/) are handled by Layer 2.
3241    // This test verifies:
3242    //   - L0: src/service.rs with inline tests -> self-mapped
3243    //   - L1: src/test_service.rs -> src/service.rs (same src/ directory)
3244    // -----------------------------------------------------------------------
3245    #[test]
3246    fn rs_ws_e2e_02_l0_l1_still_work_at_workspace_level() {
3247        // Given: a workspace with crate_a containing src/service.rs (with inline tests)
3248        // and src/test_service.rs (same-directory filename convention match)
3249        let tmp = tempfile::tempdir().unwrap();
3250        std::fs::write(
3251            tmp.path().join("Cargo.toml"),
3252            "[workspace]\nmembers = [\"crate_a\"]\n",
3253        )
3254        .unwrap();
3255
3256        let member_dir = tmp.path().join("crate_a");
3257        std::fs::create_dir_all(member_dir.join("src")).unwrap();
3258        std::fs::write(
3259            member_dir.join("Cargo.toml"),
3260            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
3261        )
3262        .unwrap();
3263
3264        // Layer 0: inline tests in service.rs
3265        let service_rs = member_dir.join("src").join("service.rs");
3266        std::fs::write(
3267            &service_rs,
3268            r#"pub fn do_work() {}
3269
3270#[cfg(test)]
3271mod tests {
3272    use super::*;
3273    #[test]
3274    fn test_do_work() { do_work(); }
3275}
3276"#,
3277        )
3278        .unwrap();
3279
3280        // Layer 1: test_service.rs in the same src/ directory -> service.rs
3281        let test_service_rs = member_dir.join("src").join("test_service.rs");
3282        std::fs::write(
3283            &test_service_rs,
3284            "#[test]\nfn test_service_smoke() { assert!(true); }\n",
3285        )
3286        .unwrap();
3287
3288        let extractor = RustExtractor::new();
3289        let prod_path = service_rs.to_string_lossy().into_owned();
3290        let test_path = test_service_rs.to_string_lossy().into_owned();
3291        let production_files = vec![prod_path.clone()];
3292        let test_sources: HashMap<String, String> = [(
3293            test_path.clone(),
3294            std::fs::read_to_string(&test_service_rs).unwrap(),
3295        )]
3296        .into_iter()
3297        .collect();
3298
3299        // When: map_test_files_with_imports is called at workspace root
3300        let result = extractor.map_test_files_with_imports(
3301            &production_files,
3302            &test_sources,
3303            tmp.path(),
3304            false,
3305        );
3306
3307        // Then: service.rs self-mapped (Layer 0) and test_service.rs mapped (Layer 1)
3308        let mapping = result.iter().find(|m| m.production_file == prod_path);
3309        assert!(mapping.is_some(), "No mapping for service.rs");
3310        let mapping = mapping.unwrap();
3311        assert!(
3312            mapping.test_files.contains(&prod_path),
3313            "Expected service.rs self-mapped (Layer 0), got: {:?}",
3314            mapping.test_files
3315        );
3316        assert!(
3317            mapping.test_files.contains(&test_path),
3318            "Expected test_service.rs mapped (Layer 1), got: {:?}",
3319            mapping.test_files
3320        );
3321    }
3322
3323    // -----------------------------------------------------------------------
3324    // RS-WS-E2E-03: Non-virtual workspace (both [workspace] and [package])
3325    //
3326    // Root Cargo.toml has both [workspace] and [package] (like clap).
3327    // L2 must work for both root crate and member crates.
3328    // -----------------------------------------------------------------------
3329    #[test]
3330    fn rs_ws_e2e_03_non_virtual_workspace_l2() {
3331        // Given: a non-virtual workspace with root package "root_pkg"
3332        // and member "member_a"
3333        let tmp = tempfile::tempdir().unwrap();
3334        std::fs::write(
3335            tmp.path().join("Cargo.toml"),
3336            "[workspace]\nmembers = [\"member_a\"]\n\n[package]\nname = \"root_pkg\"\nversion = \"0.1.0\"\n",
3337        )
3338        .unwrap();
3339
3340        // Root crate src + tests
3341        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
3342        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
3343        let root_src = tmp.path().join("src").join("lib.rs");
3344        std::fs::write(&root_src, "pub fn root_fn() {}\n").unwrap();
3345        let root_test = tmp.path().join("tests").join("test_root.rs");
3346        std::fs::write(
3347            &root_test,
3348            "use root_pkg::lib::root_fn;\n#[test]\nfn test_root() { }\n",
3349        )
3350        .unwrap();
3351
3352        // Member crate
3353        let member_dir = tmp.path().join("member_a");
3354        std::fs::create_dir_all(member_dir.join("src")).unwrap();
3355        std::fs::create_dir_all(member_dir.join("tests")).unwrap();
3356        std::fs::write(
3357            member_dir.join("Cargo.toml"),
3358            "[package]\nname = \"member_a\"\nversion = \"0.1.0\"\n",
3359        )
3360        .unwrap();
3361        let member_src = member_dir.join("src").join("handler.rs");
3362        std::fs::write(&member_src, "pub fn handle() {}\n").unwrap();
3363        let member_test = member_dir.join("tests").join("test_handler.rs");
3364        std::fs::write(
3365            &member_test,
3366            "use member_a::handler::handle;\n#[test]\nfn test_handle() { handle(); }\n",
3367        )
3368        .unwrap();
3369
3370        let extractor = RustExtractor::new();
3371        let root_src_path = root_src.to_string_lossy().into_owned();
3372        let member_src_path = member_src.to_string_lossy().into_owned();
3373        let root_test_path = root_test.to_string_lossy().into_owned();
3374        let member_test_path = member_test.to_string_lossy().into_owned();
3375
3376        let production_files = vec![root_src_path.clone(), member_src_path.clone()];
3377        let test_sources: HashMap<String, String> = [
3378            (
3379                root_test_path.clone(),
3380                std::fs::read_to_string(&root_test).unwrap(),
3381            ),
3382            (
3383                member_test_path.clone(),
3384                std::fs::read_to_string(&member_test).unwrap(),
3385            ),
3386        ]
3387        .into_iter()
3388        .collect();
3389
3390        // When: map_test_files_with_imports at workspace root
3391        let result = extractor.map_test_files_with_imports(
3392            &production_files,
3393            &test_sources,
3394            tmp.path(),
3395            false,
3396        );
3397
3398        // Then: member's test maps to member's src via L2
3399        let member_mapping = result.iter().find(|m| m.production_file == member_src_path);
3400        assert!(member_mapping.is_some(), "No mapping for member handler.rs");
3401        let member_mapping = member_mapping.unwrap();
3402        assert!(
3403            member_mapping.test_files.contains(&member_test_path),
3404            "Expected member test mapped via L2, got: {:?}",
3405            member_mapping.test_files
3406        );
3407        assert_eq!(
3408            member_mapping.strategy,
3409            MappingStrategy::ImportTracing,
3410            "Expected ImportTracing for member, got: {:?}",
3411            member_mapping.strategy
3412        );
3413    }
3414
3415    // -----------------------------------------------------------------------
3416    // RS-WS-08: has_workspace_section detects [workspace]
3417    // -----------------------------------------------------------------------
3418    #[test]
3419    fn rs_ws_08_has_workspace_section() {
3420        let tmp = tempfile::tempdir().unwrap();
3421
3422        // Virtual workspace
3423        std::fs::write(
3424            tmp.path().join("Cargo.toml"),
3425            "[workspace]\nmembers = [\"a\"]\n",
3426        )
3427        .unwrap();
3428        assert!(has_workspace_section(tmp.path()));
3429
3430        // Non-virtual workspace
3431        std::fs::write(
3432            tmp.path().join("Cargo.toml"),
3433            "[workspace]\nmembers = [\"a\"]\n\n[package]\nname = \"root\"\n",
3434        )
3435        .unwrap();
3436        assert!(has_workspace_section(tmp.path()));
3437
3438        // Single crate (no workspace)
3439        std::fs::write(
3440            tmp.path().join("Cargo.toml"),
3441            "[package]\nname = \"single\"\n",
3442        )
3443        .unwrap();
3444        assert!(!has_workspace_section(tmp.path()));
3445
3446        // No Cargo.toml
3447        std::fs::remove_file(tmp.path().join("Cargo.toml")).unwrap();
3448        assert!(!has_workspace_section(tmp.path()));
3449    }
3450
3451    // -----------------------------------------------------------------------
3452    // RS-L0-BARREL-01: mod.rs with inline tests must NOT be self-mapped (TC-01)
3453    // -----------------------------------------------------------------------
3454    #[test]
3455    fn rs_l0_barrel_01_mod_rs_excluded() {
3456        // Given: mod.rs containing #[cfg(test)] in production_files
3457        let tmp = tempfile::tempdir().unwrap();
3458        let src_dir = tmp.path().join("src");
3459        std::fs::create_dir_all(&src_dir).unwrap();
3460
3461        let mod_rs = src_dir.join("mod.rs");
3462        std::fs::write(
3463            &mod_rs,
3464            r#"pub mod sub;
3465
3466#[cfg(test)]
3467mod tests {
3468    #[test]
3469    fn test_something() {}
3470}
3471"#,
3472        )
3473        .unwrap();
3474
3475        let extractor = RustExtractor::new();
3476        let prod_path = mod_rs.to_string_lossy().into_owned();
3477        let production_files = vec![prod_path.clone()];
3478        let test_sources: HashMap<String, String> = HashMap::new();
3479
3480        // When: map_test_files_with_imports is called
3481        let result = extractor.map_test_files_with_imports(
3482            &production_files,
3483            &test_sources,
3484            tmp.path(),
3485            false,
3486        );
3487
3488        // Then: mod.rs is NOT self-mapped (barrel file exclusion)
3489        let mapping = result.iter().find(|m| m.production_file == prod_path);
3490        assert!(mapping.is_some());
3491        assert!(
3492            !mapping.unwrap().test_files.contains(&prod_path),
3493            "mod.rs should NOT be self-mapped, but found in: {:?}",
3494            mapping.unwrap().test_files
3495        );
3496    }
3497
3498    // -----------------------------------------------------------------------
3499    // RS-L0-BARREL-02: lib.rs with inline tests must NOT be self-mapped (TC-02)
3500    // -----------------------------------------------------------------------
3501    #[test]
3502    fn rs_l0_barrel_02_lib_rs_excluded() {
3503        // Given: lib.rs containing #[cfg(test)] in production_files
3504        let tmp = tempfile::tempdir().unwrap();
3505        let src_dir = tmp.path().join("src");
3506        std::fs::create_dir_all(&src_dir).unwrap();
3507
3508        let lib_rs = src_dir.join("lib.rs");
3509        std::fs::write(
3510            &lib_rs,
3511            r#"pub mod utils;
3512
3513#[cfg(test)]
3514mod tests {
3515    #[test]
3516    fn test_lib() {}
3517}
3518"#,
3519        )
3520        .unwrap();
3521
3522        let extractor = RustExtractor::new();
3523        let prod_path = lib_rs.to_string_lossy().into_owned();
3524        let production_files = vec![prod_path.clone()];
3525        let test_sources: HashMap<String, String> = HashMap::new();
3526
3527        // When: map_test_files_with_imports is called
3528        let result = extractor.map_test_files_with_imports(
3529            &production_files,
3530            &test_sources,
3531            tmp.path(),
3532            false,
3533        );
3534
3535        // Then: lib.rs is NOT self-mapped (barrel file exclusion)
3536        let mapping = result.iter().find(|m| m.production_file == prod_path);
3537        assert!(mapping.is_some());
3538        assert!(
3539            !mapping.unwrap().test_files.contains(&prod_path),
3540            "lib.rs should NOT be self-mapped, but found in: {:?}",
3541            mapping.unwrap().test_files
3542        );
3543    }
3544
3545    // -----------------------------------------------------------------------
3546    // RS-L0-BARREL-03: regular .rs file with inline tests IS self-mapped (TC-03, regression)
3547    // -----------------------------------------------------------------------
3548    #[test]
3549    fn rs_l0_barrel_03_regular_file_self_mapped() {
3550        // Given: a regular .rs file (not a barrel) containing #[cfg(test)]
3551        let tmp = tempfile::tempdir().unwrap();
3552        let src_dir = tmp.path().join("src");
3553        std::fs::create_dir_all(&src_dir).unwrap();
3554
3555        let service_rs = src_dir.join("service.rs");
3556        std::fs::write(
3557            &service_rs,
3558            r#"pub fn do_work() {}
3559
3560#[cfg(test)]
3561mod tests {
3562    use super::*;
3563    #[test]
3564    fn test_do_work() { assert!(true); }
3565}
3566"#,
3567        )
3568        .unwrap();
3569
3570        let extractor = RustExtractor::new();
3571        let prod_path = service_rs.to_string_lossy().into_owned();
3572        let production_files = vec![prod_path.clone()];
3573        let test_sources: HashMap<String, String> = HashMap::new();
3574
3575        // When: map_test_files_with_imports is called
3576        let result = extractor.map_test_files_with_imports(
3577            &production_files,
3578            &test_sources,
3579            tmp.path(),
3580            false,
3581        );
3582
3583        // Then: service.rs IS self-mapped (regular file with inline tests)
3584        let mapping = result.iter().find(|m| m.production_file == prod_path);
3585        assert!(mapping.is_some());
3586        assert!(
3587            mapping.unwrap().test_files.contains(&prod_path),
3588            "service.rs should be self-mapped, but not found in: {:?}",
3589            mapping.unwrap().test_files
3590        );
3591    }
3592
3593    // -----------------------------------------------------------------------
3594    // RS-L0-BARREL-04: main.rs with inline tests must NOT be self-mapped (TC-04)
3595    // -----------------------------------------------------------------------
3596    #[test]
3597    fn rs_l0_barrel_04_main_rs_excluded() {
3598        // Given: main.rs containing #[cfg(test)] in production_files
3599        let tmp = tempfile::tempdir().unwrap();
3600        let src_dir = tmp.path().join("src");
3601        std::fs::create_dir_all(&src_dir).unwrap();
3602
3603        let main_rs = src_dir.join("main.rs");
3604        std::fs::write(
3605            &main_rs,
3606            r#"fn main() {}
3607
3608#[cfg(test)]
3609mod tests {
3610    #[test]
3611    fn test_main() {}
3612}
3613"#,
3614        )
3615        .unwrap();
3616
3617        let extractor = RustExtractor::new();
3618        let prod_path = main_rs.to_string_lossy().into_owned();
3619        let production_files = vec![prod_path.clone()];
3620        let test_sources: HashMap<String, String> = HashMap::new();
3621
3622        // When: map_test_files_with_imports is called
3623        let result = extractor.map_test_files_with_imports(
3624            &production_files,
3625            &test_sources,
3626            tmp.path(),
3627            false,
3628        );
3629
3630        // Then: main.rs is NOT self-mapped (entry point file exclusion)
3631        let mapping = result.iter().find(|m| m.production_file == prod_path);
3632        assert!(mapping.is_some());
3633        assert!(
3634            !mapping.unwrap().test_files.contains(&prod_path),
3635            "main.rs should NOT be self-mapped, but found in: {:?}",
3636            mapping.unwrap().test_files
3637        );
3638    }
3639
3640    // -----------------------------------------------------------------------
3641    // RS-L0-DETECT-01: #[cfg(test)] mod tests {} -> detect_inline_tests = true
3642    // (REGRESSION: should PASS with current implementation)
3643    // -----------------------------------------------------------------------
3644    #[test]
3645    fn rs_l0_detect_01_cfg_test_with_mod_block() {
3646        // Given: source with #[cfg(test)] followed by mod tests { ... }
3647        let source = r#"
3648pub fn add(a: i32, b: i32) -> i32 { a + b }
3649
3650#[cfg(test)]
3651mod tests {
3652    use super::*;
3653
3654    #[test]
3655    fn test_add() {
3656        assert_eq!(add(1, 2), 3);
3657    }
3658}
3659"#;
3660        // When: detect_inline_tests is called
3661        // Then: returns true (real inline test module)
3662        assert!(detect_inline_tests(source));
3663    }
3664
3665    // -----------------------------------------------------------------------
3666    // RS-L0-DETECT-02: #[cfg(test)] for helper method (no mod) -> false
3667    // -----------------------------------------------------------------------
3668    #[test]
3669    fn rs_l0_detect_02_cfg_test_for_helper_method() {
3670        // Given: source with #[cfg(test)] applied to a function (not a mod)
3671        let source = r#"
3672pub struct Connection;
3673
3674impl Connection {
3675    #[cfg(test)]
3676    pub fn test_helper(&self) -> bool {
3677        true
3678    }
3679}
3680"#;
3681        // When: detect_inline_tests is called
3682        // Then: returns false (cfg(test) does not annotate a mod_item)
3683        assert!(!detect_inline_tests(source));
3684    }
3685
3686    // -----------------------------------------------------------------------
3687    // RS-L0-DETECT-03: #[cfg(test)] for mock substitution (use statement) -> false
3688    // -----------------------------------------------------------------------
3689    #[test]
3690    fn rs_l0_detect_03_cfg_test_for_use_statement() {
3691        // Given: source with #[cfg(test)] applied to a use statement (mock substitution)
3692        let source = r#"
3693#[cfg(not(test))]
3694use real_http::Client;
3695
3696#[cfg(test)]
3697use mock_http::Client;
3698
3699pub fn fetch(url: &str) -> String {
3700    Client::get(url)
3701}
3702"#;
3703        // When: detect_inline_tests is called
3704        // Then: returns false (cfg(test) annotates a use item, not a mod_item)
3705        assert!(!detect_inline_tests(source));
3706    }
3707
3708    // -----------------------------------------------------------------------
3709    // RS-L0-DETECT-04: #[cfg(test)] mod tests; (external module ref) -> true
3710    // (REGRESSION: should PASS with current implementation)
3711    // -----------------------------------------------------------------------
3712    #[test]
3713    fn rs_l0_detect_04_cfg_test_with_external_mod_ref() {
3714        // Given: source with #[cfg(test)] followed by mod tests; (semicolon form)
3715        let source = r#"
3716pub fn compute(x: i32) -> i32 { x * 2 }
3717
3718#[cfg(test)]
3719mod tests;
3720"#;
3721        // When: detect_inline_tests is called
3722        // Then: returns true (mod_item via external module reference)
3723        assert!(detect_inline_tests(source));
3724    }
3725
3726    // -----------------------------------------------------------------------
3727    // RS-L2-EXPORT-FILTER-01: test imports symbol directly from module path,
3728    // module file does NOT export that symbol -> file NOT mapped
3729    //
3730    // Scenario: use myapp::runtime::driver::{Builder}
3731    // driver.rs resolves directly (non-barrel), does NOT export Builder.
3732    // collect_import_matches() else-branch currently maps it without symbol check.
3733    // apply_l2_imports() should filter via file_exports_any_symbol().
3734    // -----------------------------------------------------------------------
3735    #[test]
3736    fn rs_l2_export_filter_01_no_export_not_mapped() {
3737        // Given: temp directory mimicking a crate with:
3738        //   src/runtime/driver.rs: exports spawn() and Driver, NOT Builder
3739        //   tests/test_runtime.rs: use myapp::runtime::driver::{Builder}
3740        let tmp = tempfile::tempdir().unwrap();
3741        let src_runtime = tmp.path().join("src").join("runtime");
3742        let tests_dir = tmp.path().join("tests");
3743        std::fs::create_dir_all(&src_runtime).unwrap();
3744        std::fs::create_dir_all(&tests_dir).unwrap();
3745
3746        // Cargo.toml
3747        std::fs::write(
3748            tmp.path().join("Cargo.toml"),
3749            "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\n",
3750        )
3751        .unwrap();
3752
3753        // src/runtime/driver.rs - exports spawn() and Driver, NOT Builder
3754        let driver_rs = src_runtime.join("driver.rs");
3755        std::fs::write(&driver_rs, "pub fn spawn() {}\npub struct Driver;\n").unwrap();
3756
3757        // tests/test_runtime.rs - imports Builder directly from runtime::driver
3758        // driver.rs resolves as a non-barrel file (no mod.rs lookup needed)
3759        let test_rs = tests_dir.join("test_runtime.rs");
3760        let test_source = "use myapp::runtime::driver::{Builder};\n\n#[test]\nfn test_build() {}\n";
3761        std::fs::write(&test_rs, test_source).unwrap();
3762
3763        let extractor = RustExtractor::new();
3764        let driver_path = driver_rs.to_string_lossy().into_owned();
3765        let test_path = test_rs.to_string_lossy().into_owned();
3766        let production_files = vec![driver_path.clone()];
3767        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
3768            .into_iter()
3769            .collect();
3770
3771        // When: map_test_files_with_imports is called
3772        let result = extractor.map_test_files_with_imports(
3773            &production_files,
3774            &test_sources,
3775            tmp.path(),
3776            false,
3777        );
3778
3779        // Then: driver.rs is NOT mapped to test_runtime.rs
3780        // (driver.rs does not export Builder — apply_l2_imports must filter it)
3781        let mapping = result.iter().find(|m| m.production_file == driver_path);
3782        if let Some(m) = mapping {
3783            assert!(
3784                !m.test_files.contains(&test_path),
3785                "driver.rs should NOT be mapped (does not export Builder), but found: {:?}",
3786                m.test_files
3787            );
3788        }
3789        // If no mapping entry exists at all, that is also acceptable
3790    }
3791
3792    // -----------------------------------------------------------------------
3793    // RS-L2-EXPORT-FILTER-02: barrel with pub mod service, test imports
3794    // ServiceFn which service.rs DOES export -> service.rs IS mapped
3795    // (REGRESSION: should PASS with current implementation)
3796    // -----------------------------------------------------------------------
3797    #[test]
3798    fn rs_l2_export_filter_02_exports_symbol_is_mapped() {
3799        // Given: temp directory mimicking a crate with:
3800        //   src/app/mod.rs: pub mod service;
3801        //   src/app/service.rs: exports pub fn service_fn()
3802        //   tests/test_app.rs: use myapp::app::{service_fn}
3803        let tmp = tempfile::tempdir().unwrap();
3804        let src_app = tmp.path().join("src").join("app");
3805        let tests_dir = tmp.path().join("tests");
3806        std::fs::create_dir_all(&src_app).unwrap();
3807        std::fs::create_dir_all(&tests_dir).unwrap();
3808
3809        // Cargo.toml
3810        std::fs::write(
3811            tmp.path().join("Cargo.toml"),
3812            "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\n",
3813        )
3814        .unwrap();
3815
3816        // src/app/mod.rs - pub mod service
3817        let mod_rs = src_app.join("mod.rs");
3818        std::fs::write(&mod_rs, "pub mod service;\n").unwrap();
3819
3820        // src/app/service.rs - exports service_fn
3821        let service_rs = src_app.join("service.rs");
3822        std::fs::write(&service_rs, "pub fn service_fn() {}\n").unwrap();
3823
3824        // tests/test_app.rs - imports service_fn from app
3825        let test_rs = tests_dir.join("test_app.rs");
3826        let test_source = "use myapp::app::{service_fn};\n\n#[test]\nfn test_service() {}\n";
3827        std::fs::write(&test_rs, test_source).unwrap();
3828
3829        let extractor = RustExtractor::new();
3830        let service_path = service_rs.to_string_lossy().into_owned();
3831        let test_path = test_rs.to_string_lossy().into_owned();
3832        let production_files = vec![service_path.clone()];
3833        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
3834            .into_iter()
3835            .collect();
3836
3837        // When: map_test_files_with_imports is called
3838        let result = extractor.map_test_files_with_imports(
3839            &production_files,
3840            &test_sources,
3841            tmp.path(),
3842            false,
3843        );
3844
3845        // Then: service.rs IS mapped to test_app.rs
3846        // (service.rs exports service_fn which the test imports)
3847        let mapping = result.iter().find(|m| m.production_file == service_path);
3848        assert!(mapping.is_some(), "service.rs should have a mapping entry");
3849        assert!(
3850            mapping.unwrap().test_files.contains(&test_path),
3851            "service.rs should be mapped to test_app.rs, got: {:?}",
3852            mapping.unwrap().test_files
3853        );
3854    }
3855
3856    // -----------------------------------------------------------------------
3857    // RS-BARREL-CFG-01: cfg_feat! { pub mod sub; } -> extract_barrel_re_exports
3858    // -----------------------------------------------------------------------
3859    #[test]
3860    fn rs_barrel_cfg_macro_pub_mod() {
3861        // Given: barrel mod.rs with cfg_feat! { pub mod sub; }
3862        let source = r#"
3863cfg_feat! {
3864    pub mod sub;
3865}
3866"#;
3867
3868        // When: extract_barrel_re_exports is called
3869        let ext = RustExtractor::new();
3870        let result = ext.extract_barrel_re_exports(source, "src/mod.rs");
3871
3872        // Then: result contains BarrelReExport for "./sub" with wildcard=true
3873        assert!(
3874            !result.is_empty(),
3875            "Expected non-empty result, got: {:?}",
3876            result
3877        );
3878        assert!(
3879            result
3880                .iter()
3881                .any(|r| r.from_specifier == "./sub" && r.wildcard),
3882            "./sub with wildcard=true not found in {:?}",
3883            result
3884        );
3885    }
3886
3887    // -----------------------------------------------------------------------
3888    // RS-BARREL-CFG-02: cfg_feat! { pub use util::{Symbol}; } -> extract_barrel_re_exports
3889    // -----------------------------------------------------------------------
3890    #[test]
3891    fn rs_barrel_cfg_macro_pub_use_braces() {
3892        // Given: barrel mod.rs with cfg_feat! { pub use util::{Symbol}; }
3893        let source = r#"
3894cfg_feat! {
3895    pub use util::{Symbol};
3896}
3897"#;
3898
3899        // When: extract_barrel_re_exports is called
3900        let ext = RustExtractor::new();
3901        let result = ext.extract_barrel_re_exports(source, "src/mod.rs");
3902
3903        // Then: result contains BarrelReExport for "./util" with symbols=["Symbol"]
3904        assert!(
3905            !result.is_empty(),
3906            "Expected non-empty result, got: {:?}",
3907            result
3908        );
3909        assert!(
3910            result.iter().any(|r| r.from_specifier == "./util"
3911                && !r.wildcard
3912                && r.symbols.contains(&"Symbol".to_string())),
3913            "./util with symbols=[\"Symbol\"] not found in {:?}",
3914            result
3915        );
3916    }
3917
3918    // -----------------------------------------------------------------------
3919    // RS-BARREL-CFG-03: top-level pub mod foo; (no macro) regression
3920    // -----------------------------------------------------------------------
3921    #[test]
3922    fn rs_barrel_top_level_regression() {
3923        // Given: barrel mod.rs with top-level pub mod foo; (no macro wrapper)
3924        let source = "pub mod foo;\n";
3925
3926        // When: extract_barrel_re_exports is called
3927        let ext = RustExtractor::new();
3928        let result = ext.extract_barrel_re_exports(source, "src/mod.rs");
3929
3930        // Then: foo is detected (regression - top-level pub mod must still work)
3931        let entry = result.iter().find(|e| e.from_specifier == "./foo");
3932        assert!(
3933            entry.is_some(),
3934            "./foo not found in {:?} (regression: top-level pub mod broken)",
3935            result
3936        );
3937        assert!(entry.unwrap().wildcard);
3938    }
3939
3940    // -----------------------------------------------------------------------
3941    // RS-IMP-09: parse_use_path single-segment module import (use crate::fs)
3942    // -----------------------------------------------------------------------
3943    #[test]
3944    fn rs_imp_09_single_segment_module_import() {
3945        // Given: source with a single-segment module import after crate:: prefix
3946        let source = "use crate::fs;\n";
3947
3948        // When: extract_all_import_specifiers is called
3949        let extractor = RustExtractor::new();
3950        let result = extractor.extract_all_import_specifiers(source);
3951
3952        // Then: result contains ("fs", []) — single-segment module import with empty symbols
3953        let entry = result.iter().find(|(spec, _)| spec == "fs");
3954        assert!(
3955            entry.is_some(),
3956            "fs not found in {:?} (single-segment module import should be registered)",
3957            result
3958        );
3959        let (_, symbols) = entry.unwrap();
3960        assert!(
3961            symbols.is_empty(),
3962            "Expected empty symbols for module import, got: {:?}",
3963            symbols
3964        );
3965    }
3966
3967    // -----------------------------------------------------------------------
3968    // RS-IMP-10: parse_use_path single-segment with crate_name
3969    // -----------------------------------------------------------------------
3970    #[test]
3971    fn rs_imp_10_single_segment_with_crate_name() {
3972        // Given: source `use my_crate::util;\n`, crate_name = "my_crate"
3973        let source = "use my_crate::util;\n";
3974
3975        // When: extract_import_specifiers_with_crate_name called with crate_name = "my_crate"
3976        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
3977
3978        // Then: result contains ("util", []) — single-segment module import with empty symbols
3979        let entry = result.iter().find(|(spec, _)| spec == "util");
3980        assert!(
3981            entry.is_some(),
3982            "util not found in {:?} (single-segment with crate_name should be registered)",
3983            result
3984        );
3985        let (_, symbols) = entry.unwrap();
3986        assert!(
3987            symbols.is_empty(),
3988            "Expected empty symbols for module import, got: {:?}",
3989            symbols
3990        );
3991    }
3992
3993    // -----------------------------------------------------------------------
3994    // RS-BARREL-SELF-01: extract_barrel_re_exports strips self:: from wildcard
3995    // -----------------------------------------------------------------------
3996    #[test]
3997    fn rs_barrel_self_01_strips_self_from_wildcard() {
3998        // Given: barrel source with `pub use self::sub::*;`
3999        let source = "pub use self::sub::*;\n";
4000
4001        // When: extract_barrel_re_exports is called on mod.rs
4002        let extractor = RustExtractor::new();
4003        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
4004
4005        // Then: from_specifier = "./sub" (not "./self/sub"), wildcard = true
4006        let entry = result.iter().find(|e| e.from_specifier == "./sub");
4007        assert!(
4008            entry.is_some(),
4009            "./sub not found in {:?} (self:: prefix should be stripped from wildcard)",
4010            result
4011        );
4012        assert!(
4013            entry.unwrap().wildcard,
4014            "Expected wildcard=true for pub use self::sub::*"
4015        );
4016    }
4017
4018    // -----------------------------------------------------------------------
4019    // RS-BARREL-SELF-02: extract_barrel_re_exports strips self:: from symbol
4020    // -----------------------------------------------------------------------
4021    #[test]
4022    fn rs_barrel_self_02_strips_self_from_symbol() {
4023        // Given: barrel source with `pub use self::file::File;`
4024        let source = "pub use self::file::File;\n";
4025
4026        // When: extract_barrel_re_exports is called on mod.rs
4027        let extractor = RustExtractor::new();
4028        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
4029
4030        // Then: from_specifier = "./file" (not "./self/file"), symbols = ["File"]
4031        let entry = result.iter().find(|e| e.from_specifier == "./file");
4032        assert!(
4033            entry.is_some(),
4034            "./file not found in {:?} (self:: prefix should be stripped from symbol import)",
4035            result
4036        );
4037        let entry = entry.unwrap();
4038        assert!(
4039            entry.symbols.contains(&"File".to_string()),
4040            "Expected symbols=[\"File\"], got: {:?}",
4041            entry.symbols
4042        );
4043    }
4044
4045    // -----------------------------------------------------------------------
4046    // RS-BARREL-SELF-03: extract_barrel_re_exports strips self:: from use list
4047    // -----------------------------------------------------------------------
4048    #[test]
4049    fn rs_barrel_self_03_strips_self_from_use_list() {
4050        // Given: barrel source with `pub use self::sync::{Mutex, RwLock};`
4051        let source = "pub use self::sync::{Mutex, RwLock};\n";
4052
4053        // When: extract_barrel_re_exports is called on mod.rs
4054        let extractor = RustExtractor::new();
4055        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
4056
4057        // Then: from_specifier = "./sync" (not "./self/sync"), symbols contains "Mutex" and "RwLock"
4058        let entry = result.iter().find(|e| e.from_specifier == "./sync");
4059        assert!(
4060            entry.is_some(),
4061            "./sync not found in {:?} (self:: prefix should be stripped from use list)",
4062            result
4063        );
4064        let entry = entry.unwrap();
4065        assert!(
4066            entry.symbols.contains(&"Mutex".to_string()),
4067            "Expected Mutex in symbols, got: {:?}",
4068            entry.symbols
4069        );
4070        assert!(
4071            entry.symbols.contains(&"RwLock".to_string()),
4072            "Expected RwLock in symbols, got: {:?}",
4073            entry.symbols
4074        );
4075    }
4076
4077    // -----------------------------------------------------------------------
4078    // RS-BARREL-CFG-SELF-01: extract_re_exports_from_text strips self:: in cfg macro
4079    // -----------------------------------------------------------------------
4080    #[test]
4081    fn rs_barrel_cfg_self_01_strips_self_in_cfg_macro() {
4082        // Given: barrel source with cfg macro block containing `pub use self::inner::Symbol;`
4083        let source = "cfg_feat! { pub use self::inner::Symbol; }\n";
4084
4085        // When: extract_barrel_re_exports is called on mod.rs
4086        let extractor = RustExtractor::new();
4087        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
4088
4089        // Then: from_specifier = "./inner" (not "./self/inner"), symbols = ["Symbol"]
4090        let entry = result.iter().find(|e| e.from_specifier == "./inner");
4091        assert!(
4092            entry.is_some(),
4093            "./inner not found in {:?} (self:: prefix should be stripped in cfg macro text path)",
4094            result
4095        );
4096        let entry = entry.unwrap();
4097        assert!(
4098            entry.symbols.contains(&"Symbol".to_string()),
4099            "Expected symbols=[\"Symbol\"], got: {:?}",
4100            entry.symbols
4101        );
4102    }
4103
4104    // -----------------------------------------------------------------------
4105    // RS-L2-SELF-BARREL-E2E: L2 resolves through self:: barrel
4106    // -----------------------------------------------------------------------
4107    #[test]
4108    fn rs_l2_self_barrel_e2e_resolves_through_self_barrel() {
4109        // Given: Cargo project with:
4110        //   src/fs/mod.rs (barrel: `pub use self::file::File;`)
4111        //   src/fs/file.rs (exports `pub struct File;`)
4112        //   tests/test_fs.rs (imports `use my_crate::fs::File;`)
4113        let tmp = tempfile::tempdir().unwrap();
4114        let src_fs_dir = tmp.path().join("src").join("fs");
4115        let tests_dir = tmp.path().join("tests");
4116        std::fs::create_dir_all(&src_fs_dir).unwrap();
4117        std::fs::create_dir_all(&tests_dir).unwrap();
4118
4119        std::fs::write(
4120            tmp.path().join("Cargo.toml"),
4121            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
4122        )
4123        .unwrap();
4124
4125        let mod_rs = src_fs_dir.join("mod.rs");
4126        std::fs::write(&mod_rs, "pub use self::file::File;\n").unwrap();
4127
4128        let file_rs = src_fs_dir.join("file.rs");
4129        std::fs::write(&file_rs, "pub struct File;\n").unwrap();
4130
4131        let test_fs_rs = tests_dir.join("test_fs.rs");
4132        let test_source = "use my_crate::fs::File;\n\n#[test]\nfn test_fs() {}\n";
4133        std::fs::write(&test_fs_rs, test_source).unwrap();
4134
4135        let extractor = RustExtractor::new();
4136        let file_path = file_rs.to_string_lossy().into_owned();
4137        let test_path = test_fs_rs.to_string_lossy().into_owned();
4138        let production_files = vec![file_path.clone()];
4139        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
4140            .into_iter()
4141            .collect();
4142
4143        // When: map_test_files_with_imports is called
4144        let result = extractor.map_test_files_with_imports(
4145            &production_files,
4146            &test_sources,
4147            tmp.path(),
4148            false,
4149        );
4150
4151        // Then: src/fs/file.rs is mapped to test_fs.rs (L2 resolves through self:: barrel)
4152        let mapping = result.iter().find(|m| m.production_file == file_path);
4153        assert!(mapping.is_some(), "No mapping for src/fs/file.rs");
4154        let mapping = mapping.unwrap();
4155        assert!(
4156            mapping.test_files.contains(&test_path),
4157            "Expected test_fs.rs to map to file.rs through self:: barrel (L2), got: {:?}",
4158            mapping.test_files
4159        );
4160    }
4161
4162    // -----------------------------------------------------------------------
4163    // RS-L2-SINGLE-SEG-E2E: L2 resolves single-segment module import
4164    // -----------------------------------------------------------------------
4165    #[test]
4166    fn rs_l2_single_seg_e2e_resolves_single_segment_module() {
4167        // Given: Cargo project with:
4168        //   src/fs/mod.rs (barrel: `pub mod copy;`)
4169        //   src/fs/copy.rs (`pub fn copy_file() {}`)
4170        //   tests/test_fs.rs (imports `use my_crate::fs;`)
4171        let tmp = tempfile::tempdir().unwrap();
4172        let src_fs_dir = tmp.path().join("src").join("fs");
4173        let tests_dir = tmp.path().join("tests");
4174        std::fs::create_dir_all(&src_fs_dir).unwrap();
4175        std::fs::create_dir_all(&tests_dir).unwrap();
4176
4177        std::fs::write(
4178            tmp.path().join("Cargo.toml"),
4179            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
4180        )
4181        .unwrap();
4182
4183        let mod_rs = src_fs_dir.join("mod.rs");
4184        std::fs::write(&mod_rs, "pub mod copy;\n").unwrap();
4185
4186        let copy_rs = src_fs_dir.join("copy.rs");
4187        std::fs::write(&copy_rs, "pub fn copy_file() {}\n").unwrap();
4188
4189        let test_fs_rs = tests_dir.join("test_fs.rs");
4190        let test_source = "use my_crate::fs;\n\n#[test]\nfn test_fs() {}\n";
4191        std::fs::write(&test_fs_rs, test_source).unwrap();
4192
4193        let extractor = RustExtractor::new();
4194        let mod_path = mod_rs.to_string_lossy().into_owned();
4195        let copy_path = copy_rs.to_string_lossy().into_owned();
4196        let test_path = test_fs_rs.to_string_lossy().into_owned();
4197        let production_files = vec![mod_path.clone(), copy_path.clone()];
4198        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
4199            .into_iter()
4200            .collect();
4201
4202        // When: map_test_files_with_imports is called
4203        let result = extractor.map_test_files_with_imports(
4204            &production_files,
4205            &test_sources,
4206            tmp.path(),
4207            false,
4208        );
4209
4210        // Then: src/fs/mod.rs or sub-modules (copy.rs) is mapped to test_fs.rs
4211        // (single-segment import `use my_crate::fs` should resolve to the fs module)
4212        let mod_mapping = result.iter().find(|m| m.production_file == mod_path);
4213        let copy_mapping = result.iter().find(|m| m.production_file == copy_path);
4214        let mod_mapped = mod_mapping
4215            .map(|m| m.test_files.contains(&test_path))
4216            .unwrap_or(false);
4217        let copy_mapped = copy_mapping
4218            .map(|m| m.test_files.contains(&test_path))
4219            .unwrap_or(false);
4220        assert!(
4221            mod_mapped || copy_mapped,
4222            "Expected test_fs.rs to map to src/fs/mod.rs or src/fs/copy.rs via single-segment L2, \
4223             mod_mapping: {:?}, copy_mapping: {:?}",
4224            mod_mapping.map(|m| &m.test_files),
4225            copy_mapping.map(|m| &m.test_files)
4226        );
4227    }
4228
4229    // -----------------------------------------------------------------------
4230    // RS-EXPORT-CFG-01: file_exports_any_symbol finds pub struct inside cfg macro
4231    // -----------------------------------------------------------------------
4232    #[test]
4233    fn rs_export_cfg_01_finds_pub_struct_inside_cfg_macro() {
4234        use std::io::Write;
4235
4236        // Given: temp file with source `cfg_net! { pub struct TcpListener { field: u32 } }`
4237        let mut tmp = tempfile::NamedTempFile::new().unwrap();
4238        write!(
4239            tmp,
4240            "cfg_net! {{ pub struct TcpListener {{ field: u32 }} }}\n"
4241        )
4242        .unwrap();
4243        let path = tmp.path();
4244
4245        // When: file_exports_any_symbol(path, ["TcpListener"]) called
4246        let extractor = RustExtractor::new();
4247        let symbols = vec!["TcpListener".to_string()];
4248        let result = extractor.file_exports_any_symbol(path, &symbols);
4249
4250        // Then: returns true
4251        assert!(
4252            result,
4253            "Expected file_exports_any_symbol to return true for pub struct inside cfg macro, got false"
4254        );
4255    }
4256
4257    // -----------------------------------------------------------------------
4258    // RS-EXPORT-CFG-02: file_exports_any_symbol returns false for non-exported symbol
4259    // -----------------------------------------------------------------------
4260    #[test]
4261    fn rs_export_cfg_02_returns_false_for_missing_symbol() {
4262        use std::io::Write;
4263
4264        // Given: temp file with source `cfg_net! { pub struct TcpListener { field: u32 } }`
4265        let mut tmp = tempfile::NamedTempFile::new().unwrap();
4266        write!(
4267            tmp,
4268            "cfg_net! {{ pub struct TcpListener {{ field: u32 }} }}\n"
4269        )
4270        .unwrap();
4271        let path = tmp.path();
4272
4273        // When: file_exports_any_symbol(path, ["NotHere"]) called
4274        let extractor = RustExtractor::new();
4275        let symbols = vec!["NotHere".to_string()];
4276        let result = extractor.file_exports_any_symbol(path, &symbols);
4277
4278        // Then: returns false
4279        assert!(
4280            !result,
4281            "Expected file_exports_any_symbol to return false for symbol not in file, got true"
4282        );
4283    }
4284
4285    // -----------------------------------------------------------------------
4286    // RS-EXPORT-CFG-03: file_exports_any_symbol does not match pub(crate)
4287    // -----------------------------------------------------------------------
4288    #[test]
4289    fn rs_export_cfg_03_does_not_match_pub_crate() {
4290        use std::io::Write;
4291
4292        // Given: temp file with source `cfg_net! { pub(crate) struct Internal { field: u32 } }`
4293        let mut tmp = tempfile::NamedTempFile::new().unwrap();
4294        write!(
4295            tmp,
4296            "cfg_net! {{ pub(crate) struct Internal {{ field: u32 }} }}\n"
4297        )
4298        .unwrap();
4299        let path = tmp.path();
4300
4301        // When: file_exports_any_symbol(path, ["Internal"]) called
4302        let extractor = RustExtractor::new();
4303        let symbols = vec!["Internal".to_string()];
4304        let result = extractor.file_exports_any_symbol(path, &symbols);
4305
4306        // Then: returns false (pub(crate) is not a public export)
4307        assert!(
4308            !result,
4309            "Expected file_exports_any_symbol to return false for pub(crate) struct, got true"
4310        );
4311    }
4312
4313    // -----------------------------------------------------------------------
4314    // RS-MULTILINE-USE-01: join_multiline_pub_use joins multi-line pub use
4315    // -----------------------------------------------------------------------
4316    #[test]
4317    fn rs_multiline_use_01_joins_multiline_pub_use() {
4318        // Given: multi-line pub use block
4319        let text = "    pub use util::{\n        AsyncReadExt,\n        AsyncWriteExt,\n    };\n";
4320
4321        // When: join_multiline_pub_use called
4322        let result = join_multiline_pub_use(text);
4323
4324        // Then: result contains "pub use util::{" and "AsyncReadExt" and "}" on one line
4325        assert!(
4326            result.contains("pub use util::{"),
4327            "Expected result to contain 'pub use util::{{', got: {:?}",
4328            result
4329        );
4330        assert!(
4331            result.contains("AsyncReadExt"),
4332            "Expected result to contain 'AsyncReadExt', got: {:?}",
4333            result
4334        );
4335        assert!(
4336            result.contains('}'),
4337            "Expected result to contain '}}', got: {:?}",
4338            result
4339        );
4340        // The joined form should appear on a single line (no newline within the pub use statement)
4341        let joined_line = result.lines().find(|l| l.contains("pub use util::"));
4342        assert!(
4343            joined_line.is_some(),
4344            "Expected a single line containing 'pub use util::', got: {:?}",
4345            result
4346        );
4347        let joined_line = joined_line.unwrap();
4348        assert!(
4349            joined_line.contains("AsyncReadExt") && joined_line.contains('}'),
4350            "Expected joined line to contain both 'AsyncReadExt' and '}}', got: {:?}",
4351            joined_line
4352        );
4353    }
4354
4355    // -----------------------------------------------------------------------
4356    // RS-MULTILINE-USE-02: extract_re_exports_from_text parses joined multi-line pub use
4357    // -----------------------------------------------------------------------
4358    #[test]
4359    fn rs_multiline_use_02_extract_re_exports_parses_multiline_cfg_pub_use() {
4360        // Given: barrel source with multi-line pub use inside cfg macro
4361        let source =
4362            "cfg_io! {\n    pub use util::{\n        Copy,\n        AsyncReadExt,\n    };\n}\n";
4363
4364        // When: extract_barrel_re_exports called on mod.rs
4365        let extractor = RustExtractor::new();
4366        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
4367
4368        // Then: result has entry with from_specifier="./util", symbols contains "Copy" and "AsyncReadExt"
4369        let entry = result.iter().find(|e| e.from_specifier == "./util");
4370        assert!(
4371            entry.is_some(),
4372            "Expected entry with from_specifier='./util', got: {:?}",
4373            result
4374        );
4375        let entry = entry.unwrap();
4376        assert!(
4377            entry.symbols.contains(&"Copy".to_string()),
4378            "Expected 'Copy' in symbols, got: {:?}",
4379            entry.symbols
4380        );
4381        assert!(
4382            entry.symbols.contains(&"AsyncReadExt".to_string()),
4383            "Expected 'AsyncReadExt' in symbols, got: {:?}",
4384            entry.symbols
4385        );
4386    }
4387
4388    // -----------------------------------------------------------------------
4389    // RS-L2-CFG-EXPORT-E2E: L2 resolves through cfg-wrapped production file
4390    // -----------------------------------------------------------------------
4391    #[test]
4392    fn rs_l2_cfg_export_e2e_resolves_through_cfg_wrapped_production_file() {
4393        // Given: Cargo project with:
4394        //   src/net/mod.rs: `cfg_net! { pub mod tcp; pub use tcp::listener::TcpListener; }`
4395        //   src/net/tcp/mod.rs: `pub mod listener;`
4396        //   src/net/tcp/listener.rs: `cfg_net! { pub struct TcpListener; }`
4397        //   tests/test_net.rs: `use my_crate::net::TcpListener;`
4398        let tmp = tempfile::tempdir().unwrap();
4399        let src_net_dir = tmp.path().join("src").join("net");
4400        let src_tcp_dir = src_net_dir.join("tcp");
4401        let src_listener_dir = src_tcp_dir.join("listener");
4402        let tests_dir = tmp.path().join("tests");
4403        std::fs::create_dir_all(&src_net_dir).unwrap();
4404        std::fs::create_dir_all(&src_tcp_dir).unwrap();
4405        std::fs::create_dir_all(&src_listener_dir).unwrap();
4406        std::fs::create_dir_all(&tests_dir).unwrap();
4407
4408        std::fs::write(
4409            tmp.path().join("Cargo.toml"),
4410            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
4411        )
4412        .unwrap();
4413
4414        let net_mod_rs = src_net_dir.join("mod.rs");
4415        std::fs::write(
4416            &net_mod_rs,
4417            "cfg_net! { pub mod tcp; pub use tcp::listener::TcpListener; }\n",
4418        )
4419        .unwrap();
4420
4421        let tcp_mod_rs = src_tcp_dir.join("mod.rs");
4422        std::fs::write(&tcp_mod_rs, "pub mod listener;\n").unwrap();
4423
4424        let listener_rs = src_tcp_dir.join("listener.rs");
4425        std::fs::write(&listener_rs, "cfg_net! { pub struct TcpListener; }\n").unwrap();
4426
4427        let test_net_rs = tests_dir.join("test_net.rs");
4428        let test_source = "use my_crate::net::TcpListener;\n\n#[test]\nfn test_net() {}\n";
4429        std::fs::write(&test_net_rs, test_source).unwrap();
4430
4431        let extractor = RustExtractor::new();
4432        let listener_path = listener_rs.to_string_lossy().into_owned();
4433        let test_path = test_net_rs.to_string_lossy().into_owned();
4434        let production_files = vec![
4435            net_mod_rs.to_string_lossy().into_owned(),
4436            tcp_mod_rs.to_string_lossy().into_owned(),
4437            listener_path.clone(),
4438        ];
4439        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
4440            .into_iter()
4441            .collect();
4442
4443        // When: map_test_files_with_imports called
4444        let result = extractor.map_test_files_with_imports(
4445            &production_files,
4446            &test_sources,
4447            tmp.path(),
4448            false,
4449        );
4450
4451        // Then: src/net/tcp/listener.rs is mapped to test_net.rs
4452        let mapping = result.iter().find(|m| m.production_file == listener_path);
4453        assert!(
4454            mapping.is_some(),
4455            "No mapping found for src/net/tcp/listener.rs"
4456        );
4457        let mapping = mapping.unwrap();
4458        assert!(
4459            mapping.test_files.contains(&test_path),
4460            "Expected test_net.rs to map to listener.rs through cfg-wrapped barrel (L2), got: {:?}",
4461            mapping.test_files
4462        );
4463    }
4464
4465    // -----------------------------------------------------------------------
4466    // RS-L2-CFG-MULTILINE-E2E: L2 resolves through multi-line cfg pub use
4467    // -----------------------------------------------------------------------
4468    #[test]
4469    fn rs_l2_cfg_multiline_e2e_resolves_through_multiline_cfg_pub_use() {
4470        // Given: Cargo project with:
4471        //   src/io/mod.rs: `cfg_io! {\n    pub use util::{\n        AsyncReadExt,\n        Copy,\n    };\n}`
4472        //   src/io/util.rs: `pub trait AsyncReadExt {} pub fn Copy() {}`
4473        //   tests/test_io.rs: `use my_crate::io::AsyncReadExt;`
4474        let tmp = tempfile::tempdir().unwrap();
4475        let src_io_dir = tmp.path().join("src").join("io");
4476        let tests_dir = tmp.path().join("tests");
4477        std::fs::create_dir_all(&src_io_dir).unwrap();
4478        std::fs::create_dir_all(&tests_dir).unwrap();
4479
4480        std::fs::write(
4481            tmp.path().join("Cargo.toml"),
4482            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
4483        )
4484        .unwrap();
4485
4486        let io_mod_rs = src_io_dir.join("mod.rs");
4487        std::fs::write(
4488            &io_mod_rs,
4489            "cfg_io! {\n    pub use util::{\n        AsyncReadExt,\n        Copy,\n    };\n}\n",
4490        )
4491        .unwrap();
4492
4493        let util_rs = src_io_dir.join("util.rs");
4494        std::fs::write(&util_rs, "pub trait AsyncReadExt {}\npub fn Copy() {}\n").unwrap();
4495
4496        let test_io_rs = tests_dir.join("test_io.rs");
4497        let test_source = "use my_crate::io::AsyncReadExt;\n\n#[test]\nfn test_io() {}\n";
4498        std::fs::write(&test_io_rs, test_source).unwrap();
4499
4500        let extractor = RustExtractor::new();
4501        let util_path = util_rs.to_string_lossy().into_owned();
4502        let test_path = test_io_rs.to_string_lossy().into_owned();
4503        let production_files = vec![io_mod_rs.to_string_lossy().into_owned(), util_path.clone()];
4504        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
4505            .into_iter()
4506            .collect();
4507
4508        // When: map_test_files_with_imports called
4509        let result = extractor.map_test_files_with_imports(
4510            &production_files,
4511            &test_sources,
4512            tmp.path(),
4513            false,
4514        );
4515
4516        // Then: src/io/util.rs mapped to test_io.rs
4517        let mapping = result.iter().find(|m| m.production_file == util_path);
4518        assert!(mapping.is_some(), "No mapping found for src/io/util.rs");
4519        let mapping = mapping.unwrap();
4520        assert!(
4521            mapping.test_files.contains(&test_path),
4522            "Expected test_io.rs to map to util.rs through multi-line cfg pub use (L2), got: {:?}",
4523            mapping.test_files
4524        );
4525    }
4526
4527    // -----------------------------------------------------------------------
4528    // US-01: underscore_path_sync_broadcast
4529    // -----------------------------------------------------------------------
4530    #[test]
4531    fn us_01_underscore_path_sync_broadcast() {
4532        // Given: test "tests/sync_broadcast.rs" and prod "src/sync/broadcast.rs"
4533        let extractor = RustExtractor::new();
4534        let production_files = vec!["src/sync/broadcast.rs".to_string()];
4535        let test_sources: HashMap<String, String> =
4536            [("tests/sync_broadcast.rs".to_string(), String::new())]
4537                .into_iter()
4538                .collect();
4539        let scan_root = PathBuf::from(".");
4540
4541        // When: map_test_files_with_imports (L1.5 matching)
4542        let result = extractor.map_test_files_with_imports(
4543            &production_files,
4544            &test_sources,
4545            &scan_root,
4546            false,
4547        );
4548
4549        // Then: test maps to prod via L1.5 (stem "sync_broadcast" -> prefix "sync", suffix "broadcast")
4550        let mapping = result
4551            .iter()
4552            .find(|m| m.production_file == "src/sync/broadcast.rs");
4553        assert!(mapping.is_some(), "No mapping for src/sync/broadcast.rs");
4554        assert!(
4555            mapping
4556                .unwrap()
4557                .test_files
4558                .contains(&"tests/sync_broadcast.rs".to_string()),
4559            "Expected tests/sync_broadcast.rs to map to src/sync/broadcast.rs via L1.5, got: {:?}",
4560            mapping.unwrap().test_files
4561        );
4562    }
4563
4564    // -----------------------------------------------------------------------
4565    // US-02: underscore_path_sync_oneshot
4566    // -----------------------------------------------------------------------
4567    #[test]
4568    fn us_02_underscore_path_sync_oneshot() {
4569        // Given: test "tests/sync_oneshot.rs" and prod "src/sync/oneshot.rs"
4570        let extractor = RustExtractor::new();
4571        let production_files = vec!["src/sync/oneshot.rs".to_string()];
4572        let test_sources: HashMap<String, String> =
4573            [("tests/sync_oneshot.rs".to_string(), String::new())]
4574                .into_iter()
4575                .collect();
4576        let scan_root = PathBuf::from(".");
4577
4578        // When: map_test_files_with_imports (L1.5 matching)
4579        let result = extractor.map_test_files_with_imports(
4580            &production_files,
4581            &test_sources,
4582            &scan_root,
4583            false,
4584        );
4585
4586        // Then: test maps to prod via L1.5
4587        let mapping = result
4588            .iter()
4589            .find(|m| m.production_file == "src/sync/oneshot.rs");
4590        assert!(mapping.is_some(), "No mapping for src/sync/oneshot.rs");
4591        assert!(
4592            mapping
4593                .unwrap()
4594                .test_files
4595                .contains(&"tests/sync_oneshot.rs".to_string()),
4596            "Expected tests/sync_oneshot.rs to map to src/sync/oneshot.rs via L1.5, got: {:?}",
4597            mapping.unwrap().test_files
4598        );
4599    }
4600
4601    // -----------------------------------------------------------------------
4602    // US-03: underscore_path_task_blocking
4603    // -----------------------------------------------------------------------
4604    #[test]
4605    fn us_03_underscore_path_task_blocking() {
4606        // Given: test "tests/task_blocking.rs" and prod "src/task/blocking.rs"
4607        let extractor = RustExtractor::new();
4608        let production_files = vec!["src/task/blocking.rs".to_string()];
4609        let test_sources: HashMap<String, String> =
4610            [("tests/task_blocking.rs".to_string(), String::new())]
4611                .into_iter()
4612                .collect();
4613        let scan_root = PathBuf::from(".");
4614
4615        // When: map_test_files_with_imports (L1.5 matching)
4616        let result = extractor.map_test_files_with_imports(
4617            &production_files,
4618            &test_sources,
4619            &scan_root,
4620            false,
4621        );
4622
4623        // Then: test maps to prod via L1.5
4624        let mapping = result
4625            .iter()
4626            .find(|m| m.production_file == "src/task/blocking.rs");
4627        assert!(mapping.is_some(), "No mapping for src/task/blocking.rs");
4628        assert!(
4629            mapping
4630                .unwrap()
4631                .test_files
4632                .contains(&"tests/task_blocking.rs".to_string()),
4633            "Expected tests/task_blocking.rs to map to src/task/blocking.rs via L1.5, got: {:?}",
4634            mapping.unwrap().test_files
4635        );
4636    }
4637
4638    // -----------------------------------------------------------------------
4639    // US-04: underscore_path_macros_select
4640    // -----------------------------------------------------------------------
4641    #[test]
4642    fn us_04_underscore_path_macros_select() {
4643        // Given: test "tests/macros_select.rs" and prod "src/macros/select.rs"
4644        let extractor = RustExtractor::new();
4645        let production_files = vec!["src/macros/select.rs".to_string()];
4646        let test_sources: HashMap<String, String> =
4647            [("tests/macros_select.rs".to_string(), String::new())]
4648                .into_iter()
4649                .collect();
4650        let scan_root = PathBuf::from(".");
4651
4652        // When: map_test_files_with_imports (L1.5 matching)
4653        let result = extractor.map_test_files_with_imports(
4654            &production_files,
4655            &test_sources,
4656            &scan_root,
4657            false,
4658        );
4659
4660        // Then: test maps to prod via L1.5
4661        let mapping = result
4662            .iter()
4663            .find(|m| m.production_file == "src/macros/select.rs");
4664        assert!(mapping.is_some(), "No mapping for src/macros/select.rs");
4665        assert!(
4666            mapping
4667                .unwrap()
4668                .test_files
4669                .contains(&"tests/macros_select.rs".to_string()),
4670            "Expected tests/macros_select.rs to map to src/macros/select.rs via L1.5, got: {:?}",
4671            mapping.unwrap().test_files
4672        );
4673    }
4674
4675    // -----------------------------------------------------------------------
4676    // US-05: underscore_path_no_underscore_unchanged
4677    // -----------------------------------------------------------------------
4678    #[test]
4679    fn us_05_underscore_path_no_underscore_unchanged() {
4680        // Given: test "tests/abc.rs" (no underscore in stem) and prod "src/abc.rs"
4681        // L1 normally matches same-directory only. Cross-dir match falls to L2.
4682        // L1.5 should not apply here (no underscore to split on).
4683        let extractor = RustExtractor::new();
4684        let production_files = vec!["src/abc.rs".to_string()];
4685        let test_sources: HashMap<String, String> = [("tests/abc.rs".to_string(), String::new())]
4686            .into_iter()
4687            .collect();
4688        let scan_root = PathBuf::from(".");
4689
4690        // When: map_test_files_with_imports
4691        let result = extractor.map_test_files_with_imports(
4692            &production_files,
4693            &test_sources,
4694            &scan_root,
4695            false,
4696        );
4697
4698        // Then: mapping structure exists (L1.5 does not break normal behavior)
4699        // "abc.rs" test_stem = "abc", but different dir so L1 won't match.
4700        // L1.5 has no underscore -> no additional match. Result is no test_files for src/abc.rs.
4701        let mapping = result.iter().find(|m| m.production_file == "src/abc.rs");
4702        assert!(mapping.is_some(), "No mapping entry for src/abc.rs");
4703        // L1.5 must not create a spurious match for a stem with no underscore
4704        assert!(
4705            !mapping
4706                .unwrap()
4707                .test_files
4708                .contains(&"tests/abc.rs".to_string()),
4709            "L1.5 must not match tests/abc.rs -> src/abc.rs (different dirs, no underscore): {:?}",
4710            mapping.unwrap().test_files
4711        );
4712    }
4713
4714    // -----------------------------------------------------------------------
4715    // US-06: underscore_path_wrong_dir_no_match
4716    // -----------------------------------------------------------------------
4717    #[test]
4718    fn us_06_underscore_path_wrong_dir_no_match() {
4719        // Given: test "tests/sync_broadcast.rs" and prod "src/runtime/broadcast.rs" (wrong dir)
4720        let extractor = RustExtractor::new();
4721        let production_files = vec!["src/runtime/broadcast.rs".to_string()];
4722        let test_sources: HashMap<String, String> =
4723            [("tests/sync_broadcast.rs".to_string(), String::new())]
4724                .into_iter()
4725                .collect();
4726        let scan_root = PathBuf::from(".");
4727
4728        // When: map_test_files_with_imports (L1.5)
4729        let result = extractor.map_test_files_with_imports(
4730            &production_files,
4731            &test_sources,
4732            &scan_root,
4733            false,
4734        );
4735
4736        // Then: NO match (prefix "sync" is not present in "src/runtime/broadcast.rs")
4737        let mapping = result
4738            .iter()
4739            .find(|m| m.production_file == "src/runtime/broadcast.rs");
4740        assert!(
4741            mapping.is_some(),
4742            "No mapping entry for src/runtime/broadcast.rs"
4743        );
4744        assert!(
4745            !mapping.unwrap().test_files.contains(&"tests/sync_broadcast.rs".to_string()),
4746            "L1.5 must NOT match tests/sync_broadcast.rs -> src/runtime/broadcast.rs (wrong dir), got: {:?}",
4747            mapping.unwrap().test_files
4748        );
4749    }
4750
4751    // -----------------------------------------------------------------------
4752    // US-07: underscore_path_short_suffix_no_match
4753    // -----------------------------------------------------------------------
4754    #[test]
4755    fn us_07_underscore_path_short_suffix_no_match() {
4756        // Given: test "tests/a_b.rs" (suffix "b" is 1 char) and prod "src/a/b.rs"
4757        let extractor = RustExtractor::new();
4758        let production_files = vec!["src/a/b.rs".to_string()];
4759        let test_sources: HashMap<String, String> = [("tests/a_b.rs".to_string(), String::new())]
4760            .into_iter()
4761            .collect();
4762        let scan_root = PathBuf::from(".");
4763
4764        // When: map_test_files_with_imports (L1.5)
4765        let result = extractor.map_test_files_with_imports(
4766            &production_files,
4767            &test_sources,
4768            &scan_root,
4769            false,
4770        );
4771
4772        // Then: NO match (suffix "b" is 1 char <= 2 chars guard)
4773        let mapping = result.iter().find(|m| m.production_file == "src/a/b.rs");
4774        assert!(mapping.is_some(), "No mapping entry for src/a/b.rs");
4775        assert!(
4776            !mapping
4777                .unwrap()
4778                .test_files
4779                .contains(&"tests/a_b.rs".to_string()),
4780            "L1.5 must NOT match tests/a_b.rs -> src/a/b.rs (short suffix guard), got: {:?}",
4781            mapping.unwrap().test_files
4782        );
4783    }
4784
4785    // -----------------------------------------------------------------------
4786    // US-08: underscore_path_already_l1_matched_skipped
4787    // -----------------------------------------------------------------------
4788    #[test]
4789    fn us_08_underscore_path_already_l1_matched_skipped() {
4790        // Given: test "tests/broadcast.rs" and prod "src/broadcast.rs" (L1 exact match)
4791        //        AND test "tests/sync_broadcast.rs" (underscore) and prod "src/sync/broadcast.rs"
4792        let extractor = RustExtractor::new();
4793        let production_files = vec![
4794            "src/broadcast.rs".to_string(),
4795            "src/sync/broadcast.rs".to_string(),
4796        ];
4797        let test_sources: HashMap<String, String> = [
4798            ("tests/broadcast.rs".to_string(), String::new()),
4799            ("tests/sync_broadcast.rs".to_string(), String::new()),
4800        ]
4801        .into_iter()
4802        .collect();
4803        let scan_root = PathBuf::from(".");
4804
4805        // When: map_test_files_with_imports
4806        let result = extractor.map_test_files_with_imports(
4807            &production_files,
4808            &test_sources,
4809            &scan_root,
4810            false,
4811        );
4812
4813        // Then: "tests/broadcast.rs" is matched by L1 to "src/broadcast.rs"
4814        // Note: L1 requires same directory; tests/ and src/ differ.
4815        // "tests/sync_broadcast.rs" should be matched by L1.5 to "src/sync/broadcast.rs"
4816        let sync_mapping = result
4817            .iter()
4818            .find(|m| m.production_file == "src/sync/broadcast.rs");
4819        assert!(
4820            sync_mapping.is_some(),
4821            "No mapping entry for src/sync/broadcast.rs"
4822        );
4823        assert!(
4824            sync_mapping
4825                .unwrap()
4826                .test_files
4827                .contains(&"tests/sync_broadcast.rs".to_string()),
4828            "Expected tests/sync_broadcast.rs to map to src/sync/broadcast.rs via L1.5, got: {:?}",
4829            sync_mapping.unwrap().test_files
4830        );
4831    }
4832
4833    // -----------------------------------------------------------------------
4834    // XC-01: cross_crate_extract_root_crate_name
4835    // -----------------------------------------------------------------------
4836    #[test]
4837    fn xc_01_cross_crate_extract_root_crate_name() {
4838        // Given: source with `use clap::builder::Arg` and crate_names=["clap", "clap_builder"]
4839        let source = "use clap::builder::Arg;\n";
4840        let crate_names = ["clap", "clap_builder"];
4841
4842        // When: extract_import_specifiers_with_crate_names
4843        let result = extract_import_specifiers_with_crate_names(source, &crate_names);
4844
4845        // Then: returns entry with matched_crate_name="clap", specifier="builder", symbols=["Arg"]
4846        assert!(
4847            !result.is_empty(),
4848            "Expected at least one import entry, got empty"
4849        );
4850        let entry = result
4851            .iter()
4852            .find(|(crate_n, spec, _)| crate_n == "clap" && spec == "builder");
4853        assert!(
4854            entry.is_some(),
4855            "Expected entry (clap, builder, [Arg]), got: {:?}",
4856            result
4857        );
4858        let (_, _, symbols) = entry.unwrap();
4859        assert!(
4860            symbols.contains(&"Arg".to_string()),
4861            "Expected symbols to contain 'Arg', got: {:?}",
4862            symbols
4863        );
4864    }
4865
4866    // -----------------------------------------------------------------------
4867    // XC-02: cross_crate_extract_member_crate_name
4868    // -----------------------------------------------------------------------
4869    #[test]
4870    fn xc_02_cross_crate_extract_member_crate_name() {
4871        // Given: source with `use clap_builder::error::ErrorKind`
4872        // and crate_names=["clap", "clap_builder"]
4873        let source = "use clap_builder::error::ErrorKind;\n";
4874        let crate_names = ["clap", "clap_builder"];
4875
4876        // When: extract_import_specifiers_with_crate_names
4877        let result = extract_import_specifiers_with_crate_names(source, &crate_names);
4878
4879        // Then: returns entry with matched_crate_name="clap_builder", specifier="error",
4880        //       symbols=["ErrorKind"]
4881        assert!(
4882            !result.is_empty(),
4883            "Expected at least one import entry, got empty"
4884        );
4885        let entry = result
4886            .iter()
4887            .find(|(crate_n, spec, _)| crate_n == "clap_builder" && spec == "error");
4888        assert!(
4889            entry.is_some(),
4890            "Expected entry (clap_builder, error, [ErrorKind]), got: {:?}",
4891            result
4892        );
4893        let (_, _, symbols) = entry.unwrap();
4894        assert!(
4895            symbols.contains(&"ErrorKind".to_string()),
4896            "Expected symbols to contain 'ErrorKind', got: {:?}",
4897            symbols
4898        );
4899    }
4900
4901    // -----------------------------------------------------------------------
4902    // XC-03: cross_crate_skips_external
4903    // -----------------------------------------------------------------------
4904    #[test]
4905    fn xc_03_cross_crate_skips_external() {
4906        // Given: source with `use std::collections::HashMap` and crate_names=["clap"]
4907        let source = "use std::collections::HashMap;\n";
4908        let crate_names = ["clap"];
4909
4910        // When: extract_import_specifiers_with_crate_names
4911        let result = extract_import_specifiers_with_crate_names(source, &crate_names);
4912
4913        // Then: empty (std is not in crate_names)
4914        assert!(
4915            result.is_empty(),
4916            "Expected empty result for std:: import not in crate_names, got: {:?}",
4917            result
4918        );
4919    }
4920
4921    // -----------------------------------------------------------------------
4922    // XC-04: cross_crate_crate_prefix_still_works
4923    // -----------------------------------------------------------------------
4924    #[test]
4925    fn xc_04_cross_crate_crate_prefix_still_works() {
4926        // Given: source with `use crate::utils` and crate_names=["clap"]
4927        let source = "use crate::utils;\n";
4928        let crate_names = ["clap"];
4929
4930        // When: extract_import_specifiers_with_crate_names
4931        let result = extract_import_specifiers_with_crate_names(source, &crate_names);
4932
4933        // Then: returns entry with matched_crate_name="crate", specifier="utils", symbols=[]
4934        // (existing behavior for `use crate::` is preserved)
4935        assert!(
4936            !result.is_empty(),
4937            "Expected at least one import entry for `use crate::utils`, got empty"
4938        );
4939        let entry = result
4940            .iter()
4941            .find(|(crate_n, spec, _)| crate_n == "crate" && spec == "utils");
4942        assert!(
4943            entry.is_some(),
4944            "Expected entry (crate, utils, []), got: {:?}",
4945            result
4946        );
4947    }
4948
4949    // -----------------------------------------------------------------------
4950    // XC-05: cross_crate_root_test_maps_to_root_src
4951    // -----------------------------------------------------------------------
4952    #[test]
4953    fn xc_05_cross_crate_root_test_maps_to_root_src() {
4954        // Given: mini workspace:
4955        //   Cargo.toml (workspace root + [package] name="my_crate")
4956        //   src/builder.rs
4957        //   tests/test_builder.rs with `use my_crate::builder::Command;`
4958        let tmp = tempfile::tempdir().unwrap();
4959        let src_dir = tmp.path().join("src");
4960        let tests_dir = tmp.path().join("tests");
4961        std::fs::create_dir_all(&src_dir).unwrap();
4962        std::fs::create_dir_all(&tests_dir).unwrap();
4963
4964        std::fs::write(
4965            tmp.path().join("Cargo.toml"),
4966            "[package]\nname = \"my_crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
4967        )
4968        .unwrap();
4969
4970        let builder_rs = src_dir.join("builder.rs");
4971        std::fs::write(&builder_rs, "pub struct Command;\n").unwrap();
4972
4973        let test_builder_rs = tests_dir.join("test_builder.rs");
4974        let test_source = "use my_crate::builder::Command;\n\n#[test]\nfn test_builder() {}\n";
4975        std::fs::write(&test_builder_rs, test_source).unwrap();
4976
4977        let extractor = RustExtractor::new();
4978        let prod_path = builder_rs.to_string_lossy().into_owned();
4979        let test_path = test_builder_rs.to_string_lossy().into_owned();
4980        let production_files = vec![prod_path.clone()];
4981        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
4982            .into_iter()
4983            .collect();
4984
4985        // When: map_test_files_with_imports (cross-crate L2 resolution)
4986        let result = extractor.map_test_files_with_imports(
4987            &production_files,
4988            &test_sources,
4989            tmp.path(),
4990            false,
4991        );
4992
4993        // Then: test_builder.rs maps to src/builder.rs via L2 cross-crate
4994        let mapping = result.iter().find(|m| m.production_file == prod_path);
4995        assert!(mapping.is_some(), "No mapping found for src/builder.rs");
4996        assert!(
4997            mapping.unwrap().test_files.contains(&test_path),
4998            "Expected test_builder.rs to map to builder.rs via cross-crate L2, got: {:?}",
4999            mapping.unwrap().test_files
5000        );
5001    }
5002
5003    // -----------------------------------------------------------------------
5004    // XC-06: cross_crate_root_test_maps_to_member
5005    // -----------------------------------------------------------------------
5006    #[test]
5007    fn xc_06_cross_crate_root_test_maps_to_member() {
5008        // Given: mini workspace:
5009        //   Cargo.toml (workspace root, no [package])
5010        //   member_a/Cargo.toml ([package] name="member_a")
5011        //   member_a/src/builder.rs: pub struct Cmd;
5012        //   tests/test_builder.rs with `use member_a::builder::Cmd;`
5013        //   (tests/ is owned by root, not member_a)
5014        let tmp = tempfile::tempdir().unwrap();
5015        let member_dir = tmp.path().join("member_a");
5016        let member_src = member_dir.join("src");
5017        let tests_dir = tmp.path().join("tests");
5018        std::fs::create_dir_all(&member_src).unwrap();
5019        std::fs::create_dir_all(&tests_dir).unwrap();
5020
5021        // Workspace root Cargo.toml (no [package], only [workspace])
5022        std::fs::write(
5023            tmp.path().join("Cargo.toml"),
5024            "[workspace]\nmembers = [\"member_a\"]\n",
5025        )
5026        .unwrap();
5027
5028        // Member Cargo.toml
5029        std::fs::write(
5030            member_dir.join("Cargo.toml"),
5031            "[package]\nname = \"member_a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
5032        )
5033        .unwrap();
5034
5035        let builder_rs = member_src.join("builder.rs");
5036        std::fs::write(&builder_rs, "pub struct Cmd;\n").unwrap();
5037
5038        let test_builder_rs = tests_dir.join("test_builder.rs");
5039        let test_source = "use member_a::builder::Cmd;\n\n#[test]\nfn test_builder() {}\n";
5040        std::fs::write(&test_builder_rs, test_source).unwrap();
5041
5042        let extractor = RustExtractor::new();
5043        let prod_path = builder_rs.to_string_lossy().into_owned();
5044        let test_path = test_builder_rs.to_string_lossy().into_owned();
5045        let production_files = vec![prod_path.clone()];
5046        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
5047            .into_iter()
5048            .collect();
5049
5050        // When: map_test_files_with_imports (cross-crate L2: root test -> member src)
5051        let result = extractor.map_test_files_with_imports(
5052            &production_files,
5053            &test_sources,
5054            tmp.path(),
5055            false,
5056        );
5057
5058        // Then: test_builder.rs (root test) maps to member_a/src/builder.rs via cross-crate L2
5059        let mapping = result.iter().find(|m| m.production_file == prod_path);
5060        assert!(
5061            mapping.is_some(),
5062            "No mapping found for member_a/src/builder.rs"
5063        );
5064        assert!(
5065            mapping.unwrap().test_files.contains(&test_path),
5066            "Expected root test_builder.rs to map to member_a/src/builder.rs via cross-crate L2, got: {:?}",
5067            mapping.unwrap().test_files
5068        );
5069    }
5070
5071    // -----------------------------------------------------------------------
5072    // XC-07: cross_crate_member_test_not_affected
5073    //
5074    // A member-owned test (member_a/tests/test.rs) is resolved by per-member L2
5075    // (using member_a's crate name), NOT by cross-crate fallback from root.
5076    // The test verifies that per-member L2 continues to work correctly after
5077    // cross-crate fallback is introduced.
5078    // -----------------------------------------------------------------------
5079    #[test]
5080    fn xc_07_cross_crate_member_test_not_affected() {
5081        // Given: mini workspace:
5082        //   Cargo.toml (workspace root, no [package])
5083        //   member_a/Cargo.toml ([package] name="member_a")
5084        //   member_a/src/engine.rs: pub struct Engine;
5085        //   member_a/tests/test_engine.rs with `use member_a::engine::Engine;`
5086        //   (this test is member-owned, not a root integration test)
5087        let tmp = tempfile::tempdir().unwrap();
5088        let member_dir = tmp.path().join("member_a");
5089        let member_src = member_dir.join("src");
5090        let member_tests = member_dir.join("tests");
5091        std::fs::create_dir_all(&member_src).unwrap();
5092        std::fs::create_dir_all(&member_tests).unwrap();
5093
5094        // Workspace root Cargo.toml (no [package])
5095        std::fs::write(
5096            tmp.path().join("Cargo.toml"),
5097            "[workspace]\nmembers = [\"member_a\"]\n",
5098        )
5099        .unwrap();
5100
5101        // Member Cargo.toml
5102        std::fs::write(
5103            member_dir.join("Cargo.toml"),
5104            "[package]\nname = \"member_a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
5105        )
5106        .unwrap();
5107
5108        let engine_rs = member_src.join("engine.rs");
5109        std::fs::write(&engine_rs, "pub struct Engine;\n").unwrap();
5110
5111        let test_rs = member_tests.join("test_engine.rs");
5112        let test_source = "use member_a::engine::Engine;\n\n#[test]\nfn test_engine() {}\n";
5113        std::fs::write(&test_rs, test_source).unwrap();
5114
5115        let extractor = RustExtractor::new();
5116        let prod_path = engine_rs.to_string_lossy().into_owned();
5117        let test_path = test_rs.to_string_lossy().into_owned();
5118        let production_files = vec![prod_path.clone()];
5119        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
5120            .into_iter()
5121            .collect();
5122
5123        // When: map_test_files_with_imports
5124        // member_a/tests/test_engine.rs is a member-owned test; handled by per-member L2
5125        let result = extractor.map_test_files_with_imports(
5126            &production_files,
5127            &test_sources,
5128            tmp.path(),
5129            false,
5130        );
5131
5132        // Then: the member-owned test maps correctly via per-member L2
5133        // (cross-crate fallback is NOT applied to this test because it is member-owned,
5134        //  but normal per-member L2 must still resolve it)
5135        let mapping = result.iter().find(|m| m.production_file == prod_path);
5136        assert!(
5137            mapping.is_some(),
5138            "No mapping found for member_a/src/engine.rs"
5139        );
5140        assert!(
5141            mapping.unwrap().test_files.contains(&test_path),
5142            "Expected member_a/tests/test_engine.rs to map to member_a/src/engine.rs via per-member L2, got: {:?}",
5143            mapping.unwrap().test_files
5144        );
5145    }
5146
5147    // -----------------------------------------------------------------------
5148    // SD-01: subdir_stem_match_same_crate
5149    // -----------------------------------------------------------------------
5150    #[test]
5151    fn sd_01_subdir_stem_match_same_crate() {
5152        // Given: test "tests/builder/action.rs" and prod "src/builder/action.rs"
5153        let extractor = RustExtractor::new();
5154        let production_files = vec!["src/builder/action.rs".to_string()];
5155        let test_sources: HashMap<String, String> =
5156            [("tests/builder/action.rs".to_string(), String::new())]
5157                .into_iter()
5158                .collect();
5159        let scan_root = PathBuf::from(".");
5160
5161        // When: map_test_files_with_imports (L1.6 subdir matching)
5162        let result = extractor.map_test_files_with_imports(
5163            &production_files,
5164            &test_sources,
5165            &scan_root,
5166            false,
5167        );
5168
5169        // Then: test maps to prod (subdir="builder", stem="action")
5170        let mapping = result
5171            .iter()
5172            .find(|m| m.production_file == "src/builder/action.rs");
5173        assert!(mapping.is_some(), "No mapping for src/builder/action.rs");
5174        assert!(
5175            mapping
5176                .unwrap()
5177                .test_files
5178                .contains(&"tests/builder/action.rs".to_string()),
5179            "Expected tests/builder/action.rs to map to src/builder/action.rs via L1.6 subdir matching, got: {:?}",
5180            mapping.unwrap().test_files
5181        );
5182    }
5183
5184    // -----------------------------------------------------------------------
5185    // SD-02: subdir_stem_match_cross_crate
5186    // -----------------------------------------------------------------------
5187    #[test]
5188    fn sd_02_subdir_stem_match_cross_crate() {
5189        // Given: test "tests/builder/command.rs" and prod "member_a/src/builder/command.rs"
5190        let extractor = RustExtractor::new();
5191        let production_files = vec!["member_a/src/builder/command.rs".to_string()];
5192        let test_sources: HashMap<String, String> =
5193            [("tests/builder/command.rs".to_string(), String::new())]
5194                .into_iter()
5195                .collect();
5196        let scan_root = PathBuf::from(".");
5197
5198        // When: map_test_files_with_imports (L1.6 subdir matching)
5199        let result = extractor.map_test_files_with_imports(
5200            &production_files,
5201            &test_sources,
5202            &scan_root,
5203            false,
5204        );
5205
5206        // Then: test maps to prod (subdir "builder" matches across crate boundary)
5207        let mapping = result
5208            .iter()
5209            .find(|m| m.production_file == "member_a/src/builder/command.rs");
5210        assert!(
5211            mapping.is_some(),
5212            "No mapping for member_a/src/builder/command.rs"
5213        );
5214        assert!(
5215            mapping
5216                .unwrap()
5217                .test_files
5218                .contains(&"tests/builder/command.rs".to_string()),
5219            "Expected tests/builder/command.rs to map to member_a/src/builder/command.rs via L1.6 subdir matching, got: {:?}",
5220            mapping.unwrap().test_files
5221        );
5222    }
5223
5224    // -----------------------------------------------------------------------
5225    // SD-03: subdir_wrong_dir_no_match
5226    // -----------------------------------------------------------------------
5227    #[test]
5228    fn sd_03_subdir_wrong_dir_no_match() {
5229        // Given: test "tests/builder/action.rs" and prod "src/parser/action.rs" (wrong dir)
5230        let extractor = RustExtractor::new();
5231        let production_files = vec!["src/parser/action.rs".to_string()];
5232        let test_sources: HashMap<String, String> =
5233            [("tests/builder/action.rs".to_string(), String::new())]
5234                .into_iter()
5235                .collect();
5236        let scan_root = PathBuf::from(".");
5237
5238        // When: map_test_files_with_imports
5239        let result = extractor.map_test_files_with_imports(
5240            &production_files,
5241            &test_sources,
5242            &scan_root,
5243            false,
5244        );
5245
5246        // Then: NO match (subdir "builder" not in "src/parser/")
5247        let mapping = result
5248            .iter()
5249            .find(|m| m.production_file == "src/parser/action.rs");
5250        assert!(
5251            mapping.is_some(),
5252            "No mapping entry for src/parser/action.rs"
5253        );
5254        assert!(
5255            mapping.unwrap().test_files.is_empty(),
5256            "Expected NO match for src/parser/action.rs (wrong dir), got: {:?}",
5257            mapping.unwrap().test_files
5258        );
5259    }
5260
5261    // -----------------------------------------------------------------------
5262    // SD-04: subdir_no_subdir_skip
5263    // -----------------------------------------------------------------------
5264    #[test]
5265    fn sd_04_subdir_no_subdir_skip() {
5266        // Given: test "tests/action.rs" (directly in tests/, no subdir) and prod "src/builder/action.rs"
5267        let extractor = RustExtractor::new();
5268        let production_files = vec!["src/builder/action.rs".to_string()];
5269        let test_sources: HashMap<String, String> =
5270            [("tests/action.rs".to_string(), String::new())]
5271                .into_iter()
5272                .collect();
5273        let scan_root = PathBuf::from(".");
5274
5275        // When: map_test_files_with_imports
5276        let result = extractor.map_test_files_with_imports(
5277            &production_files,
5278            &test_sources,
5279            &scan_root,
5280            false,
5281        );
5282
5283        // Then: NO match (no test subdir to match)
5284        let mapping = result
5285            .iter()
5286            .find(|m| m.production_file == "src/builder/action.rs");
5287        assert!(
5288            mapping.is_some(),
5289            "No mapping entry for src/builder/action.rs"
5290        );
5291        assert!(
5292            mapping.unwrap().test_files.is_empty(),
5293            "Expected NO match for src/builder/action.rs (no test subdir), got: {:?}",
5294            mapping.unwrap().test_files
5295        );
5296    }
5297
5298    // -----------------------------------------------------------------------
5299    // SD-05: subdir_main_rs_skip
5300    // -----------------------------------------------------------------------
5301    #[test]
5302    fn sd_05_subdir_main_rs_skip() {
5303        // Given: test "tests/builder/main.rs" (main.rs has no test_stem)
5304        // and prod "src/builder/action.rs"
5305        let extractor = RustExtractor::new();
5306        let production_files = vec!["src/builder/action.rs".to_string()];
5307        let test_sources: HashMap<String, String> =
5308            [("tests/builder/main.rs".to_string(), String::new())]
5309                .into_iter()
5310                .collect();
5311        let scan_root = PathBuf::from(".");
5312
5313        // When: map_test_files_with_imports
5314        let result = extractor.map_test_files_with_imports(
5315            &production_files,
5316            &test_sources,
5317            &scan_root,
5318            false,
5319        );
5320
5321        // Then: skip (main.rs excluded by test_stem returning None)
5322        let mapping = result
5323            .iter()
5324            .find(|m| m.production_file == "src/builder/action.rs");
5325        assert!(
5326            mapping.is_some(),
5327            "No mapping entry for src/builder/action.rs"
5328        );
5329        assert!(
5330            mapping.unwrap().test_files.is_empty(),
5331            "Expected NO match for src/builder/action.rs (main.rs skipped), got: {:?}",
5332            mapping.unwrap().test_files
5333        );
5334    }
5335
5336    // -----------------------------------------------------------------------
5337    // SD-06: subdir_already_matched_skip
5338    //
5339    // Verify that a test already matched by L1.5 is skipped by L1.6, while
5340    // an unmatched subdir test is still processed by L1.6.
5341    // -----------------------------------------------------------------------
5342    #[test]
5343    fn sd_06_subdir_already_matched_skip() {
5344        // Given: test "tests/sync_action.rs" already L1.5-matched to "src/sync/action.rs"
5345        // And test "tests/builder/command.rs" not yet matched
5346        // And prod "src/builder/command.rs" available
5347        let extractor = RustExtractor::new();
5348        let production_files = vec![
5349            "src/sync/action.rs".to_string(),
5350            "src/builder/command.rs".to_string(),
5351        ];
5352        let test_sources: HashMap<String, String> = [
5353            ("tests/sync_action.rs".to_string(), String::new()),
5354            ("tests/builder/command.rs".to_string(), String::new()),
5355        ]
5356        .into_iter()
5357        .collect();
5358        let scan_root = PathBuf::from(".");
5359
5360        // When: map_test_files_with_imports
5361        let result = extractor.map_test_files_with_imports(
5362            &production_files,
5363            &test_sources,
5364            &scan_root,
5365            false,
5366        );
5367
5368        // Then: tests/sync_action.rs is L1.5-matched to src/sync/action.rs
5369        let l15_mapping = result
5370            .iter()
5371            .find(|m| m.production_file == "src/sync/action.rs");
5372        assert!(
5373            l15_mapping.is_some(),
5374            "No mapping entry for src/sync/action.rs"
5375        );
5376        assert!(
5377            l15_mapping
5378                .unwrap()
5379                .test_files
5380                .contains(&"tests/sync_action.rs".to_string()),
5381            "Expected tests/sync_action.rs to L1.5-match to src/sync/action.rs, got: {:?}",
5382            l15_mapping.unwrap().test_files
5383        );
5384
5385        // And: tests/builder/command.rs should be processed by L1.6 subdir matching
5386        // (this assert will fail in RED since apply_l1_subdir_matching is a stub)
5387        let sd_mapping = result
5388            .iter()
5389            .find(|m| m.production_file == "src/builder/command.rs");
5390        assert!(
5391            sd_mapping.is_some(),
5392            "No mapping entry for src/builder/command.rs"
5393        );
5394        assert!(
5395            sd_mapping
5396                .unwrap()
5397                .test_files
5398                .contains(&"tests/builder/command.rs".to_string()),
5399            "Expected tests/builder/command.rs to L1.6-match to src/builder/command.rs, got: {:?}",
5400            sd_mapping.unwrap().test_files
5401        );
5402    }
5403
5404    // -----------------------------------------------------------------------
5405    // CCB-01: pub use sibling_crate::* in root lib.rs → cross-crate barrel
5406    //
5407    // Given: workspace with root lib.rs containing `pub use sibling_crate::*`
5408    //        and a workspace member `sibling_crate` with `pub struct Symbol`
5409    // When:  test file uses `use root_crate::Symbol` and observe runs
5410    // Then:  the test file is mapped to sibling_crate/src/lib.rs (or the file
5411    //        that exports Symbol) via the new apply_l2_cross_crate_barrel logic
5412    // -----------------------------------------------------------------------
5413    #[test]
5414    fn ccb_01_wildcard_cross_crate_barrel_maps_test_to_member() {
5415        // Given: workspace structure
5416        //   root/
5417        //     Cargo.toml  (workspace + package "root_crate"; member: "sibling_crate")
5418        //     src/lib.rs  (pub use sibling_crate::*;)
5419        //     tests/test_symbol.rs  (use root_crate::Symbol;)
5420        //   root/sibling_crate/
5421        //     Cargo.toml  (package "sibling_crate")
5422        //     src/lib.rs  (pub struct Symbol {})
5423        let tmp = tempfile::tempdir().unwrap();
5424
5425        // Root Cargo.toml: workspace + package
5426        std::fs::write(
5427            tmp.path().join("Cargo.toml"),
5428            "[workspace]\nmembers = [\"sibling_crate\"]\n\n[package]\nname = \"root_crate\"\nversion = \"0.1.0\"\n",
5429        )
5430        .unwrap();
5431
5432        // Root src/lib.rs: wildcard re-export
5433        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5434        let root_lib = tmp.path().join("src").join("lib.rs");
5435        std::fs::write(&root_lib, "pub use sibling_crate::*;\n").unwrap();
5436
5437        // Root tests/test_symbol.rs: imports via root crate name
5438        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
5439        let test_file = tmp.path().join("tests").join("test_symbol.rs");
5440        std::fs::write(
5441            &test_file,
5442            "use root_crate::Symbol;\n#[test]\nfn test_symbol() { let _s = Symbol {}; }\n",
5443        )
5444        .unwrap();
5445
5446        // sibling_crate/Cargo.toml
5447        let sib_dir = tmp.path().join("sibling_crate");
5448        std::fs::create_dir_all(sib_dir.join("src")).unwrap();
5449        std::fs::write(
5450            sib_dir.join("Cargo.toml"),
5451            "[package]\nname = \"sibling_crate\"\nversion = \"0.1.0\"\n",
5452        )
5453        .unwrap();
5454
5455        // sibling_crate/src/lib.rs: defines Symbol
5456        let sib_lib = sib_dir.join("src").join("lib.rs");
5457        std::fs::write(&sib_lib, "pub struct Symbol {}\n").unwrap();
5458
5459        let extractor = RustExtractor::new();
5460        let sib_lib_path = sib_lib.to_string_lossy().into_owned();
5461        let test_file_path = test_file.to_string_lossy().into_owned();
5462
5463        let production_files = vec![sib_lib_path.clone()];
5464        let test_sources: HashMap<String, String> = [(
5465            test_file_path.clone(),
5466            std::fs::read_to_string(&test_file).unwrap(),
5467        )]
5468        .into_iter()
5469        .collect();
5470
5471        // When: map_test_files_with_imports is called at workspace root
5472        let result = extractor.map_test_files_with_imports(
5473            &production_files,
5474            &test_sources,
5475            tmp.path(),
5476            false,
5477        );
5478
5479        // Then: sibling_crate/src/lib.rs has test_symbol.rs in its test_files
5480        let mapping = result.iter().find(|m| m.production_file == sib_lib_path);
5481        assert!(
5482            mapping.is_some(),
5483            "No mapping entry for sibling_crate/src/lib.rs. All mappings: {:#?}",
5484            result
5485        );
5486        assert!(
5487            mapping.unwrap().test_files.contains(&test_file_path),
5488            "Expected test_symbol.rs mapped to sibling_crate/src/lib.rs via CCB, \
5489             but test_files: {:?}",
5490            mapping.unwrap().test_files
5491        );
5492    }
5493
5494    // -----------------------------------------------------------------------
5495    // CCB-02: named cross-crate re-export → test maps to member
5496    //
5497    // Given: root lib.rs has `pub use sibling_crate::SpecificType` (named)
5498    // When:  test uses `use root_crate::SpecificType`
5499    // Then:  mapped to sibling_crate/src/lib.rs
5500    // -----------------------------------------------------------------------
5501    #[test]
5502    fn ccb_02_named_cross_crate_reexport_maps_test_to_member() {
5503        let tmp = tempfile::tempdir().unwrap();
5504
5505        std::fs::write(
5506            tmp.path().join("Cargo.toml"),
5507            "[workspace]\nmembers = [\"sibling_crate\"]\n\n[package]\nname = \"root_crate\"\nversion = \"0.1.0\"\n",
5508        )
5509        .unwrap();
5510
5511        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5512        let root_lib = tmp.path().join("src").join("lib.rs");
5513        // Named re-export: only SpecificType is exposed, not wildcard
5514        std::fs::write(&root_lib, "pub use sibling_crate::SpecificType;\n").unwrap();
5515
5516        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
5517        let test_file = tmp.path().join("tests").join("test_specific.rs");
5518        std::fs::write(
5519            &test_file,
5520            "use root_crate::SpecificType;\n#[test]\nfn test_it() { let _x: SpecificType = todo!(); }\n",
5521        )
5522        .unwrap();
5523
5524        let sib_dir = tmp.path().join("sibling_crate");
5525        std::fs::create_dir_all(sib_dir.join("src")).unwrap();
5526        std::fs::write(
5527            sib_dir.join("Cargo.toml"),
5528            "[package]\nname = \"sibling_crate\"\nversion = \"0.1.0\"\n",
5529        )
5530        .unwrap();
5531        let sib_lib = sib_dir.join("src").join("lib.rs");
5532        std::fs::write(&sib_lib, "pub struct SpecificType {}\n").unwrap();
5533
5534        let extractor = RustExtractor::new();
5535        let sib_lib_path = sib_lib.to_string_lossy().into_owned();
5536        let test_file_path = test_file.to_string_lossy().into_owned();
5537
5538        let production_files = vec![sib_lib_path.clone()];
5539        let test_sources: HashMap<String, String> = [(
5540            test_file_path.clone(),
5541            std::fs::read_to_string(&test_file).unwrap(),
5542        )]
5543        .into_iter()
5544        .collect();
5545
5546        // When
5547        let result = extractor.map_test_files_with_imports(
5548            &production_files,
5549            &test_sources,
5550            tmp.path(),
5551            false,
5552        );
5553
5554        // Then: sibling_crate/src/lib.rs has test_specific.rs in test_files
5555        let mapping = result.iter().find(|m| m.production_file == sib_lib_path);
5556        assert!(
5557            mapping.is_some(),
5558            "No mapping entry for sibling_crate/src/lib.rs. All mappings: {:#?}",
5559            result
5560        );
5561        assert!(
5562            mapping.unwrap().test_files.contains(&test_file_path),
5563            "Expected test_specific.rs mapped via named CCB re-export, \
5564             but test_files: {:?}",
5565            mapping.unwrap().test_files
5566        );
5567    }
5568
5569    // -----------------------------------------------------------------------
5570    // CCB-03: nonexistent crate in pub use → no mapping, no panic
5571    //
5572    // Given: root lib.rs has `pub use nonexistent_crate::*` but that crate is
5573    //        not a workspace member
5574    // When:  observe runs
5575    // Then:  mapping is empty (no entries), no panic
5576    // -----------------------------------------------------------------------
5577    #[test]
5578    fn ccb_03_nonexistent_member_produces_empty_mapping_no_panic() {
5579        let tmp = tempfile::tempdir().unwrap();
5580
5581        // Workspace with no members listed (nonexistent_crate is not present)
5582        std::fs::write(
5583            tmp.path().join("Cargo.toml"),
5584            "[workspace]\nmembers = []\n\n[package]\nname = \"root_crate\"\nversion = \"0.1.0\"\n",
5585        )
5586        .unwrap();
5587
5588        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5589        let root_lib = tmp.path().join("src").join("lib.rs");
5590        std::fs::write(&root_lib, "pub use nonexistent_crate::*;\n").unwrap();
5591
5592        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
5593        let test_file = tmp.path().join("tests").join("test_ghost.rs");
5594        std::fs::write(
5595            &test_file,
5596            "use root_crate::Ghost;\n#[test]\nfn test_ghost() {}\n",
5597        )
5598        .unwrap();
5599
5600        let extractor = RustExtractor::new();
5601        // root_lib is the only production file (no sibling prod files)
5602        let root_lib_path = root_lib.to_string_lossy().into_owned();
5603        let test_file_path = test_file.to_string_lossy().into_owned();
5604
5605        let production_files = vec![root_lib_path.clone()];
5606        let test_sources: HashMap<String, String> = [(
5607            test_file_path.clone(),
5608            std::fs::read_to_string(&test_file).unwrap(),
5609        )]
5610        .into_iter()
5611        .collect();
5612
5613        // When: must not panic
5614        let result = extractor.map_test_files_with_imports(
5615            &production_files,
5616            &test_sources,
5617            tmp.path(),
5618            false,
5619        );
5620
5621        // Then: test_ghost.rs is not mapped to any production file via CCB
5622        //       (nonexistent_crate has no member → no new mapping added)
5623        let ghost_mapped = result
5624            .iter()
5625            .any(|m| m.test_files.contains(&test_file_path));
5626        assert!(
5627            !ghost_mapped,
5628            "test_ghost.rs should not be mapped (nonexistent_crate is not a member), \
5629             but found in mappings: {:#?}",
5630            result
5631        );
5632    }
5633
5634    // -----------------------------------------------------------------------
5635    // CCB-04: local pub mod + cross-crate wildcard → existing L2 still works
5636    //
5637    // Given: root lib.rs has `pub mod local_module` (local fn) + `pub use sibling::*`
5638    // When:  test imports `use root_crate::local_module::local_fn`
5639    // Then:  local_module.rs is resolved by existing L2 (regression guard)
5640    // -----------------------------------------------------------------------
5641    #[test]
5642    fn ccb_04_local_pubmod_still_resolved_by_existing_l2() {
5643        let tmp = tempfile::tempdir().unwrap();
5644
5645        std::fs::write(
5646            tmp.path().join("Cargo.toml"),
5647            "[workspace]\nmembers = [\"sibling\"]\n\n[package]\nname = \"root_crate\"\nversion = \"0.1.0\"\n",
5648        )
5649        .unwrap();
5650
5651        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5652
5653        // root lib.rs: both pub mod (local) and pub use (cross-crate)
5654        let root_lib = tmp.path().join("src").join("lib.rs");
5655        std::fs::write(&root_lib, "pub mod local_module;\npub use sibling::*;\n").unwrap();
5656
5657        // local_module.rs: defines local_fn
5658        let local_mod = tmp.path().join("src").join("local_module.rs");
5659        std::fs::write(&local_mod, "pub fn local_fn() {}\n").unwrap();
5660
5661        // test: imports via the local module path
5662        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
5663        let test_file = tmp.path().join("tests").join("test_local.rs");
5664        std::fs::write(
5665            &test_file,
5666            "use root_crate::local_module::local_fn;\n#[test]\nfn test_local_fn() { local_fn(); }\n",
5667        )
5668        .unwrap();
5669
5670        // sibling member (provides cross-crate items, not tested here)
5671        let sib_dir = tmp.path().join("sibling");
5672        std::fs::create_dir_all(sib_dir.join("src")).unwrap();
5673        std::fs::write(
5674            sib_dir.join("Cargo.toml"),
5675            "[package]\nname = \"sibling\"\nversion = \"0.1.0\"\n",
5676        )
5677        .unwrap();
5678        std::fs::write(sib_dir.join("src").join("lib.rs"), "pub fn sib_fn() {}\n").unwrap();
5679
5680        let extractor = RustExtractor::new();
5681        let local_mod_path = local_mod.to_string_lossy().into_owned();
5682        let test_file_path = test_file.to_string_lossy().into_owned();
5683
5684        let production_files = vec![local_mod_path.clone()];
5685        let test_sources: HashMap<String, String> = [(
5686            test_file_path.clone(),
5687            std::fs::read_to_string(&test_file).unwrap(),
5688        )]
5689        .into_iter()
5690        .collect();
5691
5692        // When
5693        let result = extractor.map_test_files_with_imports(
5694            &production_files,
5695            &test_sources,
5696            tmp.path(),
5697            false,
5698        );
5699
5700        // Then: local_module.rs is still mapped via existing L2 (regression guard)
5701        let mapping = result.iter().find(|m| m.production_file == local_mod_path);
5702        assert!(
5703            mapping.is_some(),
5704            "No mapping entry for local_module.rs. All mappings: {:#?}",
5705            result
5706        );
5707        assert!(
5708            mapping.unwrap().test_files.contains(&test_file_path),
5709            "Expected test_local.rs mapped to local_module.rs via existing L2, \
5710             but test_files: {:?}",
5711            mapping.unwrap().test_files
5712        );
5713    }
5714
5715    // -----------------------------------------------------------------------
5716    // CCB-05: 2-level cross-crate barrel chain (root → mid → mid/src/sub.rs)
5717    //
5718    // Given: root lib.rs has `pub use mid::*`, mid lib.rs has `pub use sub::*`
5719    //        (wildcard re-export of sub module), mid/src/sub.rs defines MidItem
5720    // When:  test uses `use root_crate::MidItem` (flat symbol via double-barrel)
5721    // Then:  mapped to mid/src/sub.rs via CCB 2-level chain
5722    //
5723    // This is NOT resolvable by existing L2 because:
5724    //   - existing cross-crate L2 resolves `use root_crate::` against mid/src/
5725    //     via crate name "root_crate" or "mid", but MidItem is defined in sub.rs
5726    //     and is only re-exported from mid via `pub use sub::*`
5727    //   - apply_l2_cross_crate_barrel must follow mid lib.rs barrel to sub.rs
5728    // -----------------------------------------------------------------------
5729    #[test]
5730    fn ccb_05_two_level_cross_crate_barrel_chain() {
5731        let tmp = tempfile::tempdir().unwrap();
5732
5733        // Root workspace: member = "mid"
5734        std::fs::write(
5735            tmp.path().join("Cargo.toml"),
5736            "[workspace]\nmembers = [\"mid\"]\n\n[package]\nname = \"root_crate\"\nversion = \"0.1.0\"\n",
5737        )
5738        .unwrap();
5739
5740        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5741        let root_lib = tmp.path().join("src").join("lib.rs");
5742        // Level 1: root re-exports mid's public items
5743        std::fs::write(&root_lib, "pub use mid::*;\n").unwrap();
5744
5745        // Test: imports MidItem directly from root_crate (flat access via barrel chain)
5746        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
5747        let test_file = tmp.path().join("tests").join("test_mid_item.rs");
5748        std::fs::write(
5749            &test_file,
5750            "use root_crate::MidItem;\n#[test]\nfn test_mid_item() { let _ = MidItem {}; }\n",
5751        )
5752        .unwrap();
5753
5754        // mid member
5755        let mid_dir = tmp.path().join("mid");
5756        std::fs::create_dir_all(mid_dir.join("src")).unwrap();
5757        std::fs::write(
5758            mid_dir.join("Cargo.toml"),
5759            "[package]\nname = \"mid\"\nversion = \"0.1.0\"\n",
5760        )
5761        .unwrap();
5762        // Level 2: mid lib.rs re-exports sub's items via wildcard
5763        std::fs::write(
5764            mid_dir.join("src").join("lib.rs"),
5765            "pub mod sub;\npub use sub::*;\n",
5766        )
5767        .unwrap();
5768
5769        // mid/src/sub.rs: defines MidItem
5770        let sub_rs = mid_dir.join("src").join("sub.rs");
5771        std::fs::write(&sub_rs, "pub struct MidItem {}\n").unwrap();
5772
5773        let extractor = RustExtractor::new();
5774        let sub_rs_path = sub_rs.to_string_lossy().into_owned();
5775        let test_file_path = test_file.to_string_lossy().into_owned();
5776
5777        let production_files = vec![sub_rs_path.clone()];
5778        let test_sources: HashMap<String, String> = [(
5779            test_file_path.clone(),
5780            std::fs::read_to_string(&test_file).unwrap(),
5781        )]
5782        .into_iter()
5783        .collect();
5784
5785        // When
5786        let result = extractor.map_test_files_with_imports(
5787            &production_files,
5788            &test_sources,
5789            tmp.path(),
5790            false,
5791        );
5792
5793        // Then: mid/src/sub.rs has test_mid_item.rs in test_files (2-level chain resolved)
5794        let mapping = result.iter().find(|m| m.production_file == sub_rs_path);
5795        assert!(
5796            mapping.is_some(),
5797            "No mapping entry for mid/src/sub.rs. All mappings: {:#?}",
5798            result
5799        );
5800        assert!(
5801            mapping.unwrap().test_files.contains(&test_file_path),
5802            "Expected test_mid_item.rs mapped to mid/src/sub.rs via 2-level CCB chain, \
5803             but test_files: {:?}",
5804            mapping.unwrap().test_files
5805        );
5806    }
5807
5808    // -----------------------------------------------------------------------
5809    // CCB-06: cross-crate wildcard + 50+ pub items → filter to 1 file
5810    //
5811    // Given: root lib.rs `pub use big_crate::*`, big_crate has 50+ pub items
5812    //        spread across multiple files, but test only imports `SpecificFn`
5813    // When:  observe runs with file_exports_any_symbol filter
5814    // Then:  only the file that exports SpecificFn is mapped (not all 50+ files)
5815    // -----------------------------------------------------------------------
5816    #[test]
5817    fn ccb_06_wildcard_with_many_items_filters_to_single_file() {
5818        let tmp = tempfile::tempdir().unwrap();
5819
5820        std::fs::write(
5821            tmp.path().join("Cargo.toml"),
5822            "[workspace]\nmembers = [\"big_crate\"]\n\n[package]\nname = \"root_crate\"\nversion = \"0.1.0\"\n",
5823        )
5824        .unwrap();
5825
5826        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5827        let root_lib = tmp.path().join("src").join("lib.rs");
5828        std::fs::write(&root_lib, "pub use big_crate::*;\n").unwrap();
5829
5830        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
5831        let test_file = tmp.path().join("tests").join("test_specific_fn.rs");
5832        std::fs::write(
5833            &test_file,
5834            "use root_crate::SpecificFn;\n#[test]\nfn test_specific() { SpecificFn::run(); }\n",
5835        )
5836        .unwrap();
5837
5838        // big_crate: one target file + many other files
5839        let big_dir = tmp.path().join("big_crate");
5840        std::fs::create_dir_all(big_dir.join("src")).unwrap();
5841        std::fs::write(
5842            big_dir.join("Cargo.toml"),
5843            "[package]\nname = \"big_crate\"\nversion = \"0.1.0\"\n",
5844        )
5845        .unwrap();
5846
5847        // lib.rs: exposes all items
5848        let mut lib_content = String::new();
5849        // 50 dummy pub items in lib.rs
5850        for i in 0..50 {
5851            lib_content.push_str(&format!("pub struct Item{i} {{}}\n"));
5852        }
5853        std::fs::write(big_dir.join("src").join("lib.rs"), &lib_content).unwrap();
5854
5855        // specific.rs: the one file that exports SpecificFn
5856        let specific_rs = big_dir.join("src").join("specific.rs");
5857        std::fs::write(
5858            &specific_rs,
5859            "pub struct SpecificFn;\nimpl SpecificFn { pub fn run() {} }\n",
5860        )
5861        .unwrap();
5862
5863        let extractor = RustExtractor::new();
5864        let specific_rs_path = specific_rs.to_string_lossy().into_owned();
5865        let test_file_path = test_file.to_string_lossy().into_owned();
5866
5867        // Production files: all files in big_crate/src/
5868        let big_lib_path = big_dir
5869            .join("src")
5870            .join("lib.rs")
5871            .to_string_lossy()
5872            .into_owned();
5873        let production_files = vec![big_lib_path.clone(), specific_rs_path.clone()];
5874        let test_sources: HashMap<String, String> = [(
5875            test_file_path.clone(),
5876            std::fs::read_to_string(&test_file).unwrap(),
5877        )]
5878        .into_iter()
5879        .collect();
5880
5881        // When
5882        let result = extractor.map_test_files_with_imports(
5883            &production_files,
5884            &test_sources,
5885            tmp.path(),
5886            false,
5887        );
5888
5889        // Then: specific.rs has test_specific_fn.rs in test_files
5890        let specific_mapping = result
5891            .iter()
5892            .find(|m| m.production_file == specific_rs_path);
5893        assert!(
5894            specific_mapping.is_some(),
5895            "No mapping entry for big_crate/src/specific.rs. All mappings: {:#?}",
5896            result
5897        );
5898        assert!(
5899            specific_mapping
5900                .unwrap()
5901                .test_files
5902                .contains(&test_file_path),
5903            "Expected test_specific_fn.rs mapped to specific.rs via CCB + symbol filter, \
5904             but test_files: {:?}",
5905            specific_mapping.unwrap().test_files
5906        );
5907
5908        // And: big_crate/src/lib.rs does NOT have test_specific_fn.rs in test_files
5909        // (symbol filter should prevent fan-out to lib.rs which has no SpecificFn)
5910        let lib_mapping = result.iter().find(|m| m.production_file == big_lib_path);
5911        if let Some(lib_m) = lib_mapping {
5912            assert!(
5913                !lib_m.test_files.contains(&test_file_path),
5914                "test_specific_fn.rs should NOT be fan-out mapped to big_crate/src/lib.rs \
5915                 (which does not export SpecificFn), but found in: {:?}",
5916                lib_m.test_files
5917            );
5918        }
5919    }
5920}