Skip to main content

pytest_language_server/fixtures/
scanner.rs

1//! Workspace and virtual environment scanning for fixture definitions.
2
3use super::imports::try_init_stdlib_from_python;
4use super::types::{FixtureDefinition, FixtureScope, TypeImportSpec};
5use super::FixtureDatabase;
6use glob::Pattern;
7use rayon::prelude::*;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use tracing::{debug, error, info, warn};
11use walkdir::WalkDir;
12
13/// A pytest11 entry point from a dist-info package.
14#[derive(Debug, Clone)]
15pub(crate) struct Pytest11EntryPoint {
16    /// The plugin name (left side of =)
17    pub(crate) name: String,
18    /// The Python module path (right side of =)
19    pub(crate) module_path: String,
20}
21
22impl FixtureDatabase {
23    /// Directories that should be skipped during workspace scanning.
24    /// These are typically large directories that don't contain test files.
25    const SKIP_DIRECTORIES: &'static [&'static str] = &[
26        // Version control
27        ".git",
28        ".hg",
29        ".svn",
30        // Virtual environments (scanned separately for plugins)
31        ".venv",
32        "venv",
33        "env",
34        ".env",
35        // Python caches and build artifacts
36        "__pycache__",
37        ".pytest_cache",
38        ".mypy_cache",
39        ".ruff_cache",
40        ".tox",
41        ".nox",
42        "build",
43        "dist",
44        ".eggs",
45        // JavaScript/Node
46        "node_modules",
47        "bower_components",
48        // Rust (for mixed projects)
49        "target",
50        // IDE and editor directories
51        ".idea",
52        ".vscode",
53        // Other common large directories
54        ".cache",
55        ".local",
56        "vendor",
57        "site-packages",
58    ];
59
60    /// Check if a directory should be skipped during scanning.
61    pub(crate) fn should_skip_directory(dir_name: &str) -> bool {
62        // Check exact matches
63        if Self::SKIP_DIRECTORIES.contains(&dir_name) {
64            return true;
65        }
66        // Also skip directories ending with .egg-info
67        if dir_name.ends_with(".egg-info") {
68            return true;
69        }
70        false
71    }
72
73    /// Scan a workspace directory for test files and conftest.py files.
74    /// Optionally accepts exclude patterns from configuration.
75    pub fn scan_workspace(&self, root_path: &Path) {
76        self.scan_workspace_with_excludes(root_path, &[]);
77    }
78
79    /// Scan a workspace directory with custom exclude patterns.
80    pub fn scan_workspace_with_excludes(&self, root_path: &Path, exclude_patterns: &[Pattern]) {
81        let root_path_buf = root_path
82            .canonicalize()
83            .unwrap_or_else(|_| root_path.to_path_buf());
84        let root_path = root_path_buf.as_path();
85
86        info!("Scanning workspace: {:?}", root_path);
87
88        *self.workspace_root.lock().unwrap() = Some(root_path.to_path_buf());
89
90        if !root_path.exists() {
91            warn!(
92                "Workspace path does not exist, skipping scan: {:?}",
93                root_path
94            );
95            return;
96        }
97
98        // Phase 1: Collect all file paths (sequential, fast)
99        let mut files_to_process: Vec<std::path::PathBuf> = Vec::new();
100        let mut skipped_dirs = 0;
101
102        // Use WalkDir with filter to skip large/irrelevant directories
103        let walker = WalkDir::new(root_path).into_iter().filter_entry(|entry| {
104            // Allow files to pass through
105            if entry.file_type().is_file() {
106                return true;
107            }
108            // For directories, check if we should skip them
109            if let Some(dir_name) = entry.file_name().to_str() {
110                !Self::should_skip_directory(dir_name)
111            } else {
112                true
113            }
114        });
115
116        for entry in walker {
117            let entry = match entry {
118                Ok(e) => e,
119                Err(err) => {
120                    // Log directory traversal errors (permission denied, etc.)
121                    if err
122                        .io_error()
123                        .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
124                    {
125                        warn!(
126                            "Permission denied accessing path during workspace scan: {}",
127                            err
128                        );
129                    } else {
130                        debug!("Error during workspace scan: {}", err);
131                    }
132                    continue;
133                }
134            };
135
136            let path = entry.path();
137
138            if let Ok(relative) = path.strip_prefix(root_path) {
139                if relative.components().any(|c| {
140                    c.as_os_str()
141                        .to_str()
142                        .is_some_and(Self::should_skip_directory)
143                }) {
144                    skipped_dirs += 1;
145                    continue;
146                }
147            }
148
149            // Skip files matching user-configured exclude patterns
150            // Patterns are matched against paths relative to workspace root
151            if !exclude_patterns.is_empty() {
152                if let Ok(relative_path) = path.strip_prefix(root_path) {
153                    let relative_str = relative_path.to_string_lossy();
154                    if exclude_patterns.iter().any(|p| p.matches(&relative_str)) {
155                        debug!("Skipping excluded path: {:?}", path);
156                        continue;
157                    }
158                }
159            }
160
161            // Look for conftest.py or test_*.py or *_test.py files
162            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
163                if filename == "conftest.py"
164                    || filename.starts_with("test_") && filename.ends_with(".py")
165                    || filename.ends_with("_test.py")
166                {
167                    files_to_process.push(path.to_path_buf());
168                }
169            }
170        }
171
172        if skipped_dirs > 0 {
173            debug!("Skipped {} entries in filtered directories", skipped_dirs);
174        }
175
176        let total_files = files_to_process.len();
177        info!("Found {} test/conftest files to process", total_files);
178
179        // Phase 2: Process files in parallel using rayon
180        // Use analyze_file_fresh since this is initial scan (no previous definitions to clean)
181        let error_count = AtomicUsize::new(0);
182        let permission_denied_count = AtomicUsize::new(0);
183
184        files_to_process.par_iter().for_each(|path| {
185            debug!("Found test/conftest file: {:?}", path);
186            match std::fs::read_to_string(path) {
187                Ok(content) => {
188                    self.analyze_file_fresh(path.clone(), &content);
189                }
190                Err(err) => {
191                    if err.kind() == std::io::ErrorKind::PermissionDenied {
192                        debug!("Permission denied reading file: {:?}", path);
193                        permission_denied_count.fetch_add(1, Ordering::Relaxed);
194                    } else {
195                        error!("Failed to read file {:?}: {}", path, err);
196                        error_count.fetch_add(1, Ordering::Relaxed);
197                    }
198                }
199            }
200        });
201
202        let errors = error_count.load(Ordering::Relaxed);
203        let permission_errors = permission_denied_count.load(Ordering::Relaxed);
204
205        if errors > 0 {
206            warn!("Workspace scan completed with {} read errors", errors);
207        }
208        if permission_errors > 0 {
209            warn!(
210                "Workspace scan: skipped {} files due to permission denied",
211                permission_errors
212            );
213        }
214
215        info!(
216            "Workspace scan complete. Processed {} files ({} permission denied, {} errors)",
217            total_files, permission_errors, errors
218        );
219
220        // Phase 3: Scan virtual environment for pytest plugins first
221        // (must happen before import scanning so venv plugin files are in file_cache)
222        self.scan_venv_fixtures(root_path);
223
224        // Phase 4: Scan modules imported by conftest.py and venv plugin files
225        // This ensures fixtures defined in separate modules (imported via star import
226        // or pytest_plugins variable) are discovered
227        self.scan_imported_fixture_modules(root_path);
228
229        info!("Total fixtures defined: {}", self.definitions.len());
230        info!("Total files with fixture usages: {}", self.usages.len());
231    }
232
233    /// Scan Python modules that are imported by conftest.py files.
234    /// This discovers fixtures defined in separate modules that are re-exported via star imports.
235    /// Handles transitive imports (A imports B, B imports C) by iteratively scanning until no new modules are found.
236    fn scan_imported_fixture_modules(&self, _root_path: &Path) {
237        use std::collections::HashSet;
238
239        info!("Scanning for imported fixture modules");
240
241        // Track all files we've already processed to find imports from
242        let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
243
244        // Start with conftest.py, test files, and venv plugin files
245        // (pytest_plugins can appear in any of these)
246        let site_packages_paths = self.site_packages_paths.lock().unwrap().clone();
247        let editable_roots: Vec<PathBuf> = self
248            .editable_install_roots
249            .lock()
250            .unwrap()
251            .iter()
252            .map(|e| e.source_root.clone())
253            .collect();
254        let mut files_to_check: Vec<std::path::PathBuf> = self
255            .file_cache
256            .iter()
257            .filter(|entry| {
258                let key = entry.key();
259                let is_conftest_or_test = key
260                    .file_name()
261                    .and_then(|n| n.to_str())
262                    .map(|n| {
263                        n == "conftest.py"
264                            || (n.starts_with("test_") && n.ends_with(".py"))
265                            || n.ends_with("_test.py")
266                    })
267                    .unwrap_or(false);
268                let is_venv_plugin = site_packages_paths.iter().any(|sp| key.starts_with(sp));
269                let is_editable_plugin = editable_roots.iter().any(|er| key.starts_with(er));
270                let is_entry_point_plugin = self.plugin_fixture_files.contains_key(key);
271                is_conftest_or_test || is_venv_plugin || is_editable_plugin || is_entry_point_plugin
272            })
273            .map(|entry| entry.key().clone())
274            .collect();
275
276        if files_to_check.is_empty() {
277            debug!("No conftest/test/plugin files found, skipping import scan");
278            return;
279        }
280
281        info!(
282            "Starting import scan with {} conftest/test/plugin files",
283            files_to_check.len()
284        );
285
286        // Iteratively process files until no new modules are discovered
287        // Track modules that were already cached but need re-analysis because
288        // they were newly marked as plugin files (is_plugin flag refresh).
289        let mut reanalyze_as_plugin: HashSet<std::path::PathBuf> = HashSet::new();
290
291        let mut iteration = 0;
292        while !files_to_check.is_empty() {
293            iteration += 1;
294            debug!(
295                "Import scan iteration {}: checking {} files",
296                iteration,
297                files_to_check.len()
298            );
299
300            let mut new_modules: HashSet<std::path::PathBuf> = HashSet::new();
301
302            for file_path in &files_to_check {
303                if processed_files.contains(file_path) {
304                    continue;
305                }
306                processed_files.insert(file_path.clone());
307
308                // Check if the importing file is itself a plugin file.
309                // If so, modules it imports via star import or pytest_plugins
310                // should also be marked as plugin files (transitive propagation).
311                let importer_is_plugin = self.plugin_fixture_files.contains_key(file_path);
312
313                // Get the file content
314                let Some(content) = self.get_file_content(file_path) else {
315                    continue;
316                };
317
318                // Parse the AST
319                let Some(parsed) = self.get_parsed_ast(file_path, &content) else {
320                    continue;
321                };
322
323                let line_index = self.get_line_index(file_path, &content);
324
325                // Extract imports and pytest_plugins
326                if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
327                    let imports =
328                        self.extract_fixture_imports(&module.body, file_path, &line_index);
329
330                    for import in imports {
331                        if let Some(resolved_path) =
332                            self.resolve_module_to_file(&import.module_path, file_path)
333                        {
334                            let canonical = self.get_canonical_path(resolved_path);
335
336                            // Only star imports propagate plugin status to the
337                            // whole imported module.  Explicit `from X import Y`
338                            // should not mark the entire module as a plugin
339                            // because only specific names are being pulled in.
340                            let should_mark_plugin = importer_is_plugin && import.is_star_import;
341
342                            if should_mark_plugin
343                                && !self.plugin_fixture_files.contains_key(&canonical)
344                            {
345                                self.plugin_fixture_files.insert(canonical.clone(), ());
346                                // If already cached, we need to re-analyze so
347                                // existing definitions get is_plugin=true.
348                                if self.file_cache.contains_key(&canonical) {
349                                    reanalyze_as_plugin.insert(canonical.clone());
350                                }
351                            }
352
353                            if !processed_files.contains(&canonical)
354                                && !self.file_cache.contains_key(&canonical)
355                            {
356                                new_modules.insert(canonical);
357                            }
358                        }
359                    }
360
361                    // Also extract pytest_plugins variable declarations.
362                    // pytest_plugins is an explicit pytest mechanism for
363                    // declaring plugin modules, so the entire referenced module
364                    // should always be marked as a plugin when the importer is
365                    // a plugin file.
366                    let plugin_modules = self.extract_pytest_plugins(&module.body);
367                    for module_path in plugin_modules {
368                        if let Some(resolved_path) =
369                            self.resolve_module_to_file(&module_path, file_path)
370                        {
371                            let canonical = self.get_canonical_path(resolved_path);
372
373                            if importer_is_plugin
374                                && !self.plugin_fixture_files.contains_key(&canonical)
375                            {
376                                self.plugin_fixture_files.insert(canonical.clone(), ());
377                                // If already cached, we need to re-analyze so
378                                // existing definitions get is_plugin=true.
379                                if self.file_cache.contains_key(&canonical) {
380                                    reanalyze_as_plugin.insert(canonical.clone());
381                                }
382                            }
383
384                            if !processed_files.contains(&canonical)
385                                && !self.file_cache.contains_key(&canonical)
386                            {
387                                new_modules.insert(canonical);
388                            }
389                        }
390                    }
391                }
392            }
393
394            if new_modules.is_empty() {
395                debug!("No new modules found in iteration {}", iteration);
396                break;
397            }
398
399            info!(
400                "Iteration {}: found {} new modules to analyze",
401                iteration,
402                new_modules.len()
403            );
404
405            // Analyze the new modules
406            for module_path in &new_modules {
407                if module_path.exists() {
408                    debug!("Analyzing imported module: {:?}", module_path);
409                    match std::fs::read_to_string(module_path) {
410                        Ok(content) => {
411                            self.analyze_file_fresh(module_path.clone(), &content);
412                        }
413                        Err(err) => {
414                            debug!("Failed to read imported module {:?}: {}", module_path, err);
415                        }
416                    }
417                }
418            }
419
420            // Next iteration will check the newly analyzed modules for their imports
421            files_to_check = new_modules.into_iter().collect();
422        }
423
424        // Re-analyze modules that were already cached but newly marked as
425        // plugin files.  This refreshes FixtureDefinition.is_plugin on every
426        // fixture in those files so they participate in Priority 3 resolution.
427        if !reanalyze_as_plugin.is_empty() {
428            info!(
429                "Re-analyzing {} cached module(s) newly marked as plugin files",
430                reanalyze_as_plugin.len()
431            );
432            for module_path in &reanalyze_as_plugin {
433                if let Some(content) = self.get_file_content(module_path) {
434                    debug!("Re-analyzing as plugin: {:?}", module_path);
435                    // Use analyze_file (not _fresh) to clean up old definitions
436                    // before recording new ones with is_plugin=true.
437                    self.analyze_file(module_path.clone(), &content);
438                }
439            }
440        }
441
442        info!(
443            "Imported fixture module scan complete after {} iterations",
444            iteration
445        );
446    }
447
448    /// Scan virtual environment for pytest plugin fixtures.
449    fn scan_venv_fixtures(&self, root_path: &Path) {
450        info!("Scanning for pytest plugins in virtual environment");
451
452        // Try to find virtual environment
453        let venv_paths = vec![
454            root_path.join(".venv"),
455            root_path.join("venv"),
456            root_path.join("env"),
457        ];
458
459        info!("Checking for venv in: {:?}", root_path);
460        for venv_path in &venv_paths {
461            debug!("Checking venv path: {:?}", venv_path);
462            if venv_path.exists() {
463                info!("Found virtual environment at: {:?}", venv_path);
464                self.scan_venv_site_packages(venv_path);
465                return;
466            } else {
467                debug!("  Does not exist: {:?}", venv_path);
468            }
469        }
470
471        // Also check for system-wide VIRTUAL_ENV
472        if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
473            info!("Found VIRTUAL_ENV environment variable: {}", venv);
474            let venv_path = std::path::PathBuf::from(venv);
475            if venv_path.exists() {
476                let venv_path = venv_path.canonicalize().unwrap_or(venv_path);
477                info!("Using VIRTUAL_ENV: {:?}", venv_path);
478                self.scan_venv_site_packages(&venv_path);
479                return;
480            } else {
481                warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
482            }
483        } else {
484            debug!("No VIRTUAL_ENV environment variable set");
485        }
486
487        warn!("No virtual environment found - third-party fixtures will not be available");
488    }
489
490    fn scan_venv_site_packages(&self, venv_path: &Path) {
491        info!("Scanning venv site-packages in: {:?}", venv_path);
492
493        // Attempt to replace the built-in stdlib list with the authoritative set
494        // from the venv's own Python binary (requires Python ≥ 3.10).  Falls back
495        // silently to the static list for older interpreters or if the binary
496        // cannot be found or executed.
497        if try_init_stdlib_from_python(venv_path) {
498            info!("stdlib module list populated from venv Python");
499        } else {
500            info!(
501                "using built-in stdlib module list \
502                 (Python < 3.10 or binary not found in {:?})",
503                venv_path
504            );
505        }
506
507        // Find site-packages directory
508        let lib_path = venv_path.join("lib");
509        debug!("Checking lib path: {:?}", lib_path);
510
511        if lib_path.exists() {
512            // Look for python* directories
513            if let Ok(entries) = std::fs::read_dir(&lib_path) {
514                for entry in entries.flatten() {
515                    let path = entry.path();
516                    let dirname = path.file_name().unwrap_or_default().to_string_lossy();
517                    debug!("Found in lib: {:?}", dirname);
518
519                    if path.is_dir() && dirname.starts_with("python") {
520                        let site_packages = path.join("site-packages");
521                        debug!("Checking site-packages: {:?}", site_packages);
522
523                        if site_packages.exists() {
524                            let site_packages =
525                                site_packages.canonicalize().unwrap_or(site_packages);
526                            info!("Found site-packages: {:?}", site_packages);
527                            self.site_packages_paths
528                                .lock()
529                                .unwrap()
530                                .push(site_packages.clone());
531                            self.scan_pytest_plugins(&site_packages);
532                            return;
533                        }
534                    }
535                }
536            }
537        }
538
539        // Try Windows path
540        let windows_site_packages = venv_path.join("Lib/site-packages");
541        debug!("Checking Windows path: {:?}", windows_site_packages);
542        if windows_site_packages.exists() {
543            let windows_site_packages = windows_site_packages
544                .canonicalize()
545                .unwrap_or(windows_site_packages);
546            info!("Found site-packages (Windows): {:?}", windows_site_packages);
547            self.site_packages_paths
548                .lock()
549                .unwrap()
550                .push(windows_site_packages.clone());
551            self.scan_pytest_plugins(&windows_site_packages);
552            return;
553        }
554
555        warn!("Could not find site-packages in venv: {:?}", venv_path);
556    }
557
558    /// Parse `entry_points.txt` content and extract pytest11 entries.
559    ///
560    /// Returns all successfully parsed entries from the `[pytest11]` section.
561    /// Returns an empty vec if there is no `[pytest11]` section or no valid
562    /// `name = value` lines within that section. Malformed lines are ignored.
563    fn parse_pytest11_entry_points(content: &str) -> Vec<Pytest11EntryPoint> {
564        let mut results = Vec::new();
565        let mut in_pytest11_section = false;
566
567        for line in content.lines() {
568            let line = line.trim();
569
570            // Check for section headers
571            if line.starts_with('[') && line.ends_with(']') {
572                in_pytest11_section = line == "[pytest11]";
573                continue;
574            }
575
576            // Parse entries within pytest11 section
577            if in_pytest11_section && !line.is_empty() && !line.starts_with('#') {
578                if let Some((name, module_path)) = line.split_once('=') {
579                    results.push(Pytest11EntryPoint {
580                        name: name.trim().to_string(),
581                        module_path: module_path.trim().to_string(),
582                    });
583                }
584            }
585        }
586        results
587    }
588
589    /// Resolve a Python module path to a file system path within site-packages.
590    ///
591    /// Examples:
592    /// - "pytest_mock" → site_packages/pytest_mock/__init__.py or site_packages/pytest_mock.py
593    /// - "pytest_asyncio.plugin" → site_packages/pytest_asyncio/plugin.py
594    ///
595    /// Returns the path to a `.py` file (may be `__init__.py` for packages).
596    fn resolve_entry_point_module_to_path(
597        site_packages: &Path,
598        module_path: &str,
599    ) -> Option<PathBuf> {
600        // Strip any :attr suffix (e.g., "module:function" -> "module")
601        let module_path = module_path.split(':').next().unwrap_or(module_path);
602
603        // Split into components
604        let parts: Vec<&str> = module_path.split('.').collect();
605
606        if parts.is_empty() {
607            return None;
608        }
609
610        // Reject path traversal and null bytes in module path components
611        if parts
612            .iter()
613            .any(|p| p.contains("..") || p.contains('\0') || p.is_empty())
614        {
615            return None;
616        }
617
618        // Build the path from module components
619        let mut path = site_packages.to_path_buf();
620        for part in &parts {
621            path.push(part);
622        }
623
624        // Ensure resolved path stays within the base directory
625        let check_bounded = |candidate: &Path| -> Option<PathBuf> {
626            let canonical = candidate.canonicalize().ok()?;
627            let base_canonical = site_packages.canonicalize().ok()?;
628            if canonical.starts_with(&base_canonical) {
629                Some(canonical)
630            } else {
631                None
632            }
633        };
634
635        // Check if it's a module file (add .py extension)
636        let py_file = path.with_extension("py");
637        if py_file.exists() {
638            return check_bounded(&py_file);
639        }
640
641        // Check if it's a package directory (has __init__.py)
642        if path.is_dir() {
643            let init_file = path.join("__init__.py");
644            if init_file.exists() {
645                return check_bounded(&init_file);
646            }
647        }
648
649        None
650    }
651
652    /// Scan a single Python file for fixture definitions.
653    fn scan_single_plugin_file(&self, file_path: &Path) {
654        if file_path.extension().and_then(|s| s.to_str()) != Some("py") {
655            return;
656        }
657
658        debug!("Scanning plugin file: {:?}", file_path);
659
660        // Mark this file as a plugin file so fixtures from it get is_plugin=true
661        let canonical = file_path
662            .canonicalize()
663            .unwrap_or_else(|_| file_path.to_path_buf());
664        self.plugin_fixture_files.insert(canonical, ());
665
666        if let Ok(content) = std::fs::read_to_string(file_path) {
667            self.analyze_file(file_path.to_path_buf(), &content);
668        }
669    }
670
671    /// Load pytest plugins from a single dist-info directory's entry points.
672    ///
673    /// Reads entry_points.txt, parses [pytest11] section, resolves modules,
674    /// and scans discovered plugin files for fixtures.
675    ///
676    /// Returns the number of plugin modules scanned.
677    fn load_plugin_from_entry_point(&self, dist_info_path: &Path, site_packages: &Path) -> usize {
678        let entry_points_file = dist_info_path.join("entry_points.txt");
679
680        let content = match std::fs::read_to_string(&entry_points_file) {
681            Ok(c) => c,
682            Err(_) => return 0, // No entry_points.txt or unreadable
683        };
684
685        let entries = Self::parse_pytest11_entry_points(&content);
686
687        if entries.is_empty() {
688            return 0; // No pytest11 plugins in this package
689        }
690
691        let mut scanned_count = 0;
692
693        for entry in entries {
694            debug!(
695                "Found pytest11 entry: {} = {}",
696                entry.name, entry.module_path
697            );
698
699            let resolved =
700                Self::resolve_entry_point_module_to_path(site_packages, &entry.module_path)
701                    .or_else(|| self.resolve_entry_point_in_editable_installs(&entry.module_path));
702
703            if let Some(path) = resolved {
704                let scanned = if path.file_name().and_then(|n| n.to_str()) == Some("__init__.py") {
705                    let package_dir = path.parent().expect("__init__.py must have parent");
706                    info!(
707                        "Scanning pytest plugin package directory for {}: {:?}",
708                        entry.name, package_dir
709                    );
710                    self.scan_plugin_directory(package_dir);
711                    true
712                } else if path.is_file() {
713                    info!("Scanning pytest plugin: {} -> {:?}", entry.name, path);
714                    self.scan_single_plugin_file(&path);
715                    true
716                } else {
717                    debug!(
718                        "Resolved module path for plugin {} is not a file: {:?}",
719                        entry.name, path
720                    );
721                    false
722                };
723
724                if scanned {
725                    scanned_count += 1;
726                }
727            } else {
728                debug!(
729                    "Could not resolve module path: {} for plugin {}",
730                    entry.module_path, entry.name
731                );
732            }
733        }
734
735        scanned_count
736    }
737
738    /// Scan pytest's internal _pytest package for built-in fixtures.
739    /// This handles fixtures like tmp_path, capsys, monkeypatch, etc.
740    fn scan_pytest_internal_fixtures(&self, site_packages: &Path) {
741        let pytest_internal = site_packages.join("_pytest");
742
743        if !pytest_internal.exists() || !pytest_internal.is_dir() {
744            debug!("_pytest directory not found in site-packages");
745            return;
746        }
747
748        info!(
749            "Scanning pytest internal fixtures in: {:?}",
750            pytest_internal
751        );
752        self.scan_plugin_directory(&pytest_internal);
753
754        // `request` is not defined via @pytest.fixture anywhere in _pytest/ —
755        // pytest injects it programmatically via FixtureManager.  Register a
756        // synthetic definition so that hover, inlay hints, completion and code
757        // actions all know its type.
758        self.register_request_builtin_fixture(&pytest_internal);
759    }
760
761    /// Inject a hard-coded `FixtureDefinition` for the `request` fixture.
762    ///
763    /// pytest's built-in `request` fixture is registered programmatically by
764    /// `FixtureManager` and therefore never appears as a `@pytest.fixture`-
765    /// decorated function that the AST scanner could pick up.  We synthesise
766    /// the definition here so every LSP feature (hover, inlay hints,
767    /// completions, go-to-definition, code actions) can work with it.
768    ///
769    /// The `file_path` is set to `_pytest/fixtures.py` when that file exists,
770    /// which gives a useful go-to-definition target.  A sentinel path is used
771    /// as a fallback so the entry never gets accidentally cleared by a
772    /// subsequent `analyze_file` call on a real source file.
773    fn register_request_builtin_fixture(&self, pytest_internal: &Path) {
774        // Prefer the real _pytest/fixtures.py for go-to-definition.
775        let fixtures_py = pytest_internal.join("fixtures.py");
776        let file_path = if fixtures_py.exists() {
777            fixtures_py
778                .canonicalize()
779                .unwrap_or_else(|_| fixtures_py.clone())
780        } else {
781            // Sentinel path – will never be passed to analyze_file.
782            pytest_internal.join("_pytest_request_builtin.py")
783        };
784
785        // Guard: skip if we'd register an identical entry again (same path).
786        // Otherwise drop any stale synthetic entry first so a second scan that
787        // finds the real fixtures.py after a sentinel-path registration doesn't
788        // accumulate two entries.
789        if let Some(existing) = self.definitions.get("request") {
790            if existing.iter().any(|d| d.file_path == file_path) {
791                debug!(
792                    "Synthetic 'request' fixture already registered for {:?}, skipping",
793                    file_path
794                );
795                return;
796            }
797        }
798        drop(self.definitions.remove("request"));
799
800        let docstring = concat!(
801            "Special fixture providing information about the requesting test context.\n",
802            "\n",
803            "See https://docs.pytest.org/en/stable/reference/reference.html#request"
804        );
805
806        let definition = FixtureDefinition {
807            name: "request".to_string(),
808            file_path,
809            line: 1,
810            end_line: 1,
811            start_char: 0,
812            end_char: "request".len(),
813            docstring: Some(docstring.to_string()),
814            return_type: Some("FixtureRequest".to_string()),
815            return_type_imports: vec![TypeImportSpec {
816                check_name: "FixtureRequest".to_string(),
817                import_statement: "from pytest import FixtureRequest".to_string(),
818            }],
819            is_third_party: true,
820            is_plugin: true,
821            dependencies: vec![],
822            scope: FixtureScope::Function,
823            yield_line: None,
824            autouse: false,
825        };
826
827        info!("Registering synthetic 'request' fixture definition");
828        self.record_fixture_definition(definition);
829    }
830
831    /// Extract the raw and normalized package name from a `.dist-info` directory name.
832    /// Returns `(raw_name, normalized_name)`.
833    /// e.g., `my-package-1.0.0.dist-info` → `("my-package", "my_package")`
834    fn extract_package_name_from_dist_info(dir_name: &str) -> Option<(String, String)> {
835        // Strip the .dist-info or .egg-info suffix
836        let name_version = dir_name
837            .strip_suffix(".dist-info")
838            .or_else(|| dir_name.strip_suffix(".egg-info"))?;
839
840        // The format is `name-version`. Split on '-' and take the first segment.
841        // Package names can contain hyphens, but the version always starts with a digit,
842        // so find the first '-' followed by a digit.
843        let name = if let Some(idx) = name_version.char_indices().position(|(i, c)| {
844            c == '-' && name_version[i + 1..].starts_with(|c: char| c.is_ascii_digit())
845        }) {
846            &name_version[..idx]
847        } else {
848            name_version
849        };
850
851        let raw = name.to_string();
852        // Normalize: PEP 503 says dashes, dots, underscores are interchangeable
853        let normalized = name.replace(['-', '.'], "_").to_lowercase();
854        Some((raw, normalized))
855    }
856
857    /// Discover editable installs by scanning `.dist-info` directories for `direct_url.json`.
858    fn discover_editable_installs(&self, site_packages: &Path) {
859        info!("Scanning for editable installs in: {:?}", site_packages);
860
861        // Validate the site-packages path is a real directory before reading from it
862        if !site_packages.is_dir() {
863            warn!(
864                "site-packages path is not a directory, skipping editable install scan: {:?}",
865                site_packages
866            );
867            return;
868        }
869
870        // Clear previous editable installs to avoid duplicates on re-scan
871        self.editable_install_roots.lock().unwrap().clear();
872
873        // Index all .pth files once (stem → full path) to avoid re-reading site-packages per package
874        let pth_index = Self::build_pth_index(site_packages);
875
876        let entries = match std::fs::read_dir(site_packages) {
877            Ok(e) => e,
878            Err(_) => return,
879        };
880
881        for entry in entries.flatten() {
882            let path = entry.path();
883            let filename = path.file_name().unwrap_or_default().to_string_lossy();
884
885            if !filename.ends_with(".dist-info") {
886                continue;
887            }
888
889            let direct_url_path = path.join("direct_url.json");
890            let content = match std::fs::read_to_string(&direct_url_path) {
891                Ok(c) => c,
892                Err(_) => continue,
893            };
894
895            // Parse direct_url.json to check for editable installs
896            let json: serde_json::Value = match serde_json::from_str(&content) {
897                Ok(v) => v,
898                Err(_) => continue,
899            };
900
901            // Check if dir_info.editable is true
902            let is_editable = json
903                .get("dir_info")
904                .and_then(|d| d.get("editable"))
905                .and_then(|e| e.as_bool())
906                .unwrap_or(false);
907
908            if !is_editable {
909                continue;
910            }
911
912            let Some((raw_name, normalized_name)) =
913                Self::extract_package_name_from_dist_info(&filename)
914            else {
915                continue;
916            };
917
918            // Find the .pth file that points to the source root
919            let source_root = Self::find_editable_pth_source_root(
920                &pth_index,
921                &raw_name,
922                &normalized_name,
923                site_packages,
924            );
925            let Some(source_root) = source_root else {
926                debug!(
927                    "No .pth file found for editable install: {}",
928                    normalized_name
929                );
930                continue;
931            };
932
933            info!(
934                "Discovered editable install: {} -> {:?}",
935                normalized_name, source_root
936            );
937            self.editable_install_roots
938                .lock()
939                .unwrap()
940                .push(super::EditableInstall {
941                    package_name: normalized_name,
942                    raw_package_name: raw_name,
943                    source_root,
944                    site_packages: site_packages.to_path_buf(),
945                });
946        }
947
948        let count = self.editable_install_roots.lock().unwrap().len();
949        info!("Discovered {} editable install(s)", count);
950    }
951
952    /// Build an index of `.pth` file stems to their full paths.
953    /// Read site-packages once and store `stem → path` for O(1) lookup.
954    fn build_pth_index(site_packages: &Path) -> std::collections::HashMap<String, PathBuf> {
955        let mut index = std::collections::HashMap::new();
956        if !site_packages.is_dir() {
957            return index;
958        }
959        let entries = match std::fs::read_dir(site_packages) {
960            Ok(e) => e,
961            Err(_) => return index,
962        };
963        for entry in entries.flatten() {
964            let fname = entry.file_name();
965            let fname_str = fname.to_string_lossy();
966            if fname_str.ends_with(".pth") {
967                let stem = fname_str.strip_suffix(".pth").unwrap_or(&fname_str);
968                index.insert(stem.to_string(), entry.path());
969            }
970        }
971        index
972    }
973
974    /// Find the source root from a `.pth` file for an editable install.
975    /// Uses both raw and normalized package names to handle pip's varying naming conventions.
976    /// Looks for both old-style (`_<pkg>.pth`) and new-style (`__editable__.<pkg>.pth`) naming.
977    fn find_editable_pth_source_root(
978        pth_index: &std::collections::HashMap<String, PathBuf>,
979        raw_name: &str,
980        normalized_name: &str,
981        site_packages: &Path,
982    ) -> Option<PathBuf> {
983        // Build candidates from both raw and normalized names.
984        // Raw name preserves original dashes/dots (e.g., "my-package"),
985        // normalized uses underscores (e.g., "my_package").
986        let mut candidates: Vec<String> = vec![
987            format!("__editable__.{}", normalized_name),
988            format!("_{}", normalized_name),
989            normalized_name.to_string(),
990        ];
991        if raw_name != normalized_name {
992            candidates.push(format!("__editable__.{}", raw_name));
993            candidates.push(format!("_{}", raw_name));
994            candidates.push(raw_name.to_string());
995        }
996
997        // Search the pre-built index for matching .pth stems
998        for (stem, pth_path) in pth_index {
999            let matches = candidates.iter().any(|c| {
1000                stem == c
1001                    || stem.strip_prefix(c).is_some_and(|rest| {
1002                        rest.starts_with('-')
1003                            && rest[1..].starts_with(|ch: char| ch.is_ascii_digit())
1004                    })
1005            });
1006            if !matches {
1007                continue;
1008            }
1009
1010            // Parse the .pth file: first non-comment, non-import line is the path
1011            let content = match std::fs::read_to_string(pth_path) {
1012                Ok(c) => c,
1013                Err(_) => continue,
1014            };
1015
1016            for line in content.lines() {
1017                let line = line.trim();
1018                if line.is_empty() || line.starts_with('#') || line.starts_with("import ") {
1019                    continue;
1020                }
1021                // Validate: reject lines with null bytes, control characters,
1022                // or path traversal sequences
1023                if line.contains('\0')
1024                    || line.bytes().any(|b| b < 0x20 && b != b'\t')
1025                    || line.contains("..")
1026                {
1027                    debug!("Skipping .pth line with invalid characters: {:?}", line);
1028                    continue;
1029                }
1030                let candidate = PathBuf::from(line);
1031                let resolved = if candidate.is_absolute() {
1032                    candidate
1033                } else {
1034                    site_packages.join(&candidate)
1035                };
1036                // Canonicalize to resolve symlinks and validate existence,
1037                // then verify it's an actual directory
1038                match resolved.canonicalize() {
1039                    Ok(canonical) if canonical.is_dir() => return Some(canonical),
1040                    Ok(canonical) => {
1041                        debug!(".pth path is not a directory: {:?}", canonical);
1042                        continue;
1043                    }
1044                    Err(_) => {
1045                        debug!("Could not canonicalize .pth path: {:?}", resolved);
1046                        continue;
1047                    }
1048                }
1049            }
1050        }
1051
1052        None
1053    }
1054
1055    /// Try to resolve an entry point module path through editable install source roots.
1056    fn resolve_entry_point_in_editable_installs(&self, module_path: &str) -> Option<PathBuf> {
1057        let installs = self.editable_install_roots.lock().unwrap();
1058        for install in installs.iter() {
1059            if let Some(path) =
1060                Self::resolve_entry_point_module_to_path(&install.source_root, module_path)
1061            {
1062                return Some(path);
1063            }
1064        }
1065        None
1066    }
1067
1068    fn scan_pytest_plugins(&self, site_packages: &Path) {
1069        info!(
1070            "Scanning for pytest plugins via entry points in: {:?}",
1071            site_packages
1072        );
1073
1074        // Discover editable installs before scanning entry points
1075        self.discover_editable_installs(site_packages);
1076
1077        let mut plugin_count = 0;
1078
1079        // First, scan pytest's internal fixtures (special case)
1080        self.scan_pytest_internal_fixtures(site_packages);
1081
1082        // Iterate over ALL dist-info directories and check for pytest11 entry points
1083        for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
1084            let entry = match entry {
1085                Ok(e) => e,
1086                Err(_) => continue,
1087            };
1088
1089            let path = entry.path();
1090            let filename = path.file_name().unwrap_or_default().to_string_lossy();
1091
1092            // Only process dist metadata directories
1093            if !filename.ends_with(".dist-info") && !filename.ends_with(".egg-info") {
1094                continue;
1095            }
1096
1097            // Try to load plugins from this package's entry points
1098            let scanned = self.load_plugin_from_entry_point(&path, site_packages);
1099            if scanned > 0 {
1100                plugin_count += scanned;
1101                debug!("Loaded {} plugin module(s) from {}", scanned, filename);
1102            }
1103        }
1104
1105        info!(
1106            "Discovered fixtures from {} pytest plugin modules",
1107            plugin_count
1108        );
1109    }
1110
1111    fn scan_plugin_directory(&self, plugin_dir: &Path) {
1112        // Recursively scan for Python files with fixtures
1113        for entry in WalkDir::new(plugin_dir)
1114            .max_depth(3) // Limit depth to avoid scanning too much
1115            .into_iter()
1116            .filter_map(|e| e.ok())
1117        {
1118            let path = entry.path();
1119
1120            if path.extension().and_then(|s| s.to_str()) == Some("py") {
1121                // Only scan files that might have fixtures (not test files)
1122                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
1123                    // Skip test files and __pycache__
1124                    if filename.starts_with("test_") || filename.contains("__pycache__") {
1125                        continue;
1126                    }
1127
1128                    debug!("Scanning plugin file: {:?}", path);
1129
1130                    // Mark this file as a plugin file so fixtures get is_plugin=true
1131                    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1132                    self.plugin_fixture_files.insert(canonical, ());
1133
1134                    if let Ok(content) = std::fs::read_to_string(path) {
1135                        self.analyze_file(path.to_path_buf(), &content);
1136                    }
1137                }
1138            }
1139        }
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146    use std::fs;
1147    use tempfile::tempdir;
1148
1149    #[test]
1150    fn test_parse_pytest11_entry_points_basic() {
1151        let content = r#"
1152[console_scripts]
1153my-cli = my_package:main
1154
1155[pytest11]
1156my_plugin = my_package.plugin
1157another = another_pkg
1158
1159[other_section]
1160foo = bar
1161"#;
1162
1163        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1164        assert_eq!(entries.len(), 2);
1165        assert_eq!(entries[0].name, "my_plugin");
1166        assert_eq!(entries[0].module_path, "my_package.plugin");
1167        assert_eq!(entries[1].name, "another");
1168        assert_eq!(entries[1].module_path, "another_pkg");
1169    }
1170
1171    #[test]
1172    fn test_parse_pytest11_entry_points_empty_file() {
1173        let entries = FixtureDatabase::parse_pytest11_entry_points("");
1174        assert!(entries.is_empty());
1175    }
1176
1177    #[test]
1178    fn test_parse_pytest11_entry_points_no_pytest11_section() {
1179        let content = r#"
1180[console_scripts]
1181my-cli = my_package:main
1182"#;
1183        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1184        assert!(entries.is_empty());
1185    }
1186
1187    #[test]
1188    fn test_parse_pytest11_entry_points_with_comments() {
1189        let content = r#"
1190[pytest11]
1191# This is a comment
1192my_plugin = my_package.plugin
1193# Another comment
1194"#;
1195        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1196        assert_eq!(entries.len(), 1);
1197        assert_eq!(entries[0].name, "my_plugin");
1198    }
1199
1200    #[test]
1201    fn test_parse_pytest11_entry_points_with_whitespace() {
1202        let content = r#"
1203[pytest11]
1204   my_plugin   =   my_package.plugin
1205another=another_pkg
1206"#;
1207        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1208        assert_eq!(entries.len(), 2);
1209        assert_eq!(entries[0].name, "my_plugin");
1210        assert_eq!(entries[0].module_path, "my_package.plugin");
1211        assert_eq!(entries[1].name, "another");
1212        assert_eq!(entries[1].module_path, "another_pkg");
1213    }
1214
1215    #[test]
1216    fn test_parse_pytest11_entry_points_with_attr() {
1217        // Some entry points have :attr suffix (e.g., module:function)
1218        let content = r#"
1219[pytest11]
1220my_plugin = my_package.module:plugin_entry
1221"#;
1222        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1223        assert_eq!(entries.len(), 1);
1224        assert_eq!(entries[0].module_path, "my_package.module:plugin_entry");
1225    }
1226
1227    #[test]
1228    fn test_parse_pytest11_entry_points_multiple_sections_before_pytest11() {
1229        let content = r#"
1230[console_scripts]
1231cli = pkg:main
1232
1233[gui_scripts]
1234gui = pkg:gui_main
1235
1236[pytest11]
1237my_plugin = my_package.plugin
1238
1239[other]
1240extra = something
1241"#;
1242        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1243        assert_eq!(entries.len(), 1);
1244        assert_eq!(entries[0].name, "my_plugin");
1245    }
1246
1247    #[test]
1248    fn test_resolve_entry_point_module_to_path_package() {
1249        let temp = tempdir().unwrap();
1250        let site_packages = temp.path();
1251
1252        // Create a package with __init__.py
1253        let pkg_dir = site_packages.join("my_plugin");
1254        fs::create_dir_all(&pkg_dir).unwrap();
1255        fs::write(pkg_dir.join("__init__.py"), "# plugin code").unwrap();
1256
1257        // Should resolve to __init__.py (canonicalized)
1258        let result =
1259            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1260        assert!(result.is_some());
1261        assert_eq!(
1262            result.unwrap(),
1263            pkg_dir.join("__init__.py").canonicalize().unwrap()
1264        );
1265    }
1266
1267    #[test]
1268    fn test_resolve_entry_point_module_to_path_submodule() {
1269        let temp = tempdir().unwrap();
1270        let site_packages = temp.path();
1271
1272        // Create a package with a submodule
1273        let pkg_dir = site_packages.join("my_plugin");
1274        fs::create_dir_all(&pkg_dir).unwrap();
1275        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1276        fs::write(pkg_dir.join("plugin.py"), "# plugin code").unwrap();
1277
1278        // Should resolve to plugin.py (canonicalized)
1279        let result =
1280            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin.plugin");
1281        assert!(result.is_some());
1282        assert_eq!(
1283            result.unwrap(),
1284            pkg_dir.join("plugin.py").canonicalize().unwrap()
1285        );
1286    }
1287
1288    #[test]
1289    fn test_resolve_entry_point_module_to_path_single_file() {
1290        let temp = tempdir().unwrap();
1291        let site_packages = temp.path();
1292
1293        // Create a single-file module
1294        fs::write(site_packages.join("my_plugin.py"), "# plugin code").unwrap();
1295
1296        // Should resolve to my_plugin.py (canonicalized)
1297        let result =
1298            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1299        assert!(result.is_some());
1300        assert_eq!(
1301            result.unwrap(),
1302            site_packages.join("my_plugin.py").canonicalize().unwrap()
1303        );
1304    }
1305
1306    #[test]
1307    fn test_resolve_entry_point_module_to_path_not_found() {
1308        let temp = tempdir().unwrap();
1309        let site_packages = temp.path();
1310
1311        // Nothing exists
1312        let result = FixtureDatabase::resolve_entry_point_module_to_path(
1313            site_packages,
1314            "nonexistent_plugin",
1315        );
1316        assert!(result.is_none());
1317    }
1318
1319    #[test]
1320    fn test_resolve_entry_point_module_strips_attr() {
1321        let temp = tempdir().unwrap();
1322        let site_packages = temp.path();
1323
1324        // Create a package with a submodule
1325        let pkg_dir = site_packages.join("my_plugin");
1326        fs::create_dir_all(&pkg_dir).unwrap();
1327        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1328        fs::write(pkg_dir.join("module.py"), "# plugin code").unwrap();
1329
1330        // Should resolve even with :attr suffix (canonicalized)
1331        let result = FixtureDatabase::resolve_entry_point_module_to_path(
1332            site_packages,
1333            "my_plugin.module:entry_function",
1334        );
1335        assert!(result.is_some());
1336        assert_eq!(
1337            result.unwrap(),
1338            pkg_dir.join("module.py").canonicalize().unwrap()
1339        );
1340    }
1341
1342    #[test]
1343    fn test_resolve_entry_point_rejects_path_traversal() {
1344        let temp = tempdir().unwrap();
1345        let site_packages = temp.path();
1346
1347        // Create a valid module so the path would resolve if not for validation
1348        fs::write(site_packages.join("valid.py"), "# code").unwrap();
1349
1350        // After splitting on '.', this yields empty segments ["", "", "%2Fetc%2Fpasswd"]
1351        // and is rejected by the empty-segment validation
1352        let result =
1353            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "..%2Fetc%2Fpasswd");
1354        assert!(result.is_none(), "should reject traversal-like pattern");
1355
1356        // "valid...secret" splits to ["valid", "", "", "secret"] — caught by empty segments
1357        let result =
1358            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "valid...secret");
1359        assert!(
1360            result.is_none(),
1361            "should reject module names with consecutive dots (empty segments)"
1362        );
1363
1364        // "pkg..evil" splits to ["pkg", "", "evil"] — also caught by empty segments
1365        let result =
1366            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "pkg..evil");
1367        assert!(
1368            result.is_none(),
1369            "should reject module names with consecutive dots"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_resolve_entry_point_rejects_null_bytes() {
1375        let temp = tempdir().unwrap();
1376        let site_packages = temp.path();
1377
1378        let result =
1379            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "module\0name");
1380        assert!(result.is_none(), "should reject null bytes");
1381    }
1382
1383    #[test]
1384    fn test_resolve_entry_point_rejects_empty_segments() {
1385        let temp = tempdir().unwrap();
1386        let site_packages = temp.path();
1387
1388        // "foo..bar" splits on '.' to ["foo", "", "bar"]
1389        let result = FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "foo..bar");
1390        assert!(result.is_none(), "should reject empty path segments");
1391    }
1392
1393    #[cfg(unix)]
1394    #[test]
1395    fn test_resolve_entry_point_rejects_symlink_escape() {
1396        let temp = tempdir().unwrap();
1397        let site_packages = temp.path();
1398
1399        // Create an outside directory with a .py file
1400        let outside = tempdir().unwrap();
1401        fs::write(outside.path().join("evil.py"), "# malicious").unwrap();
1402
1403        // Create a symlink inside site-packages pointing outside
1404        std::os::unix::fs::symlink(outside.path(), site_packages.join("escaped")).unwrap();
1405
1406        let result =
1407            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "escaped.evil");
1408        assert!(
1409            result.is_none(),
1410            "should reject paths that escape site-packages via symlink"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_entry_point_plugin_discovery_integration() {
1416        // Create mock site-packages structure
1417        let temp = tempdir().unwrap();
1418        let site_packages = temp.path();
1419
1420        // Create a mock plugin package
1421        let plugin_dir = site_packages.join("my_pytest_plugin");
1422        fs::create_dir_all(&plugin_dir).unwrap();
1423
1424        let plugin_content = r#"
1425import pytest
1426
1427@pytest.fixture
1428def my_dynamic_fixture():
1429    """A fixture discovered via entry points."""
1430    return "discovered via entry point"
1431
1432@pytest.fixture
1433def another_dynamic_fixture():
1434    return 42
1435"#;
1436        fs::write(plugin_dir.join("__init__.py"), plugin_content).unwrap();
1437
1438        // Create dist-info with entry points
1439        let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1440        fs::create_dir_all(&dist_info).unwrap();
1441
1442        let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1443        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1444
1445        // Scan and verify
1446        let db = FixtureDatabase::new();
1447        db.scan_pytest_plugins(site_packages);
1448
1449        assert!(
1450            db.definitions.contains_key("my_dynamic_fixture"),
1451            "my_dynamic_fixture should be discovered"
1452        );
1453        assert!(
1454            db.definitions.contains_key("another_dynamic_fixture"),
1455            "another_dynamic_fixture should be discovered"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_entry_point_discovery_submodule() {
1461        let temp = tempdir().unwrap();
1462        let site_packages = temp.path();
1463
1464        // Create package with plugin in submodule (like pytest_asyncio.plugin)
1465        let plugin_dir = site_packages.join("my_pytest_plugin");
1466        fs::create_dir_all(&plugin_dir).unwrap();
1467        fs::write(plugin_dir.join("__init__.py"), "# main init").unwrap();
1468
1469        let plugin_content = r#"
1470import pytest
1471
1472@pytest.fixture
1473def submodule_fixture():
1474    return "from submodule"
1475"#;
1476        fs::write(plugin_dir.join("plugin.py"), plugin_content).unwrap();
1477
1478        // Create dist-info with entry points pointing to submodule
1479        let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1480        fs::create_dir_all(&dist_info).unwrap();
1481
1482        let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin.plugin\n";
1483        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1484
1485        // Scan and verify
1486        let db = FixtureDatabase::new();
1487        db.scan_pytest_plugins(site_packages);
1488
1489        assert!(
1490            db.definitions.contains_key("submodule_fixture"),
1491            "submodule_fixture should be discovered"
1492        );
1493    }
1494
1495    #[test]
1496    fn test_entry_point_discovery_package_scans_submodules() {
1497        let temp = tempdir().unwrap();
1498        let site_packages = temp.path();
1499
1500        // Create package with fixtures in a submodule
1501        let plugin_dir = site_packages.join("my_pytest_plugin");
1502        fs::create_dir_all(&plugin_dir).unwrap();
1503        fs::write(plugin_dir.join("__init__.py"), "# package init").unwrap();
1504
1505        let plugin_content = r#"
1506import pytest
1507
1508@pytest.fixture
1509def package_submodule_fixture():
1510    return "from package submodule"
1511"#;
1512        fs::write(plugin_dir.join("fixtures.py"), plugin_content).unwrap();
1513
1514        // Create dist-info with entry points pointing to package
1515        let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1516        fs::create_dir_all(&dist_info).unwrap();
1517
1518        let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1519        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1520
1521        // Scan and verify submodule fixtures are discovered
1522        let db = FixtureDatabase::new();
1523        db.scan_pytest_plugins(site_packages);
1524
1525        assert!(
1526            db.definitions.contains_key("package_submodule_fixture"),
1527            "package_submodule_fixture should be discovered"
1528        );
1529    }
1530
1531    #[test]
1532    fn test_entry_point_discovery_no_pytest11_section() {
1533        let temp = tempdir().unwrap();
1534        let site_packages = temp.path();
1535
1536        // Create a package that's NOT a pytest plugin
1537        let pkg_dir = site_packages.join("some_package");
1538        fs::create_dir_all(&pkg_dir).unwrap();
1539
1540        let pkg_content = r#"
1541import pytest
1542
1543@pytest.fixture
1544def should_not_be_found():
1545    return "this package is not a pytest plugin"
1546"#;
1547        fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1548
1549        // Create dist-info WITHOUT pytest11 section
1550        let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1551        fs::create_dir_all(&dist_info).unwrap();
1552
1553        let entry_points = "[console_scripts]\nsome_cli = some_package:main\n";
1554        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1555
1556        // Scan and verify
1557        let db = FixtureDatabase::new();
1558        db.scan_pytest_plugins(site_packages);
1559
1560        assert!(
1561            !db.definitions.contains_key("should_not_be_found"),
1562            "should_not_be_found should NOT be discovered (not a pytest plugin)"
1563        );
1564    }
1565
1566    #[test]
1567    fn test_entry_point_discovery_missing_entry_points_txt() {
1568        let temp = tempdir().unwrap();
1569        let site_packages = temp.path();
1570
1571        // Create a package
1572        let pkg_dir = site_packages.join("some_package");
1573        fs::create_dir_all(&pkg_dir).unwrap();
1574
1575        let pkg_content = r#"
1576import pytest
1577
1578@pytest.fixture
1579def should_not_be_found():
1580    return "no entry_points.txt"
1581"#;
1582        fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1583
1584        // Create dist-info WITHOUT entry_points.txt file
1585        let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1586        fs::create_dir_all(&dist_info).unwrap();
1587        // Don't create entry_points.txt
1588
1589        // Scan and verify
1590        let db = FixtureDatabase::new();
1591        db.scan_pytest_plugins(site_packages);
1592
1593        assert!(
1594            !db.definitions.contains_key("should_not_be_found"),
1595            "should_not_be_found should NOT be discovered (no entry_points.txt)"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_entry_point_discovery_egg_info() {
1601        let temp = tempdir().unwrap();
1602        let site_packages = temp.path();
1603
1604        // Create a package
1605        let pkg_dir = site_packages.join("legacy_plugin");
1606        fs::create_dir_all(&pkg_dir).unwrap();
1607        fs::write(
1608            pkg_dir.join("__init__.py"),
1609            r#"
1610import pytest
1611
1612@pytest.fixture
1613def legacy_plugin_fixture():
1614    return "from egg-info"
1615"#,
1616        )
1617        .unwrap();
1618
1619        // Create egg-info with entry points
1620        let egg_info = site_packages.join("legacy_plugin-1.0.0.egg-info");
1621        fs::create_dir_all(&egg_info).unwrap();
1622        let entry_points = "[pytest11]\nlegacy_plugin = legacy_plugin\n";
1623        fs::write(egg_info.join("entry_points.txt"), entry_points).unwrap();
1624
1625        // Scan and verify
1626        let db = FixtureDatabase::new();
1627        db.scan_pytest_plugins(site_packages);
1628
1629        assert!(
1630            db.definitions.contains_key("legacy_plugin_fixture"),
1631            "legacy_plugin_fixture should be discovered"
1632        );
1633    }
1634
1635    #[test]
1636    fn test_entry_point_discovery_multiple_plugins() {
1637        let temp = tempdir().unwrap();
1638        let site_packages = temp.path();
1639
1640        // Create first plugin
1641        let plugin1_dir = site_packages.join("plugin_one");
1642        fs::create_dir_all(&plugin1_dir).unwrap();
1643        fs::write(
1644            plugin1_dir.join("__init__.py"),
1645            r#"
1646import pytest
1647
1648@pytest.fixture
1649def fixture_from_plugin_one():
1650    return 1
1651"#,
1652        )
1653        .unwrap();
1654
1655        let dist_info1 = site_packages.join("plugin_one-1.0.0.dist-info");
1656        fs::create_dir_all(&dist_info1).unwrap();
1657        fs::write(
1658            dist_info1.join("entry_points.txt"),
1659            "[pytest11]\nplugin_one = plugin_one\n",
1660        )
1661        .unwrap();
1662
1663        // Create second plugin
1664        let plugin2_dir = site_packages.join("plugin_two");
1665        fs::create_dir_all(&plugin2_dir).unwrap();
1666        fs::write(
1667            plugin2_dir.join("__init__.py"),
1668            r#"
1669import pytest
1670
1671@pytest.fixture
1672def fixture_from_plugin_two():
1673    return 2
1674"#,
1675        )
1676        .unwrap();
1677
1678        let dist_info2 = site_packages.join("plugin_two-2.0.0.dist-info");
1679        fs::create_dir_all(&dist_info2).unwrap();
1680        fs::write(
1681            dist_info2.join("entry_points.txt"),
1682            "[pytest11]\nplugin_two = plugin_two\n",
1683        )
1684        .unwrap();
1685
1686        // Scan and verify both are discovered
1687        let db = FixtureDatabase::new();
1688        db.scan_pytest_plugins(site_packages);
1689
1690        assert!(
1691            db.definitions.contains_key("fixture_from_plugin_one"),
1692            "fixture_from_plugin_one should be discovered"
1693        );
1694        assert!(
1695            db.definitions.contains_key("fixture_from_plugin_two"),
1696            "fixture_from_plugin_two should be discovered"
1697        );
1698    }
1699
1700    #[test]
1701    fn test_entry_point_discovery_multiple_entries_in_one_package() {
1702        let temp = tempdir().unwrap();
1703        let site_packages = temp.path();
1704
1705        // Create a package with multiple plugin modules
1706        let plugin_dir = site_packages.join("multi_plugin");
1707        fs::create_dir_all(&plugin_dir).unwrap();
1708        fs::write(plugin_dir.join("__init__.py"), "").unwrap();
1709
1710        fs::write(
1711            plugin_dir.join("fixtures_a.py"),
1712            r#"
1713import pytest
1714
1715@pytest.fixture
1716def fixture_a():
1717    return "A"
1718"#,
1719        )
1720        .unwrap();
1721
1722        fs::write(
1723            plugin_dir.join("fixtures_b.py"),
1724            r#"
1725import pytest
1726
1727@pytest.fixture
1728def fixture_b():
1729    return "B"
1730"#,
1731        )
1732        .unwrap();
1733
1734        // Create dist-info with multiple pytest11 entries
1735        let dist_info = site_packages.join("multi_plugin-1.0.0.dist-info");
1736        fs::create_dir_all(&dist_info).unwrap();
1737        fs::write(
1738            dist_info.join("entry_points.txt"),
1739            r#"[pytest11]
1740fixtures_a = multi_plugin.fixtures_a
1741fixtures_b = multi_plugin.fixtures_b
1742"#,
1743        )
1744        .unwrap();
1745
1746        // Scan and verify both modules are scanned
1747        let db = FixtureDatabase::new();
1748        db.scan_pytest_plugins(site_packages);
1749
1750        assert!(
1751            db.definitions.contains_key("fixture_a"),
1752            "fixture_a should be discovered"
1753        );
1754        assert!(
1755            db.definitions.contains_key("fixture_b"),
1756            "fixture_b should be discovered"
1757        );
1758    }
1759
1760    #[test]
1761    fn test_pytest_internal_fixtures_scanned() {
1762        let temp = tempdir().unwrap();
1763        let site_packages = temp.path();
1764
1765        // Create mock _pytest directory (pytest's internal package)
1766        let pytest_internal = site_packages.join("_pytest");
1767        fs::create_dir_all(&pytest_internal).unwrap();
1768
1769        let internal_fixtures = r#"
1770import pytest
1771
1772@pytest.fixture
1773def tmp_path():
1774    """Pytest's built-in tmp_path fixture."""
1775    pass
1776
1777@pytest.fixture
1778def capsys():
1779    """Pytest's built-in capsys fixture."""
1780    pass
1781"#;
1782        fs::write(pytest_internal.join("fixtures.py"), internal_fixtures).unwrap();
1783
1784        // Scan and verify internal fixtures are discovered
1785        let db = FixtureDatabase::new();
1786        db.scan_pytest_plugins(site_packages);
1787
1788        // Note: We're checking that _pytest is scanned as a special case
1789        // even without entry points
1790        assert!(
1791            db.definitions.contains_key("tmp_path"),
1792            "tmp_path should be discovered from _pytest"
1793        );
1794        assert!(
1795            db.definitions.contains_key("capsys"),
1796            "capsys should be discovered from _pytest"
1797        );
1798    }
1799
1800    #[test]
1801    fn test_extract_package_name_from_dist_info() {
1802        assert_eq!(
1803            FixtureDatabase::extract_package_name_from_dist_info("mypackage-1.0.0.dist-info"),
1804            Some(("mypackage".to_string(), "mypackage".to_string()))
1805        );
1806        assert_eq!(
1807            FixtureDatabase::extract_package_name_from_dist_info("my-package-1.0.0.dist-info"),
1808            Some(("my-package".to_string(), "my_package".to_string()))
1809        );
1810        assert_eq!(
1811            FixtureDatabase::extract_package_name_from_dist_info("My.Package-2.3.4.dist-info"),
1812            Some(("My.Package".to_string(), "my_package".to_string()))
1813        );
1814        assert_eq!(
1815            FixtureDatabase::extract_package_name_from_dist_info("pytest_mock-3.12.0.dist-info"),
1816            Some(("pytest_mock".to_string(), "pytest_mock".to_string()))
1817        );
1818        assert_eq!(
1819            FixtureDatabase::extract_package_name_from_dist_info("mypackage-0.1.0.egg-info"),
1820            Some(("mypackage".to_string(), "mypackage".to_string()))
1821        );
1822        // Edge case: no version
1823        assert_eq!(
1824            FixtureDatabase::extract_package_name_from_dist_info("mypackage.dist-info"),
1825            Some(("mypackage".to_string(), "mypackage".to_string()))
1826        );
1827    }
1828
1829    #[test]
1830    fn test_discover_editable_installs() {
1831        let temp = tempdir().unwrap();
1832        let site_packages = temp.path();
1833
1834        // Create a source root for the editable package
1835        let source_root = tempdir().unwrap();
1836        let pkg_dir = source_root.path().join("mypackage");
1837        fs::create_dir_all(&pkg_dir).unwrap();
1838        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1839
1840        // Create dist-info with direct_url.json indicating editable
1841        let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
1842        fs::create_dir_all(&dist_info).unwrap();
1843
1844        let direct_url = serde_json::json!({
1845            "url": format!("file://{}", source_root.path().display()),
1846            "dir_info": {
1847                "editable": true
1848            }
1849        });
1850        fs::write(
1851            dist_info.join("direct_url.json"),
1852            serde_json::to_string(&direct_url).unwrap(),
1853        )
1854        .unwrap();
1855
1856        // Create a .pth file pointing to the source root
1857        let pth_content = format!("{}\n", source_root.path().display());
1858        fs::write(
1859            site_packages.join("__editable__.mypackage-0.1.0.pth"),
1860            &pth_content,
1861        )
1862        .unwrap();
1863
1864        let db = FixtureDatabase::new();
1865        db.discover_editable_installs(site_packages);
1866
1867        let installs = db.editable_install_roots.lock().unwrap();
1868        assert_eq!(installs.len(), 1, "Should discover one editable install");
1869        assert_eq!(installs[0].package_name, "mypackage");
1870        assert_eq!(
1871            installs[0].source_root,
1872            source_root.path().canonicalize().unwrap()
1873        );
1874    }
1875
1876    #[test]
1877    fn test_discover_editable_installs_pth_with_dashes() {
1878        let temp = tempdir().unwrap();
1879        let site_packages = temp.path();
1880
1881        // Create a source root
1882        let source_root = tempdir().unwrap();
1883        let pkg_dir = source_root.path().join("my_package");
1884        fs::create_dir_all(&pkg_dir).unwrap();
1885        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1886
1887        // dist-info uses dashes (PEP 427): my-package-0.1.0.dist-info
1888        let dist_info = site_packages.join("my-package-0.1.0.dist-info");
1889        fs::create_dir_all(&dist_info).unwrap();
1890        let direct_url = serde_json::json!({
1891            "url": format!("file://{}", source_root.path().display()),
1892            "dir_info": { "editable": true }
1893        });
1894        fs::write(
1895            dist_info.join("direct_url.json"),
1896            serde_json::to_string(&direct_url).unwrap(),
1897        )
1898        .unwrap();
1899
1900        // .pth file keeps dashes (matches pip's actual behavior)
1901        let pth_content = format!("{}\n", source_root.path().display());
1902        fs::write(
1903            site_packages.join("__editable__.my-package-0.1.0.pth"),
1904            &pth_content,
1905        )
1906        .unwrap();
1907
1908        let db = FixtureDatabase::new();
1909        db.discover_editable_installs(site_packages);
1910
1911        let installs = db.editable_install_roots.lock().unwrap();
1912        assert_eq!(
1913            installs.len(),
1914            1,
1915            "Should discover editable install from .pth with dashes"
1916        );
1917        assert_eq!(installs[0].package_name, "my_package");
1918        assert_eq!(
1919            installs[0].source_root,
1920            source_root.path().canonicalize().unwrap()
1921        );
1922    }
1923
1924    #[test]
1925    fn test_discover_editable_installs_pth_with_dots() {
1926        let temp = tempdir().unwrap();
1927        let site_packages = temp.path();
1928
1929        // Create a source root
1930        let source_root = tempdir().unwrap();
1931        fs::create_dir_all(source_root.path().join("my_package")).unwrap();
1932        fs::write(source_root.path().join("my_package/__init__.py"), "").unwrap();
1933
1934        // dist-info uses dots: My.Package-1.0.0.dist-info
1935        let dist_info = site_packages.join("My.Package-1.0.0.dist-info");
1936        fs::create_dir_all(&dist_info).unwrap();
1937        let direct_url = serde_json::json!({
1938            "url": format!("file://{}", source_root.path().display()),
1939            "dir_info": { "editable": true }
1940        });
1941        fs::write(
1942            dist_info.join("direct_url.json"),
1943            serde_json::to_string(&direct_url).unwrap(),
1944        )
1945        .unwrap();
1946
1947        // .pth file keeps dots
1948        let pth_content = format!("{}\n", source_root.path().display());
1949        fs::write(
1950            site_packages.join("__editable__.My.Package-1.0.0.pth"),
1951            &pth_content,
1952        )
1953        .unwrap();
1954
1955        let db = FixtureDatabase::new();
1956        db.discover_editable_installs(site_packages);
1957
1958        let installs = db.editable_install_roots.lock().unwrap();
1959        assert_eq!(
1960            installs.len(),
1961            1,
1962            "Should discover editable install from .pth with dots"
1963        );
1964        assert_eq!(installs[0].package_name, "my_package");
1965        assert_eq!(
1966            installs[0].source_root,
1967            source_root.path().canonicalize().unwrap()
1968        );
1969    }
1970
1971    #[test]
1972    fn test_discover_editable_installs_dedup_on_rescan() {
1973        let temp = tempdir().unwrap();
1974        let site_packages = temp.path();
1975
1976        let source_root = tempdir().unwrap();
1977        fs::create_dir_all(source_root.path().join("pkg")).unwrap();
1978        fs::write(source_root.path().join("pkg/__init__.py"), "").unwrap();
1979
1980        let dist_info = site_packages.join("pkg-0.1.0.dist-info");
1981        fs::create_dir_all(&dist_info).unwrap();
1982        let direct_url = serde_json::json!({
1983            "url": format!("file://{}", source_root.path().display()),
1984            "dir_info": { "editable": true }
1985        });
1986        fs::write(
1987            dist_info.join("direct_url.json"),
1988            serde_json::to_string(&direct_url).unwrap(),
1989        )
1990        .unwrap();
1991
1992        let pth_content = format!("{}\n", source_root.path().display());
1993        fs::write(site_packages.join("pkg.pth"), &pth_content).unwrap();
1994
1995        let db = FixtureDatabase::new();
1996
1997        // Scan twice
1998        db.discover_editable_installs(site_packages);
1999        db.discover_editable_installs(site_packages);
2000
2001        let installs = db.editable_install_roots.lock().unwrap();
2002        assert_eq!(
2003            installs.len(),
2004            1,
2005            "Re-scanning should not produce duplicates"
2006        );
2007    }
2008
2009    #[test]
2010    fn test_editable_install_entry_point_resolution() {
2011        let temp = tempdir().unwrap();
2012        let site_packages = temp.path();
2013
2014        // Create a source root with a plugin module
2015        let source_root = tempdir().unwrap();
2016        let pkg_dir = source_root.path().join("mypackage");
2017        fs::create_dir_all(&pkg_dir).unwrap();
2018
2019        let plugin_content = r#"
2020import pytest
2021
2022@pytest.fixture
2023def editable_fixture():
2024    return "from editable install"
2025"#;
2026        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2027        fs::write(pkg_dir.join("plugin.py"), plugin_content).unwrap();
2028
2029        // Create dist-info with direct_url.json and entry_points.txt
2030        let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
2031        fs::create_dir_all(&dist_info).unwrap();
2032
2033        let direct_url = serde_json::json!({
2034            "url": format!("file://{}", source_root.path().display()),
2035            "dir_info": { "editable": true }
2036        });
2037        fs::write(
2038            dist_info.join("direct_url.json"),
2039            serde_json::to_string(&direct_url).unwrap(),
2040        )
2041        .unwrap();
2042
2043        let entry_points = "[pytest11]\nmypackage = mypackage.plugin\n";
2044        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
2045
2046        // Create .pth file
2047        let pth_content = format!("{}\n", source_root.path().display());
2048        fs::write(
2049            site_packages.join("__editable__.mypackage-0.1.0.pth"),
2050            &pth_content,
2051        )
2052        .unwrap();
2053
2054        let db = FixtureDatabase::new();
2055        db.scan_pytest_plugins(site_packages);
2056
2057        assert!(
2058            db.definitions.contains_key("editable_fixture"),
2059            "editable_fixture should be discovered via entry point fallback"
2060        );
2061    }
2062
2063    #[test]
2064    fn test_discover_editable_installs_namespace_package() {
2065        let temp = tempdir().unwrap();
2066        let site_packages = temp.path();
2067
2068        let source_root = tempdir().unwrap();
2069        let pkg_dir = source_root.path().join("namespace").join("pkg");
2070        fs::create_dir_all(&pkg_dir).unwrap();
2071        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2072
2073        let dist_info = site_packages.join("namespace.pkg-1.0.0.dist-info");
2074        fs::create_dir_all(&dist_info).unwrap();
2075        let direct_url = serde_json::json!({
2076            "url": format!("file://{}", source_root.path().display()),
2077            "dir_info": { "editable": true }
2078        });
2079        fs::write(
2080            dist_info.join("direct_url.json"),
2081            serde_json::to_string(&direct_url).unwrap(),
2082        )
2083        .unwrap();
2084
2085        let pth_content = format!("{}\n", source_root.path().display());
2086        fs::write(
2087            site_packages.join("__editable__.namespace.pkg-1.0.0.pth"),
2088            &pth_content,
2089        )
2090        .unwrap();
2091
2092        let db = FixtureDatabase::new();
2093        db.discover_editable_installs(site_packages);
2094
2095        let installs = db.editable_install_roots.lock().unwrap();
2096        assert_eq!(
2097            installs.len(),
2098            1,
2099            "Should discover namespace editable install"
2100        );
2101        assert_eq!(installs[0].package_name, "namespace_pkg");
2102        assert_eq!(installs[0].raw_package_name, "namespace.pkg");
2103        assert_eq!(
2104            installs[0].source_root,
2105            source_root.path().canonicalize().unwrap()
2106        );
2107    }
2108
2109    #[test]
2110    fn test_pth_prefix_matching_no_false_positive() {
2111        // "foo" candidate should NOT match "foo-bar.pth" (different package)
2112        let temp = tempdir().unwrap();
2113        let site_packages = temp.path();
2114
2115        let source_root_foo = tempdir().unwrap();
2116        fs::create_dir_all(source_root_foo.path()).unwrap();
2117
2118        let source_root_foobar = tempdir().unwrap();
2119        fs::create_dir_all(source_root_foobar.path()).unwrap();
2120
2121        // Create foo-bar.pth pointing to foobar source
2122        fs::write(
2123            site_packages.join("foo-bar.pth"),
2124            format!("{}\n", source_root_foobar.path().display()),
2125        )
2126        .unwrap();
2127
2128        let pth_index = FixtureDatabase::build_pth_index(site_packages);
2129
2130        // "foo" should NOT match "foo-bar" (different package, not a version suffix)
2131        let result =
2132            FixtureDatabase::find_editable_pth_source_root(&pth_index, "foo", "foo", site_packages);
2133        assert!(
2134            result.is_none(),
2135            "foo should not match foo-bar.pth (different package)"
2136        );
2137
2138        // "foo-bar" exact match should work
2139        let result = FixtureDatabase::find_editable_pth_source_root(
2140            &pth_index,
2141            "foo-bar",
2142            "foo_bar",
2143            site_packages,
2144        );
2145        assert!(result.is_some(), "foo-bar should match foo-bar.pth exactly");
2146    }
2147
2148    #[test]
2149    fn test_transitive_plugin_status_via_pytest_plugins() {
2150        let workspace = tempdir().unwrap();
2151        let workspace_canonical = workspace.path().canonicalize().unwrap();
2152
2153        let db = FixtureDatabase::new();
2154        *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2155
2156        // Create the package structure:
2157        //   mypackage/
2158        //     __init__.py
2159        //     plugin.py      <- entry point plugin, has pytest_plugins = ["mypackage.helpers"]
2160        //     helpers.py     <- imported by plugin.py, defines a fixture
2161        let pkg_dir = workspace_canonical.join("mypackage");
2162        fs::create_dir_all(&pkg_dir).unwrap();
2163        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2164
2165        let plugin_content = r#"
2166import pytest
2167
2168pytest_plugins = ["mypackage.helpers"]
2169
2170@pytest.fixture
2171def direct_plugin_fixture():
2172    return "from plugin.py"
2173"#;
2174        let plugin_file = pkg_dir.join("plugin.py");
2175        fs::write(&plugin_file, plugin_content).unwrap();
2176
2177        let helpers_content = r#"
2178import pytest
2179
2180@pytest.fixture
2181def transitive_plugin_fixture():
2182    return "from helpers.py, imported by plugin.py"
2183"#;
2184        let helpers_file = pkg_dir.join("helpers.py");
2185        fs::write(&helpers_file, helpers_content).unwrap();
2186
2187        // Mark plugin.py as a plugin file (simulating scan_single_plugin_file)
2188        let canonical_plugin = plugin_file.canonicalize().unwrap();
2189        db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2190
2191        // Analyze plugin.py (Phase 3 equivalent)
2192        db.analyze_file(canonical_plugin.clone(), plugin_content);
2193
2194        // Run Phase 4 equivalent: scan_imported_fixture_modules
2195        // This should discover helpers.py via pytest_plugins and propagate plugin status
2196        db.scan_imported_fixture_modules(&workspace_canonical);
2197
2198        // The direct fixture should be is_plugin
2199        let direct_is_plugin = db
2200            .definitions
2201            .get("direct_plugin_fixture")
2202            .map(|defs| defs[0].is_plugin);
2203        assert_eq!(
2204            direct_is_plugin,
2205            Some(true),
2206            "direct_plugin_fixture should have is_plugin=true"
2207        );
2208
2209        // The transitive fixture (from helpers.py) should ALSO be is_plugin
2210        let transitive_is_plugin = db
2211            .definitions
2212            .get("transitive_plugin_fixture")
2213            .map(|defs| defs[0].is_plugin);
2214        assert_eq!(
2215            transitive_is_plugin,
2216            Some(true),
2217            "transitive_plugin_fixture should have is_plugin=true (propagated from plugin.py)"
2218        );
2219
2220        let transitive_is_third_party = db
2221            .definitions
2222            .get("transitive_plugin_fixture")
2223            .map(|defs| defs[0].is_third_party);
2224        assert_eq!(
2225            transitive_is_third_party,
2226            Some(false),
2227            "transitive_plugin_fixture should NOT be third-party (workspace-local)"
2228        );
2229
2230        // Both should be available from a test file
2231        let tests_dir = workspace_canonical.join("tests");
2232        fs::create_dir_all(&tests_dir).unwrap();
2233        let test_file = tests_dir.join("test_transitive.py");
2234        let test_content = "def test_transitive(): pass\n";
2235        fs::write(&test_file, test_content).unwrap();
2236        let canonical_test = test_file.canonicalize().unwrap();
2237        db.analyze_file(canonical_test.clone(), test_content);
2238
2239        let available = db.get_available_fixtures(&canonical_test);
2240        let available_names: Vec<&str> = available.iter().map(|d| d.name.as_str()).collect();
2241        assert!(
2242            available_names.contains(&"direct_plugin_fixture"),
2243            "direct_plugin_fixture should be available. Got: {:?}",
2244            available_names
2245        );
2246        assert!(
2247            available_names.contains(&"transitive_plugin_fixture"),
2248            "transitive_plugin_fixture should be available (transitively via plugin). Got: {:?}",
2249            available_names
2250        );
2251    }
2252
2253    #[test]
2254    fn test_transitive_plugin_status_via_star_import() {
2255        let workspace = tempdir().unwrap();
2256        let workspace_canonical = workspace.path().canonicalize().unwrap();
2257
2258        let db = FixtureDatabase::new();
2259        *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2260
2261        // Create the package structure:
2262        //   mypackage/
2263        //     __init__.py
2264        //     plugin.py       <- entry point plugin, has `from .fixtures import *`
2265        //     fixtures.py     <- imported via star import, defines a fixture
2266        let pkg_dir = workspace_canonical.join("mypackage");
2267        fs::create_dir_all(&pkg_dir).unwrap();
2268        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2269
2270        let plugin_content = r#"
2271import pytest
2272from .fixtures import *
2273
2274@pytest.fixture
2275def star_direct_fixture():
2276    return "from plugin.py"
2277"#;
2278        let plugin_file = pkg_dir.join("plugin.py");
2279        fs::write(&plugin_file, plugin_content).unwrap();
2280
2281        let fixtures_content = r#"
2282import pytest
2283
2284@pytest.fixture
2285def star_imported_fixture():
2286    return "from fixtures.py, star-imported by plugin.py"
2287"#;
2288        let fixtures_file = pkg_dir.join("fixtures.py");
2289        fs::write(&fixtures_file, fixtures_content).unwrap();
2290
2291        // Mark plugin.py as a plugin file
2292        let canonical_plugin = plugin_file.canonicalize().unwrap();
2293        db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2294
2295        // Analyze plugin.py
2296        db.analyze_file(canonical_plugin.clone(), plugin_content);
2297
2298        // Run import scanning (Phase 4)
2299        db.scan_imported_fixture_modules(&workspace_canonical);
2300
2301        // The star-imported fixture should also be marked is_plugin
2302        let star_is_plugin = db
2303            .definitions
2304            .get("star_imported_fixture")
2305            .map(|defs| defs[0].is_plugin);
2306        assert_eq!(
2307            star_is_plugin,
2308            Some(true),
2309            "star_imported_fixture should have is_plugin=true (propagated from plugin.py via star import)"
2310        );
2311
2312        // Both fixtures should be available from a test file
2313        let test_file = workspace_canonical.join("test_star.py");
2314        let test_content = "def test_star(): pass\n";
2315        fs::write(&test_file, test_content).unwrap();
2316        let canonical_test = test_file.canonicalize().unwrap();
2317        db.analyze_file(canonical_test.clone(), test_content);
2318
2319        let available = db.get_available_fixtures(&canonical_test);
2320        let available_names: Vec<&str> = available.iter().map(|d| d.name.as_str()).collect();
2321        assert!(
2322            available_names.contains(&"star_direct_fixture"),
2323            "star_direct_fixture should be available. Got: {:?}",
2324            available_names
2325        );
2326        assert!(
2327            available_names.contains(&"star_imported_fixture"),
2328            "star_imported_fixture should be available (transitively via star import). Got: {:?}",
2329            available_names
2330        );
2331    }
2332
2333    #[test]
2334    fn test_non_plugin_conftest_import_not_marked_as_plugin() {
2335        let workspace = tempdir().unwrap();
2336        let workspace_canonical = workspace.path().canonicalize().unwrap();
2337
2338        let db = FixtureDatabase::new();
2339        *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2340
2341        // Create a conftest.py that imports from a helpers module
2342        // This is NOT a plugin file — conftest imports should NOT propagate is_plugin
2343        let conftest_content = r#"
2344import pytest
2345from .helpers import *
2346"#;
2347        let conftest_file = workspace_canonical.join("conftest.py");
2348        fs::write(&conftest_file, conftest_content).unwrap();
2349
2350        let helpers_content = r#"
2351import pytest
2352
2353@pytest.fixture
2354def conftest_helper_fixture():
2355    return "from helpers, imported by conftest"
2356"#;
2357        let helpers_file = workspace_canonical.join("helpers.py");
2358        fs::write(&helpers_file, helpers_content).unwrap();
2359
2360        let canonical_conftest = conftest_file.canonicalize().unwrap();
2361        // Do NOT insert conftest into plugin_fixture_files — it's a regular conftest
2362        db.analyze_file(canonical_conftest.clone(), conftest_content);
2363
2364        db.scan_imported_fixture_modules(&workspace_canonical);
2365
2366        // The helper fixture should NOT be marked as is_plugin
2367        let is_plugin = db
2368            .definitions
2369            .get("conftest_helper_fixture")
2370            .map(|defs| defs[0].is_plugin);
2371        if let Some(is_plugin) = is_plugin {
2372            assert!(
2373                !is_plugin,
2374                "Fixture imported by conftest (not a plugin) should NOT be marked is_plugin"
2375            );
2376        }
2377        // (It's OK if the fixture wasn't found at all — the import resolution
2378        // may not work for bare relative imports without a package. The key assertion
2379        // is that if found, it must not be is_plugin.)
2380    }
2381
2382    #[test]
2383    fn test_already_cached_module_marked_plugin_via_pytest_plugins() {
2384        // Scenario: a module is analyzed during Phase 1/2 (e.g. it was picked
2385        // up as a conftest or test file, or pre-analyzed).  Later, a plugin
2386        // file references it via `pytest_plugins`.  The module should be
2387        // re-analyzed so its fixtures get `is_plugin=true`.
2388        let workspace = tempdir().unwrap();
2389        let workspace_canonical = workspace.path().canonicalize().unwrap();
2390
2391        let db = FixtureDatabase::new();
2392        *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2393
2394        // Create the package structure:
2395        //   mypackage/
2396        //     __init__.py
2397        //     plugin.py      <- entry point plugin, has pytest_plugins = ["mypackage.helpers"]
2398        //     helpers.py     <- already cached before plugin scan
2399        let pkg_dir = workspace_canonical.join("mypackage");
2400        fs::create_dir_all(&pkg_dir).unwrap();
2401        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2402
2403        let helpers_content = r#"
2404import pytest
2405
2406@pytest.fixture
2407def pre_cached_fixture():
2408    return "I was analyzed before the plugin scan"
2409"#;
2410        let helpers_file = pkg_dir.join("helpers.py");
2411        fs::write(&helpers_file, helpers_content).unwrap();
2412        let canonical_helpers = helpers_file.canonicalize().unwrap();
2413
2414        // Pre-analyze helpers.py (simulating Phase 1/2 picking it up)
2415        db.analyze_file(canonical_helpers.clone(), helpers_content);
2416
2417        // At this point the fixture should exist but NOT be a plugin
2418        let before = db
2419            .definitions
2420            .get("pre_cached_fixture")
2421            .map(|defs| defs[0].is_plugin);
2422        assert_eq!(
2423            before,
2424            Some(false),
2425            "pre_cached_fixture should initially have is_plugin=false"
2426        );
2427
2428        // Now create plugin.py that references helpers via pytest_plugins
2429        let plugin_content = r#"
2430import pytest
2431
2432pytest_plugins = ["mypackage.helpers"]
2433
2434@pytest.fixture
2435def direct_fixture():
2436    return "from plugin.py"
2437"#;
2438        let plugin_file = pkg_dir.join("plugin.py");
2439        fs::write(&plugin_file, plugin_content).unwrap();
2440        let canonical_plugin = plugin_file.canonicalize().unwrap();
2441
2442        // Mark plugin.py as a plugin file and analyze it
2443        db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2444        db.analyze_file(canonical_plugin.clone(), plugin_content);
2445
2446        // Run Phase 4: should discover helpers.py via pytest_plugins,
2447        // mark it as plugin, and re-analyze so is_plugin gets refreshed
2448        db.scan_imported_fixture_modules(&workspace_canonical);
2449
2450        // The pre-cached fixture should now have is_plugin=true
2451        let after = db
2452            .definitions
2453            .get("pre_cached_fixture")
2454            .map(|defs| defs[0].is_plugin);
2455        assert_eq!(
2456            after,
2457            Some(true),
2458            "pre_cached_fixture should have is_plugin=true after re-analysis \
2459             (was already cached when plugin declared pytest_plugins)"
2460        );
2461    }
2462
2463    #[test]
2464    fn test_already_cached_module_marked_plugin_via_star_import() {
2465        // Scenario: a module is analyzed during Phase 1/2.  Later, a plugin
2466        // file star-imports it (`from .fixtures import *`).  The module should
2467        // be re-analyzed so its fixtures get `is_plugin=true`.
2468        let workspace = tempdir().unwrap();
2469        let workspace_canonical = workspace.path().canonicalize().unwrap();
2470
2471        let db = FixtureDatabase::new();
2472        *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2473
2474        let pkg_dir = workspace_canonical.join("mypkg");
2475        fs::create_dir_all(&pkg_dir).unwrap();
2476        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2477
2478        let fixtures_content = r#"
2479import pytest
2480
2481@pytest.fixture
2482def star_pre_cached():
2483    return "cached before plugin scan"
2484"#;
2485        let fixtures_file = pkg_dir.join("fixtures.py");
2486        fs::write(&fixtures_file, fixtures_content).unwrap();
2487        let canonical_fixtures = fixtures_file.canonicalize().unwrap();
2488
2489        // Pre-analyze fixtures.py
2490        db.analyze_file(canonical_fixtures.clone(), fixtures_content);
2491
2492        let before = db
2493            .definitions
2494            .get("star_pre_cached")
2495            .map(|defs| defs[0].is_plugin);
2496        assert_eq!(before, Some(false), "should start as is_plugin=false");
2497
2498        // Create plugin.py that star-imports from fixtures
2499        let plugin_content = r#"
2500import pytest
2501from .fixtures import *
2502
2503@pytest.fixture
2504def plugin_direct():
2505    return "direct"
2506"#;
2507        let plugin_file = pkg_dir.join("plugin.py");
2508        fs::write(&plugin_file, plugin_content).unwrap();
2509        let canonical_plugin = plugin_file.canonicalize().unwrap();
2510
2511        db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2512        db.analyze_file(canonical_plugin.clone(), plugin_content);
2513
2514        db.scan_imported_fixture_modules(&workspace_canonical);
2515
2516        // After re-analysis the star-imported module's fixtures should be is_plugin
2517        let after = db
2518            .definitions
2519            .get("star_pre_cached")
2520            .map(|defs| defs[0].is_plugin);
2521        assert_eq!(
2522            after,
2523            Some(true),
2524            "star_pre_cached should have is_plugin=true after re-analysis \
2525             (module was star-imported by a plugin file)"
2526        );
2527    }
2528
2529    #[test]
2530    fn test_explicit_import_does_not_propagate_plugin_status() {
2531        // Scenario: a plugin file does `from .utils import some_helper`.
2532        // The entire utils module should NOT be marked as a plugin — only
2533        // star imports and pytest_plugins should propagate plugin status.
2534        let workspace = tempdir().unwrap();
2535        let workspace_canonical = workspace.path().canonicalize().unwrap();
2536
2537        let db = FixtureDatabase::new();
2538        *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2539
2540        let pkg_dir = workspace_canonical.join("explpkg");
2541        fs::create_dir_all(&pkg_dir).unwrap();
2542        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2543
2544        let utils_content = r#"
2545import pytest
2546
2547def helper_function():
2548    return 42
2549
2550@pytest.fixture
2551def utils_fixture():
2552    return "from utils"
2553"#;
2554        let utils_file = pkg_dir.join("utils.py");
2555        fs::write(&utils_file, utils_content).unwrap();
2556
2557        // Create plugin.py that does an explicit import (not star, not pytest_plugins)
2558        let plugin_content = r#"
2559import pytest
2560from .utils import helper_function
2561
2562@pytest.fixture
2563def explicit_plugin_fixture():
2564    return helper_function()
2565"#;
2566        let plugin_file = pkg_dir.join("plugin.py");
2567        fs::write(&plugin_file, plugin_content).unwrap();
2568        let canonical_plugin = plugin_file.canonicalize().unwrap();
2569
2570        db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2571        db.analyze_file(canonical_plugin.clone(), plugin_content);
2572
2573        db.scan_imported_fixture_modules(&workspace_canonical);
2574
2575        // The plugin's own fixture should be is_plugin
2576        let plugin_fixture = db
2577            .definitions
2578            .get("explicit_plugin_fixture")
2579            .map(|defs| defs[0].is_plugin);
2580        assert_eq!(
2581            plugin_fixture,
2582            Some(true),
2583            "explicit_plugin_fixture should have is_plugin=true"
2584        );
2585
2586        // The utils module's fixture should NOT be marked as is_plugin
2587        // because the import was explicit (`from .utils import helper_function`),
2588        // not a star import or pytest_plugins reference.
2589        let utils_is_plugin = db
2590            .definitions
2591            .get("utils_fixture")
2592            .map(|defs| defs[0].is_plugin);
2593        if let Some(is_plugin) = utils_is_plugin {
2594            assert!(
2595                !is_plugin,
2596                "utils_fixture should NOT be is_plugin — the plugin only did \
2597                 an explicit import of helper_function, not a star import"
2598            );
2599        }
2600        // (It's also acceptable if utils_fixture wasn't discovered at all,
2601        // since utils.py isn't a conftest/test file. The key assertion is
2602        // that if found, it must not be is_plugin.)
2603    }
2604}