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
147    let mut cursor = QueryCursor::new();
148    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
149
150    while let Some(m) = matches.next() {
151        let mut is_cfg = false;
152        let mut is_test = false;
153
154        for cap in m.captures {
155            let text = cap.node.utf8_text(source_bytes).unwrap_or("");
156            if attr_name_idx == Some(cap.index) && text == "cfg" {
157                is_cfg = true;
158            }
159            if cfg_arg_idx == Some(cap.index) && text == "test" {
160                is_test = true;
161            }
162        }
163
164        if is_cfg && is_test {
165            return true;
166        }
167    }
168
169    false
170}
171
172// ---------------------------------------------------------------------------
173// ObserveExtractor impl
174// ---------------------------------------------------------------------------
175
176impl ObserveExtractor for RustExtractor {
177    fn extract_production_functions(
178        &self,
179        source: &str,
180        file_path: &str,
181    ) -> Vec<ProductionFunction> {
182        let mut parser = Self::parser();
183        let tree = match parser.parse(source, None) {
184            Some(t) => t,
185            None => return Vec::new(),
186        };
187        let source_bytes = source.as_bytes();
188        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
189
190        let name_idx = query.capture_index_for_name("name");
191        let class_name_idx = query.capture_index_for_name("class_name");
192        let method_name_idx = query.capture_index_for_name("method_name");
193        let function_idx = query.capture_index_for_name("function");
194        let method_idx = query.capture_index_for_name("method");
195
196        // Find byte ranges of #[cfg(test)] mod blocks to exclude
197        let cfg_test_ranges = find_cfg_test_ranges(source);
198
199        let mut cursor = QueryCursor::new();
200        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
201        let mut result = Vec::new();
202
203        while let Some(m) = matches.next() {
204            let mut fn_name: Option<String> = None;
205            let mut class_name: Option<String> = None;
206            let mut line: usize = 1;
207            let mut is_exported = false;
208            let mut fn_start_byte: usize = 0;
209
210            for cap in m.captures {
211                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
212                let node_line = cap.node.start_position().row + 1;
213
214                if name_idx == Some(cap.index) || method_name_idx == Some(cap.index) {
215                    fn_name = Some(text);
216                    line = node_line;
217                } else if class_name_idx == Some(cap.index) {
218                    class_name = Some(text);
219                }
220
221                // Check visibility for function/method nodes
222                if function_idx == Some(cap.index) || method_idx == Some(cap.index) {
223                    fn_start_byte = cap.node.start_byte();
224                    is_exported = has_pub_visibility(cap.node);
225                }
226            }
227
228            if let Some(name) = fn_name {
229                // Skip functions inside #[cfg(test)] blocks
230                if cfg_test_ranges
231                    .iter()
232                    .any(|(start, end)| fn_start_byte >= *start && fn_start_byte < *end)
233                {
234                    continue;
235                }
236
237                result.push(ProductionFunction {
238                    name,
239                    file: file_path.to_string(),
240                    line,
241                    class_name,
242                    is_exported,
243                });
244            }
245        }
246
247        // Deduplicate
248        let mut seen = HashSet::new();
249        result.retain(|f| seen.insert((f.name.clone(), f.class_name.clone())));
250
251        result
252    }
253
254    fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
255        // For Rust, extract_imports returns relative imports (use crate::... mapped to relative paths)
256        let all = self.extract_all_import_specifiers(source);
257        let mut result = Vec::new();
258        for (specifier, symbols) in all {
259            for sym in &symbols {
260                result.push(ImportMapping {
261                    symbol_name: sym.clone(),
262                    module_specifier: specifier.clone(),
263                    file: file_path.to_string(),
264                    line: 1,
265                    symbols: symbols.clone(),
266                });
267            }
268        }
269        result
270    }
271
272    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
273        extract_import_specifiers_with_crate_name(source, None)
274    }
275
276    fn extract_barrel_re_exports(&self, source: &str, file_path: &str) -> Vec<BarrelReExport> {
277        if !self.is_barrel_file(file_path) {
278            return Vec::new();
279        }
280
281        let mut parser = Self::parser();
282        let tree = match parser.parse(source, None) {
283            Some(t) => t,
284            None => return Vec::new(),
285        };
286        let source_bytes = source.as_bytes();
287        let root = tree.root_node();
288        let mut result = Vec::new();
289
290        for i in 0..root.child_count() {
291            let child = root.child(i).unwrap();
292
293            // pub mod foo; -> BarrelReExport { from_specifier: "./foo", wildcard: true }
294            if child.kind() == "mod_item" && has_pub_visibility(child) {
295                // Check it's a declaration (no body block)
296                let has_body = child.child_by_field_name("body").is_some();
297                if !has_body {
298                    if let Some(name_node) = child.child_by_field_name("name") {
299                        let mod_name = name_node.utf8_text(source_bytes).unwrap_or("");
300                        result.push(BarrelReExport {
301                            symbols: Vec::new(),
302                            from_specifier: format!("./{mod_name}"),
303                            wildcard: true,
304                            namespace_wildcard: false,
305                        });
306                    }
307                }
308            }
309
310            // pub use foo::*; or pub use foo::{Bar, Baz};
311            if child.kind() == "use_declaration" && has_pub_visibility(child) {
312                if let Some(arg) = child.child_by_field_name("argument") {
313                    extract_pub_use_re_exports(&arg, source_bytes, &mut result);
314                }
315            }
316        }
317
318        result
319    }
320
321    fn source_extensions(&self) -> &[&str] {
322        &["rs"]
323    }
324
325    fn index_file_names(&self) -> &[&str] {
326        &["mod.rs", "lib.rs"]
327    }
328
329    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
330        production_stem(path)
331    }
332
333    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
334        test_stem(path)
335    }
336
337    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
338        is_non_sut_helper(file_path, is_known_production)
339    }
340
341    fn file_exports_any_symbol(&self, path: &Path, symbols: &[String]) -> bool {
342        if symbols.is_empty() {
343            return true;
344        }
345        // Optimistic fallback on read/parse failure (matches core default and Python).
346        // FN avoidance is preferred over FP avoidance here.
347        let source = match std::fs::read_to_string(path) {
348            Ok(s) => s,
349            Err(_) => return true,
350        };
351        let mut parser = Self::parser();
352        let tree = match parser.parse(&source, None) {
353            Some(t) => t,
354            None => return true,
355        };
356        let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
357        let symbol_idx = query
358            .capture_index_for_name("symbol_name")
359            .expect("@symbol_name capture not found in exported_symbol.scm");
360
361        let source_bytes = source.as_bytes();
362        let mut cursor = QueryCursor::new();
363        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
364        while let Some(m) = matches.next() {
365            for cap in m.captures {
366                if cap.index == symbol_idx {
367                    let name = cap.node.utf8_text(source_bytes).unwrap_or("");
368                    if symbols.iter().any(|s| s == name) {
369                        return true;
370                    }
371                }
372            }
373        }
374        false
375    }
376}
377
378// ---------------------------------------------------------------------------
379// Helpers
380// ---------------------------------------------------------------------------
381
382/// Check if a tree-sitter node has `pub` visibility modifier.
383fn has_pub_visibility(node: tree_sitter::Node) -> bool {
384    for i in 0..node.child_count() {
385        if let Some(child) = node.child(i) {
386            if child.kind() == "visibility_modifier" {
387                return true;
388            }
389            // Stop at the first non-attribute, non-visibility child
390            if child.kind() != "attribute_item" && child.kind() != "visibility_modifier" {
391                break;
392            }
393        }
394    }
395    false
396}
397
398/// Find byte ranges of #[cfg(test)] mod blocks.
399/// In tree-sitter-rust, `#[cfg(test)]` is an attribute_item that is a sibling
400/// of the mod_item it annotates. We find the attribute, then look at the next
401/// sibling to get the mod_item range.
402fn find_cfg_test_ranges(source: &str) -> Vec<(usize, usize)> {
403    let mut parser = RustExtractor::parser();
404    let tree = match parser.parse(source, None) {
405        Some(t) => t,
406        None => return Vec::new(),
407    };
408    let source_bytes = source.as_bytes();
409    let query = cached_query(&CFG_TEST_QUERY_CACHE, CFG_TEST_QUERY);
410
411    let attr_name_idx = query.capture_index_for_name("attr_name");
412    let cfg_arg_idx = query.capture_index_for_name("cfg_arg");
413    let cfg_test_attr_idx = query.capture_index_for_name("cfg_test_attr");
414
415    let mut cursor = QueryCursor::new();
416    let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
417    let mut ranges = Vec::new();
418
419    while let Some(m) = matches.next() {
420        let mut is_cfg = false;
421        let mut is_test = false;
422        let mut attr_node = None;
423
424        for cap in m.captures {
425            let text = cap.node.utf8_text(source_bytes).unwrap_or("");
426            if attr_name_idx == Some(cap.index) && text == "cfg" {
427                is_cfg = true;
428            }
429            if cfg_arg_idx == Some(cap.index) && text == "test" {
430                is_test = true;
431            }
432            if cfg_test_attr_idx == Some(cap.index) {
433                attr_node = Some(cap.node);
434            }
435        }
436
437        if is_cfg && is_test {
438            if let Some(attr) = attr_node {
439                // Find the next sibling which should be the mod_item
440                let mut sibling = attr.next_sibling();
441                while let Some(s) = sibling {
442                    if s.kind() == "mod_item" {
443                        ranges.push((s.start_byte(), s.end_byte()));
444                        break;
445                    }
446                    sibling = s.next_sibling();
447                }
448            }
449        }
450    }
451
452    ranges
453}
454
455/// Extract use declarations from a `use_declaration` node.
456/// Processes `use crate::...` imports and, if `crate_name` is provided,
457/// also `use {crate_name}::...` imports (for integration tests).
458fn extract_use_declaration(
459    node: &tree_sitter::Node,
460    source_bytes: &[u8],
461    result: &mut HashMap<String, Vec<String>>,
462    crate_name: Option<&str>,
463) {
464    let arg = match node.child_by_field_name("argument") {
465        Some(a) => a,
466        None => return,
467    };
468    let full_text = arg.utf8_text(source_bytes).unwrap_or("");
469
470    // Handle `crate::` prefix
471    if let Some(path_after_crate) = full_text.strip_prefix("crate::") {
472        parse_use_path(path_after_crate, result);
473        return;
474    }
475
476    // Handle `{crate_name}::` prefix for integration tests
477    if let Some(name) = crate_name {
478        let prefix = format!("{name}::");
479        if let Some(path_after_name) = full_text.strip_prefix(&prefix) {
480            parse_use_path(path_after_name, result);
481        }
482    }
483}
484
485/// Extract import specifiers with optional crate name support.
486/// When `crate_name` is `Some`, also resolves `use {crate_name}::...` imports
487/// in addition to the standard `use crate::...` imports.
488pub fn extract_import_specifiers_with_crate_name(
489    source: &str,
490    crate_name: Option<&str>,
491) -> Vec<(String, Vec<String>)> {
492    let mut parser = RustExtractor::parser();
493    let tree = match parser.parse(source, None) {
494        Some(t) => t,
495        None => return Vec::new(),
496    };
497    let source_bytes = source.as_bytes();
498
499    // Manual tree walking for Rust use statements, more reliable than queries
500    // for complex use trees
501    let root = tree.root_node();
502    let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
503
504    for i in 0..root.child_count() {
505        let child = root.child(i).unwrap();
506        if child.kind() == "use_declaration" {
507            extract_use_declaration(&child, source_bytes, &mut result_map, crate_name);
508        }
509    }
510
511    result_map.into_iter().collect()
512}
513
514// ---------------------------------------------------------------------------
515// Workspace support
516// ---------------------------------------------------------------------------
517
518/// A member crate in a Cargo workspace.
519#[derive(Debug)]
520pub struct WorkspaceMember {
521    /// Crate name (hyphens converted to underscores).
522    pub crate_name: String,
523    /// Absolute path to the member crate root (directory containing Cargo.toml).
524    pub member_root: std::path::PathBuf,
525}
526
527/// Directories to skip during workspace member traversal.
528const SKIP_DIRS: &[&str] = &["target", ".cargo", "vendor"];
529
530/// Maximum directory traversal depth when searching for workspace members.
531const MAX_TRAVERSE_DEPTH: usize = 4;
532
533/// Check whether a `Cargo.toml` at `scan_root` contains a `[workspace]` section.
534pub fn has_workspace_section(scan_root: &Path) -> bool {
535    let cargo_toml = scan_root.join("Cargo.toml");
536    let content = match std::fs::read_to_string(&cargo_toml) {
537        Ok(c) => c,
538        Err(_) => return false,
539    };
540    content.lines().any(|line| line.trim() == "[workspace]")
541}
542
543/// Find all member crates in a Cargo workspace rooted at `scan_root`.
544///
545/// Returns an empty `Vec` if `scan_root` does not have a `[workspace]` section
546/// in its `Cargo.toml`.
547///
548/// Supports both virtual workspaces (no `[package]`) and non-virtual workspaces
549/// (both `[workspace]` and `[package]`).
550///
551/// Directories named `target`, `.cargo`, `vendor`, or starting with `.` are
552/// skipped.  Traversal is limited to `MAX_TRAVERSE_DEPTH` levels.
553pub fn find_workspace_members(scan_root: &Path) -> Vec<WorkspaceMember> {
554    if !has_workspace_section(scan_root) {
555        return Vec::new();
556    }
557
558    let mut members = Vec::new();
559    find_members_recursive(scan_root, scan_root, 0, &mut members);
560    members
561}
562
563fn find_members_recursive(
564    scan_root: &Path,
565    dir: &Path,
566    depth: usize,
567    members: &mut Vec<WorkspaceMember>,
568) {
569    if depth > MAX_TRAVERSE_DEPTH {
570        return;
571    }
572
573    let read_dir = match std::fs::read_dir(dir) {
574        Ok(rd) => rd,
575        Err(_) => return,
576    };
577
578    for entry in read_dir.flatten() {
579        let path = entry.path();
580        if !path.is_dir() {
581            continue;
582        }
583
584        let dir_name = match path.file_name().and_then(|n| n.to_str()) {
585            Some(n) => n,
586            None => continue,
587        };
588
589        // Skip hidden directories and known non-source dirs
590        if dir_name.starts_with('.') || SKIP_DIRS.contains(&dir_name) {
591            continue;
592        }
593
594        // Skip the scan root itself (already checked above)
595        if path == scan_root {
596            continue;
597        }
598
599        // Check if this subdirectory has a member Cargo.toml with [package]
600        if let Some(crate_name) = parse_crate_name(&path) {
601            members.push(WorkspaceMember {
602                crate_name,
603                member_root: path.to_path_buf(),
604            });
605            // Don't recurse into member crates (avoids cross-crate confusion)
606            // However, nested workspaces / virtual manifests are rare; skip for now.
607            continue;
608        }
609
610        // Recurse into directories without their own [package]
611        find_members_recursive(scan_root, &path, depth + 1, members);
612    }
613}
614
615/// Find the workspace member that owns `path` by longest prefix match.
616///
617/// Returns `None` if no member's `member_root` is a prefix of `path`.
618pub fn find_member_for_path<'a>(
619    path: &Path,
620    members: &'a [WorkspaceMember],
621) -> Option<&'a WorkspaceMember> {
622    members
623        .iter()
624        .filter(|m| path.starts_with(&m.member_root))
625        .max_by_key(|m| m.member_root.components().count())
626}
627
628/// Parse the `name = "..."` field from a Cargo.toml `[package]` section.
629/// Hyphens in the name are converted to underscores.
630/// Returns `None` if the file cannot be read or `[package]` section is absent.
631pub fn parse_crate_name(scan_root: &Path) -> Option<String> {
632    let cargo_toml = scan_root.join("Cargo.toml");
633    let content = std::fs::read_to_string(&cargo_toml).ok()?;
634
635    let mut in_package = false;
636    for line in content.lines() {
637        let trimmed = line.trim();
638
639        // Detect section headers
640        if trimmed.starts_with('[') {
641            if trimmed == "[package]" {
642                in_package = true;
643            } else {
644                // Once we hit another section, stop looking
645                if in_package {
646                    break;
647                }
648            }
649            continue;
650        }
651
652        if in_package {
653            // Parse `name = "..."` or `name = '...'`
654            if let Some(rest) = trimmed.strip_prefix("name") {
655                let rest = rest.trim();
656                if let Some(rest) = rest.strip_prefix('=') {
657                    let rest = rest.trim();
658                    // Strip surrounding quotes
659                    let name = if let Some(inner) =
660                        rest.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
661                    {
662                        inner
663                    } else if let Some(inner) =
664                        rest.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))
665                    {
666                        inner
667                    } else {
668                        continue;
669                    };
670                    return Some(name.replace('-', "_"));
671                }
672            }
673        }
674    }
675
676    None
677}
678
679/// Parse a use path after `crate::` has been stripped.
680/// e.g. "user::User" -> ("user", ["User"])
681///      "models::user::User" -> ("models/user", ["User"])
682///      "user::{User, Admin}" -> ("user", ["User", "Admin"])
683///      "user::*" -> ("user", [])
684fn parse_use_path(path: &str, result: &mut HashMap<String, Vec<String>>) {
685    // Handle use list: `module::{A, B}`
686    if let Some(brace_start) = path.find('{') {
687        let module_part = &path[..brace_start.saturating_sub(2)]; // strip trailing ::
688        let specifier = module_part.replace("::", "/");
689        if let Some(brace_end) = path.find('}') {
690            let list_content = &path[brace_start + 1..brace_end];
691            let symbols: Vec<String> = list_content
692                .split(',')
693                .map(|s| s.trim().to_string())
694                .filter(|s| !s.is_empty() && s != "*")
695                .collect();
696            if !specifier.is_empty() {
697                result.entry(specifier).or_default().extend(symbols);
698            }
699        }
700        return;
701    }
702
703    // Handle wildcard: `module::*`
704    if let Some(module_part) = path.strip_suffix("::*") {
705        let specifier = module_part.replace("::", "/");
706        if !specifier.is_empty() {
707            result.entry(specifier).or_default();
708        }
709        return;
710    }
711
712    // Simple path: `module::Symbol`
713    let parts: Vec<&str> = path.split("::").collect();
714    if parts.len() >= 2 {
715        let module_parts = &parts[..parts.len() - 1];
716        let symbol = parts[parts.len() - 1];
717        let specifier = module_parts.join("/");
718        result
719            .entry(specifier)
720            .or_default()
721            .push(symbol.to_string());
722    }
723}
724
725/// Extract pub use re-exports for barrel files.
726fn extract_pub_use_re_exports(
727    arg: &tree_sitter::Node,
728    source_bytes: &[u8],
729    result: &mut Vec<BarrelReExport>,
730) {
731    let full_text = arg.utf8_text(source_bytes).unwrap_or("");
732
733    // pub use module::*;
734    if full_text.ends_with("::*") {
735        let module_part = full_text.strip_suffix("::*").unwrap_or("");
736        result.push(BarrelReExport {
737            symbols: Vec::new(),
738            from_specifier: format!("./{}", module_part.replace("::", "/")),
739            wildcard: true,
740            namespace_wildcard: false,
741        });
742        return;
743    }
744
745    // pub use module::{A, B};
746    if let Some(brace_start) = full_text.find('{') {
747        let module_part = &full_text[..brace_start.saturating_sub(2)]; // strip trailing ::
748        if let Some(brace_end) = full_text.find('}') {
749            let list_content = &full_text[brace_start + 1..brace_end];
750            let symbols: Vec<String> = list_content
751                .split(',')
752                .map(|s| s.trim().to_string())
753                .filter(|s| !s.is_empty())
754                .collect();
755            result.push(BarrelReExport {
756                symbols,
757                from_specifier: format!("./{}", module_part.replace("::", "/")),
758                wildcard: false,
759                namespace_wildcard: false,
760            });
761        }
762        return;
763    }
764
765    // pub use module::Symbol;
766    let parts: Vec<&str> = full_text.split("::").collect();
767    if parts.len() >= 2 {
768        let module_parts = &parts[..parts.len() - 1];
769        let symbol = parts[parts.len() - 1];
770        result.push(BarrelReExport {
771            symbols: vec![symbol.to_string()],
772            from_specifier: format!("./{}", module_parts.join("/")),
773            wildcard: false,
774            namespace_wildcard: false,
775        });
776    }
777}
778
779// ---------------------------------------------------------------------------
780// Concrete methods (not in trait)
781// ---------------------------------------------------------------------------
782
783impl RustExtractor {
784    /// Layer 0 + Layer 1 + Layer 2: Map test files to production files.
785    ///
786    /// Layer 0: Inline test self-mapping (#[cfg(test)] in production files)
787    /// Layer 1: Filename convention matching
788    /// Layer 2: Import tracing (use crate::...)
789    pub fn map_test_files_with_imports(
790        &self,
791        production_files: &[String],
792        test_sources: &HashMap<String, String>,
793        scan_root: &Path,
794    ) -> Vec<FileMapping> {
795        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
796
797        // Layer 1: filename convention
798        let mut mappings =
799            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
800
801        // Layer 0: Inline test self-mapping
802        for (idx, prod_file) in production_files.iter().enumerate() {
803            if let Ok(source) = std::fs::read_to_string(prod_file) {
804                if detect_inline_tests(&source) {
805                    // Self-map: production file maps to itself
806                    if !mappings[idx].test_files.contains(prod_file) {
807                        mappings[idx].test_files.push(prod_file.clone());
808                    }
809                }
810            }
811        }
812
813        // Build canonical path -> production index lookup
814        let canonical_root = match scan_root.canonicalize() {
815            Ok(r) => r,
816            Err(_) => return mappings,
817        };
818        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
819        for (idx, prod) in production_files.iter().enumerate() {
820            if let Ok(canonical) = Path::new(prod).canonicalize() {
821                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
822            }
823        }
824
825        // Record Layer 1 matches per production file index
826        let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
827            .iter()
828            .map(|m| m.test_files.iter().cloned().collect())
829            .collect();
830
831        // Resolve crate name for integration test import matching
832        let crate_name = parse_crate_name(scan_root);
833        let members = find_workspace_members(scan_root);
834
835        // Layer 2: import tracing
836        if let Some(ref name) = crate_name {
837            // Root has a [package]: apply L2 for root crate itself
838            self.apply_l2_imports(
839                test_sources,
840                name,
841                scan_root,
842                &canonical_root,
843                &canonical_to_idx,
844                &mut mappings,
845            );
846        }
847
848        if !members.is_empty() {
849            // Workspace mode: apply L2 per member crate
850            for member in &members {
851                // Collect only the test files belonging to this member
852                let member_test_sources: HashMap<String, String> = test_sources
853                    .iter()
854                    .filter(|(path, _)| {
855                        find_member_for_path(Path::new(path.as_str()), &members)
856                            .map(|m| std::ptr::eq(m, member))
857                            .unwrap_or(false)
858                    })
859                    .map(|(k, v)| (k.clone(), v.clone()))
860                    .collect();
861
862                self.apply_l2_imports(
863                    &member_test_sources,
864                    &member.crate_name,
865                    &member.member_root,
866                    &canonical_root,
867                    &canonical_to_idx,
868                    &mut mappings,
869                );
870            }
871        } else if crate_name.is_none() {
872            // Fallback: no [package] and no workspace members; apply L2 with "crate"
873            // pseudo-name to handle `use crate::...` references
874            self.apply_l2_imports(
875                test_sources,
876                "crate",
877                scan_root,
878                &canonical_root,
879                &canonical_to_idx,
880                &mut mappings,
881            );
882        }
883
884        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
885        // set strategy to ImportTracing
886        for (i, mapping) in mappings.iter_mut().enumerate() {
887            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
888            if !has_layer1 && !mapping.test_files.is_empty() {
889                mapping.strategy = MappingStrategy::ImportTracing;
890            }
891        }
892
893        mappings
894    }
895
896    /// Apply Layer 2 import tracing for a single crate root.
897    ///
898    /// `crate_name`: the crate name (underscored).
899    /// `crate_root`: the crate root directory (contains `Cargo.toml` and `src/`).
900    fn apply_l2_imports(
901        &self,
902        test_sources: &HashMap<String, String>,
903        crate_name: &str,
904        crate_root: &Path,
905        canonical_root: &Path,
906        canonical_to_idx: &HashMap<String, usize>,
907        mappings: &mut [FileMapping],
908    ) {
909        for (test_file, source) in test_sources {
910            let imports = extract_import_specifiers_with_crate_name(source, Some(crate_name));
911            let mut matched_indices = HashSet::<usize>::new();
912
913            for (specifier, symbols) in &imports {
914                // Convert specifier to file path relative to member crate root (src/)
915                let src_relative = crate_root.join("src").join(specifier);
916
917                if let Some(resolved) = exspec_core::observe::resolve_absolute_base_to_file(
918                    self,
919                    &src_relative,
920                    canonical_root,
921                ) {
922                    exspec_core::observe::collect_import_matches(
923                        self,
924                        &resolved,
925                        symbols,
926                        canonical_to_idx,
927                        &mut matched_indices,
928                        canonical_root,
929                    );
930                }
931            }
932
933            for idx in matched_indices {
934                if !mappings[idx].test_files.contains(test_file) {
935                    mappings[idx].test_files.push(test_file.clone());
936                }
937            }
938        }
939    }
940}
941
942// ---------------------------------------------------------------------------
943// Tests
944// ---------------------------------------------------------------------------
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use std::path::PathBuf;
950
951    // -----------------------------------------------------------------------
952    // RS-STEM-01: tests/test_foo.rs -> test_stem = Some("foo")
953    // -----------------------------------------------------------------------
954    #[test]
955    fn rs_stem_01_test_prefix() {
956        // Given: a file named tests/test_foo.rs
957        // When: test_stem is called
958        // Then: returns Some("foo")
959        let extractor = RustExtractor::new();
960        assert_eq!(extractor.test_stem("tests/test_foo.rs"), Some("foo"));
961    }
962
963    // -----------------------------------------------------------------------
964    // RS-STEM-02: tests/foo_test.rs -> test_stem = Some("foo")
965    // -----------------------------------------------------------------------
966    #[test]
967    fn rs_stem_02_test_suffix() {
968        // Given: a file named tests/foo_test.rs
969        // When: test_stem is called
970        // Then: returns Some("foo")
971        let extractor = RustExtractor::new();
972        assert_eq!(extractor.test_stem("tests/foo_test.rs"), Some("foo"));
973    }
974
975    // -----------------------------------------------------------------------
976    // RS-STEM-03: tests/integration.rs -> test_stem = Some("integration")
977    // -----------------------------------------------------------------------
978    #[test]
979    fn rs_stem_03_tests_dir_integration() {
980        // Given: a file in tests/ directory without test_ prefix or _test suffix
981        // When: test_stem is called
982        // Then: returns Some("integration") because tests/ directory files are integration tests
983        let extractor = RustExtractor::new();
984        assert_eq!(
985            extractor.test_stem("tests/integration.rs"),
986            Some("integration")
987        );
988    }
989
990    // -----------------------------------------------------------------------
991    // RS-STEM-04: src/user.rs -> test_stem = None
992    // -----------------------------------------------------------------------
993    #[test]
994    fn rs_stem_04_production_file_no_test_stem() {
995        // Given: a production file in src/
996        // When: test_stem is called
997        // Then: returns None
998        let extractor = RustExtractor::new();
999        assert_eq!(extractor.test_stem("src/user.rs"), None);
1000    }
1001
1002    // -----------------------------------------------------------------------
1003    // RS-STEM-05: src/user.rs -> production_stem = Some("user")
1004    // -----------------------------------------------------------------------
1005    #[test]
1006    fn rs_stem_05_production_stem_regular() {
1007        // Given: a regular production file
1008        // When: production_stem is called
1009        // Then: returns Some("user")
1010        let extractor = RustExtractor::new();
1011        assert_eq!(extractor.production_stem("src/user.rs"), Some("user"));
1012    }
1013
1014    // -----------------------------------------------------------------------
1015    // RS-STEM-06: src/lib.rs -> production_stem = None
1016    // -----------------------------------------------------------------------
1017    #[test]
1018    fn rs_stem_06_production_stem_lib() {
1019        // Given: lib.rs (barrel file)
1020        // When: production_stem is called
1021        // Then: returns None
1022        let extractor = RustExtractor::new();
1023        assert_eq!(extractor.production_stem("src/lib.rs"), None);
1024    }
1025
1026    // -----------------------------------------------------------------------
1027    // RS-STEM-07: src/mod.rs -> production_stem = None
1028    // -----------------------------------------------------------------------
1029    #[test]
1030    fn rs_stem_07_production_stem_mod() {
1031        // Given: mod.rs (barrel file)
1032        // When: production_stem is called
1033        // Then: returns None
1034        let extractor = RustExtractor::new();
1035        assert_eq!(extractor.production_stem("src/mod.rs"), None);
1036    }
1037
1038    // -----------------------------------------------------------------------
1039    // RS-STEM-08: src/main.rs -> production_stem = None
1040    // -----------------------------------------------------------------------
1041    #[test]
1042    fn rs_stem_08_production_stem_main() {
1043        // Given: main.rs (entry point)
1044        // When: production_stem is called
1045        // Then: returns None
1046        let extractor = RustExtractor::new();
1047        assert_eq!(extractor.production_stem("src/main.rs"), None);
1048    }
1049
1050    // -----------------------------------------------------------------------
1051    // RS-STEM-09: tests/test_foo.rs -> production_stem = None
1052    // -----------------------------------------------------------------------
1053    #[test]
1054    fn rs_stem_09_production_stem_test_file() {
1055        // Given: a test file
1056        // When: production_stem is called
1057        // Then: returns None
1058        let extractor = RustExtractor::new();
1059        assert_eq!(extractor.production_stem("tests/test_foo.rs"), None);
1060    }
1061
1062    // -----------------------------------------------------------------------
1063    // RS-HELPER-01: build.rs -> is_non_sut_helper = true
1064    // -----------------------------------------------------------------------
1065    #[test]
1066    fn rs_helper_01_build_rs() {
1067        // Given: build.rs
1068        // When: is_non_sut_helper is called
1069        // Then: returns true
1070        let extractor = RustExtractor::new();
1071        assert!(extractor.is_non_sut_helper("build.rs", false));
1072    }
1073
1074    // -----------------------------------------------------------------------
1075    // RS-HELPER-02: tests/common/mod.rs -> is_non_sut_helper = true
1076    // -----------------------------------------------------------------------
1077    #[test]
1078    fn rs_helper_02_tests_common() {
1079        // Given: tests/common/mod.rs (test helper module)
1080        // When: is_non_sut_helper is called
1081        // Then: returns true
1082        let extractor = RustExtractor::new();
1083        assert!(extractor.is_non_sut_helper("tests/common/mod.rs", false));
1084    }
1085
1086    // -----------------------------------------------------------------------
1087    // RS-HELPER-03: src/user.rs -> is_non_sut_helper = false
1088    // -----------------------------------------------------------------------
1089    #[test]
1090    fn rs_helper_03_regular_production_file() {
1091        // Given: a regular production file
1092        // When: is_non_sut_helper is called
1093        // Then: returns false
1094        let extractor = RustExtractor::new();
1095        assert!(!extractor.is_non_sut_helper("src/user.rs", false));
1096    }
1097
1098    // -----------------------------------------------------------------------
1099    // RS-HELPER-04: benches/bench.rs -> is_non_sut_helper = true
1100    // -----------------------------------------------------------------------
1101    #[test]
1102    fn rs_helper_04_benches() {
1103        // Given: a benchmark file
1104        // When: is_non_sut_helper is called
1105        // Then: returns true
1106        let extractor = RustExtractor::new();
1107        assert!(extractor.is_non_sut_helper("benches/bench.rs", false));
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // RS-L0-01: #[cfg(test)] mod tests {} -> detect_inline_tests = true
1112    // -----------------------------------------------------------------------
1113    #[test]
1114    fn rs_l0_01_cfg_test_present() {
1115        // Given: source with #[cfg(test)] mod tests block
1116        let source = r#"
1117pub fn add(a: i32, b: i32) -> i32 { a + b }
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    #[test]
1124    fn test_add() {
1125        assert_eq!(add(1, 2), 3);
1126    }
1127}
1128"#;
1129        // When: detect_inline_tests is called
1130        // Then: returns true
1131        assert!(detect_inline_tests(source));
1132    }
1133
1134    // -----------------------------------------------------------------------
1135    // RS-L0-02: no #[cfg(test)] -> detect_inline_tests = false
1136    // -----------------------------------------------------------------------
1137    #[test]
1138    fn rs_l0_02_no_cfg_test() {
1139        // Given: source without #[cfg(test)]
1140        let source = r#"
1141pub fn add(a: i32, b: i32) -> i32 { a + b }
1142"#;
1143        // When: detect_inline_tests is called
1144        // Then: returns false
1145        assert!(!detect_inline_tests(source));
1146    }
1147
1148    // -----------------------------------------------------------------------
1149    // RS-L0-03: #[cfg(not(test))] only -> detect_inline_tests = false
1150    // -----------------------------------------------------------------------
1151    #[test]
1152    fn rs_l0_03_cfg_not_test() {
1153        // Given: source with #[cfg(not(test))] only (no #[cfg(test)])
1154        let source = r#"
1155#[cfg(not(test))]
1156mod production_only {
1157    pub fn real_thing() {}
1158}
1159"#;
1160        // When: detect_inline_tests is called
1161        // Then: returns false
1162        assert!(!detect_inline_tests(source));
1163    }
1164
1165    // -----------------------------------------------------------------------
1166    // RS-FUNC-01: pub fn create_user() {} -> name="create_user", is_exported=true
1167    // -----------------------------------------------------------------------
1168    #[test]
1169    fn rs_func_01_pub_function() {
1170        // Given: source with a pub function
1171        let source = "pub fn create_user() {}\n";
1172
1173        // When: extract_production_functions is called
1174        let extractor = RustExtractor::new();
1175        let result = extractor.extract_production_functions(source, "src/user.rs");
1176
1177        // Then: name="create_user", is_exported=true
1178        let func = result.iter().find(|f| f.name == "create_user");
1179        assert!(func.is_some(), "create_user not found in {:?}", result);
1180        assert!(func.unwrap().is_exported);
1181    }
1182
1183    // -----------------------------------------------------------------------
1184    // RS-FUNC-02: fn private_fn() {} -> name="private_fn", is_exported=false
1185    // -----------------------------------------------------------------------
1186    #[test]
1187    fn rs_func_02_private_function() {
1188        // Given: source with a private function
1189        let source = "fn private_fn() {}\n";
1190
1191        // When: extract_production_functions is called
1192        let extractor = RustExtractor::new();
1193        let result = extractor.extract_production_functions(source, "src/internal.rs");
1194
1195        // Then: name="private_fn", is_exported=false
1196        let func = result.iter().find(|f| f.name == "private_fn");
1197        assert!(func.is_some(), "private_fn not found in {:?}", result);
1198        assert!(!func.unwrap().is_exported);
1199    }
1200
1201    // -----------------------------------------------------------------------
1202    // RS-FUNC-03: impl User { pub fn save() {} } -> name="save", class_name=Some("User")
1203    // -----------------------------------------------------------------------
1204    #[test]
1205    fn rs_func_03_impl_method() {
1206        // Given: source with an impl block
1207        let source = r#"
1208struct User;
1209
1210impl User {
1211    pub fn save(&self) {}
1212}
1213"#;
1214        // When: extract_production_functions is called
1215        let extractor = RustExtractor::new();
1216        let result = extractor.extract_production_functions(source, "src/user.rs");
1217
1218        // Then: name="save", class_name=Some("User")
1219        let method = result.iter().find(|f| f.name == "save");
1220        assert!(method.is_some(), "save not found in {:?}", result);
1221        let method = method.unwrap();
1222        assert_eq!(method.class_name, Some("User".to_string()));
1223        assert!(method.is_exported);
1224    }
1225
1226    // -----------------------------------------------------------------------
1227    // RS-FUNC-04: functions inside #[cfg(test)] mod tests are NOT extracted
1228    // -----------------------------------------------------------------------
1229    #[test]
1230    fn rs_func_04_cfg_test_excluded() {
1231        // Given: source with functions inside #[cfg(test)] mod
1232        let source = r#"
1233pub fn real_function() {}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238
1239    #[test]
1240    fn test_real_function() {
1241        assert!(true);
1242    }
1243}
1244"#;
1245        // When: extract_production_functions is called
1246        let extractor = RustExtractor::new();
1247        let result = extractor.extract_production_functions(source, "src/lib.rs");
1248
1249        // Then: only real_function is extracted, not test_real_function
1250        assert_eq!(result.len(), 1);
1251        assert_eq!(result[0].name, "real_function");
1252    }
1253
1254    // -----------------------------------------------------------------------
1255    // RS-IMP-01: use crate::user::User -> ("user", ["User"])
1256    // -----------------------------------------------------------------------
1257    #[test]
1258    fn rs_imp_01_simple_crate_import() {
1259        // Given: source with a simple crate import
1260        let source = "use crate::user::User;\n";
1261
1262        // When: extract_all_import_specifiers is called
1263        let extractor = RustExtractor::new();
1264        let result = extractor.extract_all_import_specifiers(source);
1265
1266        // Then: ("user", ["User"])
1267        let entry = result.iter().find(|(spec, _)| spec == "user");
1268        assert!(entry.is_some(), "user not found in {:?}", result);
1269        let (_, symbols) = entry.unwrap();
1270        assert!(symbols.contains(&"User".to_string()));
1271    }
1272
1273    // -----------------------------------------------------------------------
1274    // RS-IMP-02: use crate::models::user::User -> ("models/user", ["User"])
1275    // -----------------------------------------------------------------------
1276    #[test]
1277    fn rs_imp_02_nested_crate_import() {
1278        // Given: source with a nested crate import
1279        let source = "use crate::models::user::User;\n";
1280
1281        // When: extract_all_import_specifiers is called
1282        let extractor = RustExtractor::new();
1283        let result = extractor.extract_all_import_specifiers(source);
1284
1285        // Then: ("models/user", ["User"])
1286        let entry = result.iter().find(|(spec, _)| spec == "models/user");
1287        assert!(entry.is_some(), "models/user not found in {:?}", result);
1288        let (_, symbols) = entry.unwrap();
1289        assert!(symbols.contains(&"User".to_string()));
1290    }
1291
1292    // -----------------------------------------------------------------------
1293    // RS-IMP-03: use crate::user::{User, Admin} -> ("user", ["User", "Admin"])
1294    // -----------------------------------------------------------------------
1295    #[test]
1296    fn rs_imp_03_use_list() {
1297        // Given: source with a use list
1298        let source = "use crate::user::{User, Admin};\n";
1299
1300        // When: extract_all_import_specifiers is called
1301        let extractor = RustExtractor::new();
1302        let result = extractor.extract_all_import_specifiers(source);
1303
1304        // Then: ("user", ["User", "Admin"])
1305        let entry = result.iter().find(|(spec, _)| spec == "user");
1306        assert!(entry.is_some(), "user not found in {:?}", result);
1307        let (_, symbols) = entry.unwrap();
1308        assert!(
1309            symbols.contains(&"User".to_string()),
1310            "User not in {:?}",
1311            symbols
1312        );
1313        assert!(
1314            symbols.contains(&"Admin".to_string()),
1315            "Admin not in {:?}",
1316            symbols
1317        );
1318    }
1319
1320    // -----------------------------------------------------------------------
1321    // RS-IMP-04: use std::collections::HashMap -> external crate -> skipped
1322    // -----------------------------------------------------------------------
1323    #[test]
1324    fn rs_imp_04_external_crate_skipped() {
1325        // Given: source with an external crate import
1326        let source = "use std::collections::HashMap;\n";
1327
1328        // When: extract_all_import_specifiers is called
1329        let extractor = RustExtractor::new();
1330        let result = extractor.extract_all_import_specifiers(source);
1331
1332        // Then: not included (only crate:: imports are tracked)
1333        assert!(
1334            result.is_empty(),
1335            "external imports should be skipped: {:?}",
1336            result
1337        );
1338    }
1339
1340    // -----------------------------------------------------------------------
1341    // RS-BARREL-01: mod.rs -> is_barrel_file = true
1342    // -----------------------------------------------------------------------
1343    #[test]
1344    fn rs_barrel_01_mod_rs() {
1345        // Given: mod.rs
1346        // When: is_barrel_file is called
1347        // Then: returns true
1348        let extractor = RustExtractor::new();
1349        assert!(extractor.is_barrel_file("src/models/mod.rs"));
1350    }
1351
1352    // -----------------------------------------------------------------------
1353    // RS-BARREL-02: lib.rs -> is_barrel_file = true
1354    // -----------------------------------------------------------------------
1355    #[test]
1356    fn rs_barrel_02_lib_rs() {
1357        // Given: lib.rs
1358        // When: is_barrel_file is called
1359        // Then: returns true
1360        let extractor = RustExtractor::new();
1361        assert!(extractor.is_barrel_file("src/lib.rs"));
1362    }
1363
1364    // -----------------------------------------------------------------------
1365    // RS-BARREL-03: pub mod user; in mod.rs -> extract_barrel_re_exports
1366    // -----------------------------------------------------------------------
1367    #[test]
1368    fn rs_barrel_03_pub_mod() {
1369        // Given: mod.rs with pub mod user;
1370        let source = "pub mod user;\n";
1371
1372        // When: extract_barrel_re_exports is called
1373        let extractor = RustExtractor::new();
1374        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
1375
1376        // Then: from_specifier="./user", wildcard=true
1377        let entry = result.iter().find(|e| e.from_specifier == "./user");
1378        assert!(entry.is_some(), "./user not found in {:?}", result);
1379        assert!(entry.unwrap().wildcard);
1380    }
1381
1382    // -----------------------------------------------------------------------
1383    // RS-BARREL-04: pub use user::*; in mod.rs -> extract_barrel_re_exports
1384    // -----------------------------------------------------------------------
1385    #[test]
1386    fn rs_barrel_04_pub_use_wildcard() {
1387        // Given: mod.rs with pub use user::*;
1388        let source = "pub use user::*;\n";
1389
1390        // When: extract_barrel_re_exports is called
1391        let extractor = RustExtractor::new();
1392        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
1393
1394        // Then: from_specifier="./user", wildcard=true
1395        let entry = result.iter().find(|e| e.from_specifier == "./user");
1396        assert!(entry.is_some(), "./user not found in {:?}", result);
1397        assert!(entry.unwrap().wildcard);
1398    }
1399
1400    // -----------------------------------------------------------------------
1401    // RS-E2E-01: inline tests -> Layer 0 self-map
1402    // -----------------------------------------------------------------------
1403    #[test]
1404    fn rs_e2e_01_inline_test_self_map() {
1405        // Given: a temp directory with a production file containing inline tests
1406        let tmp = tempfile::tempdir().unwrap();
1407        let src_dir = tmp.path().join("src");
1408        std::fs::create_dir_all(&src_dir).unwrap();
1409
1410        let user_rs = src_dir.join("user.rs");
1411        std::fs::write(
1412            &user_rs,
1413            r#"pub fn create_user() {}
1414
1415#[cfg(test)]
1416mod tests {
1417    use super::*;
1418    #[test]
1419    fn test_create_user() { assert!(true); }
1420}
1421"#,
1422        )
1423        .unwrap();
1424
1425        let extractor = RustExtractor::new();
1426        let prod_path = user_rs.to_string_lossy().into_owned();
1427        let production_files = vec![prod_path.clone()];
1428        let test_sources: HashMap<String, String> = HashMap::new();
1429
1430        // When: map_test_files_with_imports is called
1431        let result =
1432            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1433
1434        // Then: user.rs is self-mapped (Layer 0)
1435        let mapping = result.iter().find(|m| m.production_file == prod_path);
1436        assert!(mapping.is_some());
1437        assert!(
1438            mapping.unwrap().test_files.contains(&prod_path),
1439            "Expected self-map for inline tests: {:?}",
1440            mapping.unwrap().test_files
1441        );
1442    }
1443
1444    // -----------------------------------------------------------------------
1445    // RS-E2E-02: stem match -> Layer 1
1446    // -----------------------------------------------------------------------
1447    #[test]
1448    fn rs_e2e_02_layer1_stem_match() {
1449        // Given: production file and test file with matching stems
1450        let extractor = RustExtractor::new();
1451        let production_files = vec!["src/user.rs".to_string()];
1452        let test_sources: HashMap<String, String> =
1453            [("tests/test_user.rs".to_string(), String::new())]
1454                .into_iter()
1455                .collect();
1456
1457        // When: map_test_files_with_imports is called
1458        let scan_root = PathBuf::from(".");
1459        let result =
1460            extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1461
1462        // Then: Layer 1 stem match (same directory not required for test_stem)
1463        // Note: map_test_files requires same directory, but tests/ files have test_stem
1464        // that matches production_stem. However, core::map_test_files uses directory matching.
1465        // For cross-directory matching, we rely on Layer 2 (import tracing).
1466        // This test verifies the mapping structure is correct.
1467        let mapping = result.iter().find(|m| m.production_file == "src/user.rs");
1468        assert!(mapping.is_some());
1469    }
1470
1471    // -----------------------------------------------------------------------
1472    // RS-E2E-03: import match -> Layer 2
1473    // -----------------------------------------------------------------------
1474    #[test]
1475    fn rs_e2e_03_layer2_import_tracing() {
1476        // Given: a temp directory with production and test files
1477        let tmp = tempfile::tempdir().unwrap();
1478        let src_dir = tmp.path().join("src");
1479        let tests_dir = tmp.path().join("tests");
1480        std::fs::create_dir_all(&src_dir).unwrap();
1481        std::fs::create_dir_all(&tests_dir).unwrap();
1482
1483        let service_rs = src_dir.join("service.rs");
1484        std::fs::write(&service_rs, "pub struct Service;\n").unwrap();
1485
1486        let test_service_rs = tests_dir.join("test_service.rs");
1487        let test_source = "use crate::service::Service;\n\n#[test]\nfn test_it() {}\n";
1488        std::fs::write(&test_service_rs, test_source).unwrap();
1489
1490        let extractor = RustExtractor::new();
1491        let prod_path = service_rs.to_string_lossy().into_owned();
1492        let test_path = test_service_rs.to_string_lossy().into_owned();
1493        let production_files = vec![prod_path.clone()];
1494        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
1495            .into_iter()
1496            .collect();
1497
1498        // When: map_test_files_with_imports is called
1499        let result =
1500            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1501
1502        // Then: service.rs is matched to test_service.rs via import tracing
1503        let mapping = result.iter().find(|m| m.production_file == prod_path);
1504        assert!(mapping.is_some());
1505        assert!(
1506            mapping.unwrap().test_files.contains(&test_path),
1507            "Expected import tracing match: {:?}",
1508            mapping.unwrap().test_files
1509        );
1510    }
1511
1512    // -----------------------------------------------------------------------
1513    // RS-E2E-04: tests/common/mod.rs -> helper excluded
1514    // -----------------------------------------------------------------------
1515    #[test]
1516    fn rs_e2e_04_helper_excluded() {
1517        // Given: tests/common/mod.rs alongside test files
1518        let extractor = RustExtractor::new();
1519        let production_files = vec!["src/user.rs".to_string()];
1520        let test_sources: HashMap<String, String> = [
1521            ("tests/test_user.rs".to_string(), String::new()),
1522            (
1523                "tests/common/mod.rs".to_string(),
1524                "pub fn setup() {}\n".to_string(),
1525            ),
1526        ]
1527        .into_iter()
1528        .collect();
1529
1530        // When: map_test_files_with_imports is called
1531        let scan_root = PathBuf::from(".");
1532        let result =
1533            extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1534
1535        // Then: tests/common/mod.rs is NOT in any mapping
1536        for mapping in &result {
1537            assert!(
1538                !mapping
1539                    .test_files
1540                    .iter()
1541                    .any(|f| f.contains("common/mod.rs")),
1542                "common/mod.rs should not appear: {:?}",
1543                mapping
1544            );
1545        }
1546    }
1547
1548    // -----------------------------------------------------------------------
1549    // RS-CRATE-01: parse_crate_name: 正常パース
1550    // -----------------------------------------------------------------------
1551    #[test]
1552    fn rs_crate_01_parse_crate_name_hyphen() {
1553        // Given: Cargo.toml に [package]\nname = "my-crate" を含む tempdir
1554        let tmp = tempfile::tempdir().unwrap();
1555        std::fs::write(
1556            tmp.path().join("Cargo.toml"),
1557            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
1558        )
1559        .unwrap();
1560
1561        // When: parse_crate_name(dir) を呼ぶ
1562        let result = parse_crate_name(tmp.path());
1563
1564        // Then: Some("my_crate") を返す(ハイフン→アンダースコア変換)
1565        assert_eq!(result, Some("my_crate".to_string()));
1566    }
1567
1568    // -----------------------------------------------------------------------
1569    // RS-CRATE-02: parse_crate_name: ハイフンなし
1570    // -----------------------------------------------------------------------
1571    #[test]
1572    fn rs_crate_02_parse_crate_name_no_hyphen() {
1573        // Given: Cargo.toml に name = "tokio" を含む tempdir
1574        let tmp = tempfile::tempdir().unwrap();
1575        std::fs::write(
1576            tmp.path().join("Cargo.toml"),
1577            "[package]\nname = \"tokio\"\nversion = \"1.0.0\"\n",
1578        )
1579        .unwrap();
1580
1581        // When: parse_crate_name(dir)
1582        let result = parse_crate_name(tmp.path());
1583
1584        // Then: Some("tokio")
1585        assert_eq!(result, Some("tokio".to_string()));
1586    }
1587
1588    // -----------------------------------------------------------------------
1589    // RS-CRATE-03: parse_crate_name: ファイルなし
1590    // -----------------------------------------------------------------------
1591    #[test]
1592    fn rs_crate_03_parse_crate_name_no_file() {
1593        // Given: Cargo.toml が存在しない tempdir
1594        let tmp = tempfile::tempdir().unwrap();
1595
1596        // When: parse_crate_name(dir)
1597        let result = parse_crate_name(tmp.path());
1598
1599        // Then: None
1600        assert_eq!(result, None);
1601    }
1602
1603    // -----------------------------------------------------------------------
1604    // RS-CRATE-04: parse_crate_name: workspace (package なし)
1605    // -----------------------------------------------------------------------
1606    #[test]
1607    fn rs_crate_04_parse_crate_name_workspace() {
1608        // Given: [workspace]\nmembers = ["crate1"] のみの Cargo.toml
1609        let tmp = tempfile::tempdir().unwrap();
1610        std::fs::write(
1611            tmp.path().join("Cargo.toml"),
1612            "[workspace]\nmembers = [\"crate1\"]\n",
1613        )
1614        .unwrap();
1615
1616        // When: parse_crate_name(dir)
1617        let result = parse_crate_name(tmp.path());
1618
1619        // Then: None
1620        assert_eq!(result, None);
1621    }
1622
1623    // -----------------------------------------------------------------------
1624    // RS-IMP-05: crate_name simple import
1625    // -----------------------------------------------------------------------
1626    #[test]
1627    fn rs_imp_05_crate_name_simple_import() {
1628        // Given: source = "use my_crate::user::User;\n", crate_name = Some("my_crate")
1629        let source = "use my_crate::user::User;\n";
1630
1631        // When: extract_import_specifiers_with_crate_name(source, Some("my_crate"))
1632        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
1633
1634        // Then: [("user", ["User"])]
1635        let entry = result.iter().find(|(spec, _)| spec == "user");
1636        assert!(entry.is_some(), "user not found in {:?}", result);
1637        let (_, symbols) = entry.unwrap();
1638        assert!(
1639            symbols.contains(&"User".to_string()),
1640            "User not in {:?}",
1641            symbols
1642        );
1643    }
1644
1645    // -----------------------------------------------------------------------
1646    // RS-IMP-06: crate_name use list
1647    // -----------------------------------------------------------------------
1648    #[test]
1649    fn rs_imp_06_crate_name_use_list() {
1650        // Given: source = "use my_crate::user::{User, Admin};\n", crate_name = Some("my_crate")
1651        let source = "use my_crate::user::{User, Admin};\n";
1652
1653        // When: extract_import_specifiers_with_crate_name(source, Some("my_crate"))
1654        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
1655
1656        // Then: [("user", ["User", "Admin"])]
1657        let entry = result.iter().find(|(spec, _)| spec == "user");
1658        assert!(entry.is_some(), "user not found in {:?}", result);
1659        let (_, symbols) = entry.unwrap();
1660        assert!(
1661            symbols.contains(&"User".to_string()),
1662            "User not in {:?}",
1663            symbols
1664        );
1665        assert!(
1666            symbols.contains(&"Admin".to_string()),
1667            "Admin not in {:?}",
1668            symbols
1669        );
1670    }
1671
1672    // -----------------------------------------------------------------------
1673    // RS-IMP-07: crate_name=None ではスキップ
1674    // -----------------------------------------------------------------------
1675    #[test]
1676    fn rs_imp_07_crate_name_none_skips() {
1677        // Given: source = "use my_crate::user::User;\n", crate_name = None
1678        let source = "use my_crate::user::User;\n";
1679
1680        // When: extract_import_specifiers_with_crate_name(source, None)
1681        let result = extract_import_specifiers_with_crate_name(source, None);
1682
1683        // Then: [] (空)
1684        assert!(
1685            result.is_empty(),
1686            "Expected empty result when crate_name=None, got: {:?}",
1687            result
1688        );
1689    }
1690
1691    // -----------------------------------------------------------------------
1692    // RS-IMP-08: crate:: と crate_name:: 混在
1693    // -----------------------------------------------------------------------
1694    #[test]
1695    fn rs_imp_08_mixed_crate_and_crate_name() {
1696        // Given: source に `use crate::service::Service;` と `use my_crate::user::User;` の両方
1697        // crate_name = Some("my_crate")
1698        let source = "use crate::service::Service;\nuse my_crate::user::User;\n";
1699
1700        // When: extract_import_specifiers_with_crate_name(source, Some("my_crate"))
1701        let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
1702
1703        // Then: [("service", ["Service"]), ("user", ["User"])] の両方が検出される
1704        let service_entry = result.iter().find(|(spec, _)| spec == "service");
1705        assert!(service_entry.is_some(), "service not found in {:?}", result);
1706        let (_, service_symbols) = service_entry.unwrap();
1707        assert!(
1708            service_symbols.contains(&"Service".to_string()),
1709            "Service not in {:?}",
1710            service_symbols
1711        );
1712
1713        let user_entry = result.iter().find(|(spec, _)| spec == "user");
1714        assert!(user_entry.is_some(), "user not found in {:?}", result);
1715        let (_, user_symbols) = user_entry.unwrap();
1716        assert!(
1717            user_symbols.contains(&"User".to_string()),
1718            "User not in {:?}",
1719            user_symbols
1720        );
1721    }
1722
1723    // -----------------------------------------------------------------------
1724    // RS-L2-INTEG: 統合テスト (tempdir)
1725    // -----------------------------------------------------------------------
1726    #[test]
1727    fn rs_l2_integ_crate_name_import_layer2() {
1728        // Given: tempdir に以下を作成
1729        //   - Cargo.toml: [package]\nname = "my-crate"\nversion = "0.1.0"\nedition = "2021"
1730        //   - src/user.rs: pub struct User;
1731        //   - tests/test_user.rs: use my_crate::user::User; (ソース)
1732        let tmp = tempfile::tempdir().unwrap();
1733        let src_dir = tmp.path().join("src");
1734        let tests_dir = tmp.path().join("tests");
1735        std::fs::create_dir_all(&src_dir).unwrap();
1736        std::fs::create_dir_all(&tests_dir).unwrap();
1737
1738        std::fs::write(
1739            tmp.path().join("Cargo.toml"),
1740            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1741        )
1742        .unwrap();
1743
1744        let user_rs = src_dir.join("user.rs");
1745        std::fs::write(&user_rs, "pub struct User;\n").unwrap();
1746
1747        let test_user_rs = tests_dir.join("test_user.rs");
1748        let test_source = "use my_crate::user::User;\n\n#[test]\nfn test_user() {}\n";
1749        std::fs::write(&test_user_rs, test_source).unwrap();
1750
1751        let extractor = RustExtractor::new();
1752        let prod_path = user_rs.to_string_lossy().into_owned();
1753        let test_path = test_user_rs.to_string_lossy().into_owned();
1754        let production_files = vec![prod_path.clone()];
1755        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
1756            .into_iter()
1757            .collect();
1758
1759        // When: map_test_files_with_imports を呼ぶ
1760        let result =
1761            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1762
1763        // Then: test_user.rs → user.rs が Layer 2 (ImportTracing) でマッチ
1764        let mapping = result.iter().find(|m| m.production_file == prod_path);
1765        assert!(mapping.is_some(), "production file mapping not found");
1766        let mapping = mapping.unwrap();
1767        assert!(
1768            mapping.test_files.contains(&test_path),
1769            "Expected test_user.rs to map to user.rs via Layer 2, got: {:?}",
1770            mapping.test_files
1771        );
1772        assert_eq!(
1773            mapping.strategy,
1774            MappingStrategy::ImportTracing,
1775            "Expected ImportTracing strategy, got: {:?}",
1776            mapping.strategy
1777        );
1778    }
1779
1780    // -----------------------------------------------------------------------
1781    // RS-DEEP-REEXPORT-01: 2段 re-export — src/models/mod.rs: pub mod user;
1782    // -----------------------------------------------------------------------
1783    #[test]
1784    fn rs_deep_reexport_01_two_hop() {
1785        // Given: tempdir に以下を作成
1786        //   Cargo.toml: [package]\nname = "my-crate"\n...
1787        //   src/models/mod.rs: pub mod user;
1788        //   src/models/user.rs: pub struct User;
1789        //   tests/test_models.rs: use my_crate::models::User;
1790        let tmp = tempfile::tempdir().unwrap();
1791        let src_models_dir = tmp.path().join("src").join("models");
1792        let tests_dir = tmp.path().join("tests");
1793        std::fs::create_dir_all(&src_models_dir).unwrap();
1794        std::fs::create_dir_all(&tests_dir).unwrap();
1795
1796        std::fs::write(
1797            tmp.path().join("Cargo.toml"),
1798            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1799        )
1800        .unwrap();
1801
1802        let mod_rs = src_models_dir.join("mod.rs");
1803        std::fs::write(&mod_rs, "pub mod user;\n").unwrap();
1804
1805        let user_rs = src_models_dir.join("user.rs");
1806        std::fs::write(&user_rs, "pub struct User;\n").unwrap();
1807
1808        let test_models_rs = tests_dir.join("test_models.rs");
1809        let test_source = "use my_crate::models::User;\n\n#[test]\nfn test_user() {}\n";
1810        std::fs::write(&test_models_rs, test_source).unwrap();
1811
1812        let extractor = RustExtractor::new();
1813        let user_path = user_rs.to_string_lossy().into_owned();
1814        let test_path = test_models_rs.to_string_lossy().into_owned();
1815        let production_files = vec![user_path.clone()];
1816        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
1817            .into_iter()
1818            .collect();
1819
1820        // When: map_test_files_with_imports を呼ぶ
1821        let result =
1822            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1823
1824        // Then: test_models.rs → user.rs が Layer 2 (ImportTracing) でマッチ
1825        let mapping = result.iter().find(|m| m.production_file == user_path);
1826        assert!(mapping.is_some(), "production file mapping not found");
1827        let mapping = mapping.unwrap();
1828        assert!(
1829            mapping.test_files.contains(&test_path),
1830            "Expected test_models.rs to map to user.rs via Layer 2 (pub mod chain), got: {:?}",
1831            mapping.test_files
1832        );
1833        assert_eq!(
1834            mapping.strategy,
1835            MappingStrategy::ImportTracing,
1836            "Expected ImportTracing strategy, got: {:?}",
1837            mapping.strategy
1838        );
1839    }
1840
1841    // -----------------------------------------------------------------------
1842    // RS-DEEP-REEXPORT-02: 3段 re-export — lib.rs → models/mod.rs → user.rs
1843    // テストが `use my_crate::models::User;` (user セグメントなし) のみを使うため
1844    // pub mod wildcard chain なしでは user.rs にマッチできない
1845    // -----------------------------------------------------------------------
1846    #[test]
1847    fn rs_deep_reexport_02_three_hop() {
1848        // Given: tempdir に以下を作成
1849        //   Cargo.toml: [package]\nname = "my-crate"\n...
1850        //   src/lib.rs: pub mod models;
1851        //   src/models/mod.rs: pub mod user;
1852        //   src/models/user.rs: pub struct User;
1853        //   tests/test_account.rs: use my_crate::models::User; (user セグメントなし)
1854        let tmp = tempfile::tempdir().unwrap();
1855        let src_dir = tmp.path().join("src");
1856        let src_models_dir = src_dir.join("models");
1857        let tests_dir = tmp.path().join("tests");
1858        std::fs::create_dir_all(&src_models_dir).unwrap();
1859        std::fs::create_dir_all(&tests_dir).unwrap();
1860
1861        std::fs::write(
1862            tmp.path().join("Cargo.toml"),
1863            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1864        )
1865        .unwrap();
1866
1867        std::fs::write(src_dir.join("lib.rs"), "pub mod models;\n").unwrap();
1868
1869        let mod_rs = src_models_dir.join("mod.rs");
1870        std::fs::write(&mod_rs, "pub mod user;\n").unwrap();
1871
1872        let user_rs = src_models_dir.join("user.rs");
1873        std::fs::write(&user_rs, "pub struct User;\n").unwrap();
1874
1875        // test_account.rs: ファイル名は user と無関係 → Layer 1 ではマッチしない
1876        let test_account_rs = tests_dir.join("test_account.rs");
1877        let test_source = "use my_crate::models::User;\n\n#[test]\nfn test_account() {}\n";
1878        std::fs::write(&test_account_rs, test_source).unwrap();
1879
1880        let extractor = RustExtractor::new();
1881        let user_path = user_rs.to_string_lossy().into_owned();
1882        let test_path = test_account_rs.to_string_lossy().into_owned();
1883        let production_files = vec![user_path.clone()];
1884        let test_sources: HashMap<String, String> = [(test_path.clone(), test_source.to_string())]
1885            .into_iter()
1886            .collect();
1887
1888        // When: map_test_files_with_imports を呼ぶ
1889        let result =
1890            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1891
1892        // Then: test_account.rs → user.rs が Layer 2 (ImportTracing) でマッチ
1893        // (lib.rs → models/ → pub mod user; の wildcard chain を辿る必要がある)
1894        let mapping = result.iter().find(|m| m.production_file == user_path);
1895        assert!(mapping.is_some(), "production file mapping not found");
1896        let mapping = mapping.unwrap();
1897        assert!(
1898            mapping.test_files.contains(&test_path),
1899            "Expected test_account.rs to map to user.rs via Layer 2 (3-hop pub mod chain), got: {:?}",
1900            mapping.test_files
1901        );
1902        assert_eq!(
1903            mapping.strategy,
1904            MappingStrategy::ImportTracing,
1905            "Expected ImportTracing strategy, got: {:?}",
1906            mapping.strategy
1907        );
1908    }
1909
1910    // -----------------------------------------------------------------------
1911    // RS-DEEP-REEXPORT-03: pub use + pub mod 混在 → 両エントリ返す
1912    // -----------------------------------------------------------------------
1913    #[test]
1914    fn rs_deep_reexport_03_pub_use_and_pub_mod() {
1915        // Given: mod.rs with `pub mod internal;` and `pub use internal::Exported;`
1916        let source = "pub mod internal;\npub use internal::Exported;\n";
1917
1918        // When: extract_barrel_re_exports is called
1919        let extractor = RustExtractor::new();
1920        let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
1921
1922        // Then: 2エントリ返す
1923        //   1. from_specifier="./internal", wildcard=true  (pub mod)
1924        //   2. from_specifier="./internal", symbols=["Exported"]  (pub use)
1925        let wildcard_entry = result
1926            .iter()
1927            .find(|e| e.from_specifier == "./internal" && e.wildcard);
1928        assert!(
1929            wildcard_entry.is_some(),
1930            "Expected wildcard=true entry for pub mod internal, got: {:?}",
1931            result
1932        );
1933
1934        let symbol_entry = result.iter().find(|e| {
1935            e.from_specifier == "./internal"
1936                && !e.wildcard
1937                && e.symbols.contains(&"Exported".to_string())
1938        });
1939        assert!(
1940            symbol_entry.is_some(),
1941            "Expected symbols=[\"Exported\"] entry for pub use internal::Exported, got: {:?}",
1942            result
1943        );
1944    }
1945
1946    // -----------------------------------------------------------------------
1947    // RS-EXPORT-01: pub fn match
1948    // -----------------------------------------------------------------------
1949    #[test]
1950    fn rs_export_01_pub_fn_match() {
1951        // Given: a file with pub fn create_user
1952        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1953            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
1954        let extractor = RustExtractor::new();
1955        let symbols = vec!["create_user".to_string()];
1956
1957        // When: file_exports_any_symbol is called
1958        let result = extractor.file_exports_any_symbol(&path, &symbols);
1959
1960        // Then: returns true
1961        assert!(result, "Expected true for pub fn create_user");
1962    }
1963
1964    // -----------------------------------------------------------------------
1965    // RS-EXPORT-02: pub struct match
1966    // -----------------------------------------------------------------------
1967    #[test]
1968    fn rs_export_02_pub_struct_match() {
1969        // Given: a file with pub struct User
1970        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1971            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
1972        let extractor = RustExtractor::new();
1973        let symbols = vec!["User".to_string()];
1974
1975        // When: file_exports_any_symbol is called
1976        let result = extractor.file_exports_any_symbol(&path, &symbols);
1977
1978        // Then: returns true
1979        assert!(result, "Expected true for pub struct User");
1980    }
1981
1982    // -----------------------------------------------------------------------
1983    // RS-EXPORT-03: non-existent symbol
1984    // -----------------------------------------------------------------------
1985    #[test]
1986    fn rs_export_03_nonexistent_symbol() {
1987        // Given: a file without NonExistent symbol
1988        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1989            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
1990        let extractor = RustExtractor::new();
1991        let symbols = vec!["NonExistent".to_string()];
1992
1993        // When: file_exports_any_symbol is called
1994        let result = extractor.file_exports_any_symbol(&path, &symbols);
1995
1996        // Then: returns false
1997        assert!(!result, "Expected false for NonExistent symbol");
1998    }
1999
2000    // -----------------------------------------------------------------------
2001    // RS-EXPORT-04: file with no pub symbols
2002    // -----------------------------------------------------------------------
2003    #[test]
2004    fn rs_export_04_no_pub_symbols() {
2005        // Given: a file with no pub items
2006        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2007            .join("../../tests/fixtures/rust/observe/no_pub_symbols.rs");
2008        let extractor = RustExtractor::new();
2009        let symbols = vec!["internal_only".to_string()];
2010
2011        // When: file_exports_any_symbol is called
2012        let result = extractor.file_exports_any_symbol(&path, &symbols);
2013
2014        // Then: returns false
2015        assert!(!result, "Expected false for file with no pub symbols");
2016    }
2017
2018    // -----------------------------------------------------------------------
2019    // RS-EXPORT-05: pub use/mod only (no direct pub definitions)
2020    // -----------------------------------------------------------------------
2021    #[test]
2022    fn rs_export_05_pub_use_mod_only() {
2023        // Given: a file with only pub use and pub mod (barrel re-exports)
2024        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2025            .join("../../tests/fixtures/rust/observe/pub_use_only.rs");
2026        let extractor = RustExtractor::new();
2027        let symbols = vec!["Foo".to_string()];
2028
2029        // When: file_exports_any_symbol is called
2030        let result = extractor.file_exports_any_symbol(&path, &symbols);
2031
2032        // Then: returns false (pub use/mod are handled by barrel resolution)
2033        assert!(
2034            !result,
2035            "Expected false for pub use/mod only file (barrel resolution handles these)"
2036        );
2037    }
2038
2039    // -----------------------------------------------------------------------
2040    // RS-EXPORT-06: empty symbol list
2041    // -----------------------------------------------------------------------
2042    #[test]
2043    fn rs_export_06_empty_symbols() {
2044        // Given: any file
2045        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2046            .join("../../tests/fixtures/rust/observe/exported_pub_symbols.rs");
2047        let extractor = RustExtractor::new();
2048        let symbols: Vec<String> = vec![];
2049
2050        // When: file_exports_any_symbol is called with empty symbols
2051        let result = extractor.file_exports_any_symbol(&path, &symbols);
2052
2053        // Then: returns true (short-circuit)
2054        assert!(result, "Expected true for empty symbol list");
2055    }
2056
2057    // -----------------------------------------------------------------------
2058    // RS-EXPORT-07: non-existent file (optimistic fallback)
2059    // -----------------------------------------------------------------------
2060    #[test]
2061    fn rs_export_07_nonexistent_file() {
2062        // Given: a non-existent file path
2063        let path = PathBuf::from("/nonexistent/path/to/file.rs");
2064        let extractor = RustExtractor::new();
2065        let symbols = vec!["Foo".to_string()];
2066
2067        // When: file_exports_any_symbol is called
2068        // Then: returns true (optimistic fallback, matches core default and Python)
2069        let result = extractor.file_exports_any_symbol(&path, &symbols);
2070        assert!(
2071            result,
2072            "Expected true for non-existent file (optimistic fallback)"
2073        );
2074    }
2075
2076    // -----------------------------------------------------------------------
2077    // RS-WS-01: workspace with 2 members -> 2 members detected
2078    // -----------------------------------------------------------------------
2079    #[test]
2080    fn rs_ws_01_workspace_two_members() {
2081        // Given: a workspace with 2 member crates
2082        let tmp = tempfile::tempdir().unwrap();
2083        std::fs::write(
2084            tmp.path().join("Cargo.toml"),
2085            "[workspace]\nmembers = [\"crate_a\", \"crate_b\"]\n",
2086        )
2087        .unwrap();
2088        std::fs::create_dir_all(tmp.path().join("crate_a/src")).unwrap();
2089        std::fs::write(
2090            tmp.path().join("crate_a/Cargo.toml"),
2091            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
2092        )
2093        .unwrap();
2094        std::fs::create_dir_all(tmp.path().join("crate_b/src")).unwrap();
2095        std::fs::write(
2096            tmp.path().join("crate_b/Cargo.toml"),
2097            "[package]\nname = \"crate_b\"\nversion = \"0.1.0\"\n",
2098        )
2099        .unwrap();
2100
2101        // When: find_workspace_members is called
2102        let members = find_workspace_members(tmp.path());
2103
2104        // Then: 2 WorkspaceMembers detected
2105        assert_eq!(members.len(), 2, "Expected 2 members, got: {:?}", members);
2106        let names: Vec<&str> = members.iter().map(|m| m.crate_name.as_str()).collect();
2107        assert!(
2108            names.contains(&"crate_a"),
2109            "crate_a not found in {:?}",
2110            names
2111        );
2112        assert!(
2113            names.contains(&"crate_b"),
2114            "crate_b not found in {:?}",
2115            names
2116        );
2117    }
2118
2119    // -----------------------------------------------------------------------
2120    // RS-WS-02: single crate (non-workspace) returns empty
2121    // -----------------------------------------------------------------------
2122    #[test]
2123    fn rs_ws_02_single_crate_returns_empty() {
2124        // Given: a single crate (no [workspace] section, has [package])
2125        let tmp = tempfile::tempdir().unwrap();
2126        std::fs::write(
2127            tmp.path().join("Cargo.toml"),
2128            "[package]\nname = \"my_crate\"\nversion = \"0.1.0\"\n",
2129        )
2130        .unwrap();
2131        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2132
2133        // When: find_workspace_members is called
2134        let members = find_workspace_members(tmp.path());
2135
2136        // Then: empty Vec (not a workspace root)
2137        assert!(members.is_empty(), "Expected empty, got: {:?}", members);
2138    }
2139
2140    // -----------------------------------------------------------------------
2141    // RS-WS-03: target/ directory is skipped
2142    // -----------------------------------------------------------------------
2143    #[test]
2144    fn rs_ws_03_target_dir_skipped() {
2145        // Given: a workspace where target/ contains a Cargo.toml
2146        let tmp = tempfile::tempdir().unwrap();
2147        std::fs::write(
2148            tmp.path().join("Cargo.toml"),
2149            "[workspace]\nmembers = [\"crate_a\"]\n",
2150        )
2151        .unwrap();
2152        std::fs::create_dir_all(tmp.path().join("crate_a/src")).unwrap();
2153        std::fs::write(
2154            tmp.path().join("crate_a/Cargo.toml"),
2155            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
2156        )
2157        .unwrap();
2158        // A Cargo.toml inside target/ (should be ignored)
2159        std::fs::create_dir_all(tmp.path().join("target/debug/build/fake")).unwrap();
2160        std::fs::write(
2161            tmp.path().join("target/debug/build/fake/Cargo.toml"),
2162            "[package]\nname = \"fake_crate\"\nversion = \"0.1.0\"\n",
2163        )
2164        .unwrap();
2165
2166        // When: find_workspace_members is called
2167        let members = find_workspace_members(tmp.path());
2168
2169        // Then: only crate_a detected (target/ is skipped)
2170        assert_eq!(members.len(), 1, "Expected 1 member, got: {:?}", members);
2171        assert_eq!(members[0].crate_name, "crate_a");
2172    }
2173
2174    // -----------------------------------------------------------------------
2175    // RS-WS-04: hyphenated crate name -> underscore conversion
2176    // -----------------------------------------------------------------------
2177    #[test]
2178    fn rs_ws_04_hyphenated_crate_name_converted() {
2179        // Given: a workspace with a member crate named "my-crate" (hyphenated)
2180        let tmp = tempfile::tempdir().unwrap();
2181        std::fs::write(
2182            tmp.path().join("Cargo.toml"),
2183            "[workspace]\nmembers = [\"my-crate\"]\n",
2184        )
2185        .unwrap();
2186        std::fs::create_dir_all(tmp.path().join("my-crate/src")).unwrap();
2187        std::fs::write(
2188            tmp.path().join("my-crate/Cargo.toml"),
2189            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2190        )
2191        .unwrap();
2192
2193        // When: find_workspace_members is called
2194        let members = find_workspace_members(tmp.path());
2195
2196        // Then: crate_name = "my_crate" (hyphens converted to underscores)
2197        assert_eq!(members.len(), 1, "Expected 1 member, got: {:?}", members);
2198        assert_eq!(members[0].crate_name, "my_crate");
2199    }
2200
2201    // -----------------------------------------------------------------------
2202    // RS-WS-05: test file in member/tests/ -> Some(foo member)
2203    // -----------------------------------------------------------------------
2204    #[test]
2205    fn rs_ws_05_find_member_for_path_in_tests() {
2206        // Given: workspace members [crate_a at /tmp/ws/crate_a]
2207        let tmp = tempfile::tempdir().unwrap();
2208        let member_root = tmp.path().join("crate_a");
2209        std::fs::create_dir_all(&member_root).unwrap();
2210        let members = vec![WorkspaceMember {
2211            crate_name: "crate_a".to_string(),
2212            member_root: member_root.clone(),
2213        }];
2214
2215        // When: find_member_for_path with a test file inside crate_a/tests/
2216        let test_file = member_root.join("tests").join("integration.rs");
2217        let result = find_member_for_path(&test_file, &members);
2218
2219        // Then: returns Some(crate_a member)
2220        assert!(result.is_some(), "Expected Some(crate_a), got None");
2221        assert_eq!(result.unwrap().crate_name, "crate_a");
2222    }
2223
2224    // -----------------------------------------------------------------------
2225    // RS-WS-06: test file not in any member -> None
2226    // -----------------------------------------------------------------------
2227    #[test]
2228    fn rs_ws_06_find_member_for_path_not_in_any() {
2229        // Given: workspace members [crate_a]
2230        let tmp = tempfile::tempdir().unwrap();
2231        let member_root = tmp.path().join("crate_a");
2232        std::fs::create_dir_all(&member_root).unwrap();
2233        let members = vec![WorkspaceMember {
2234            crate_name: "crate_a".to_string(),
2235            member_root: member_root.clone(),
2236        }];
2237
2238        // When: find_member_for_path with a path outside any member
2239        let outside_path = tmp.path().join("other").join("test.rs");
2240        let result = find_member_for_path(&outside_path, &members);
2241
2242        // Then: returns None
2243        assert!(
2244            result.is_none(),
2245            "Expected None, got: {:?}",
2246            result.map(|m| &m.crate_name)
2247        );
2248    }
2249
2250    // -----------------------------------------------------------------------
2251    // RS-WS-07: longest prefix match for nested members
2252    // -----------------------------------------------------------------------
2253    #[test]
2254    fn rs_ws_07_find_member_longest_prefix() {
2255        // Given: workspace with nested members [ws/crates/foo, ws/crates/foo-extra]
2256        let tmp = tempfile::tempdir().unwrap();
2257        let foo_root = tmp.path().join("crates").join("foo");
2258        let foo_extra_root = tmp.path().join("crates").join("foo-extra");
2259        std::fs::create_dir_all(&foo_root).unwrap();
2260        std::fs::create_dir_all(&foo_extra_root).unwrap();
2261        let members = vec![
2262            WorkspaceMember {
2263                crate_name: "foo".to_string(),
2264                member_root: foo_root.clone(),
2265            },
2266            WorkspaceMember {
2267                crate_name: "foo_extra".to_string(),
2268                member_root: foo_extra_root.clone(),
2269            },
2270        ];
2271
2272        // When: find_member_for_path with a path inside foo-extra/
2273        let test_file = foo_extra_root.join("tests").join("test_bar.rs");
2274        let result = find_member_for_path(&test_file, &members);
2275
2276        // Then: returns foo-extra (longest prefix match)
2277        assert!(result.is_some(), "Expected Some(foo_extra), got None");
2278        assert_eq!(result.unwrap().crate_name, "foo_extra");
2279    }
2280
2281    // -----------------------------------------------------------------------
2282    // RS-WS-E2E-01: workspace L2 import tracing works
2283    // -----------------------------------------------------------------------
2284    #[test]
2285    fn rs_ws_e2e_01_workspace_l2_import_tracing() {
2286        // Given: a workspace with crate_a containing src/user.rs and tests/test_user.rs
2287        // that imports `use crate_a::user::create_user`
2288        let tmp = tempfile::tempdir().unwrap();
2289        std::fs::write(
2290            tmp.path().join("Cargo.toml"),
2291            "[workspace]\nmembers = [\"crate_a\"]\n",
2292        )
2293        .unwrap();
2294
2295        let member_dir = tmp.path().join("crate_a");
2296        std::fs::create_dir_all(member_dir.join("src")).unwrap();
2297        std::fs::create_dir_all(member_dir.join("tests")).unwrap();
2298        std::fs::write(
2299            member_dir.join("Cargo.toml"),
2300            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
2301        )
2302        .unwrap();
2303
2304        let user_rs = member_dir.join("src").join("user.rs");
2305        std::fs::write(&user_rs, "pub fn create_user() {}\n").unwrap();
2306
2307        let test_rs = member_dir.join("tests").join("test_user.rs");
2308        std::fs::write(
2309            &test_rs,
2310            "use crate_a::user::create_user;\n#[test]\nfn test_create_user() { create_user(); }\n",
2311        )
2312        .unwrap();
2313
2314        let extractor = RustExtractor::new();
2315        let prod_path = user_rs.to_string_lossy().into_owned();
2316        let test_path = test_rs.to_string_lossy().into_owned();
2317        let production_files = vec![prod_path.clone()];
2318        let test_sources: HashMap<String, String> = [(
2319            test_path.clone(),
2320            std::fs::read_to_string(&test_rs).unwrap(),
2321        )]
2322        .into_iter()
2323        .collect();
2324
2325        // When: map_test_files_with_imports is called at workspace root
2326        let result =
2327            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2328
2329        // Then: test_user.rs -> user.rs via Layer 2 (ImportTracing)
2330        let mapping = result.iter().find(|m| m.production_file == prod_path);
2331        assert!(mapping.is_some(), "No mapping for user.rs");
2332        let mapping = mapping.unwrap();
2333        assert!(
2334            mapping.test_files.contains(&test_path),
2335            "Expected test_user.rs in test_files, got: {:?}",
2336            mapping.test_files
2337        );
2338        assert_eq!(
2339            mapping.strategy,
2340            MappingStrategy::ImportTracing,
2341            "Expected ImportTracing strategy, got: {:?}",
2342            mapping.strategy
2343        );
2344    }
2345
2346    // -----------------------------------------------------------------------
2347    // RS-WS-E2E-02: L0/L1 still work at workspace level
2348    //
2349    // Layer 1 (FileNameConvention) matches within the same directory only.
2350    // Cross-directory matches (src/ vs tests/) are handled by Layer 2.
2351    // This test verifies:
2352    //   - L0: src/service.rs with inline tests -> self-mapped
2353    //   - L1: src/test_service.rs -> src/service.rs (same src/ directory)
2354    // -----------------------------------------------------------------------
2355    #[test]
2356    fn rs_ws_e2e_02_l0_l1_still_work_at_workspace_level() {
2357        // Given: a workspace with crate_a containing src/service.rs (with inline tests)
2358        // and src/test_service.rs (same-directory filename convention match)
2359        let tmp = tempfile::tempdir().unwrap();
2360        std::fs::write(
2361            tmp.path().join("Cargo.toml"),
2362            "[workspace]\nmembers = [\"crate_a\"]\n",
2363        )
2364        .unwrap();
2365
2366        let member_dir = tmp.path().join("crate_a");
2367        std::fs::create_dir_all(member_dir.join("src")).unwrap();
2368        std::fs::write(
2369            member_dir.join("Cargo.toml"),
2370            "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\n",
2371        )
2372        .unwrap();
2373
2374        // Layer 0: inline tests in service.rs
2375        let service_rs = member_dir.join("src").join("service.rs");
2376        std::fs::write(
2377            &service_rs,
2378            r#"pub fn do_work() {}
2379
2380#[cfg(test)]
2381mod tests {
2382    use super::*;
2383    #[test]
2384    fn test_do_work() { do_work(); }
2385}
2386"#,
2387        )
2388        .unwrap();
2389
2390        // Layer 1: test_service.rs in the same src/ directory -> service.rs
2391        let test_service_rs = member_dir.join("src").join("test_service.rs");
2392        std::fs::write(
2393            &test_service_rs,
2394            "#[test]\nfn test_service_smoke() { assert!(true); }\n",
2395        )
2396        .unwrap();
2397
2398        let extractor = RustExtractor::new();
2399        let prod_path = service_rs.to_string_lossy().into_owned();
2400        let test_path = test_service_rs.to_string_lossy().into_owned();
2401        let production_files = vec![prod_path.clone()];
2402        let test_sources: HashMap<String, String> = [(
2403            test_path.clone(),
2404            std::fs::read_to_string(&test_service_rs).unwrap(),
2405        )]
2406        .into_iter()
2407        .collect();
2408
2409        // When: map_test_files_with_imports is called at workspace root
2410        let result =
2411            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2412
2413        // Then: service.rs self-mapped (Layer 0) and test_service.rs mapped (Layer 1)
2414        let mapping = result.iter().find(|m| m.production_file == prod_path);
2415        assert!(mapping.is_some(), "No mapping for service.rs");
2416        let mapping = mapping.unwrap();
2417        assert!(
2418            mapping.test_files.contains(&prod_path),
2419            "Expected service.rs self-mapped (Layer 0), got: {:?}",
2420            mapping.test_files
2421        );
2422        assert!(
2423            mapping.test_files.contains(&test_path),
2424            "Expected test_service.rs mapped (Layer 1), got: {:?}",
2425            mapping.test_files
2426        );
2427    }
2428
2429    // -----------------------------------------------------------------------
2430    // RS-WS-E2E-03: Non-virtual workspace (both [workspace] and [package])
2431    //
2432    // Root Cargo.toml has both [workspace] and [package] (like clap).
2433    // L2 must work for both root crate and member crates.
2434    // -----------------------------------------------------------------------
2435    #[test]
2436    fn rs_ws_e2e_03_non_virtual_workspace_l2() {
2437        // Given: a non-virtual workspace with root package "root_pkg"
2438        // and member "member_a"
2439        let tmp = tempfile::tempdir().unwrap();
2440        std::fs::write(
2441            tmp.path().join("Cargo.toml"),
2442            "[workspace]\nmembers = [\"member_a\"]\n\n[package]\nname = \"root_pkg\"\nversion = \"0.1.0\"\n",
2443        )
2444        .unwrap();
2445
2446        // Root crate src + tests
2447        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2448        std::fs::create_dir_all(tmp.path().join("tests")).unwrap();
2449        let root_src = tmp.path().join("src").join("lib.rs");
2450        std::fs::write(&root_src, "pub fn root_fn() {}\n").unwrap();
2451        let root_test = tmp.path().join("tests").join("test_root.rs");
2452        std::fs::write(
2453            &root_test,
2454            "use root_pkg::lib::root_fn;\n#[test]\nfn test_root() { }\n",
2455        )
2456        .unwrap();
2457
2458        // Member crate
2459        let member_dir = tmp.path().join("member_a");
2460        std::fs::create_dir_all(member_dir.join("src")).unwrap();
2461        std::fs::create_dir_all(member_dir.join("tests")).unwrap();
2462        std::fs::write(
2463            member_dir.join("Cargo.toml"),
2464            "[package]\nname = \"member_a\"\nversion = \"0.1.0\"\n",
2465        )
2466        .unwrap();
2467        let member_src = member_dir.join("src").join("handler.rs");
2468        std::fs::write(&member_src, "pub fn handle() {}\n").unwrap();
2469        let member_test = member_dir.join("tests").join("test_handler.rs");
2470        std::fs::write(
2471            &member_test,
2472            "use member_a::handler::handle;\n#[test]\nfn test_handle() { handle(); }\n",
2473        )
2474        .unwrap();
2475
2476        let extractor = RustExtractor::new();
2477        let root_src_path = root_src.to_string_lossy().into_owned();
2478        let member_src_path = member_src.to_string_lossy().into_owned();
2479        let root_test_path = root_test.to_string_lossy().into_owned();
2480        let member_test_path = member_test.to_string_lossy().into_owned();
2481
2482        let production_files = vec![root_src_path.clone(), member_src_path.clone()];
2483        let test_sources: HashMap<String, String> = [
2484            (
2485                root_test_path.clone(),
2486                std::fs::read_to_string(&root_test).unwrap(),
2487            ),
2488            (
2489                member_test_path.clone(),
2490                std::fs::read_to_string(&member_test).unwrap(),
2491            ),
2492        ]
2493        .into_iter()
2494        .collect();
2495
2496        // When: map_test_files_with_imports at workspace root
2497        let result =
2498            extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2499
2500        // Then: member's test maps to member's src via L2
2501        let member_mapping = result.iter().find(|m| m.production_file == member_src_path);
2502        assert!(member_mapping.is_some(), "No mapping for member handler.rs");
2503        let member_mapping = member_mapping.unwrap();
2504        assert!(
2505            member_mapping.test_files.contains(&member_test_path),
2506            "Expected member test mapped via L2, got: {:?}",
2507            member_mapping.test_files
2508        );
2509        assert_eq!(
2510            member_mapping.strategy,
2511            MappingStrategy::ImportTracing,
2512            "Expected ImportTracing for member, got: {:?}",
2513            member_mapping.strategy
2514        );
2515    }
2516
2517    // -----------------------------------------------------------------------
2518    // RS-WS-08: has_workspace_section detects [workspace]
2519    // -----------------------------------------------------------------------
2520    #[test]
2521    fn rs_ws_08_has_workspace_section() {
2522        let tmp = tempfile::tempdir().unwrap();
2523
2524        // Virtual workspace
2525        std::fs::write(
2526            tmp.path().join("Cargo.toml"),
2527            "[workspace]\nmembers = [\"a\"]\n",
2528        )
2529        .unwrap();
2530        assert!(has_workspace_section(tmp.path()));
2531
2532        // Non-virtual workspace
2533        std::fs::write(
2534            tmp.path().join("Cargo.toml"),
2535            "[workspace]\nmembers = [\"a\"]\n\n[package]\nname = \"root\"\n",
2536        )
2537        .unwrap();
2538        assert!(has_workspace_section(tmp.path()));
2539
2540        // Single crate (no workspace)
2541        std::fs::write(
2542            tmp.path().join("Cargo.toml"),
2543            "[package]\nname = \"single\"\n",
2544        )
2545        .unwrap();
2546        assert!(!has_workspace_section(tmp.path()));
2547
2548        // No Cargo.toml
2549        std::fs::remove_file(tmp.path().join("Cargo.toml")).unwrap();
2550        assert!(!has_workspace_section(tmp.path()));
2551    }
2552}