Skip to main content

exspec_core/
observe.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4// ---------------------------------------------------------------------------
5// Types
6// ---------------------------------------------------------------------------
7
8#[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    /// True when this is a namespace re-export (`export * as Ns from '...'`).
45    /// The namespace name changes the symbol space, so nested resolution must
46    /// treat this as an opaque wildcard (symbols dropped on recursion).
47    pub namespace_wildcard: bool,
48}
49
50// ---------------------------------------------------------------------------
51// Trait
52// ---------------------------------------------------------------------------
53
54pub 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    // Default implementations
70    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
91// ---------------------------------------------------------------------------
92// Free functions
93// ---------------------------------------------------------------------------
94
95pub const MAX_BARREL_DEPTH: usize = 3;
96
97/// Layer 1: Map test files to production files by filename convention (stem matching).
98pub 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
142/// Resolve a module specifier to an absolute file path.
143/// Returns None if the file does not exist or is outside scan_root.
144pub 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
159/// Resolve an already-computed absolute base path to an actual source file.
160///
161/// Probes in order:
162/// 1. Direct hit (when `base` already has a known extension).
163/// 2. Append each known extension.
164/// 3. Directory index fallback.
165pub 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    // Fallback: directory index
195    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
210/// Resolve barrel re-exports starting from `barrel_path` for the given `symbols`.
211/// Follows up to MAX_BARREL_DEPTH hops, prevents cycles via `visited` set.
212pub 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                // Namespace re-export (`export * as Ns from '...'`) changes the symbol
281                // namespace, so the caller's symbol names no longer match the nested
282                // exports. Treat as opaque wildcard by passing empty symbols.
283                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                // Non-barrel path: namespace_wildcard does not change symbols filtering here.
300                // The caller's symbols are used as-is for file_exports_any_symbol check.
301                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
317/// Helper: given a resolved file path, follow barrel re-exports if needed and
318/// collect matching production-file indices.
319pub 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// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[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    /// Configurable MockExtractor for CORE-CIM tests.
396    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            // Returns empty to avoid real fs access; barrel resolution tested separately
437            vec![]
438        }
439        fn source_extensions(&self) -> &[&str] {
440            &["ts", "tsx"]
441        }
442        fn index_file_names(&self) -> &[&str] {
443            // Return a static slice matching our barrel file names
444            &["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    // TC-01: map_test_files で Layer 1 stem matching が動作
460    #[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    // TC-01b: map_test_files でマッチしない場合は空
473    #[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    // TC-03: is_barrel_file が index_file_names で判定
484    #[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")); // not in mock's index_file_names
491    }
492
493    // TC-06: Send + Sync bound
494    #[test]
495    fn tc06_trait_is_send_sync() {
496        fn assert_send_sync<T: Send + Sync>() {}
497        assert_send_sync::<MockExtractor>();
498        // Box<dyn ObserveExtractor> should also work
499        let _: Box<dyn ObserveExtractor + Send + Sync> = Box::new(MockExtractor);
500    }
501
502    // CORE-CIM-01: barrel file 経由の production match
503    //
504    // Given: is_barrel_file returns true (path ends in index.ts), but resolve_barrel_exports
505    //        returns no files (in-memory extractor avoids real fs access). The barrel path itself
506    //        is also present in canonical_to_idx as a fallback production entry.
507    //        When barrel resolves to zero files, no index should be added.
508    //        Separate assertion: when is_barrel_file=true the non-barrel branch is NOT taken.
509    #[test]
510    fn core_cim_01_barrel_file_skips_direct_match_branch() {
511        // Given
512        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        // canonical_to_idx contains the barrel path itself
518        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        // When: barrel file — resolve_barrel_exports returns empty (no real fs),
523        //       so no production files are resolved. indices must stay empty.
524        collect_import_matches(
525            &ext,
526            barrel_path,
527            &symbols,
528            &canonical_to_idx,
529            &mut indices,
530            canonical_root,
531        );
532
533        // Then: barrel branch was taken (no direct-match insert), indices remains empty
534        assert!(
535            indices.is_empty(),
536            "barrel path itself must not be added via direct-match branch"
537        );
538    }
539
540    // CORE-CIM-02: 非 barrel file の直接 match
541    //
542    // Given: is_barrel_file returns false, is_non_sut_helper returns false,
543    //        production file exists in canonical_to_idx at index 0
544    // When: collect_import_matches is called with the production file path
545    // Then: index 0 is inserted into indices
546    #[test]
547    fn core_cim_02_non_barrel_direct_match() {
548        // Given
549        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        // When
559        collect_import_matches(
560            &ext,
561            prod_path,
562            &symbols,
563            &canonical_to_idx,
564            &mut indices,
565            canonical_root,
566        );
567
568        // Then
569        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    // CORE-CIM-03: helper file はスキップ
577    //
578    // Given: is_non_sut_helper returns true for the resolved path
579    // When: collect_import_matches is called
580    // Then: indices stays empty
581    #[test]
582    fn core_cim_03_helper_file_skipped() {
583        // Given
584        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        // When
594        collect_import_matches(
595            &ext,
596            helper_path,
597            &symbols,
598            &canonical_to_idx,
599            &mut indices,
600            canonical_root,
601        );
602
603        // Then
604        assert!(
605            indices.is_empty(),
606            "helper files must be skipped and not added to indices"
607        );
608    }
609
610    // CORE-CIM-04: canonical_to_idx に存在しない file はスキップ
611    //
612    // Given: canonical_to_idx is empty
613    // When: collect_import_matches is called with any non-barrel, non-helper path
614    // Then: indices stays empty
615    #[test]
616    fn core_cim_04_unknown_file_skipped() {
617        // Given
618        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(); // empty
624        let mut indices: HashSet<usize> = HashSet::new();
625
626        // When
627        collect_import_matches(
628            &ext,
629            unknown_path,
630            &symbols,
631            &canonical_to_idx,
632            &mut indices,
633            canonical_root,
634        );
635
636        // Then
637        assert!(
638            indices.is_empty(),
639            "file not in canonical_to_idx must be skipped"
640        );
641    }
642}