1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, PartialEq)]
9pub struct ProductionFunction {
10 pub name: String,
11 pub file: String,
12 pub line: usize,
13 pub class_name: Option<String>,
14 pub is_exported: bool,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct FileMapping {
19 pub production_file: String,
20 pub test_files: Vec<String>,
21 pub strategy: MappingStrategy,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum MappingStrategy {
26 FileNameConvention,
27 ImportTracing,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct ImportMapping {
32 pub symbol_name: String,
33 pub module_specifier: String,
34 pub file: String,
35 pub line: usize,
36 pub symbols: Vec<String>,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct BarrelReExport {
41 pub symbols: Vec<String>,
42 pub from_specifier: String,
43 pub wildcard: bool,
44 pub namespace_wildcard: bool,
48}
49
50pub trait ObserveExtractor: Send + Sync {
55 fn extract_production_functions(
56 &self,
57 source: &str,
58 file_path: &str,
59 ) -> Vec<ProductionFunction>;
60 fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping>;
61 fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)>;
62 fn extract_barrel_re_exports(&self, source: &str, file_path: &str) -> Vec<BarrelReExport>;
63 fn source_extensions(&self) -> &[&str];
64 fn index_file_names(&self) -> &[&str];
65 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str>;
66 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str>;
67 fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool;
68
69 fn is_barrel_file(&self, path: &str) -> bool {
71 let file_name = Path::new(path)
72 .file_name()
73 .and_then(|f| f.to_str())
74 .unwrap_or("");
75 self.index_file_names().contains(&file_name)
76 }
77
78 fn file_exports_any_symbol(&self, _path: &Path, _symbols: &[String]) -> bool {
79 true
80 }
81
82 fn resolve_alias_imports(
83 &self,
84 _source: &str,
85 _scan_root: &Path,
86 ) -> Vec<(String, Vec<String>, Option<PathBuf>)> {
87 Vec::new()
88 }
89}
90
91pub const MAX_BARREL_DEPTH: usize = 3;
96
97pub fn map_test_files(
99 ext: &dyn ObserveExtractor,
100 production_files: &[String],
101 test_files: &[String],
102) -> Vec<FileMapping> {
103 let mut tests_by_key: HashMap<(String, String), Vec<String>> = HashMap::new();
104
105 for test_file in test_files {
106 let Some(stem) = ext.test_stem(test_file) else {
107 continue;
108 };
109 let directory = Path::new(test_file)
110 .parent()
111 .map(|parent| parent.to_string_lossy().into_owned())
112 .unwrap_or_default();
113 tests_by_key
114 .entry((directory, stem.to_string()))
115 .or_default()
116 .push(test_file.clone());
117 }
118
119 production_files
120 .iter()
121 .map(|production_file| {
122 let test_matches = ext
123 .production_stem(production_file)
124 .and_then(|stem| {
125 let directory = Path::new(production_file)
126 .parent()
127 .map(|parent| parent.to_string_lossy().into_owned())
128 .unwrap_or_default();
129 tests_by_key.get(&(directory, stem.to_string()))
130 })
131 .cloned()
132 .unwrap_or_default();
133 FileMapping {
134 production_file: production_file.clone(),
135 test_files: test_matches,
136 strategy: MappingStrategy::FileNameConvention,
137 }
138 })
139 .collect()
140}
141
142pub fn resolve_import_path(
145 ext: &dyn ObserveExtractor,
146 module_specifier: &str,
147 from_file: &Path,
148 scan_root: &Path,
149) -> Option<String> {
150 let base_dir_raw = from_file.parent()?;
151 let base_dir = base_dir_raw
152 .canonicalize()
153 .unwrap_or_else(|_| base_dir_raw.to_path_buf());
154 let raw_path = base_dir.join(module_specifier);
155 let canonical_root = scan_root.canonicalize().ok()?;
156 resolve_absolute_base_to_file(ext, &raw_path, &canonical_root)
157}
158
159pub fn resolve_absolute_base_to_file(
166 ext: &dyn ObserveExtractor,
167 base: &Path,
168 canonical_root: &Path,
169) -> Option<String> {
170 let extensions = ext.source_extensions();
171 let has_known_ext = base
172 .extension()
173 .and_then(|e| e.to_str())
174 .is_some_and(|e| extensions.contains(&e));
175
176 let candidates: Vec<PathBuf> = if has_known_ext {
177 vec![base.to_path_buf()]
178 } else {
179 let base_str = base.as_os_str().to_string_lossy();
180 extensions
181 .iter()
182 .map(|e| PathBuf::from(format!("{base_str}.{e}")))
183 .collect()
184 };
185
186 for candidate in &candidates {
187 if let Ok(canonical) = candidate.canonicalize() {
188 if canonical.starts_with(canonical_root) {
189 return Some(canonical.to_string_lossy().into_owned());
190 }
191 }
192 }
193
194 if !has_known_ext {
196 let base_str = base.as_os_str().to_string_lossy();
197 for index_name in ext.index_file_names() {
198 let candidate = PathBuf::from(format!("{base_str}/{index_name}"));
199 if let Ok(canonical) = candidate.canonicalize() {
200 if canonical.starts_with(canonical_root) {
201 return Some(canonical.to_string_lossy().into_owned());
202 }
203 }
204 }
205 }
206
207 None
208}
209
210pub fn resolve_barrel_exports(
213 ext: &dyn ObserveExtractor,
214 barrel_path: &Path,
215 symbols: &[String],
216 scan_root: &Path,
217) -> Vec<PathBuf> {
218 let canonical_root = match scan_root.canonicalize() {
219 Ok(r) => r,
220 Err(_) => return Vec::new(),
221 };
222 let mut visited: HashSet<PathBuf> = HashSet::new();
223 let mut results: Vec<PathBuf> = Vec::new();
224 resolve_barrel_exports_inner(
225 ext,
226 barrel_path,
227 symbols,
228 scan_root,
229 &canonical_root,
230 &mut visited,
231 0,
232 &mut results,
233 );
234 results
235}
236
237#[allow(clippy::too_many_arguments)]
238fn resolve_barrel_exports_inner(
239 ext: &dyn ObserveExtractor,
240 barrel_path: &Path,
241 symbols: &[String],
242 scan_root: &Path,
243 canonical_root: &Path,
244 visited: &mut HashSet<PathBuf>,
245 depth: usize,
246 results: &mut Vec<PathBuf>,
247) {
248 if depth >= MAX_BARREL_DEPTH {
249 return;
250 }
251
252 let canonical_barrel = match barrel_path.canonicalize() {
253 Ok(p) => p,
254 Err(_) => return,
255 };
256 if !visited.insert(canonical_barrel) {
257 return;
258 }
259
260 let source = match std::fs::read_to_string(barrel_path) {
261 Ok(s) => s,
262 Err(_) => return,
263 };
264
265 let re_exports = ext.extract_barrel_re_exports(&source, &barrel_path.to_string_lossy());
266
267 for re_export in &re_exports {
268 if !re_export.wildcard {
269 let has_match =
270 symbols.is_empty() || symbols.iter().any(|s| re_export.symbols.contains(s));
271 if !has_match {
272 continue;
273 }
274 }
275
276 if let Some(resolved_str) =
277 resolve_import_path(ext, &re_export.from_specifier, barrel_path, scan_root)
278 {
279 if ext.is_barrel_file(&resolved_str) {
280 let nested_symbols: &[String] = if re_export.namespace_wildcard {
284 &[]
285 } else {
286 symbols
287 };
288 resolve_barrel_exports_inner(
289 ext,
290 &PathBuf::from(&resolved_str),
291 nested_symbols,
292 scan_root,
293 canonical_root,
294 visited,
295 depth + 1,
296 results,
297 );
298 } else if !ext.is_non_sut_helper(&resolved_str, false) {
299 if !symbols.is_empty()
302 && re_export.wildcard
303 && !ext.file_exports_any_symbol(Path::new(&resolved_str), symbols)
304 {
305 continue;
306 }
307 if let Ok(canonical) = PathBuf::from(&resolved_str).canonicalize() {
308 if canonical.starts_with(canonical_root) && !results.contains(&canonical) {
309 results.push(canonical);
310 }
311 }
312 }
313 }
314 }
315}
316
317pub fn collect_import_matches(
320 ext: &dyn ObserveExtractor,
321 resolved: &str,
322 symbols: &[String],
323 canonical_to_idx: &HashMap<String, usize>,
324 indices: &mut HashSet<usize>,
325 canonical_root: &Path,
326) {
327 if ext.is_barrel_file(resolved) {
328 let barrel_path = PathBuf::from(resolved);
329 let resolved_files = resolve_barrel_exports(ext, &barrel_path, symbols, canonical_root);
330 for prod in resolved_files {
331 let prod_str = prod.to_string_lossy().into_owned();
332 if !ext.is_non_sut_helper(&prod_str, canonical_to_idx.contains_key(&prod_str)) {
333 if let Some(&idx) = canonical_to_idx.get(&prod_str) {
334 indices.insert(idx);
335 }
336 }
337 }
338 } else if !ext.is_non_sut_helper(resolved, canonical_to_idx.contains_key(resolved)) {
339 if let Some(&idx) = canonical_to_idx.get(resolved) {
340 indices.insert(idx);
341 }
342 }
343}
344
345#[cfg(test)]
350mod tests {
351 use super::*;
352
353 struct MockExtractor;
354
355 impl ObserveExtractor for MockExtractor {
356 fn extract_production_functions(
357 &self,
358 _source: &str,
359 _file_path: &str,
360 ) -> Vec<ProductionFunction> {
361 vec![]
362 }
363 fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
364 vec![]
365 }
366 fn extract_all_import_specifiers(&self, _source: &str) -> Vec<(String, Vec<String>)> {
367 vec![]
368 }
369 fn extract_barrel_re_exports(
370 &self,
371 _source: &str,
372 _file_path: &str,
373 ) -> Vec<BarrelReExport> {
374 vec![]
375 }
376 fn source_extensions(&self) -> &[&str] {
377 &["ts", "tsx", "js", "jsx"]
378 }
379 fn index_file_names(&self) -> &[&str] {
380 &["index.ts", "index.tsx"]
381 }
382 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
383 Path::new(path).file_stem()?.to_str()
384 }
385 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
386 let stem = Path::new(path).file_stem()?.to_str()?;
387 stem.strip_suffix(".spec")
388 .or_else(|| stem.strip_suffix(".test"))
389 }
390 fn is_non_sut_helper(&self, _file_path: &str, _is_known_production: bool) -> bool {
391 false
392 }
393 }
394
395 struct ConfigurableMockExtractor {
397 barrel_file_names: Vec<String>,
398 helper_file_paths: Vec<String>,
399 }
400
401 impl ConfigurableMockExtractor {
402 fn new() -> Self {
403 Self {
404 barrel_file_names: vec!["index.ts".to_string()],
405 helper_file_paths: vec![],
406 }
407 }
408
409 fn with_helpers(helper_paths: Vec<String>) -> Self {
410 Self {
411 barrel_file_names: vec!["index.ts".to_string()],
412 helper_file_paths: helper_paths,
413 }
414 }
415 }
416
417 impl ObserveExtractor for ConfigurableMockExtractor {
418 fn extract_production_functions(
419 &self,
420 _source: &str,
421 _file_path: &str,
422 ) -> Vec<ProductionFunction> {
423 vec![]
424 }
425 fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
426 vec![]
427 }
428 fn extract_all_import_specifiers(&self, _source: &str) -> Vec<(String, Vec<String>)> {
429 vec![]
430 }
431 fn extract_barrel_re_exports(
432 &self,
433 _source: &str,
434 _file_path: &str,
435 ) -> Vec<BarrelReExport> {
436 vec![]
438 }
439 fn source_extensions(&self) -> &[&str] {
440 &["ts", "tsx"]
441 }
442 fn index_file_names(&self) -> &[&str] {
443 &["index.ts"]
445 }
446 fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
447 Path::new(path).file_stem()?.to_str()
448 }
449 fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
450 let stem = Path::new(path).file_stem()?.to_str()?;
451 stem.strip_suffix(".spec")
452 .or_else(|| stem.strip_suffix(".test"))
453 }
454 fn is_non_sut_helper(&self, file_path: &str, _is_known_production: bool) -> bool {
455 self.helper_file_paths.iter().any(|h| h == file_path)
456 }
457 }
458
459 #[test]
461 fn tc01_map_test_files_stem_matching() {
462 let mock = MockExtractor;
463 let production = vec!["src/user.service.ts".to_string()];
464 let tests = vec!["src/user.service.spec.ts".to_string()];
465 let result = map_test_files(&mock, &production, &tests);
466 assert_eq!(result.len(), 1);
467 assert_eq!(result[0].production_file, "src/user.service.ts");
468 assert_eq!(result[0].test_files, vec!["src/user.service.spec.ts"]);
469 assert_eq!(result[0].strategy, MappingStrategy::FileNameConvention);
470 }
471
472 #[test]
474 fn tc01b_map_test_files_no_match() {
475 let mock = MockExtractor;
476 let production = vec!["src/user.service.ts".to_string()];
477 let tests = vec!["src/order.service.spec.ts".to_string()];
478 let result = map_test_files(&mock, &production, &tests);
479 assert_eq!(result.len(), 1);
480 assert!(result[0].test_files.is_empty());
481 }
482
483 #[test]
485 fn tc03_is_barrel_file_default_impl() {
486 let mock = MockExtractor;
487 assert!(mock.is_barrel_file("src/index.ts"));
488 assert!(mock.is_barrel_file("src/index.tsx"));
489 assert!(!mock.is_barrel_file("src/user.service.ts"));
490 assert!(!mock.is_barrel_file("src/index.rs")); }
492
493 #[test]
495 fn tc06_trait_is_send_sync() {
496 fn assert_send_sync<T: Send + Sync>() {}
497 assert_send_sync::<MockExtractor>();
498 let _: Box<dyn ObserveExtractor + Send + Sync> = Box::new(MockExtractor);
500 }
501
502 #[test]
510 fn core_cim_01_barrel_file_skips_direct_match_branch() {
511 let ext = ConfigurableMockExtractor::new();
513 let barrel_path = "/project/src/index.ts";
514 let symbols: Vec<String> = vec!["UserService".to_string()];
515 let canonical_root = Path::new("/project/src");
516
517 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
519 canonical_to_idx.insert(barrel_path.to_string(), 0);
520 let mut indices: HashSet<usize> = HashSet::new();
521
522 collect_import_matches(
525 &ext,
526 barrel_path,
527 &symbols,
528 &canonical_to_idx,
529 &mut indices,
530 canonical_root,
531 );
532
533 assert!(
535 indices.is_empty(),
536 "barrel path itself must not be added via direct-match branch"
537 );
538 }
539
540 #[test]
547 fn core_cim_02_non_barrel_direct_match() {
548 let ext = ConfigurableMockExtractor::new();
550 let prod_path = "/project/src/user.service.ts";
551 let symbols: Vec<String> = vec!["UserService".to_string()];
552 let canonical_root = Path::new("/project/src");
553
554 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
555 canonical_to_idx.insert(prod_path.to_string(), 0);
556 let mut indices: HashSet<usize> = HashSet::new();
557
558 collect_import_matches(
560 &ext,
561 prod_path,
562 &symbols,
563 &canonical_to_idx,
564 &mut indices,
565 canonical_root,
566 );
567
568 assert!(
570 indices.contains(&0),
571 "production file index must be inserted for non-barrel direct match"
572 );
573 assert_eq!(indices.len(), 1);
574 }
575
576 #[test]
582 fn core_cim_03_helper_file_skipped() {
583 let helper_path = "/project/src/test-utils.ts";
585 let ext = ConfigurableMockExtractor::with_helpers(vec![helper_path.to_string()]);
586 let symbols: Vec<String> = vec![];
587 let canonical_root = Path::new("/project/src");
588
589 let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
590 canonical_to_idx.insert(helper_path.to_string(), 0);
591 let mut indices: HashSet<usize> = HashSet::new();
592
593 collect_import_matches(
595 &ext,
596 helper_path,
597 &symbols,
598 &canonical_to_idx,
599 &mut indices,
600 canonical_root,
601 );
602
603 assert!(
605 indices.is_empty(),
606 "helper files must be skipped and not added to indices"
607 );
608 }
609
610 #[test]
616 fn core_cim_04_unknown_file_skipped() {
617 let ext = ConfigurableMockExtractor::new();
619 let unknown_path = "/project/src/unknown.service.ts";
620 let symbols: Vec<String> = vec![];
621 let canonical_root = Path::new("/project/src");
622
623 let canonical_to_idx: HashMap<String, usize> = HashMap::new(); let mut indices: HashSet<usize> = HashSet::new();
625
626 collect_import_matches(
628 &ext,
629 unknown_path,
630 &symbols,
631 &canonical_to_idx,
632 &mut indices,
633 canonical_root,
634 );
635
636 assert!(
638 indices.is_empty(),
639 "file not in canonical_to_idx must be skipped"
640 );
641 }
642}