Skip to main content

exspec_lang_php/
observe.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use streaming_iterator::StreamingIterator;
6use tree_sitter::{Query, QueryCursor};
7
8use exspec_core::observe::{
9    BarrelReExport, FileMapping, ImportMapping, MappingStrategy, ObserveExtractor,
10    ProductionFunction,
11};
12
13use super::PhpExtractor;
14
15const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
16static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
17
18const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
19static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
20
21fn php_language() -> tree_sitter::Language {
22    tree_sitter_php::LANGUAGE_PHP.into()
23}
24
25fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
26    lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
27}
28
29// ---------------------------------------------------------------------------
30// Stem helpers
31// ---------------------------------------------------------------------------
32
33/// Extract stem from a PHP test file path.
34/// `tests/UserTest.php` -> `Some("User")`   (Test suffix, PHPUnit)
35/// `tests/user_test.php` -> `Some("user")`  (_test suffix, Pest)
36/// `tests/Unit/OrderServiceTest.php` -> `Some("OrderService")`
37/// `src/User.php` -> `None`
38pub fn test_stem(path: &str) -> Option<&str> {
39    let file_name = Path::new(path).file_name()?.to_str()?;
40    // Must end with .php
41    let stem = file_name.strip_suffix(".php")?;
42
43    // *Test.php (PHPUnit convention)
44    if let Some(rest) = stem.strip_suffix("Test") {
45        if !rest.is_empty() {
46            return Some(rest);
47        }
48    }
49
50    // *_test.php (Pest convention)
51    if let Some(rest) = stem.strip_suffix("_test") {
52        if !rest.is_empty() {
53            return Some(rest);
54        }
55    }
56
57    None
58}
59
60/// Extract stem from a PHP production file path.
61/// `src/User.php` -> `Some("User")`
62/// `src/Models/User.php` -> `Some("User")`
63/// `tests/UserTest.php` -> `None`
64pub fn production_stem(path: &str) -> Option<&str> {
65    // Test files are not production files
66    if test_stem(path).is_some() {
67        return None;
68    }
69
70    let file_name = Path::new(path).file_name()?.to_str()?;
71    let stem = file_name.strip_suffix(".php")?;
72
73    if stem.is_empty() {
74        return None;
75    }
76
77    Some(stem)
78}
79
80/// Check if a file is a non-SUT helper (not subject under test).
81pub fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
82    // If the file is already known to be a production file, it's not a helper.
83    if is_known_production {
84        return false;
85    }
86
87    let normalized = file_path.replace('\\', "/");
88    let file_name = Path::new(&normalized)
89        .file_name()
90        .and_then(|f| f.to_str())
91        .unwrap_or("");
92
93    // TestCase.php (base test class)
94    if file_name == "TestCase.php" {
95        return true;
96    }
97
98    // *Factory.php in tests/ (Laravel factory)
99    if file_name.ends_with("Factory.php") {
100        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
101        if in_tests {
102            return true;
103        }
104    }
105
106    // Abstract*.php in tests/
107    if file_name.starts_with("Abstract") && file_name.ends_with(".php") {
108        let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
109        if in_tests {
110            return true;
111        }
112    }
113
114    // Trait*.php or *Trait.php in tests/ (test traits)
115    let in_tests = normalized.starts_with("tests/") || normalized.contains("/tests/");
116    if in_tests
117        && file_name.ends_with(".php")
118        && (file_name.starts_with("Trait") || file_name.ends_with("Trait.php"))
119    {
120        return true;
121    }
122
123    // Files in tests/Traits/ directory
124    if normalized.contains("/tests/Traits/") || normalized.starts_with("tests/Traits/") {
125        return true;
126    }
127
128    // Fixtures and Stubs directories under tests/ are test infrastructure, not SUT
129    let lower = normalized.to_lowercase();
130    if (lower.contains("/tests/fixtures/") || lower.starts_with("tests/fixtures/"))
131        || (lower.contains("/tests/stubs/") || lower.starts_with("tests/stubs/"))
132    {
133        return true;
134    }
135
136    // Kernel.php
137    if file_name == "Kernel.php" {
138        return true;
139    }
140
141    // bootstrap.php or bootstrap/*.php
142    if file_name == "bootstrap.php" {
143        return true;
144    }
145    if normalized.starts_with("bootstrap/") || normalized.contains("/bootstrap/") {
146        return true;
147    }
148
149    false
150}
151
152// ---------------------------------------------------------------------------
153// PSR-4 prefix resolution
154// ---------------------------------------------------------------------------
155
156/// Load PSR-4 namespace prefix -> directory mappings from composer.json.
157/// Returns a map of namespace prefix (trailing `\` stripped) -> directory (trailing `/` stripped).
158/// Returns an empty map if composer.json is absent or unparseable.
159pub fn load_psr4_prefixes(scan_root: &Path) -> HashMap<String, String> {
160    let composer_path = scan_root.join("composer.json");
161    let content = match std::fs::read_to_string(&composer_path) {
162        Ok(s) => s,
163        Err(_) => return HashMap::new(),
164    };
165    let value: serde_json::Value = match serde_json::from_str(&content) {
166        Ok(v) => v,
167        Err(_) => return HashMap::new(),
168    };
169
170    let mut result = HashMap::new();
171
172    // Parse both autoload and autoload-dev psr-4 sections
173    for section in &["autoload", "autoload-dev"] {
174        if let Some(psr4) = value
175            .get(section)
176            .and_then(|a| a.get("psr-4"))
177            .and_then(|p| p.as_object())
178        {
179            for (ns, dir) in psr4 {
180                // Strip trailing backslash from namespace prefix
181                let ns_key = ns.trim_end_matches('\\').to_string();
182                // Strip trailing slash from directory
183                let dir_val = dir.as_str().unwrap_or("").trim_end_matches('/').to_string();
184                if !ns_key.is_empty() {
185                    result.insert(ns_key, dir_val);
186                }
187            }
188        }
189    }
190
191    result
192}
193
194// ---------------------------------------------------------------------------
195// External package detection
196// ---------------------------------------------------------------------------
197
198/// Known external PHP package namespace prefixes to skip during import resolution.
199const EXTERNAL_NAMESPACES: &[&str] = &[
200    "PHPUnit",
201    "Illuminate",
202    "Symfony",
203    "Doctrine",
204    "Mockery",
205    "Carbon",
206    "Pest",
207    "Laravel",
208    "Monolog",
209    "Psr",
210    "GuzzleHttp",
211    "League",
212    "Ramsey",
213    "Spatie",
214    "Nette",
215    "Webmozart",
216    "PhpParser",
217    "SebastianBergmann",
218];
219
220fn is_external_namespace(namespace: &str, scan_root: Option<&Path>) -> bool {
221    let first_segment = namespace.split('/').next().unwrap_or("");
222    let is_known_external = EXTERNAL_NAMESPACES
223        .iter()
224        .any(|&ext| first_segment.eq_ignore_ascii_case(ext));
225
226    if !is_known_external {
227        return false;
228    }
229
230    // If scan_root is provided, check if the namespace source exists locally.
231    // If it does, this is a framework self-test scenario — treat as internal.
232    if let Some(root) = scan_root {
233        for prefix in &["src", "app", "lib", ""] {
234            let candidate = if prefix.is_empty() {
235                root.join(first_segment)
236            } else {
237                root.join(prefix).join(first_segment)
238            };
239            if candidate.is_dir() {
240                return false;
241            }
242        }
243    }
244
245    true
246}
247
248// ---------------------------------------------------------------------------
249// ObserveExtractor impl
250// ---------------------------------------------------------------------------
251
252impl ObserveExtractor for PhpExtractor {
253    fn extract_production_functions(
254        &self,
255        source: &str,
256        file_path: &str,
257    ) -> Vec<ProductionFunction> {
258        let mut parser = Self::parser();
259        let tree = match parser.parse(source, None) {
260            Some(t) => t,
261            None => return Vec::new(),
262        };
263        let source_bytes = source.as_bytes();
264        let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
265
266        let name_idx = query.capture_index_for_name("name");
267        let class_name_idx = query.capture_index_for_name("class_name");
268        let method_name_idx = query.capture_index_for_name("method_name");
269        let function_idx = query.capture_index_for_name("function");
270        let method_idx = query.capture_index_for_name("method");
271
272        let mut cursor = QueryCursor::new();
273        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
274        let mut result = Vec::new();
275
276        while let Some(m) = matches.next() {
277            let mut fn_name: Option<String> = None;
278            let mut class_name: Option<String> = None;
279            let mut line: usize = 1;
280            let mut is_exported = true; // default: top-level functions are exported
281            let mut method_node: Option<tree_sitter::Node> = None;
282
283            for cap in m.captures {
284                let text = cap.node.utf8_text(source_bytes).unwrap_or("").to_string();
285                let node_line = cap.node.start_position().row + 1;
286
287                if name_idx == Some(cap.index) {
288                    fn_name = Some(text);
289                    line = node_line;
290                } else if class_name_idx == Some(cap.index) {
291                    class_name = Some(text);
292                } else if method_name_idx == Some(cap.index) {
293                    fn_name = Some(text);
294                    line = node_line;
295                }
296
297                // Capture method node for visibility check
298                if method_idx == Some(cap.index) {
299                    method_node = Some(cap.node);
300                }
301
302                // Top-level function: always exported
303                if function_idx == Some(cap.index) {
304                    is_exported = true;
305                }
306            }
307
308            // Determine visibility from method node
309            if let Some(method) = method_node {
310                is_exported = has_public_visibility(method, source_bytes);
311            }
312
313            if let Some(name) = fn_name {
314                result.push(ProductionFunction {
315                    name,
316                    file: file_path.to_string(),
317                    line,
318                    class_name,
319                    is_exported,
320                });
321            }
322        }
323
324        result
325    }
326
327    fn extract_imports(&self, _source: &str, _file_path: &str) -> Vec<ImportMapping> {
328        // PHP has no relative imports; Layer 2 uses PSR-4 namespace resolution
329        Vec::new()
330    }
331
332    fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
333        let mut parser = Self::parser();
334        let tree = match parser.parse(source, None) {
335            Some(t) => t,
336            None => return Vec::new(),
337        };
338        let source_bytes = source.as_bytes();
339        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
340
341        let namespace_path_idx = query.capture_index_for_name("namespace_path");
342
343        let mut cursor = QueryCursor::new();
344        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
345
346        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
347
348        while let Some(m) = matches.next() {
349            for cap in m.captures {
350                if namespace_path_idx != Some(cap.index) {
351                    continue;
352                }
353                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
354                // Convert `App\Models\User` -> `App/Models/User`
355                let fs_path = raw.replace('\\', "/");
356
357                // Skip external packages (no scan_root — trait method, conservative filter)
358                if is_external_namespace(&fs_path, None) {
359                    continue;
360                }
361
362                // Split into module path and symbol
363                // `App/Models/User` -> module=`App/Models`, symbol=`User`
364                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
365                if parts.len() < 2 {
366                    // Single segment (no slash): use as both module and symbol
367                    // e.g., `use User;` -> module="", symbol="User"
368                    // Skip these edge cases
369                    continue;
370                }
371
372                // Find the last '/' to split module from symbol
373                if let Some(last_slash) = fs_path.rfind('/') {
374                    let module_path = &fs_path[..last_slash];
375                    let symbol = &fs_path[last_slash + 1..];
376                    if !module_path.is_empty() && !symbol.is_empty() {
377                        result_map
378                            .entry(module_path.to_string())
379                            .or_default()
380                            .push(symbol.to_string());
381                    }
382                }
383            }
384        }
385
386        result_map.into_iter().collect()
387    }
388
389    fn extract_barrel_re_exports(&self, _source: &str, _file_path: &str) -> Vec<BarrelReExport> {
390        // PHP has no barrel export pattern
391        Vec::new()
392    }
393
394    fn source_extensions(&self) -> &[&str] {
395        &["php"]
396    }
397
398    fn index_file_names(&self) -> &[&str] {
399        // PHP has no index files equivalent
400        &[]
401    }
402
403    fn production_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
404        production_stem(path)
405    }
406
407    fn test_stem<'a>(&self, path: &'a str) -> Option<&'a str> {
408        test_stem(path)
409    }
410
411    fn is_non_sut_helper(&self, file_path: &str, is_known_production: bool) -> bool {
412        is_non_sut_helper(file_path, is_known_production)
413    }
414}
415
416// ---------------------------------------------------------------------------
417// Concrete methods (not in trait)
418// ---------------------------------------------------------------------------
419
420impl PhpExtractor {
421    /// Extract all import specifiers without external namespace filtering.
422    /// Returns (module_path, [symbols]) pairs for all `use` statements.
423    fn extract_raw_import_specifiers(source: &str) -> Vec<(String, Vec<String>)> {
424        let mut parser = Self::parser();
425        let tree = match parser.parse(source, None) {
426            Some(t) => t,
427            None => return Vec::new(),
428        };
429        let source_bytes = source.as_bytes();
430        let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
431
432        let namespace_path_idx = query.capture_index_for_name("namespace_path");
433
434        let mut cursor = QueryCursor::new();
435        let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
436
437        let mut result_map: HashMap<String, Vec<String>> = HashMap::new();
438
439        while let Some(m) = matches.next() {
440            for cap in m.captures {
441                if namespace_path_idx != Some(cap.index) {
442                    continue;
443                }
444                let raw = cap.node.utf8_text(source_bytes).unwrap_or("");
445                let fs_path = raw.replace('\\', "/");
446
447                let parts: Vec<&str> = fs_path.splitn(2, '/').collect();
448                if parts.len() < 2 {
449                    continue;
450                }
451
452                if let Some(last_slash) = fs_path.rfind('/') {
453                    let module_path = &fs_path[..last_slash];
454                    let symbol = &fs_path[last_slash + 1..];
455                    if !module_path.is_empty() && !symbol.is_empty() {
456                        result_map
457                            .entry(module_path.to_string())
458                            .or_default()
459                            .push(symbol.to_string());
460                    }
461                }
462            }
463        }
464
465        result_map.into_iter().collect()
466    }
467
468    /// Layer 1 + Layer 2 (PSR-4): Map test files to production files.
469    pub fn map_test_files_with_imports(
470        &self,
471        production_files: &[String],
472        test_sources: &HashMap<String, String>,
473        scan_root: &Path,
474        l1_exclusive: bool,
475    ) -> Vec<FileMapping> {
476        let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
477
478        // Layer 1: filename convention (stem matching)
479        let mut mappings =
480            exspec_core::observe::map_test_files(self, production_files, &test_file_list);
481
482        // Build canonical path -> production index lookup
483        let canonical_root = match scan_root.canonicalize() {
484            Ok(r) => r,
485            Err(_) => return mappings,
486        };
487        let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
488        for (idx, prod) in production_files.iter().enumerate() {
489            if let Ok(canonical) = Path::new(prod).canonicalize() {
490                canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
491            }
492        }
493
494        // Record Layer 1 matches per production file index
495        let layer1_tests_per_prod: Vec<std::collections::HashSet<String>> = mappings
496            .iter()
497            .map(|m| m.test_files.iter().cloned().collect())
498            .collect();
499
500        // Collect set of test files matched by L1 for l1_exclusive mode
501        let layer1_matched: std::collections::HashSet<String> = layer1_tests_per_prod
502            .iter()
503            .flat_map(|s| s.iter().cloned())
504            .collect();
505
506        // Load PSR-4 prefix mappings from composer.json (e.g., "MyApp" -> "custom_src")
507        let psr4_prefixes = load_psr4_prefixes(scan_root);
508
509        // Layer 2: PSR-4 convention import resolution
510        // Use raw imports (unfiltered) and apply scan_root-aware external filtering
511        for (test_file, source) in test_sources {
512            if l1_exclusive && layer1_matched.contains(test_file) {
513                continue;
514            }
515            let raw_specifiers = Self::extract_raw_import_specifiers(source);
516            let specifiers: Vec<(String, Vec<String>)> = raw_specifiers
517                .into_iter()
518                .filter(|(module_path, _)| !is_external_namespace(module_path, Some(scan_root)))
519                .collect();
520            let mut matched_indices = std::collections::HashSet::<usize>::new();
521
522            for (module_path, _symbols) in &specifiers {
523                // PSR-4 resolution:
524                // `App/Models/User` -> try `src/Models/User.php`, `app/Models/User.php`, etc.
525                //
526                // Strategy: strip the first segment (PSR-4 prefix like "App")
527                // and search for the remaining path under common directories.
528                let parts: Vec<&str> = module_path.splitn(2, '/').collect();
529                let first_segment = parts[0];
530                let path_without_prefix = if parts.len() == 2 {
531                    parts[1]
532                } else {
533                    module_path.as_str()
534                };
535
536                // Check if first segment matches a PSR-4 prefix from composer.json
537                // e.g., "MyApp" -> "custom_src" means resolve under custom_src/
538                let psr4_dir = psr4_prefixes.get(first_segment);
539
540                // Derive the PHP file name from the last segment of module_path
541                // e.g., `App/Models` -> last segment is `Models` -> file is `Models.php`
542                // But module_path is actually the directory, not the file.
543                // The symbol is in the symbols list, but we need to reconstruct the file path.
544                // Actually, at this point module_path = `App/Models` and symbol could be `User`,
545                // so the full file is `Models/User.php` (without prefix).
546
547                // We need to get the symbols too
548                for symbol in _symbols {
549                    let file_name = format!("{symbol}.php");
550
551                    // If composer.json defines a PSR-4 mapping for this namespace prefix,
552                    // try the mapped directory first.
553                    if let Some(psr4_base) = psr4_dir {
554                        let candidate = canonical_root
555                            .join(psr4_base)
556                            .join(path_without_prefix)
557                            .join(&file_name);
558                        if let Ok(canonical_candidate) = candidate.canonicalize() {
559                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
560                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
561                                matched_indices.insert(idx);
562                            }
563                        }
564                    }
565
566                    // Try: <scan_root>/<common_prefix>/<path_without_prefix>/<symbol>.php
567                    let common_prefixes = ["src", "app", "lib", ""];
568                    for prefix in &common_prefixes {
569                        let candidate = if prefix.is_empty() {
570                            canonical_root.join(path_without_prefix).join(&file_name)
571                        } else {
572                            canonical_root
573                                .join(prefix)
574                                .join(path_without_prefix)
575                                .join(&file_name)
576                        };
577
578                        if let Ok(canonical_candidate) = candidate.canonicalize() {
579                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
580                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
581                                matched_indices.insert(idx);
582                            }
583                        }
584                    }
585
586                    // Also try with the first segment kept (in case directory matches namespace 1:1)
587                    // e.g., framework self-tests: `Illuminate/Http` -> `src/Illuminate/Http/Request.php`
588                    for prefix in &common_prefixes {
589                        let candidate = if prefix.is_empty() {
590                            canonical_root.join(module_path).join(&file_name)
591                        } else {
592                            canonical_root
593                                .join(prefix)
594                                .join(module_path)
595                                .join(&file_name)
596                        };
597                        if let Ok(canonical_candidate) = candidate.canonicalize() {
598                            let candidate_str = canonical_candidate.to_string_lossy().into_owned();
599                            if let Some(&idx) = canonical_to_idx.get(&candidate_str) {
600                                matched_indices.insert(idx);
601                            }
602                        }
603                    }
604                }
605            }
606
607            for idx in matched_indices {
608                if !mappings[idx].test_files.contains(test_file) {
609                    mappings[idx].test_files.push(test_file.clone());
610                }
611            }
612        }
613
614        // Update strategy: if a production file had no Layer 1 matches but has Layer 2 matches,
615        // set strategy to ImportTracing
616        for (i, mapping) in mappings.iter_mut().enumerate() {
617            let has_layer1 = !layer1_tests_per_prod[i].is_empty();
618            if !has_layer1 && !mapping.test_files.is_empty() {
619                mapping.strategy = MappingStrategy::ImportTracing;
620            }
621        }
622
623        mappings
624    }
625}
626
627// ---------------------------------------------------------------------------
628// Visibility helper
629// ---------------------------------------------------------------------------
630
631/// Check if a PHP method_declaration node has `public` visibility.
632/// Returns true for public, false for private/protected.
633/// If no visibility_modifier child is found, defaults to true (public by convention in PHP).
634fn has_public_visibility(node: tree_sitter::Node, source_bytes: &[u8]) -> bool {
635    for i in 0..node.child_count() {
636        if let Some(child) = node.child(i) {
637            if child.kind() == "visibility_modifier" {
638                let text = child.utf8_text(source_bytes).unwrap_or("");
639                return text == "public";
640            }
641        }
642    }
643    // No visibility modifier -> treat as public by default
644    true
645}
646
647// ---------------------------------------------------------------------------
648// Tests
649// ---------------------------------------------------------------------------
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use std::collections::HashMap;
655
656    // -----------------------------------------------------------------------
657    // PHP-STEM-01: tests/UserTest.php -> test_stem = Some("User")
658    // -----------------------------------------------------------------------
659    #[test]
660    fn php_stem_01_test_suffix() {
661        // Given: a file named UserTest.php in tests/
662        // When: test_stem is called
663        // Then: returns Some("User")
664        assert_eq!(test_stem("tests/UserTest.php"), Some("User"));
665    }
666
667    // -----------------------------------------------------------------------
668    // PHP-STEM-02: tests/user_test.php -> test_stem = Some("user")
669    // -----------------------------------------------------------------------
670    #[test]
671    fn php_stem_02_pest_suffix() {
672        // Given: a Pest-style file user_test.php
673        // When: test_stem is called
674        // Then: returns Some("user")
675        assert_eq!(test_stem("tests/user_test.php"), Some("user"));
676    }
677
678    // -----------------------------------------------------------------------
679    // PHP-STEM-03: tests/Unit/OrderServiceTest.php -> test_stem = Some("OrderService")
680    // -----------------------------------------------------------------------
681    #[test]
682    fn php_stem_03_nested() {
683        // Given: a nested test file OrderServiceTest.php
684        // When: test_stem is called
685        // Then: returns Some("OrderService")
686        assert_eq!(
687            test_stem("tests/Unit/OrderServiceTest.php"),
688            Some("OrderService")
689        );
690    }
691
692    // -----------------------------------------------------------------------
693    // PHP-STEM-04: src/User.php -> test_stem = None
694    // -----------------------------------------------------------------------
695    #[test]
696    fn php_stem_04_non_test() {
697        // Given: a production file src/User.php
698        // When: test_stem is called
699        // Then: returns None
700        assert_eq!(test_stem("src/User.php"), None);
701    }
702
703    // -----------------------------------------------------------------------
704    // PHP-STEM-05: src/User.php -> production_stem = Some("User")
705    // -----------------------------------------------------------------------
706    #[test]
707    fn php_stem_05_prod_stem() {
708        // Given: a production file src/User.php
709        // When: production_stem is called
710        // Then: returns Some("User")
711        assert_eq!(production_stem("src/User.php"), Some("User"));
712    }
713
714    // -----------------------------------------------------------------------
715    // PHP-STEM-06: src/Models/User.php -> production_stem = Some("User")
716    // -----------------------------------------------------------------------
717    #[test]
718    fn php_stem_06_prod_nested() {
719        // Given: a nested production file src/Models/User.php
720        // When: production_stem is called
721        // Then: returns Some("User")
722        assert_eq!(production_stem("src/Models/User.php"), Some("User"));
723    }
724
725    // -----------------------------------------------------------------------
726    // PHP-STEM-07: tests/UserTest.php -> production_stem = None
727    // -----------------------------------------------------------------------
728    #[test]
729    fn php_stem_07_test_not_prod() {
730        // Given: a test file tests/UserTest.php
731        // When: production_stem is called
732        // Then: returns None (test files are not production files)
733        assert_eq!(production_stem("tests/UserTest.php"), None);
734    }
735
736    // -----------------------------------------------------------------------
737    // PHP-HELPER-01: tests/TestCase.php -> is_non_sut_helper = true
738    // -----------------------------------------------------------------------
739    #[test]
740    fn php_helper_01_test_case() {
741        // Given: the base test class TestCase.php
742        // When: is_non_sut_helper is called
743        // Then: returns true
744        assert!(is_non_sut_helper("tests/TestCase.php", false));
745    }
746
747    // -----------------------------------------------------------------------
748    // PHP-HELPER-02: tests/UserFactory.php -> is_non_sut_helper = true
749    // -----------------------------------------------------------------------
750    #[test]
751    fn php_helper_02_factory() {
752        // Given: a Laravel factory file in tests/
753        // When: is_non_sut_helper is called
754        // Then: returns true
755        assert!(is_non_sut_helper("tests/UserFactory.php", false));
756    }
757
758    // -----------------------------------------------------------------------
759    // PHP-HELPER-03: src/User.php -> is_non_sut_helper = false
760    // -----------------------------------------------------------------------
761    #[test]
762    fn php_helper_03_production() {
763        // Given: a regular production file
764        // When: is_non_sut_helper is called
765        // Then: returns false
766        assert!(!is_non_sut_helper("src/User.php", false));
767    }
768
769    // -----------------------------------------------------------------------
770    // PHP-HELPER-04: tests/Traits/CreatesUsers.php -> is_non_sut_helper = true
771    // -----------------------------------------------------------------------
772    #[test]
773    fn php_helper_04_test_trait() {
774        // Given: a test trait in tests/Traits/
775        // When: is_non_sut_helper is called
776        // Then: returns true
777        assert!(is_non_sut_helper("tests/Traits/CreatesUsers.php", false));
778    }
779
780    // -----------------------------------------------------------------------
781    // PHP-HELPER-05: bootstrap/app.php -> is_non_sut_helper = true
782    // -----------------------------------------------------------------------
783    #[test]
784    fn php_helper_05_bootstrap() {
785        // Given: a bootstrap file
786        // When: is_non_sut_helper is called
787        // Then: returns true
788        assert!(is_non_sut_helper("bootstrap/app.php", false));
789    }
790
791    // -----------------------------------------------------------------------
792    // PHP-FUNC-01: public function createUser() -> name="createUser", is_exported=true
793    // -----------------------------------------------------------------------
794    #[test]
795    fn php_func_01_public_method() {
796        // Given: a class with a public method
797        // When: extract_production_functions is called
798        // Then: name="createUser", is_exported=true
799        let ext = PhpExtractor::new();
800        let source = "<?php\nclass User {\n    public function createUser() {}\n}";
801        let fns = ext.extract_production_functions(source, "src/User.php");
802        let f = fns.iter().find(|f| f.name == "createUser").unwrap();
803        assert!(f.is_exported);
804    }
805
806    // -----------------------------------------------------------------------
807    // PHP-FUNC-02: private function helper() -> name="helper", is_exported=false
808    // -----------------------------------------------------------------------
809    #[test]
810    fn php_func_02_private_method() {
811        // Given: a class with a private method
812        // When: extract_production_functions is called
813        // Then: name="helper", is_exported=false
814        let ext = PhpExtractor::new();
815        let source = "<?php\nclass User {\n    private function helper() {}\n}";
816        let fns = ext.extract_production_functions(source, "src/User.php");
817        let f = fns.iter().find(|f| f.name == "helper").unwrap();
818        assert!(!f.is_exported);
819    }
820
821    // -----------------------------------------------------------------------
822    // PHP-FUNC-03: class User { public function save() } -> class_name=Some("User")
823    // -----------------------------------------------------------------------
824    #[test]
825    fn php_func_03_class_method() {
826        // Given: a class User with a public method save()
827        // When: extract_production_functions is called
828        // Then: name="save", class_name=Some("User")
829        let ext = PhpExtractor::new();
830        let source = "<?php\nclass User {\n    public function save() {}\n}";
831        let fns = ext.extract_production_functions(source, "src/User.php");
832        let f = fns.iter().find(|f| f.name == "save").unwrap();
833        assert_eq!(f.class_name, Some("User".to_string()));
834    }
835
836    // -----------------------------------------------------------------------
837    // PHP-FUNC-04: function global_helper() (top-level) -> exported
838    // -----------------------------------------------------------------------
839    #[test]
840    fn php_func_04_top_level_function() {
841        // Given: a top-level function global_helper()
842        // When: extract_production_functions is called
843        // Then: name="global_helper", is_exported=true
844        let ext = PhpExtractor::new();
845        let source = "<?php\nfunction global_helper() {\n    return 42;\n}";
846        let fns = ext.extract_production_functions(source, "src/helpers.php");
847        let f = fns.iter().find(|f| f.name == "global_helper").unwrap();
848        assert!(f.is_exported);
849        assert_eq!(f.class_name, None);
850    }
851
852    // -----------------------------------------------------------------------
853    // PHP-IMP-01: use App\Models\User; -> ("App/Models", ["User"])
854    // -----------------------------------------------------------------------
855    #[test]
856    fn php_imp_01_app_models() {
857        // Given: a use statement for App\Models\User
858        // When: extract_all_import_specifiers is called
859        // Then: returns ("App/Models", ["User"])
860        let ext = PhpExtractor::new();
861        let source = "<?php\nuse App\\Models\\User;\n";
862        let imports = ext.extract_all_import_specifiers(source);
863        assert!(
864            imports
865                .iter()
866                .any(|(m, s)| m == "App/Models" && s.contains(&"User".to_string())),
867            "expected App/Models -> [User], got: {imports:?}"
868        );
869    }
870
871    // -----------------------------------------------------------------------
872    // PHP-IMP-02: use App\Services\UserService; -> ("App/Services", ["UserService"])
873    // -----------------------------------------------------------------------
874    #[test]
875    fn php_imp_02_app_services() {
876        // Given: a use statement for App\Services\UserService
877        // When: extract_all_import_specifiers is called
878        // Then: returns ("App/Services", ["UserService"])
879        let ext = PhpExtractor::new();
880        let source = "<?php\nuse App\\Services\\UserService;\n";
881        let imports = ext.extract_all_import_specifiers(source);
882        assert!(
883            imports
884                .iter()
885                .any(|(m, s)| m == "App/Services" && s.contains(&"UserService".to_string())),
886            "expected App/Services -> [UserService], got: {imports:?}"
887        );
888    }
889
890    // -----------------------------------------------------------------------
891    // PHP-IMP-03: use PHPUnit\Framework\TestCase; -> external package -> skipped
892    // -----------------------------------------------------------------------
893    #[test]
894    fn php_imp_03_external_phpunit() {
895        // Given: a use statement for external PHPUnit package
896        // When: extract_all_import_specifiers is called
897        // Then: returns empty (external packages are filtered)
898        let ext = PhpExtractor::new();
899        let source = "<?php\nuse PHPUnit\\Framework\\TestCase;\n";
900        let imports = ext.extract_all_import_specifiers(source);
901        assert!(
902            imports.is_empty(),
903            "external PHPUnit should be filtered, got: {imports:?}"
904        );
905    }
906
907    // -----------------------------------------------------------------------
908    // PHP-IMP-04: use Illuminate\Http\Request; -> external package -> skipped
909    // -----------------------------------------------------------------------
910    #[test]
911    fn php_imp_04_external_illuminate() {
912        // Given: a use statement for external Illuminate (Laravel) package
913        // When: extract_all_import_specifiers is called
914        // Then: returns empty (external packages are filtered)
915        let ext = PhpExtractor::new();
916        let source = "<?php\nuse Illuminate\\Http\\Request;\n";
917        let imports = ext.extract_all_import_specifiers(source);
918        assert!(
919            imports.is_empty(),
920            "external Illuminate should be filtered, got: {imports:?}"
921        );
922    }
923
924    // -----------------------------------------------------------------------
925    // PHP-E2E-01: User.php + UserTest.php in the same directory -> Layer 1 stem match
926    // -----------------------------------------------------------------------
927    #[test]
928    fn php_e2e_01_stem_match() {
929        // Given: production file User.php and test file UserTest.php in the same directory
930        // (Layer 1 stem matching works when files share the same parent directory)
931        // When: map_test_files_with_imports is called
932        // Then: UserTest.php is matched to User.php via Layer 1 stem matching
933        let dir = tempfile::tempdir().expect("failed to create tempdir");
934
935        let prod_file = dir.path().join("User.php");
936        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
937
938        let test_file = dir.path().join("UserTest.php");
939        std::fs::write(&test_file, "<?php\nclass UserTest extends TestCase {}").unwrap();
940
941        let ext = PhpExtractor::new();
942        let production_files = vec![prod_file.to_string_lossy().into_owned()];
943        let mut test_sources = HashMap::new();
944        test_sources.insert(
945            test_file.to_string_lossy().into_owned(),
946            "<?php\nclass UserTest extends TestCase {}".to_string(),
947        );
948
949        let mappings =
950            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
951
952        assert!(!mappings.is_empty(), "expected at least one mapping");
953        let user_mapping = mappings
954            .iter()
955            .find(|m| m.production_file.contains("User.php"))
956            .expect("expected User.php in mappings");
957        assert!(
958            !user_mapping.test_files.is_empty(),
959            "expected UserTest.php to be mapped to User.php via Layer 1 stem match"
960        );
961    }
962
963    // -----------------------------------------------------------------------
964    // PHP-E2E-02: tests/ServiceTest.php imports use App\Services\OrderService
965    //             -> Layer 2 PSR-4 import match
966    // -----------------------------------------------------------------------
967    #[test]
968    fn php_e2e_02_import_match() {
969        // Given: production file app/Services/OrderService.php
970        //        and test file tests/ServiceTest.php with `use App\Services\OrderService;`
971        // When: map_test_files_with_imports is called
972        // Then: ServiceTest.php is matched to OrderService.php via Layer 2 import tracing
973        let dir = tempfile::tempdir().expect("failed to create tempdir");
974        let services_dir = dir.path().join("app").join("Services");
975        std::fs::create_dir_all(&services_dir).unwrap();
976        let test_dir = dir.path().join("tests");
977        std::fs::create_dir_all(&test_dir).unwrap();
978
979        let prod_file = services_dir.join("OrderService.php");
980        std::fs::write(&prod_file, "<?php\nclass OrderService {}").unwrap();
981
982        let test_file = test_dir.join("ServiceTest.php");
983        let test_source =
984            "<?php\nuse App\\Services\\OrderService;\nclass ServiceTest extends TestCase {}";
985        std::fs::write(&test_file, test_source).unwrap();
986
987        let ext = PhpExtractor::new();
988        let production_files = vec![prod_file.to_string_lossy().into_owned()];
989        let mut test_sources = HashMap::new();
990        test_sources.insert(
991            test_file.to_string_lossy().into_owned(),
992            test_source.to_string(),
993        );
994
995        let mappings =
996            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
997
998        let order_mapping = mappings
999            .iter()
1000            .find(|m| m.production_file.contains("OrderService.php"))
1001            .expect("expected OrderService.php in mappings");
1002        assert!(
1003            !order_mapping.test_files.is_empty(),
1004            "expected ServiceTest.php to be mapped to OrderService.php via import tracing"
1005        );
1006    }
1007
1008    // -----------------------------------------------------------------------
1009    // PHP-E2E-03: tests/TestCase.php -> helper exclusion
1010    // -----------------------------------------------------------------------
1011    #[test]
1012    fn php_e2e_03_helper_exclusion() {
1013        // Given: a TestCase.php base class in tests/
1014        // When: map_test_files_with_imports is called
1015        // Then: TestCase.php is excluded (is_non_sut_helper = true)
1016        let dir = tempfile::tempdir().expect("failed to create tempdir");
1017        let src_dir = dir.path().join("src");
1018        std::fs::create_dir_all(&src_dir).unwrap();
1019        let test_dir = dir.path().join("tests");
1020        std::fs::create_dir_all(&test_dir).unwrap();
1021
1022        let prod_file = src_dir.join("User.php");
1023        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1024
1025        // TestCase.php should be treated as a helper, not a test file
1026        let test_case_file = test_dir.join("TestCase.php");
1027        std::fs::write(&test_case_file, "<?php\nabstract class TestCase {}").unwrap();
1028
1029        let ext = PhpExtractor::new();
1030        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1031        let mut test_sources = HashMap::new();
1032        test_sources.insert(
1033            test_case_file.to_string_lossy().into_owned(),
1034            "<?php\nabstract class TestCase {}".to_string(),
1035        );
1036
1037        let mappings =
1038            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1039
1040        // TestCase.php should not be matched to User.php
1041        let user_mapping = mappings
1042            .iter()
1043            .find(|m| m.production_file.contains("User.php"));
1044        if let Some(mapping) = user_mapping {
1045            assert!(
1046                mapping.test_files.is_empty()
1047                    || !mapping
1048                        .test_files
1049                        .iter()
1050                        .any(|t| t.contains("TestCase.php")),
1051                "TestCase.php should not be mapped as a test file for User.php"
1052            );
1053        }
1054    }
1055
1056    // -----------------------------------------------------------------------
1057    // PHP-FW-01: laravel/framework layout -> Illuminate import resolves locally
1058    // -----------------------------------------------------------------------
1059    #[test]
1060    fn php_fw_01_laravel_framework_self_test() {
1061        // Given: laravel/framework layout with src/Illuminate/Http/Request.php
1062        //        and tests/Http/RequestTest.php importing `use Illuminate\Http\Request`
1063        // When: map_test_files_with_imports is called
1064        // Then: RequestTest.php is mapped to Request.php via Layer 2
1065        let dir = tempfile::tempdir().expect("failed to create tempdir");
1066        let src_dir = dir.path().join("src").join("Illuminate").join("Http");
1067        std::fs::create_dir_all(&src_dir).unwrap();
1068        let test_dir = dir.path().join("tests").join("Http");
1069        std::fs::create_dir_all(&test_dir).unwrap();
1070
1071        let prod_file = src_dir.join("Request.php");
1072        std::fs::write(
1073            &prod_file,
1074            "<?php\nnamespace Illuminate\\Http;\nclass Request {}",
1075        )
1076        .unwrap();
1077
1078        let test_file = test_dir.join("RequestTest.php");
1079        let test_source =
1080            "<?php\nuse Illuminate\\Http\\Request;\nclass RequestTest extends TestCase {}";
1081        std::fs::write(&test_file, test_source).unwrap();
1082
1083        let ext = PhpExtractor::new();
1084        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1085        let mut test_sources = HashMap::new();
1086        test_sources.insert(
1087            test_file.to_string_lossy().into_owned(),
1088            test_source.to_string(),
1089        );
1090
1091        let mappings =
1092            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1093
1094        let request_mapping = mappings
1095            .iter()
1096            .find(|m| m.production_file.contains("Request.php"))
1097            .expect("expected Request.php in mappings");
1098        assert!(
1099            request_mapping
1100                .test_files
1101                .iter()
1102                .any(|t| t.contains("RequestTest.php")),
1103            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1104            request_mapping.test_files
1105        );
1106    }
1107
1108    // -----------------------------------------------------------------------
1109    // PHP-FW-02: normal app -> Illuminate import still filtered (no local source)
1110    // -----------------------------------------------------------------------
1111    #[test]
1112    fn php_fw_02_normal_app_illuminate_filtered() {
1113        // Given: normal app layout with app/Models/User.php
1114        //        and tests/UserTest.php importing `use Illuminate\Http\Request`
1115        //        (no local Illuminate directory)
1116        // When: map_test_files_with_imports is called
1117        // Then: Illuminate import is NOT resolved (no mapping via import)
1118        let dir = tempfile::tempdir().expect("failed to create tempdir");
1119        let app_dir = dir.path().join("app").join("Models");
1120        std::fs::create_dir_all(&app_dir).unwrap();
1121        let test_dir = dir.path().join("tests");
1122        std::fs::create_dir_all(&test_dir).unwrap();
1123
1124        let prod_file = app_dir.join("User.php");
1125        std::fs::write(&prod_file, "<?php\nclass User {}").unwrap();
1126
1127        // This test imports Illuminate but there's no local Illuminate source
1128        let test_file = test_dir.join("OrderTest.php");
1129        let test_source =
1130            "<?php\nuse Illuminate\\Http\\Request;\nclass OrderTest extends TestCase {}";
1131        std::fs::write(&test_file, test_source).unwrap();
1132
1133        let ext = PhpExtractor::new();
1134        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1135        let mut test_sources = HashMap::new();
1136        test_sources.insert(
1137            test_file.to_string_lossy().into_owned(),
1138            test_source.to_string(),
1139        );
1140
1141        let mappings =
1142            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1143
1144        // User.php should not have OrderTest.php mapped (no stem match, no import match)
1145        let user_mapping = mappings
1146            .iter()
1147            .find(|m| m.production_file.contains("User.php"))
1148            .expect("expected User.php in mappings");
1149        assert!(
1150            !user_mapping
1151                .test_files
1152                .iter()
1153                .any(|t| t.contains("OrderTest.php")),
1154            "Illuminate import should be filtered when no local source exists"
1155        );
1156    }
1157
1158    // -----------------------------------------------------------------------
1159    // PHP-FW-03: PHPUnit import still filtered via integration test (regression)
1160    // -----------------------------------------------------------------------
1161    #[test]
1162    fn php_fw_03_phpunit_still_external() {
1163        // Given: app with src/Calculator.php and tests/CalculatorTest.php
1164        //        importing only `use PHPUnit\Framework\TestCase` (no local PHPUnit source)
1165        // When: map_test_files_with_imports is called
1166        // Then: PHPUnit import does not create a false mapping
1167        let dir = tempfile::tempdir().expect("failed to create tempdir");
1168        let src_dir = dir.path().join("src");
1169        std::fs::create_dir_all(&src_dir).unwrap();
1170        let test_dir = dir.path().join("tests");
1171        std::fs::create_dir_all(&test_dir).unwrap();
1172
1173        let prod_file = src_dir.join("Calculator.php");
1174        std::fs::write(&prod_file, "<?php\nclass Calculator {}").unwrap();
1175
1176        // Test imports only PHPUnit (external) — no import-based mapping should occur
1177        let test_file = test_dir.join("OtherTest.php");
1178        let test_source =
1179            "<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OtherTest extends TestCase {}";
1180        std::fs::write(&test_file, test_source).unwrap();
1181
1182        let ext = PhpExtractor::new();
1183        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1184        let mut test_sources = HashMap::new();
1185        test_sources.insert(
1186            test_file.to_string_lossy().into_owned(),
1187            test_source.to_string(),
1188        );
1189
1190        let mappings =
1191            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1192
1193        let calc_mapping = mappings
1194            .iter()
1195            .find(|m| m.production_file.contains("Calculator.php"))
1196            .expect("expected Calculator.php in mappings");
1197        assert!(
1198            !calc_mapping
1199                .test_files
1200                .iter()
1201                .any(|t| t.contains("OtherTest.php")),
1202            "PHPUnit import should not create a mapping to Calculator.php"
1203        );
1204    }
1205
1206    // -----------------------------------------------------------------------
1207    // PHP-FW-04: symfony/symfony layout -> Symfony import resolves locally
1208    // -----------------------------------------------------------------------
1209    #[test]
1210    fn php_fw_04_symfony_self_test() {
1211        // Given: symfony layout with src/Symfony/Component/HttpFoundation/Request.php
1212        //        and tests/HttpFoundation/RequestTest.php importing
1213        //        `use Symfony\Component\HttpFoundation\Request`
1214        // When: map_test_files_with_imports is called
1215        // Then: RequestTest.php is mapped to Request.php via Layer 2
1216        let dir = tempfile::tempdir().expect("failed to create tempdir");
1217        let src_dir = dir
1218            .path()
1219            .join("src")
1220            .join("Symfony")
1221            .join("Component")
1222            .join("HttpFoundation");
1223        std::fs::create_dir_all(&src_dir).unwrap();
1224        let test_dir = dir.path().join("tests").join("HttpFoundation");
1225        std::fs::create_dir_all(&test_dir).unwrap();
1226
1227        let prod_file = src_dir.join("Request.php");
1228        std::fs::write(
1229            &prod_file,
1230            "<?php\nnamespace Symfony\\Component\\HttpFoundation;\nclass Request {}",
1231        )
1232        .unwrap();
1233
1234        let test_file = test_dir.join("RequestTest.php");
1235        let test_source = "<?php\nuse Symfony\\Component\\HttpFoundation\\Request;\nclass RequestTest extends TestCase {}";
1236        std::fs::write(&test_file, test_source).unwrap();
1237
1238        let ext = PhpExtractor::new();
1239        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1240        let mut test_sources = HashMap::new();
1241        test_sources.insert(
1242            test_file.to_string_lossy().into_owned(),
1243            test_source.to_string(),
1244        );
1245
1246        let mappings =
1247            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1248
1249        let request_mapping = mappings
1250            .iter()
1251            .find(|m| m.production_file.contains("Request.php"))
1252            .expect("expected Request.php in mappings");
1253        assert!(
1254            request_mapping
1255                .test_files
1256                .iter()
1257                .any(|t| t.contains("RequestTest.php")),
1258            "expected RequestTest.php to be mapped to Request.php via Layer 2, got: {:?}",
1259            request_mapping.test_files
1260        );
1261    }
1262
1263    // -----------------------------------------------------------------------
1264    // PHP-HELPER-06: tests/Fixtures/SomeHelper.php -> is_non_sut_helper = true
1265    // -----------------------------------------------------------------------
1266    #[test]
1267    fn php_helper_06_fixtures_dir() {
1268        // Given: a file in tests/Fixtures/
1269        // When: is_non_sut_helper is called
1270        // Then: returns true (Fixtures are test infrastructure, not SUT)
1271        assert!(is_non_sut_helper("tests/Fixtures/SomeHelper.php", false));
1272    }
1273
1274    // -----------------------------------------------------------------------
1275    // PHP-HELPER-07: tests/Fixtures/nested/Stub.php -> is_non_sut_helper = true
1276    // -----------------------------------------------------------------------
1277    #[test]
1278    fn php_helper_07_fixtures_nested() {
1279        // Given: a file in tests/Fixtures/nested/
1280        // When: is_non_sut_helper is called
1281        // Then: returns true
1282        assert!(is_non_sut_helper("tests/Fixtures/nested/Stub.php", false));
1283    }
1284
1285    // -----------------------------------------------------------------------
1286    // PHP-HELPER-08: tests/Stubs/UserStub.php -> is_non_sut_helper = true
1287    // -----------------------------------------------------------------------
1288    #[test]
1289    fn php_helper_08_stubs_dir() {
1290        // Given: a file in tests/Stubs/
1291        // When: is_non_sut_helper is called
1292        // Then: returns true (Stubs are test infrastructure, not SUT)
1293        assert!(is_non_sut_helper("tests/Stubs/UserStub.php", false));
1294    }
1295
1296    // -----------------------------------------------------------------------
1297    // PHP-HELPER-09: tests/Stubs/nested/FakeRepo.php -> is_non_sut_helper = true
1298    // -----------------------------------------------------------------------
1299    #[test]
1300    fn php_helper_09_stubs_nested() {
1301        // Given: a file in tests/Stubs/nested/
1302        // When: is_non_sut_helper is called
1303        // Then: returns true
1304        assert!(is_non_sut_helper("tests/Stubs/nested/FakeRepo.php", false));
1305    }
1306
1307    // -----------------------------------------------------------------------
1308    // PHP-HELPER-10: app/Stubs/Template.php -> is_non_sut_helper = false (guard test)
1309    // -----------------------------------------------------------------------
1310    #[test]
1311    fn php_helper_10_non_test_stubs() {
1312        // Given: a file in app/Stubs/ (not under tests/)
1313        // When: is_non_sut_helper is called
1314        // Then: returns false (only tests/ subdirs are filtered)
1315        assert!(!is_non_sut_helper("app/Stubs/Template.php", false));
1316    }
1317
1318    // -----------------------------------------------------------------------
1319    // PHP-PSR4-01: custom_src/ prefix via composer.json -> resolution success
1320    // -----------------------------------------------------------------------
1321    #[test]
1322    fn php_psr4_01_composer_json_resolution() {
1323        // Given: a project with composer.json defining PSR-4 autoload:
1324        //   {"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}
1325        //   production file: custom_src/Models/Order.php
1326        //   test file: tests/OrderTest.php with `use MyApp\Models\Order;`
1327        // When: map_test_files_with_imports is called
1328        // Then: OrderTest.php is matched to Order.php via PSR-4 resolution
1329        let dir = tempfile::tempdir().expect("failed to create tempdir");
1330        let custom_src_dir = dir.path().join("custom_src").join("Models");
1331        std::fs::create_dir_all(&custom_src_dir).unwrap();
1332        let test_dir = dir.path().join("tests");
1333        std::fs::create_dir_all(&test_dir).unwrap();
1334
1335        // Write composer.json with custom PSR-4 prefix
1336        let composer_json = r#"{"autoload": {"psr-4": {"MyApp\\": "custom_src/"}}}"#;
1337        std::fs::write(dir.path().join("composer.json"), composer_json).unwrap();
1338
1339        let prod_file = custom_src_dir.join("Order.php");
1340        std::fs::write(
1341            &prod_file,
1342            "<?php\nnamespace MyApp\\Models;\nclass Order {}",
1343        )
1344        .unwrap();
1345
1346        let test_file = test_dir.join("OrderTest.php");
1347        let test_source = "<?php\nuse MyApp\\Models\\Order;\nclass OrderTest extends TestCase {}";
1348        std::fs::write(&test_file, test_source).unwrap();
1349
1350        let ext = PhpExtractor::new();
1351        let production_files = vec![prod_file.to_string_lossy().into_owned()];
1352        let mut test_sources = HashMap::new();
1353        test_sources.insert(
1354            test_file.to_string_lossy().into_owned(),
1355            test_source.to_string(),
1356        );
1357
1358        let mappings =
1359            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1360
1361        let order_mapping = mappings
1362            .iter()
1363            .find(|m| m.production_file.contains("Order.php"))
1364            .expect("expected Order.php in mappings");
1365        assert!(
1366            order_mapping
1367                .test_files
1368                .iter()
1369                .any(|t| t.contains("OrderTest.php")),
1370            "expected OrderTest.php to be mapped to Order.php via PSR-4 composer.json resolution, got: {:?}",
1371            order_mapping.test_files
1372        );
1373    }
1374
1375    // -----------------------------------------------------------------------
1376    // PHP-CLI-01: observe --lang php . -> CLI dispatch verification
1377    // -----------------------------------------------------------------------
1378    #[test]
1379    fn php_cli_01_dispatch() {
1380        // Given: a tempdir with a PHP file
1381        // When: PhpExtractor::map_test_files_with_imports is called on an empty project
1382        // Then: returns an empty (or valid) mapping without panicking
1383        let dir = tempfile::tempdir().expect("failed to create tempdir");
1384        let ext = PhpExtractor::new();
1385        let production_files: Vec<String> = vec![];
1386        let test_sources: HashMap<String, String> = HashMap::new();
1387        let mappings =
1388            ext.map_test_files_with_imports(&production_files, &test_sources, dir.path(), false);
1389        assert!(mappings.is_empty());
1390    }
1391}