1use std::collections::HashMap;
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
10pub use exspec_core::observe::{
12 BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
13 ProductionFunction,
14};
15
16const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
17static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
18
19const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
20static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
21
22const RE_EXPORT_QUERY: &str = include_str!("../queries/re_export.scm");
23static RE_EXPORT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
24
25const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
26static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct Route {
31 pub http_method: String,
32 pub path: String,
33 pub handler_name: String,
34 pub class_name: String,
35 pub file: String,
36 pub line: usize,
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct DecoratorInfo {
42 pub name: String,
43 pub arguments: Vec<String>,
44 pub target_name: String,
45 pub class_name: String,
46 pub file: String,
47 pub line: usize,
48}
49
50const HTTP_METHODS: &[&str] = &["Get", "Post", "Put", "Patch", "Delete", "Head", "Options"];
52
53const GAP_RELEVANT_DECORATORS: &[&str] = &[
55 "UseGuards",
56 "UsePipes",
57 "IsEmail",
58 "IsNotEmpty",
59 "MinLength",
60 "MaxLength",
61 "IsOptional",
62 "IsString",
63 "IsNumber",
64 "IsInt",
65 "IsBoolean",
66 "IsDate",
67 "IsEnum",
68 "IsArray",
69 "ValidateNested",
70 "Min",
71 "Max",
72 "Matches",
73 "IsUrl",
74 "IsUUID",
75];
76
77impl ObserveExtractor for TypeScriptExtractor {
78 fn extract_production_functions(
79 &self,
80 source: &str,
81 file_path: &str,
82 ) -> Vec<ProductionFunction> {
83 self.extract_production_functions_impl(source, file_path)
84 }
85
86 fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
87 self.extract_imports_impl(source, file_path)
88 }
89
90 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
91 self.extract_all_import_specifiers_impl(source)
92 }
93
94 fn extract_barrel_re_exports(&self, source: &str, file_path: &str) -> Vec<BarrelReExport> {
95 self.extract_barrel_re_exports_impl(source, file_path)
96 }
97
98 fn source_extensions(&self) -> &[&str] {
99 &["ts", "tsx", "js", "jsx"]
100 }
101
102 fn index_file_names(&self) -> &[&str] {
103 &["index.ts", "index.tsx"]
104 }
105
106 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
107 production_stem(path)
108 }
109
110 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
111 test_stem(path)
112 }
113
114 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
115 is_non_sut_helper(file_path, is_known_production)
116 }
117
118 fn file_exports_any_symbol(&self, file_path: &Path, symbols: &[String]) -> bool {
119 file_exports_any_symbol(file_path, symbols)
120 }
121}
122
123impl TypeScriptExtractor {
124 pub fn map_test_files(
126 &self,
127 production_files: &[String],
128 test_files: &[String],
129 ) -> Vec<FileMapping> {
130 exspec_core::observe::map_test_files(self, production_files, test_files)
131 }
132
133 pub fn extract_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
135 let mut parser = Self::parser();
136 let tree = match parser.parse(source, None) {
137 Some(t) => t,
138 None => return Vec::new(),
139 };
140 let source_bytes = source.as_bytes();
141
142 let mut routes = Vec::new();
143
144 for node in iter_children(tree.root_node()) {
146 let (container, class_node) = match node.kind() {
148 "export_statement" => {
149 let cls = node
150 .named_children(&mut node.walk())
151 .find(|c| c.kind() == "class_declaration");
152 match cls {
153 Some(c) => (node, c),
154 None => continue,
155 }
156 }
157 "class_declaration" => (node, node),
158 _ => continue,
159 };
160
161 let (base_path, class_name) =
163 match extract_controller_info(container, class_node, source_bytes) {
164 Some(info) => info,
165 None => continue,
166 };
167
168 let class_body = match class_node.child_by_field_name("body") {
169 Some(b) => b,
170 None => continue,
171 };
172
173 let mut decorator_acc: Vec<Node> = Vec::new();
174 for child in iter_children(class_body) {
175 match child.kind() {
176 "decorator" => decorator_acc.push(child),
177 "method_definition" => {
178 let handler_name = child
179 .child_by_field_name("name")
180 .and_then(|n| n.utf8_text(source_bytes).ok())
181 .unwrap_or("")
182 .to_string();
183 let line = child.start_position().row + 1;
184
185 for dec in &decorator_acc {
186 if let Some((dec_name, dec_arg)) =
187 extract_decorator_call(*dec, source_bytes)
188 {
189 if HTTP_METHODS.contains(&dec_name.as_str()) {
190 let sub_path = dec_arg.unwrap_or_default();
191 routes.push(Route {
192 http_method: dec_name.to_uppercase(),
193 path: normalize_path(&base_path, &sub_path),
194 handler_name: handler_name.clone(),
195 class_name: class_name.clone(),
196 file: file_path.to_string(),
197 line,
198 });
199 }
200 }
201 }
202 decorator_acc.clear();
203 }
204 _ => {}
205 }
206 }
207 }
208
209 routes
210 }
211
212 pub fn extract_decorators(&self, source: &str, file_path: &str) -> Vec<DecoratorInfo> {
214 let mut parser = Self::parser();
215 let tree = match parser.parse(source, None) {
216 Some(t) => t,
217 None => return Vec::new(),
218 };
219 let source_bytes = source.as_bytes();
220
221 let mut decorators = Vec::new();
222
223 for node in iter_children(tree.root_node()) {
224 let (container, class_node) = match node.kind() {
225 "export_statement" => {
226 let cls = node
227 .named_children(&mut node.walk())
228 .find(|c| c.kind() == "class_declaration");
229 match cls {
230 Some(c) => (node, c),
231 None => continue,
232 }
233 }
234 "class_declaration" => (node, node),
235 _ => continue,
236 };
237
238 let class_name = class_node
239 .child_by_field_name("name")
240 .and_then(|n| n.utf8_text(source_bytes).ok())
241 .unwrap_or("")
242 .to_string();
243
244 let class_level_decorators: Vec<Node> = find_decorators_on_node(container, class_node);
247 collect_gap_decorators(
248 &class_level_decorators,
249 &class_name, &class_name,
251 file_path,
252 source_bytes,
253 &mut decorators,
254 );
255
256 let class_body = match class_node.child_by_field_name("body") {
257 Some(b) => b,
258 None => continue,
259 };
260
261 let mut decorator_acc: Vec<Node> = Vec::new();
262 for child in iter_children(class_body) {
263 match child.kind() {
264 "decorator" => decorator_acc.push(child),
265 "method_definition" => {
266 let method_name = child
267 .child_by_field_name("name")
268 .and_then(|n| n.utf8_text(source_bytes).ok())
269 .unwrap_or("")
270 .to_string();
271
272 collect_gap_decorators(
273 &decorator_acc,
274 &method_name,
275 &class_name,
276 file_path,
277 source_bytes,
278 &mut decorators,
279 );
280 decorator_acc.clear();
281 }
282 "public_field_definition" => {
284 let field_name = child
285 .child_by_field_name("name")
286 .and_then(|n| n.utf8_text(source_bytes).ok())
287 .unwrap_or("")
288 .to_string();
289
290 let field_decorators: Vec<Node> = iter_children(child)
291 .filter(|c| c.kind() == "decorator")
292 .collect();
293 collect_gap_decorators(
294 &field_decorators,
295 &field_name,
296 &class_name,
297 file_path,
298 source_bytes,
299 &mut decorators,
300 );
301 decorator_acc.clear();
302 }
303 _ => {}
304 }
305 }
306 }
307
308 decorators
309 }
310
311 fn extract_production_functions_impl(
312 &self,
313 source: &str,
314 file_path: &str,
315 ) -> Vec<ProductionFunction> {
316 let mut parser = Self::parser();
317 let tree = match parser.parse(source, None) {
318 Some(t) => t,
319 None => return Vec::new(),
320 };
321
322 let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
323 let mut cursor = QueryCursor::new();
324 let source_bytes = source.as_bytes();
325
326 let idx_name = query
327 .capture_index_for_name("name")
328 .expect("@name capture not found in production_function.scm");
329 let idx_exported_function = query
330 .capture_index_for_name("exported_function")
331 .expect("@exported_function capture not found");
332 let idx_function = query
333 .capture_index_for_name("function")
334 .expect("@function capture not found");
335 let idx_method = query
336 .capture_index_for_name("method")
337 .expect("@method capture not found");
338 let idx_exported_arrow = query
339 .capture_index_for_name("exported_arrow")
340 .expect("@exported_arrow capture not found");
341 let idx_arrow = query
342 .capture_index_for_name("arrow")
343 .expect("@arrow capture not found");
344
345 let mut dedup: HashMap<(usize, String), ProductionFunction> = HashMap::new();
350
351 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
352 while let Some(m) = matches.next() {
353 let name_node = match m.captures.iter().find(|c| c.index == idx_name) {
354 Some(c) => c.node,
355 None => continue,
356 };
357 let name = name_node.utf8_text(source_bytes).unwrap_or("").to_string();
358 let line = name_node.start_position().row + 1; let (is_exported, class_name) = if m
362 .captures
363 .iter()
364 .any(|c| c.index == idx_exported_function || c.index == idx_exported_arrow)
365 {
366 (true, None)
367 } else if m
368 .captures
369 .iter()
370 .any(|c| c.index == idx_function || c.index == idx_arrow)
371 {
372 (false, None)
373 } else if let Some(c) = m.captures.iter().find(|c| c.index == idx_method) {
374 let (cname, exported) = find_class_info(c.node, source_bytes);
375 (exported, cname)
376 } else {
377 continue;
378 };
379
380 dedup
381 .entry((line, name.clone()))
382 .and_modify(|existing| {
383 if is_exported {
384 existing.is_exported = true;
385 }
386 })
387 .or_insert(ProductionFunction {
388 name,
389 file: file_path.to_string(),
390 line,
391 class_name,
392 is_exported,
393 });
394 }
395
396 let mut results: Vec<ProductionFunction> = dedup.into_values().collect();
397 results.sort_by_key(|f| f.line);
398 results
399 }
400}
401
402fn iter_children(node: Node) -> impl Iterator<Item = Node> {
404 (0..node.child_count()).filter_map(move |i| node.child(i))
405}
406
407fn extract_controller_info(
411 container: Node,
412 class_node: Node,
413 source: &[u8],
414) -> Option<(String, String)> {
415 let class_name = class_node
416 .child_by_field_name("name")
417 .and_then(|n| n.utf8_text(source).ok())?
418 .to_string();
419
420 for search_node in [container, class_node] {
422 for i in 0..search_node.child_count() {
423 let child = match search_node.child(i) {
424 Some(c) => c,
425 None => continue,
426 };
427 if child.kind() != "decorator" {
428 continue;
429 }
430 if let Some((name, arg)) = extract_decorator_call(child, source) {
431 if name == "Controller" {
432 let base_path = arg.unwrap_or_default();
433 return Some((base_path, class_name));
434 }
435 }
436 }
437 }
438 None
439}
440
441fn collect_gap_decorators(
443 decorator_acc: &[Node],
444 target_name: &str,
445 class_name: &str,
446 file_path: &str,
447 source: &[u8],
448 output: &mut Vec<DecoratorInfo>,
449) {
450 for dec in decorator_acc {
451 if let Some((dec_name, _)) = extract_decorator_call(*dec, source) {
452 if GAP_RELEVANT_DECORATORS.contains(&dec_name.as_str()) {
453 let args = extract_decorator_args(*dec, source);
454 output.push(DecoratorInfo {
455 name: dec_name,
456 arguments: args,
457 target_name: target_name.to_string(),
458 class_name: class_name.to_string(),
459 file: file_path.to_string(),
460 line: dec.start_position().row + 1,
461 });
462 }
463 }
464 }
465}
466
467fn extract_decorator_call(decorator_node: Node, source: &[u8]) -> Option<(String, Option<String>)> {
471 for i in 0..decorator_node.child_count() {
472 let child = match decorator_node.child(i) {
473 Some(c) => c,
474 None => continue,
475 };
476
477 match child.kind() {
478 "call_expression" => {
479 let func_node = child.child_by_field_name("function")?;
480 let name = func_node.utf8_text(source).ok()?.to_string();
481 let args_node = child.child_by_field_name("arguments")?;
482
483 if args_node.named_child_count() == 0 {
484 return Some((name, None));
486 }
487 let first_string = find_first_string_arg(args_node, source);
489 if first_string.is_some() {
490 return Some((name, first_string));
491 }
492 return Some((name, Some("<dynamic>".to_string())));
494 }
495 "identifier" => {
496 let name = child.utf8_text(source).ok()?.to_string();
497 return Some((name, None));
498 }
499 _ => {}
500 }
501 }
502 None
503}
504
505fn extract_decorator_args(decorator_node: Node, source: &[u8]) -> Vec<String> {
508 let mut args = Vec::new();
509 for i in 0..decorator_node.child_count() {
510 let child = match decorator_node.child(i) {
511 Some(c) => c,
512 None => continue,
513 };
514 if child.kind() == "call_expression" {
515 if let Some(args_node) = child.child_by_field_name("arguments") {
516 for j in 0..args_node.named_child_count() {
517 if let Some(arg) = args_node.named_child(j) {
518 if let Ok(text) = arg.utf8_text(source) {
519 args.push(text.to_string());
520 }
521 }
522 }
523 }
524 }
525 }
526 args
527}
528
529fn find_first_string_arg(args_node: Node, source: &[u8]) -> Option<String> {
531 for i in 0..args_node.named_child_count() {
532 let arg = args_node.named_child(i)?;
533 if arg.kind() == "string" {
534 let text = arg.utf8_text(source).ok()?;
535 let stripped = text.trim_matches(|c| c == '\'' || c == '"');
537 if !stripped.is_empty() {
538 return Some(stripped.to_string());
539 }
540 }
541 }
542 None
543}
544
545fn normalize_path(base: &str, sub: &str) -> String {
550 let base = base.trim_matches('/');
551 let sub = sub.trim_matches('/');
552 match (base.is_empty(), sub.is_empty()) {
553 (true, true) => "/".to_string(),
554 (true, false) => format!("/{sub}"),
555 (false, true) => format!("/{base}"),
556 (false, false) => format!("/{base}/{sub}"),
557 }
558}
559
560fn find_decorators_on_node<'a>(container: Node<'a>, class_node: Node<'a>) -> Vec<Node<'a>> {
563 let mut result = Vec::new();
564 for search_node in [container, class_node] {
565 for i in 0..search_node.child_count() {
566 if let Some(child) = search_node.child(i) {
567 if child.kind() == "decorator" {
568 result.push(child);
569 }
570 }
571 }
572 }
573 result
574}
575
576fn find_class_info(method_node: Node, source: &[u8]) -> (Option<String>, bool) {
578 let mut current = method_node.parent();
579 while let Some(node) = current {
580 if node.kind() == "class_body" {
581 if let Some(class_node) = node.parent() {
582 let class_kind = class_node.kind();
583 if class_kind == "class_declaration"
584 || class_kind == "class"
585 || class_kind == "abstract_class_declaration"
586 {
587 let class_name = class_node
588 .child_by_field_name("name")
589 .and_then(|n| n.utf8_text(source).ok())
590 .map(|s| s.to_string());
591
592 let is_exported = class_node
594 .parent()
595 .is_some_and(|p| p.kind() == "export_statement");
596
597 return (class_name, is_exported);
598 }
599 }
600 }
601 current = node.parent();
602 }
603 (None, false)
604}
605
606fn is_type_only_import(symbol_node: Node) -> bool {
609 let parent = symbol_node.parent();
611 if let Some(p) = parent {
612 if p.kind() == "import_specifier" {
613 for i in 0..p.child_count() {
614 if let Some(child) = p.child(i) {
615 if child.kind() == "type" {
616 return true;
617 }
618 }
619 }
620 }
621 }
622
623 let mut current = Some(symbol_node);
626 while let Some(node) = current {
627 if node.kind() == "import_statement" {
628 for i in 0..node.child_count() {
629 if let Some(child) = node.child(i) {
630 if child.kind() == "type" {
631 return true;
632 }
633 }
634 }
635 break;
636 }
637 current = node.parent();
638 }
639 false
640}
641
642impl TypeScriptExtractor {
643 fn extract_imports_impl(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
644 let mut parser = Self::parser();
645 let tree = match parser.parse(source, None) {
646 Some(t) => t,
647 None => return Vec::new(),
648 };
649 let source_bytes = source.as_bytes();
650 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
651 let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
652 let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
653
654 let mut cursor = QueryCursor::new();
655 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
656 let mut result = Vec::new();
657
658 while let Some(m) = matches.next() {
659 let mut symbol_node = None;
660 let mut symbol = None;
661 let mut specifier = None;
662 let mut symbol_line = 0usize;
663 for cap in m.captures {
664 if cap.index == symbol_idx {
665 symbol_node = Some(cap.node);
666 symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
667 symbol_line = cap.node.start_position().row + 1;
668 } else if cap.index == specifier_idx {
669 specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
670 }
671 }
672 if let Some(spec) = specifier {
673 if !spec.starts_with("./") && !spec.starts_with("../") {
675 continue;
676 }
677
678 if let Some(sym) = symbol {
679 if let Some(snode) = symbol_node {
684 if is_type_only_import(snode) {
685 continue;
686 }
687 }
688
689 result.push(ImportMapping {
690 symbol_name: sym.to_string(),
691 module_specifier: spec.to_string(),
692 file: file_path.to_string(),
693 line: symbol_line,
694 symbols: Vec::new(),
695 });
696 } else {
697 if !result.iter().any(|e| e.module_specifier == spec) {
701 result.push(ImportMapping {
702 symbol_name: String::new(),
703 module_specifier: spec.to_string(),
704 file: file_path.to_string(),
705 line: 0,
706 symbols: Vec::new(),
707 });
708 }
709 }
710 }
711 }
712 let specifier_to_symbols: HashMap<String, Vec<String>> =
715 result.iter().fold(HashMap::new(), |mut acc, im| {
716 acc.entry(im.module_specifier.clone())
717 .or_default()
718 .push(im.symbol_name.clone());
719 acc
720 });
721 for im in &mut result {
722 im.symbols = specifier_to_symbols
723 .get(&im.module_specifier)
724 .cloned()
725 .unwrap_or_default();
726 }
727 result
728 }
729
730 fn extract_all_import_specifiers_impl(&self, source: &str) -> Vec<(String, Vec<String>)> {
731 let mut parser = Self::parser();
732 let tree = match parser.parse(source, None) {
733 Some(t) => t,
734 None => return Vec::new(),
735 };
736 let source_bytes = source.as_bytes();
737 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
738 let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
739 let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
740
741 let mut cursor = QueryCursor::new();
742 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
743 let mut specifier_symbols: std::collections::HashMap<String, Vec<String>> =
745 std::collections::HashMap::new();
746
747 while let Some(m) = matches.next() {
748 let mut symbol_node = None;
749 let mut symbol = None;
750 let mut specifier = None;
751 for cap in m.captures {
752 if cap.index == symbol_idx {
753 symbol_node = Some(cap.node);
754 symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
755 } else if cap.index == specifier_idx {
756 specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
757 }
758 }
759 if let Some(spec) = specifier {
760 if spec.starts_with("./") || spec.starts_with("../") {
762 continue;
763 }
764 if let Some(sym) = symbol {
765 if let Some(snode) = symbol_node {
768 if is_type_only_import(snode) {
769 continue;
770 }
771 }
772 specifier_symbols
773 .entry(spec.to_string())
774 .or_default()
775 .push(sym.to_string());
776 } else {
777 specifier_symbols.entry(spec.to_string()).or_default();
781 }
782 }
783 }
784
785 specifier_symbols.into_iter().collect()
786 }
787
788 fn extract_barrel_re_exports_impl(
789 &self,
790 source: &str,
791 _file_path: &str,
792 ) -> Vec<BarrelReExport> {
793 let mut parser = Self::parser();
794 let tree = match parser.parse(source, None) {
795 Some(t) => t,
796 None => return Vec::new(),
797 };
798 let source_bytes = source.as_bytes();
799 let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
800
801 let symbol_idx = query.capture_index_for_name("symbol_name");
802 let wildcard_idx = query.capture_index_for_name("wildcard");
803 let ns_wildcard_idx = query.capture_index_for_name("ns_wildcard");
804 let specifier_idx = query
805 .capture_index_for_name("from_specifier")
806 .expect("@from_specifier capture not found in re_export.scm");
807
808 let mut cursor = QueryCursor::new();
809 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
810
811 struct ReExportEntry {
815 symbols: Vec<String>,
816 wildcard: bool,
817 namespace_wildcard: bool,
818 }
819 let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
820
821 while let Some(m) = matches.next() {
822 let mut from_spec = None;
823 let mut sym_name = None;
824 let mut is_wildcard = false;
825 let mut is_ns_wildcard = false;
826
827 for cap in m.captures {
828 if ns_wildcard_idx == Some(cap.index) {
829 is_wildcard = true;
830 is_ns_wildcard = true;
831 } else if wildcard_idx == Some(cap.index) {
832 is_wildcard = true;
833 } else if cap.index == specifier_idx {
834 from_spec = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
835 } else if symbol_idx == Some(cap.index) {
836 sym_name = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
837 }
838 }
839
840 let Some(spec) = from_spec else { continue };
841
842 let entry = grouped.entry(spec).or_insert(ReExportEntry {
843 symbols: Vec::new(),
844 wildcard: false,
845 namespace_wildcard: false,
846 });
847 if is_wildcard {
848 entry.wildcard = true;
849 }
850 if is_ns_wildcard {
851 entry.namespace_wildcard = true;
852 }
853 if let Some(sym) = sym_name {
854 if !sym.is_empty() && !entry.symbols.contains(&sym) {
855 entry.symbols.push(sym);
856 }
857 }
858 }
859
860 grouped
861 .into_iter()
862 .map(|(from_spec, entry)| BarrelReExport {
863 symbols: entry.symbols,
864 from_specifier: from_spec,
865 wildcard: entry.wildcard,
866 namespace_wildcard: entry.namespace_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 l1_exclusive: bool,
877 ) -> Vec<FileMapping> {
878 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
879
880 let mut mappings = self.map_test_files(production_files, &test_file_list);
882
883 let canonical_root = match scan_root.canonicalize() {
885 Ok(r) => r,
886 Err(_) => return mappings,
887 };
888 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
889 for (idx, prod) in production_files.iter().enumerate() {
890 if let Ok(canonical) = Path::new(prod).canonicalize() {
891 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
892 }
893 }
894
895 let layer1_matched: std::collections::HashSet<String> = mappings
897 .iter()
898 .flat_map(|m| m.test_files.iter().cloned())
899 .collect();
900
901 let tsconfig_paths =
903 crate::tsconfig::discover_tsconfig(&canonical_root).and_then(|tsconfig_path| {
904 let content = std::fs::read_to_string(&tsconfig_path)
905 .map_err(|e| {
906 eprintln!("[exspec] warning: failed to read tsconfig: {e}");
907 })
908 .ok()?;
909 let tsconfig_dir = tsconfig_path.parent().unwrap_or(&canonical_root);
910 crate::tsconfig::TsconfigPaths::from_str(&content, tsconfig_dir)
911 .or_else(|| {
912 eprintln!("[exspec] warning: failed to parse tsconfig paths, alias resolution disabled");
913 None
914 })
915 });
916
917 let mut nm_symlink_cache: HashMap<String, Option<PathBuf>> = HashMap::new();
919
920 for (test_file, source) in test_sources {
923 if l1_exclusive && layer1_matched.contains(test_file) {
924 continue;
925 }
926 let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
927 let from_file = Path::new(test_file);
928 let mut matched_indices = std::collections::HashSet::new();
929
930 for import in &imports {
931 if let Some(resolved) = exspec_core::observe::resolve_import_path(
932 self,
933 &import.module_specifier,
934 from_file,
935 &canonical_root,
936 ) {
937 exspec_core::observe::collect_import_matches(
938 self,
939 &resolved,
940 &import.symbols,
941 &canonical_to_idx,
942 &mut matched_indices,
943 &canonical_root,
944 );
945 }
946 }
947
948 let all_specifiers =
950 <Self as ObserveExtractor>::extract_all_import_specifiers(self, source);
951
952 if let Some(ref tc_paths) = tsconfig_paths {
954 for (specifier, symbols) in &all_specifiers {
955 let Some(alias_base) = tc_paths.resolve_alias(specifier) else {
956 continue;
957 };
958 if let Some(resolved) =
959 resolve_absolute_base_to_file(self, &alias_base, &canonical_root)
960 {
961 exspec_core::observe::collect_import_matches(
962 self,
963 &resolved,
964 symbols,
965 &canonical_to_idx,
966 &mut matched_indices,
967 &canonical_root,
968 );
969 }
970 }
971 }
972
973 for (specifier, symbols) in &all_specifiers {
977 if let Some(ref tc_paths) = tsconfig_paths {
979 if tc_paths.resolve_alias(specifier).is_some() {
980 continue;
981 }
982 }
983 if let Some(resolved_dir) =
984 resolve_node_modules_symlink(specifier, &canonical_root, &mut nm_symlink_cache)
985 {
986 let resolved_dir_str = resolved_dir.to_string_lossy().into_owned();
989 for prod_canonical in canonical_to_idx.keys() {
990 if prod_canonical.starts_with(&resolved_dir_str) {
991 exspec_core::observe::collect_import_matches(
992 self,
993 prod_canonical,
994 symbols,
995 &canonical_to_idx,
996 &mut matched_indices,
997 &canonical_root,
998 );
999 }
1000 }
1001 }
1002 }
1003
1004 for idx in matched_indices {
1005 if !mappings[idx].test_files.contains(test_file) {
1007 mappings[idx].test_files.push(test_file.clone());
1008 }
1009 }
1010 }
1011
1012 for mapping in &mut mappings {
1015 let has_layer1 = mapping
1016 .test_files
1017 .iter()
1018 .any(|t| layer1_matched.contains(t));
1019 if !has_layer1 && !mapping.test_files.is_empty() {
1020 mapping.strategy = MappingStrategy::ImportTracing;
1021 }
1022 }
1023
1024 mappings
1025 }
1026}
1027
1028fn resolve_node_modules_symlink(
1041 specifier: &str,
1042 scan_root: &Path,
1043 cache: &mut HashMap<String, Option<PathBuf>>,
1044) -> Option<PathBuf> {
1045 if let Some(cached) = cache.get(specifier) {
1046 return cached.clone();
1047 }
1048
1049 let candidate = scan_root.join("node_modules").join(specifier);
1050 let result = match std::fs::symlink_metadata(&candidate) {
1051 Ok(meta) if meta.file_type().is_symlink() => candidate.canonicalize().ok(),
1052 _ => None,
1053 };
1054
1055 cache.insert(specifier.to_string(), result.clone());
1056 result
1057}
1058
1059pub fn resolve_import_path(
1062 module_specifier: &str,
1063 from_file: &Path,
1064 scan_root: &Path,
1065) -> Option<String> {
1066 let ext = crate::TypeScriptExtractor::new();
1067 exspec_core::observe::resolve_import_path(&ext, module_specifier, from_file, scan_root)
1068}
1069
1070fn resolve_absolute_base_to_file(
1072 ext: &dyn ObserveExtractor,
1073 base: &Path,
1074 canonical_root: &Path,
1075) -> Option<String> {
1076 exspec_core::observe::resolve_absolute_base_to_file(ext, base, canonical_root)
1077}
1078
1079fn is_type_definition_file(file_path: &str) -> bool {
1082 let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1083 return false;
1084 };
1085 if let Some(stem) = Path::new(file_name).file_stem().and_then(|s| s.to_str()) {
1086 for suffix in &[".enum", ".interface", ".exception"] {
1087 if stem.ends_with(suffix) && stem != &suffix[1..] {
1088 return true;
1089 }
1090 }
1091 }
1092 false
1093}
1094
1095fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
1103 if file_path
1107 .split('/')
1108 .any(|seg| seg == "test" || seg == "__tests__")
1109 {
1110 return true;
1111 }
1112
1113 let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1114 return false;
1115 };
1116
1117 if matches!(
1119 file_name,
1120 "constants.ts"
1121 | "constants.js"
1122 | "constants.tsx"
1123 | "constants.jsx"
1124 | "index.ts"
1125 | "index.js"
1126 | "index.tsx"
1127 | "index.jsx"
1128 ) {
1129 return true;
1130 }
1131
1132 if !is_known_production && is_type_definition_file(file_path) {
1136 return true;
1137 }
1138
1139 false
1140}
1141
1142fn file_exports_any_symbol(file_path: &Path, symbols: &[String]) -> bool {
1145 if symbols.is_empty() {
1146 return true;
1147 }
1148 let source = match std::fs::read_to_string(file_path) {
1149 Ok(s) => s,
1150 Err(_) => return false,
1151 };
1152 let mut parser = TypeScriptExtractor::parser();
1153 let tree = match parser.parse(&source, None) {
1154 Some(t) => t,
1155 None => return false,
1156 };
1157 let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
1158 let symbol_idx = query
1159 .capture_index_for_name("symbol_name")
1160 .expect("@symbol_name capture not found in exported_symbol.scm");
1161
1162 let mut cursor = QueryCursor::new();
1163 let source_bytes = source.as_bytes();
1164 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1165 while let Some(m) = matches.next() {
1166 for cap in m.captures {
1167 if cap.index == symbol_idx {
1168 let name = cap.node.utf8_text(source_bytes).unwrap_or("");
1169 if symbols.iter().any(|s| s == name) {
1170 return true;
1171 }
1172 }
1173 }
1174 }
1175 false
1176}
1177
1178pub fn resolve_barrel_exports(
1180 barrel_path: &Path,
1181 symbols: &[String],
1182 scan_root: &Path,
1183) -> Vec<PathBuf> {
1184 let ext = crate::TypeScriptExtractor::new();
1185 exspec_core::observe::resolve_barrel_exports(&ext, barrel_path, symbols, scan_root)
1186}
1187
1188const NEXTJS_HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
1190
1191const NEXTJS_ROUTE_HANDLER_QUERY: &str = include_str!("../queries/nextjs_route_handler.scm");
1192static NEXTJS_ROUTE_HANDLER_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
1193
1194pub fn file_path_to_route_path(file_path: &str) -> Option<String> {
1208 let normalized = file_path.replace('\\', "/");
1210
1211 if !normalized.ends_with("/route.ts") && !normalized.ends_with("/route.tsx") {
1213 return None;
1214 }
1215
1216 let path = if let Some(pos) = normalized.find("/src/app/") {
1219 &normalized[pos + "/src/app/".len()..]
1220 } else if let Some(pos) = normalized.find("/app/") {
1221 &normalized[pos + "/app/".len()..]
1222 } else if let Some(stripped) = normalized.strip_prefix("src/app/") {
1223 stripped
1224 } else if let Some(stripped) = normalized.strip_prefix("app/") {
1225 stripped
1226 } else {
1227 return None;
1228 };
1229
1230 let path = path
1232 .strip_suffix("/route.ts")
1233 .or_else(|| path.strip_suffix("/route.tsx"))
1234 .unwrap_or("");
1235
1236 let mut result = String::new();
1238 for segment in path.split('/') {
1239 if segment.is_empty() {
1240 continue;
1241 }
1242 if segment.starts_with('(') && segment.ends_with(')') {
1244 continue;
1245 }
1246 if segment.starts_with("[[...") && segment.ends_with("]]") {
1248 let name = &segment[5..segment.len() - 2];
1249 result.push('/');
1250 result.push(':');
1251 result.push_str(name);
1252 result.push_str("*?");
1253 continue;
1254 }
1255 if segment.starts_with("[...") && segment.ends_with(']') {
1257 let name = &segment[4..segment.len() - 1];
1258 result.push('/');
1259 result.push(':');
1260 result.push_str(name);
1261 result.push('*');
1262 continue;
1263 }
1264 if segment.starts_with('[') && segment.ends_with(']') {
1266 let name = &segment[1..segment.len() - 1];
1267 result.push('/');
1268 result.push(':');
1269 result.push_str(name);
1270 continue;
1271 }
1272 result.push('/');
1274 result.push_str(segment);
1275 }
1276
1277 if result.is_empty() {
1278 Some("/".to_string())
1279 } else {
1280 Some(result)
1281 }
1282}
1283
1284impl TypeScriptExtractor {
1285 pub fn extract_nextjs_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
1290 let route_path = match file_path_to_route_path(file_path) {
1291 Some(p) => p,
1292 None => return Vec::new(),
1293 };
1294
1295 if source.is_empty() {
1296 return Vec::new();
1297 }
1298
1299 let mut parser = Self::parser();
1300 let tree = match parser.parse(source, None) {
1301 Some(t) => t,
1302 None => return Vec::new(),
1303 };
1304 let source_bytes = source.as_bytes();
1305
1306 let query = cached_query(
1307 &NEXTJS_ROUTE_HANDLER_QUERY_CACHE,
1308 NEXTJS_ROUTE_HANDLER_QUERY,
1309 );
1310
1311 let mut cursor = QueryCursor::new();
1312 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1313
1314 let handler_name_idx = query
1315 .capture_index_for_name("handler_name")
1316 .expect("nextjs_route_handler.scm must define @handler_name capture");
1317
1318 let mut routes = Vec::new();
1319 while let Some(m) = matches.next() {
1320 for cap in m.captures {
1321 if cap.index == handler_name_idx {
1322 let name = match cap.node.utf8_text(source_bytes) {
1323 Ok(n) => n.to_string(),
1324 Err(_) => continue,
1325 };
1326 if NEXTJS_HTTP_METHODS.contains(&name.as_str()) {
1327 let line = cap.node.start_position().row + 1;
1328 routes.push(Route {
1329 http_method: name.clone(),
1330 path: route_path.clone(),
1331 handler_name: name,
1332 class_name: String::new(),
1333 file: file_path.to_string(),
1334 line,
1335 });
1336 }
1337 }
1338 }
1339 }
1340
1341 routes
1342 }
1343}
1344
1345fn production_stem(path: &str) -> Option<&str> {
1346 Path::new(path).file_stem()?.to_str()
1347}
1348
1349fn test_stem(path: &str) -> Option<&str> {
1350 let stem = Path::new(path).file_stem()?.to_str()?;
1351 stem.strip_suffix(".spec")
1352 .or_else(|| stem.strip_suffix(".test"))
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357 use super::*;
1358
1359 fn fixture(name: &str) -> String {
1360 let path = format!(
1361 "{}/tests/fixtures/typescript/observe/{}",
1362 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-typescript", ""),
1363 name
1364 );
1365 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
1366 }
1367
1368 #[test]
1370 fn exported_functions_extracted() {
1371 let source = fixture("exported_functions.ts");
1373 let extractor = TypeScriptExtractor::new();
1374
1375 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1377
1378 let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1380 let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1381 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1382 assert!(
1383 names.contains(&"findById"),
1384 "expected findById in {names:?}"
1385 );
1386 }
1387
1388 #[test]
1390 fn non_exported_function_has_flag_false() {
1391 let source = fixture("exported_functions.ts");
1393 let extractor = TypeScriptExtractor::new();
1394
1395 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1397
1398 let helper = funcs.iter().find(|f| f.name == "internalHelper");
1400 assert!(helper.is_some(), "expected internalHelper to be extracted");
1401 assert!(!helper.unwrap().is_exported);
1402 }
1403
1404 #[test]
1406 fn class_methods_with_class_name() {
1407 let source = fixture("class_methods.ts");
1409 let extractor = TypeScriptExtractor::new();
1410
1411 let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1413
1414 let controller_methods: Vec<&ProductionFunction> = funcs
1416 .iter()
1417 .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1418 .collect();
1419 let names: Vec<&str> = controller_methods.iter().map(|f| f.name.as_str()).collect();
1420 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1421 assert!(names.contains(&"create"), "expected create in {names:?}");
1422 assert!(
1423 names.contains(&"validate"),
1424 "expected validate in {names:?}"
1425 );
1426 }
1427
1428 #[test]
1430 fn exported_class_is_exported() {
1431 let source = fixture("class_methods.ts");
1433 let extractor = TypeScriptExtractor::new();
1434
1435 let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1437
1438 let controller_methods: Vec<&ProductionFunction> = funcs
1440 .iter()
1441 .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1442 .collect();
1443 assert!(
1444 controller_methods.iter().all(|f| f.is_exported),
1445 "all UsersController methods should be exported"
1446 );
1447
1448 let internal_methods: Vec<&ProductionFunction> = funcs
1450 .iter()
1451 .filter(|f| f.class_name.as_deref() == Some("InternalService"))
1452 .collect();
1453 assert!(
1454 !internal_methods.is_empty(),
1455 "expected InternalService methods"
1456 );
1457 assert!(
1458 internal_methods.iter().all(|f| !f.is_exported),
1459 "all InternalService methods should not be exported"
1460 );
1461 }
1462
1463 #[test]
1465 fn arrow_exports_extracted() {
1466 let source = fixture("arrow_exports.ts");
1468 let extractor = TypeScriptExtractor::new();
1469
1470 let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1472
1473 let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1475 let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1476 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1477 assert!(
1478 names.contains(&"findById"),
1479 "expected findById in {names:?}"
1480 );
1481 }
1482
1483 #[test]
1485 fn non_exported_arrow_flag_false() {
1486 let source = fixture("arrow_exports.ts");
1488 let extractor = TypeScriptExtractor::new();
1489
1490 let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1492
1493 let internal = funcs.iter().find(|f| f.name == "internalFn");
1495 assert!(internal.is_some(), "expected internalFn to be extracted");
1496 assert!(!internal.unwrap().is_exported);
1497 }
1498
1499 #[test]
1501 fn mixed_file_all_types() {
1502 let source = fixture("mixed.ts");
1504 let extractor = TypeScriptExtractor::new();
1505
1506 let funcs = extractor.extract_production_functions(&source, "mixed.ts");
1508
1509 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1511 assert!(names.contains(&"getUser"), "expected getUser in {names:?}");
1513 assert!(
1514 names.contains(&"createUser"),
1515 "expected createUser in {names:?}"
1516 );
1517 assert!(
1519 names.contains(&"formatName"),
1520 "expected formatName in {names:?}"
1521 );
1522 assert!(
1523 names.contains(&"validateInput"),
1524 "expected validateInput in {names:?}"
1525 );
1526
1527 let get_user = funcs.iter().find(|f| f.name == "getUser").unwrap();
1529 assert!(get_user.is_exported);
1530 let format_name = funcs.iter().find(|f| f.name == "formatName").unwrap();
1531 assert!(!format_name.is_exported);
1532
1533 let find_all = funcs
1535 .iter()
1536 .find(|f| f.name == "findAll" && f.class_name.is_some())
1537 .unwrap();
1538 assert_eq!(find_all.class_name.as_deref(), Some("UserService"));
1539 assert!(find_all.is_exported);
1540
1541 let transform = funcs.iter().find(|f| f.name == "transform").unwrap();
1542 assert_eq!(transform.class_name.as_deref(), Some("PrivateHelper"));
1543 assert!(!transform.is_exported);
1544 }
1545
1546 #[test]
1548 fn decorated_methods_extracted() {
1549 let source = fixture("nestjs_controller.ts");
1551 let extractor = TypeScriptExtractor::new();
1552
1553 let funcs = extractor.extract_production_functions(&source, "nestjs_controller.ts");
1555
1556 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1558 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1559 assert!(names.contains(&"create"), "expected create in {names:?}");
1560 assert!(names.contains(&"remove"), "expected remove in {names:?}");
1561
1562 for func in &funcs {
1563 assert_eq!(func.class_name.as_deref(), Some("UsersController"));
1564 assert!(func.is_exported);
1565 }
1566 }
1567
1568 #[test]
1570 fn line_numbers_correct() {
1571 let source = fixture("exported_functions.ts");
1573 let extractor = TypeScriptExtractor::new();
1574
1575 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1577
1578 let find_all = funcs.iter().find(|f| f.name == "findAll").unwrap();
1580 assert_eq!(find_all.line, 1, "findAll should be on line 1");
1581
1582 let find_by_id = funcs.iter().find(|f| f.name == "findById").unwrap();
1583 assert_eq!(find_by_id.line, 5, "findById should be on line 5");
1584
1585 let helper = funcs.iter().find(|f| f.name == "internalHelper").unwrap();
1586 assert_eq!(helper.line, 9, "internalHelper should be on line 9");
1587 }
1588
1589 #[test]
1591 fn empty_source_returns_empty() {
1592 let extractor = TypeScriptExtractor::new();
1594
1595 let funcs = extractor.extract_production_functions("", "empty.ts");
1597
1598 assert!(funcs.is_empty());
1600 }
1601
1602 #[test]
1606 fn basic_controller_routes() {
1607 let source = fixture("nestjs_controller.ts");
1609 let extractor = TypeScriptExtractor::new();
1610
1611 let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1613
1614 assert_eq!(routes.len(), 3, "expected 3 routes, got {routes:?}");
1616 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1617 assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
1618 assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
1619 assert!(
1620 methods.contains(&"DELETE"),
1621 "expected DELETE in {methods:?}"
1622 );
1623
1624 let get_route = routes.iter().find(|r| r.http_method == "GET").unwrap();
1625 assert_eq!(get_route.path, "/users");
1626
1627 let delete_route = routes.iter().find(|r| r.http_method == "DELETE").unwrap();
1628 assert_eq!(delete_route.path, "/users/:id");
1629 }
1630
1631 #[test]
1633 fn route_path_combination() {
1634 let source = fixture("nestjs_routes_advanced.ts");
1636 let extractor = TypeScriptExtractor::new();
1637
1638 let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1640
1641 let active = routes
1643 .iter()
1644 .find(|r| r.handler_name == "findActive")
1645 .unwrap();
1646 assert_eq!(active.http_method, "GET");
1647 assert_eq!(active.path, "/api/v1/users/active");
1648 }
1649
1650 #[test]
1652 fn controller_no_path() {
1653 let source = fixture("nestjs_empty_controller.ts");
1655 let extractor = TypeScriptExtractor::new();
1656
1657 let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1659
1660 assert_eq!(routes.len(), 1, "expected 1 route, got {routes:?}");
1662 assert_eq!(routes[0].http_method, "GET");
1663 assert_eq!(routes[0].path, "/health");
1664 }
1665
1666 #[test]
1668 fn method_without_route_decorator() {
1669 let source = fixture("nestjs_empty_controller.ts");
1671 let extractor = TypeScriptExtractor::new();
1672
1673 let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1675
1676 let helper = routes.iter().find(|r| r.handler_name == "helperMethod");
1678 assert!(helper.is_none(), "helperMethod should not be a route");
1679 }
1680
1681 #[test]
1683 fn all_http_methods() {
1684 let source = fixture("nestjs_routes_advanced.ts");
1686 let extractor = TypeScriptExtractor::new();
1687
1688 let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1690
1691 assert_eq!(routes.len(), 9, "expected 9 routes, got {routes:?}");
1693 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1694 assert!(methods.contains(&"GET"));
1695 assert!(methods.contains(&"POST"));
1696 assert!(methods.contains(&"PUT"));
1697 assert!(methods.contains(&"PATCH"));
1698 assert!(methods.contains(&"DELETE"));
1699 assert!(methods.contains(&"HEAD"));
1700 assert!(methods.contains(&"OPTIONS"));
1701 }
1702
1703 #[test]
1705 fn use_guards_decorator() {
1706 let source = fixture("nestjs_guards_pipes.ts");
1708 let extractor = TypeScriptExtractor::new();
1709
1710 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1712
1713 let guards: Vec<&DecoratorInfo> = decorators
1715 .iter()
1716 .filter(|d| d.name == "UseGuards")
1717 .collect();
1718 assert!(!guards.is_empty(), "expected UseGuards decorators");
1719 let auth_guard = guards
1720 .iter()
1721 .find(|d| d.arguments.contains(&"AuthGuard".to_string()));
1722 assert!(auth_guard.is_some(), "expected AuthGuard argument");
1723 }
1724
1725 #[test]
1727 fn multiple_decorators_on_method() {
1728 let source = fixture("nestjs_controller.ts");
1730 let extractor = TypeScriptExtractor::new();
1731
1732 let decorators = extractor.extract_decorators(&source, "nestjs_controller.ts");
1734
1735 let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1737 assert!(
1738 names.contains(&"UseGuards"),
1739 "expected UseGuards in {names:?}"
1740 );
1741 assert!(
1742 !names.contains(&"Delete"),
1743 "Delete should not be in decorators"
1744 );
1745 }
1746
1747 #[test]
1749 fn class_validator_on_dto() {
1750 let source = fixture("nestjs_dto_validation.ts");
1752 let extractor = TypeScriptExtractor::new();
1753
1754 let decorators = extractor.extract_decorators(&source, "nestjs_dto_validation.ts");
1756
1757 let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1759 assert!(names.contains(&"IsEmail"), "expected IsEmail in {names:?}");
1760 assert!(
1761 names.contains(&"IsNotEmpty"),
1762 "expected IsNotEmpty in {names:?}"
1763 );
1764 }
1765
1766 #[test]
1768 fn use_pipes_decorator() {
1769 let source = fixture("nestjs_guards_pipes.ts");
1771 let extractor = TypeScriptExtractor::new();
1772
1773 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1775
1776 let pipes: Vec<&DecoratorInfo> =
1778 decorators.iter().filter(|d| d.name == "UsePipes").collect();
1779 assert!(!pipes.is_empty(), "expected UsePipes decorators");
1780 assert!(pipes[0].arguments.contains(&"ValidationPipe".to_string()));
1781 }
1782
1783 #[test]
1785 fn empty_source_returns_empty_routes_and_decorators() {
1786 let extractor = TypeScriptExtractor::new();
1788
1789 let routes = extractor.extract_routes("", "empty.ts");
1791 let decorators = extractor.extract_decorators("", "empty.ts");
1792
1793 assert!(routes.is_empty());
1795 assert!(decorators.is_empty());
1796 }
1797
1798 #[test]
1800 fn non_nestjs_class_ignored() {
1801 let source = fixture("class_methods.ts");
1803 let extractor = TypeScriptExtractor::new();
1804
1805 let routes = extractor.extract_routes(&source, "class_methods.ts");
1807
1808 assert!(routes.is_empty(), "expected no routes from plain class");
1810 }
1811
1812 #[test]
1814 fn route_handler_and_class_name() {
1815 let source = fixture("nestjs_controller.ts");
1817 let extractor = TypeScriptExtractor::new();
1818
1819 let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1821
1822 let handlers: Vec<&str> = routes.iter().map(|r| r.handler_name.as_str()).collect();
1824 assert!(handlers.contains(&"findAll"));
1825 assert!(handlers.contains(&"create"));
1826 assert!(handlers.contains(&"remove"));
1827 for route in &routes {
1828 assert_eq!(route.class_name, "UsersController");
1829 }
1830 }
1831
1832 #[test]
1834 fn class_level_use_guards() {
1835 let source = fixture("nestjs_guards_pipes.ts");
1837 let extractor = TypeScriptExtractor::new();
1838
1839 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1841
1842 let class_guards: Vec<&DecoratorInfo> = decorators
1844 .iter()
1845 .filter(|d| {
1846 d.name == "UseGuards"
1847 && d.target_name == "ProtectedController"
1848 && d.class_name == "ProtectedController"
1849 })
1850 .collect();
1851 assert!(
1852 !class_guards.is_empty(),
1853 "expected class-level UseGuards, got {decorators:?}"
1854 );
1855 assert!(class_guards[0]
1856 .arguments
1857 .contains(&"JwtAuthGuard".to_string()));
1858 }
1859
1860 #[test]
1862 fn dynamic_controller_path() {
1863 let source = fixture("nestjs_dynamic_routes.ts");
1865 let extractor = TypeScriptExtractor::new();
1866
1867 let routes = extractor.extract_routes(&source, "nestjs_dynamic_routes.ts");
1869
1870 assert_eq!(routes.len(), 1);
1872 assert!(
1873 routes[0].path.contains("<dynamic>"),
1874 "expected <dynamic> in path, got {:?}",
1875 routes[0].path
1876 );
1877 }
1878
1879 #[test]
1881 fn abstract_class_methods_extracted() {
1882 let source = fixture("abstract_class.ts");
1884 let extractor = TypeScriptExtractor::new();
1885
1886 let funcs = extractor.extract_production_functions(&source, "abstract_class.ts");
1888
1889 let validate = funcs.iter().find(|f| f.name == "validate");
1891 assert!(validate.is_some(), "expected validate to be extracted");
1892 let validate = validate.unwrap();
1893 assert_eq!(validate.class_name.as_deref(), Some("BaseService"));
1894 assert!(validate.is_exported);
1895
1896 let process = funcs.iter().find(|f| f.name == "process");
1897 assert!(process.is_some(), "expected process to be extracted");
1898 let process = process.unwrap();
1899 assert_eq!(process.class_name.as_deref(), Some("InternalBase"));
1900 assert!(!process.is_exported);
1901 }
1902
1903 #[test]
1904 fn basic_spec_mapping() {
1905 let extractor = TypeScriptExtractor::new();
1907 let production_files = vec!["src/users.service.ts".to_string()];
1908 let test_files = vec!["src/users.service.spec.ts".to_string()];
1909
1910 let mappings = extractor.map_test_files(&production_files, &test_files);
1912
1913 assert_eq!(
1915 mappings,
1916 vec![FileMapping {
1917 production_file: "src/users.service.ts".to_string(),
1918 test_files: vec!["src/users.service.spec.ts".to_string()],
1919 strategy: MappingStrategy::FileNameConvention,
1920 }]
1921 );
1922 }
1923
1924 #[test]
1925 fn test_suffix_mapping() {
1926 let extractor = TypeScriptExtractor::new();
1928 let production_files = vec!["src/utils.ts".to_string()];
1929 let test_files = vec!["src/utils.test.ts".to_string()];
1930
1931 let mappings = extractor.map_test_files(&production_files, &test_files);
1933
1934 assert_eq!(
1936 mappings[0].test_files,
1937 vec!["src/utils.test.ts".to_string()]
1938 );
1939 }
1940
1941 #[test]
1942 fn multiple_test_files() {
1943 let extractor = TypeScriptExtractor::new();
1945 let production_files = vec!["src/app.ts".to_string()];
1946 let test_files = vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()];
1947
1948 let mappings = extractor.map_test_files(&production_files, &test_files);
1950
1951 assert_eq!(
1953 mappings[0].test_files,
1954 vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()]
1955 );
1956 }
1957
1958 #[test]
1959 fn nestjs_controller() {
1960 let extractor = TypeScriptExtractor::new();
1962 let production_files = vec!["src/users/users.controller.ts".to_string()];
1963 let test_files = vec!["src/users/users.controller.spec.ts".to_string()];
1964
1965 let mappings = extractor.map_test_files(&production_files, &test_files);
1967
1968 assert_eq!(
1970 mappings[0].test_files,
1971 vec!["src/users/users.controller.spec.ts".to_string()]
1972 );
1973 }
1974
1975 #[test]
1976 fn no_matching_test() {
1977 let extractor = TypeScriptExtractor::new();
1979 let production_files = vec!["src/orphan.ts".to_string()];
1980 let test_files = vec!["src/other.spec.ts".to_string()];
1981
1982 let mappings = extractor.map_test_files(&production_files, &test_files);
1984
1985 assert_eq!(mappings[0].test_files, Vec::<String>::new());
1987 }
1988
1989 #[test]
1990 fn different_directory_no_match() {
1991 let extractor = TypeScriptExtractor::new();
1993 let production_files = vec!["src/users.ts".to_string()];
1994 let test_files = vec!["test/users.spec.ts".to_string()];
1995
1996 let mappings = extractor.map_test_files(&production_files, &test_files);
1998
1999 assert_eq!(mappings[0].test_files, Vec::<String>::new());
2001 }
2002
2003 #[test]
2004 fn empty_input() {
2005 let extractor = TypeScriptExtractor::new();
2007
2008 let mappings = extractor.map_test_files(&[], &[]);
2010
2011 assert!(mappings.is_empty());
2013 }
2014
2015 #[test]
2016 fn tsx_files() {
2017 let extractor = TypeScriptExtractor::new();
2019 let production_files = vec!["src/App.tsx".to_string()];
2020 let test_files = vec!["src/App.test.tsx".to_string()];
2021
2022 let mappings = extractor.map_test_files(&production_files, &test_files);
2024
2025 assert_eq!(mappings[0].test_files, vec!["src/App.test.tsx".to_string()]);
2027 }
2028
2029 #[test]
2030 fn unmatched_test_ignored() {
2031 let extractor = TypeScriptExtractor::new();
2033 let production_files = vec!["src/a.ts".to_string()];
2034 let test_files = vec!["src/a.spec.ts".to_string(), "src/b.spec.ts".to_string()];
2035
2036 let mappings = extractor.map_test_files(&production_files, &test_files);
2038
2039 assert_eq!(mappings.len(), 1);
2041 assert_eq!(mappings[0].test_files, vec!["src/a.spec.ts".to_string()]);
2042 }
2043
2044 #[test]
2045 fn stem_extraction() {
2046 assert_eq!(
2050 production_stem("src/users.service.ts"),
2051 Some("users.service")
2052 );
2053 assert_eq!(production_stem("src/App.tsx"), Some("App"));
2054 assert_eq!(
2055 test_stem("src/users.service.spec.ts"),
2056 Some("users.service")
2057 );
2058 assert_eq!(test_stem("src/utils.test.ts"), Some("utils"));
2059 assert_eq!(test_stem("src/App.test.tsx"), Some("App"));
2060 assert_eq!(test_stem("src/invalid.ts"), None);
2061 }
2062
2063 #[test]
2067 fn im1_named_import_symbol_and_specifier() {
2068 let source = fixture("import_named.ts");
2070 let extractor = TypeScriptExtractor::new();
2071
2072 let imports = extractor.extract_imports(&source, "import_named.ts");
2074
2075 let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2077 assert!(
2078 found.is_some(),
2079 "expected UsersController in imports: {imports:?}"
2080 );
2081 assert_eq!(
2082 found.unwrap().module_specifier,
2083 "./users.controller",
2084 "wrong specifier"
2085 );
2086 }
2087
2088 #[test]
2090 fn im2_multiple_named_imports() {
2091 let source = fixture("import_mixed.ts");
2093 let extractor = TypeScriptExtractor::new();
2094
2095 let imports = extractor.extract_imports(&source, "import_mixed.ts");
2097
2098 let from_module: Vec<&ImportMapping> = imports
2100 .iter()
2101 .filter(|i| i.module_specifier == "./module")
2102 .collect();
2103 let symbols: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
2104 assert!(symbols.contains(&"A"), "expected A in symbols: {symbols:?}");
2105 assert!(symbols.contains(&"B"), "expected B in symbols: {symbols:?}");
2106 assert!(
2108 from_module.len() >= 2,
2109 "expected at least 2 imports from ./module, got {from_module:?}"
2110 );
2111 }
2112
2113 #[test]
2115 fn im3_alias_import_original_name() {
2116 let source = fixture("import_mixed.ts");
2118 let extractor = TypeScriptExtractor::new();
2119
2120 let imports = extractor.extract_imports(&source, "import_mixed.ts");
2122
2123 let a_count = imports.iter().filter(|i| i.symbol_name == "A").count();
2126 assert!(
2127 a_count >= 1,
2128 "expected at least one import with symbol_name 'A', got: {imports:?}"
2129 );
2130 }
2131
2132 #[test]
2134 fn im4_default_import() {
2135 let source = fixture("import_default.ts");
2137 let extractor = TypeScriptExtractor::new();
2138
2139 let imports = extractor.extract_imports(&source, "import_default.ts");
2141
2142 assert_eq!(imports.len(), 1, "expected 1 import, got {imports:?}");
2144 assert_eq!(imports[0].symbol_name, "UsersController");
2145 assert_eq!(imports[0].module_specifier, "./users.controller");
2146 }
2147
2148 #[test]
2150 fn im5_npm_package_excluded() {
2151 let source = "import { Test } from '@nestjs/testing';";
2153 let extractor = TypeScriptExtractor::new();
2154
2155 let imports = extractor.extract_imports(source, "test.ts");
2157
2158 assert!(imports.is_empty(), "expected empty vec, got {imports:?}");
2160 }
2161
2162 #[test]
2164 fn im6_relative_parent_path() {
2165 let source = fixture("import_named.ts");
2167 let extractor = TypeScriptExtractor::new();
2168
2169 let imports = extractor.extract_imports(&source, "import_named.ts");
2171
2172 let found = imports
2174 .iter()
2175 .find(|i| i.module_specifier == "../services/s.service");
2176 assert!(
2177 found.is_some(),
2178 "expected ../services/s.service in imports: {imports:?}"
2179 );
2180 assert_eq!(found.unwrap().symbol_name, "S");
2181 }
2182
2183 #[test]
2185 fn im7_empty_source_returns_empty() {
2186 let extractor = TypeScriptExtractor::new();
2188
2189 let imports = extractor.extract_imports("", "empty.ts");
2191
2192 assert!(imports.is_empty());
2194 }
2195
2196 #[test]
2198 fn im8_namespace_import() {
2199 let source = fixture("import_namespace.ts");
2201 let extractor = TypeScriptExtractor::new();
2202
2203 let imports = extractor.extract_imports(&source, "import_namespace.ts");
2205
2206 let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2208 assert!(
2209 found.is_some(),
2210 "expected UsersController in imports: {imports:?}"
2211 );
2212 assert_eq!(found.unwrap().module_specifier, "./users.controller");
2213
2214 let helpers = imports.iter().find(|i| i.symbol_name == "helpers");
2216 assert!(
2217 helpers.is_some(),
2218 "expected helpers in imports: {imports:?}"
2219 );
2220 assert_eq!(helpers.unwrap().module_specifier, "../utils/helpers");
2221
2222 let express = imports.iter().find(|i| i.symbol_name == "express");
2224 assert!(
2225 express.is_none(),
2226 "npm package should be excluded: {imports:?}"
2227 );
2228 }
2229
2230 #[test]
2232 fn im9_type_only_import_excluded() {
2233 let source = fixture("import_type_only.ts");
2235 let extractor = TypeScriptExtractor::new();
2236
2237 let imports = extractor.extract_imports(&source, "import_type_only.ts");
2239
2240 let user_service = imports.iter().find(|i| i.symbol_name == "UserService");
2242 assert!(
2243 user_service.is_none(),
2244 "type-only import should be excluded: {imports:?}"
2245 );
2246
2247 let create_dto = imports.iter().find(|i| i.symbol_name == "CreateUserDto");
2249 assert!(
2250 create_dto.is_none(),
2251 "inline type modifier import should be excluded: {imports:?}"
2252 );
2253
2254 let controller = imports.iter().find(|i| i.symbol_name == "UsersController");
2256 assert!(
2257 controller.is_some(),
2258 "normal import should remain: {imports:?}"
2259 );
2260 assert_eq!(controller.unwrap().module_specifier, "./users.controller");
2261 }
2262
2263 #[test]
2267 fn rp1_resolve_ts_without_extension() {
2268 use std::io::Write as IoWrite;
2269 use tempfile::TempDir;
2270
2271 let dir = TempDir::new().unwrap();
2273 let src_dir = dir.path().join("src");
2274 std::fs::create_dir_all(&src_dir).unwrap();
2275 let target = src_dir.join("users.controller.ts");
2276 std::fs::File::create(&target).unwrap();
2277
2278 let from_file = src_dir.join("users.controller.spec.ts");
2279
2280 let result = resolve_import_path("./users.controller", &from_file, dir.path());
2282
2283 assert!(
2285 result.is_some(),
2286 "expected Some for existing .ts file, got None"
2287 );
2288 let resolved = result.unwrap();
2289 assert!(
2290 resolved.ends_with("users.controller.ts"),
2291 "expected path ending with users.controller.ts, got {resolved}"
2292 );
2293 }
2294
2295 #[test]
2297 fn rp2_resolve_ts_with_extension() {
2298 use tempfile::TempDir;
2299
2300 let dir = TempDir::new().unwrap();
2302 let src_dir = dir.path().join("src");
2303 std::fs::create_dir_all(&src_dir).unwrap();
2304 let target = src_dir.join("users.controller.ts");
2305 std::fs::File::create(&target).unwrap();
2306
2307 let from_file = src_dir.join("users.controller.spec.ts");
2308
2309 let result = resolve_import_path("./users.controller.ts", &from_file, dir.path());
2311
2312 assert!(
2314 result.is_some(),
2315 "expected Some for existing file with explicit .ts extension"
2316 );
2317 }
2318
2319 #[test]
2321 fn rp3_nonexistent_file_returns_none() {
2322 use tempfile::TempDir;
2323
2324 let dir = TempDir::new().unwrap();
2326 let src_dir = dir.path().join("src");
2327 std::fs::create_dir_all(&src_dir).unwrap();
2328 let from_file = src_dir.join("some.spec.ts");
2329
2330 let result = resolve_import_path("./nonexistent", &from_file, dir.path());
2332
2333 assert!(result.is_none(), "expected None for nonexistent file");
2335 }
2336
2337 #[test]
2339 fn rp4_outside_scan_root_returns_none() {
2340 use tempfile::TempDir;
2341
2342 let dir = TempDir::new().unwrap();
2344 let src_dir = dir.path().join("src");
2345 std::fs::create_dir_all(&src_dir).unwrap();
2346 let from_file = src_dir.join("some.spec.ts");
2347
2348 let result = resolve_import_path("../../outside", &from_file, dir.path());
2350
2351 assert!(result.is_none(), "expected None for path outside scan_root");
2353 }
2354
2355 #[test]
2357 fn rp5_resolve_tsx_without_extension() {
2358 use tempfile::TempDir;
2359
2360 let dir = TempDir::new().unwrap();
2362 let src_dir = dir.path().join("src");
2363 std::fs::create_dir_all(&src_dir).unwrap();
2364 let target = src_dir.join("App.tsx");
2365 std::fs::File::create(&target).unwrap();
2366
2367 let from_file = src_dir.join("App.test.tsx");
2368
2369 let result = resolve_import_path("./App", &from_file, dir.path());
2371
2372 assert!(
2374 result.is_some(),
2375 "expected Some for existing .tsx file, got None"
2376 );
2377 let resolved = result.unwrap();
2378 assert!(
2379 resolved.ends_with("App.tsx"),
2380 "expected path ending with App.tsx, got {resolved}"
2381 );
2382 }
2383
2384 #[test]
2388 fn mt1_layer1_and_layer2_both_matched() {
2389 use tempfile::TempDir;
2390
2391 let dir = TempDir::new().unwrap();
2396 let src_dir = dir.path().join("src");
2397 let test_dir = dir.path().join("test");
2398 std::fs::create_dir_all(&src_dir).unwrap();
2399 std::fs::create_dir_all(&test_dir).unwrap();
2400
2401 let prod_path = src_dir.join("users.controller.ts");
2402 std::fs::File::create(&prod_path).unwrap();
2403
2404 let layer1_test = src_dir.join("users.controller.spec.ts");
2405 let layer1_source = r#"// Layer 1 spec
2406describe('UsersController', () => {});
2407"#;
2408
2409 let layer2_test = test_dir.join("users.controller.spec.ts");
2410 let layer2_source = format!(
2411 "import {{ UsersController }} from '../src/users.controller';\ndescribe('cross', () => {{}});\n"
2412 );
2413
2414 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2415 let mut test_sources = HashMap::new();
2416 test_sources.insert(
2417 layer1_test.to_string_lossy().into_owned(),
2418 layer1_source.to_string(),
2419 );
2420 test_sources.insert(
2421 layer2_test.to_string_lossy().into_owned(),
2422 layer2_source.to_string(),
2423 );
2424
2425 let extractor = TypeScriptExtractor::new();
2426
2427 let mappings = extractor.map_test_files_with_imports(
2429 &production_files,
2430 &test_sources,
2431 dir.path(),
2432 false,
2433 );
2434
2435 assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2437 let mapping = &mappings[0];
2438 assert!(
2439 mapping
2440 .test_files
2441 .contains(&layer1_test.to_string_lossy().into_owned()),
2442 "expected Layer 1 test in mapping, got {:?}",
2443 mapping.test_files
2444 );
2445 assert!(
2446 mapping
2447 .test_files
2448 .contains(&layer2_test.to_string_lossy().into_owned()),
2449 "expected Layer 2 test in mapping, got {:?}",
2450 mapping.test_files
2451 );
2452 }
2453
2454 #[test]
2456 fn mt2_cross_directory_import_tracing() {
2457 use tempfile::TempDir;
2458
2459 let dir = TempDir::new().unwrap();
2464 let src_dir = dir.path().join("src").join("services");
2465 let test_dir = dir.path().join("test");
2466 std::fs::create_dir_all(&src_dir).unwrap();
2467 std::fs::create_dir_all(&test_dir).unwrap();
2468
2469 let prod_path = src_dir.join("user.service.ts");
2470 std::fs::File::create(&prod_path).unwrap();
2471
2472 let test_path = test_dir.join("user.service.spec.ts");
2473 let test_source = format!(
2474 "import {{ UserService }} from '../src/services/user.service';\ndescribe('cross', () => {{}});\n"
2475 );
2476
2477 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2478 let mut test_sources = HashMap::new();
2479 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2480
2481 let extractor = TypeScriptExtractor::new();
2482
2483 let mappings = extractor.map_test_files_with_imports(
2485 &production_files,
2486 &test_sources,
2487 dir.path(),
2488 false,
2489 );
2490
2491 assert_eq!(mappings.len(), 1);
2493 let mapping = &mappings[0];
2494 assert!(
2495 mapping
2496 .test_files
2497 .contains(&test_path.to_string_lossy().into_owned()),
2498 "expected test in mapping via ImportTracing, got {:?}",
2499 mapping.test_files
2500 );
2501 assert_eq!(
2502 mapping.strategy,
2503 MappingStrategy::ImportTracing,
2504 "expected ImportTracing strategy"
2505 );
2506 }
2507
2508 #[test]
2510 fn mt3_npm_only_import_not_matched() {
2511 use tempfile::TempDir;
2512
2513 let dir = TempDir::new().unwrap();
2517 let src_dir = dir.path().join("src");
2518 let test_dir = dir.path().join("test");
2519 std::fs::create_dir_all(&src_dir).unwrap();
2520 std::fs::create_dir_all(&test_dir).unwrap();
2521
2522 let prod_path = src_dir.join("users.controller.ts");
2523 std::fs::File::create(&prod_path).unwrap();
2524
2525 let test_path = test_dir.join("something.spec.ts");
2526 let test_source =
2527 "import { Test } from '@nestjs/testing';\ndescribe('npm', () => {});\n".to_string();
2528
2529 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2530 let mut test_sources = HashMap::new();
2531 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2532
2533 let extractor = TypeScriptExtractor::new();
2534
2535 let mappings = extractor.map_test_files_with_imports(
2537 &production_files,
2538 &test_sources,
2539 dir.path(),
2540 false,
2541 );
2542
2543 assert_eq!(mappings.len(), 1);
2545 assert!(
2546 mappings[0].test_files.is_empty(),
2547 "expected no test files for npm-only import, got {:?}",
2548 mappings[0].test_files
2549 );
2550 }
2551
2552 #[test]
2554 fn mt4_one_test_imports_multiple_productions() {
2555 use tempfile::TempDir;
2556
2557 let dir = TempDir::new().unwrap();
2562 let src_dir = dir.path().join("src");
2563 let test_dir = dir.path().join("test");
2564 std::fs::create_dir_all(&src_dir).unwrap();
2565 std::fs::create_dir_all(&test_dir).unwrap();
2566
2567 let prod_a = src_dir.join("a.service.ts");
2568 let prod_b = src_dir.join("b.service.ts");
2569 std::fs::File::create(&prod_a).unwrap();
2570 std::fs::File::create(&prod_b).unwrap();
2571
2572 let test_path = test_dir.join("ab.spec.ts");
2573 let test_source = format!(
2574 "import {{ A }} from '../src/a.service';\nimport {{ B }} from '../src/b.service';\ndescribe('ab', () => {{}});\n"
2575 );
2576
2577 let production_files = vec![
2578 prod_a.to_string_lossy().into_owned(),
2579 prod_b.to_string_lossy().into_owned(),
2580 ];
2581 let mut test_sources = HashMap::new();
2582 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2583
2584 let extractor = TypeScriptExtractor::new();
2585
2586 let mappings = extractor.map_test_files_with_imports(
2588 &production_files,
2589 &test_sources,
2590 dir.path(),
2591 false,
2592 );
2593
2594 assert_eq!(mappings.len(), 2, "expected 2 FileMappings (A and B)");
2596 for mapping in &mappings {
2597 assert!(
2598 mapping
2599 .test_files
2600 .contains(&test_path.to_string_lossy().into_owned()),
2601 "expected ab.spec.ts mapped to {}, got {:?}",
2602 mapping.production_file,
2603 mapping.test_files
2604 );
2605 }
2606 }
2607
2608 #[test]
2610 fn is_non_sut_helper_constants_ts() {
2611 assert!(is_non_sut_helper("src/constants.ts", false));
2612 }
2613
2614 #[test]
2616 fn is_non_sut_helper_index_ts() {
2617 assert!(is_non_sut_helper("src/index.ts", false));
2618 }
2619
2620 #[test]
2622 fn is_non_sut_helper_extension_variants() {
2623 assert!(is_non_sut_helper("src/constants.js", false));
2624 assert!(is_non_sut_helper("src/constants.tsx", false));
2625 assert!(is_non_sut_helper("src/constants.jsx", false));
2626 assert!(is_non_sut_helper("src/index.js", false));
2627 assert!(is_non_sut_helper("src/index.tsx", false));
2628 assert!(is_non_sut_helper("src/index.jsx", false));
2629 }
2630
2631 #[test]
2633 fn is_non_sut_helper_rejects_non_helpers() {
2634 assert!(!is_non_sut_helper("src/my-constants.ts", false));
2635 assert!(!is_non_sut_helper("src/service.ts", false));
2636 assert!(!is_non_sut_helper("src/app.constants.ts", false));
2637 assert!(!is_non_sut_helper("src/constants-v2.ts", false));
2638 }
2639
2640 #[test]
2642 fn is_non_sut_helper_rejects_directory_name() {
2643 assert!(!is_non_sut_helper("constants/app.ts", false));
2644 assert!(!is_non_sut_helper("index/service.ts", false));
2645 }
2646
2647 #[test]
2649 fn is_non_sut_helper_enum_ts() {
2650 let path = "src/enums/request-method.enum.ts";
2652 assert!(is_non_sut_helper(path, false));
2655 }
2656
2657 #[test]
2659 fn is_non_sut_helper_interface_ts() {
2660 let path = "src/interfaces/middleware-configuration.interface.ts";
2662 assert!(is_non_sut_helper(path, false));
2665 }
2666
2667 #[test]
2669 fn is_non_sut_helper_exception_ts() {
2670 let path = "src/errors/unknown-module.exception.ts";
2672 assert!(is_non_sut_helper(path, false));
2675 }
2676
2677 #[test]
2679 fn is_non_sut_helper_test_path() {
2680 let path = "packages/core/test/utils/string.cleaner.ts";
2682 assert!(is_non_sut_helper(path, false));
2685 assert!(is_non_sut_helper(
2687 "packages/core/__tests__/utils/helper.ts",
2688 false
2689 ));
2690 assert!(!is_non_sut_helper(
2692 "/home/user/projects/contest/src/service.ts",
2693 false
2694 ));
2695 assert!(!is_non_sut_helper("src/latest/foo.ts", false));
2696 }
2697
2698 #[test]
2700 fn is_non_sut_helper_rejects_plain_filename() {
2701 assert!(!is_non_sut_helper("src/enum.ts", false));
2706 assert!(!is_non_sut_helper("src/interface.ts", false));
2707 assert!(!is_non_sut_helper("src/exception.ts", false));
2708 }
2709
2710 #[test]
2712 fn is_non_sut_helper_enum_interface_extension_variants() {
2713 assert!(is_non_sut_helper("src/foo.enum.js", false));
2717 assert!(is_non_sut_helper("src/bar.interface.tsx", false));
2718 }
2719
2720 #[test]
2724 fn is_type_definition_file_enum() {
2725 assert!(is_type_definition_file("src/foo.enum.ts"));
2726 }
2727
2728 #[test]
2730 fn is_type_definition_file_interface() {
2731 assert!(is_type_definition_file("src/bar.interface.ts"));
2732 }
2733
2734 #[test]
2736 fn is_type_definition_file_exception() {
2737 assert!(is_type_definition_file("src/baz.exception.ts"));
2738 }
2739
2740 #[test]
2742 fn is_type_definition_file_service() {
2743 assert!(!is_type_definition_file("src/service.ts"));
2744 }
2745
2746 #[test]
2748 fn is_type_definition_file_constants() {
2749 assert!(!is_type_definition_file("src/constants.ts"));
2751 }
2752
2753 #[test]
2757 fn is_non_sut_helper_production_enum_bypassed() {
2758 assert!(!is_non_sut_helper("src/foo.enum.ts", true));
2762 }
2763
2764 #[test]
2766 fn is_non_sut_helper_unknown_enum_filtered() {
2767 assert!(is_non_sut_helper("src/foo.enum.ts", false));
2771 }
2772
2773 #[test]
2775 fn is_non_sut_helper_constants_always_filtered() {
2776 assert!(is_non_sut_helper("src/constants.ts", true));
2780 }
2781
2782 #[test]
2786 fn barrel_01_resolve_directory_to_index_ts() {
2787 use tempfile::TempDir;
2788
2789 let dir = TempDir::new().unwrap();
2791 let decorators_dir = dir.path().join("decorators");
2792 std::fs::create_dir_all(&decorators_dir).unwrap();
2793 std::fs::File::create(decorators_dir.join("index.ts")).unwrap();
2794
2795 let src_dir = dir.path().join("src");
2797 std::fs::create_dir_all(&src_dir).unwrap();
2798 let from_file = src_dir.join("some.spec.ts");
2799
2800 let result = resolve_import_path("../decorators", &from_file, dir.path());
2802
2803 assert!(
2805 result.is_some(),
2806 "expected Some for directory with index.ts, got None"
2807 );
2808 let resolved = result.unwrap();
2809 assert!(
2810 resolved.ends_with("decorators/index.ts"),
2811 "expected path ending with decorators/index.ts, got {resolved}"
2812 );
2813 }
2814
2815 #[test]
2817 fn barrel_02_re_export_named_capture() {
2818 let source = "export { Foo } from './foo';";
2820 let extractor = TypeScriptExtractor::new();
2821
2822 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2824
2825 assert_eq!(
2827 re_exports.len(),
2828 1,
2829 "expected 1 re-export, got {re_exports:?}"
2830 );
2831 let re = &re_exports[0];
2832 assert_eq!(re.symbols, vec!["Foo".to_string()]);
2833 assert_eq!(re.from_specifier, "./foo");
2834 assert!(!re.wildcard);
2835 }
2836
2837 #[test]
2839 fn barrel_03_re_export_wildcard_capture() {
2840 let source = "export * from './foo';";
2842 let extractor = TypeScriptExtractor::new();
2843
2844 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2846
2847 assert_eq!(
2849 re_exports.len(),
2850 1,
2851 "expected 1 re-export, got {re_exports:?}"
2852 );
2853 let re = &re_exports[0];
2854 assert!(re.wildcard, "expected wildcard=true");
2855 assert_eq!(re.from_specifier, "./foo");
2856 }
2857
2858 #[test]
2860 fn barrel_04_resolve_barrel_exports_one_hop() {
2861 use tempfile::TempDir;
2862
2863 let dir = TempDir::new().unwrap();
2867 let index_path = dir.path().join("index.ts");
2868 std::fs::write(&index_path, "export { Foo } from './foo';").unwrap();
2869 let foo_path = dir.path().join("foo.ts");
2870 std::fs::File::create(&foo_path).unwrap();
2871
2872 let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2874
2875 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2877 assert!(
2878 result[0].ends_with("foo.ts"),
2879 "expected foo.ts, got {:?}",
2880 result[0]
2881 );
2882 }
2883
2884 #[test]
2886 fn barrel_05_resolve_barrel_exports_two_hops() {
2887 use tempfile::TempDir;
2888
2889 let dir = TempDir::new().unwrap();
2894 let index_path = dir.path().join("index.ts");
2895 std::fs::write(&index_path, "export * from './core';").unwrap();
2896
2897 let core_dir = dir.path().join("core");
2898 std::fs::create_dir_all(&core_dir).unwrap();
2899 std::fs::write(core_dir.join("index.ts"), "export { Foo } from './foo';").unwrap();
2900 let foo_path = core_dir.join("foo.ts");
2901 std::fs::File::create(&foo_path).unwrap();
2902
2903 let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2905
2906 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2908 assert!(
2909 result[0].ends_with("foo.ts"),
2910 "expected foo.ts, got {:?}",
2911 result[0]
2912 );
2913 }
2914
2915 #[test]
2917 fn barrel_06_circular_barrel_no_infinite_loop() {
2918 use tempfile::TempDir;
2919
2920 let dir = TempDir::new().unwrap();
2924 let a_dir = dir.path().join("a");
2925 let b_dir = dir.path().join("b");
2926 std::fs::create_dir_all(&a_dir).unwrap();
2927 std::fs::create_dir_all(&b_dir).unwrap();
2928 std::fs::write(a_dir.join("index.ts"), "export * from '../b';").unwrap();
2929 std::fs::write(b_dir.join("index.ts"), "export * from '../a';").unwrap();
2930
2931 let a_index = a_dir.join("index.ts");
2932
2933 let result = resolve_barrel_exports(&a_index, &["Foo".to_string()], dir.path());
2935
2936 assert!(
2938 result.is_empty(),
2939 "expected empty result for circular barrel, got {result:?}"
2940 );
2941 }
2942
2943 #[test]
2945 fn barrel_07_layer2_barrel_import_matches_production() {
2946 use tempfile::TempDir;
2947
2948 let dir = TempDir::new().unwrap();
2954 let src_dir = dir.path().join("src");
2955 let decorators_dir = src_dir.join("decorators");
2956 let test_dir = dir.path().join("test");
2957 std::fs::create_dir_all(&decorators_dir).unwrap();
2958 std::fs::create_dir_all(&test_dir).unwrap();
2959
2960 let prod_path = src_dir.join("foo.service.ts");
2962 std::fs::File::create(&prod_path).unwrap();
2963
2964 std::fs::write(
2966 decorators_dir.join("index.ts"),
2967 "export { Foo } from '../foo.service';",
2968 )
2969 .unwrap();
2970
2971 let test_path = test_dir.join("foo.spec.ts");
2973 std::fs::write(
2974 &test_path,
2975 "import { Foo } from '../src/decorators';\ndescribe('foo', () => {});",
2976 )
2977 .unwrap();
2978
2979 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2980 let mut test_sources = HashMap::new();
2981 test_sources.insert(
2982 test_path.to_string_lossy().into_owned(),
2983 std::fs::read_to_string(&test_path).unwrap(),
2984 );
2985
2986 let extractor = TypeScriptExtractor::new();
2987
2988 let mappings = extractor.map_test_files_with_imports(
2990 &production_files,
2991 &test_sources,
2992 dir.path(),
2993 false,
2994 );
2995
2996 assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2998 assert!(
2999 mappings[0]
3000 .test_files
3001 .contains(&test_path.to_string_lossy().into_owned()),
3002 "expected foo.spec.ts mapped via barrel, got {:?}",
3003 mappings[0].test_files
3004 );
3005 }
3006
3007 #[test]
3009 fn barrel_08_non_sut_filter_applied_after_barrel_resolution() {
3010 use tempfile::TempDir;
3011
3012 let dir = TempDir::new().unwrap();
3017 let src_dir = dir.path().join("src");
3018 let test_dir = dir.path().join("test");
3019 std::fs::create_dir_all(&src_dir).unwrap();
3020 std::fs::create_dir_all(&test_dir).unwrap();
3021
3022 let prod_path = src_dir.join("user.service.ts");
3024 std::fs::File::create(&prod_path).unwrap();
3025
3026 std::fs::write(
3028 src_dir.join("index.ts"),
3029 "export { SOME_CONST } from './constants';",
3030 )
3031 .unwrap();
3032 std::fs::File::create(src_dir.join("constants.ts")).unwrap();
3034
3035 let test_path = test_dir.join("barrel_const.spec.ts");
3037 std::fs::write(
3038 &test_path,
3039 "import { SOME_CONST } from '../src';\ndescribe('const', () => {});",
3040 )
3041 .unwrap();
3042
3043 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3044 let mut test_sources = HashMap::new();
3045 test_sources.insert(
3046 test_path.to_string_lossy().into_owned(),
3047 std::fs::read_to_string(&test_path).unwrap(),
3048 );
3049
3050 let extractor = TypeScriptExtractor::new();
3051
3052 let mappings = extractor.map_test_files_with_imports(
3054 &production_files,
3055 &test_sources,
3056 dir.path(),
3057 false,
3058 );
3059
3060 assert_eq!(
3062 mappings.len(),
3063 1,
3064 "expected 1 FileMapping for user.service.ts"
3065 );
3066 assert!(
3067 mappings[0].test_files.is_empty(),
3068 "constants.ts should be filtered out, but got {:?}",
3069 mappings[0].test_files
3070 );
3071 }
3072
3073 #[test]
3075 fn barrel_09_extract_imports_retains_symbols() {
3076 let source = "import { Foo, Bar } from './module';";
3078 let extractor = TypeScriptExtractor::new();
3079
3080 let imports = extractor.extract_imports(source, "test.ts");
3082
3083 let from_module: Vec<&ImportMapping> = imports
3087 .iter()
3088 .filter(|i| i.module_specifier == "./module")
3089 .collect();
3090 let names: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
3091 assert!(names.contains(&"Foo"), "expected Foo in symbols: {names:?}");
3092 assert!(names.contains(&"Bar"), "expected Bar in symbols: {names:?}");
3093
3094 let grouped = imports
3098 .iter()
3099 .filter(|i| i.module_specifier == "./module")
3100 .fold(Vec::<String>::new(), |mut acc, i| {
3101 acc.push(i.symbol_name.clone());
3102 acc
3103 });
3104 assert_eq!(
3107 grouped.len(),
3108 2,
3109 "expected 2 symbols from ./module, got {grouped:?}"
3110 );
3111
3112 let first_import = imports
3115 .iter()
3116 .find(|i| i.module_specifier == "./module")
3117 .expect("expected at least one import from ./module");
3118 let symbols = &first_import.symbols;
3119 assert!(
3120 symbols.contains(&"Foo".to_string()),
3121 "symbols should contain Foo, got {symbols:?}"
3122 );
3123 assert!(
3124 symbols.contains(&"Bar".to_string()),
3125 "symbols should contain Bar, got {symbols:?}"
3126 );
3127 assert_eq!(
3128 symbols.len(),
3129 2,
3130 "expected exactly 2 symbols, got {symbols:?}"
3131 );
3132 }
3133
3134 #[test]
3138 fn barrel_10_wildcard_barrel_symbol_filter() {
3139 use tempfile::TempDir;
3140
3141 let dir = TempDir::new().unwrap();
3147 let core_dir = dir.path().join("core");
3148 std::fs::create_dir_all(&core_dir).unwrap();
3149
3150 std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3151 std::fs::write(
3152 core_dir.join("index.ts"),
3153 "export * from './foo';\nexport * from './bar';",
3154 )
3155 .unwrap();
3156 std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3157 std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3158
3159 let result = resolve_barrel_exports(
3161 &dir.path().join("index.ts"),
3162 &["Foo".to_string()],
3163 dir.path(),
3164 );
3165
3166 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
3168 assert!(
3169 result[0].ends_with("foo.ts"),
3170 "expected foo.ts, got {:?}",
3171 result[0]
3172 );
3173 }
3174
3175 #[test]
3177 fn barrel_11_wildcard_barrel_empty_symbols_match_all() {
3178 use tempfile::TempDir;
3179
3180 let dir = TempDir::new().unwrap();
3181 let core_dir = dir.path().join("core");
3182 std::fs::create_dir_all(&core_dir).unwrap();
3183
3184 std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3185 std::fs::write(
3186 core_dir.join("index.ts"),
3187 "export * from './foo';\nexport * from './bar';",
3188 )
3189 .unwrap();
3190 std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3191 std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3192
3193 let result = resolve_barrel_exports(&dir.path().join("index.ts"), &[], dir.path());
3195
3196 assert_eq!(result.len(), 2, "expected 2 resolved files, got {result:?}");
3198 }
3199
3200 #[test]
3207 fn boundary_b1_ns_reexport_captured_as_wildcard() {
3208 let source = "export * as Validators from './validators';";
3210 let extractor = TypeScriptExtractor::new();
3211
3212 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
3214
3215 assert_eq!(
3217 re_exports.len(),
3218 1,
3219 "expected 1 re-export for namespace export, got {:?}",
3220 re_exports
3221 );
3222 let re = &re_exports[0];
3223 assert_eq!(re.from_specifier, "./validators");
3224 assert!(
3225 re.wildcard,
3226 "expected wildcard=true for namespace re-export, got {:?}",
3227 re
3228 );
3229 }
3230
3231 #[test]
3233 fn boundary_b1_ns_reexport_mapping_miss() {
3234 use tempfile::TempDir;
3235
3236 let dir = TempDir::new().unwrap();
3242 let validators_dir = dir.path().join("validators");
3243 let test_dir = dir.path().join("test");
3244 std::fs::create_dir_all(&validators_dir).unwrap();
3245 std::fs::create_dir_all(&test_dir).unwrap();
3246
3247 let prod_path = validators_dir.join("foo.service.ts");
3249 std::fs::File::create(&prod_path).unwrap();
3250
3251 std::fs::write(
3253 dir.path().join("index.ts"),
3254 "export * as Validators from './validators';",
3255 )
3256 .unwrap();
3257
3258 std::fs::write(
3260 validators_dir.join("index.ts"),
3261 "export { FooService } from './foo.service';",
3262 )
3263 .unwrap();
3264
3265 let test_path = test_dir.join("foo.spec.ts");
3267 std::fs::write(
3268 &test_path,
3269 "import { Validators } from '../index';\ndescribe('FooService', () => {});",
3270 )
3271 .unwrap();
3272
3273 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3274 let mut test_sources = HashMap::new();
3275 test_sources.insert(
3276 test_path.to_string_lossy().into_owned(),
3277 std::fs::read_to_string(&test_path).unwrap(),
3278 );
3279
3280 let extractor = TypeScriptExtractor::new();
3281
3282 let mappings = extractor.map_test_files_with_imports(
3284 &production_files,
3285 &test_sources,
3286 dir.path(),
3287 false,
3288 );
3289
3290 let prod_mapping = mappings
3292 .iter()
3293 .find(|m| m.production_file.contains("foo.service.ts"));
3294 assert!(
3295 prod_mapping.is_some(),
3296 "expected foo.service.ts to appear in mappings, got {:?}",
3297 mappings
3298 );
3299 let mapping = prod_mapping.unwrap();
3300 assert!(
3301 !mapping.test_files.is_empty(),
3302 "expected foo.service.ts to have test_files (Layer 2 via namespace re-export), got {:?}",
3303 mapping
3304 );
3305 }
3306
3307 #[test]
3309 fn boundary_b2_non_relative_import_skipped() {
3310 let source = "import { Injectable } from '@nestjs/common';";
3312 let extractor = TypeScriptExtractor::new();
3313
3314 let imports = extractor.extract_imports(source, "app.service.ts");
3316
3317 assert!(
3319 imports.is_empty(),
3320 "expected empty imports for non-relative path, got {:?}",
3321 imports
3322 );
3323 }
3324
3325 #[cfg(unix)]
3328 #[test]
3329 fn boundary_b2_cross_pkg_symlink_resolved() {
3330 use std::os::unix::fs::symlink;
3331 use tempfile::TempDir;
3332
3333 let dir = TempDir::new().unwrap();
3341 let core_src = dir.path().join("packages").join("core").join("src");
3342 let core_test = dir.path().join("packages").join("core").join("test");
3343 let core_nm_org = dir
3344 .path()
3345 .join("packages")
3346 .join("core")
3347 .join("node_modules")
3348 .join("@org");
3349 let common_src = dir.path().join("packages").join("common").join("src");
3350 std::fs::create_dir_all(&core_src).unwrap();
3351 std::fs::create_dir_all(&core_test).unwrap();
3352 std::fs::create_dir_all(&core_nm_org).unwrap();
3353 std::fs::create_dir_all(&common_src).unwrap();
3354
3355 let local_prod_path = core_src.join("foo.service.ts");
3356 std::fs::File::create(&local_prod_path).unwrap();
3357
3358 let common_path = common_src.join("foo.ts");
3359 std::fs::File::create(&common_path).unwrap();
3360
3361 let symlink_path = core_nm_org.join("common");
3363 let target = dir.path().join("packages").join("common");
3364 symlink(&target, &symlink_path).unwrap();
3365
3366 let test_path = core_test.join("foo.spec.ts");
3367 std::fs::write(
3368 &test_path,
3369 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3370 )
3371 .unwrap();
3372
3373 let scan_root = dir.path().join("packages").join("core");
3374 let production_files = vec![
3376 local_prod_path.to_string_lossy().into_owned(),
3377 common_path.to_string_lossy().into_owned(),
3378 ];
3379 let mut test_sources = HashMap::new();
3380 test_sources.insert(
3381 test_path.to_string_lossy().into_owned(),
3382 std::fs::read_to_string(&test_path).unwrap(),
3383 );
3384
3385 let extractor = TypeScriptExtractor::new();
3386
3387 let mappings = extractor.map_test_files_with_imports(
3389 &production_files,
3390 &test_sources,
3391 &scan_root,
3392 false,
3393 );
3394
3395 let common_path_str = common_path.to_string_lossy().into_owned();
3397 let common_mapping = mappings
3398 .iter()
3399 .find(|m| m.production_file == common_path_str);
3400 assert!(
3401 common_mapping.is_some(),
3402 "expected common/src/foo.ts to have a mapping"
3403 );
3404 let test_file_str = test_path.to_string_lossy().into_owned();
3405 assert!(
3406 common_mapping.unwrap().test_files.contains(&test_file_str),
3407 "expected foo.spec.ts to be mapped to common/src/foo.ts via symlink"
3408 );
3409 }
3410
3411 #[cfg(unix)]
3413 #[test]
3414 fn b2_sym_01_symlink_followed() {
3415 use std::os::unix::fs::symlink;
3416 use tempfile::TempDir;
3417
3418 let dir = TempDir::new().unwrap();
3421 let nm_org = dir.path().join("node_modules").join("@org");
3422 std::fs::create_dir_all(&nm_org).unwrap();
3423 let target = dir.path().join("packages").join("common");
3424 std::fs::create_dir_all(&target).unwrap();
3425 let symlink_path = nm_org.join("common");
3426 symlink(&target, &symlink_path).unwrap();
3427
3428 let mut cache = HashMap::new();
3429
3430 let result = resolve_node_modules_symlink("@org/common", dir.path(), &mut cache);
3432
3433 let expected = target.canonicalize().unwrap();
3435 assert_eq!(
3436 result,
3437 Some(expected),
3438 "expected symlink to be followed to real path"
3439 );
3440 }
3441
3442 #[cfg(unix)]
3444 #[test]
3445 fn b2_sym_02_real_directory_returns_none() {
3446 use tempfile::TempDir;
3447
3448 let dir = TempDir::new().unwrap();
3451 let nm_org = dir.path().join("node_modules").join("@org").join("common");
3452 std::fs::create_dir_all(&nm_org).unwrap();
3453
3454 let mut cache = HashMap::new();
3455
3456 let result = resolve_node_modules_symlink("@org/common", dir.path(), &mut cache);
3458
3459 assert_eq!(
3461 result, None,
3462 "expected None for real directory (not symlink)"
3463 );
3464 }
3465
3466 #[cfg(unix)]
3468 #[test]
3469 fn b2_sym_03_nonexistent_returns_none() {
3470 use tempfile::TempDir;
3471
3472 let dir = TempDir::new().unwrap();
3474 let nm = dir.path().join("node_modules");
3475 std::fs::create_dir_all(&nm).unwrap();
3476
3477 let mut cache = HashMap::new();
3478
3479 let result = resolve_node_modules_symlink("@org/nonexistent", dir.path(), &mut cache);
3481
3482 assert_eq!(result, None, "expected None for non-existent specifier");
3484 }
3485
3486 #[cfg(unix)]
3488 #[test]
3489 fn b2_map_02_tsconfig_alias_priority() {
3490 use std::os::unix::fs::symlink;
3491 use tempfile::TempDir;
3492
3493 let dir = TempDir::new().unwrap();
3501 let core_src = dir.path().join("packages").join("core").join("src");
3502 let core_test = dir.path().join("packages").join("core").join("test");
3503 let core_nm_org = dir
3504 .path()
3505 .join("packages")
3506 .join("core")
3507 .join("node_modules")
3508 .join("@org");
3509 let common_src = dir.path().join("packages").join("common").join("src");
3510 std::fs::create_dir_all(&core_src).unwrap();
3511 std::fs::create_dir_all(&core_test).unwrap();
3512 std::fs::create_dir_all(&core_nm_org).unwrap();
3513 std::fs::create_dir_all(&common_src).unwrap();
3514
3515 let local_prod_path = core_src.join("foo.service.ts");
3516 std::fs::write(&local_prod_path, "export class FooService {}").unwrap();
3517
3518 let common_path = common_src.join("foo.ts");
3519 std::fs::File::create(&common_path).unwrap();
3520
3521 let symlink_path = core_nm_org.join("common");
3522 let target = dir.path().join("packages").join("common");
3523 symlink(&target, &symlink_path).unwrap();
3524
3525 let tsconfig = serde_json::json!({
3527 "compilerOptions": {
3528 "paths": {
3529 "@org/common": ["src/foo.service"]
3530 }
3531 }
3532 });
3533 let core_root = dir.path().join("packages").join("core");
3534 std::fs::write(core_root.join("tsconfig.json"), tsconfig.to_string()).unwrap();
3535
3536 let test_path = core_test.join("foo.spec.ts");
3537 std::fs::write(
3538 &test_path,
3539 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3540 )
3541 .unwrap();
3542
3543 let production_files = vec![
3544 local_prod_path.to_string_lossy().into_owned(),
3545 common_path.to_string_lossy().into_owned(),
3546 ];
3547 let mut test_sources = HashMap::new();
3548 test_sources.insert(
3549 test_path.to_string_lossy().into_owned(),
3550 std::fs::read_to_string(&test_path).unwrap(),
3551 );
3552
3553 let extractor = TypeScriptExtractor::new();
3554
3555 let mappings = extractor.map_test_files_with_imports(
3557 &production_files,
3558 &test_sources,
3559 &core_root,
3560 false,
3561 );
3562
3563 let test_file_str = test_path.to_string_lossy().into_owned();
3565 let local_prod_str = local_prod_path.to_string_lossy().into_owned();
3566 let common_path_str = common_path.to_string_lossy().into_owned();
3567
3568 let local_mapping = mappings
3569 .iter()
3570 .find(|m| m.production_file == local_prod_str);
3571 assert!(
3572 local_mapping.map_or(false, |m| m.test_files.contains(&test_file_str)),
3573 "expected foo.service.ts to be mapped via tsconfig alias"
3574 );
3575
3576 let common_mapping = mappings
3577 .iter()
3578 .find(|m| m.production_file == common_path_str);
3579 assert!(
3580 !common_mapping.map_or(false, |m| m.test_files.contains(&test_file_str)),
3581 "expected common/src/foo.ts NOT to be mapped (tsconfig alias should win)"
3582 );
3583 }
3584
3585 #[cfg(unix)]
3587 #[test]
3588 fn b2_multi_01_two_test_files_both_mapped() {
3589 use std::os::unix::fs::symlink;
3590 use tempfile::TempDir;
3591
3592 let dir = TempDir::new().unwrap();
3598 let core_test = dir.path().join("packages").join("core").join("test");
3599 let core_nm_org = dir
3600 .path()
3601 .join("packages")
3602 .join("core")
3603 .join("node_modules")
3604 .join("@org");
3605 let common_src = dir.path().join("packages").join("common").join("src");
3606 std::fs::create_dir_all(&core_test).unwrap();
3607 std::fs::create_dir_all(&core_nm_org).unwrap();
3608 std::fs::create_dir_all(&common_src).unwrap();
3609
3610 let common_path = common_src.join("foo.ts");
3611 std::fs::File::create(&common_path).unwrap();
3612
3613 let symlink_path = core_nm_org.join("common");
3614 let target = dir.path().join("packages").join("common");
3615 symlink(&target, &symlink_path).unwrap();
3616
3617 let test_path1 = core_test.join("foo.spec.ts");
3618 let test_path2 = core_test.join("bar.spec.ts");
3619 std::fs::write(
3620 &test_path1,
3621 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3622 )
3623 .unwrap();
3624 std::fs::write(
3625 &test_path2,
3626 "import { Foo } from '@org/common';\ndescribe('Bar', () => {});",
3627 )
3628 .unwrap();
3629
3630 let scan_root = dir.path().join("packages").join("core");
3631 let production_files = vec![common_path.to_string_lossy().into_owned()];
3632 let mut test_sources = HashMap::new();
3633 test_sources.insert(
3634 test_path1.to_string_lossy().into_owned(),
3635 std::fs::read_to_string(&test_path1).unwrap(),
3636 );
3637 test_sources.insert(
3638 test_path2.to_string_lossy().into_owned(),
3639 std::fs::read_to_string(&test_path2).unwrap(),
3640 );
3641
3642 let extractor = TypeScriptExtractor::new();
3643
3644 let mappings = extractor.map_test_files_with_imports(
3646 &production_files,
3647 &test_sources,
3648 &scan_root,
3649 false,
3650 );
3651
3652 let common_path_str = common_path.to_string_lossy().into_owned();
3654 let test1_str = test_path1.to_string_lossy().into_owned();
3655 let test2_str = test_path2.to_string_lossy().into_owned();
3656
3657 let common_mapping = mappings
3658 .iter()
3659 .find(|m| m.production_file == common_path_str);
3660 assert!(
3661 common_mapping.is_some(),
3662 "expected common/src/foo.ts to have a mapping"
3663 );
3664 let mapped_tests = &common_mapping.unwrap().test_files;
3665 assert!(
3666 mapped_tests.contains(&test1_str),
3667 "expected foo.spec.ts to be mapped"
3668 );
3669 assert!(
3670 mapped_tests.contains(&test2_str),
3671 "expected bar.spec.ts to be mapped"
3672 );
3673 }
3674
3675 #[test]
3677 fn boundary_b3_tsconfig_alias_not_resolved() {
3678 let source = "import { FooService } from '@app/services/foo.service';";
3680 let extractor = TypeScriptExtractor::new();
3681
3682 let imports = extractor.extract_imports(source, "app.module.ts");
3684
3685 assert!(
3689 imports.is_empty(),
3690 "expected empty imports for tsconfig alias, got {:?}",
3691 imports
3692 );
3693 }
3694
3695 #[test]
3697 fn boundary_b4_enum_primary_target_filtered() {
3698 use tempfile::TempDir;
3699
3700 let dir = TempDir::new().unwrap();
3704 let src_dir = dir.path().join("src");
3705 let test_dir = dir.path().join("test");
3706 std::fs::create_dir_all(&src_dir).unwrap();
3707 std::fs::create_dir_all(&test_dir).unwrap();
3708
3709 let prod_path = src_dir.join("route-paramtypes.enum.ts");
3710 std::fs::File::create(&prod_path).unwrap();
3711
3712 let test_path = test_dir.join("route.spec.ts");
3713 std::fs::write(
3714 &test_path,
3715 "import { RouteParamtypes } from '../src/route-paramtypes.enum';\ndescribe('Route', () => {});",
3716 )
3717 .unwrap();
3718
3719 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3720 let mut test_sources = HashMap::new();
3721 test_sources.insert(
3722 test_path.to_string_lossy().into_owned(),
3723 std::fs::read_to_string(&test_path).unwrap(),
3724 );
3725
3726 let extractor = TypeScriptExtractor::new();
3727
3728 let mappings = extractor.map_test_files_with_imports(
3730 &production_files,
3731 &test_sources,
3732 dir.path(),
3733 false,
3734 );
3735
3736 let enum_mapping = mappings
3738 .iter()
3739 .find(|m| m.production_file.ends_with("route-paramtypes.enum.ts"));
3740 assert!(
3741 enum_mapping.is_some(),
3742 "expected mapping for route-paramtypes.enum.ts"
3743 );
3744 let enum_mapping = enum_mapping.unwrap();
3745 assert!(
3746 !enum_mapping.test_files.is_empty(),
3747 "expected test_files for route-paramtypes.enum.ts (production file), got empty"
3748 );
3749 }
3750
3751 #[test]
3753 fn boundary_b4_interface_primary_target_filtered() {
3754 use tempfile::TempDir;
3755
3756 let dir = TempDir::new().unwrap();
3760 let src_dir = dir.path().join("src");
3761 let test_dir = dir.path().join("test");
3762 std::fs::create_dir_all(&src_dir).unwrap();
3763 std::fs::create_dir_all(&test_dir).unwrap();
3764
3765 let prod_path = src_dir.join("user.interface.ts");
3766 std::fs::File::create(&prod_path).unwrap();
3767
3768 let test_path = test_dir.join("user.spec.ts");
3769 std::fs::write(
3770 &test_path,
3771 "import { User } from '../src/user.interface';\ndescribe('User', () => {});",
3772 )
3773 .unwrap();
3774
3775 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3776 let mut test_sources = HashMap::new();
3777 test_sources.insert(
3778 test_path.to_string_lossy().into_owned(),
3779 std::fs::read_to_string(&test_path).unwrap(),
3780 );
3781
3782 let extractor = TypeScriptExtractor::new();
3783
3784 let mappings = extractor.map_test_files_with_imports(
3786 &production_files,
3787 &test_sources,
3788 dir.path(),
3789 false,
3790 );
3791
3792 let iface_mapping = mappings
3794 .iter()
3795 .find(|m| m.production_file.ends_with("user.interface.ts"));
3796 assert!(
3797 iface_mapping.is_some(),
3798 "expected mapping for user.interface.ts"
3799 );
3800 let iface_mapping = iface_mapping.unwrap();
3801 assert!(
3802 !iface_mapping.test_files.is_empty(),
3803 "expected test_files for user.interface.ts (production file), got empty"
3804 );
3805 }
3806
3807 #[test]
3809 fn boundary_b5_dynamic_import_not_extracted() {
3810 let source = fixture("import_dynamic.ts");
3813 let extractor = TypeScriptExtractor::new();
3814
3815 let imports = extractor.extract_imports(&source, "import_dynamic.ts");
3817
3818 let specifiers: Vec<&str> = imports
3820 .iter()
3821 .map(|i| i.module_specifier.as_str())
3822 .collect();
3823 assert!(
3824 specifiers.contains(&"./user.service"),
3825 "expected './user.service' in imports, got {imports:?}"
3826 );
3827 assert!(
3828 specifiers.contains(&"./bar"),
3829 "expected './bar' in imports, got {imports:?}"
3830 );
3831 }
3832
3833 #[test]
3835 fn tc01_dynamic_import_relative_maps_to_module() {
3836 let source = "describe('x', () => { it('y', async () => { const m = await import('./user.service'); }); });";
3838 let extractor = TypeScriptExtractor::new();
3839
3840 let imports = extractor.extract_imports(source, "test.ts");
3842
3843 let found = imports
3845 .iter()
3846 .find(|i| i.module_specifier == "./user.service");
3847 assert!(
3848 found.is_some(),
3849 "expected './user.service' in imports, got {imports:?}"
3850 );
3851 }
3852
3853 #[test]
3855 fn tc03_destructured_dynamic_import_maps_to_module() {
3856 let source = "describe('x', () => { it('y', async () => { const { foo } = await import('./bar'); }); });";
3858 let extractor = TypeScriptExtractor::new();
3859
3860 let imports = extractor.extract_imports(source, "test.ts");
3862
3863 let found = imports.iter().find(|i| i.module_specifier == "./bar");
3865 assert!(
3866 found.is_some(),
3867 "expected './bar' in imports, got {imports:?}"
3868 );
3869 }
3870
3871 #[test]
3873 fn tc02_dynamic_import_path_alias_resolves_to_production_file() {
3874 use tempfile::TempDir;
3875
3876 let dir = TempDir::new().unwrap();
3881 let src_lib_dir = dir.path().join("src").join("lib");
3882 let test_dir = dir.path().join("test");
3883 std::fs::create_dir_all(&src_lib_dir).unwrap();
3884 std::fs::create_dir_all(&test_dir).unwrap();
3885
3886 let tsconfig = dir.path().join("tsconfig.json");
3887 std::fs::write(
3888 &tsconfig,
3889 r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
3890 )
3891 .unwrap();
3892
3893 let prod_path = src_lib_dir.join("api-client.ts");
3894 std::fs::File::create(&prod_path).unwrap();
3895
3896 let test_path = test_dir.join("api-client.test.ts");
3897 let test_source = "describe('api-client', () => { it('loads', async () => { const m = await import('@/lib/api-client'); }); });\n";
3898 std::fs::write(&test_path, test_source).unwrap();
3899
3900 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3901 let mut test_sources = std::collections::HashMap::new();
3902 test_sources.insert(
3903 test_path.to_string_lossy().into_owned(),
3904 test_source.to_string(),
3905 );
3906
3907 let extractor = TypeScriptExtractor::new();
3908
3909 let mappings = extractor.map_test_files_with_imports(
3911 &production_files,
3912 &test_sources,
3913 dir.path(),
3914 false,
3915 );
3916
3917 let mapping = mappings
3919 .iter()
3920 .find(|m| m.production_file.contains("api-client.ts"))
3921 .expect("expected mapping for api-client.ts, got no match");
3922 assert!(
3923 mapping
3924 .test_files
3925 .contains(&test_path.to_string_lossy().into_owned()),
3926 "expected api-client.test.ts in mapping, got {:?}",
3927 mapping.test_files
3928 );
3929 }
3930
3931 #[test]
3935 fn test_observe_tsconfig_alias_basic() {
3936 use tempfile::TempDir;
3937
3938 let dir = TempDir::new().unwrap();
3943 let src_dir = dir.path().join("src");
3944 let test_dir = dir.path().join("test");
3945 std::fs::create_dir_all(&src_dir).unwrap();
3946 std::fs::create_dir_all(&test_dir).unwrap();
3947
3948 let tsconfig = dir.path().join("tsconfig.json");
3949 std::fs::write(
3950 &tsconfig,
3951 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3952 )
3953 .unwrap();
3954
3955 let prod_path = src_dir.join("foo.service.ts");
3956 std::fs::File::create(&prod_path).unwrap();
3957
3958 let test_path = test_dir.join("foo.service.spec.ts");
3959 let test_source =
3960 "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3961 std::fs::write(&test_path, test_source).unwrap();
3962
3963 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3964 let mut test_sources = HashMap::new();
3965 test_sources.insert(
3966 test_path.to_string_lossy().into_owned(),
3967 test_source.to_string(),
3968 );
3969
3970 let extractor = TypeScriptExtractor::new();
3971
3972 let mappings = extractor.map_test_files_with_imports(
3974 &production_files,
3975 &test_sources,
3976 dir.path(),
3977 false,
3978 );
3979
3980 let mapping = mappings
3982 .iter()
3983 .find(|m| m.production_file.contains("foo.service.ts"))
3984 .expect("expected mapping for foo.service.ts");
3985 assert!(
3986 mapping
3987 .test_files
3988 .contains(&test_path.to_string_lossy().into_owned()),
3989 "expected foo.service.spec.ts in mapping via alias, got {:?}",
3990 mapping.test_files
3991 );
3992 }
3993
3994 #[test]
3996 fn test_observe_no_tsconfig_alias_ignored() {
3997 use tempfile::TempDir;
3998
3999 let dir = TempDir::new().unwrap();
4004 let src_dir = dir.path().join("src");
4005 let test_dir = dir.path().join("test");
4006 std::fs::create_dir_all(&src_dir).unwrap();
4007 std::fs::create_dir_all(&test_dir).unwrap();
4008
4009 let prod_path = src_dir.join("foo.service.ts");
4010 std::fs::File::create(&prod_path).unwrap();
4011
4012 let test_path = test_dir.join("foo.service.spec.ts");
4013 let test_source =
4014 "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
4015
4016 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4017 let mut test_sources = HashMap::new();
4018 test_sources.insert(
4019 test_path.to_string_lossy().into_owned(),
4020 test_source.to_string(),
4021 );
4022
4023 let extractor = TypeScriptExtractor::new();
4024
4025 let mappings = extractor.map_test_files_with_imports(
4027 &production_files,
4028 &test_sources,
4029 dir.path(),
4030 false,
4031 );
4032
4033 let all_test_files: Vec<&String> =
4035 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4036 assert!(
4037 all_test_files.is_empty(),
4038 "expected no test_files when tsconfig absent, got {:?}",
4039 all_test_files
4040 );
4041 }
4042
4043 #[test]
4045 fn test_observe_tsconfig_alias_barrel() {
4046 use tempfile::TempDir;
4047
4048 let dir = TempDir::new().unwrap();
4054 let src_dir = dir.path().join("src");
4055 let services_dir = src_dir.join("services");
4056 let test_dir = dir.path().join("test");
4057 std::fs::create_dir_all(&services_dir).unwrap();
4058 std::fs::create_dir_all(&test_dir).unwrap();
4059
4060 std::fs::write(
4061 dir.path().join("tsconfig.json"),
4062 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4063 )
4064 .unwrap();
4065
4066 let prod_path = src_dir.join("bar.service.ts");
4067 std::fs::File::create(&prod_path).unwrap();
4068
4069 std::fs::write(
4070 services_dir.join("index.ts"),
4071 "export { BarService } from '../bar.service';\n",
4072 )
4073 .unwrap();
4074
4075 let test_path = test_dir.join("bar.service.spec.ts");
4076 let test_source =
4077 "import { BarService } from '@app/services';\ndescribe('BarService', () => {});\n";
4078 std::fs::write(&test_path, test_source).unwrap();
4079
4080 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4081 let mut test_sources = HashMap::new();
4082 test_sources.insert(
4083 test_path.to_string_lossy().into_owned(),
4084 test_source.to_string(),
4085 );
4086
4087 let extractor = TypeScriptExtractor::new();
4088
4089 let mappings = extractor.map_test_files_with_imports(
4091 &production_files,
4092 &test_sources,
4093 dir.path(),
4094 false,
4095 );
4096
4097 let mapping = mappings
4099 .iter()
4100 .find(|m| m.production_file.contains("bar.service.ts"))
4101 .expect("expected mapping for bar.service.ts");
4102 assert!(
4103 mapping
4104 .test_files
4105 .contains(&test_path.to_string_lossy().into_owned()),
4106 "expected bar.service.spec.ts mapped via alias+barrel, got {:?}",
4107 mapping.test_files
4108 );
4109 }
4110
4111 #[test]
4113 fn test_observe_tsconfig_alias_mixed() {
4114 use tempfile::TempDir;
4115
4116 let dir = TempDir::new().unwrap();
4123 let src_dir = dir.path().join("src");
4124 let test_dir = dir.path().join("test");
4125 std::fs::create_dir_all(&src_dir).unwrap();
4126 std::fs::create_dir_all(&test_dir).unwrap();
4127
4128 std::fs::write(
4129 dir.path().join("tsconfig.json"),
4130 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4131 )
4132 .unwrap();
4133
4134 let foo_path = src_dir.join("foo.service.ts");
4135 let bar_path = src_dir.join("bar.service.ts");
4136 std::fs::File::create(&foo_path).unwrap();
4137 std::fs::File::create(&bar_path).unwrap();
4138
4139 let test_path = test_dir.join("mixed.spec.ts");
4140 let test_source = "\
4141import { FooService } from '@app/foo.service';
4142import { BarService } from '../src/bar.service';
4143describe('Mixed', () => {});
4144";
4145 std::fs::write(&test_path, test_source).unwrap();
4146
4147 let production_files = vec![
4148 foo_path.to_string_lossy().into_owned(),
4149 bar_path.to_string_lossy().into_owned(),
4150 ];
4151 let mut test_sources = HashMap::new();
4152 test_sources.insert(
4153 test_path.to_string_lossy().into_owned(),
4154 test_source.to_string(),
4155 );
4156
4157 let extractor = TypeScriptExtractor::new();
4158
4159 let mappings = extractor.map_test_files_with_imports(
4161 &production_files,
4162 &test_sources,
4163 dir.path(),
4164 false,
4165 );
4166
4167 let foo_mapping = mappings
4169 .iter()
4170 .find(|m| m.production_file.contains("foo.service.ts"))
4171 .expect("expected mapping for foo.service.ts");
4172 assert!(
4173 foo_mapping
4174 .test_files
4175 .contains(&test_path.to_string_lossy().into_owned()),
4176 "expected mixed.spec.ts in foo mapping, got {:?}",
4177 foo_mapping.test_files
4178 );
4179 let bar_mapping = mappings
4180 .iter()
4181 .find(|m| m.production_file.contains("bar.service.ts"))
4182 .expect("expected mapping for bar.service.ts");
4183 assert!(
4184 bar_mapping
4185 .test_files
4186 .contains(&test_path.to_string_lossy().into_owned()),
4187 "expected mixed.spec.ts in bar mapping, got {:?}",
4188 bar_mapping.test_files
4189 );
4190 }
4191
4192 #[test]
4194 fn test_observe_tsconfig_alias_helper_filtered() {
4195 use tempfile::TempDir;
4196
4197 let dir = TempDir::new().unwrap();
4202 let src_dir = dir.path().join("src");
4203 let test_dir = dir.path().join("test");
4204 std::fs::create_dir_all(&src_dir).unwrap();
4205 std::fs::create_dir_all(&test_dir).unwrap();
4206
4207 std::fs::write(
4208 dir.path().join("tsconfig.json"),
4209 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4210 )
4211 .unwrap();
4212
4213 let prod_path = src_dir.join("constants.ts");
4214 std::fs::File::create(&prod_path).unwrap();
4215
4216 let test_path = test_dir.join("constants.spec.ts");
4217 let test_source =
4218 "import { APP_NAME } from '@app/constants';\ndescribe('Constants', () => {});\n";
4219 std::fs::write(&test_path, test_source).unwrap();
4220
4221 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4222 let mut test_sources = HashMap::new();
4223 test_sources.insert(
4224 test_path.to_string_lossy().into_owned(),
4225 test_source.to_string(),
4226 );
4227
4228 let extractor = TypeScriptExtractor::new();
4229
4230 let mappings = extractor.map_test_files_with_imports(
4232 &production_files,
4233 &test_sources,
4234 dir.path(),
4235 false,
4236 );
4237
4238 let all_test_files: Vec<&String> =
4240 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4241 assert!(
4242 all_test_files.is_empty(),
4243 "expected constants.ts filtered by is_non_sut_helper, got {:?}",
4244 all_test_files
4245 );
4246 }
4247
4248 #[test]
4250 fn test_observe_tsconfig_alias_nonexistent() {
4251 use tempfile::TempDir;
4252
4253 let dir = TempDir::new().unwrap();
4259 let src_dir = dir.path().join("src");
4260 let test_dir = dir.path().join("test");
4261 std::fs::create_dir_all(&src_dir).unwrap();
4262 std::fs::create_dir_all(&test_dir).unwrap();
4263
4264 std::fs::write(
4265 dir.path().join("tsconfig.json"),
4266 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4267 )
4268 .unwrap();
4269
4270 let prod_path = src_dir.join("foo.service.ts");
4271 std::fs::File::create(&prod_path).unwrap();
4272
4273 let test_path = test_dir.join("nonexistent.spec.ts");
4274 let test_source =
4275 "import { Missing } from '@app/nonexistent';\ndescribe('Nonexistent', () => {});\n";
4276 std::fs::write(&test_path, test_source).unwrap();
4277
4278 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4279 let mut test_sources = HashMap::new();
4280 test_sources.insert(
4281 test_path.to_string_lossy().into_owned(),
4282 test_source.to_string(),
4283 );
4284
4285 let extractor = TypeScriptExtractor::new();
4286
4287 let mappings = extractor.map_test_files_with_imports(
4289 &production_files,
4290 &test_sources,
4291 dir.path(),
4292 false,
4293 );
4294
4295 let all_test_files: Vec<&String> =
4297 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4298 assert!(
4299 all_test_files.is_empty(),
4300 "expected no mapping for alias to nonexistent file, got {:?}",
4301 all_test_files
4302 );
4303 }
4304
4305 #[test]
4308 fn boundary_b3_tsconfig_alias_resolved() {
4309 use tempfile::TempDir;
4310
4311 let dir = TempDir::new().unwrap();
4316 let src_dir = dir.path().join("src");
4317 let services_dir = src_dir.join("services");
4318 let test_dir = dir.path().join("test");
4319 std::fs::create_dir_all(&services_dir).unwrap();
4320 std::fs::create_dir_all(&test_dir).unwrap();
4321
4322 std::fs::write(
4323 dir.path().join("tsconfig.json"),
4324 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4325 )
4326 .unwrap();
4327
4328 let prod_path = services_dir.join("foo.service.ts");
4329 std::fs::File::create(&prod_path).unwrap();
4330
4331 let test_path = test_dir.join("foo.service.spec.ts");
4332 let test_source = "import { FooService } from '@app/services/foo.service';\ndescribe('FooService', () => {});\n";
4333 std::fs::write(&test_path, test_source).unwrap();
4334
4335 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4336 let mut test_sources = HashMap::new();
4337 test_sources.insert(
4338 test_path.to_string_lossy().into_owned(),
4339 test_source.to_string(),
4340 );
4341
4342 let extractor = TypeScriptExtractor::new();
4343
4344 let mappings = extractor.map_test_files_with_imports(
4346 &production_files,
4347 &test_sources,
4348 dir.path(),
4349 false,
4350 );
4351
4352 let mapping = mappings
4354 .iter()
4355 .find(|m| m.production_file.contains("foo.service.ts"))
4356 .expect("expected FileMapping for foo.service.ts");
4357 assert!(
4358 mapping
4359 .test_files
4360 .contains(&test_path.to_string_lossy().into_owned()),
4361 "expected tsconfig alias to be resolved (B3 fix), got {:?}",
4362 mapping.test_files
4363 );
4364 }
4365
4366 #[test]
4368 fn boundary_b6_import_outside_scan_root() {
4369 use tempfile::TempDir;
4370
4371 let dir = TempDir::new().unwrap();
4377 let core_src = dir.path().join("packages").join("core").join("src");
4378 let core_test = dir.path().join("packages").join("core").join("test");
4379 let common_src = dir.path().join("packages").join("common").join("src");
4380 std::fs::create_dir_all(&core_src).unwrap();
4381 std::fs::create_dir_all(&core_test).unwrap();
4382 std::fs::create_dir_all(&common_src).unwrap();
4383
4384 let prod_path = core_src.join("foo.service.ts");
4385 std::fs::File::create(&prod_path).unwrap();
4386
4387 let shared_path = common_src.join("shared.ts");
4389 std::fs::File::create(&shared_path).unwrap();
4390
4391 let test_path = core_test.join("foo.spec.ts");
4392 std::fs::write(
4393 &test_path,
4394 "import { Shared } from '../../common/src/shared';\ndescribe('Foo', () => {});",
4395 )
4396 .unwrap();
4397
4398 let scan_root = dir.path().join("packages").join("core");
4399 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4401 let mut test_sources = HashMap::new();
4402 test_sources.insert(
4403 test_path.to_string_lossy().into_owned(),
4404 std::fs::read_to_string(&test_path).unwrap(),
4405 );
4406
4407 let extractor = TypeScriptExtractor::new();
4408
4409 let mappings = extractor.map_test_files_with_imports(
4411 &production_files,
4412 &test_sources,
4413 &scan_root,
4414 false,
4415 );
4416
4417 let all_test_files: Vec<&String> =
4421 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4422 assert!(
4423 all_test_files.is_empty(),
4424 "expected no test_files (import target outside scan_root), got {:?}",
4425 all_test_files
4426 );
4427 }
4428
4429 #[test]
4433 fn test_b1_ns_reexport_extracted_as_wildcard() {
4434 let source = "export * as Validators from './validators';";
4436 let extractor = TypeScriptExtractor::new();
4437
4438 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4440
4441 assert_eq!(
4443 re_exports.len(),
4444 1,
4445 "expected 1 re-export, got {:?}",
4446 re_exports
4447 );
4448 let re = &re_exports[0];
4449 assert_eq!(re.from_specifier, "./validators");
4450 assert!(
4451 re.wildcard,
4452 "expected wildcard=true for `export * as Ns from`, got {:?}",
4453 re
4454 );
4455 }
4456
4457 #[test]
4459 fn test_b1_ns_reexport_mapping_resolves_via_layer2() {
4460 use tempfile::TempDir;
4461
4462 let dir = TempDir::new().unwrap();
4468 let validators_dir = dir.path().join("validators");
4469 let test_dir = dir.path().join("test");
4470 std::fs::create_dir_all(&validators_dir).unwrap();
4471 std::fs::create_dir_all(&test_dir).unwrap();
4472
4473 let prod_path = validators_dir.join("foo.service.ts");
4474 std::fs::File::create(&prod_path).unwrap();
4475
4476 std::fs::write(
4477 dir.path().join("index.ts"),
4478 "export * as Ns from './validators';",
4479 )
4480 .unwrap();
4481 std::fs::write(
4482 validators_dir.join("index.ts"),
4483 "export { FooService } from './foo.service';",
4484 )
4485 .unwrap();
4486
4487 let test_path = test_dir.join("foo.spec.ts");
4488 std::fs::write(
4489 &test_path,
4490 "import { Ns } from '../index';\ndescribe('FooService', () => {});",
4491 )
4492 .unwrap();
4493
4494 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4495 let mut test_sources = HashMap::new();
4496 test_sources.insert(
4497 test_path.to_string_lossy().into_owned(),
4498 std::fs::read_to_string(&test_path).unwrap(),
4499 );
4500
4501 let extractor = TypeScriptExtractor::new();
4502
4503 let mappings = extractor.map_test_files_with_imports(
4505 &production_files,
4506 &test_sources,
4507 dir.path(),
4508 false,
4509 );
4510
4511 let prod_mapping = mappings
4513 .iter()
4514 .find(|m| m.production_file.contains("foo.service.ts"));
4515 assert!(
4516 prod_mapping.is_some(),
4517 "expected foo.service.ts in mappings, got {:?}",
4518 mappings
4519 );
4520 let mapping = prod_mapping.unwrap();
4521 assert!(
4522 !mapping.test_files.is_empty(),
4523 "expected foo.service.ts mapped to foo.spec.ts via namespace re-export, got {:?}",
4524 mapping
4525 );
4526 assert!(
4527 mapping.test_files.iter().any(|f| f.contains("foo.spec.ts")),
4528 "expected foo.spec.ts in test_files, got {:?}",
4529 mapping.test_files
4530 );
4531 }
4532
4533 #[test]
4535 fn test_b1_ns_reexport_mixed_with_plain_wildcard() {
4536 let source = "export * from './a';\nexport * as B from './b';";
4538 let extractor = TypeScriptExtractor::new();
4539
4540 let mut re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4542
4543 assert_eq!(
4545 re_exports.len(),
4546 2,
4547 "expected 2 re-exports, got {:?}",
4548 re_exports
4549 );
4550
4551 re_exports.sort_by(|a, b| a.from_specifier.cmp(&b.from_specifier));
4552
4553 let re_a = re_exports.iter().find(|r| r.from_specifier == "./a");
4554 let re_b = re_exports.iter().find(|r| r.from_specifier == "./b");
4555
4556 assert!(
4557 re_a.is_some(),
4558 "expected re-export from './a', got {:?}",
4559 re_exports
4560 );
4561 assert!(
4562 re_b.is_some(),
4563 "expected re-export from './b', got {:?}",
4564 re_exports
4565 );
4566 let a = re_a.unwrap();
4567 let b = re_b.unwrap();
4568 assert!(
4569 a.wildcard,
4570 "expected wildcard=true for plain `export * from './a'`, got {:?}",
4571 a
4572 );
4573 assert!(
4574 !a.namespace_wildcard,
4575 "expected namespace_wildcard=false for plain wildcard, got {:?}",
4576 a
4577 );
4578 assert!(
4579 b.wildcard,
4580 "expected wildcard=true for namespace `export * as B from './b'`, got {:?}",
4581 b
4582 );
4583 assert!(
4584 b.namespace_wildcard,
4585 "expected namespace_wildcard=true for namespace re-export, got {:?}",
4586 b
4587 );
4588 }
4589
4590 #[test]
4592 fn test_b1_ns_reexport_mixed_with_named_reexport() {
4593 let source = "export { Foo } from './a';\nexport * as B from './b';";
4595 let extractor = TypeScriptExtractor::new();
4596
4597 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4599
4600 assert_eq!(
4602 re_exports.len(),
4603 2,
4604 "expected 2 re-exports, got {:?}",
4605 re_exports
4606 );
4607
4608 let re_a = re_exports.iter().find(|r| r.from_specifier == "./a");
4609 let re_b = re_exports.iter().find(|r| r.from_specifier == "./b");
4610
4611 assert!(
4612 re_a.is_some(),
4613 "expected re-export from './a', got {:?}",
4614 re_exports
4615 );
4616 assert!(
4617 re_b.is_some(),
4618 "expected re-export from './b', got {:?}",
4619 re_exports
4620 );
4621
4622 let re_a = re_a.unwrap();
4623 assert!(
4624 !re_a.wildcard,
4625 "expected wildcard=false for named re-export from './a', got {:?}",
4626 re_a
4627 );
4628 assert_eq!(
4629 re_a.symbols,
4630 vec!["Foo".to_string()],
4631 "expected symbols=[\"Foo\"] for './a', got {:?}",
4632 re_a.symbols
4633 );
4634
4635 let re_b = re_b.unwrap();
4636 assert!(
4637 re_b.wildcard,
4638 "expected wildcard=true for namespace re-export from './b', got {:?}",
4639 re_b
4640 );
4641 }
4642
4643 #[test]
4647 fn nx_fp_01_basic_app_router_path() {
4648 let result = file_path_to_route_path("app/api/users/route.ts");
4651 assert_eq!(result, Some("/api/users".to_string()));
4653 }
4654
4655 #[test]
4657 fn nx_fp_02_src_app_prefix_stripped() {
4658 let result = file_path_to_route_path("src/app/api/users/route.ts");
4661 assert_eq!(result, Some("/api/users".to_string()));
4663 }
4664
4665 #[test]
4667 fn nx_fp_03_dynamic_segment() {
4668 let result = file_path_to_route_path("app/api/users/[id]/route.ts");
4671 assert_eq!(result, Some("/api/users/:id".to_string()));
4673 }
4674
4675 #[test]
4677 fn nx_fp_04_route_group_removed() {
4678 let result = file_path_to_route_path("app/(admin)/api/route.ts");
4681 assert_eq!(result, Some("/api".to_string()));
4683 }
4684
4685 #[test]
4687 fn nx_fp_05_route_tsx_extension() {
4688 let result = file_path_to_route_path("app/api/route.tsx");
4691 assert_eq!(result, Some("/api".to_string()));
4693 }
4694
4695 #[test]
4697 fn nx_fp_06_non_route_file_rejected() {
4698 let result = file_path_to_route_path("app/api/users/page.ts");
4701 assert_eq!(result, None);
4703 }
4704
4705 #[test]
4707 fn nx_fp_07_catch_all_segment() {
4708 let result = file_path_to_route_path("app/api/[...slug]/route.ts");
4711 assert_eq!(result, Some("/api/:slug*".to_string()));
4713 }
4714
4715 #[test]
4717 fn nx_fp_08_optional_catch_all_segment() {
4718 let result = file_path_to_route_path("app/api/[[...slug]]/route.ts");
4721 assert_eq!(result, Some("/api/:slug*?".to_string()));
4723 }
4724
4725 #[test]
4727 fn nx_fp_09_root_route() {
4728 let result = file_path_to_route_path("app/route.ts");
4731 assert_eq!(result, Some("/".to_string()));
4733 }
4734
4735 #[test]
4739 fn nx_rt_01_basic_get_handler() {
4740 let source = "export async function GET(request: Request) { return Response.json([]); }";
4742 let extractor = TypeScriptExtractor::new();
4743
4744 let routes = extractor.extract_nextjs_routes(source, "app/api/users/route.ts");
4746
4747 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4749 assert_eq!(routes[0].http_method, "GET");
4750 assert_eq!(routes[0].path, "/api/users");
4751 assert_eq!(routes[0].handler_name, "GET");
4752 }
4753
4754 #[test]
4756 fn nx_rt_02_multiple_http_methods() {
4757 let source = r#"
4759export async function GET() { return Response.json([]); }
4760export async function POST() { return Response.json({}); }
4761"#;
4762 let extractor = TypeScriptExtractor::new();
4763
4764 let routes = extractor.extract_nextjs_routes(source, "app/api/users/route.ts");
4766
4767 assert_eq!(routes.len(), 2, "expected 2 routes, got {:?}", routes);
4769 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
4770 assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
4771 assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
4772 for r in &routes {
4773 assert_eq!(r.path, "/api/users");
4774 }
4775 }
4776
4777 #[test]
4779 fn nx_rt_03_dynamic_segment_path() {
4780 let source = "export async function GET() { return Response.json({}); }";
4782 let extractor = TypeScriptExtractor::new();
4783
4784 let routes = extractor.extract_nextjs_routes(source, "app/api/users/[id]/route.ts");
4786
4787 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4789 assert_eq!(routes[0].path, "/api/users/:id");
4790 }
4791
4792 #[test]
4794 fn nx_rt_04_non_route_file_returns_empty() {
4795 let source = "export async function GET() { return null; }";
4797 let extractor = TypeScriptExtractor::new();
4798
4799 let routes = extractor.extract_nextjs_routes(source, "app/api/users/page.ts");
4801
4802 assert!(
4804 routes.is_empty(),
4805 "expected empty routes for page.ts, got {:?}",
4806 routes
4807 );
4808 }
4809
4810 #[test]
4812 fn nx_rt_05_no_http_method_exports_returns_empty() {
4813 let source = "export function helper() { return null; }";
4815 let extractor = TypeScriptExtractor::new();
4816
4817 let routes = extractor.extract_nextjs_routes(source, "app/api/route.ts");
4819
4820 assert!(
4822 routes.is_empty(),
4823 "expected empty routes for helper(), got {:?}",
4824 routes
4825 );
4826 }
4827
4828 #[test]
4830 fn nx_rt_06_arrow_function_export() {
4831 let source = "export const GET = async () => Response.json([]);";
4833 let extractor = TypeScriptExtractor::new();
4834
4835 let routes = extractor.extract_nextjs_routes(source, "app/api/route.ts");
4837
4838 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4840 assert_eq!(routes[0].http_method, "GET");
4841 assert_eq!(routes[0].path, "/api");
4842 assert_eq!(routes[0].handler_name, "GET");
4843 }
4844
4845 #[test]
4847 fn nx_rt_07_empty_source_returns_empty() {
4848 let extractor = TypeScriptExtractor::new();
4850
4851 let routes = extractor.extract_nextjs_routes("", "app/api/route.ts");
4853
4854 assert!(routes.is_empty(), "expected empty routes for empty source");
4856 }
4857
4858 #[test]
4860 fn ts_l1ex_01_l1_exclusive_suppresses_l2() {
4861 use tempfile::TempDir;
4862
4863 let dir = TempDir::new().unwrap();
4870 let src_dir = dir.path().join("src");
4871 std::fs::create_dir_all(&src_dir).unwrap();
4872
4873 let user_service = src_dir.join("user.service.ts");
4874 std::fs::File::create(&user_service).unwrap();
4875 let auth_service = src_dir.join("auth.service.ts");
4876 std::fs::File::create(&auth_service).unwrap();
4877
4878 let test_file = src_dir.join("user.service.spec.ts");
4879 let test_source =
4881 "import { AuthService } from './auth.service';\ndescribe('UserService', () => {});\n";
4882
4883 let production_files = vec![
4884 user_service.to_string_lossy().into_owned(),
4885 auth_service.to_string_lossy().into_owned(),
4886 ];
4887 let mut test_sources = HashMap::new();
4888 test_sources.insert(
4889 test_file.to_string_lossy().into_owned(),
4890 test_source.to_string(),
4891 );
4892
4893 let extractor = TypeScriptExtractor::new();
4894
4895 let mappings_exclusive = extractor.map_test_files_with_imports(
4897 &production_files,
4898 &test_sources,
4899 dir.path(),
4900 true,
4901 );
4902
4903 let user_mapping = mappings_exclusive
4905 .iter()
4906 .find(|m| m.production_file.contains("user.service.ts"));
4907 assert!(
4908 user_mapping.is_some(),
4909 "expected user.service.ts in mappings, got {:?}",
4910 mappings_exclusive
4911 );
4912 assert!(
4913 !user_mapping.unwrap().test_files.is_empty(),
4914 "expected user.service.ts mapped to user.service.spec.ts, got {:?}",
4915 user_mapping.unwrap().test_files
4916 );
4917
4918 let auth_mapping = mappings_exclusive
4919 .iter()
4920 .find(|m| m.production_file.contains("auth.service.ts"));
4921 assert!(
4922 auth_mapping
4923 .map(|m| m.test_files.is_empty())
4924 .unwrap_or(true),
4925 "expected auth.service.ts NOT mapped when l1_exclusive=true, got {:?}",
4926 auth_mapping
4927 );
4928
4929 let mappings_default = extractor.map_test_files_with_imports(
4931 &production_files,
4932 &test_sources,
4933 dir.path(),
4934 false,
4935 );
4936
4937 let auth_mapping_default = mappings_default
4939 .iter()
4940 .find(|m| m.production_file.contains("auth.service.ts"));
4941 assert!(
4942 auth_mapping_default
4943 .map(|m| !m.test_files.is_empty())
4944 .unwrap_or(false),
4945 "expected auth.service.ts mapped when l1_exclusive=false, got {:?}",
4946 auth_mapping_default
4947 );
4948 }
4949
4950 #[test]
4952 fn nx_rt_08_route_group_in_path() {
4953 let source = "export async function GET() { return Response.json({}); }";
4955 let extractor = TypeScriptExtractor::new();
4956
4957 let routes = extractor.extract_nextjs_routes(source, "app/(auth)/api/login/route.ts");
4959
4960 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4962 assert_eq!(routes[0].path, "/api/login");
4963 }
4964}