1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Node, Query, QueryCursor};
7
8use super::{cached_query, TypeScriptExtractor};
9
10const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
11static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
12
13const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
14static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
15
16const RE_EXPORT_QUERY: &str = include_str!("../queries/re_export.scm");
17static RE_EXPORT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
18
19const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
20static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
21
22const MAX_BARREL_DEPTH: usize = 3;
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct ProductionFunction {
28 pub name: String,
29 pub file: String,
30 pub line: usize,
31 pub class_name: Option<String>,
32 pub is_exported: bool,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub struct Route {
38 pub http_method: String,
39 pub path: String,
40 pub handler_name: String,
41 pub class_name: String,
42 pub file: String,
43 pub line: usize,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct DecoratorInfo {
49 pub name: String,
50 pub arguments: Vec<String>,
51 pub target_name: String,
52 pub class_name: String,
53 pub file: String,
54 pub line: usize,
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub struct FileMapping {
59 pub production_file: String,
60 pub test_files: Vec<String>,
61 pub strategy: MappingStrategy,
62}
63
64#[derive(Debug, Clone, PartialEq)]
65pub enum MappingStrategy {
66 FileNameConvention,
67 ImportTracing,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub struct ImportMapping {
73 pub symbol_name: String,
74 pub module_specifier: String,
75 pub file: String,
76 pub line: usize,
77 pub symbols: Vec<String>,
80}
81
82#[derive(Debug, Clone, PartialEq)]
84pub struct BarrelReExport {
85 pub symbols: Vec<String>,
87 pub from_specifier: String,
89 pub wildcard: bool,
91}
92
93const HTTP_METHODS: &[&str] = &["Get", "Post", "Put", "Patch", "Delete", "Head", "Options"];
95
96const GAP_RELEVANT_DECORATORS: &[&str] = &[
98 "UseGuards",
99 "UsePipes",
100 "IsEmail",
101 "IsNotEmpty",
102 "MinLength",
103 "MaxLength",
104 "IsOptional",
105 "IsString",
106 "IsNumber",
107 "IsInt",
108 "IsBoolean",
109 "IsDate",
110 "IsEnum",
111 "IsArray",
112 "ValidateNested",
113 "Min",
114 "Max",
115 "Matches",
116 "IsUrl",
117 "IsUUID",
118];
119
120impl TypeScriptExtractor {
121 pub fn map_test_files(
122 &self,
123 production_files: &[String],
124 test_files: &[String],
125 ) -> Vec<FileMapping> {
126 let mut tests_by_key: HashMap<(String, String), Vec<String>> = HashMap::new();
127
128 for test_file in test_files {
129 let Some(stem) = test_stem(test_file) else {
130 continue;
131 };
132 let directory = Path::new(test_file)
133 .parent()
134 .map(|parent| parent.to_string_lossy().into_owned())
135 .unwrap_or_default();
136
137 tests_by_key
138 .entry((directory, stem.to_string()))
139 .or_default()
140 .push(test_file.clone());
141 }
142
143 production_files
144 .iter()
145 .map(|production_file| {
146 let test_matches = production_stem(production_file)
147 .and_then(|stem| {
148 let directory = Path::new(production_file)
149 .parent()
150 .map(|parent| parent.to_string_lossy().into_owned())
151 .unwrap_or_default();
152 tests_by_key.get(&(directory, stem.to_string())).cloned()
153 })
154 .unwrap_or_default();
155
156 FileMapping {
157 production_file: production_file.clone(),
158 test_files: test_matches,
159 strategy: MappingStrategy::FileNameConvention,
160 }
161 })
162 .collect()
163 }
164
165 pub fn extract_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
167 let mut parser = Self::parser();
168 let tree = match parser.parse(source, None) {
169 Some(t) => t,
170 None => return Vec::new(),
171 };
172 let source_bytes = source.as_bytes();
173
174 let mut routes = Vec::new();
175
176 for node in iter_children(tree.root_node()) {
178 let (container, class_node) = match node.kind() {
180 "export_statement" => {
181 let cls = node
182 .named_children(&mut node.walk())
183 .find(|c| c.kind() == "class_declaration");
184 match cls {
185 Some(c) => (node, c),
186 None => continue,
187 }
188 }
189 "class_declaration" => (node, node),
190 _ => continue,
191 };
192
193 let (base_path, class_name) =
195 match extract_controller_info(container, class_node, source_bytes) {
196 Some(info) => info,
197 None => continue,
198 };
199
200 let class_body = match class_node.child_by_field_name("body") {
201 Some(b) => b,
202 None => continue,
203 };
204
205 let mut decorator_acc: Vec<Node> = Vec::new();
206 for child in iter_children(class_body) {
207 match child.kind() {
208 "decorator" => decorator_acc.push(child),
209 "method_definition" => {
210 let handler_name = child
211 .child_by_field_name("name")
212 .and_then(|n| n.utf8_text(source_bytes).ok())
213 .unwrap_or("")
214 .to_string();
215 let line = child.start_position().row + 1;
216
217 for dec in &decorator_acc {
218 if let Some((dec_name, dec_arg)) =
219 extract_decorator_call(*dec, source_bytes)
220 {
221 if HTTP_METHODS.contains(&dec_name.as_str()) {
222 let sub_path = dec_arg.unwrap_or_default();
223 routes.push(Route {
224 http_method: dec_name.to_uppercase(),
225 path: normalize_path(&base_path, &sub_path),
226 handler_name: handler_name.clone(),
227 class_name: class_name.clone(),
228 file: file_path.to_string(),
229 line,
230 });
231 }
232 }
233 }
234 decorator_acc.clear();
235 }
236 _ => {}
237 }
238 }
239 }
240
241 routes
242 }
243
244 pub fn extract_decorators(&self, source: &str, file_path: &str) -> Vec<DecoratorInfo> {
246 let mut parser = Self::parser();
247 let tree = match parser.parse(source, None) {
248 Some(t) => t,
249 None => return Vec::new(),
250 };
251 let source_bytes = source.as_bytes();
252
253 let mut decorators = Vec::new();
254
255 for node in iter_children(tree.root_node()) {
256 let (container, class_node) = match node.kind() {
257 "export_statement" => {
258 let cls = node
259 .named_children(&mut node.walk())
260 .find(|c| c.kind() == "class_declaration");
261 match cls {
262 Some(c) => (node, c),
263 None => continue,
264 }
265 }
266 "class_declaration" => (node, node),
267 _ => continue,
268 };
269
270 let class_name = class_node
271 .child_by_field_name("name")
272 .and_then(|n| n.utf8_text(source_bytes).ok())
273 .unwrap_or("")
274 .to_string();
275
276 let class_level_decorators: Vec<Node> = find_decorators_on_node(container, class_node);
279 collect_gap_decorators(
280 &class_level_decorators,
281 &class_name, &class_name,
283 file_path,
284 source_bytes,
285 &mut decorators,
286 );
287
288 let class_body = match class_node.child_by_field_name("body") {
289 Some(b) => b,
290 None => continue,
291 };
292
293 let mut decorator_acc: Vec<Node> = Vec::new();
294 for child in iter_children(class_body) {
295 match child.kind() {
296 "decorator" => decorator_acc.push(child),
297 "method_definition" => {
298 let method_name = child
299 .child_by_field_name("name")
300 .and_then(|n| n.utf8_text(source_bytes).ok())
301 .unwrap_or("")
302 .to_string();
303
304 collect_gap_decorators(
305 &decorator_acc,
306 &method_name,
307 &class_name,
308 file_path,
309 source_bytes,
310 &mut decorators,
311 );
312 decorator_acc.clear();
313 }
314 "public_field_definition" => {
316 let field_name = child
317 .child_by_field_name("name")
318 .and_then(|n| n.utf8_text(source_bytes).ok())
319 .unwrap_or("")
320 .to_string();
321
322 let field_decorators: Vec<Node> = iter_children(child)
323 .filter(|c| c.kind() == "decorator")
324 .collect();
325 collect_gap_decorators(
326 &field_decorators,
327 &field_name,
328 &class_name,
329 file_path,
330 source_bytes,
331 &mut decorators,
332 );
333 decorator_acc.clear();
334 }
335 _ => {}
336 }
337 }
338 }
339
340 decorators
341 }
342
343 pub fn extract_production_functions(
345 &self,
346 source: &str,
347 file_path: &str,
348 ) -> Vec<ProductionFunction> {
349 let mut parser = Self::parser();
350 let tree = match parser.parse(source, None) {
351 Some(t) => t,
352 None => return Vec::new(),
353 };
354
355 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
356 let mut cursor = QueryCursor::new();
357 let source_bytes = source.as_bytes();
358
359 let idx_name = query
360 .capture_index_for_name("name")
361 .expect("@name capture not found in production_function.scm");
362 let idx_exported_function = query
363 .capture_index_for_name("exported_function")
364 .expect("@exported_function capture not found");
365 let idx_function = query
366 .capture_index_for_name("function")
367 .expect("@function capture not found");
368 let idx_method = query
369 .capture_index_for_name("method")
370 .expect("@method capture not found");
371 let idx_exported_arrow = query
372 .capture_index_for_name("exported_arrow")
373 .expect("@exported_arrow capture not found");
374 let idx_arrow = query
375 .capture_index_for_name("arrow")
376 .expect("@arrow capture not found");
377
378 let mut dedup: HashMap<(usize, String), ProductionFunction> = HashMap::new();
383
384 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
385 while let Some(m) = matches.next() {
386 let name_node = match m.captures.iter().find(|c| c.index == idx_name) {
387 Some(c) => c.node,
388 None => continue,
389 };
390 let name = name_node.utf8_text(source_bytes).unwrap_or("").to_string();
391 let line = name_node.start_position().row + 1; let (is_exported, class_name) = if m
395 .captures
396 .iter()
397 .any(|c| c.index == idx_exported_function || c.index == idx_exported_arrow)
398 {
399 (true, None)
400 } else if m
401 .captures
402 .iter()
403 .any(|c| c.index == idx_function || c.index == idx_arrow)
404 {
405 (false, None)
406 } else if let Some(c) = m.captures.iter().find(|c| c.index == idx_method) {
407 let (cname, exported) = find_class_info(c.node, source_bytes);
408 (exported, cname)
409 } else {
410 continue;
411 };
412
413 dedup
414 .entry((line, name.clone()))
415 .and_modify(|existing| {
416 if is_exported {
417 existing.is_exported = true;
418 }
419 })
420 .or_insert(ProductionFunction {
421 name,
422 file: file_path.to_string(),
423 line,
424 class_name,
425 is_exported,
426 });
427 }
428
429 let mut results: Vec<ProductionFunction> = dedup.into_values().collect();
430 results.sort_by_key(|f| f.line);
431 results
432 }
433}
434
435fn iter_children(node: Node) -> impl Iterator<Item = Node> {
437 (0..node.child_count()).filter_map(move |i| node.child(i))
438}
439
440fn extract_controller_info(
444 container: Node,
445 class_node: Node,
446 source: &[u8],
447) -> Option<(String, String)> {
448 let class_name = class_node
449 .child_by_field_name("name")
450 .and_then(|n| n.utf8_text(source).ok())?
451 .to_string();
452
453 for search_node in [container, class_node] {
455 for i in 0..search_node.child_count() {
456 let child = match search_node.child(i) {
457 Some(c) => c,
458 None => continue,
459 };
460 if child.kind() != "decorator" {
461 continue;
462 }
463 if let Some((name, arg)) = extract_decorator_call(child, source) {
464 if name == "Controller" {
465 let base_path = arg.unwrap_or_default();
466 return Some((base_path, class_name));
467 }
468 }
469 }
470 }
471 None
472}
473
474fn collect_gap_decorators(
476 decorator_acc: &[Node],
477 target_name: &str,
478 class_name: &str,
479 file_path: &str,
480 source: &[u8],
481 output: &mut Vec<DecoratorInfo>,
482) {
483 for dec in decorator_acc {
484 if let Some((dec_name, _)) = extract_decorator_call(*dec, source) {
485 if GAP_RELEVANT_DECORATORS.contains(&dec_name.as_str()) {
486 let args = extract_decorator_args(*dec, source);
487 output.push(DecoratorInfo {
488 name: dec_name,
489 arguments: args,
490 target_name: target_name.to_string(),
491 class_name: class_name.to_string(),
492 file: file_path.to_string(),
493 line: dec.start_position().row + 1,
494 });
495 }
496 }
497 }
498}
499
500fn extract_decorator_call(decorator_node: Node, source: &[u8]) -> Option<(String, Option<String>)> {
504 for i in 0..decorator_node.child_count() {
505 let child = match decorator_node.child(i) {
506 Some(c) => c,
507 None => continue,
508 };
509
510 match child.kind() {
511 "call_expression" => {
512 let func_node = child.child_by_field_name("function")?;
513 let name = func_node.utf8_text(source).ok()?.to_string();
514 let args_node = child.child_by_field_name("arguments")?;
515
516 if args_node.named_child_count() == 0 {
517 return Some((name, None));
519 }
520 let first_string = find_first_string_arg(args_node, source);
522 if first_string.is_some() {
523 return Some((name, first_string));
524 }
525 return Some((name, Some("<dynamic>".to_string())));
527 }
528 "identifier" => {
529 let name = child.utf8_text(source).ok()?.to_string();
530 return Some((name, None));
531 }
532 _ => {}
533 }
534 }
535 None
536}
537
538fn extract_decorator_args(decorator_node: Node, source: &[u8]) -> Vec<String> {
541 let mut args = Vec::new();
542 for i in 0..decorator_node.child_count() {
543 let child = match decorator_node.child(i) {
544 Some(c) => c,
545 None => continue,
546 };
547 if child.kind() == "call_expression" {
548 if let Some(args_node) = child.child_by_field_name("arguments") {
549 for j in 0..args_node.named_child_count() {
550 if let Some(arg) = args_node.named_child(j) {
551 if let Ok(text) = arg.utf8_text(source) {
552 args.push(text.to_string());
553 }
554 }
555 }
556 }
557 }
558 }
559 args
560}
561
562fn find_first_string_arg(args_node: Node, source: &[u8]) -> Option<String> {
564 for i in 0..args_node.named_child_count() {
565 let arg = args_node.named_child(i)?;
566 if arg.kind() == "string" {
567 let text = arg.utf8_text(source).ok()?;
568 let stripped = text.trim_matches(|c| c == '\'' || c == '"');
570 if !stripped.is_empty() {
571 return Some(stripped.to_string());
572 }
573 }
574 }
575 None
576}
577
578fn normalize_path(base: &str, sub: &str) -> String {
583 let base = base.trim_matches('/');
584 let sub = sub.trim_matches('/');
585 match (base.is_empty(), sub.is_empty()) {
586 (true, true) => "/".to_string(),
587 (true, false) => format!("/{sub}"),
588 (false, true) => format!("/{base}"),
589 (false, false) => format!("/{base}/{sub}"),
590 }
591}
592
593fn find_decorators_on_node<'a>(container: Node<'a>, class_node: Node<'a>) -> Vec<Node<'a>> {
596 let mut result = Vec::new();
597 for search_node in [container, class_node] {
598 for i in 0..search_node.child_count() {
599 if let Some(child) = search_node.child(i) {
600 if child.kind() == "decorator" {
601 result.push(child);
602 }
603 }
604 }
605 }
606 result
607}
608
609fn find_class_info(method_node: Node, source: &[u8]) -> (Option<String>, bool) {
611 let mut current = method_node.parent();
612 while let Some(node) = current {
613 if node.kind() == "class_body" {
614 if let Some(class_node) = node.parent() {
615 let class_kind = class_node.kind();
616 if class_kind == "class_declaration"
617 || class_kind == "class"
618 || class_kind == "abstract_class_declaration"
619 {
620 let class_name = class_node
621 .child_by_field_name("name")
622 .and_then(|n| n.utf8_text(source).ok())
623 .map(|s| s.to_string());
624
625 let is_exported = class_node
627 .parent()
628 .is_some_and(|p| p.kind() == "export_statement");
629
630 return (class_name, is_exported);
631 }
632 }
633 }
634 current = node.parent();
635 }
636 (None, false)
637}
638
639fn is_type_only_import(symbol_node: Node) -> bool {
642 let parent = symbol_node.parent();
644 if let Some(p) = parent {
645 if p.kind() == "import_specifier" {
646 for i in 0..p.child_count() {
647 if let Some(child) = p.child(i) {
648 if child.kind() == "type" {
649 return true;
650 }
651 }
652 }
653 }
654 }
655
656 let mut current = Some(symbol_node);
659 while let Some(node) = current {
660 if node.kind() == "import_statement" {
661 for i in 0..node.child_count() {
662 if let Some(child) = node.child(i) {
663 if child.kind() == "type" {
664 return true;
665 }
666 }
667 }
668 break;
669 }
670 current = node.parent();
671 }
672 false
673}
674
675impl TypeScriptExtractor {
678 pub fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
679 let mut parser = Self::parser();
680 let tree = match parser.parse(source, None) {
681 Some(t) => t,
682 None => return Vec::new(),
683 };
684 let source_bytes = source.as_bytes();
685 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
686 let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
687 let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
688
689 let mut cursor = QueryCursor::new();
690 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
691 let mut result = Vec::new();
692
693 while let Some(m) = matches.next() {
694 let mut symbol_node = None;
695 let mut symbol = None;
696 let mut specifier = None;
697 let mut symbol_line = 0usize;
698 for cap in m.captures {
699 if cap.index == symbol_idx {
700 symbol_node = Some(cap.node);
701 symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
702 symbol_line = cap.node.start_position().row + 1;
703 } else if cap.index == specifier_idx {
704 specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
705 }
706 }
707 if let (Some(sym), Some(spec)) = (symbol, specifier) {
708 if !spec.starts_with("./") && !spec.starts_with("../") {
710 continue;
711 }
712
713 if let Some(snode) = symbol_node {
717 if is_type_only_import(snode) {
718 continue;
719 }
720 }
721
722 result.push(ImportMapping {
723 symbol_name: sym.to_string(),
724 module_specifier: spec.to_string(),
725 file: file_path.to_string(),
726 line: symbol_line,
727 symbols: Vec::new(),
728 });
729 }
730 }
731 let specifier_to_symbols: HashMap<String, Vec<String>> =
734 result.iter().fold(HashMap::new(), |mut acc, im| {
735 acc.entry(im.module_specifier.clone())
736 .or_default()
737 .push(im.symbol_name.clone());
738 acc
739 });
740 for im in &mut result {
741 im.symbols = specifier_to_symbols
742 .get(&im.module_specifier)
743 .cloned()
744 .unwrap_or_default();
745 }
746 result
747 }
748
749 pub fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
753 let mut parser = Self::parser();
754 let tree = match parser.parse(source, None) {
755 Some(t) => t,
756 None => return Vec::new(),
757 };
758 let source_bytes = source.as_bytes();
759 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
760 let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
761 let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
762
763 let mut cursor = QueryCursor::new();
764 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
765 let mut specifier_symbols: std::collections::HashMap<String, Vec<String>> =
767 std::collections::HashMap::new();
768
769 while let Some(m) = matches.next() {
770 let mut symbol_node = None;
771 let mut symbol = None;
772 let mut specifier = None;
773 for cap in m.captures {
774 if cap.index == symbol_idx {
775 symbol_node = Some(cap.node);
776 symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
777 } else if cap.index == specifier_idx {
778 specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
779 }
780 }
781 if let (Some(sym), Some(spec)) = (symbol, specifier) {
782 if spec.starts_with("./") || spec.starts_with("../") {
784 continue;
785 }
786 if let Some(snode) = symbol_node {
788 if is_type_only_import(snode) {
789 continue;
790 }
791 }
792 specifier_symbols
793 .entry(spec.to_string())
794 .or_default()
795 .push(sym.to_string());
796 }
797 }
798
799 specifier_symbols.into_iter().collect()
800 }
801
802 pub fn extract_barrel_re_exports(&self, source: &str, _file_path: &str) -> Vec<BarrelReExport> {
804 let mut parser = Self::parser();
805 let tree = match parser.parse(source, None) {
806 Some(t) => t,
807 None => return Vec::new(),
808 };
809 let source_bytes = source.as_bytes();
810 let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
811
812 let symbol_idx = query.capture_index_for_name("symbol_name");
813 let wildcard_idx = query.capture_index_for_name("wildcard");
814 let specifier_idx = query
815 .capture_index_for_name("from_specifier")
816 .expect("@from_specifier capture not found in re_export.scm");
817
818 let mut cursor = QueryCursor::new();
819 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
820
821 struct ReExportEntry {
825 symbols: Vec<String>,
826 wildcard: bool,
827 }
828 let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
829
830 while let Some(m) = matches.next() {
831 let mut from_spec = None;
832 let mut sym_name = None;
833 let mut is_wildcard = false;
834
835 for cap in m.captures {
836 if wildcard_idx == Some(cap.index) {
837 is_wildcard = true;
838 } else if cap.index == specifier_idx {
839 from_spec = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
840 } else if symbol_idx == Some(cap.index) {
841 sym_name = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
842 }
843 }
844
845 let Some(spec) = from_spec else { continue };
846
847 let entry = grouped.entry(spec).or_insert(ReExportEntry {
848 symbols: Vec::new(),
849 wildcard: false,
850 });
851 if is_wildcard {
852 entry.wildcard = true;
853 }
854 if let Some(sym) = sym_name {
855 if !sym.is_empty() && !entry.symbols.contains(&sym) {
856 entry.symbols.push(sym);
857 }
858 }
859 }
860
861 grouped
862 .into_iter()
863 .map(|(from_spec, entry)| BarrelReExport {
864 symbols: entry.symbols,
865 from_specifier: from_spec,
866 wildcard: entry.wildcard,
867 })
868 .collect()
869 }
870
871 pub fn map_test_files_with_imports(
872 &self,
873 production_files: &[String],
874 test_sources: &HashMap<String, String>,
875 scan_root: &Path,
876 ) -> Vec<FileMapping> {
877 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
878
879 let mut mappings = self.map_test_files(production_files, &test_file_list);
881
882 let canonical_root = match scan_root.canonicalize() {
884 Ok(r) => r,
885 Err(_) => return mappings,
886 };
887 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
888 for (idx, prod) in production_files.iter().enumerate() {
889 if let Ok(canonical) = Path::new(prod).canonicalize() {
890 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
891 }
892 }
893
894 let layer1_matched: std::collections::HashSet<String> = mappings
896 .iter()
897 .flat_map(|m| m.test_files.iter().cloned())
898 .collect();
899
900 let tsconfig_paths =
902 crate::tsconfig::discover_tsconfig(&canonical_root).and_then(|tsconfig_path| {
903 let content = std::fs::read_to_string(&tsconfig_path)
904 .map_err(|e| {
905 eprintln!("[exspec] warning: failed to read tsconfig: {e}");
906 })
907 .ok()?;
908 let tsconfig_dir = tsconfig_path.parent().unwrap_or(&canonical_root);
909 crate::tsconfig::TsconfigPaths::from_str(&content, tsconfig_dir)
910 .or_else(|| {
911 eprintln!("[exspec] warning: failed to parse tsconfig paths, alias resolution disabled");
912 None
913 })
914 });
915
916 for (test_file, source) in test_sources {
919 let imports = self.extract_imports(source, test_file);
920 let from_file = Path::new(test_file);
921 let mut matched_indices = std::collections::HashSet::new();
922
923 let collect_matches = |resolved: &str,
926 symbols: &[String],
927 indices: &mut HashSet<usize>| {
928 if is_barrel_file(resolved) {
929 let barrel_path = PathBuf::from(resolved);
930 let resolved_files =
931 resolve_barrel_exports(&barrel_path, symbols, &canonical_root);
932 for prod in resolved_files {
933 let prod_str = prod.to_string_lossy().into_owned();
934 if !is_non_sut_helper(&prod_str, canonical_to_idx.contains_key(&prod_str)) {
935 if let Some(&idx) = canonical_to_idx.get(&prod_str) {
936 indices.insert(idx);
937 }
938 }
939 }
940 } else if !is_non_sut_helper(resolved, canonical_to_idx.contains_key(resolved)) {
941 if let Some(&idx) = canonical_to_idx.get(resolved) {
942 indices.insert(idx);
943 }
944 }
945 };
946
947 for import in &imports {
948 if let Some(resolved) =
949 resolve_import_path(&import.module_specifier, from_file, &canonical_root)
950 {
951 collect_matches(&resolved, &import.symbols, &mut matched_indices);
952 }
953 }
954
955 if let Some(ref tc_paths) = tsconfig_paths {
957 let alias_imports = self.extract_all_import_specifiers(source);
958 for (specifier, symbols) in &alias_imports {
959 let Some(alias_base) = tc_paths.resolve_alias(specifier) else {
960 continue;
961 };
962 if let Some(resolved) =
963 resolve_absolute_base_to_file(&alias_base, &canonical_root)
964 {
965 collect_matches(&resolved, symbols, &mut matched_indices);
966 }
967 }
968 }
969
970 for idx in matched_indices {
971 if !mappings[idx].test_files.contains(test_file) {
973 mappings[idx].test_files.push(test_file.clone());
974 }
975 }
976 }
977
978 for mapping in &mut mappings {
981 let has_layer1 = mapping
982 .test_files
983 .iter()
984 .any(|t| layer1_matched.contains(t));
985 if !has_layer1 && !mapping.test_files.is_empty() {
986 mapping.strategy = MappingStrategy::ImportTracing;
987 }
988 }
989
990 mappings
991 }
992}
993
994pub fn resolve_import_path(
997 module_specifier: &str,
998 from_file: &Path,
999 scan_root: &Path,
1000) -> Option<String> {
1001 let base_dir_raw = from_file.parent()?;
1005 let base_dir = base_dir_raw
1006 .canonicalize()
1007 .unwrap_or_else(|_| base_dir_raw.to_path_buf());
1008 let raw_path = base_dir.join(module_specifier);
1011 let canonical_root = scan_root.canonicalize().ok()?;
1012 resolve_absolute_base_to_file(&raw_path, &canonical_root)
1013}
1014
1015fn resolve_absolute_base_to_file(base: &Path, canonical_root: &Path) -> Option<String> {
1024 const TS_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx"];
1025 let has_known_ext = base
1026 .extension()
1027 .and_then(|e| e.to_str())
1028 .is_some_and(|e| TS_EXTENSIONS.contains(&e));
1029
1030 let candidates: Vec<PathBuf> = if has_known_ext {
1031 vec![base.to_path_buf()]
1032 } else {
1033 let base_str = base.as_os_str().to_string_lossy();
1034 TS_EXTENSIONS
1035 .iter()
1036 .map(|ext| PathBuf::from(format!("{base_str}.{ext}")))
1037 .collect()
1038 };
1039
1040 for candidate in &candidates {
1041 if let Ok(canonical) = candidate.canonicalize() {
1042 if canonical.starts_with(canonical_root) {
1043 return Some(canonical.to_string_lossy().into_owned());
1044 }
1045 }
1046 }
1047
1048 if !has_known_ext {
1050 let base_str = base.as_os_str().to_string_lossy();
1051 let index_candidates = [
1052 PathBuf::from(format!("{base_str}/index.ts")),
1053 PathBuf::from(format!("{base_str}/index.tsx")),
1054 ];
1055 for candidate in &index_candidates {
1056 if let Ok(canonical) = candidate.canonicalize() {
1057 if canonical.starts_with(canonical_root) {
1058 return Some(canonical.to_string_lossy().into_owned());
1059 }
1060 }
1061 }
1062 }
1063
1064 None
1065}
1066
1067fn is_type_definition_file(file_path: &str) -> bool {
1070 let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1071 return false;
1072 };
1073 if let Some(stem) = Path::new(file_name).file_stem().and_then(|s| s.to_str()) {
1074 for suffix in &[".enum", ".interface", ".exception"] {
1075 if stem.ends_with(suffix) && stem != &suffix[1..] {
1076 return true;
1077 }
1078 }
1079 }
1080 false
1081}
1082
1083fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
1091 if file_path
1095 .split('/')
1096 .any(|seg| seg == "test" || seg == "__tests__")
1097 {
1098 return true;
1099 }
1100
1101 let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1102 return false;
1103 };
1104
1105 if matches!(
1107 file_name,
1108 "constants.ts"
1109 | "constants.js"
1110 | "constants.tsx"
1111 | "constants.jsx"
1112 | "index.ts"
1113 | "index.js"
1114 | "index.tsx"
1115 | "index.jsx"
1116 ) {
1117 return true;
1118 }
1119
1120 if !is_known_production && is_type_definition_file(file_path) {
1124 return true;
1125 }
1126
1127 false
1128}
1129
1130fn is_barrel_file(path: &str) -> bool {
1132 let file_name = Path::new(path)
1133 .file_name()
1134 .and_then(|f| f.to_str())
1135 .unwrap_or("");
1136 file_name == "index.ts" || file_name == "index.tsx"
1137}
1138
1139fn file_exports_any_symbol(file_path: &Path, symbols: &[String]) -> bool {
1142 if symbols.is_empty() {
1143 return true;
1144 }
1145 let source = match std::fs::read_to_string(file_path) {
1146 Ok(s) => s,
1147 Err(_) => return false,
1148 };
1149 let mut parser = TypeScriptExtractor::parser();
1150 let tree = match parser.parse(&source, None) {
1151 Some(t) => t,
1152 None => return false,
1153 };
1154 let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
1155 let symbol_idx = query
1156 .capture_index_for_name("symbol_name")
1157 .expect("@symbol_name capture not found in exported_symbol.scm");
1158
1159 let mut cursor = QueryCursor::new();
1160 let source_bytes = source.as_bytes();
1161 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1162 while let Some(m) = matches.next() {
1163 for cap in m.captures {
1164 if cap.index == symbol_idx {
1165 let name = cap.node.utf8_text(source_bytes).unwrap_or("");
1166 if symbols.iter().any(|s| s == name) {
1167 return true;
1168 }
1169 }
1170 }
1171 }
1172 false
1173}
1174
1175pub fn resolve_barrel_exports(
1179 barrel_path: &Path,
1180 symbols: &[String],
1181 scan_root: &Path,
1182) -> Vec<PathBuf> {
1183 let canonical_root = match scan_root.canonicalize() {
1184 Ok(r) => r,
1185 Err(_) => return Vec::new(),
1186 };
1187 let extractor = crate::TypeScriptExtractor::new();
1188 let mut visited: HashSet<PathBuf> = HashSet::new();
1189 let mut results: Vec<PathBuf> = Vec::new();
1190 resolve_barrel_exports_inner(
1191 barrel_path,
1192 symbols,
1193 scan_root,
1194 &canonical_root,
1195 &extractor,
1196 &mut visited,
1197 0,
1198 &mut results,
1199 );
1200 results
1201}
1202
1203#[allow(clippy::too_many_arguments)]
1204fn resolve_barrel_exports_inner(
1205 barrel_path: &Path,
1206 symbols: &[String],
1207 scan_root: &Path,
1208 canonical_root: &Path,
1209 extractor: &crate::TypeScriptExtractor,
1210 visited: &mut HashSet<PathBuf>,
1211 depth: usize,
1212 results: &mut Vec<PathBuf>,
1213) {
1214 if depth >= MAX_BARREL_DEPTH {
1215 return;
1216 }
1217
1218 let canonical_barrel = match barrel_path.canonicalize() {
1219 Ok(p) => p,
1220 Err(_) => return,
1221 };
1222 if !visited.insert(canonical_barrel) {
1223 return;
1224 }
1225
1226 let source = match std::fs::read_to_string(barrel_path) {
1227 Ok(s) => s,
1228 Err(_) => return,
1229 };
1230
1231 let re_exports = extractor.extract_barrel_re_exports(&source, &barrel_path.to_string_lossy());
1232
1233 for re_export in &re_exports {
1234 if !re_export.wildcard {
1238 let has_match =
1239 symbols.is_empty() || symbols.iter().any(|s| re_export.symbols.contains(s));
1240 if !has_match {
1241 continue;
1242 }
1243 }
1244
1245 if let Some(resolved_str) =
1246 resolve_import_path(&re_export.from_specifier, barrel_path, scan_root)
1247 {
1248 if is_barrel_file(&resolved_str) {
1249 resolve_barrel_exports_inner(
1250 &PathBuf::from(&resolved_str),
1251 symbols,
1252 scan_root,
1253 canonical_root,
1254 extractor,
1255 visited,
1256 depth + 1,
1257 results,
1258 );
1259 } else if !is_non_sut_helper(&resolved_str, false) {
1260 if !symbols.is_empty()
1263 && re_export.wildcard
1264 && !file_exports_any_symbol(Path::new(&resolved_str), symbols)
1265 {
1266 continue;
1267 }
1268 if let Ok(canonical) = PathBuf::from(&resolved_str).canonicalize() {
1269 if canonical.starts_with(canonical_root) && !results.contains(&canonical) {
1270 results.push(canonical);
1271 }
1272 }
1273 }
1274 }
1275 }
1276}
1277
1278fn production_stem(path: &str) -> Option<&str> {
1279 Path::new(path).file_stem()?.to_str()
1280}
1281
1282fn test_stem(path: &str) -> Option<&str> {
1283 let stem = Path::new(path).file_stem()?.to_str()?;
1284 stem.strip_suffix(".spec")
1285 .or_else(|| stem.strip_suffix(".test"))
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use super::*;
1291
1292 fn fixture(name: &str) -> String {
1293 let path = format!(
1294 "{}/tests/fixtures/typescript/observe/{}",
1295 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-typescript", ""),
1296 name
1297 );
1298 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
1299 }
1300
1301 #[test]
1303 fn exported_functions_extracted() {
1304 let source = fixture("exported_functions.ts");
1306 let extractor = TypeScriptExtractor::new();
1307
1308 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1310
1311 let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1313 let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1314 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1315 assert!(
1316 names.contains(&"findById"),
1317 "expected findById in {names:?}"
1318 );
1319 }
1320
1321 #[test]
1323 fn non_exported_function_has_flag_false() {
1324 let source = fixture("exported_functions.ts");
1326 let extractor = TypeScriptExtractor::new();
1327
1328 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1330
1331 let helper = funcs.iter().find(|f| f.name == "internalHelper");
1333 assert!(helper.is_some(), "expected internalHelper to be extracted");
1334 assert!(!helper.unwrap().is_exported);
1335 }
1336
1337 #[test]
1339 fn class_methods_with_class_name() {
1340 let source = fixture("class_methods.ts");
1342 let extractor = TypeScriptExtractor::new();
1343
1344 let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1346
1347 let controller_methods: Vec<&ProductionFunction> = funcs
1349 .iter()
1350 .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1351 .collect();
1352 let names: Vec<&str> = controller_methods.iter().map(|f| f.name.as_str()).collect();
1353 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1354 assert!(names.contains(&"create"), "expected create in {names:?}");
1355 assert!(
1356 names.contains(&"validate"),
1357 "expected validate in {names:?}"
1358 );
1359 }
1360
1361 #[test]
1363 fn exported_class_is_exported() {
1364 let source = fixture("class_methods.ts");
1366 let extractor = TypeScriptExtractor::new();
1367
1368 let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1370
1371 let controller_methods: Vec<&ProductionFunction> = funcs
1373 .iter()
1374 .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1375 .collect();
1376 assert!(
1377 controller_methods.iter().all(|f| f.is_exported),
1378 "all UsersController methods should be exported"
1379 );
1380
1381 let internal_methods: Vec<&ProductionFunction> = funcs
1383 .iter()
1384 .filter(|f| f.class_name.as_deref() == Some("InternalService"))
1385 .collect();
1386 assert!(
1387 !internal_methods.is_empty(),
1388 "expected InternalService methods"
1389 );
1390 assert!(
1391 internal_methods.iter().all(|f| !f.is_exported),
1392 "all InternalService methods should not be exported"
1393 );
1394 }
1395
1396 #[test]
1398 fn arrow_exports_extracted() {
1399 let source = fixture("arrow_exports.ts");
1401 let extractor = TypeScriptExtractor::new();
1402
1403 let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1405
1406 let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1408 let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1409 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1410 assert!(
1411 names.contains(&"findById"),
1412 "expected findById in {names:?}"
1413 );
1414 }
1415
1416 #[test]
1418 fn non_exported_arrow_flag_false() {
1419 let source = fixture("arrow_exports.ts");
1421 let extractor = TypeScriptExtractor::new();
1422
1423 let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1425
1426 let internal = funcs.iter().find(|f| f.name == "internalFn");
1428 assert!(internal.is_some(), "expected internalFn to be extracted");
1429 assert!(!internal.unwrap().is_exported);
1430 }
1431
1432 #[test]
1434 fn mixed_file_all_types() {
1435 let source = fixture("mixed.ts");
1437 let extractor = TypeScriptExtractor::new();
1438
1439 let funcs = extractor.extract_production_functions(&source, "mixed.ts");
1441
1442 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1444 assert!(names.contains(&"getUser"), "expected getUser in {names:?}");
1446 assert!(
1447 names.contains(&"createUser"),
1448 "expected createUser in {names:?}"
1449 );
1450 assert!(
1452 names.contains(&"formatName"),
1453 "expected formatName in {names:?}"
1454 );
1455 assert!(
1456 names.contains(&"validateInput"),
1457 "expected validateInput in {names:?}"
1458 );
1459
1460 let get_user = funcs.iter().find(|f| f.name == "getUser").unwrap();
1462 assert!(get_user.is_exported);
1463 let format_name = funcs.iter().find(|f| f.name == "formatName").unwrap();
1464 assert!(!format_name.is_exported);
1465
1466 let find_all = funcs
1468 .iter()
1469 .find(|f| f.name == "findAll" && f.class_name.is_some())
1470 .unwrap();
1471 assert_eq!(find_all.class_name.as_deref(), Some("UserService"));
1472 assert!(find_all.is_exported);
1473
1474 let transform = funcs.iter().find(|f| f.name == "transform").unwrap();
1475 assert_eq!(transform.class_name.as_deref(), Some("PrivateHelper"));
1476 assert!(!transform.is_exported);
1477 }
1478
1479 #[test]
1481 fn decorated_methods_extracted() {
1482 let source = fixture("nestjs_controller.ts");
1484 let extractor = TypeScriptExtractor::new();
1485
1486 let funcs = extractor.extract_production_functions(&source, "nestjs_controller.ts");
1488
1489 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1491 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1492 assert!(names.contains(&"create"), "expected create in {names:?}");
1493 assert!(names.contains(&"remove"), "expected remove in {names:?}");
1494
1495 for func in &funcs {
1496 assert_eq!(func.class_name.as_deref(), Some("UsersController"));
1497 assert!(func.is_exported);
1498 }
1499 }
1500
1501 #[test]
1503 fn line_numbers_correct() {
1504 let source = fixture("exported_functions.ts");
1506 let extractor = TypeScriptExtractor::new();
1507
1508 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1510
1511 let find_all = funcs.iter().find(|f| f.name == "findAll").unwrap();
1513 assert_eq!(find_all.line, 1, "findAll should be on line 1");
1514
1515 let find_by_id = funcs.iter().find(|f| f.name == "findById").unwrap();
1516 assert_eq!(find_by_id.line, 5, "findById should be on line 5");
1517
1518 let helper = funcs.iter().find(|f| f.name == "internalHelper").unwrap();
1519 assert_eq!(helper.line, 9, "internalHelper should be on line 9");
1520 }
1521
1522 #[test]
1524 fn empty_source_returns_empty() {
1525 let extractor = TypeScriptExtractor::new();
1527
1528 let funcs = extractor.extract_production_functions("", "empty.ts");
1530
1531 assert!(funcs.is_empty());
1533 }
1534
1535 #[test]
1539 fn basic_controller_routes() {
1540 let source = fixture("nestjs_controller.ts");
1542 let extractor = TypeScriptExtractor::new();
1543
1544 let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1546
1547 assert_eq!(routes.len(), 3, "expected 3 routes, got {routes:?}");
1549 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1550 assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
1551 assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
1552 assert!(
1553 methods.contains(&"DELETE"),
1554 "expected DELETE in {methods:?}"
1555 );
1556
1557 let get_route = routes.iter().find(|r| r.http_method == "GET").unwrap();
1558 assert_eq!(get_route.path, "/users");
1559
1560 let delete_route = routes.iter().find(|r| r.http_method == "DELETE").unwrap();
1561 assert_eq!(delete_route.path, "/users/:id");
1562 }
1563
1564 #[test]
1566 fn route_path_combination() {
1567 let source = fixture("nestjs_routes_advanced.ts");
1569 let extractor = TypeScriptExtractor::new();
1570
1571 let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1573
1574 let active = routes
1576 .iter()
1577 .find(|r| r.handler_name == "findActive")
1578 .unwrap();
1579 assert_eq!(active.http_method, "GET");
1580 assert_eq!(active.path, "/api/v1/users/active");
1581 }
1582
1583 #[test]
1585 fn controller_no_path() {
1586 let source = fixture("nestjs_empty_controller.ts");
1588 let extractor = TypeScriptExtractor::new();
1589
1590 let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1592
1593 assert_eq!(routes.len(), 1, "expected 1 route, got {routes:?}");
1595 assert_eq!(routes[0].http_method, "GET");
1596 assert_eq!(routes[0].path, "/health");
1597 }
1598
1599 #[test]
1601 fn method_without_route_decorator() {
1602 let source = fixture("nestjs_empty_controller.ts");
1604 let extractor = TypeScriptExtractor::new();
1605
1606 let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1608
1609 let helper = routes.iter().find(|r| r.handler_name == "helperMethod");
1611 assert!(helper.is_none(), "helperMethod should not be a route");
1612 }
1613
1614 #[test]
1616 fn all_http_methods() {
1617 let source = fixture("nestjs_routes_advanced.ts");
1619 let extractor = TypeScriptExtractor::new();
1620
1621 let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1623
1624 assert_eq!(routes.len(), 9, "expected 9 routes, got {routes:?}");
1626 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1627 assert!(methods.contains(&"GET"));
1628 assert!(methods.contains(&"POST"));
1629 assert!(methods.contains(&"PUT"));
1630 assert!(methods.contains(&"PATCH"));
1631 assert!(methods.contains(&"DELETE"));
1632 assert!(methods.contains(&"HEAD"));
1633 assert!(methods.contains(&"OPTIONS"));
1634 }
1635
1636 #[test]
1638 fn use_guards_decorator() {
1639 let source = fixture("nestjs_guards_pipes.ts");
1641 let extractor = TypeScriptExtractor::new();
1642
1643 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1645
1646 let guards: Vec<&DecoratorInfo> = decorators
1648 .iter()
1649 .filter(|d| d.name == "UseGuards")
1650 .collect();
1651 assert!(!guards.is_empty(), "expected UseGuards decorators");
1652 let auth_guard = guards
1653 .iter()
1654 .find(|d| d.arguments.contains(&"AuthGuard".to_string()));
1655 assert!(auth_guard.is_some(), "expected AuthGuard argument");
1656 }
1657
1658 #[test]
1660 fn multiple_decorators_on_method() {
1661 let source = fixture("nestjs_controller.ts");
1663 let extractor = TypeScriptExtractor::new();
1664
1665 let decorators = extractor.extract_decorators(&source, "nestjs_controller.ts");
1667
1668 let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1670 assert!(
1671 names.contains(&"UseGuards"),
1672 "expected UseGuards in {names:?}"
1673 );
1674 assert!(
1675 !names.contains(&"Delete"),
1676 "Delete should not be in decorators"
1677 );
1678 }
1679
1680 #[test]
1682 fn class_validator_on_dto() {
1683 let source = fixture("nestjs_dto_validation.ts");
1685 let extractor = TypeScriptExtractor::new();
1686
1687 let decorators = extractor.extract_decorators(&source, "nestjs_dto_validation.ts");
1689
1690 let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1692 assert!(names.contains(&"IsEmail"), "expected IsEmail in {names:?}");
1693 assert!(
1694 names.contains(&"IsNotEmpty"),
1695 "expected IsNotEmpty in {names:?}"
1696 );
1697 }
1698
1699 #[test]
1701 fn use_pipes_decorator() {
1702 let source = fixture("nestjs_guards_pipes.ts");
1704 let extractor = TypeScriptExtractor::new();
1705
1706 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1708
1709 let pipes: Vec<&DecoratorInfo> =
1711 decorators.iter().filter(|d| d.name == "UsePipes").collect();
1712 assert!(!pipes.is_empty(), "expected UsePipes decorators");
1713 assert!(pipes[0].arguments.contains(&"ValidationPipe".to_string()));
1714 }
1715
1716 #[test]
1718 fn empty_source_returns_empty_routes_and_decorators() {
1719 let extractor = TypeScriptExtractor::new();
1721
1722 let routes = extractor.extract_routes("", "empty.ts");
1724 let decorators = extractor.extract_decorators("", "empty.ts");
1725
1726 assert!(routes.is_empty());
1728 assert!(decorators.is_empty());
1729 }
1730
1731 #[test]
1733 fn non_nestjs_class_ignored() {
1734 let source = fixture("class_methods.ts");
1736 let extractor = TypeScriptExtractor::new();
1737
1738 let routes = extractor.extract_routes(&source, "class_methods.ts");
1740
1741 assert!(routes.is_empty(), "expected no routes from plain class");
1743 }
1744
1745 #[test]
1747 fn route_handler_and_class_name() {
1748 let source = fixture("nestjs_controller.ts");
1750 let extractor = TypeScriptExtractor::new();
1751
1752 let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1754
1755 let handlers: Vec<&str> = routes.iter().map(|r| r.handler_name.as_str()).collect();
1757 assert!(handlers.contains(&"findAll"));
1758 assert!(handlers.contains(&"create"));
1759 assert!(handlers.contains(&"remove"));
1760 for route in &routes {
1761 assert_eq!(route.class_name, "UsersController");
1762 }
1763 }
1764
1765 #[test]
1767 fn class_level_use_guards() {
1768 let source = fixture("nestjs_guards_pipes.ts");
1770 let extractor = TypeScriptExtractor::new();
1771
1772 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1774
1775 let class_guards: Vec<&DecoratorInfo> = decorators
1777 .iter()
1778 .filter(|d| {
1779 d.name == "UseGuards"
1780 && d.target_name == "ProtectedController"
1781 && d.class_name == "ProtectedController"
1782 })
1783 .collect();
1784 assert!(
1785 !class_guards.is_empty(),
1786 "expected class-level UseGuards, got {decorators:?}"
1787 );
1788 assert!(class_guards[0]
1789 .arguments
1790 .contains(&"JwtAuthGuard".to_string()));
1791 }
1792
1793 #[test]
1795 fn dynamic_controller_path() {
1796 let source = fixture("nestjs_dynamic_routes.ts");
1798 let extractor = TypeScriptExtractor::new();
1799
1800 let routes = extractor.extract_routes(&source, "nestjs_dynamic_routes.ts");
1802
1803 assert_eq!(routes.len(), 1);
1805 assert!(
1806 routes[0].path.contains("<dynamic>"),
1807 "expected <dynamic> in path, got {:?}",
1808 routes[0].path
1809 );
1810 }
1811
1812 #[test]
1814 fn abstract_class_methods_extracted() {
1815 let source = fixture("abstract_class.ts");
1817 let extractor = TypeScriptExtractor::new();
1818
1819 let funcs = extractor.extract_production_functions(&source, "abstract_class.ts");
1821
1822 let validate = funcs.iter().find(|f| f.name == "validate");
1824 assert!(validate.is_some(), "expected validate to be extracted");
1825 let validate = validate.unwrap();
1826 assert_eq!(validate.class_name.as_deref(), Some("BaseService"));
1827 assert!(validate.is_exported);
1828
1829 let process = funcs.iter().find(|f| f.name == "process");
1830 assert!(process.is_some(), "expected process to be extracted");
1831 let process = process.unwrap();
1832 assert_eq!(process.class_name.as_deref(), Some("InternalBase"));
1833 assert!(!process.is_exported);
1834 }
1835
1836 #[test]
1837 fn basic_spec_mapping() {
1838 let extractor = TypeScriptExtractor::new();
1840 let production_files = vec!["src/users.service.ts".to_string()];
1841 let test_files = vec!["src/users.service.spec.ts".to_string()];
1842
1843 let mappings = extractor.map_test_files(&production_files, &test_files);
1845
1846 assert_eq!(
1848 mappings,
1849 vec![FileMapping {
1850 production_file: "src/users.service.ts".to_string(),
1851 test_files: vec!["src/users.service.spec.ts".to_string()],
1852 strategy: MappingStrategy::FileNameConvention,
1853 }]
1854 );
1855 }
1856
1857 #[test]
1858 fn test_suffix_mapping() {
1859 let extractor = TypeScriptExtractor::new();
1861 let production_files = vec!["src/utils.ts".to_string()];
1862 let test_files = vec!["src/utils.test.ts".to_string()];
1863
1864 let mappings = extractor.map_test_files(&production_files, &test_files);
1866
1867 assert_eq!(
1869 mappings[0].test_files,
1870 vec!["src/utils.test.ts".to_string()]
1871 );
1872 }
1873
1874 #[test]
1875 fn multiple_test_files() {
1876 let extractor = TypeScriptExtractor::new();
1878 let production_files = vec!["src/app.ts".to_string()];
1879 let test_files = vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()];
1880
1881 let mappings = extractor.map_test_files(&production_files, &test_files);
1883
1884 assert_eq!(
1886 mappings[0].test_files,
1887 vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()]
1888 );
1889 }
1890
1891 #[test]
1892 fn nestjs_controller() {
1893 let extractor = TypeScriptExtractor::new();
1895 let production_files = vec!["src/users/users.controller.ts".to_string()];
1896 let test_files = vec!["src/users/users.controller.spec.ts".to_string()];
1897
1898 let mappings = extractor.map_test_files(&production_files, &test_files);
1900
1901 assert_eq!(
1903 mappings[0].test_files,
1904 vec!["src/users/users.controller.spec.ts".to_string()]
1905 );
1906 }
1907
1908 #[test]
1909 fn no_matching_test() {
1910 let extractor = TypeScriptExtractor::new();
1912 let production_files = vec!["src/orphan.ts".to_string()];
1913 let test_files = vec!["src/other.spec.ts".to_string()];
1914
1915 let mappings = extractor.map_test_files(&production_files, &test_files);
1917
1918 assert_eq!(mappings[0].test_files, Vec::<String>::new());
1920 }
1921
1922 #[test]
1923 fn different_directory_no_match() {
1924 let extractor = TypeScriptExtractor::new();
1926 let production_files = vec!["src/users.ts".to_string()];
1927 let test_files = vec!["test/users.spec.ts".to_string()];
1928
1929 let mappings = extractor.map_test_files(&production_files, &test_files);
1931
1932 assert_eq!(mappings[0].test_files, Vec::<String>::new());
1934 }
1935
1936 #[test]
1937 fn empty_input() {
1938 let extractor = TypeScriptExtractor::new();
1940
1941 let mappings = extractor.map_test_files(&[], &[]);
1943
1944 assert!(mappings.is_empty());
1946 }
1947
1948 #[test]
1949 fn tsx_files() {
1950 let extractor = TypeScriptExtractor::new();
1952 let production_files = vec!["src/App.tsx".to_string()];
1953 let test_files = vec!["src/App.test.tsx".to_string()];
1954
1955 let mappings = extractor.map_test_files(&production_files, &test_files);
1957
1958 assert_eq!(mappings[0].test_files, vec!["src/App.test.tsx".to_string()]);
1960 }
1961
1962 #[test]
1963 fn unmatched_test_ignored() {
1964 let extractor = TypeScriptExtractor::new();
1966 let production_files = vec!["src/a.ts".to_string()];
1967 let test_files = vec!["src/a.spec.ts".to_string(), "src/b.spec.ts".to_string()];
1968
1969 let mappings = extractor.map_test_files(&production_files, &test_files);
1971
1972 assert_eq!(mappings.len(), 1);
1974 assert_eq!(mappings[0].test_files, vec!["src/a.spec.ts".to_string()]);
1975 }
1976
1977 #[test]
1978 fn stem_extraction() {
1979 assert_eq!(
1983 production_stem("src/users.service.ts"),
1984 Some("users.service")
1985 );
1986 assert_eq!(production_stem("src/App.tsx"), Some("App"));
1987 assert_eq!(
1988 test_stem("src/users.service.spec.ts"),
1989 Some("users.service")
1990 );
1991 assert_eq!(test_stem("src/utils.test.ts"), Some("utils"));
1992 assert_eq!(test_stem("src/App.test.tsx"), Some("App"));
1993 assert_eq!(test_stem("src/invalid.ts"), None);
1994 }
1995
1996 #[test]
2000 fn im1_named_import_symbol_and_specifier() {
2001 let source = fixture("import_named.ts");
2003 let extractor = TypeScriptExtractor::new();
2004
2005 let imports = extractor.extract_imports(&source, "import_named.ts");
2007
2008 let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2010 assert!(
2011 found.is_some(),
2012 "expected UsersController in imports: {imports:?}"
2013 );
2014 assert_eq!(
2015 found.unwrap().module_specifier,
2016 "./users.controller",
2017 "wrong specifier"
2018 );
2019 }
2020
2021 #[test]
2023 fn im2_multiple_named_imports() {
2024 let source = fixture("import_mixed.ts");
2026 let extractor = TypeScriptExtractor::new();
2027
2028 let imports = extractor.extract_imports(&source, "import_mixed.ts");
2030
2031 let from_module: Vec<&ImportMapping> = imports
2033 .iter()
2034 .filter(|i| i.module_specifier == "./module")
2035 .collect();
2036 let symbols: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
2037 assert!(symbols.contains(&"A"), "expected A in symbols: {symbols:?}");
2038 assert!(symbols.contains(&"B"), "expected B in symbols: {symbols:?}");
2039 assert!(
2041 from_module.len() >= 2,
2042 "expected at least 2 imports from ./module, got {from_module:?}"
2043 );
2044 }
2045
2046 #[test]
2048 fn im3_alias_import_original_name() {
2049 let source = fixture("import_mixed.ts");
2051 let extractor = TypeScriptExtractor::new();
2052
2053 let imports = extractor.extract_imports(&source, "import_mixed.ts");
2055
2056 let a_count = imports.iter().filter(|i| i.symbol_name == "A").count();
2059 assert!(
2060 a_count >= 1,
2061 "expected at least one import with symbol_name 'A', got: {imports:?}"
2062 );
2063 }
2064
2065 #[test]
2067 fn im4_default_import() {
2068 let source = fixture("import_default.ts");
2070 let extractor = TypeScriptExtractor::new();
2071
2072 let imports = extractor.extract_imports(&source, "import_default.ts");
2074
2075 assert_eq!(imports.len(), 1, "expected 1 import, got {imports:?}");
2077 assert_eq!(imports[0].symbol_name, "UsersController");
2078 assert_eq!(imports[0].module_specifier, "./users.controller");
2079 }
2080
2081 #[test]
2083 fn im5_npm_package_excluded() {
2084 let source = "import { Test } from '@nestjs/testing';";
2086 let extractor = TypeScriptExtractor::new();
2087
2088 let imports = extractor.extract_imports(source, "test.ts");
2090
2091 assert!(imports.is_empty(), "expected empty vec, got {imports:?}");
2093 }
2094
2095 #[test]
2097 fn im6_relative_parent_path() {
2098 let source = fixture("import_named.ts");
2100 let extractor = TypeScriptExtractor::new();
2101
2102 let imports = extractor.extract_imports(&source, "import_named.ts");
2104
2105 let found = imports
2107 .iter()
2108 .find(|i| i.module_specifier == "../services/s.service");
2109 assert!(
2110 found.is_some(),
2111 "expected ../services/s.service in imports: {imports:?}"
2112 );
2113 assert_eq!(found.unwrap().symbol_name, "S");
2114 }
2115
2116 #[test]
2118 fn im7_empty_source_returns_empty() {
2119 let extractor = TypeScriptExtractor::new();
2121
2122 let imports = extractor.extract_imports("", "empty.ts");
2124
2125 assert!(imports.is_empty());
2127 }
2128
2129 #[test]
2131 fn im8_namespace_import() {
2132 let source = fixture("import_namespace.ts");
2134 let extractor = TypeScriptExtractor::new();
2135
2136 let imports = extractor.extract_imports(&source, "import_namespace.ts");
2138
2139 let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2141 assert!(
2142 found.is_some(),
2143 "expected UsersController in imports: {imports:?}"
2144 );
2145 assert_eq!(found.unwrap().module_specifier, "./users.controller");
2146
2147 let helpers = imports.iter().find(|i| i.symbol_name == "helpers");
2149 assert!(
2150 helpers.is_some(),
2151 "expected helpers in imports: {imports:?}"
2152 );
2153 assert_eq!(helpers.unwrap().module_specifier, "../utils/helpers");
2154
2155 let express = imports.iter().find(|i| i.symbol_name == "express");
2157 assert!(
2158 express.is_none(),
2159 "npm package should be excluded: {imports:?}"
2160 );
2161 }
2162
2163 #[test]
2165 fn im9_type_only_import_excluded() {
2166 let source = fixture("import_type_only.ts");
2168 let extractor = TypeScriptExtractor::new();
2169
2170 let imports = extractor.extract_imports(&source, "import_type_only.ts");
2172
2173 let user_service = imports.iter().find(|i| i.symbol_name == "UserService");
2175 assert!(
2176 user_service.is_none(),
2177 "type-only import should be excluded: {imports:?}"
2178 );
2179
2180 let create_dto = imports.iter().find(|i| i.symbol_name == "CreateUserDto");
2182 assert!(
2183 create_dto.is_none(),
2184 "inline type modifier import should be excluded: {imports:?}"
2185 );
2186
2187 let controller = imports.iter().find(|i| i.symbol_name == "UsersController");
2189 assert!(
2190 controller.is_some(),
2191 "normal import should remain: {imports:?}"
2192 );
2193 assert_eq!(controller.unwrap().module_specifier, "./users.controller");
2194 }
2195
2196 #[test]
2200 fn rp1_resolve_ts_without_extension() {
2201 use std::io::Write as IoWrite;
2202 use tempfile::TempDir;
2203
2204 let dir = TempDir::new().unwrap();
2206 let src_dir = dir.path().join("src");
2207 std::fs::create_dir_all(&src_dir).unwrap();
2208 let target = src_dir.join("users.controller.ts");
2209 std::fs::File::create(&target).unwrap();
2210
2211 let from_file = src_dir.join("users.controller.spec.ts");
2212
2213 let result = resolve_import_path("./users.controller", &from_file, dir.path());
2215
2216 assert!(
2218 result.is_some(),
2219 "expected Some for existing .ts file, got None"
2220 );
2221 let resolved = result.unwrap();
2222 assert!(
2223 resolved.ends_with("users.controller.ts"),
2224 "expected path ending with users.controller.ts, got {resolved}"
2225 );
2226 }
2227
2228 #[test]
2230 fn rp2_resolve_ts_with_extension() {
2231 use tempfile::TempDir;
2232
2233 let dir = TempDir::new().unwrap();
2235 let src_dir = dir.path().join("src");
2236 std::fs::create_dir_all(&src_dir).unwrap();
2237 let target = src_dir.join("users.controller.ts");
2238 std::fs::File::create(&target).unwrap();
2239
2240 let from_file = src_dir.join("users.controller.spec.ts");
2241
2242 let result = resolve_import_path("./users.controller.ts", &from_file, dir.path());
2244
2245 assert!(
2247 result.is_some(),
2248 "expected Some for existing file with explicit .ts extension"
2249 );
2250 }
2251
2252 #[test]
2254 fn rp3_nonexistent_file_returns_none() {
2255 use tempfile::TempDir;
2256
2257 let dir = TempDir::new().unwrap();
2259 let src_dir = dir.path().join("src");
2260 std::fs::create_dir_all(&src_dir).unwrap();
2261 let from_file = src_dir.join("some.spec.ts");
2262
2263 let result = resolve_import_path("./nonexistent", &from_file, dir.path());
2265
2266 assert!(result.is_none(), "expected None for nonexistent file");
2268 }
2269
2270 #[test]
2272 fn rp4_outside_scan_root_returns_none() {
2273 use tempfile::TempDir;
2274
2275 let dir = TempDir::new().unwrap();
2277 let src_dir = dir.path().join("src");
2278 std::fs::create_dir_all(&src_dir).unwrap();
2279 let from_file = src_dir.join("some.spec.ts");
2280
2281 let result = resolve_import_path("../../outside", &from_file, dir.path());
2283
2284 assert!(result.is_none(), "expected None for path outside scan_root");
2286 }
2287
2288 #[test]
2290 fn rp5_resolve_tsx_without_extension() {
2291 use tempfile::TempDir;
2292
2293 let dir = TempDir::new().unwrap();
2295 let src_dir = dir.path().join("src");
2296 std::fs::create_dir_all(&src_dir).unwrap();
2297 let target = src_dir.join("App.tsx");
2298 std::fs::File::create(&target).unwrap();
2299
2300 let from_file = src_dir.join("App.test.tsx");
2301
2302 let result = resolve_import_path("./App", &from_file, dir.path());
2304
2305 assert!(
2307 result.is_some(),
2308 "expected Some for existing .tsx file, got None"
2309 );
2310 let resolved = result.unwrap();
2311 assert!(
2312 resolved.ends_with("App.tsx"),
2313 "expected path ending with App.tsx, got {resolved}"
2314 );
2315 }
2316
2317 #[test]
2321 fn mt1_layer1_and_layer2_both_matched() {
2322 use tempfile::TempDir;
2323
2324 let dir = TempDir::new().unwrap();
2329 let src_dir = dir.path().join("src");
2330 let test_dir = dir.path().join("test");
2331 std::fs::create_dir_all(&src_dir).unwrap();
2332 std::fs::create_dir_all(&test_dir).unwrap();
2333
2334 let prod_path = src_dir.join("users.controller.ts");
2335 std::fs::File::create(&prod_path).unwrap();
2336
2337 let layer1_test = src_dir.join("users.controller.spec.ts");
2338 let layer1_source = r#"// Layer 1 spec
2339describe('UsersController', () => {});
2340"#;
2341
2342 let layer2_test = test_dir.join("users.controller.spec.ts");
2343 let layer2_source = format!(
2344 "import {{ UsersController }} from '../src/users.controller';\ndescribe('cross', () => {{}});\n"
2345 );
2346
2347 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2348 let mut test_sources = HashMap::new();
2349 test_sources.insert(
2350 layer1_test.to_string_lossy().into_owned(),
2351 layer1_source.to_string(),
2352 );
2353 test_sources.insert(
2354 layer2_test.to_string_lossy().into_owned(),
2355 layer2_source.to_string(),
2356 );
2357
2358 let extractor = TypeScriptExtractor::new();
2359
2360 let mappings =
2362 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
2363
2364 assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2366 let mapping = &mappings[0];
2367 assert!(
2368 mapping
2369 .test_files
2370 .contains(&layer1_test.to_string_lossy().into_owned()),
2371 "expected Layer 1 test in mapping, got {:?}",
2372 mapping.test_files
2373 );
2374 assert!(
2375 mapping
2376 .test_files
2377 .contains(&layer2_test.to_string_lossy().into_owned()),
2378 "expected Layer 2 test in mapping, got {:?}",
2379 mapping.test_files
2380 );
2381 }
2382
2383 #[test]
2385 fn mt2_cross_directory_import_tracing() {
2386 use tempfile::TempDir;
2387
2388 let dir = TempDir::new().unwrap();
2393 let src_dir = dir.path().join("src").join("services");
2394 let test_dir = dir.path().join("test");
2395 std::fs::create_dir_all(&src_dir).unwrap();
2396 std::fs::create_dir_all(&test_dir).unwrap();
2397
2398 let prod_path = src_dir.join("user.service.ts");
2399 std::fs::File::create(&prod_path).unwrap();
2400
2401 let test_path = test_dir.join("user.service.spec.ts");
2402 let test_source = format!(
2403 "import {{ UserService }} from '../src/services/user.service';\ndescribe('cross', () => {{}});\n"
2404 );
2405
2406 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2407 let mut test_sources = HashMap::new();
2408 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2409
2410 let extractor = TypeScriptExtractor::new();
2411
2412 let mappings =
2414 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
2415
2416 assert_eq!(mappings.len(), 1);
2418 let mapping = &mappings[0];
2419 assert!(
2420 mapping
2421 .test_files
2422 .contains(&test_path.to_string_lossy().into_owned()),
2423 "expected test in mapping via ImportTracing, got {:?}",
2424 mapping.test_files
2425 );
2426 assert_eq!(
2427 mapping.strategy,
2428 MappingStrategy::ImportTracing,
2429 "expected ImportTracing strategy"
2430 );
2431 }
2432
2433 #[test]
2435 fn mt3_npm_only_import_not_matched() {
2436 use tempfile::TempDir;
2437
2438 let dir = TempDir::new().unwrap();
2442 let src_dir = dir.path().join("src");
2443 let test_dir = dir.path().join("test");
2444 std::fs::create_dir_all(&src_dir).unwrap();
2445 std::fs::create_dir_all(&test_dir).unwrap();
2446
2447 let prod_path = src_dir.join("users.controller.ts");
2448 std::fs::File::create(&prod_path).unwrap();
2449
2450 let test_path = test_dir.join("something.spec.ts");
2451 let test_source =
2452 "import { Test } from '@nestjs/testing';\ndescribe('npm', () => {});\n".to_string();
2453
2454 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2455 let mut test_sources = HashMap::new();
2456 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2457
2458 let extractor = TypeScriptExtractor::new();
2459
2460 let mappings =
2462 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
2463
2464 assert_eq!(mappings.len(), 1);
2466 assert!(
2467 mappings[0].test_files.is_empty(),
2468 "expected no test files for npm-only import, got {:?}",
2469 mappings[0].test_files
2470 );
2471 }
2472
2473 #[test]
2475 fn mt4_one_test_imports_multiple_productions() {
2476 use tempfile::TempDir;
2477
2478 let dir = TempDir::new().unwrap();
2483 let src_dir = dir.path().join("src");
2484 let test_dir = dir.path().join("test");
2485 std::fs::create_dir_all(&src_dir).unwrap();
2486 std::fs::create_dir_all(&test_dir).unwrap();
2487
2488 let prod_a = src_dir.join("a.service.ts");
2489 let prod_b = src_dir.join("b.service.ts");
2490 std::fs::File::create(&prod_a).unwrap();
2491 std::fs::File::create(&prod_b).unwrap();
2492
2493 let test_path = test_dir.join("ab.spec.ts");
2494 let test_source = format!(
2495 "import {{ A }} from '../src/a.service';\nimport {{ B }} from '../src/b.service';\ndescribe('ab', () => {{}});\n"
2496 );
2497
2498 let production_files = vec![
2499 prod_a.to_string_lossy().into_owned(),
2500 prod_b.to_string_lossy().into_owned(),
2501 ];
2502 let mut test_sources = HashMap::new();
2503 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2504
2505 let extractor = TypeScriptExtractor::new();
2506
2507 let mappings =
2509 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
2510
2511 assert_eq!(mappings.len(), 2, "expected 2 FileMappings (A and B)");
2513 for mapping in &mappings {
2514 assert!(
2515 mapping
2516 .test_files
2517 .contains(&test_path.to_string_lossy().into_owned()),
2518 "expected ab.spec.ts mapped to {}, got {:?}",
2519 mapping.production_file,
2520 mapping.test_files
2521 );
2522 }
2523 }
2524
2525 #[test]
2527 fn is_non_sut_helper_constants_ts() {
2528 assert!(is_non_sut_helper("src/constants.ts", false));
2529 }
2530
2531 #[test]
2533 fn is_non_sut_helper_index_ts() {
2534 assert!(is_non_sut_helper("src/index.ts", false));
2535 }
2536
2537 #[test]
2539 fn is_non_sut_helper_extension_variants() {
2540 assert!(is_non_sut_helper("src/constants.js", false));
2541 assert!(is_non_sut_helper("src/constants.tsx", false));
2542 assert!(is_non_sut_helper("src/constants.jsx", false));
2543 assert!(is_non_sut_helper("src/index.js", false));
2544 assert!(is_non_sut_helper("src/index.tsx", false));
2545 assert!(is_non_sut_helper("src/index.jsx", false));
2546 }
2547
2548 #[test]
2550 fn is_non_sut_helper_rejects_non_helpers() {
2551 assert!(!is_non_sut_helper("src/my-constants.ts", false));
2552 assert!(!is_non_sut_helper("src/service.ts", false));
2553 assert!(!is_non_sut_helper("src/app.constants.ts", false));
2554 assert!(!is_non_sut_helper("src/constants-v2.ts", false));
2555 }
2556
2557 #[test]
2559 fn is_non_sut_helper_rejects_directory_name() {
2560 assert!(!is_non_sut_helper("constants/app.ts", false));
2561 assert!(!is_non_sut_helper("index/service.ts", false));
2562 }
2563
2564 #[test]
2566 fn is_non_sut_helper_enum_ts() {
2567 let path = "src/enums/request-method.enum.ts";
2569 assert!(is_non_sut_helper(path, false));
2572 }
2573
2574 #[test]
2576 fn is_non_sut_helper_interface_ts() {
2577 let path = "src/interfaces/middleware-configuration.interface.ts";
2579 assert!(is_non_sut_helper(path, false));
2582 }
2583
2584 #[test]
2586 fn is_non_sut_helper_exception_ts() {
2587 let path = "src/errors/unknown-module.exception.ts";
2589 assert!(is_non_sut_helper(path, false));
2592 }
2593
2594 #[test]
2596 fn is_non_sut_helper_test_path() {
2597 let path = "packages/core/test/utils/string.cleaner.ts";
2599 assert!(is_non_sut_helper(path, false));
2602 assert!(is_non_sut_helper(
2604 "packages/core/__tests__/utils/helper.ts",
2605 false
2606 ));
2607 assert!(!is_non_sut_helper(
2609 "/home/user/projects/contest/src/service.ts",
2610 false
2611 ));
2612 assert!(!is_non_sut_helper("src/latest/foo.ts", false));
2613 }
2614
2615 #[test]
2617 fn is_non_sut_helper_rejects_plain_filename() {
2618 assert!(!is_non_sut_helper("src/enum.ts", false));
2623 assert!(!is_non_sut_helper("src/interface.ts", false));
2624 assert!(!is_non_sut_helper("src/exception.ts", false));
2625 }
2626
2627 #[test]
2629 fn is_non_sut_helper_enum_interface_extension_variants() {
2630 assert!(is_non_sut_helper("src/foo.enum.js", false));
2634 assert!(is_non_sut_helper("src/bar.interface.tsx", false));
2635 }
2636
2637 #[test]
2641 fn is_type_definition_file_enum() {
2642 assert!(is_type_definition_file("src/foo.enum.ts"));
2643 }
2644
2645 #[test]
2647 fn is_type_definition_file_interface() {
2648 assert!(is_type_definition_file("src/bar.interface.ts"));
2649 }
2650
2651 #[test]
2653 fn is_type_definition_file_exception() {
2654 assert!(is_type_definition_file("src/baz.exception.ts"));
2655 }
2656
2657 #[test]
2659 fn is_type_definition_file_service() {
2660 assert!(!is_type_definition_file("src/service.ts"));
2661 }
2662
2663 #[test]
2665 fn is_type_definition_file_constants() {
2666 assert!(!is_type_definition_file("src/constants.ts"));
2668 }
2669
2670 #[test]
2674 fn is_non_sut_helper_production_enum_bypassed() {
2675 assert!(!is_non_sut_helper("src/foo.enum.ts", true));
2679 }
2680
2681 #[test]
2683 fn is_non_sut_helper_unknown_enum_filtered() {
2684 assert!(is_non_sut_helper("src/foo.enum.ts", false));
2688 }
2689
2690 #[test]
2692 fn is_non_sut_helper_constants_always_filtered() {
2693 assert!(is_non_sut_helper("src/constants.ts", true));
2697 }
2698
2699 #[test]
2703 fn barrel_01_resolve_directory_to_index_ts() {
2704 use tempfile::TempDir;
2705
2706 let dir = TempDir::new().unwrap();
2708 let decorators_dir = dir.path().join("decorators");
2709 std::fs::create_dir_all(&decorators_dir).unwrap();
2710 std::fs::File::create(decorators_dir.join("index.ts")).unwrap();
2711
2712 let src_dir = dir.path().join("src");
2714 std::fs::create_dir_all(&src_dir).unwrap();
2715 let from_file = src_dir.join("some.spec.ts");
2716
2717 let result = resolve_import_path("../decorators", &from_file, dir.path());
2719
2720 assert!(
2722 result.is_some(),
2723 "expected Some for directory with index.ts, got None"
2724 );
2725 let resolved = result.unwrap();
2726 assert!(
2727 resolved.ends_with("decorators/index.ts"),
2728 "expected path ending with decorators/index.ts, got {resolved}"
2729 );
2730 }
2731
2732 #[test]
2734 fn barrel_02_re_export_named_capture() {
2735 let source = "export { Foo } from './foo';";
2737 let extractor = TypeScriptExtractor::new();
2738
2739 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2741
2742 assert_eq!(
2744 re_exports.len(),
2745 1,
2746 "expected 1 re-export, got {re_exports:?}"
2747 );
2748 let re = &re_exports[0];
2749 assert_eq!(re.symbols, vec!["Foo".to_string()]);
2750 assert_eq!(re.from_specifier, "./foo");
2751 assert!(!re.wildcard);
2752 }
2753
2754 #[test]
2756 fn barrel_03_re_export_wildcard_capture() {
2757 let source = "export * from './foo';";
2759 let extractor = TypeScriptExtractor::new();
2760
2761 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2763
2764 assert_eq!(
2766 re_exports.len(),
2767 1,
2768 "expected 1 re-export, got {re_exports:?}"
2769 );
2770 let re = &re_exports[0];
2771 assert!(re.wildcard, "expected wildcard=true");
2772 assert_eq!(re.from_specifier, "./foo");
2773 }
2774
2775 #[test]
2777 fn barrel_04_resolve_barrel_exports_one_hop() {
2778 use tempfile::TempDir;
2779
2780 let dir = TempDir::new().unwrap();
2784 let index_path = dir.path().join("index.ts");
2785 std::fs::write(&index_path, "export { Foo } from './foo';").unwrap();
2786 let foo_path = dir.path().join("foo.ts");
2787 std::fs::File::create(&foo_path).unwrap();
2788
2789 let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2791
2792 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2794 assert!(
2795 result[0].ends_with("foo.ts"),
2796 "expected foo.ts, got {:?}",
2797 result[0]
2798 );
2799 }
2800
2801 #[test]
2803 fn barrel_05_resolve_barrel_exports_two_hops() {
2804 use tempfile::TempDir;
2805
2806 let dir = TempDir::new().unwrap();
2811 let index_path = dir.path().join("index.ts");
2812 std::fs::write(&index_path, "export * from './core';").unwrap();
2813
2814 let core_dir = dir.path().join("core");
2815 std::fs::create_dir_all(&core_dir).unwrap();
2816 std::fs::write(core_dir.join("index.ts"), "export { Foo } from './foo';").unwrap();
2817 let foo_path = core_dir.join("foo.ts");
2818 std::fs::File::create(&foo_path).unwrap();
2819
2820 let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2822
2823 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2825 assert!(
2826 result[0].ends_with("foo.ts"),
2827 "expected foo.ts, got {:?}",
2828 result[0]
2829 );
2830 }
2831
2832 #[test]
2834 fn barrel_06_circular_barrel_no_infinite_loop() {
2835 use tempfile::TempDir;
2836
2837 let dir = TempDir::new().unwrap();
2841 let a_dir = dir.path().join("a");
2842 let b_dir = dir.path().join("b");
2843 std::fs::create_dir_all(&a_dir).unwrap();
2844 std::fs::create_dir_all(&b_dir).unwrap();
2845 std::fs::write(a_dir.join("index.ts"), "export * from '../b';").unwrap();
2846 std::fs::write(b_dir.join("index.ts"), "export * from '../a';").unwrap();
2847
2848 let a_index = a_dir.join("index.ts");
2849
2850 let result = resolve_barrel_exports(&a_index, &["Foo".to_string()], dir.path());
2852
2853 assert!(
2855 result.is_empty(),
2856 "expected empty result for circular barrel, got {result:?}"
2857 );
2858 }
2859
2860 #[test]
2862 fn barrel_07_layer2_barrel_import_matches_production() {
2863 use tempfile::TempDir;
2864
2865 let dir = TempDir::new().unwrap();
2871 let src_dir = dir.path().join("src");
2872 let decorators_dir = src_dir.join("decorators");
2873 let test_dir = dir.path().join("test");
2874 std::fs::create_dir_all(&decorators_dir).unwrap();
2875 std::fs::create_dir_all(&test_dir).unwrap();
2876
2877 let prod_path = src_dir.join("foo.service.ts");
2879 std::fs::File::create(&prod_path).unwrap();
2880
2881 std::fs::write(
2883 decorators_dir.join("index.ts"),
2884 "export { Foo } from '../foo.service';",
2885 )
2886 .unwrap();
2887
2888 let test_path = test_dir.join("foo.spec.ts");
2890 std::fs::write(
2891 &test_path,
2892 "import { Foo } from '../src/decorators';\ndescribe('foo', () => {});",
2893 )
2894 .unwrap();
2895
2896 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2897 let mut test_sources = HashMap::new();
2898 test_sources.insert(
2899 test_path.to_string_lossy().into_owned(),
2900 std::fs::read_to_string(&test_path).unwrap(),
2901 );
2902
2903 let extractor = TypeScriptExtractor::new();
2904
2905 let mappings =
2907 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
2908
2909 assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2911 assert!(
2912 mappings[0]
2913 .test_files
2914 .contains(&test_path.to_string_lossy().into_owned()),
2915 "expected foo.spec.ts mapped via barrel, got {:?}",
2916 mappings[0].test_files
2917 );
2918 }
2919
2920 #[test]
2922 fn barrel_08_non_sut_filter_applied_after_barrel_resolution() {
2923 use tempfile::TempDir;
2924
2925 let dir = TempDir::new().unwrap();
2930 let src_dir = dir.path().join("src");
2931 let test_dir = dir.path().join("test");
2932 std::fs::create_dir_all(&src_dir).unwrap();
2933 std::fs::create_dir_all(&test_dir).unwrap();
2934
2935 let prod_path = src_dir.join("user.service.ts");
2937 std::fs::File::create(&prod_path).unwrap();
2938
2939 std::fs::write(
2941 src_dir.join("index.ts"),
2942 "export { SOME_CONST } from './constants';",
2943 )
2944 .unwrap();
2945 std::fs::File::create(src_dir.join("constants.ts")).unwrap();
2947
2948 let test_path = test_dir.join("barrel_const.spec.ts");
2950 std::fs::write(
2951 &test_path,
2952 "import { SOME_CONST } from '../src';\ndescribe('const', () => {});",
2953 )
2954 .unwrap();
2955
2956 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2957 let mut test_sources = HashMap::new();
2958 test_sources.insert(
2959 test_path.to_string_lossy().into_owned(),
2960 std::fs::read_to_string(&test_path).unwrap(),
2961 );
2962
2963 let extractor = TypeScriptExtractor::new();
2964
2965 let mappings =
2967 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
2968
2969 assert_eq!(
2971 mappings.len(),
2972 1,
2973 "expected 1 FileMapping for user.service.ts"
2974 );
2975 assert!(
2976 mappings[0].test_files.is_empty(),
2977 "constants.ts should be filtered out, but got {:?}",
2978 mappings[0].test_files
2979 );
2980 }
2981
2982 #[test]
2984 fn barrel_09_extract_imports_retains_symbols() {
2985 let source = "import { Foo, Bar } from './module';";
2987 let extractor = TypeScriptExtractor::new();
2988
2989 let imports = extractor.extract_imports(source, "test.ts");
2991
2992 let from_module: Vec<&ImportMapping> = imports
2996 .iter()
2997 .filter(|i| i.module_specifier == "./module")
2998 .collect();
2999 let names: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
3000 assert!(names.contains(&"Foo"), "expected Foo in symbols: {names:?}");
3001 assert!(names.contains(&"Bar"), "expected Bar in symbols: {names:?}");
3002
3003 let grouped = imports
3007 .iter()
3008 .filter(|i| i.module_specifier == "./module")
3009 .fold(Vec::<String>::new(), |mut acc, i| {
3010 acc.push(i.symbol_name.clone());
3011 acc
3012 });
3013 assert_eq!(
3016 grouped.len(),
3017 2,
3018 "expected 2 symbols from ./module, got {grouped:?}"
3019 );
3020
3021 let first_import = imports
3024 .iter()
3025 .find(|i| i.module_specifier == "./module")
3026 .expect("expected at least one import from ./module");
3027 let symbols = &first_import.symbols;
3028 assert!(
3029 symbols.contains(&"Foo".to_string()),
3030 "symbols should contain Foo, got {symbols:?}"
3031 );
3032 assert!(
3033 symbols.contains(&"Bar".to_string()),
3034 "symbols should contain Bar, got {symbols:?}"
3035 );
3036 assert_eq!(
3037 symbols.len(),
3038 2,
3039 "expected exactly 2 symbols, got {symbols:?}"
3040 );
3041 }
3042
3043 #[test]
3047 fn barrel_10_wildcard_barrel_symbol_filter() {
3048 use tempfile::TempDir;
3049
3050 let dir = TempDir::new().unwrap();
3056 let core_dir = dir.path().join("core");
3057 std::fs::create_dir_all(&core_dir).unwrap();
3058
3059 std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3060 std::fs::write(
3061 core_dir.join("index.ts"),
3062 "export * from './foo';\nexport * from './bar';",
3063 )
3064 .unwrap();
3065 std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3066 std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3067
3068 let result = resolve_barrel_exports(
3070 &dir.path().join("index.ts"),
3071 &["Foo".to_string()],
3072 dir.path(),
3073 );
3074
3075 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
3077 assert!(
3078 result[0].ends_with("foo.ts"),
3079 "expected foo.ts, got {:?}",
3080 result[0]
3081 );
3082 }
3083
3084 #[test]
3086 fn barrel_11_wildcard_barrel_empty_symbols_match_all() {
3087 use tempfile::TempDir;
3088
3089 let dir = TempDir::new().unwrap();
3090 let core_dir = dir.path().join("core");
3091 std::fs::create_dir_all(&core_dir).unwrap();
3092
3093 std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3094 std::fs::write(
3095 core_dir.join("index.ts"),
3096 "export * from './foo';\nexport * from './bar';",
3097 )
3098 .unwrap();
3099 std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3100 std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3101
3102 let result = resolve_barrel_exports(&dir.path().join("index.ts"), &[], dir.path());
3104
3105 assert_eq!(result.len(), 2, "expected 2 resolved files, got {result:?}");
3107 }
3108
3109 #[test]
3115 fn boundary_b1_ns_reexport_not_captured() {
3116 let source = "export * as Validators from './validators';";
3118 let extractor = TypeScriptExtractor::new();
3119
3120 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
3122
3123 assert!(
3127 re_exports.is_empty(),
3128 "expected empty re_exports for namespace export, got {:?}",
3129 re_exports
3130 );
3131 }
3132
3133 #[test]
3135 fn boundary_b1_ns_reexport_mapping_miss() {
3136 use tempfile::TempDir;
3137
3138 let dir = TempDir::new().unwrap();
3144 let validators_dir = dir.path().join("validators");
3145 let test_dir = dir.path().join("test");
3146 std::fs::create_dir_all(&validators_dir).unwrap();
3147 std::fs::create_dir_all(&test_dir).unwrap();
3148
3149 let prod_path = validators_dir.join("foo.service.ts");
3151 std::fs::File::create(&prod_path).unwrap();
3152
3153 std::fs::write(
3155 dir.path().join("index.ts"),
3156 "export * as Validators from './validators';",
3157 )
3158 .unwrap();
3159
3160 std::fs::write(
3162 validators_dir.join("index.ts"),
3163 "export { FooService } from './foo.service';",
3164 )
3165 .unwrap();
3166
3167 let test_path = test_dir.join("foo.spec.ts");
3169 std::fs::write(
3170 &test_path,
3171 "import { Validators } from '../index';\ndescribe('FooService', () => {});",
3172 )
3173 .unwrap();
3174
3175 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3176 let mut test_sources = HashMap::new();
3177 test_sources.insert(
3178 test_path.to_string_lossy().into_owned(),
3179 std::fs::read_to_string(&test_path).unwrap(),
3180 );
3181
3182 let extractor = TypeScriptExtractor::new();
3183
3184 let mappings =
3186 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3187
3188 let all_test_files: Vec<&String> =
3191 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3192 assert!(
3193 all_test_files.is_empty(),
3194 "expected no test_files mapped (FN: namespace re-export not resolved), got {:?}",
3195 all_test_files
3196 );
3197 }
3198
3199 #[test]
3201 fn boundary_b2_non_relative_import_skipped() {
3202 let source = "import { Injectable } from '@nestjs/common';";
3204 let extractor = TypeScriptExtractor::new();
3205
3206 let imports = extractor.extract_imports(source, "app.service.ts");
3208
3209 assert!(
3211 imports.is_empty(),
3212 "expected empty imports for non-relative path, got {:?}",
3213 imports
3214 );
3215 }
3216
3217 #[test]
3219 fn boundary_b2_cross_pkg_barrel_unresolvable() {
3220 use tempfile::TempDir;
3221
3222 let dir = TempDir::new().unwrap();
3228 let core_src = dir.path().join("packages").join("core").join("src");
3229 let core_test = dir.path().join("packages").join("core").join("test");
3230 let common_src = dir.path().join("packages").join("common").join("src");
3231 std::fs::create_dir_all(&core_src).unwrap();
3232 std::fs::create_dir_all(&core_test).unwrap();
3233 std::fs::create_dir_all(&common_src).unwrap();
3234
3235 let prod_path = core_src.join("foo.service.ts");
3236 std::fs::File::create(&prod_path).unwrap();
3237
3238 let common_path = common_src.join("foo.ts");
3239 std::fs::File::create(&common_path).unwrap();
3240
3241 let test_path = core_test.join("foo.spec.ts");
3242 std::fs::write(
3243 &test_path,
3244 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3245 )
3246 .unwrap();
3247
3248 let scan_root = dir.path().join("packages").join("core");
3249 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3250 let mut test_sources = HashMap::new();
3251 test_sources.insert(
3252 test_path.to_string_lossy().into_owned(),
3253 std::fs::read_to_string(&test_path).unwrap(),
3254 );
3255
3256 let extractor = TypeScriptExtractor::new();
3257
3258 let mappings =
3260 extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
3261
3262 let all_test_files: Vec<&String> =
3265 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3266 assert!(
3267 all_test_files.is_empty(),
3268 "expected no test_files mapped (FN: cross-package import not resolved), got {:?}",
3269 all_test_files
3270 );
3271 }
3272
3273 #[test]
3275 fn boundary_b3_tsconfig_alias_not_resolved() {
3276 let source = "import { FooService } from '@app/services/foo.service';";
3278 let extractor = TypeScriptExtractor::new();
3279
3280 let imports = extractor.extract_imports(source, "app.module.ts");
3282
3283 assert!(
3287 imports.is_empty(),
3288 "expected empty imports for tsconfig alias, got {:?}",
3289 imports
3290 );
3291 }
3292
3293 #[test]
3295 fn boundary_b4_enum_primary_target_filtered() {
3296 use tempfile::TempDir;
3297
3298 let dir = TempDir::new().unwrap();
3302 let src_dir = dir.path().join("src");
3303 let test_dir = dir.path().join("test");
3304 std::fs::create_dir_all(&src_dir).unwrap();
3305 std::fs::create_dir_all(&test_dir).unwrap();
3306
3307 let prod_path = src_dir.join("route-paramtypes.enum.ts");
3308 std::fs::File::create(&prod_path).unwrap();
3309
3310 let test_path = test_dir.join("route.spec.ts");
3311 std::fs::write(
3312 &test_path,
3313 "import { RouteParamtypes } from '../src/route-paramtypes.enum';\ndescribe('Route', () => {});",
3314 )
3315 .unwrap();
3316
3317 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3318 let mut test_sources = HashMap::new();
3319 test_sources.insert(
3320 test_path.to_string_lossy().into_owned(),
3321 std::fs::read_to_string(&test_path).unwrap(),
3322 );
3323
3324 let extractor = TypeScriptExtractor::new();
3325
3326 let mappings =
3328 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3329
3330 let enum_mapping = mappings
3332 .iter()
3333 .find(|m| m.production_file.ends_with("route-paramtypes.enum.ts"));
3334 assert!(
3335 enum_mapping.is_some(),
3336 "expected mapping for route-paramtypes.enum.ts"
3337 );
3338 let enum_mapping = enum_mapping.unwrap();
3339 assert!(
3340 !enum_mapping.test_files.is_empty(),
3341 "expected test_files for route-paramtypes.enum.ts (production file), got empty"
3342 );
3343 }
3344
3345 #[test]
3347 fn boundary_b4_interface_primary_target_filtered() {
3348 use tempfile::TempDir;
3349
3350 let dir = TempDir::new().unwrap();
3354 let src_dir = dir.path().join("src");
3355 let test_dir = dir.path().join("test");
3356 std::fs::create_dir_all(&src_dir).unwrap();
3357 std::fs::create_dir_all(&test_dir).unwrap();
3358
3359 let prod_path = src_dir.join("user.interface.ts");
3360 std::fs::File::create(&prod_path).unwrap();
3361
3362 let test_path = test_dir.join("user.spec.ts");
3363 std::fs::write(
3364 &test_path,
3365 "import { User } from '../src/user.interface';\ndescribe('User', () => {});",
3366 )
3367 .unwrap();
3368
3369 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3370 let mut test_sources = HashMap::new();
3371 test_sources.insert(
3372 test_path.to_string_lossy().into_owned(),
3373 std::fs::read_to_string(&test_path).unwrap(),
3374 );
3375
3376 let extractor = TypeScriptExtractor::new();
3377
3378 let mappings =
3380 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3381
3382 let iface_mapping = mappings
3384 .iter()
3385 .find(|m| m.production_file.ends_with("user.interface.ts"));
3386 assert!(
3387 iface_mapping.is_some(),
3388 "expected mapping for user.interface.ts"
3389 );
3390 let iface_mapping = iface_mapping.unwrap();
3391 assert!(
3392 !iface_mapping.test_files.is_empty(),
3393 "expected test_files for user.interface.ts (production file), got empty"
3394 );
3395 }
3396
3397 #[test]
3399 fn boundary_b5_dynamic_import_not_extracted() {
3400 let source = fixture("import_dynamic.ts");
3402 let extractor = TypeScriptExtractor::new();
3403
3404 let imports = extractor.extract_imports(&source, "import_dynamic.ts");
3406
3407 assert!(
3409 imports.is_empty(),
3410 "expected empty imports for dynamic import(), got {:?}",
3411 imports
3412 );
3413 }
3414
3415 #[test]
3419 fn test_observe_tsconfig_alias_basic() {
3420 use tempfile::TempDir;
3421
3422 let dir = TempDir::new().unwrap();
3427 let src_dir = dir.path().join("src");
3428 let test_dir = dir.path().join("test");
3429 std::fs::create_dir_all(&src_dir).unwrap();
3430 std::fs::create_dir_all(&test_dir).unwrap();
3431
3432 let tsconfig = dir.path().join("tsconfig.json");
3433 std::fs::write(
3434 &tsconfig,
3435 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3436 )
3437 .unwrap();
3438
3439 let prod_path = src_dir.join("foo.service.ts");
3440 std::fs::File::create(&prod_path).unwrap();
3441
3442 let test_path = test_dir.join("foo.service.spec.ts");
3443 let test_source =
3444 "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3445 std::fs::write(&test_path, test_source).unwrap();
3446
3447 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3448 let mut test_sources = HashMap::new();
3449 test_sources.insert(
3450 test_path.to_string_lossy().into_owned(),
3451 test_source.to_string(),
3452 );
3453
3454 let extractor = TypeScriptExtractor::new();
3455
3456 let mappings =
3458 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3459
3460 let mapping = mappings
3462 .iter()
3463 .find(|m| m.production_file.contains("foo.service.ts"))
3464 .expect("expected mapping for foo.service.ts");
3465 assert!(
3466 mapping
3467 .test_files
3468 .contains(&test_path.to_string_lossy().into_owned()),
3469 "expected foo.service.spec.ts in mapping via alias, got {:?}",
3470 mapping.test_files
3471 );
3472 }
3473
3474 #[test]
3476 fn test_observe_no_tsconfig_alias_ignored() {
3477 use tempfile::TempDir;
3478
3479 let dir = TempDir::new().unwrap();
3484 let src_dir = dir.path().join("src");
3485 let test_dir = dir.path().join("test");
3486 std::fs::create_dir_all(&src_dir).unwrap();
3487 std::fs::create_dir_all(&test_dir).unwrap();
3488
3489 let prod_path = src_dir.join("foo.service.ts");
3490 std::fs::File::create(&prod_path).unwrap();
3491
3492 let test_path = test_dir.join("foo.service.spec.ts");
3493 let test_source =
3494 "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3495
3496 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3497 let mut test_sources = HashMap::new();
3498 test_sources.insert(
3499 test_path.to_string_lossy().into_owned(),
3500 test_source.to_string(),
3501 );
3502
3503 let extractor = TypeScriptExtractor::new();
3504
3505 let mappings =
3507 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3508
3509 let all_test_files: Vec<&String> =
3511 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3512 assert!(
3513 all_test_files.is_empty(),
3514 "expected no test_files when tsconfig absent, got {:?}",
3515 all_test_files
3516 );
3517 }
3518
3519 #[test]
3521 fn test_observe_tsconfig_alias_barrel() {
3522 use tempfile::TempDir;
3523
3524 let dir = TempDir::new().unwrap();
3530 let src_dir = dir.path().join("src");
3531 let services_dir = src_dir.join("services");
3532 let test_dir = dir.path().join("test");
3533 std::fs::create_dir_all(&services_dir).unwrap();
3534 std::fs::create_dir_all(&test_dir).unwrap();
3535
3536 std::fs::write(
3537 dir.path().join("tsconfig.json"),
3538 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3539 )
3540 .unwrap();
3541
3542 let prod_path = src_dir.join("bar.service.ts");
3543 std::fs::File::create(&prod_path).unwrap();
3544
3545 std::fs::write(
3546 services_dir.join("index.ts"),
3547 "export { BarService } from '../bar.service';\n",
3548 )
3549 .unwrap();
3550
3551 let test_path = test_dir.join("bar.service.spec.ts");
3552 let test_source =
3553 "import { BarService } from '@app/services';\ndescribe('BarService', () => {});\n";
3554 std::fs::write(&test_path, test_source).unwrap();
3555
3556 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3557 let mut test_sources = HashMap::new();
3558 test_sources.insert(
3559 test_path.to_string_lossy().into_owned(),
3560 test_source.to_string(),
3561 );
3562
3563 let extractor = TypeScriptExtractor::new();
3564
3565 let mappings =
3567 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3568
3569 let mapping = mappings
3571 .iter()
3572 .find(|m| m.production_file.contains("bar.service.ts"))
3573 .expect("expected mapping for bar.service.ts");
3574 assert!(
3575 mapping
3576 .test_files
3577 .contains(&test_path.to_string_lossy().into_owned()),
3578 "expected bar.service.spec.ts mapped via alias+barrel, got {:?}",
3579 mapping.test_files
3580 );
3581 }
3582
3583 #[test]
3585 fn test_observe_tsconfig_alias_mixed() {
3586 use tempfile::TempDir;
3587
3588 let dir = TempDir::new().unwrap();
3595 let src_dir = dir.path().join("src");
3596 let test_dir = dir.path().join("test");
3597 std::fs::create_dir_all(&src_dir).unwrap();
3598 std::fs::create_dir_all(&test_dir).unwrap();
3599
3600 std::fs::write(
3601 dir.path().join("tsconfig.json"),
3602 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3603 )
3604 .unwrap();
3605
3606 let foo_path = src_dir.join("foo.service.ts");
3607 let bar_path = src_dir.join("bar.service.ts");
3608 std::fs::File::create(&foo_path).unwrap();
3609 std::fs::File::create(&bar_path).unwrap();
3610
3611 let test_path = test_dir.join("mixed.spec.ts");
3612 let test_source = "\
3613import { FooService } from '@app/foo.service';
3614import { BarService } from '../src/bar.service';
3615describe('Mixed', () => {});
3616";
3617 std::fs::write(&test_path, test_source).unwrap();
3618
3619 let production_files = vec![
3620 foo_path.to_string_lossy().into_owned(),
3621 bar_path.to_string_lossy().into_owned(),
3622 ];
3623 let mut test_sources = HashMap::new();
3624 test_sources.insert(
3625 test_path.to_string_lossy().into_owned(),
3626 test_source.to_string(),
3627 );
3628
3629 let extractor = TypeScriptExtractor::new();
3630
3631 let mappings =
3633 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3634
3635 let foo_mapping = mappings
3637 .iter()
3638 .find(|m| m.production_file.contains("foo.service.ts"))
3639 .expect("expected mapping for foo.service.ts");
3640 assert!(
3641 foo_mapping
3642 .test_files
3643 .contains(&test_path.to_string_lossy().into_owned()),
3644 "expected mixed.spec.ts in foo mapping, got {:?}",
3645 foo_mapping.test_files
3646 );
3647 let bar_mapping = mappings
3648 .iter()
3649 .find(|m| m.production_file.contains("bar.service.ts"))
3650 .expect("expected mapping for bar.service.ts");
3651 assert!(
3652 bar_mapping
3653 .test_files
3654 .contains(&test_path.to_string_lossy().into_owned()),
3655 "expected mixed.spec.ts in bar mapping, got {:?}",
3656 bar_mapping.test_files
3657 );
3658 }
3659
3660 #[test]
3662 fn test_observe_tsconfig_alias_helper_filtered() {
3663 use tempfile::TempDir;
3664
3665 let dir = TempDir::new().unwrap();
3670 let src_dir = dir.path().join("src");
3671 let test_dir = dir.path().join("test");
3672 std::fs::create_dir_all(&src_dir).unwrap();
3673 std::fs::create_dir_all(&test_dir).unwrap();
3674
3675 std::fs::write(
3676 dir.path().join("tsconfig.json"),
3677 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3678 )
3679 .unwrap();
3680
3681 let prod_path = src_dir.join("constants.ts");
3682 std::fs::File::create(&prod_path).unwrap();
3683
3684 let test_path = test_dir.join("constants.spec.ts");
3685 let test_source =
3686 "import { APP_NAME } from '@app/constants';\ndescribe('Constants', () => {});\n";
3687 std::fs::write(&test_path, test_source).unwrap();
3688
3689 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3690 let mut test_sources = HashMap::new();
3691 test_sources.insert(
3692 test_path.to_string_lossy().into_owned(),
3693 test_source.to_string(),
3694 );
3695
3696 let extractor = TypeScriptExtractor::new();
3697
3698 let mappings =
3700 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3701
3702 let all_test_files: Vec<&String> =
3704 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3705 assert!(
3706 all_test_files.is_empty(),
3707 "expected constants.ts filtered by is_non_sut_helper, got {:?}",
3708 all_test_files
3709 );
3710 }
3711
3712 #[test]
3714 fn test_observe_tsconfig_alias_nonexistent() {
3715 use tempfile::TempDir;
3716
3717 let dir = TempDir::new().unwrap();
3723 let src_dir = dir.path().join("src");
3724 let test_dir = dir.path().join("test");
3725 std::fs::create_dir_all(&src_dir).unwrap();
3726 std::fs::create_dir_all(&test_dir).unwrap();
3727
3728 std::fs::write(
3729 dir.path().join("tsconfig.json"),
3730 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3731 )
3732 .unwrap();
3733
3734 let prod_path = src_dir.join("foo.service.ts");
3735 std::fs::File::create(&prod_path).unwrap();
3736
3737 let test_path = test_dir.join("nonexistent.spec.ts");
3738 let test_source =
3739 "import { Missing } from '@app/nonexistent';\ndescribe('Nonexistent', () => {});\n";
3740 std::fs::write(&test_path, test_source).unwrap();
3741
3742 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3743 let mut test_sources = HashMap::new();
3744 test_sources.insert(
3745 test_path.to_string_lossy().into_owned(),
3746 test_source.to_string(),
3747 );
3748
3749 let extractor = TypeScriptExtractor::new();
3750
3751 let mappings =
3753 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3754
3755 let all_test_files: Vec<&String> =
3757 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3758 assert!(
3759 all_test_files.is_empty(),
3760 "expected no mapping for alias to nonexistent file, got {:?}",
3761 all_test_files
3762 );
3763 }
3764
3765 #[test]
3768 fn boundary_b3_tsconfig_alias_resolved() {
3769 use tempfile::TempDir;
3770
3771 let dir = TempDir::new().unwrap();
3776 let src_dir = dir.path().join("src");
3777 let services_dir = src_dir.join("services");
3778 let test_dir = dir.path().join("test");
3779 std::fs::create_dir_all(&services_dir).unwrap();
3780 std::fs::create_dir_all(&test_dir).unwrap();
3781
3782 std::fs::write(
3783 dir.path().join("tsconfig.json"),
3784 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3785 )
3786 .unwrap();
3787
3788 let prod_path = services_dir.join("foo.service.ts");
3789 std::fs::File::create(&prod_path).unwrap();
3790
3791 let test_path = test_dir.join("foo.service.spec.ts");
3792 let test_source = "import { FooService } from '@app/services/foo.service';\ndescribe('FooService', () => {});\n";
3793 std::fs::write(&test_path, test_source).unwrap();
3794
3795 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3796 let mut test_sources = HashMap::new();
3797 test_sources.insert(
3798 test_path.to_string_lossy().into_owned(),
3799 test_source.to_string(),
3800 );
3801
3802 let extractor = TypeScriptExtractor::new();
3803
3804 let mappings =
3806 extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
3807
3808 let mapping = mappings
3810 .iter()
3811 .find(|m| m.production_file.contains("foo.service.ts"))
3812 .expect("expected FileMapping for foo.service.ts");
3813 assert!(
3814 mapping
3815 .test_files
3816 .contains(&test_path.to_string_lossy().into_owned()),
3817 "expected tsconfig alias to be resolved (B3 fix), got {:?}",
3818 mapping.test_files
3819 );
3820 }
3821
3822 #[test]
3824 fn boundary_b6_import_outside_scan_root() {
3825 use tempfile::TempDir;
3826
3827 let dir = TempDir::new().unwrap();
3833 let core_src = dir.path().join("packages").join("core").join("src");
3834 let core_test = dir.path().join("packages").join("core").join("test");
3835 let common_src = dir.path().join("packages").join("common").join("src");
3836 std::fs::create_dir_all(&core_src).unwrap();
3837 std::fs::create_dir_all(&core_test).unwrap();
3838 std::fs::create_dir_all(&common_src).unwrap();
3839
3840 let prod_path = core_src.join("foo.service.ts");
3841 std::fs::File::create(&prod_path).unwrap();
3842
3843 let shared_path = common_src.join("shared.ts");
3845 std::fs::File::create(&shared_path).unwrap();
3846
3847 let test_path = core_test.join("foo.spec.ts");
3848 std::fs::write(
3849 &test_path,
3850 "import { Shared } from '../../common/src/shared';\ndescribe('Foo', () => {});",
3851 )
3852 .unwrap();
3853
3854 let scan_root = dir.path().join("packages").join("core");
3855 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3857 let mut test_sources = HashMap::new();
3858 test_sources.insert(
3859 test_path.to_string_lossy().into_owned(),
3860 std::fs::read_to_string(&test_path).unwrap(),
3861 );
3862
3863 let extractor = TypeScriptExtractor::new();
3864
3865 let mappings =
3867 extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
3868
3869 let all_test_files: Vec<&String> =
3873 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3874 assert!(
3875 all_test_files.is_empty(),
3876 "expected no test_files (import target outside scan_root), got {:?}",
3877 all_test_files
3878 );
3879 }
3880}