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
32pub 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 if let Some(rest) = stem.strip_prefix("test_") {
47 if !rest.is_empty() {
48 return Some(rest);
49 }
50 }
51
52 if let Some(rest) = stem.strip_suffix("_test") {
54 if !rest.is_empty() {
55 return Some(rest);
56 }
57 }
58
59 let normalized = path.replace('\\', "/");
61 if normalized.starts_with("tests/") || normalized.contains("/tests/") {
62 if stem != "mod" && stem != "main" {
64 return Some(stem);
65 }
66 }
67
68 None
69}
70
71pub 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 if stem == "lib" || stem == "mod" || stem == "main" {
83 return None;
84 }
85
86 if test_stem(path).is_some() {
88 return None;
89 }
90
91 if file_name == "build.rs" {
93 return None;
94 }
95
96 Some(stem)
97}
98
99pub 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 if file_name == "build.rs" {
109 return true;
110 }
111
112 if normalized.contains("/tests/common/") || normalized.starts_with("tests/common/") {
114 return true;
115 }
116
117 if normalized.starts_with("benches/") || normalized.contains("/benches/") {
119 return true;
120 }
121
122 if normalized.starts_with("examples/") || normalized.contains("/examples/") {
124 return true;
125 }
126
127 false
128}
129
130pub 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
172impl 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 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 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 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 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 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 if child.kind() == "mod_item" && has_pub_visibility(child) {
295 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 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 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
378fn 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 if child.kind() != "attribute_item" && child.kind() != "visibility_modifier" {
391 break;
392 }
393 }
394 }
395 false
396}
397
398fn 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 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
455fn 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 if let Some(path_after_crate) = full_text.strip_prefix("crate::") {
472 parse_use_path(path_after_crate, result);
473 return;
474 }
475
476 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
485pub 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 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#[derive(Debug)]
520pub struct WorkspaceMember {
521 pub crate_name: String,
523 pub member_root: std::path::PathBuf,
525}
526
527const SKIP_DIRS: &[&str] = &["target", ".cargo", "vendor"];
529
530const MAX_TRAVERSE_DEPTH: usize = 4;
532
533pub 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
543pub 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 if dir_name.starts_with('.') || SKIP_DIRS.contains(&dir_name) {
591 continue;
592 }
593
594 if path == scan_root {
596 continue;
597 }
598
599 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 continue;
608 }
609
610 find_members_recursive(scan_root, &path, depth + 1, members);
612 }
613}
614
615pub 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
628pub 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 if trimmed.starts_with('[') {
641 if trimmed == "[package]" {
642 in_package = true;
643 } else {
644 if in_package {
646 break;
647 }
648 }
649 continue;
650 }
651
652 if in_package {
653 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 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
679fn parse_use_path(path: &str, result: &mut HashMap<String, Vec<String>>) {
685 if let Some(brace_start) = path.find('{') {
687 let module_part = &path[..brace_start.saturating_sub(2)]; 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 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 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
725fn 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 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 if let Some(brace_start) = full_text.find('{') {
747 let module_part = &full_text[..brace_start.saturating_sub(2)]; 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 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
779impl RustExtractor {
784 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 let mut mappings =
799 exspec_core::observe::map_test_files(self, production_files, &test_file_list);
800
801 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 if !mappings[idx].test_files.contains(prod_file) {
807 mappings[idx].test_files.push(prod_file.clone());
808 }
809 }
810 }
811 }
812
813 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 let layer1_tests_per_prod: Vec<HashSet<String>> = mappings
827 .iter()
828 .map(|m| m.test_files.iter().cloned().collect())
829 .collect();
830
831 let crate_name = parse_crate_name(scan_root);
833 let members = find_workspace_members(scan_root);
834
835 if let Some(ref name) = crate_name {
837 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 for member in &members {
851 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 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 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 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 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#[cfg(test)]
947mod tests {
948 use super::*;
949 use std::path::PathBuf;
950
951 #[test]
955 fn rs_stem_01_test_prefix() {
956 let extractor = RustExtractor::new();
960 assert_eq!(extractor.test_stem("tests/test_foo.rs"), Some("foo"));
961 }
962
963 #[test]
967 fn rs_stem_02_test_suffix() {
968 let extractor = RustExtractor::new();
972 assert_eq!(extractor.test_stem("tests/foo_test.rs"), Some("foo"));
973 }
974
975 #[test]
979 fn rs_stem_03_tests_dir_integration() {
980 let extractor = RustExtractor::new();
984 assert_eq!(
985 extractor.test_stem("tests/integration.rs"),
986 Some("integration")
987 );
988 }
989
990 #[test]
994 fn rs_stem_04_production_file_no_test_stem() {
995 let extractor = RustExtractor::new();
999 assert_eq!(extractor.test_stem("src/user.rs"), None);
1000 }
1001
1002 #[test]
1006 fn rs_stem_05_production_stem_regular() {
1007 let extractor = RustExtractor::new();
1011 assert_eq!(extractor.production_stem("src/user.rs"), Some("user"));
1012 }
1013
1014 #[test]
1018 fn rs_stem_06_production_stem_lib() {
1019 let extractor = RustExtractor::new();
1023 assert_eq!(extractor.production_stem("src/lib.rs"), None);
1024 }
1025
1026 #[test]
1030 fn rs_stem_07_production_stem_mod() {
1031 let extractor = RustExtractor::new();
1035 assert_eq!(extractor.production_stem("src/mod.rs"), None);
1036 }
1037
1038 #[test]
1042 fn rs_stem_08_production_stem_main() {
1043 let extractor = RustExtractor::new();
1047 assert_eq!(extractor.production_stem("src/main.rs"), None);
1048 }
1049
1050 #[test]
1054 fn rs_stem_09_production_stem_test_file() {
1055 let extractor = RustExtractor::new();
1059 assert_eq!(extractor.production_stem("tests/test_foo.rs"), None);
1060 }
1061
1062 #[test]
1066 fn rs_helper_01_build_rs() {
1067 let extractor = RustExtractor::new();
1071 assert!(extractor.is_non_sut_helper("build.rs", false));
1072 }
1073
1074 #[test]
1078 fn rs_helper_02_tests_common() {
1079 let extractor = RustExtractor::new();
1083 assert!(extractor.is_non_sut_helper("tests/common/mod.rs", false));
1084 }
1085
1086 #[test]
1090 fn rs_helper_03_regular_production_file() {
1091 let extractor = RustExtractor::new();
1095 assert!(!extractor.is_non_sut_helper("src/user.rs", false));
1096 }
1097
1098 #[test]
1102 fn rs_helper_04_benches() {
1103 let extractor = RustExtractor::new();
1107 assert!(extractor.is_non_sut_helper("benches/bench.rs", false));
1108 }
1109
1110 #[test]
1114 fn rs_l0_01_cfg_test_present() {
1115 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 assert!(detect_inline_tests(source));
1132 }
1133
1134 #[test]
1138 fn rs_l0_02_no_cfg_test() {
1139 let source = r#"
1141pub fn add(a: i32, b: i32) -> i32 { a + b }
1142"#;
1143 assert!(!detect_inline_tests(source));
1146 }
1147
1148 #[test]
1152 fn rs_l0_03_cfg_not_test() {
1153 let source = r#"
1155#[cfg(not(test))]
1156mod production_only {
1157 pub fn real_thing() {}
1158}
1159"#;
1160 assert!(!detect_inline_tests(source));
1163 }
1164
1165 #[test]
1169 fn rs_func_01_pub_function() {
1170 let source = "pub fn create_user() {}\n";
1172
1173 let extractor = RustExtractor::new();
1175 let result = extractor.extract_production_functions(source, "src/user.rs");
1176
1177 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 #[test]
1187 fn rs_func_02_private_function() {
1188 let source = "fn private_fn() {}\n";
1190
1191 let extractor = RustExtractor::new();
1193 let result = extractor.extract_production_functions(source, "src/internal.rs");
1194
1195 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 #[test]
1205 fn rs_func_03_impl_method() {
1206 let source = r#"
1208struct User;
1209
1210impl User {
1211 pub fn save(&self) {}
1212}
1213"#;
1214 let extractor = RustExtractor::new();
1216 let result = extractor.extract_production_functions(source, "src/user.rs");
1217
1218 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 #[test]
1230 fn rs_func_04_cfg_test_excluded() {
1231 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 let extractor = RustExtractor::new();
1247 let result = extractor.extract_production_functions(source, "src/lib.rs");
1248
1249 assert_eq!(result.len(), 1);
1251 assert_eq!(result[0].name, "real_function");
1252 }
1253
1254 #[test]
1258 fn rs_imp_01_simple_crate_import() {
1259 let source = "use crate::user::User;\n";
1261
1262 let extractor = RustExtractor::new();
1264 let result = extractor.extract_all_import_specifiers(source);
1265
1266 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 #[test]
1277 fn rs_imp_02_nested_crate_import() {
1278 let source = "use crate::models::user::User;\n";
1280
1281 let extractor = RustExtractor::new();
1283 let result = extractor.extract_all_import_specifiers(source);
1284
1285 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 #[test]
1296 fn rs_imp_03_use_list() {
1297 let source = "use crate::user::{User, Admin};\n";
1299
1300 let extractor = RustExtractor::new();
1302 let result = extractor.extract_all_import_specifiers(source);
1303
1304 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 #[test]
1324 fn rs_imp_04_external_crate_skipped() {
1325 let source = "use std::collections::HashMap;\n";
1327
1328 let extractor = RustExtractor::new();
1330 let result = extractor.extract_all_import_specifiers(source);
1331
1332 assert!(
1334 result.is_empty(),
1335 "external imports should be skipped: {:?}",
1336 result
1337 );
1338 }
1339
1340 #[test]
1344 fn rs_barrel_01_mod_rs() {
1345 let extractor = RustExtractor::new();
1349 assert!(extractor.is_barrel_file("src/models/mod.rs"));
1350 }
1351
1352 #[test]
1356 fn rs_barrel_02_lib_rs() {
1357 let extractor = RustExtractor::new();
1361 assert!(extractor.is_barrel_file("src/lib.rs"));
1362 }
1363
1364 #[test]
1368 fn rs_barrel_03_pub_mod() {
1369 let source = "pub mod user;\n";
1371
1372 let extractor = RustExtractor::new();
1374 let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
1375
1376 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 #[test]
1386 fn rs_barrel_04_pub_use_wildcard() {
1387 let source = "pub use user::*;\n";
1389
1390 let extractor = RustExtractor::new();
1392 let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
1393
1394 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 #[test]
1404 fn rs_e2e_01_inline_test_self_map() {
1405 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 let result =
1432 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1433
1434 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 #[test]
1448 fn rs_e2e_02_layer1_stem_match() {
1449 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 let scan_root = PathBuf::from(".");
1459 let result =
1460 extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1461
1462 let mapping = result.iter().find(|m| m.production_file == "src/user.rs");
1468 assert!(mapping.is_some());
1469 }
1470
1471 #[test]
1475 fn rs_e2e_03_layer2_import_tracing() {
1476 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 let result =
1500 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1501
1502 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 #[test]
1516 fn rs_e2e_04_helper_excluded() {
1517 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 let scan_root = PathBuf::from(".");
1532 let result =
1533 extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
1534
1535 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 #[test]
1552 fn rs_crate_01_parse_crate_name_hyphen() {
1553 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 let result = parse_crate_name(tmp.path());
1563
1564 assert_eq!(result, Some("my_crate".to_string()));
1566 }
1567
1568 #[test]
1572 fn rs_crate_02_parse_crate_name_no_hyphen() {
1573 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 let result = parse_crate_name(tmp.path());
1583
1584 assert_eq!(result, Some("tokio".to_string()));
1586 }
1587
1588 #[test]
1592 fn rs_crate_03_parse_crate_name_no_file() {
1593 let tmp = tempfile::tempdir().unwrap();
1595
1596 let result = parse_crate_name(tmp.path());
1598
1599 assert_eq!(result, None);
1601 }
1602
1603 #[test]
1607 fn rs_crate_04_parse_crate_name_workspace() {
1608 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 let result = parse_crate_name(tmp.path());
1618
1619 assert_eq!(result, None);
1621 }
1622
1623 #[test]
1627 fn rs_imp_05_crate_name_simple_import() {
1628 let source = "use my_crate::user::User;\n";
1630
1631 let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
1633
1634 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 #[test]
1649 fn rs_imp_06_crate_name_use_list() {
1650 let source = "use my_crate::user::{User, Admin};\n";
1652
1653 let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
1655
1656 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 #[test]
1676 fn rs_imp_07_crate_name_none_skips() {
1677 let source = "use my_crate::user::User;\n";
1679
1680 let result = extract_import_specifiers_with_crate_name(source, None);
1682
1683 assert!(
1685 result.is_empty(),
1686 "Expected empty result when crate_name=None, got: {:?}",
1687 result
1688 );
1689 }
1690
1691 #[test]
1695 fn rs_imp_08_mixed_crate_and_crate_name() {
1696 let source = "use crate::service::Service;\nuse my_crate::user::User;\n";
1699
1700 let result = extract_import_specifiers_with_crate_name(source, Some("my_crate"));
1702
1703 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 #[test]
1727 fn rs_l2_integ_crate_name_import_layer2() {
1728 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 let result =
1761 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1762
1763 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 #[test]
1784 fn rs_deep_reexport_01_two_hop() {
1785 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 let result =
1822 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1823
1824 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 #[test]
1847 fn rs_deep_reexport_02_three_hop() {
1848 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 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 let result =
1890 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
1891
1892 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 #[test]
1914 fn rs_deep_reexport_03_pub_use_and_pub_mod() {
1915 let source = "pub mod internal;\npub use internal::Exported;\n";
1917
1918 let extractor = RustExtractor::new();
1920 let result = extractor.extract_barrel_re_exports(source, "src/mod.rs");
1921
1922 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 #[test]
1950 fn rs_export_01_pub_fn_match() {
1951 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 let result = extractor.file_exports_any_symbol(&path, &symbols);
1959
1960 assert!(result, "Expected true for pub fn create_user");
1962 }
1963
1964 #[test]
1968 fn rs_export_02_pub_struct_match() {
1969 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 let result = extractor.file_exports_any_symbol(&path, &symbols);
1977
1978 assert!(result, "Expected true for pub struct User");
1980 }
1981
1982 #[test]
1986 fn rs_export_03_nonexistent_symbol() {
1987 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 let result = extractor.file_exports_any_symbol(&path, &symbols);
1995
1996 assert!(!result, "Expected false for NonExistent symbol");
1998 }
1999
2000 #[test]
2004 fn rs_export_04_no_pub_symbols() {
2005 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 let result = extractor.file_exports_any_symbol(&path, &symbols);
2013
2014 assert!(!result, "Expected false for file with no pub symbols");
2016 }
2017
2018 #[test]
2022 fn rs_export_05_pub_use_mod_only() {
2023 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 let result = extractor.file_exports_any_symbol(&path, &symbols);
2031
2032 assert!(
2034 !result,
2035 "Expected false for pub use/mod only file (barrel resolution handles these)"
2036 );
2037 }
2038
2039 #[test]
2043 fn rs_export_06_empty_symbols() {
2044 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 let result = extractor.file_exports_any_symbol(&path, &symbols);
2052
2053 assert!(result, "Expected true for empty symbol list");
2055 }
2056
2057 #[test]
2061 fn rs_export_07_nonexistent_file() {
2062 let path = PathBuf::from("/nonexistent/path/to/file.rs");
2064 let extractor = RustExtractor::new();
2065 let symbols = vec!["Foo".to_string()];
2066
2067 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 #[test]
2080 fn rs_ws_01_workspace_two_members() {
2081 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 let members = find_workspace_members(tmp.path());
2103
2104 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 #[test]
2123 fn rs_ws_02_single_crate_returns_empty() {
2124 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 let members = find_workspace_members(tmp.path());
2135
2136 assert!(members.is_empty(), "Expected empty, got: {:?}", members);
2138 }
2139
2140 #[test]
2144 fn rs_ws_03_target_dir_skipped() {
2145 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 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 let members = find_workspace_members(tmp.path());
2168
2169 assert_eq!(members.len(), 1, "Expected 1 member, got: {:?}", members);
2171 assert_eq!(members[0].crate_name, "crate_a");
2172 }
2173
2174 #[test]
2178 fn rs_ws_04_hyphenated_crate_name_converted() {
2179 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 let members = find_workspace_members(tmp.path());
2195
2196 assert_eq!(members.len(), 1, "Expected 1 member, got: {:?}", members);
2198 assert_eq!(members[0].crate_name, "my_crate");
2199 }
2200
2201 #[test]
2205 fn rs_ws_05_find_member_for_path_in_tests() {
2206 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 let test_file = member_root.join("tests").join("integration.rs");
2217 let result = find_member_for_path(&test_file, &members);
2218
2219 assert!(result.is_some(), "Expected Some(crate_a), got None");
2221 assert_eq!(result.unwrap().crate_name, "crate_a");
2222 }
2223
2224 #[test]
2228 fn rs_ws_06_find_member_for_path_not_in_any() {
2229 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 let outside_path = tmp.path().join("other").join("test.rs");
2240 let result = find_member_for_path(&outside_path, &members);
2241
2242 assert!(
2244 result.is_none(),
2245 "Expected None, got: {:?}",
2246 result.map(|m| &m.crate_name)
2247 );
2248 }
2249
2250 #[test]
2254 fn rs_ws_07_find_member_longest_prefix() {
2255 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 let test_file = foo_extra_root.join("tests").join("test_bar.rs");
2274 let result = find_member_for_path(&test_file, &members);
2275
2276 assert!(result.is_some(), "Expected Some(foo_extra), got None");
2278 assert_eq!(result.unwrap().crate_name, "foo_extra");
2279 }
2280
2281 #[test]
2285 fn rs_ws_e2e_01_workspace_l2_import_tracing() {
2286 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 let result =
2327 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2328
2329 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 #[test]
2356 fn rs_ws_e2e_02_l0_l1_still_work_at_workspace_level() {
2357 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 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 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 let result =
2411 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2412
2413 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 #[test]
2436 fn rs_ws_e2e_03_non_virtual_workspace_l2() {
2437 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 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 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 let result =
2498 extractor.map_test_files_with_imports(&production_files, &test_sources, tmp.path());
2499
2500 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 #[test]
2521 fn rs_ws_08_has_workspace_section() {
2522 let tmp = tempfile::tempdir().unwrap();
2523
2524 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 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 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 std::fs::remove_file(tmp.path().join("Cargo.toml")).unwrap();
2550 assert!(!has_workspace_section(tmp.path()));
2551 }
2552}