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