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(sym), Some(spec)) = (symbol, specifier) {
673 if !spec.starts_with("./") && !spec.starts_with("../") {
675 continue;
676 }
677
678 if let Some(snode) = symbol_node {
682 if is_type_only_import(snode) {
683 continue;
684 }
685 }
686
687 result.push(ImportMapping {
688 symbol_name: sym.to_string(),
689 module_specifier: spec.to_string(),
690 file: file_path.to_string(),
691 line: symbol_line,
692 symbols: Vec::new(),
693 });
694 }
695 }
696 let specifier_to_symbols: HashMap<String, Vec<String>> =
699 result.iter().fold(HashMap::new(), |mut acc, im| {
700 acc.entry(im.module_specifier.clone())
701 .or_default()
702 .push(im.symbol_name.clone());
703 acc
704 });
705 for im in &mut result {
706 im.symbols = specifier_to_symbols
707 .get(&im.module_specifier)
708 .cloned()
709 .unwrap_or_default();
710 }
711 result
712 }
713
714 fn extract_all_import_specifiers_impl(&self, source: &str) -> Vec<(String, Vec<String>)> {
715 let mut parser = Self::parser();
716 let tree = match parser.parse(source, None) {
717 Some(t) => t,
718 None => return Vec::new(),
719 };
720 let source_bytes = source.as_bytes();
721 let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
722 let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
723 let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
724
725 let mut cursor = QueryCursor::new();
726 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
727 let mut specifier_symbols: std::collections::HashMap<String, Vec<String>> =
729 std::collections::HashMap::new();
730
731 while let Some(m) = matches.next() {
732 let mut symbol_node = None;
733 let mut symbol = None;
734 let mut specifier = None;
735 for cap in m.captures {
736 if cap.index == symbol_idx {
737 symbol_node = Some(cap.node);
738 symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
739 } else if cap.index == specifier_idx {
740 specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
741 }
742 }
743 if let (Some(sym), Some(spec)) = (symbol, specifier) {
744 if spec.starts_with("./") || spec.starts_with("../") {
746 continue;
747 }
748 if let Some(snode) = symbol_node {
750 if is_type_only_import(snode) {
751 continue;
752 }
753 }
754 specifier_symbols
755 .entry(spec.to_string())
756 .or_default()
757 .push(sym.to_string());
758 }
759 }
760
761 specifier_symbols.into_iter().collect()
762 }
763
764 fn extract_barrel_re_exports_impl(
765 &self,
766 source: &str,
767 _file_path: &str,
768 ) -> Vec<BarrelReExport> {
769 let mut parser = Self::parser();
770 let tree = match parser.parse(source, None) {
771 Some(t) => t,
772 None => return Vec::new(),
773 };
774 let source_bytes = source.as_bytes();
775 let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
776
777 let symbol_idx = query.capture_index_for_name("symbol_name");
778 let wildcard_idx = query.capture_index_for_name("wildcard");
779 let ns_wildcard_idx = query.capture_index_for_name("ns_wildcard");
780 let specifier_idx = query
781 .capture_index_for_name("from_specifier")
782 .expect("@from_specifier capture not found in re_export.scm");
783
784 let mut cursor = QueryCursor::new();
785 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
786
787 struct ReExportEntry {
791 symbols: Vec<String>,
792 wildcard: bool,
793 namespace_wildcard: bool,
794 }
795 let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
796
797 while let Some(m) = matches.next() {
798 let mut from_spec = None;
799 let mut sym_name = None;
800 let mut is_wildcard = false;
801 let mut is_ns_wildcard = false;
802
803 for cap in m.captures {
804 if ns_wildcard_idx == Some(cap.index) {
805 is_wildcard = true;
806 is_ns_wildcard = true;
807 } else if wildcard_idx == Some(cap.index) {
808 is_wildcard = true;
809 } else if cap.index == specifier_idx {
810 from_spec = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
811 } else if symbol_idx == Some(cap.index) {
812 sym_name = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
813 }
814 }
815
816 let Some(spec) = from_spec else { continue };
817
818 let entry = grouped.entry(spec).or_insert(ReExportEntry {
819 symbols: Vec::new(),
820 wildcard: false,
821 namespace_wildcard: false,
822 });
823 if is_wildcard {
824 entry.wildcard = true;
825 }
826 if is_ns_wildcard {
827 entry.namespace_wildcard = true;
828 }
829 if let Some(sym) = sym_name {
830 if !sym.is_empty() && !entry.symbols.contains(&sym) {
831 entry.symbols.push(sym);
832 }
833 }
834 }
835
836 grouped
837 .into_iter()
838 .map(|(from_spec, entry)| BarrelReExport {
839 symbols: entry.symbols,
840 from_specifier: from_spec,
841 wildcard: entry.wildcard,
842 namespace_wildcard: entry.namespace_wildcard,
843 })
844 .collect()
845 }
846
847 pub fn map_test_files_with_imports(
848 &self,
849 production_files: &[String],
850 test_sources: &HashMap<String, String>,
851 scan_root: &Path,
852 l1_exclusive: bool,
853 ) -> Vec<FileMapping> {
854 let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
855
856 let mut mappings = self.map_test_files(production_files, &test_file_list);
858
859 let canonical_root = match scan_root.canonicalize() {
861 Ok(r) => r,
862 Err(_) => return mappings,
863 };
864 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
865 for (idx, prod) in production_files.iter().enumerate() {
866 if let Ok(canonical) = Path::new(prod).canonicalize() {
867 canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
868 }
869 }
870
871 let layer1_matched: std::collections::HashSet<String> = mappings
873 .iter()
874 .flat_map(|m| m.test_files.iter().cloned())
875 .collect();
876
877 let tsconfig_paths =
879 crate::tsconfig::discover_tsconfig(&canonical_root).and_then(|tsconfig_path| {
880 let content = std::fs::read_to_string(&tsconfig_path)
881 .map_err(|e| {
882 eprintln!("[exspec] warning: failed to read tsconfig: {e}");
883 })
884 .ok()?;
885 let tsconfig_dir = tsconfig_path.parent().unwrap_or(&canonical_root);
886 crate::tsconfig::TsconfigPaths::from_str(&content, tsconfig_dir)
887 .or_else(|| {
888 eprintln!("[exspec] warning: failed to parse tsconfig paths, alias resolution disabled");
889 None
890 })
891 });
892
893 let mut nm_symlink_cache: HashMap<String, Option<PathBuf>> = HashMap::new();
895
896 for (test_file, source) in test_sources {
899 if l1_exclusive && layer1_matched.contains(test_file) {
900 continue;
901 }
902 let imports = <Self as ObserveExtractor>::extract_imports(self, source, test_file);
903 let from_file = Path::new(test_file);
904 let mut matched_indices = std::collections::HashSet::new();
905
906 for import in &imports {
907 if let Some(resolved) = exspec_core::observe::resolve_import_path(
908 self,
909 &import.module_specifier,
910 from_file,
911 &canonical_root,
912 ) {
913 exspec_core::observe::collect_import_matches(
914 self,
915 &resolved,
916 &import.symbols,
917 &canonical_to_idx,
918 &mut matched_indices,
919 &canonical_root,
920 );
921 }
922 }
923
924 let all_specifiers =
926 <Self as ObserveExtractor>::extract_all_import_specifiers(self, source);
927
928 if let Some(ref tc_paths) = tsconfig_paths {
930 for (specifier, symbols) in &all_specifiers {
931 let Some(alias_base) = tc_paths.resolve_alias(specifier) else {
932 continue;
933 };
934 if let Some(resolved) =
935 resolve_absolute_base_to_file(self, &alias_base, &canonical_root)
936 {
937 exspec_core::observe::collect_import_matches(
938 self,
939 &resolved,
940 symbols,
941 &canonical_to_idx,
942 &mut matched_indices,
943 &canonical_root,
944 );
945 }
946 }
947 }
948
949 for (specifier, symbols) in &all_specifiers {
953 if let Some(ref tc_paths) = tsconfig_paths {
955 if tc_paths.resolve_alias(specifier).is_some() {
956 continue;
957 }
958 }
959 if let Some(resolved_dir) =
960 resolve_node_modules_symlink(specifier, &canonical_root, &mut nm_symlink_cache)
961 {
962 let resolved_dir_str = resolved_dir.to_string_lossy().into_owned();
965 for prod_canonical in canonical_to_idx.keys() {
966 if prod_canonical.starts_with(&resolved_dir_str) {
967 exspec_core::observe::collect_import_matches(
968 self,
969 prod_canonical,
970 symbols,
971 &canonical_to_idx,
972 &mut matched_indices,
973 &canonical_root,
974 );
975 }
976 }
977 }
978 }
979
980 for idx in matched_indices {
981 if !mappings[idx].test_files.contains(test_file) {
983 mappings[idx].test_files.push(test_file.clone());
984 }
985 }
986 }
987
988 for mapping in &mut mappings {
991 let has_layer1 = mapping
992 .test_files
993 .iter()
994 .any(|t| layer1_matched.contains(t));
995 if !has_layer1 && !mapping.test_files.is_empty() {
996 mapping.strategy = MappingStrategy::ImportTracing;
997 }
998 }
999
1000 mappings
1001 }
1002}
1003
1004fn resolve_node_modules_symlink(
1017 specifier: &str,
1018 scan_root: &Path,
1019 cache: &mut HashMap<String, Option<PathBuf>>,
1020) -> Option<PathBuf> {
1021 if let Some(cached) = cache.get(specifier) {
1022 return cached.clone();
1023 }
1024
1025 let candidate = scan_root.join("node_modules").join(specifier);
1026 let result = match std::fs::symlink_metadata(&candidate) {
1027 Ok(meta) if meta.file_type().is_symlink() => candidate.canonicalize().ok(),
1028 _ => None,
1029 };
1030
1031 cache.insert(specifier.to_string(), result.clone());
1032 result
1033}
1034
1035pub fn resolve_import_path(
1038 module_specifier: &str,
1039 from_file: &Path,
1040 scan_root: &Path,
1041) -> Option<String> {
1042 let ext = crate::TypeScriptExtractor::new();
1043 exspec_core::observe::resolve_import_path(&ext, module_specifier, from_file, scan_root)
1044}
1045
1046fn resolve_absolute_base_to_file(
1048 ext: &dyn ObserveExtractor,
1049 base: &Path,
1050 canonical_root: &Path,
1051) -> Option<String> {
1052 exspec_core::observe::resolve_absolute_base_to_file(ext, base, canonical_root)
1053}
1054
1055fn is_type_definition_file(file_path: &str) -> bool {
1058 let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1059 return false;
1060 };
1061 if let Some(stem) = Path::new(file_name).file_stem().and_then(|s| s.to_str()) {
1062 for suffix in &[".enum", ".interface", ".exception"] {
1063 if stem.ends_with(suffix) && stem != &suffix[1..] {
1064 return true;
1065 }
1066 }
1067 }
1068 false
1069}
1070
1071fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
1079 if file_path
1083 .split('/')
1084 .any(|seg| seg == "test" || seg == "__tests__")
1085 {
1086 return true;
1087 }
1088
1089 let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
1090 return false;
1091 };
1092
1093 if matches!(
1095 file_name,
1096 "constants.ts"
1097 | "constants.js"
1098 | "constants.tsx"
1099 | "constants.jsx"
1100 | "index.ts"
1101 | "index.js"
1102 | "index.tsx"
1103 | "index.jsx"
1104 ) {
1105 return true;
1106 }
1107
1108 if !is_known_production && is_type_definition_file(file_path) {
1112 return true;
1113 }
1114
1115 false
1116}
1117
1118fn file_exports_any_symbol(file_path: &Path, symbols: &[String]) -> bool {
1121 if symbols.is_empty() {
1122 return true;
1123 }
1124 let source = match std::fs::read_to_string(file_path) {
1125 Ok(s) => s,
1126 Err(_) => return false,
1127 };
1128 let mut parser = TypeScriptExtractor::parser();
1129 let tree = match parser.parse(&source, None) {
1130 Some(t) => t,
1131 None => return false,
1132 };
1133 let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
1134 let symbol_idx = query
1135 .capture_index_for_name("symbol_name")
1136 .expect("@symbol_name capture not found in exported_symbol.scm");
1137
1138 let mut cursor = QueryCursor::new();
1139 let source_bytes = source.as_bytes();
1140 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1141 while let Some(m) = matches.next() {
1142 for cap in m.captures {
1143 if cap.index == symbol_idx {
1144 let name = cap.node.utf8_text(source_bytes).unwrap_or("");
1145 if symbols.iter().any(|s| s == name) {
1146 return true;
1147 }
1148 }
1149 }
1150 }
1151 false
1152}
1153
1154pub fn resolve_barrel_exports(
1156 barrel_path: &Path,
1157 symbols: &[String],
1158 scan_root: &Path,
1159) -> Vec<PathBuf> {
1160 let ext = crate::TypeScriptExtractor::new();
1161 exspec_core::observe::resolve_barrel_exports(&ext, barrel_path, symbols, scan_root)
1162}
1163
1164const NEXTJS_HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
1166
1167const NEXTJS_ROUTE_HANDLER_QUERY: &str = include_str!("../queries/nextjs_route_handler.scm");
1168static NEXTJS_ROUTE_HANDLER_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
1169
1170pub fn file_path_to_route_path(file_path: &str) -> Option<String> {
1184 let normalized = file_path.replace('\\', "/");
1186
1187 if !normalized.ends_with("/route.ts") && !normalized.ends_with("/route.tsx") {
1189 return None;
1190 }
1191
1192 let path = if let Some(pos) = normalized.find("/src/app/") {
1195 &normalized[pos + "/src/app/".len()..]
1196 } else if let Some(pos) = normalized.find("/app/") {
1197 &normalized[pos + "/app/".len()..]
1198 } else if let Some(stripped) = normalized.strip_prefix("src/app/") {
1199 stripped
1200 } else if let Some(stripped) = normalized.strip_prefix("app/") {
1201 stripped
1202 } else {
1203 return None;
1204 };
1205
1206 let path = path
1208 .strip_suffix("/route.ts")
1209 .or_else(|| path.strip_suffix("/route.tsx"))
1210 .unwrap_or("");
1211
1212 let mut result = String::new();
1214 for segment in path.split('/') {
1215 if segment.is_empty() {
1216 continue;
1217 }
1218 if segment.starts_with('(') && segment.ends_with(')') {
1220 continue;
1221 }
1222 if segment.starts_with("[[...") && segment.ends_with("]]") {
1224 let name = &segment[5..segment.len() - 2];
1225 result.push('/');
1226 result.push(':');
1227 result.push_str(name);
1228 result.push_str("*?");
1229 continue;
1230 }
1231 if segment.starts_with("[...") && segment.ends_with(']') {
1233 let name = &segment[4..segment.len() - 1];
1234 result.push('/');
1235 result.push(':');
1236 result.push_str(name);
1237 result.push('*');
1238 continue;
1239 }
1240 if segment.starts_with('[') && segment.ends_with(']') {
1242 let name = &segment[1..segment.len() - 1];
1243 result.push('/');
1244 result.push(':');
1245 result.push_str(name);
1246 continue;
1247 }
1248 result.push('/');
1250 result.push_str(segment);
1251 }
1252
1253 if result.is_empty() {
1254 Some("/".to_string())
1255 } else {
1256 Some(result)
1257 }
1258}
1259
1260impl TypeScriptExtractor {
1261 pub fn extract_nextjs_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
1266 let route_path = match file_path_to_route_path(file_path) {
1267 Some(p) => p,
1268 None => return Vec::new(),
1269 };
1270
1271 if source.is_empty() {
1272 return Vec::new();
1273 }
1274
1275 let mut parser = Self::parser();
1276 let tree = match parser.parse(source, None) {
1277 Some(t) => t,
1278 None => return Vec::new(),
1279 };
1280 let source_bytes = source.as_bytes();
1281
1282 let query = cached_query(
1283 &NEXTJS_ROUTE_HANDLER_QUERY_CACHE,
1284 NEXTJS_ROUTE_HANDLER_QUERY,
1285 );
1286
1287 let mut cursor = QueryCursor::new();
1288 let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
1289
1290 let handler_name_idx = query
1291 .capture_index_for_name("handler_name")
1292 .expect("nextjs_route_handler.scm must define @handler_name capture");
1293
1294 let mut routes = Vec::new();
1295 while let Some(m) = matches.next() {
1296 for cap in m.captures {
1297 if cap.index == handler_name_idx {
1298 let name = match cap.node.utf8_text(source_bytes) {
1299 Ok(n) => n.to_string(),
1300 Err(_) => continue,
1301 };
1302 if NEXTJS_HTTP_METHODS.contains(&name.as_str()) {
1303 let line = cap.node.start_position().row + 1;
1304 routes.push(Route {
1305 http_method: name.clone(),
1306 path: route_path.clone(),
1307 handler_name: name,
1308 class_name: String::new(),
1309 file: file_path.to_string(),
1310 line,
1311 });
1312 }
1313 }
1314 }
1315 }
1316
1317 routes
1318 }
1319}
1320
1321fn production_stem(path: &str) -> Option<&str> {
1322 Path::new(path).file_stem()?.to_str()
1323}
1324
1325fn test_stem(path: &str) -> Option<&str> {
1326 let stem = Path::new(path).file_stem()?.to_str()?;
1327 stem.strip_suffix(".spec")
1328 .or_else(|| stem.strip_suffix(".test"))
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334
1335 fn fixture(name: &str) -> String {
1336 let path = format!(
1337 "{}/tests/fixtures/typescript/observe/{}",
1338 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-typescript", ""),
1339 name
1340 );
1341 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
1342 }
1343
1344 #[test]
1346 fn exported_functions_extracted() {
1347 let source = fixture("exported_functions.ts");
1349 let extractor = TypeScriptExtractor::new();
1350
1351 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1353
1354 let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1356 let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1357 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1358 assert!(
1359 names.contains(&"findById"),
1360 "expected findById in {names:?}"
1361 );
1362 }
1363
1364 #[test]
1366 fn non_exported_function_has_flag_false() {
1367 let source = fixture("exported_functions.ts");
1369 let extractor = TypeScriptExtractor::new();
1370
1371 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1373
1374 let helper = funcs.iter().find(|f| f.name == "internalHelper");
1376 assert!(helper.is_some(), "expected internalHelper to be extracted");
1377 assert!(!helper.unwrap().is_exported);
1378 }
1379
1380 #[test]
1382 fn class_methods_with_class_name() {
1383 let source = fixture("class_methods.ts");
1385 let extractor = TypeScriptExtractor::new();
1386
1387 let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
1389
1390 let controller_methods: Vec<&ProductionFunction> = funcs
1392 .iter()
1393 .filter(|f| f.class_name.as_deref() == Some("UsersController"))
1394 .collect();
1395 let names: Vec<&str> = controller_methods.iter().map(|f| f.name.as_str()).collect();
1396 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1397 assert!(names.contains(&"create"), "expected create in {names:?}");
1398 assert!(
1399 names.contains(&"validate"),
1400 "expected validate in {names:?}"
1401 );
1402 }
1403
1404 #[test]
1406 fn exported_class_is_exported() {
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 assert!(
1420 controller_methods.iter().all(|f| f.is_exported),
1421 "all UsersController methods should be exported"
1422 );
1423
1424 let internal_methods: Vec<&ProductionFunction> = funcs
1426 .iter()
1427 .filter(|f| f.class_name.as_deref() == Some("InternalService"))
1428 .collect();
1429 assert!(
1430 !internal_methods.is_empty(),
1431 "expected InternalService methods"
1432 );
1433 assert!(
1434 internal_methods.iter().all(|f| !f.is_exported),
1435 "all InternalService methods should not be exported"
1436 );
1437 }
1438
1439 #[test]
1441 fn arrow_exports_extracted() {
1442 let source = fixture("arrow_exports.ts");
1444 let extractor = TypeScriptExtractor::new();
1445
1446 let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1448
1449 let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
1451 let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
1452 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1453 assert!(
1454 names.contains(&"findById"),
1455 "expected findById in {names:?}"
1456 );
1457 }
1458
1459 #[test]
1461 fn non_exported_arrow_flag_false() {
1462 let source = fixture("arrow_exports.ts");
1464 let extractor = TypeScriptExtractor::new();
1465
1466 let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
1468
1469 let internal = funcs.iter().find(|f| f.name == "internalFn");
1471 assert!(internal.is_some(), "expected internalFn to be extracted");
1472 assert!(!internal.unwrap().is_exported);
1473 }
1474
1475 #[test]
1477 fn mixed_file_all_types() {
1478 let source = fixture("mixed.ts");
1480 let extractor = TypeScriptExtractor::new();
1481
1482 let funcs = extractor.extract_production_functions(&source, "mixed.ts");
1484
1485 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1487 assert!(names.contains(&"getUser"), "expected getUser in {names:?}");
1489 assert!(
1490 names.contains(&"createUser"),
1491 "expected createUser in {names:?}"
1492 );
1493 assert!(
1495 names.contains(&"formatName"),
1496 "expected formatName in {names:?}"
1497 );
1498 assert!(
1499 names.contains(&"validateInput"),
1500 "expected validateInput in {names:?}"
1501 );
1502
1503 let get_user = funcs.iter().find(|f| f.name == "getUser").unwrap();
1505 assert!(get_user.is_exported);
1506 let format_name = funcs.iter().find(|f| f.name == "formatName").unwrap();
1507 assert!(!format_name.is_exported);
1508
1509 let find_all = funcs
1511 .iter()
1512 .find(|f| f.name == "findAll" && f.class_name.is_some())
1513 .unwrap();
1514 assert_eq!(find_all.class_name.as_deref(), Some("UserService"));
1515 assert!(find_all.is_exported);
1516
1517 let transform = funcs.iter().find(|f| f.name == "transform").unwrap();
1518 assert_eq!(transform.class_name.as_deref(), Some("PrivateHelper"));
1519 assert!(!transform.is_exported);
1520 }
1521
1522 #[test]
1524 fn decorated_methods_extracted() {
1525 let source = fixture("nestjs_controller.ts");
1527 let extractor = TypeScriptExtractor::new();
1528
1529 let funcs = extractor.extract_production_functions(&source, "nestjs_controller.ts");
1531
1532 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1534 assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
1535 assert!(names.contains(&"create"), "expected create in {names:?}");
1536 assert!(names.contains(&"remove"), "expected remove in {names:?}");
1537
1538 for func in &funcs {
1539 assert_eq!(func.class_name.as_deref(), Some("UsersController"));
1540 assert!(func.is_exported);
1541 }
1542 }
1543
1544 #[test]
1546 fn line_numbers_correct() {
1547 let source = fixture("exported_functions.ts");
1549 let extractor = TypeScriptExtractor::new();
1550
1551 let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
1553
1554 let find_all = funcs.iter().find(|f| f.name == "findAll").unwrap();
1556 assert_eq!(find_all.line, 1, "findAll should be on line 1");
1557
1558 let find_by_id = funcs.iter().find(|f| f.name == "findById").unwrap();
1559 assert_eq!(find_by_id.line, 5, "findById should be on line 5");
1560
1561 let helper = funcs.iter().find(|f| f.name == "internalHelper").unwrap();
1562 assert_eq!(helper.line, 9, "internalHelper should be on line 9");
1563 }
1564
1565 #[test]
1567 fn empty_source_returns_empty() {
1568 let extractor = TypeScriptExtractor::new();
1570
1571 let funcs = extractor.extract_production_functions("", "empty.ts");
1573
1574 assert!(funcs.is_empty());
1576 }
1577
1578 #[test]
1582 fn basic_controller_routes() {
1583 let source = fixture("nestjs_controller.ts");
1585 let extractor = TypeScriptExtractor::new();
1586
1587 let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1589
1590 assert_eq!(routes.len(), 3, "expected 3 routes, got {routes:?}");
1592 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1593 assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
1594 assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
1595 assert!(
1596 methods.contains(&"DELETE"),
1597 "expected DELETE in {methods:?}"
1598 );
1599
1600 let get_route = routes.iter().find(|r| r.http_method == "GET").unwrap();
1601 assert_eq!(get_route.path, "/users");
1602
1603 let delete_route = routes.iter().find(|r| r.http_method == "DELETE").unwrap();
1604 assert_eq!(delete_route.path, "/users/:id");
1605 }
1606
1607 #[test]
1609 fn route_path_combination() {
1610 let source = fixture("nestjs_routes_advanced.ts");
1612 let extractor = TypeScriptExtractor::new();
1613
1614 let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1616
1617 let active = routes
1619 .iter()
1620 .find(|r| r.handler_name == "findActive")
1621 .unwrap();
1622 assert_eq!(active.http_method, "GET");
1623 assert_eq!(active.path, "/api/v1/users/active");
1624 }
1625
1626 #[test]
1628 fn controller_no_path() {
1629 let source = fixture("nestjs_empty_controller.ts");
1631 let extractor = TypeScriptExtractor::new();
1632
1633 let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1635
1636 assert_eq!(routes.len(), 1, "expected 1 route, got {routes:?}");
1638 assert_eq!(routes[0].http_method, "GET");
1639 assert_eq!(routes[0].path, "/health");
1640 }
1641
1642 #[test]
1644 fn method_without_route_decorator() {
1645 let source = fixture("nestjs_empty_controller.ts");
1647 let extractor = TypeScriptExtractor::new();
1648
1649 let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
1651
1652 let helper = routes.iter().find(|r| r.handler_name == "helperMethod");
1654 assert!(helper.is_none(), "helperMethod should not be a route");
1655 }
1656
1657 #[test]
1659 fn all_http_methods() {
1660 let source = fixture("nestjs_routes_advanced.ts");
1662 let extractor = TypeScriptExtractor::new();
1663
1664 let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
1666
1667 assert_eq!(routes.len(), 9, "expected 9 routes, got {routes:?}");
1669 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
1670 assert!(methods.contains(&"GET"));
1671 assert!(methods.contains(&"POST"));
1672 assert!(methods.contains(&"PUT"));
1673 assert!(methods.contains(&"PATCH"));
1674 assert!(methods.contains(&"DELETE"));
1675 assert!(methods.contains(&"HEAD"));
1676 assert!(methods.contains(&"OPTIONS"));
1677 }
1678
1679 #[test]
1681 fn use_guards_decorator() {
1682 let source = fixture("nestjs_guards_pipes.ts");
1684 let extractor = TypeScriptExtractor::new();
1685
1686 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1688
1689 let guards: Vec<&DecoratorInfo> = decorators
1691 .iter()
1692 .filter(|d| d.name == "UseGuards")
1693 .collect();
1694 assert!(!guards.is_empty(), "expected UseGuards decorators");
1695 let auth_guard = guards
1696 .iter()
1697 .find(|d| d.arguments.contains(&"AuthGuard".to_string()));
1698 assert!(auth_guard.is_some(), "expected AuthGuard argument");
1699 }
1700
1701 #[test]
1703 fn multiple_decorators_on_method() {
1704 let source = fixture("nestjs_controller.ts");
1706 let extractor = TypeScriptExtractor::new();
1707
1708 let decorators = extractor.extract_decorators(&source, "nestjs_controller.ts");
1710
1711 let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1713 assert!(
1714 names.contains(&"UseGuards"),
1715 "expected UseGuards in {names:?}"
1716 );
1717 assert!(
1718 !names.contains(&"Delete"),
1719 "Delete should not be in decorators"
1720 );
1721 }
1722
1723 #[test]
1725 fn class_validator_on_dto() {
1726 let source = fixture("nestjs_dto_validation.ts");
1728 let extractor = TypeScriptExtractor::new();
1729
1730 let decorators = extractor.extract_decorators(&source, "nestjs_dto_validation.ts");
1732
1733 let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
1735 assert!(names.contains(&"IsEmail"), "expected IsEmail in {names:?}");
1736 assert!(
1737 names.contains(&"IsNotEmpty"),
1738 "expected IsNotEmpty in {names:?}"
1739 );
1740 }
1741
1742 #[test]
1744 fn use_pipes_decorator() {
1745 let source = fixture("nestjs_guards_pipes.ts");
1747 let extractor = TypeScriptExtractor::new();
1748
1749 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1751
1752 let pipes: Vec<&DecoratorInfo> =
1754 decorators.iter().filter(|d| d.name == "UsePipes").collect();
1755 assert!(!pipes.is_empty(), "expected UsePipes decorators");
1756 assert!(pipes[0].arguments.contains(&"ValidationPipe".to_string()));
1757 }
1758
1759 #[test]
1761 fn empty_source_returns_empty_routes_and_decorators() {
1762 let extractor = TypeScriptExtractor::new();
1764
1765 let routes = extractor.extract_routes("", "empty.ts");
1767 let decorators = extractor.extract_decorators("", "empty.ts");
1768
1769 assert!(routes.is_empty());
1771 assert!(decorators.is_empty());
1772 }
1773
1774 #[test]
1776 fn non_nestjs_class_ignored() {
1777 let source = fixture("class_methods.ts");
1779 let extractor = TypeScriptExtractor::new();
1780
1781 let routes = extractor.extract_routes(&source, "class_methods.ts");
1783
1784 assert!(routes.is_empty(), "expected no routes from plain class");
1786 }
1787
1788 #[test]
1790 fn route_handler_and_class_name() {
1791 let source = fixture("nestjs_controller.ts");
1793 let extractor = TypeScriptExtractor::new();
1794
1795 let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
1797
1798 let handlers: Vec<&str> = routes.iter().map(|r| r.handler_name.as_str()).collect();
1800 assert!(handlers.contains(&"findAll"));
1801 assert!(handlers.contains(&"create"));
1802 assert!(handlers.contains(&"remove"));
1803 for route in &routes {
1804 assert_eq!(route.class_name, "UsersController");
1805 }
1806 }
1807
1808 #[test]
1810 fn class_level_use_guards() {
1811 let source = fixture("nestjs_guards_pipes.ts");
1813 let extractor = TypeScriptExtractor::new();
1814
1815 let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
1817
1818 let class_guards: Vec<&DecoratorInfo> = decorators
1820 .iter()
1821 .filter(|d| {
1822 d.name == "UseGuards"
1823 && d.target_name == "ProtectedController"
1824 && d.class_name == "ProtectedController"
1825 })
1826 .collect();
1827 assert!(
1828 !class_guards.is_empty(),
1829 "expected class-level UseGuards, got {decorators:?}"
1830 );
1831 assert!(class_guards[0]
1832 .arguments
1833 .contains(&"JwtAuthGuard".to_string()));
1834 }
1835
1836 #[test]
1838 fn dynamic_controller_path() {
1839 let source = fixture("nestjs_dynamic_routes.ts");
1841 let extractor = TypeScriptExtractor::new();
1842
1843 let routes = extractor.extract_routes(&source, "nestjs_dynamic_routes.ts");
1845
1846 assert_eq!(routes.len(), 1);
1848 assert!(
1849 routes[0].path.contains("<dynamic>"),
1850 "expected <dynamic> in path, got {:?}",
1851 routes[0].path
1852 );
1853 }
1854
1855 #[test]
1857 fn abstract_class_methods_extracted() {
1858 let source = fixture("abstract_class.ts");
1860 let extractor = TypeScriptExtractor::new();
1861
1862 let funcs = extractor.extract_production_functions(&source, "abstract_class.ts");
1864
1865 let validate = funcs.iter().find(|f| f.name == "validate");
1867 assert!(validate.is_some(), "expected validate to be extracted");
1868 let validate = validate.unwrap();
1869 assert_eq!(validate.class_name.as_deref(), Some("BaseService"));
1870 assert!(validate.is_exported);
1871
1872 let process = funcs.iter().find(|f| f.name == "process");
1873 assert!(process.is_some(), "expected process to be extracted");
1874 let process = process.unwrap();
1875 assert_eq!(process.class_name.as_deref(), Some("InternalBase"));
1876 assert!(!process.is_exported);
1877 }
1878
1879 #[test]
1880 fn basic_spec_mapping() {
1881 let extractor = TypeScriptExtractor::new();
1883 let production_files = vec!["src/users.service.ts".to_string()];
1884 let test_files = vec!["src/users.service.spec.ts".to_string()];
1885
1886 let mappings = extractor.map_test_files(&production_files, &test_files);
1888
1889 assert_eq!(
1891 mappings,
1892 vec![FileMapping {
1893 production_file: "src/users.service.ts".to_string(),
1894 test_files: vec!["src/users.service.spec.ts".to_string()],
1895 strategy: MappingStrategy::FileNameConvention,
1896 }]
1897 );
1898 }
1899
1900 #[test]
1901 fn test_suffix_mapping() {
1902 let extractor = TypeScriptExtractor::new();
1904 let production_files = vec!["src/utils.ts".to_string()];
1905 let test_files = vec!["src/utils.test.ts".to_string()];
1906
1907 let mappings = extractor.map_test_files(&production_files, &test_files);
1909
1910 assert_eq!(
1912 mappings[0].test_files,
1913 vec!["src/utils.test.ts".to_string()]
1914 );
1915 }
1916
1917 #[test]
1918 fn multiple_test_files() {
1919 let extractor = TypeScriptExtractor::new();
1921 let production_files = vec!["src/app.ts".to_string()];
1922 let test_files = vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()];
1923
1924 let mappings = extractor.map_test_files(&production_files, &test_files);
1926
1927 assert_eq!(
1929 mappings[0].test_files,
1930 vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()]
1931 );
1932 }
1933
1934 #[test]
1935 fn nestjs_controller() {
1936 let extractor = TypeScriptExtractor::new();
1938 let production_files = vec!["src/users/users.controller.ts".to_string()];
1939 let test_files = vec!["src/users/users.controller.spec.ts".to_string()];
1940
1941 let mappings = extractor.map_test_files(&production_files, &test_files);
1943
1944 assert_eq!(
1946 mappings[0].test_files,
1947 vec!["src/users/users.controller.spec.ts".to_string()]
1948 );
1949 }
1950
1951 #[test]
1952 fn no_matching_test() {
1953 let extractor = TypeScriptExtractor::new();
1955 let production_files = vec!["src/orphan.ts".to_string()];
1956 let test_files = vec!["src/other.spec.ts".to_string()];
1957
1958 let mappings = extractor.map_test_files(&production_files, &test_files);
1960
1961 assert_eq!(mappings[0].test_files, Vec::<String>::new());
1963 }
1964
1965 #[test]
1966 fn different_directory_no_match() {
1967 let extractor = TypeScriptExtractor::new();
1969 let production_files = vec!["src/users.ts".to_string()];
1970 let test_files = vec!["test/users.spec.ts".to_string()];
1971
1972 let mappings = extractor.map_test_files(&production_files, &test_files);
1974
1975 assert_eq!(mappings[0].test_files, Vec::<String>::new());
1977 }
1978
1979 #[test]
1980 fn empty_input() {
1981 let extractor = TypeScriptExtractor::new();
1983
1984 let mappings = extractor.map_test_files(&[], &[]);
1986
1987 assert!(mappings.is_empty());
1989 }
1990
1991 #[test]
1992 fn tsx_files() {
1993 let extractor = TypeScriptExtractor::new();
1995 let production_files = vec!["src/App.tsx".to_string()];
1996 let test_files = vec!["src/App.test.tsx".to_string()];
1997
1998 let mappings = extractor.map_test_files(&production_files, &test_files);
2000
2001 assert_eq!(mappings[0].test_files, vec!["src/App.test.tsx".to_string()]);
2003 }
2004
2005 #[test]
2006 fn unmatched_test_ignored() {
2007 let extractor = TypeScriptExtractor::new();
2009 let production_files = vec!["src/a.ts".to_string()];
2010 let test_files = vec!["src/a.spec.ts".to_string(), "src/b.spec.ts".to_string()];
2011
2012 let mappings = extractor.map_test_files(&production_files, &test_files);
2014
2015 assert_eq!(mappings.len(), 1);
2017 assert_eq!(mappings[0].test_files, vec!["src/a.spec.ts".to_string()]);
2018 }
2019
2020 #[test]
2021 fn stem_extraction() {
2022 assert_eq!(
2026 production_stem("src/users.service.ts"),
2027 Some("users.service")
2028 );
2029 assert_eq!(production_stem("src/App.tsx"), Some("App"));
2030 assert_eq!(
2031 test_stem("src/users.service.spec.ts"),
2032 Some("users.service")
2033 );
2034 assert_eq!(test_stem("src/utils.test.ts"), Some("utils"));
2035 assert_eq!(test_stem("src/App.test.tsx"), Some("App"));
2036 assert_eq!(test_stem("src/invalid.ts"), None);
2037 }
2038
2039 #[test]
2043 fn im1_named_import_symbol_and_specifier() {
2044 let source = fixture("import_named.ts");
2046 let extractor = TypeScriptExtractor::new();
2047
2048 let imports = extractor.extract_imports(&source, "import_named.ts");
2050
2051 let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2053 assert!(
2054 found.is_some(),
2055 "expected UsersController in imports: {imports:?}"
2056 );
2057 assert_eq!(
2058 found.unwrap().module_specifier,
2059 "./users.controller",
2060 "wrong specifier"
2061 );
2062 }
2063
2064 #[test]
2066 fn im2_multiple_named_imports() {
2067 let source = fixture("import_mixed.ts");
2069 let extractor = TypeScriptExtractor::new();
2070
2071 let imports = extractor.extract_imports(&source, "import_mixed.ts");
2073
2074 let from_module: Vec<&ImportMapping> = imports
2076 .iter()
2077 .filter(|i| i.module_specifier == "./module")
2078 .collect();
2079 let symbols: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
2080 assert!(symbols.contains(&"A"), "expected A in symbols: {symbols:?}");
2081 assert!(symbols.contains(&"B"), "expected B in symbols: {symbols:?}");
2082 assert!(
2084 from_module.len() >= 2,
2085 "expected at least 2 imports from ./module, got {from_module:?}"
2086 );
2087 }
2088
2089 #[test]
2091 fn im3_alias_import_original_name() {
2092 let source = fixture("import_mixed.ts");
2094 let extractor = TypeScriptExtractor::new();
2095
2096 let imports = extractor.extract_imports(&source, "import_mixed.ts");
2098
2099 let a_count = imports.iter().filter(|i| i.symbol_name == "A").count();
2102 assert!(
2103 a_count >= 1,
2104 "expected at least one import with symbol_name 'A', got: {imports:?}"
2105 );
2106 }
2107
2108 #[test]
2110 fn im4_default_import() {
2111 let source = fixture("import_default.ts");
2113 let extractor = TypeScriptExtractor::new();
2114
2115 let imports = extractor.extract_imports(&source, "import_default.ts");
2117
2118 assert_eq!(imports.len(), 1, "expected 1 import, got {imports:?}");
2120 assert_eq!(imports[0].symbol_name, "UsersController");
2121 assert_eq!(imports[0].module_specifier, "./users.controller");
2122 }
2123
2124 #[test]
2126 fn im5_npm_package_excluded() {
2127 let source = "import { Test } from '@nestjs/testing';";
2129 let extractor = TypeScriptExtractor::new();
2130
2131 let imports = extractor.extract_imports(source, "test.ts");
2133
2134 assert!(imports.is_empty(), "expected empty vec, got {imports:?}");
2136 }
2137
2138 #[test]
2140 fn im6_relative_parent_path() {
2141 let source = fixture("import_named.ts");
2143 let extractor = TypeScriptExtractor::new();
2144
2145 let imports = extractor.extract_imports(&source, "import_named.ts");
2147
2148 let found = imports
2150 .iter()
2151 .find(|i| i.module_specifier == "../services/s.service");
2152 assert!(
2153 found.is_some(),
2154 "expected ../services/s.service in imports: {imports:?}"
2155 );
2156 assert_eq!(found.unwrap().symbol_name, "S");
2157 }
2158
2159 #[test]
2161 fn im7_empty_source_returns_empty() {
2162 let extractor = TypeScriptExtractor::new();
2164
2165 let imports = extractor.extract_imports("", "empty.ts");
2167
2168 assert!(imports.is_empty());
2170 }
2171
2172 #[test]
2174 fn im8_namespace_import() {
2175 let source = fixture("import_namespace.ts");
2177 let extractor = TypeScriptExtractor::new();
2178
2179 let imports = extractor.extract_imports(&source, "import_namespace.ts");
2181
2182 let found = imports.iter().find(|i| i.symbol_name == "UsersController");
2184 assert!(
2185 found.is_some(),
2186 "expected UsersController in imports: {imports:?}"
2187 );
2188 assert_eq!(found.unwrap().module_specifier, "./users.controller");
2189
2190 let helpers = imports.iter().find(|i| i.symbol_name == "helpers");
2192 assert!(
2193 helpers.is_some(),
2194 "expected helpers in imports: {imports:?}"
2195 );
2196 assert_eq!(helpers.unwrap().module_specifier, "../utils/helpers");
2197
2198 let express = imports.iter().find(|i| i.symbol_name == "express");
2200 assert!(
2201 express.is_none(),
2202 "npm package should be excluded: {imports:?}"
2203 );
2204 }
2205
2206 #[test]
2208 fn im9_type_only_import_excluded() {
2209 let source = fixture("import_type_only.ts");
2211 let extractor = TypeScriptExtractor::new();
2212
2213 let imports = extractor.extract_imports(&source, "import_type_only.ts");
2215
2216 let user_service = imports.iter().find(|i| i.symbol_name == "UserService");
2218 assert!(
2219 user_service.is_none(),
2220 "type-only import should be excluded: {imports:?}"
2221 );
2222
2223 let create_dto = imports.iter().find(|i| i.symbol_name == "CreateUserDto");
2225 assert!(
2226 create_dto.is_none(),
2227 "inline type modifier import should be excluded: {imports:?}"
2228 );
2229
2230 let controller = imports.iter().find(|i| i.symbol_name == "UsersController");
2232 assert!(
2233 controller.is_some(),
2234 "normal import should remain: {imports:?}"
2235 );
2236 assert_eq!(controller.unwrap().module_specifier, "./users.controller");
2237 }
2238
2239 #[test]
2243 fn rp1_resolve_ts_without_extension() {
2244 use std::io::Write as IoWrite;
2245 use tempfile::TempDir;
2246
2247 let dir = TempDir::new().unwrap();
2249 let src_dir = dir.path().join("src");
2250 std::fs::create_dir_all(&src_dir).unwrap();
2251 let target = src_dir.join("users.controller.ts");
2252 std::fs::File::create(&target).unwrap();
2253
2254 let from_file = src_dir.join("users.controller.spec.ts");
2255
2256 let result = resolve_import_path("./users.controller", &from_file, dir.path());
2258
2259 assert!(
2261 result.is_some(),
2262 "expected Some for existing .ts file, got None"
2263 );
2264 let resolved = result.unwrap();
2265 assert!(
2266 resolved.ends_with("users.controller.ts"),
2267 "expected path ending with users.controller.ts, got {resolved}"
2268 );
2269 }
2270
2271 #[test]
2273 fn rp2_resolve_ts_with_extension() {
2274 use tempfile::TempDir;
2275
2276 let dir = TempDir::new().unwrap();
2278 let src_dir = dir.path().join("src");
2279 std::fs::create_dir_all(&src_dir).unwrap();
2280 let target = src_dir.join("users.controller.ts");
2281 std::fs::File::create(&target).unwrap();
2282
2283 let from_file = src_dir.join("users.controller.spec.ts");
2284
2285 let result = resolve_import_path("./users.controller.ts", &from_file, dir.path());
2287
2288 assert!(
2290 result.is_some(),
2291 "expected Some for existing file with explicit .ts extension"
2292 );
2293 }
2294
2295 #[test]
2297 fn rp3_nonexistent_file_returns_none() {
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 from_file = src_dir.join("some.spec.ts");
2305
2306 let result = resolve_import_path("./nonexistent", &from_file, dir.path());
2308
2309 assert!(result.is_none(), "expected None for nonexistent file");
2311 }
2312
2313 #[test]
2315 fn rp4_outside_scan_root_returns_none() {
2316 use tempfile::TempDir;
2317
2318 let dir = TempDir::new().unwrap();
2320 let src_dir = dir.path().join("src");
2321 std::fs::create_dir_all(&src_dir).unwrap();
2322 let from_file = src_dir.join("some.spec.ts");
2323
2324 let result = resolve_import_path("../../outside", &from_file, dir.path());
2326
2327 assert!(result.is_none(), "expected None for path outside scan_root");
2329 }
2330
2331 #[test]
2333 fn rp5_resolve_tsx_without_extension() {
2334 use tempfile::TempDir;
2335
2336 let dir = TempDir::new().unwrap();
2338 let src_dir = dir.path().join("src");
2339 std::fs::create_dir_all(&src_dir).unwrap();
2340 let target = src_dir.join("App.tsx");
2341 std::fs::File::create(&target).unwrap();
2342
2343 let from_file = src_dir.join("App.test.tsx");
2344
2345 let result = resolve_import_path("./App", &from_file, dir.path());
2347
2348 assert!(
2350 result.is_some(),
2351 "expected Some for existing .tsx file, got None"
2352 );
2353 let resolved = result.unwrap();
2354 assert!(
2355 resolved.ends_with("App.tsx"),
2356 "expected path ending with App.tsx, got {resolved}"
2357 );
2358 }
2359
2360 #[test]
2364 fn mt1_layer1_and_layer2_both_matched() {
2365 use tempfile::TempDir;
2366
2367 let dir = TempDir::new().unwrap();
2372 let src_dir = dir.path().join("src");
2373 let test_dir = dir.path().join("test");
2374 std::fs::create_dir_all(&src_dir).unwrap();
2375 std::fs::create_dir_all(&test_dir).unwrap();
2376
2377 let prod_path = src_dir.join("users.controller.ts");
2378 std::fs::File::create(&prod_path).unwrap();
2379
2380 let layer1_test = src_dir.join("users.controller.spec.ts");
2381 let layer1_source = r#"// Layer 1 spec
2382describe('UsersController', () => {});
2383"#;
2384
2385 let layer2_test = test_dir.join("users.controller.spec.ts");
2386 let layer2_source = format!(
2387 "import {{ UsersController }} from '../src/users.controller';\ndescribe('cross', () => {{}});\n"
2388 );
2389
2390 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2391 let mut test_sources = HashMap::new();
2392 test_sources.insert(
2393 layer1_test.to_string_lossy().into_owned(),
2394 layer1_source.to_string(),
2395 );
2396 test_sources.insert(
2397 layer2_test.to_string_lossy().into_owned(),
2398 layer2_source.to_string(),
2399 );
2400
2401 let extractor = TypeScriptExtractor::new();
2402
2403 let mappings = extractor.map_test_files_with_imports(
2405 &production_files,
2406 &test_sources,
2407 dir.path(),
2408 false,
2409 );
2410
2411 assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2413 let mapping = &mappings[0];
2414 assert!(
2415 mapping
2416 .test_files
2417 .contains(&layer1_test.to_string_lossy().into_owned()),
2418 "expected Layer 1 test in mapping, got {:?}",
2419 mapping.test_files
2420 );
2421 assert!(
2422 mapping
2423 .test_files
2424 .contains(&layer2_test.to_string_lossy().into_owned()),
2425 "expected Layer 2 test in mapping, got {:?}",
2426 mapping.test_files
2427 );
2428 }
2429
2430 #[test]
2432 fn mt2_cross_directory_import_tracing() {
2433 use tempfile::TempDir;
2434
2435 let dir = TempDir::new().unwrap();
2440 let src_dir = dir.path().join("src").join("services");
2441 let test_dir = dir.path().join("test");
2442 std::fs::create_dir_all(&src_dir).unwrap();
2443 std::fs::create_dir_all(&test_dir).unwrap();
2444
2445 let prod_path = src_dir.join("user.service.ts");
2446 std::fs::File::create(&prod_path).unwrap();
2447
2448 let test_path = test_dir.join("user.service.spec.ts");
2449 let test_source = format!(
2450 "import {{ UserService }} from '../src/services/user.service';\ndescribe('cross', () => {{}});\n"
2451 );
2452
2453 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2454 let mut test_sources = HashMap::new();
2455 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2456
2457 let extractor = TypeScriptExtractor::new();
2458
2459 let mappings = extractor.map_test_files_with_imports(
2461 &production_files,
2462 &test_sources,
2463 dir.path(),
2464 false,
2465 );
2466
2467 assert_eq!(mappings.len(), 1);
2469 let mapping = &mappings[0];
2470 assert!(
2471 mapping
2472 .test_files
2473 .contains(&test_path.to_string_lossy().into_owned()),
2474 "expected test in mapping via ImportTracing, got {:?}",
2475 mapping.test_files
2476 );
2477 assert_eq!(
2478 mapping.strategy,
2479 MappingStrategy::ImportTracing,
2480 "expected ImportTracing strategy"
2481 );
2482 }
2483
2484 #[test]
2486 fn mt3_npm_only_import_not_matched() {
2487 use tempfile::TempDir;
2488
2489 let dir = TempDir::new().unwrap();
2493 let src_dir = dir.path().join("src");
2494 let test_dir = dir.path().join("test");
2495 std::fs::create_dir_all(&src_dir).unwrap();
2496 std::fs::create_dir_all(&test_dir).unwrap();
2497
2498 let prod_path = src_dir.join("users.controller.ts");
2499 std::fs::File::create(&prod_path).unwrap();
2500
2501 let test_path = test_dir.join("something.spec.ts");
2502 let test_source =
2503 "import { Test } from '@nestjs/testing';\ndescribe('npm', () => {});\n".to_string();
2504
2505 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2506 let mut test_sources = HashMap::new();
2507 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2508
2509 let extractor = TypeScriptExtractor::new();
2510
2511 let mappings = extractor.map_test_files_with_imports(
2513 &production_files,
2514 &test_sources,
2515 dir.path(),
2516 false,
2517 );
2518
2519 assert_eq!(mappings.len(), 1);
2521 assert!(
2522 mappings[0].test_files.is_empty(),
2523 "expected no test files for npm-only import, got {:?}",
2524 mappings[0].test_files
2525 );
2526 }
2527
2528 #[test]
2530 fn mt4_one_test_imports_multiple_productions() {
2531 use tempfile::TempDir;
2532
2533 let dir = TempDir::new().unwrap();
2538 let src_dir = dir.path().join("src");
2539 let test_dir = dir.path().join("test");
2540 std::fs::create_dir_all(&src_dir).unwrap();
2541 std::fs::create_dir_all(&test_dir).unwrap();
2542
2543 let prod_a = src_dir.join("a.service.ts");
2544 let prod_b = src_dir.join("b.service.ts");
2545 std::fs::File::create(&prod_a).unwrap();
2546 std::fs::File::create(&prod_b).unwrap();
2547
2548 let test_path = test_dir.join("ab.spec.ts");
2549 let test_source = format!(
2550 "import {{ A }} from '../src/a.service';\nimport {{ B }} from '../src/b.service';\ndescribe('ab', () => {{}});\n"
2551 );
2552
2553 let production_files = vec![
2554 prod_a.to_string_lossy().into_owned(),
2555 prod_b.to_string_lossy().into_owned(),
2556 ];
2557 let mut test_sources = HashMap::new();
2558 test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
2559
2560 let extractor = TypeScriptExtractor::new();
2561
2562 let mappings = extractor.map_test_files_with_imports(
2564 &production_files,
2565 &test_sources,
2566 dir.path(),
2567 false,
2568 );
2569
2570 assert_eq!(mappings.len(), 2, "expected 2 FileMappings (A and B)");
2572 for mapping in &mappings {
2573 assert!(
2574 mapping
2575 .test_files
2576 .contains(&test_path.to_string_lossy().into_owned()),
2577 "expected ab.spec.ts mapped to {}, got {:?}",
2578 mapping.production_file,
2579 mapping.test_files
2580 );
2581 }
2582 }
2583
2584 #[test]
2586 fn is_non_sut_helper_constants_ts() {
2587 assert!(is_non_sut_helper("src/constants.ts", false));
2588 }
2589
2590 #[test]
2592 fn is_non_sut_helper_index_ts() {
2593 assert!(is_non_sut_helper("src/index.ts", false));
2594 }
2595
2596 #[test]
2598 fn is_non_sut_helper_extension_variants() {
2599 assert!(is_non_sut_helper("src/constants.js", false));
2600 assert!(is_non_sut_helper("src/constants.tsx", false));
2601 assert!(is_non_sut_helper("src/constants.jsx", false));
2602 assert!(is_non_sut_helper("src/index.js", false));
2603 assert!(is_non_sut_helper("src/index.tsx", false));
2604 assert!(is_non_sut_helper("src/index.jsx", false));
2605 }
2606
2607 #[test]
2609 fn is_non_sut_helper_rejects_non_helpers() {
2610 assert!(!is_non_sut_helper("src/my-constants.ts", false));
2611 assert!(!is_non_sut_helper("src/service.ts", false));
2612 assert!(!is_non_sut_helper("src/app.constants.ts", false));
2613 assert!(!is_non_sut_helper("src/constants-v2.ts", false));
2614 }
2615
2616 #[test]
2618 fn is_non_sut_helper_rejects_directory_name() {
2619 assert!(!is_non_sut_helper("constants/app.ts", false));
2620 assert!(!is_non_sut_helper("index/service.ts", false));
2621 }
2622
2623 #[test]
2625 fn is_non_sut_helper_enum_ts() {
2626 let path = "src/enums/request-method.enum.ts";
2628 assert!(is_non_sut_helper(path, false));
2631 }
2632
2633 #[test]
2635 fn is_non_sut_helper_interface_ts() {
2636 let path = "src/interfaces/middleware-configuration.interface.ts";
2638 assert!(is_non_sut_helper(path, false));
2641 }
2642
2643 #[test]
2645 fn is_non_sut_helper_exception_ts() {
2646 let path = "src/errors/unknown-module.exception.ts";
2648 assert!(is_non_sut_helper(path, false));
2651 }
2652
2653 #[test]
2655 fn is_non_sut_helper_test_path() {
2656 let path = "packages/core/test/utils/string.cleaner.ts";
2658 assert!(is_non_sut_helper(path, false));
2661 assert!(is_non_sut_helper(
2663 "packages/core/__tests__/utils/helper.ts",
2664 false
2665 ));
2666 assert!(!is_non_sut_helper(
2668 "/home/user/projects/contest/src/service.ts",
2669 false
2670 ));
2671 assert!(!is_non_sut_helper("src/latest/foo.ts", false));
2672 }
2673
2674 #[test]
2676 fn is_non_sut_helper_rejects_plain_filename() {
2677 assert!(!is_non_sut_helper("src/enum.ts", false));
2682 assert!(!is_non_sut_helper("src/interface.ts", false));
2683 assert!(!is_non_sut_helper("src/exception.ts", false));
2684 }
2685
2686 #[test]
2688 fn is_non_sut_helper_enum_interface_extension_variants() {
2689 assert!(is_non_sut_helper("src/foo.enum.js", false));
2693 assert!(is_non_sut_helper("src/bar.interface.tsx", false));
2694 }
2695
2696 #[test]
2700 fn is_type_definition_file_enum() {
2701 assert!(is_type_definition_file("src/foo.enum.ts"));
2702 }
2703
2704 #[test]
2706 fn is_type_definition_file_interface() {
2707 assert!(is_type_definition_file("src/bar.interface.ts"));
2708 }
2709
2710 #[test]
2712 fn is_type_definition_file_exception() {
2713 assert!(is_type_definition_file("src/baz.exception.ts"));
2714 }
2715
2716 #[test]
2718 fn is_type_definition_file_service() {
2719 assert!(!is_type_definition_file("src/service.ts"));
2720 }
2721
2722 #[test]
2724 fn is_type_definition_file_constants() {
2725 assert!(!is_type_definition_file("src/constants.ts"));
2727 }
2728
2729 #[test]
2733 fn is_non_sut_helper_production_enum_bypassed() {
2734 assert!(!is_non_sut_helper("src/foo.enum.ts", true));
2738 }
2739
2740 #[test]
2742 fn is_non_sut_helper_unknown_enum_filtered() {
2743 assert!(is_non_sut_helper("src/foo.enum.ts", false));
2747 }
2748
2749 #[test]
2751 fn is_non_sut_helper_constants_always_filtered() {
2752 assert!(is_non_sut_helper("src/constants.ts", true));
2756 }
2757
2758 #[test]
2762 fn barrel_01_resolve_directory_to_index_ts() {
2763 use tempfile::TempDir;
2764
2765 let dir = TempDir::new().unwrap();
2767 let decorators_dir = dir.path().join("decorators");
2768 std::fs::create_dir_all(&decorators_dir).unwrap();
2769 std::fs::File::create(decorators_dir.join("index.ts")).unwrap();
2770
2771 let src_dir = dir.path().join("src");
2773 std::fs::create_dir_all(&src_dir).unwrap();
2774 let from_file = src_dir.join("some.spec.ts");
2775
2776 let result = resolve_import_path("../decorators", &from_file, dir.path());
2778
2779 assert!(
2781 result.is_some(),
2782 "expected Some for directory with index.ts, got None"
2783 );
2784 let resolved = result.unwrap();
2785 assert!(
2786 resolved.ends_with("decorators/index.ts"),
2787 "expected path ending with decorators/index.ts, got {resolved}"
2788 );
2789 }
2790
2791 #[test]
2793 fn barrel_02_re_export_named_capture() {
2794 let source = "export { Foo } from './foo';";
2796 let extractor = TypeScriptExtractor::new();
2797
2798 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2800
2801 assert_eq!(
2803 re_exports.len(),
2804 1,
2805 "expected 1 re-export, got {re_exports:?}"
2806 );
2807 let re = &re_exports[0];
2808 assert_eq!(re.symbols, vec!["Foo".to_string()]);
2809 assert_eq!(re.from_specifier, "./foo");
2810 assert!(!re.wildcard);
2811 }
2812
2813 #[test]
2815 fn barrel_03_re_export_wildcard_capture() {
2816 let source = "export * from './foo';";
2818 let extractor = TypeScriptExtractor::new();
2819
2820 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
2822
2823 assert_eq!(
2825 re_exports.len(),
2826 1,
2827 "expected 1 re-export, got {re_exports:?}"
2828 );
2829 let re = &re_exports[0];
2830 assert!(re.wildcard, "expected wildcard=true");
2831 assert_eq!(re.from_specifier, "./foo");
2832 }
2833
2834 #[test]
2836 fn barrel_04_resolve_barrel_exports_one_hop() {
2837 use tempfile::TempDir;
2838
2839 let dir = TempDir::new().unwrap();
2843 let index_path = dir.path().join("index.ts");
2844 std::fs::write(&index_path, "export { Foo } from './foo';").unwrap();
2845 let foo_path = dir.path().join("foo.ts");
2846 std::fs::File::create(&foo_path).unwrap();
2847
2848 let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2850
2851 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2853 assert!(
2854 result[0].ends_with("foo.ts"),
2855 "expected foo.ts, got {:?}",
2856 result[0]
2857 );
2858 }
2859
2860 #[test]
2862 fn barrel_05_resolve_barrel_exports_two_hops() {
2863 use tempfile::TempDir;
2864
2865 let dir = TempDir::new().unwrap();
2870 let index_path = dir.path().join("index.ts");
2871 std::fs::write(&index_path, "export * from './core';").unwrap();
2872
2873 let core_dir = dir.path().join("core");
2874 std::fs::create_dir_all(&core_dir).unwrap();
2875 std::fs::write(core_dir.join("index.ts"), "export { Foo } from './foo';").unwrap();
2876 let foo_path = core_dir.join("foo.ts");
2877 std::fs::File::create(&foo_path).unwrap();
2878
2879 let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
2881
2882 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
2884 assert!(
2885 result[0].ends_with("foo.ts"),
2886 "expected foo.ts, got {:?}",
2887 result[0]
2888 );
2889 }
2890
2891 #[test]
2893 fn barrel_06_circular_barrel_no_infinite_loop() {
2894 use tempfile::TempDir;
2895
2896 let dir = TempDir::new().unwrap();
2900 let a_dir = dir.path().join("a");
2901 let b_dir = dir.path().join("b");
2902 std::fs::create_dir_all(&a_dir).unwrap();
2903 std::fs::create_dir_all(&b_dir).unwrap();
2904 std::fs::write(a_dir.join("index.ts"), "export * from '../b';").unwrap();
2905 std::fs::write(b_dir.join("index.ts"), "export * from '../a';").unwrap();
2906
2907 let a_index = a_dir.join("index.ts");
2908
2909 let result = resolve_barrel_exports(&a_index, &["Foo".to_string()], dir.path());
2911
2912 assert!(
2914 result.is_empty(),
2915 "expected empty result for circular barrel, got {result:?}"
2916 );
2917 }
2918
2919 #[test]
2921 fn barrel_07_layer2_barrel_import_matches_production() {
2922 use tempfile::TempDir;
2923
2924 let dir = TempDir::new().unwrap();
2930 let src_dir = dir.path().join("src");
2931 let decorators_dir = src_dir.join("decorators");
2932 let test_dir = dir.path().join("test");
2933 std::fs::create_dir_all(&decorators_dir).unwrap();
2934 std::fs::create_dir_all(&test_dir).unwrap();
2935
2936 let prod_path = src_dir.join("foo.service.ts");
2938 std::fs::File::create(&prod_path).unwrap();
2939
2940 std::fs::write(
2942 decorators_dir.join("index.ts"),
2943 "export { Foo } from '../foo.service';",
2944 )
2945 .unwrap();
2946
2947 let test_path = test_dir.join("foo.spec.ts");
2949 std::fs::write(
2950 &test_path,
2951 "import { Foo } from '../src/decorators';\ndescribe('foo', () => {});",
2952 )
2953 .unwrap();
2954
2955 let production_files = vec![prod_path.to_string_lossy().into_owned()];
2956 let mut test_sources = HashMap::new();
2957 test_sources.insert(
2958 test_path.to_string_lossy().into_owned(),
2959 std::fs::read_to_string(&test_path).unwrap(),
2960 );
2961
2962 let extractor = TypeScriptExtractor::new();
2963
2964 let mappings = extractor.map_test_files_with_imports(
2966 &production_files,
2967 &test_sources,
2968 dir.path(),
2969 false,
2970 );
2971
2972 assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
2974 assert!(
2975 mappings[0]
2976 .test_files
2977 .contains(&test_path.to_string_lossy().into_owned()),
2978 "expected foo.spec.ts mapped via barrel, got {:?}",
2979 mappings[0].test_files
2980 );
2981 }
2982
2983 #[test]
2985 fn barrel_08_non_sut_filter_applied_after_barrel_resolution() {
2986 use tempfile::TempDir;
2987
2988 let dir = TempDir::new().unwrap();
2993 let src_dir = dir.path().join("src");
2994 let test_dir = dir.path().join("test");
2995 std::fs::create_dir_all(&src_dir).unwrap();
2996 std::fs::create_dir_all(&test_dir).unwrap();
2997
2998 let prod_path = src_dir.join("user.service.ts");
3000 std::fs::File::create(&prod_path).unwrap();
3001
3002 std::fs::write(
3004 src_dir.join("index.ts"),
3005 "export { SOME_CONST } from './constants';",
3006 )
3007 .unwrap();
3008 std::fs::File::create(src_dir.join("constants.ts")).unwrap();
3010
3011 let test_path = test_dir.join("barrel_const.spec.ts");
3013 std::fs::write(
3014 &test_path,
3015 "import { SOME_CONST } from '../src';\ndescribe('const', () => {});",
3016 )
3017 .unwrap();
3018
3019 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3020 let mut test_sources = HashMap::new();
3021 test_sources.insert(
3022 test_path.to_string_lossy().into_owned(),
3023 std::fs::read_to_string(&test_path).unwrap(),
3024 );
3025
3026 let extractor = TypeScriptExtractor::new();
3027
3028 let mappings = extractor.map_test_files_with_imports(
3030 &production_files,
3031 &test_sources,
3032 dir.path(),
3033 false,
3034 );
3035
3036 assert_eq!(
3038 mappings.len(),
3039 1,
3040 "expected 1 FileMapping for user.service.ts"
3041 );
3042 assert!(
3043 mappings[0].test_files.is_empty(),
3044 "constants.ts should be filtered out, but got {:?}",
3045 mappings[0].test_files
3046 );
3047 }
3048
3049 #[test]
3051 fn barrel_09_extract_imports_retains_symbols() {
3052 let source = "import { Foo, Bar } from './module';";
3054 let extractor = TypeScriptExtractor::new();
3055
3056 let imports = extractor.extract_imports(source, "test.ts");
3058
3059 let from_module: Vec<&ImportMapping> = imports
3063 .iter()
3064 .filter(|i| i.module_specifier == "./module")
3065 .collect();
3066 let names: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
3067 assert!(names.contains(&"Foo"), "expected Foo in symbols: {names:?}");
3068 assert!(names.contains(&"Bar"), "expected Bar in symbols: {names:?}");
3069
3070 let grouped = imports
3074 .iter()
3075 .filter(|i| i.module_specifier == "./module")
3076 .fold(Vec::<String>::new(), |mut acc, i| {
3077 acc.push(i.symbol_name.clone());
3078 acc
3079 });
3080 assert_eq!(
3083 grouped.len(),
3084 2,
3085 "expected 2 symbols from ./module, got {grouped:?}"
3086 );
3087
3088 let first_import = imports
3091 .iter()
3092 .find(|i| i.module_specifier == "./module")
3093 .expect("expected at least one import from ./module");
3094 let symbols = &first_import.symbols;
3095 assert!(
3096 symbols.contains(&"Foo".to_string()),
3097 "symbols should contain Foo, got {symbols:?}"
3098 );
3099 assert!(
3100 symbols.contains(&"Bar".to_string()),
3101 "symbols should contain Bar, got {symbols:?}"
3102 );
3103 assert_eq!(
3104 symbols.len(),
3105 2,
3106 "expected exactly 2 symbols, got {symbols:?}"
3107 );
3108 }
3109
3110 #[test]
3114 fn barrel_10_wildcard_barrel_symbol_filter() {
3115 use tempfile::TempDir;
3116
3117 let dir = TempDir::new().unwrap();
3123 let core_dir = dir.path().join("core");
3124 std::fs::create_dir_all(&core_dir).unwrap();
3125
3126 std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3127 std::fs::write(
3128 core_dir.join("index.ts"),
3129 "export * from './foo';\nexport * from './bar';",
3130 )
3131 .unwrap();
3132 std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3133 std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3134
3135 let result = resolve_barrel_exports(
3137 &dir.path().join("index.ts"),
3138 &["Foo".to_string()],
3139 dir.path(),
3140 );
3141
3142 assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
3144 assert!(
3145 result[0].ends_with("foo.ts"),
3146 "expected foo.ts, got {:?}",
3147 result[0]
3148 );
3149 }
3150
3151 #[test]
3153 fn barrel_11_wildcard_barrel_empty_symbols_match_all() {
3154 use tempfile::TempDir;
3155
3156 let dir = TempDir::new().unwrap();
3157 let core_dir = dir.path().join("core");
3158 std::fs::create_dir_all(&core_dir).unwrap();
3159
3160 std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
3161 std::fs::write(
3162 core_dir.join("index.ts"),
3163 "export * from './foo';\nexport * from './bar';",
3164 )
3165 .unwrap();
3166 std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
3167 std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
3168
3169 let result = resolve_barrel_exports(&dir.path().join("index.ts"), &[], dir.path());
3171
3172 assert_eq!(result.len(), 2, "expected 2 resolved files, got {result:?}");
3174 }
3175
3176 #[test]
3183 fn boundary_b1_ns_reexport_captured_as_wildcard() {
3184 let source = "export * as Validators from './validators';";
3186 let extractor = TypeScriptExtractor::new();
3187
3188 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
3190
3191 assert_eq!(
3193 re_exports.len(),
3194 1,
3195 "expected 1 re-export for namespace export, got {:?}",
3196 re_exports
3197 );
3198 let re = &re_exports[0];
3199 assert_eq!(re.from_specifier, "./validators");
3200 assert!(
3201 re.wildcard,
3202 "expected wildcard=true for namespace re-export, got {:?}",
3203 re
3204 );
3205 }
3206
3207 #[test]
3209 fn boundary_b1_ns_reexport_mapping_miss() {
3210 use tempfile::TempDir;
3211
3212 let dir = TempDir::new().unwrap();
3218 let validators_dir = dir.path().join("validators");
3219 let test_dir = dir.path().join("test");
3220 std::fs::create_dir_all(&validators_dir).unwrap();
3221 std::fs::create_dir_all(&test_dir).unwrap();
3222
3223 let prod_path = validators_dir.join("foo.service.ts");
3225 std::fs::File::create(&prod_path).unwrap();
3226
3227 std::fs::write(
3229 dir.path().join("index.ts"),
3230 "export * as Validators from './validators';",
3231 )
3232 .unwrap();
3233
3234 std::fs::write(
3236 validators_dir.join("index.ts"),
3237 "export { FooService } from './foo.service';",
3238 )
3239 .unwrap();
3240
3241 let test_path = test_dir.join("foo.spec.ts");
3243 std::fs::write(
3244 &test_path,
3245 "import { Validators } from '../index';\ndescribe('FooService', () => {});",
3246 )
3247 .unwrap();
3248
3249 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3250 let mut test_sources = HashMap::new();
3251 test_sources.insert(
3252 test_path.to_string_lossy().into_owned(),
3253 std::fs::read_to_string(&test_path).unwrap(),
3254 );
3255
3256 let extractor = TypeScriptExtractor::new();
3257
3258 let mappings = extractor.map_test_files_with_imports(
3260 &production_files,
3261 &test_sources,
3262 dir.path(),
3263 false,
3264 );
3265
3266 let prod_mapping = mappings
3268 .iter()
3269 .find(|m| m.production_file.contains("foo.service.ts"));
3270 assert!(
3271 prod_mapping.is_some(),
3272 "expected foo.service.ts to appear in mappings, got {:?}",
3273 mappings
3274 );
3275 let mapping = prod_mapping.unwrap();
3276 assert!(
3277 !mapping.test_files.is_empty(),
3278 "expected foo.service.ts to have test_files (Layer 2 via namespace re-export), got {:?}",
3279 mapping
3280 );
3281 }
3282
3283 #[test]
3285 fn boundary_b2_non_relative_import_skipped() {
3286 let source = "import { Injectable } from '@nestjs/common';";
3288 let extractor = TypeScriptExtractor::new();
3289
3290 let imports = extractor.extract_imports(source, "app.service.ts");
3292
3293 assert!(
3295 imports.is_empty(),
3296 "expected empty imports for non-relative path, got {:?}",
3297 imports
3298 );
3299 }
3300
3301 #[cfg(unix)]
3304 #[test]
3305 fn boundary_b2_cross_pkg_symlink_resolved() {
3306 use std::os::unix::fs::symlink;
3307 use tempfile::TempDir;
3308
3309 let dir = TempDir::new().unwrap();
3317 let core_src = dir.path().join("packages").join("core").join("src");
3318 let core_test = dir.path().join("packages").join("core").join("test");
3319 let core_nm_org = dir
3320 .path()
3321 .join("packages")
3322 .join("core")
3323 .join("node_modules")
3324 .join("@org");
3325 let common_src = dir.path().join("packages").join("common").join("src");
3326 std::fs::create_dir_all(&core_src).unwrap();
3327 std::fs::create_dir_all(&core_test).unwrap();
3328 std::fs::create_dir_all(&core_nm_org).unwrap();
3329 std::fs::create_dir_all(&common_src).unwrap();
3330
3331 let local_prod_path = core_src.join("foo.service.ts");
3332 std::fs::File::create(&local_prod_path).unwrap();
3333
3334 let common_path = common_src.join("foo.ts");
3335 std::fs::File::create(&common_path).unwrap();
3336
3337 let symlink_path = core_nm_org.join("common");
3339 let target = dir.path().join("packages").join("common");
3340 symlink(&target, &symlink_path).unwrap();
3341
3342 let test_path = core_test.join("foo.spec.ts");
3343 std::fs::write(
3344 &test_path,
3345 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3346 )
3347 .unwrap();
3348
3349 let scan_root = dir.path().join("packages").join("core");
3350 let production_files = vec![
3352 local_prod_path.to_string_lossy().into_owned(),
3353 common_path.to_string_lossy().into_owned(),
3354 ];
3355 let mut test_sources = HashMap::new();
3356 test_sources.insert(
3357 test_path.to_string_lossy().into_owned(),
3358 std::fs::read_to_string(&test_path).unwrap(),
3359 );
3360
3361 let extractor = TypeScriptExtractor::new();
3362
3363 let mappings = extractor.map_test_files_with_imports(
3365 &production_files,
3366 &test_sources,
3367 &scan_root,
3368 false,
3369 );
3370
3371 let common_path_str = common_path.to_string_lossy().into_owned();
3373 let common_mapping = mappings
3374 .iter()
3375 .find(|m| m.production_file == common_path_str);
3376 assert!(
3377 common_mapping.is_some(),
3378 "expected common/src/foo.ts to have a mapping"
3379 );
3380 let test_file_str = test_path.to_string_lossy().into_owned();
3381 assert!(
3382 common_mapping.unwrap().test_files.contains(&test_file_str),
3383 "expected foo.spec.ts to be mapped to common/src/foo.ts via symlink"
3384 );
3385 }
3386
3387 #[cfg(unix)]
3389 #[test]
3390 fn b2_sym_01_symlink_followed() {
3391 use std::os::unix::fs::symlink;
3392 use tempfile::TempDir;
3393
3394 let dir = TempDir::new().unwrap();
3397 let nm_org = dir.path().join("node_modules").join("@org");
3398 std::fs::create_dir_all(&nm_org).unwrap();
3399 let target = dir.path().join("packages").join("common");
3400 std::fs::create_dir_all(&target).unwrap();
3401 let symlink_path = nm_org.join("common");
3402 symlink(&target, &symlink_path).unwrap();
3403
3404 let mut cache = HashMap::new();
3405
3406 let result = resolve_node_modules_symlink("@org/common", dir.path(), &mut cache);
3408
3409 let expected = target.canonicalize().unwrap();
3411 assert_eq!(
3412 result,
3413 Some(expected),
3414 "expected symlink to be followed to real path"
3415 );
3416 }
3417
3418 #[cfg(unix)]
3420 #[test]
3421 fn b2_sym_02_real_directory_returns_none() {
3422 use tempfile::TempDir;
3423
3424 let dir = TempDir::new().unwrap();
3427 let nm_org = dir.path().join("node_modules").join("@org").join("common");
3428 std::fs::create_dir_all(&nm_org).unwrap();
3429
3430 let mut cache = HashMap::new();
3431
3432 let result = resolve_node_modules_symlink("@org/common", dir.path(), &mut cache);
3434
3435 assert_eq!(
3437 result, None,
3438 "expected None for real directory (not symlink)"
3439 );
3440 }
3441
3442 #[cfg(unix)]
3444 #[test]
3445 fn b2_sym_03_nonexistent_returns_none() {
3446 use tempfile::TempDir;
3447
3448 let dir = TempDir::new().unwrap();
3450 let nm = dir.path().join("node_modules");
3451 std::fs::create_dir_all(&nm).unwrap();
3452
3453 let mut cache = HashMap::new();
3454
3455 let result = resolve_node_modules_symlink("@org/nonexistent", dir.path(), &mut cache);
3457
3458 assert_eq!(result, None, "expected None for non-existent specifier");
3460 }
3461
3462 #[cfg(unix)]
3464 #[test]
3465 fn b2_map_02_tsconfig_alias_priority() {
3466 use std::os::unix::fs::symlink;
3467 use tempfile::TempDir;
3468
3469 let dir = TempDir::new().unwrap();
3477 let core_src = dir.path().join("packages").join("core").join("src");
3478 let core_test = dir.path().join("packages").join("core").join("test");
3479 let core_nm_org = dir
3480 .path()
3481 .join("packages")
3482 .join("core")
3483 .join("node_modules")
3484 .join("@org");
3485 let common_src = dir.path().join("packages").join("common").join("src");
3486 std::fs::create_dir_all(&core_src).unwrap();
3487 std::fs::create_dir_all(&core_test).unwrap();
3488 std::fs::create_dir_all(&core_nm_org).unwrap();
3489 std::fs::create_dir_all(&common_src).unwrap();
3490
3491 let local_prod_path = core_src.join("foo.service.ts");
3492 std::fs::write(&local_prod_path, "export class FooService {}").unwrap();
3493
3494 let common_path = common_src.join("foo.ts");
3495 std::fs::File::create(&common_path).unwrap();
3496
3497 let symlink_path = core_nm_org.join("common");
3498 let target = dir.path().join("packages").join("common");
3499 symlink(&target, &symlink_path).unwrap();
3500
3501 let tsconfig = serde_json::json!({
3503 "compilerOptions": {
3504 "paths": {
3505 "@org/common": ["src/foo.service"]
3506 }
3507 }
3508 });
3509 let core_root = dir.path().join("packages").join("core");
3510 std::fs::write(core_root.join("tsconfig.json"), tsconfig.to_string()).unwrap();
3511
3512 let test_path = core_test.join("foo.spec.ts");
3513 std::fs::write(
3514 &test_path,
3515 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3516 )
3517 .unwrap();
3518
3519 let production_files = vec![
3520 local_prod_path.to_string_lossy().into_owned(),
3521 common_path.to_string_lossy().into_owned(),
3522 ];
3523 let mut test_sources = HashMap::new();
3524 test_sources.insert(
3525 test_path.to_string_lossy().into_owned(),
3526 std::fs::read_to_string(&test_path).unwrap(),
3527 );
3528
3529 let extractor = TypeScriptExtractor::new();
3530
3531 let mappings = extractor.map_test_files_with_imports(
3533 &production_files,
3534 &test_sources,
3535 &core_root,
3536 false,
3537 );
3538
3539 let test_file_str = test_path.to_string_lossy().into_owned();
3541 let local_prod_str = local_prod_path.to_string_lossy().into_owned();
3542 let common_path_str = common_path.to_string_lossy().into_owned();
3543
3544 let local_mapping = mappings
3545 .iter()
3546 .find(|m| m.production_file == local_prod_str);
3547 assert!(
3548 local_mapping.map_or(false, |m| m.test_files.contains(&test_file_str)),
3549 "expected foo.service.ts to be mapped via tsconfig alias"
3550 );
3551
3552 let common_mapping = mappings
3553 .iter()
3554 .find(|m| m.production_file == common_path_str);
3555 assert!(
3556 !common_mapping.map_or(false, |m| m.test_files.contains(&test_file_str)),
3557 "expected common/src/foo.ts NOT to be mapped (tsconfig alias should win)"
3558 );
3559 }
3560
3561 #[cfg(unix)]
3563 #[test]
3564 fn b2_multi_01_two_test_files_both_mapped() {
3565 use std::os::unix::fs::symlink;
3566 use tempfile::TempDir;
3567
3568 let dir = TempDir::new().unwrap();
3574 let core_test = dir.path().join("packages").join("core").join("test");
3575 let core_nm_org = dir
3576 .path()
3577 .join("packages")
3578 .join("core")
3579 .join("node_modules")
3580 .join("@org");
3581 let common_src = dir.path().join("packages").join("common").join("src");
3582 std::fs::create_dir_all(&core_test).unwrap();
3583 std::fs::create_dir_all(&core_nm_org).unwrap();
3584 std::fs::create_dir_all(&common_src).unwrap();
3585
3586 let common_path = common_src.join("foo.ts");
3587 std::fs::File::create(&common_path).unwrap();
3588
3589 let symlink_path = core_nm_org.join("common");
3590 let target = dir.path().join("packages").join("common");
3591 symlink(&target, &symlink_path).unwrap();
3592
3593 let test_path1 = core_test.join("foo.spec.ts");
3594 let test_path2 = core_test.join("bar.spec.ts");
3595 std::fs::write(
3596 &test_path1,
3597 "import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
3598 )
3599 .unwrap();
3600 std::fs::write(
3601 &test_path2,
3602 "import { Foo } from '@org/common';\ndescribe('Bar', () => {});",
3603 )
3604 .unwrap();
3605
3606 let scan_root = dir.path().join("packages").join("core");
3607 let production_files = vec![common_path.to_string_lossy().into_owned()];
3608 let mut test_sources = HashMap::new();
3609 test_sources.insert(
3610 test_path1.to_string_lossy().into_owned(),
3611 std::fs::read_to_string(&test_path1).unwrap(),
3612 );
3613 test_sources.insert(
3614 test_path2.to_string_lossy().into_owned(),
3615 std::fs::read_to_string(&test_path2).unwrap(),
3616 );
3617
3618 let extractor = TypeScriptExtractor::new();
3619
3620 let mappings = extractor.map_test_files_with_imports(
3622 &production_files,
3623 &test_sources,
3624 &scan_root,
3625 false,
3626 );
3627
3628 let common_path_str = common_path.to_string_lossy().into_owned();
3630 let test1_str = test_path1.to_string_lossy().into_owned();
3631 let test2_str = test_path2.to_string_lossy().into_owned();
3632
3633 let common_mapping = mappings
3634 .iter()
3635 .find(|m| m.production_file == common_path_str);
3636 assert!(
3637 common_mapping.is_some(),
3638 "expected common/src/foo.ts to have a mapping"
3639 );
3640 let mapped_tests = &common_mapping.unwrap().test_files;
3641 assert!(
3642 mapped_tests.contains(&test1_str),
3643 "expected foo.spec.ts to be mapped"
3644 );
3645 assert!(
3646 mapped_tests.contains(&test2_str),
3647 "expected bar.spec.ts to be mapped"
3648 );
3649 }
3650
3651 #[test]
3653 fn boundary_b3_tsconfig_alias_not_resolved() {
3654 let source = "import { FooService } from '@app/services/foo.service';";
3656 let extractor = TypeScriptExtractor::new();
3657
3658 let imports = extractor.extract_imports(source, "app.module.ts");
3660
3661 assert!(
3665 imports.is_empty(),
3666 "expected empty imports for tsconfig alias, got {:?}",
3667 imports
3668 );
3669 }
3670
3671 #[test]
3673 fn boundary_b4_enum_primary_target_filtered() {
3674 use tempfile::TempDir;
3675
3676 let dir = TempDir::new().unwrap();
3680 let src_dir = dir.path().join("src");
3681 let test_dir = dir.path().join("test");
3682 std::fs::create_dir_all(&src_dir).unwrap();
3683 std::fs::create_dir_all(&test_dir).unwrap();
3684
3685 let prod_path = src_dir.join("route-paramtypes.enum.ts");
3686 std::fs::File::create(&prod_path).unwrap();
3687
3688 let test_path = test_dir.join("route.spec.ts");
3689 std::fs::write(
3690 &test_path,
3691 "import { RouteParamtypes } from '../src/route-paramtypes.enum';\ndescribe('Route', () => {});",
3692 )
3693 .unwrap();
3694
3695 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3696 let mut test_sources = HashMap::new();
3697 test_sources.insert(
3698 test_path.to_string_lossy().into_owned(),
3699 std::fs::read_to_string(&test_path).unwrap(),
3700 );
3701
3702 let extractor = TypeScriptExtractor::new();
3703
3704 let mappings = extractor.map_test_files_with_imports(
3706 &production_files,
3707 &test_sources,
3708 dir.path(),
3709 false,
3710 );
3711
3712 let enum_mapping = mappings
3714 .iter()
3715 .find(|m| m.production_file.ends_with("route-paramtypes.enum.ts"));
3716 assert!(
3717 enum_mapping.is_some(),
3718 "expected mapping for route-paramtypes.enum.ts"
3719 );
3720 let enum_mapping = enum_mapping.unwrap();
3721 assert!(
3722 !enum_mapping.test_files.is_empty(),
3723 "expected test_files for route-paramtypes.enum.ts (production file), got empty"
3724 );
3725 }
3726
3727 #[test]
3729 fn boundary_b4_interface_primary_target_filtered() {
3730 use tempfile::TempDir;
3731
3732 let dir = TempDir::new().unwrap();
3736 let src_dir = dir.path().join("src");
3737 let test_dir = dir.path().join("test");
3738 std::fs::create_dir_all(&src_dir).unwrap();
3739 std::fs::create_dir_all(&test_dir).unwrap();
3740
3741 let prod_path = src_dir.join("user.interface.ts");
3742 std::fs::File::create(&prod_path).unwrap();
3743
3744 let test_path = test_dir.join("user.spec.ts");
3745 std::fs::write(
3746 &test_path,
3747 "import { User } from '../src/user.interface';\ndescribe('User', () => {});",
3748 )
3749 .unwrap();
3750
3751 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3752 let mut test_sources = HashMap::new();
3753 test_sources.insert(
3754 test_path.to_string_lossy().into_owned(),
3755 std::fs::read_to_string(&test_path).unwrap(),
3756 );
3757
3758 let extractor = TypeScriptExtractor::new();
3759
3760 let mappings = extractor.map_test_files_with_imports(
3762 &production_files,
3763 &test_sources,
3764 dir.path(),
3765 false,
3766 );
3767
3768 let iface_mapping = mappings
3770 .iter()
3771 .find(|m| m.production_file.ends_with("user.interface.ts"));
3772 assert!(
3773 iface_mapping.is_some(),
3774 "expected mapping for user.interface.ts"
3775 );
3776 let iface_mapping = iface_mapping.unwrap();
3777 assert!(
3778 !iface_mapping.test_files.is_empty(),
3779 "expected test_files for user.interface.ts (production file), got empty"
3780 );
3781 }
3782
3783 #[test]
3785 fn boundary_b5_dynamic_import_not_extracted() {
3786 let source = fixture("import_dynamic.ts");
3788 let extractor = TypeScriptExtractor::new();
3789
3790 let imports = extractor.extract_imports(&source, "import_dynamic.ts");
3792
3793 assert!(
3795 imports.is_empty(),
3796 "expected empty imports for dynamic import(), got {:?}",
3797 imports
3798 );
3799 }
3800
3801 #[test]
3805 fn test_observe_tsconfig_alias_basic() {
3806 use tempfile::TempDir;
3807
3808 let dir = TempDir::new().unwrap();
3813 let src_dir = dir.path().join("src");
3814 let test_dir = dir.path().join("test");
3815 std::fs::create_dir_all(&src_dir).unwrap();
3816 std::fs::create_dir_all(&test_dir).unwrap();
3817
3818 let tsconfig = dir.path().join("tsconfig.json");
3819 std::fs::write(
3820 &tsconfig,
3821 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3822 )
3823 .unwrap();
3824
3825 let prod_path = src_dir.join("foo.service.ts");
3826 std::fs::File::create(&prod_path).unwrap();
3827
3828 let test_path = test_dir.join("foo.service.spec.ts");
3829 let test_source =
3830 "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3831 std::fs::write(&test_path, test_source).unwrap();
3832
3833 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3834 let mut test_sources = HashMap::new();
3835 test_sources.insert(
3836 test_path.to_string_lossy().into_owned(),
3837 test_source.to_string(),
3838 );
3839
3840 let extractor = TypeScriptExtractor::new();
3841
3842 let mappings = extractor.map_test_files_with_imports(
3844 &production_files,
3845 &test_sources,
3846 dir.path(),
3847 false,
3848 );
3849
3850 let mapping = mappings
3852 .iter()
3853 .find(|m| m.production_file.contains("foo.service.ts"))
3854 .expect("expected mapping for foo.service.ts");
3855 assert!(
3856 mapping
3857 .test_files
3858 .contains(&test_path.to_string_lossy().into_owned()),
3859 "expected foo.service.spec.ts in mapping via alias, got {:?}",
3860 mapping.test_files
3861 );
3862 }
3863
3864 #[test]
3866 fn test_observe_no_tsconfig_alias_ignored() {
3867 use tempfile::TempDir;
3868
3869 let dir = TempDir::new().unwrap();
3874 let src_dir = dir.path().join("src");
3875 let test_dir = dir.path().join("test");
3876 std::fs::create_dir_all(&src_dir).unwrap();
3877 std::fs::create_dir_all(&test_dir).unwrap();
3878
3879 let prod_path = src_dir.join("foo.service.ts");
3880 std::fs::File::create(&prod_path).unwrap();
3881
3882 let test_path = test_dir.join("foo.service.spec.ts");
3883 let test_source =
3884 "import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
3885
3886 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3887 let mut test_sources = HashMap::new();
3888 test_sources.insert(
3889 test_path.to_string_lossy().into_owned(),
3890 test_source.to_string(),
3891 );
3892
3893 let extractor = TypeScriptExtractor::new();
3894
3895 let mappings = extractor.map_test_files_with_imports(
3897 &production_files,
3898 &test_sources,
3899 dir.path(),
3900 false,
3901 );
3902
3903 let all_test_files: Vec<&String> =
3905 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
3906 assert!(
3907 all_test_files.is_empty(),
3908 "expected no test_files when tsconfig absent, got {:?}",
3909 all_test_files
3910 );
3911 }
3912
3913 #[test]
3915 fn test_observe_tsconfig_alias_barrel() {
3916 use tempfile::TempDir;
3917
3918 let dir = TempDir::new().unwrap();
3924 let src_dir = dir.path().join("src");
3925 let services_dir = src_dir.join("services");
3926 let test_dir = dir.path().join("test");
3927 std::fs::create_dir_all(&services_dir).unwrap();
3928 std::fs::create_dir_all(&test_dir).unwrap();
3929
3930 std::fs::write(
3931 dir.path().join("tsconfig.json"),
3932 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
3933 )
3934 .unwrap();
3935
3936 let prod_path = src_dir.join("bar.service.ts");
3937 std::fs::File::create(&prod_path).unwrap();
3938
3939 std::fs::write(
3940 services_dir.join("index.ts"),
3941 "export { BarService } from '../bar.service';\n",
3942 )
3943 .unwrap();
3944
3945 let test_path = test_dir.join("bar.service.spec.ts");
3946 let test_source =
3947 "import { BarService } from '@app/services';\ndescribe('BarService', () => {});\n";
3948 std::fs::write(&test_path, test_source).unwrap();
3949
3950 let production_files = vec![prod_path.to_string_lossy().into_owned()];
3951 let mut test_sources = HashMap::new();
3952 test_sources.insert(
3953 test_path.to_string_lossy().into_owned(),
3954 test_source.to_string(),
3955 );
3956
3957 let extractor = TypeScriptExtractor::new();
3958
3959 let mappings = extractor.map_test_files_with_imports(
3961 &production_files,
3962 &test_sources,
3963 dir.path(),
3964 false,
3965 );
3966
3967 let mapping = mappings
3969 .iter()
3970 .find(|m| m.production_file.contains("bar.service.ts"))
3971 .expect("expected mapping for bar.service.ts");
3972 assert!(
3973 mapping
3974 .test_files
3975 .contains(&test_path.to_string_lossy().into_owned()),
3976 "expected bar.service.spec.ts mapped via alias+barrel, got {:?}",
3977 mapping.test_files
3978 );
3979 }
3980
3981 #[test]
3983 fn test_observe_tsconfig_alias_mixed() {
3984 use tempfile::TempDir;
3985
3986 let dir = TempDir::new().unwrap();
3993 let src_dir = dir.path().join("src");
3994 let test_dir = dir.path().join("test");
3995 std::fs::create_dir_all(&src_dir).unwrap();
3996 std::fs::create_dir_all(&test_dir).unwrap();
3997
3998 std::fs::write(
3999 dir.path().join("tsconfig.json"),
4000 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4001 )
4002 .unwrap();
4003
4004 let foo_path = src_dir.join("foo.service.ts");
4005 let bar_path = src_dir.join("bar.service.ts");
4006 std::fs::File::create(&foo_path).unwrap();
4007 std::fs::File::create(&bar_path).unwrap();
4008
4009 let test_path = test_dir.join("mixed.spec.ts");
4010 let test_source = "\
4011import { FooService } from '@app/foo.service';
4012import { BarService } from '../src/bar.service';
4013describe('Mixed', () => {});
4014";
4015 std::fs::write(&test_path, test_source).unwrap();
4016
4017 let production_files = vec![
4018 foo_path.to_string_lossy().into_owned(),
4019 bar_path.to_string_lossy().into_owned(),
4020 ];
4021 let mut test_sources = HashMap::new();
4022 test_sources.insert(
4023 test_path.to_string_lossy().into_owned(),
4024 test_source.to_string(),
4025 );
4026
4027 let extractor = TypeScriptExtractor::new();
4028
4029 let mappings = extractor.map_test_files_with_imports(
4031 &production_files,
4032 &test_sources,
4033 dir.path(),
4034 false,
4035 );
4036
4037 let foo_mapping = mappings
4039 .iter()
4040 .find(|m| m.production_file.contains("foo.service.ts"))
4041 .expect("expected mapping for foo.service.ts");
4042 assert!(
4043 foo_mapping
4044 .test_files
4045 .contains(&test_path.to_string_lossy().into_owned()),
4046 "expected mixed.spec.ts in foo mapping, got {:?}",
4047 foo_mapping.test_files
4048 );
4049 let bar_mapping = mappings
4050 .iter()
4051 .find(|m| m.production_file.contains("bar.service.ts"))
4052 .expect("expected mapping for bar.service.ts");
4053 assert!(
4054 bar_mapping
4055 .test_files
4056 .contains(&test_path.to_string_lossy().into_owned()),
4057 "expected mixed.spec.ts in bar mapping, got {:?}",
4058 bar_mapping.test_files
4059 );
4060 }
4061
4062 #[test]
4064 fn test_observe_tsconfig_alias_helper_filtered() {
4065 use tempfile::TempDir;
4066
4067 let dir = TempDir::new().unwrap();
4072 let src_dir = dir.path().join("src");
4073 let test_dir = dir.path().join("test");
4074 std::fs::create_dir_all(&src_dir).unwrap();
4075 std::fs::create_dir_all(&test_dir).unwrap();
4076
4077 std::fs::write(
4078 dir.path().join("tsconfig.json"),
4079 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4080 )
4081 .unwrap();
4082
4083 let prod_path = src_dir.join("constants.ts");
4084 std::fs::File::create(&prod_path).unwrap();
4085
4086 let test_path = test_dir.join("constants.spec.ts");
4087 let test_source =
4088 "import { APP_NAME } from '@app/constants';\ndescribe('Constants', () => {});\n";
4089 std::fs::write(&test_path, test_source).unwrap();
4090
4091 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4092 let mut test_sources = HashMap::new();
4093 test_sources.insert(
4094 test_path.to_string_lossy().into_owned(),
4095 test_source.to_string(),
4096 );
4097
4098 let extractor = TypeScriptExtractor::new();
4099
4100 let mappings = extractor.map_test_files_with_imports(
4102 &production_files,
4103 &test_sources,
4104 dir.path(),
4105 false,
4106 );
4107
4108 let all_test_files: Vec<&String> =
4110 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4111 assert!(
4112 all_test_files.is_empty(),
4113 "expected constants.ts filtered by is_non_sut_helper, got {:?}",
4114 all_test_files
4115 );
4116 }
4117
4118 #[test]
4120 fn test_observe_tsconfig_alias_nonexistent() {
4121 use tempfile::TempDir;
4122
4123 let dir = TempDir::new().unwrap();
4129 let src_dir = dir.path().join("src");
4130 let test_dir = dir.path().join("test");
4131 std::fs::create_dir_all(&src_dir).unwrap();
4132 std::fs::create_dir_all(&test_dir).unwrap();
4133
4134 std::fs::write(
4135 dir.path().join("tsconfig.json"),
4136 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4137 )
4138 .unwrap();
4139
4140 let prod_path = src_dir.join("foo.service.ts");
4141 std::fs::File::create(&prod_path).unwrap();
4142
4143 let test_path = test_dir.join("nonexistent.spec.ts");
4144 let test_source =
4145 "import { Missing } from '@app/nonexistent';\ndescribe('Nonexistent', () => {});\n";
4146 std::fs::write(&test_path, test_source).unwrap();
4147
4148 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4149 let mut test_sources = HashMap::new();
4150 test_sources.insert(
4151 test_path.to_string_lossy().into_owned(),
4152 test_source.to_string(),
4153 );
4154
4155 let extractor = TypeScriptExtractor::new();
4156
4157 let mappings = extractor.map_test_files_with_imports(
4159 &production_files,
4160 &test_sources,
4161 dir.path(),
4162 false,
4163 );
4164
4165 let all_test_files: Vec<&String> =
4167 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4168 assert!(
4169 all_test_files.is_empty(),
4170 "expected no mapping for alias to nonexistent file, got {:?}",
4171 all_test_files
4172 );
4173 }
4174
4175 #[test]
4178 fn boundary_b3_tsconfig_alias_resolved() {
4179 use tempfile::TempDir;
4180
4181 let dir = TempDir::new().unwrap();
4186 let src_dir = dir.path().join("src");
4187 let services_dir = src_dir.join("services");
4188 let test_dir = dir.path().join("test");
4189 std::fs::create_dir_all(&services_dir).unwrap();
4190 std::fs::create_dir_all(&test_dir).unwrap();
4191
4192 std::fs::write(
4193 dir.path().join("tsconfig.json"),
4194 r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
4195 )
4196 .unwrap();
4197
4198 let prod_path = services_dir.join("foo.service.ts");
4199 std::fs::File::create(&prod_path).unwrap();
4200
4201 let test_path = test_dir.join("foo.service.spec.ts");
4202 let test_source = "import { FooService } from '@app/services/foo.service';\ndescribe('FooService', () => {});\n";
4203 std::fs::write(&test_path, test_source).unwrap();
4204
4205 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4206 let mut test_sources = HashMap::new();
4207 test_sources.insert(
4208 test_path.to_string_lossy().into_owned(),
4209 test_source.to_string(),
4210 );
4211
4212 let extractor = TypeScriptExtractor::new();
4213
4214 let mappings = extractor.map_test_files_with_imports(
4216 &production_files,
4217 &test_sources,
4218 dir.path(),
4219 false,
4220 );
4221
4222 let mapping = mappings
4224 .iter()
4225 .find(|m| m.production_file.contains("foo.service.ts"))
4226 .expect("expected FileMapping for foo.service.ts");
4227 assert!(
4228 mapping
4229 .test_files
4230 .contains(&test_path.to_string_lossy().into_owned()),
4231 "expected tsconfig alias to be resolved (B3 fix), got {:?}",
4232 mapping.test_files
4233 );
4234 }
4235
4236 #[test]
4238 fn boundary_b6_import_outside_scan_root() {
4239 use tempfile::TempDir;
4240
4241 let dir = TempDir::new().unwrap();
4247 let core_src = dir.path().join("packages").join("core").join("src");
4248 let core_test = dir.path().join("packages").join("core").join("test");
4249 let common_src = dir.path().join("packages").join("common").join("src");
4250 std::fs::create_dir_all(&core_src).unwrap();
4251 std::fs::create_dir_all(&core_test).unwrap();
4252 std::fs::create_dir_all(&common_src).unwrap();
4253
4254 let prod_path = core_src.join("foo.service.ts");
4255 std::fs::File::create(&prod_path).unwrap();
4256
4257 let shared_path = common_src.join("shared.ts");
4259 std::fs::File::create(&shared_path).unwrap();
4260
4261 let test_path = core_test.join("foo.spec.ts");
4262 std::fs::write(
4263 &test_path,
4264 "import { Shared } from '../../common/src/shared';\ndescribe('Foo', () => {});",
4265 )
4266 .unwrap();
4267
4268 let scan_root = dir.path().join("packages").join("core");
4269 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4271 let mut test_sources = HashMap::new();
4272 test_sources.insert(
4273 test_path.to_string_lossy().into_owned(),
4274 std::fs::read_to_string(&test_path).unwrap(),
4275 );
4276
4277 let extractor = TypeScriptExtractor::new();
4278
4279 let mappings = extractor.map_test_files_with_imports(
4281 &production_files,
4282 &test_sources,
4283 &scan_root,
4284 false,
4285 );
4286
4287 let all_test_files: Vec<&String> =
4291 mappings.iter().flat_map(|m| m.test_files.iter()).collect();
4292 assert!(
4293 all_test_files.is_empty(),
4294 "expected no test_files (import target outside scan_root), got {:?}",
4295 all_test_files
4296 );
4297 }
4298
4299 #[test]
4303 fn test_b1_ns_reexport_extracted_as_wildcard() {
4304 let source = "export * as Validators from './validators';";
4306 let extractor = TypeScriptExtractor::new();
4307
4308 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4310
4311 assert_eq!(
4313 re_exports.len(),
4314 1,
4315 "expected 1 re-export, got {:?}",
4316 re_exports
4317 );
4318 let re = &re_exports[0];
4319 assert_eq!(re.from_specifier, "./validators");
4320 assert!(
4321 re.wildcard,
4322 "expected wildcard=true for `export * as Ns from`, got {:?}",
4323 re
4324 );
4325 }
4326
4327 #[test]
4329 fn test_b1_ns_reexport_mapping_resolves_via_layer2() {
4330 use tempfile::TempDir;
4331
4332 let dir = TempDir::new().unwrap();
4338 let validators_dir = dir.path().join("validators");
4339 let test_dir = dir.path().join("test");
4340 std::fs::create_dir_all(&validators_dir).unwrap();
4341 std::fs::create_dir_all(&test_dir).unwrap();
4342
4343 let prod_path = validators_dir.join("foo.service.ts");
4344 std::fs::File::create(&prod_path).unwrap();
4345
4346 std::fs::write(
4347 dir.path().join("index.ts"),
4348 "export * as Ns from './validators';",
4349 )
4350 .unwrap();
4351 std::fs::write(
4352 validators_dir.join("index.ts"),
4353 "export { FooService } from './foo.service';",
4354 )
4355 .unwrap();
4356
4357 let test_path = test_dir.join("foo.spec.ts");
4358 std::fs::write(
4359 &test_path,
4360 "import { Ns } from '../index';\ndescribe('FooService', () => {});",
4361 )
4362 .unwrap();
4363
4364 let production_files = vec![prod_path.to_string_lossy().into_owned()];
4365 let mut test_sources = HashMap::new();
4366 test_sources.insert(
4367 test_path.to_string_lossy().into_owned(),
4368 std::fs::read_to_string(&test_path).unwrap(),
4369 );
4370
4371 let extractor = TypeScriptExtractor::new();
4372
4373 let mappings = extractor.map_test_files_with_imports(
4375 &production_files,
4376 &test_sources,
4377 dir.path(),
4378 false,
4379 );
4380
4381 let prod_mapping = mappings
4383 .iter()
4384 .find(|m| m.production_file.contains("foo.service.ts"));
4385 assert!(
4386 prod_mapping.is_some(),
4387 "expected foo.service.ts in mappings, got {:?}",
4388 mappings
4389 );
4390 let mapping = prod_mapping.unwrap();
4391 assert!(
4392 !mapping.test_files.is_empty(),
4393 "expected foo.service.ts mapped to foo.spec.ts via namespace re-export, got {:?}",
4394 mapping
4395 );
4396 assert!(
4397 mapping.test_files.iter().any(|f| f.contains("foo.spec.ts")),
4398 "expected foo.spec.ts in test_files, got {:?}",
4399 mapping.test_files
4400 );
4401 }
4402
4403 #[test]
4405 fn test_b1_ns_reexport_mixed_with_plain_wildcard() {
4406 let source = "export * from './a';\nexport * as B from './b';";
4408 let extractor = TypeScriptExtractor::new();
4409
4410 let mut re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4412
4413 assert_eq!(
4415 re_exports.len(),
4416 2,
4417 "expected 2 re-exports, got {:?}",
4418 re_exports
4419 );
4420
4421 re_exports.sort_by(|a, b| a.from_specifier.cmp(&b.from_specifier));
4422
4423 let re_a = re_exports.iter().find(|r| r.from_specifier == "./a");
4424 let re_b = re_exports.iter().find(|r| r.from_specifier == "./b");
4425
4426 assert!(
4427 re_a.is_some(),
4428 "expected re-export from './a', got {:?}",
4429 re_exports
4430 );
4431 assert!(
4432 re_b.is_some(),
4433 "expected re-export from './b', got {:?}",
4434 re_exports
4435 );
4436 let a = re_a.unwrap();
4437 let b = re_b.unwrap();
4438 assert!(
4439 a.wildcard,
4440 "expected wildcard=true for plain `export * from './a'`, got {:?}",
4441 a
4442 );
4443 assert!(
4444 !a.namespace_wildcard,
4445 "expected namespace_wildcard=false for plain wildcard, got {:?}",
4446 a
4447 );
4448 assert!(
4449 b.wildcard,
4450 "expected wildcard=true for namespace `export * as B from './b'`, got {:?}",
4451 b
4452 );
4453 assert!(
4454 b.namespace_wildcard,
4455 "expected namespace_wildcard=true for namespace re-export, got {:?}",
4456 b
4457 );
4458 }
4459
4460 #[test]
4462 fn test_b1_ns_reexport_mixed_with_named_reexport() {
4463 let source = "export { Foo } from './a';\nexport * as B from './b';";
4465 let extractor = TypeScriptExtractor::new();
4466
4467 let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
4469
4470 assert_eq!(
4472 re_exports.len(),
4473 2,
4474 "expected 2 re-exports, got {:?}",
4475 re_exports
4476 );
4477
4478 let re_a = re_exports.iter().find(|r| r.from_specifier == "./a");
4479 let re_b = re_exports.iter().find(|r| r.from_specifier == "./b");
4480
4481 assert!(
4482 re_a.is_some(),
4483 "expected re-export from './a', got {:?}",
4484 re_exports
4485 );
4486 assert!(
4487 re_b.is_some(),
4488 "expected re-export from './b', got {:?}",
4489 re_exports
4490 );
4491
4492 let re_a = re_a.unwrap();
4493 assert!(
4494 !re_a.wildcard,
4495 "expected wildcard=false for named re-export from './a', got {:?}",
4496 re_a
4497 );
4498 assert_eq!(
4499 re_a.symbols,
4500 vec!["Foo".to_string()],
4501 "expected symbols=[\"Foo\"] for './a', got {:?}",
4502 re_a.symbols
4503 );
4504
4505 let re_b = re_b.unwrap();
4506 assert!(
4507 re_b.wildcard,
4508 "expected wildcard=true for namespace re-export from './b', got {:?}",
4509 re_b
4510 );
4511 }
4512
4513 #[test]
4517 fn nx_fp_01_basic_app_router_path() {
4518 let result = file_path_to_route_path("app/api/users/route.ts");
4521 assert_eq!(result, Some("/api/users".to_string()));
4523 }
4524
4525 #[test]
4527 fn nx_fp_02_src_app_prefix_stripped() {
4528 let result = file_path_to_route_path("src/app/api/users/route.ts");
4531 assert_eq!(result, Some("/api/users".to_string()));
4533 }
4534
4535 #[test]
4537 fn nx_fp_03_dynamic_segment() {
4538 let result = file_path_to_route_path("app/api/users/[id]/route.ts");
4541 assert_eq!(result, Some("/api/users/:id".to_string()));
4543 }
4544
4545 #[test]
4547 fn nx_fp_04_route_group_removed() {
4548 let result = file_path_to_route_path("app/(admin)/api/route.ts");
4551 assert_eq!(result, Some("/api".to_string()));
4553 }
4554
4555 #[test]
4557 fn nx_fp_05_route_tsx_extension() {
4558 let result = file_path_to_route_path("app/api/route.tsx");
4561 assert_eq!(result, Some("/api".to_string()));
4563 }
4564
4565 #[test]
4567 fn nx_fp_06_non_route_file_rejected() {
4568 let result = file_path_to_route_path("app/api/users/page.ts");
4571 assert_eq!(result, None);
4573 }
4574
4575 #[test]
4577 fn nx_fp_07_catch_all_segment() {
4578 let result = file_path_to_route_path("app/api/[...slug]/route.ts");
4581 assert_eq!(result, Some("/api/:slug*".to_string()));
4583 }
4584
4585 #[test]
4587 fn nx_fp_08_optional_catch_all_segment() {
4588 let result = file_path_to_route_path("app/api/[[...slug]]/route.ts");
4591 assert_eq!(result, Some("/api/:slug*?".to_string()));
4593 }
4594
4595 #[test]
4597 fn nx_fp_09_root_route() {
4598 let result = file_path_to_route_path("app/route.ts");
4601 assert_eq!(result, Some("/".to_string()));
4603 }
4604
4605 #[test]
4609 fn nx_rt_01_basic_get_handler() {
4610 let source = "export async function GET(request: Request) { return Response.json([]); }";
4612 let extractor = TypeScriptExtractor::new();
4613
4614 let routes = extractor.extract_nextjs_routes(source, "app/api/users/route.ts");
4616
4617 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4619 assert_eq!(routes[0].http_method, "GET");
4620 assert_eq!(routes[0].path, "/api/users");
4621 assert_eq!(routes[0].handler_name, "GET");
4622 }
4623
4624 #[test]
4626 fn nx_rt_02_multiple_http_methods() {
4627 let source = r#"
4629export async function GET() { return Response.json([]); }
4630export async function POST() { return Response.json({}); }
4631"#;
4632 let extractor = TypeScriptExtractor::new();
4633
4634 let routes = extractor.extract_nextjs_routes(source, "app/api/users/route.ts");
4636
4637 assert_eq!(routes.len(), 2, "expected 2 routes, got {:?}", routes);
4639 let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
4640 assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
4641 assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
4642 for r in &routes {
4643 assert_eq!(r.path, "/api/users");
4644 }
4645 }
4646
4647 #[test]
4649 fn nx_rt_03_dynamic_segment_path() {
4650 let source = "export async function GET() { return Response.json({}); }";
4652 let extractor = TypeScriptExtractor::new();
4653
4654 let routes = extractor.extract_nextjs_routes(source, "app/api/users/[id]/route.ts");
4656
4657 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4659 assert_eq!(routes[0].path, "/api/users/:id");
4660 }
4661
4662 #[test]
4664 fn nx_rt_04_non_route_file_returns_empty() {
4665 let source = "export async function GET() { return null; }";
4667 let extractor = TypeScriptExtractor::new();
4668
4669 let routes = extractor.extract_nextjs_routes(source, "app/api/users/page.ts");
4671
4672 assert!(
4674 routes.is_empty(),
4675 "expected empty routes for page.ts, got {:?}",
4676 routes
4677 );
4678 }
4679
4680 #[test]
4682 fn nx_rt_05_no_http_method_exports_returns_empty() {
4683 let source = "export function helper() { return null; }";
4685 let extractor = TypeScriptExtractor::new();
4686
4687 let routes = extractor.extract_nextjs_routes(source, "app/api/route.ts");
4689
4690 assert!(
4692 routes.is_empty(),
4693 "expected empty routes for helper(), got {:?}",
4694 routes
4695 );
4696 }
4697
4698 #[test]
4700 fn nx_rt_06_arrow_function_export() {
4701 let source = "export const GET = async () => Response.json([]);";
4703 let extractor = TypeScriptExtractor::new();
4704
4705 let routes = extractor.extract_nextjs_routes(source, "app/api/route.ts");
4707
4708 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4710 assert_eq!(routes[0].http_method, "GET");
4711 assert_eq!(routes[0].path, "/api");
4712 assert_eq!(routes[0].handler_name, "GET");
4713 }
4714
4715 #[test]
4717 fn nx_rt_07_empty_source_returns_empty() {
4718 let extractor = TypeScriptExtractor::new();
4720
4721 let routes = extractor.extract_nextjs_routes("", "app/api/route.ts");
4723
4724 assert!(routes.is_empty(), "expected empty routes for empty source");
4726 }
4727
4728 #[test]
4730 fn ts_l1ex_01_l1_exclusive_suppresses_l2() {
4731 use tempfile::TempDir;
4732
4733 let dir = TempDir::new().unwrap();
4740 let src_dir = dir.path().join("src");
4741 std::fs::create_dir_all(&src_dir).unwrap();
4742
4743 let user_service = src_dir.join("user.service.ts");
4744 std::fs::File::create(&user_service).unwrap();
4745 let auth_service = src_dir.join("auth.service.ts");
4746 std::fs::File::create(&auth_service).unwrap();
4747
4748 let test_file = src_dir.join("user.service.spec.ts");
4749 let test_source =
4751 "import { AuthService } from './auth.service';\ndescribe('UserService', () => {});\n";
4752
4753 let production_files = vec![
4754 user_service.to_string_lossy().into_owned(),
4755 auth_service.to_string_lossy().into_owned(),
4756 ];
4757 let mut test_sources = HashMap::new();
4758 test_sources.insert(
4759 test_file.to_string_lossy().into_owned(),
4760 test_source.to_string(),
4761 );
4762
4763 let extractor = TypeScriptExtractor::new();
4764
4765 let mappings_exclusive = extractor.map_test_files_with_imports(
4767 &production_files,
4768 &test_sources,
4769 dir.path(),
4770 true,
4771 );
4772
4773 let user_mapping = mappings_exclusive
4775 .iter()
4776 .find(|m| m.production_file.contains("user.service.ts"));
4777 assert!(
4778 user_mapping.is_some(),
4779 "expected user.service.ts in mappings, got {:?}",
4780 mappings_exclusive
4781 );
4782 assert!(
4783 !user_mapping.unwrap().test_files.is_empty(),
4784 "expected user.service.ts mapped to user.service.spec.ts, got {:?}",
4785 user_mapping.unwrap().test_files
4786 );
4787
4788 let auth_mapping = mappings_exclusive
4789 .iter()
4790 .find(|m| m.production_file.contains("auth.service.ts"));
4791 assert!(
4792 auth_mapping
4793 .map(|m| m.test_files.is_empty())
4794 .unwrap_or(true),
4795 "expected auth.service.ts NOT mapped when l1_exclusive=true, got {:?}",
4796 auth_mapping
4797 );
4798
4799 let mappings_default = extractor.map_test_files_with_imports(
4801 &production_files,
4802 &test_sources,
4803 dir.path(),
4804 false,
4805 );
4806
4807 let auth_mapping_default = mappings_default
4809 .iter()
4810 .find(|m| m.production_file.contains("auth.service.ts"));
4811 assert!(
4812 auth_mapping_default
4813 .map(|m| !m.test_files.is_empty())
4814 .unwrap_or(false),
4815 "expected auth.service.ts mapped when l1_exclusive=false, got {:?}",
4816 auth_mapping_default
4817 );
4818 }
4819
4820 #[test]
4822 fn nx_rt_08_route_group_in_path() {
4823 let source = "export async function GET() { return Response.json({}); }";
4825 let extractor = TypeScriptExtractor::new();
4826
4827 let routes = extractor.extract_nextjs_routes(source, "app/(auth)/api/login/route.ts");
4829
4830 assert_eq!(routes.len(), 1, "expected 1 route, got {:?}", routes);
4832 assert_eq!(routes[0].path, "/api/login");
4833 }
4834}