Skip to main content

pytest_language_server/fixtures/
scanner.rs

1//! Workspace and virtual environment scanning for fixture definitions.
2
3use super::FixtureDatabase;
4use glob::Pattern;
5use rayon::prelude::*;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use tracing::{debug, error, info, warn};
9use walkdir::WalkDir;
10
11/// A pytest11 entry point from a dist-info package.
12#[derive(Debug, Clone)]
13pub(crate) struct Pytest11EntryPoint {
14    /// The plugin name (left side of =)
15    pub(crate) name: String,
16    /// The Python module path (right side of =)
17    pub(crate) module_path: String,
18}
19
20impl FixtureDatabase {
21    /// Directories that should be skipped during workspace scanning.
22    /// These are typically large directories that don't contain test files.
23    const SKIP_DIRECTORIES: &'static [&'static str] = &[
24        // Version control
25        ".git",
26        ".hg",
27        ".svn",
28        // Virtual environments (scanned separately for plugins)
29        ".venv",
30        "venv",
31        "env",
32        ".env",
33        // Python caches and build artifacts
34        "__pycache__",
35        ".pytest_cache",
36        ".mypy_cache",
37        ".ruff_cache",
38        ".tox",
39        ".nox",
40        "build",
41        "dist",
42        ".eggs",
43        // JavaScript/Node
44        "node_modules",
45        "bower_components",
46        // Rust (for mixed projects)
47        "target",
48        // IDE and editor directories
49        ".idea",
50        ".vscode",
51        // Other common large directories
52        ".cache",
53        ".local",
54        "vendor",
55        "site-packages",
56    ];
57
58    /// Check if a directory should be skipped during scanning.
59    pub(crate) fn should_skip_directory(dir_name: &str) -> bool {
60        // Check exact matches
61        if Self::SKIP_DIRECTORIES.contains(&dir_name) {
62            return true;
63        }
64        // Also skip directories ending with .egg-info
65        if dir_name.ends_with(".egg-info") {
66            return true;
67        }
68        false
69    }
70
71    /// Scan a workspace directory for test files and conftest.py files.
72    /// Optionally accepts exclude patterns from configuration.
73    pub fn scan_workspace(&self, root_path: &Path) {
74        self.scan_workspace_with_excludes(root_path, &[]);
75    }
76
77    /// Scan a workspace directory with custom exclude patterns.
78    pub fn scan_workspace_with_excludes(&self, root_path: &Path, exclude_patterns: &[Pattern]) {
79        info!("Scanning workspace: {:?}", root_path);
80
81        // Store workspace root for editable install third-party detection
82        *self.workspace_root.lock().unwrap() = Some(
83            root_path
84                .canonicalize()
85                .unwrap_or_else(|_| root_path.to_path_buf()),
86        );
87
88        // Defensive check: ensure the root path exists
89        if !root_path.exists() {
90            warn!(
91                "Workspace path does not exist, skipping scan: {:?}",
92                root_path
93            );
94            return;
95        }
96
97        // Phase 1: Collect all file paths (sequential, fast)
98        let mut files_to_process: Vec<std::path::PathBuf> = Vec::new();
99        let mut skipped_dirs = 0;
100
101        // Use WalkDir with filter to skip large/irrelevant directories
102        let walker = WalkDir::new(root_path).into_iter().filter_entry(|entry| {
103            // Allow files to pass through
104            if entry.file_type().is_file() {
105                return true;
106            }
107            // For directories, check if we should skip them
108            if let Some(dir_name) = entry.file_name().to_str() {
109                !Self::should_skip_directory(dir_name)
110            } else {
111                true
112            }
113        });
114
115        for entry in walker {
116            let entry = match entry {
117                Ok(e) => e,
118                Err(err) => {
119                    // Log directory traversal errors (permission denied, etc.)
120                    if err
121                        .io_error()
122                        .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
123                    {
124                        warn!(
125                            "Permission denied accessing path during workspace scan: {}",
126                            err
127                        );
128                    } else {
129                        debug!("Error during workspace scan: {}", err);
130                    }
131                    continue;
132                }
133            };
134
135            let path = entry.path();
136
137            // Skip files in filtered directories (shouldn't happen with filter_entry, but just in case)
138            if path.components().any(|c| {
139                c.as_os_str()
140                    .to_str()
141                    .is_some_and(Self::should_skip_directory)
142            }) {
143                skipped_dirs += 1;
144                continue;
145            }
146
147            // Skip files matching user-configured exclude patterns
148            // Patterns are matched against paths relative to workspace root
149            if !exclude_patterns.is_empty() {
150                if let Ok(relative_path) = path.strip_prefix(root_path) {
151                    let relative_str = relative_path.to_string_lossy();
152                    if exclude_patterns.iter().any(|p| p.matches(&relative_str)) {
153                        debug!("Skipping excluded path: {:?}", path);
154                        continue;
155                    }
156                }
157            }
158
159            // Look for conftest.py or test_*.py or *_test.py files
160            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
161                if filename == "conftest.py"
162                    || filename.starts_with("test_") && filename.ends_with(".py")
163                    || filename.ends_with("_test.py")
164                {
165                    files_to_process.push(path.to_path_buf());
166                }
167            }
168        }
169
170        if skipped_dirs > 0 {
171            debug!("Skipped {} entries in filtered directories", skipped_dirs);
172        }
173
174        let total_files = files_to_process.len();
175        info!("Found {} test/conftest files to process", total_files);
176
177        // Phase 2: Process files in parallel using rayon
178        // Use analyze_file_fresh since this is initial scan (no previous definitions to clean)
179        let error_count = AtomicUsize::new(0);
180        let permission_denied_count = AtomicUsize::new(0);
181
182        files_to_process.par_iter().for_each(|path| {
183            debug!("Found test/conftest file: {:?}", path);
184            match std::fs::read_to_string(path) {
185                Ok(content) => {
186                    self.analyze_file_fresh(path.clone(), &content);
187                }
188                Err(err) => {
189                    if err.kind() == std::io::ErrorKind::PermissionDenied {
190                        debug!("Permission denied reading file: {:?}", path);
191                        permission_denied_count.fetch_add(1, Ordering::Relaxed);
192                    } else {
193                        error!("Failed to read file {:?}: {}", path, err);
194                        error_count.fetch_add(1, Ordering::Relaxed);
195                    }
196                }
197            }
198        });
199
200        let errors = error_count.load(Ordering::Relaxed);
201        let permission_errors = permission_denied_count.load(Ordering::Relaxed);
202
203        if errors > 0 {
204            warn!("Workspace scan completed with {} read errors", errors);
205        }
206        if permission_errors > 0 {
207            warn!(
208                "Workspace scan: skipped {} files due to permission denied",
209                permission_errors
210            );
211        }
212
213        info!(
214            "Workspace scan complete. Processed {} files ({} permission denied, {} errors)",
215            total_files, permission_errors, errors
216        );
217
218        // Phase 3: Scan virtual environment for pytest plugins first
219        // (must happen before import scanning so venv plugin files are in file_cache)
220        self.scan_venv_fixtures(root_path);
221
222        // Phase 4: Scan modules imported by conftest.py and venv plugin files
223        // This ensures fixtures defined in separate modules (imported via star import
224        // or pytest_plugins variable) are discovered
225        self.scan_imported_fixture_modules(root_path);
226
227        info!("Total fixtures defined: {}", self.definitions.len());
228        info!("Total files with fixture usages: {}", self.usages.len());
229    }
230
231    /// Scan Python modules that are imported by conftest.py files.
232    /// This discovers fixtures defined in separate modules that are re-exported via star imports.
233    /// Handles transitive imports (A imports B, B imports C) by iteratively scanning until no new modules are found.
234    fn scan_imported_fixture_modules(&self, _root_path: &Path) {
235        use std::collections::HashSet;
236
237        info!("Scanning for imported fixture modules");
238
239        // Track all files we've already processed to find imports from
240        let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
241
242        // Start with conftest.py, test files, and venv plugin files
243        // (pytest_plugins can appear in any of these)
244        let site_packages_paths = self.site_packages_paths.lock().unwrap().clone();
245        let editable_roots: Vec<PathBuf> = self
246            .editable_install_roots
247            .lock()
248            .unwrap()
249            .iter()
250            .map(|e| e.source_root.clone())
251            .collect();
252        let mut files_to_check: Vec<std::path::PathBuf> = self
253            .file_cache
254            .iter()
255            .filter(|entry| {
256                let key = entry.key();
257                let is_conftest_or_test = key
258                    .file_name()
259                    .and_then(|n| n.to_str())
260                    .map(|n| {
261                        n == "conftest.py"
262                            || (n.starts_with("test_") && n.ends_with(".py"))
263                            || n.ends_with("_test.py")
264                    })
265                    .unwrap_or(false);
266                let is_venv_plugin = site_packages_paths.iter().any(|sp| key.starts_with(sp));
267                let is_editable_plugin = editable_roots.iter().any(|er| key.starts_with(er));
268                is_conftest_or_test || is_venv_plugin || is_editable_plugin
269            })
270            .map(|entry| entry.key().clone())
271            .collect();
272
273        if files_to_check.is_empty() {
274            debug!("No conftest/test/plugin files found, skipping import scan");
275            return;
276        }
277
278        info!(
279            "Starting import scan with {} conftest/test/plugin files",
280            files_to_check.len()
281        );
282
283        // Iteratively process files until no new modules are discovered
284        let mut iteration = 0;
285        while !files_to_check.is_empty() {
286            iteration += 1;
287            debug!(
288                "Import scan iteration {}: checking {} files",
289                iteration,
290                files_to_check.len()
291            );
292
293            let mut new_modules: HashSet<std::path::PathBuf> = HashSet::new();
294
295            for file_path in &files_to_check {
296                if processed_files.contains(file_path) {
297                    continue;
298                }
299                processed_files.insert(file_path.clone());
300
301                // Get the file content
302                let Some(content) = self.get_file_content(file_path) else {
303                    continue;
304                };
305
306                // Parse the AST
307                let Some(parsed) = self.get_parsed_ast(file_path, &content) else {
308                    continue;
309                };
310
311                let line_index = self.get_line_index(file_path, &content);
312
313                // Extract imports and pytest_plugins
314                if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
315                    let imports =
316                        self.extract_fixture_imports(&module.body, file_path, &line_index);
317
318                    for import in imports {
319                        if let Some(resolved_path) =
320                            self.resolve_module_to_file(&import.module_path, file_path)
321                        {
322                            let canonical = self.get_canonical_path(resolved_path);
323                            if !processed_files.contains(&canonical)
324                                && !self.file_cache.contains_key(&canonical)
325                            {
326                                new_modules.insert(canonical);
327                            }
328                        }
329                    }
330
331                    // Also extract pytest_plugins variable declarations
332                    let plugin_modules = self.extract_pytest_plugins(&module.body);
333                    for module_path in plugin_modules {
334                        if let Some(resolved_path) =
335                            self.resolve_module_to_file(&module_path, file_path)
336                        {
337                            let canonical = self.get_canonical_path(resolved_path);
338                            if !processed_files.contains(&canonical)
339                                && !self.file_cache.contains_key(&canonical)
340                            {
341                                new_modules.insert(canonical);
342                            }
343                        }
344                    }
345                }
346            }
347
348            if new_modules.is_empty() {
349                debug!("No new modules found in iteration {}", iteration);
350                break;
351            }
352
353            info!(
354                "Iteration {}: found {} new modules to analyze",
355                iteration,
356                new_modules.len()
357            );
358
359            // Analyze the new modules
360            for module_path in &new_modules {
361                if module_path.exists() {
362                    debug!("Analyzing imported module: {:?}", module_path);
363                    match std::fs::read_to_string(module_path) {
364                        Ok(content) => {
365                            self.analyze_file_fresh(module_path.clone(), &content);
366                        }
367                        Err(err) => {
368                            debug!("Failed to read imported module {:?}: {}", module_path, err);
369                        }
370                    }
371                }
372            }
373
374            // Next iteration will check the newly analyzed modules for their imports
375            files_to_check = new_modules.into_iter().collect();
376        }
377
378        info!(
379            "Imported fixture module scan complete after {} iterations",
380            iteration
381        );
382    }
383
384    /// Scan virtual environment for pytest plugin fixtures.
385    fn scan_venv_fixtures(&self, root_path: &Path) {
386        info!("Scanning for pytest plugins in virtual environment");
387
388        // Try to find virtual environment
389        let venv_paths = vec![
390            root_path.join(".venv"),
391            root_path.join("venv"),
392            root_path.join("env"),
393        ];
394
395        info!("Checking for venv in: {:?}", root_path);
396        for venv_path in &venv_paths {
397            debug!("Checking venv path: {:?}", venv_path);
398            if venv_path.exists() {
399                info!("Found virtual environment at: {:?}", venv_path);
400                self.scan_venv_site_packages(venv_path);
401                return;
402            } else {
403                debug!("  Does not exist: {:?}", venv_path);
404            }
405        }
406
407        // Also check for system-wide VIRTUAL_ENV
408        if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
409            info!("Found VIRTUAL_ENV environment variable: {}", venv);
410            let venv_path = std::path::PathBuf::from(venv);
411            if venv_path.exists() {
412                let venv_path = venv_path.canonicalize().unwrap_or(venv_path);
413                info!("Using VIRTUAL_ENV: {:?}", venv_path);
414                self.scan_venv_site_packages(&venv_path);
415                return;
416            } else {
417                warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
418            }
419        } else {
420            debug!("No VIRTUAL_ENV environment variable set");
421        }
422
423        warn!("No virtual environment found - third-party fixtures will not be available");
424    }
425
426    fn scan_venv_site_packages(&self, venv_path: &Path) {
427        info!("Scanning venv site-packages in: {:?}", venv_path);
428
429        // Find site-packages directory
430        let lib_path = venv_path.join("lib");
431        debug!("Checking lib path: {:?}", lib_path);
432
433        if lib_path.exists() {
434            // Look for python* directories
435            if let Ok(entries) = std::fs::read_dir(&lib_path) {
436                for entry in entries.flatten() {
437                    let path = entry.path();
438                    let dirname = path.file_name().unwrap_or_default().to_string_lossy();
439                    debug!("Found in lib: {:?}", dirname);
440
441                    if path.is_dir() && dirname.starts_with("python") {
442                        let site_packages = path.join("site-packages");
443                        debug!("Checking site-packages: {:?}", site_packages);
444
445                        if site_packages.exists() {
446                            let site_packages =
447                                site_packages.canonicalize().unwrap_or(site_packages);
448                            info!("Found site-packages: {:?}", site_packages);
449                            self.site_packages_paths
450                                .lock()
451                                .unwrap()
452                                .push(site_packages.clone());
453                            self.scan_pytest_plugins(&site_packages);
454                            return;
455                        }
456                    }
457                }
458            }
459        }
460
461        // Try Windows path
462        let windows_site_packages = venv_path.join("Lib/site-packages");
463        debug!("Checking Windows path: {:?}", windows_site_packages);
464        if windows_site_packages.exists() {
465            let windows_site_packages = windows_site_packages
466                .canonicalize()
467                .unwrap_or(windows_site_packages);
468            info!("Found site-packages (Windows): {:?}", windows_site_packages);
469            self.site_packages_paths
470                .lock()
471                .unwrap()
472                .push(windows_site_packages.clone());
473            self.scan_pytest_plugins(&windows_site_packages);
474            return;
475        }
476
477        warn!("Could not find site-packages in venv: {:?}", venv_path);
478    }
479
480    /// Parse `entry_points.txt` content and extract pytest11 entries.
481    ///
482    /// Returns all successfully parsed entries from the `[pytest11]` section.
483    /// Returns an empty vec if there is no `[pytest11]` section or no valid
484    /// `name = value` lines within that section. Malformed lines are ignored.
485    fn parse_pytest11_entry_points(content: &str) -> Vec<Pytest11EntryPoint> {
486        let mut results = Vec::new();
487        let mut in_pytest11_section = false;
488
489        for line in content.lines() {
490            let line = line.trim();
491
492            // Check for section headers
493            if line.starts_with('[') && line.ends_with(']') {
494                in_pytest11_section = line == "[pytest11]";
495                continue;
496            }
497
498            // Parse entries within pytest11 section
499            if in_pytest11_section && !line.is_empty() && !line.starts_with('#') {
500                if let Some((name, module_path)) = line.split_once('=') {
501                    results.push(Pytest11EntryPoint {
502                        name: name.trim().to_string(),
503                        module_path: module_path.trim().to_string(),
504                    });
505                }
506            }
507        }
508        results
509    }
510
511    /// Resolve a Python module path to a file system path within site-packages.
512    ///
513    /// Examples:
514    /// - "pytest_mock" → site_packages/pytest_mock/__init__.py or site_packages/pytest_mock.py
515    /// - "pytest_asyncio.plugin" → site_packages/pytest_asyncio/plugin.py
516    ///
517    /// Returns the path to a `.py` file (may be `__init__.py` for packages).
518    fn resolve_entry_point_module_to_path(
519        site_packages: &Path,
520        module_path: &str,
521    ) -> Option<PathBuf> {
522        // Strip any :attr suffix (e.g., "module:function" -> "module")
523        let module_path = module_path.split(':').next().unwrap_or(module_path);
524
525        // Split into components
526        let parts: Vec<&str> = module_path.split('.').collect();
527
528        if parts.is_empty() {
529            return None;
530        }
531
532        // Reject path traversal and null bytes in module path components
533        if parts
534            .iter()
535            .any(|p| p.contains("..") || p.contains('\0') || p.is_empty())
536        {
537            return None;
538        }
539
540        // Build the path from module components
541        let mut path = site_packages.to_path_buf();
542        for part in &parts {
543            path.push(part);
544        }
545
546        // Ensure resolved path stays within the base directory
547        let check_bounded = |candidate: &Path| -> Option<PathBuf> {
548            let canonical = candidate.canonicalize().ok()?;
549            let base_canonical = site_packages.canonicalize().ok()?;
550            if canonical.starts_with(&base_canonical) {
551                Some(canonical)
552            } else {
553                None
554            }
555        };
556
557        // Check if it's a module file (add .py extension)
558        let py_file = path.with_extension("py");
559        if py_file.exists() {
560            return check_bounded(&py_file);
561        }
562
563        // Check if it's a package directory (has __init__.py)
564        if path.is_dir() {
565            let init_file = path.join("__init__.py");
566            if init_file.exists() {
567                return check_bounded(&init_file);
568            }
569        }
570
571        None
572    }
573
574    /// Scan a single Python file for fixture definitions.
575    fn scan_single_plugin_file(&self, file_path: &Path) {
576        if file_path.extension().and_then(|s| s.to_str()) != Some("py") {
577            return;
578        }
579
580        debug!("Scanning plugin file: {:?}", file_path);
581
582        if let Ok(content) = std::fs::read_to_string(file_path) {
583            self.analyze_file(file_path.to_path_buf(), &content);
584        }
585    }
586
587    /// Load pytest plugins from a single dist-info directory's entry points.
588    ///
589    /// Reads entry_points.txt, parses [pytest11] section, resolves modules,
590    /// and scans discovered plugin files for fixtures.
591    ///
592    /// Returns the number of plugin modules scanned.
593    fn load_plugin_from_entry_point(&self, dist_info_path: &Path, site_packages: &Path) -> usize {
594        let entry_points_file = dist_info_path.join("entry_points.txt");
595
596        let content = match std::fs::read_to_string(&entry_points_file) {
597            Ok(c) => c,
598            Err(_) => return 0, // No entry_points.txt or unreadable
599        };
600
601        let entries = Self::parse_pytest11_entry_points(&content);
602
603        if entries.is_empty() {
604            return 0; // No pytest11 plugins in this package
605        }
606
607        let mut scanned_count = 0;
608
609        for entry in entries {
610            debug!(
611                "Found pytest11 entry: {} = {}",
612                entry.name, entry.module_path
613            );
614
615            let resolved =
616                Self::resolve_entry_point_module_to_path(site_packages, &entry.module_path)
617                    .or_else(|| self.resolve_entry_point_in_editable_installs(&entry.module_path));
618
619            if let Some(path) = resolved {
620                let scanned = if path.file_name().and_then(|n| n.to_str()) == Some("__init__.py") {
621                    let package_dir = path.parent().expect("__init__.py must have parent");
622                    info!(
623                        "Scanning pytest plugin package directory for {}: {:?}",
624                        entry.name, package_dir
625                    );
626                    self.scan_plugin_directory(package_dir);
627                    true
628                } else if path.is_file() {
629                    info!("Scanning pytest plugin: {} -> {:?}", entry.name, path);
630                    self.scan_single_plugin_file(&path);
631                    true
632                } else {
633                    debug!(
634                        "Resolved module path for plugin {} is not a file: {:?}",
635                        entry.name, path
636                    );
637                    false
638                };
639
640                if scanned {
641                    scanned_count += 1;
642                }
643            } else {
644                debug!(
645                    "Could not resolve module path: {} for plugin {}",
646                    entry.module_path, entry.name
647                );
648            }
649        }
650
651        scanned_count
652    }
653
654    /// Scan pytest's internal _pytest package for built-in fixtures.
655    /// This handles fixtures like tmp_path, capsys, monkeypatch, etc.
656    fn scan_pytest_internal_fixtures(&self, site_packages: &Path) {
657        let pytest_internal = site_packages.join("_pytest");
658
659        if !pytest_internal.exists() || !pytest_internal.is_dir() {
660            debug!("_pytest directory not found in site-packages");
661            return;
662        }
663
664        info!(
665            "Scanning pytest internal fixtures in: {:?}",
666            pytest_internal
667        );
668        self.scan_plugin_directory(&pytest_internal);
669    }
670
671    /// Extract the raw and normalized package name from a `.dist-info` directory name.
672    /// Returns `(raw_name, normalized_name)`.
673    /// e.g., `my-package-1.0.0.dist-info` → `("my-package", "my_package")`
674    fn extract_package_name_from_dist_info(dir_name: &str) -> Option<(String, String)> {
675        // Strip the .dist-info or .egg-info suffix
676        let name_version = dir_name
677            .strip_suffix(".dist-info")
678            .or_else(|| dir_name.strip_suffix(".egg-info"))?;
679
680        // The format is `name-version`. Split on '-' and take the first segment.
681        // Package names can contain hyphens, but the version always starts with a digit,
682        // so find the first '-' followed by a digit.
683        let name = if let Some(idx) = name_version.char_indices().position(|(i, c)| {
684            c == '-' && name_version[i + 1..].starts_with(|c: char| c.is_ascii_digit())
685        }) {
686            &name_version[..idx]
687        } else {
688            name_version
689        };
690
691        let raw = name.to_string();
692        // Normalize: PEP 503 says dashes, dots, underscores are interchangeable
693        let normalized = name.replace(['-', '.'], "_").to_lowercase();
694        Some((raw, normalized))
695    }
696
697    /// Discover editable installs by scanning `.dist-info` directories for `direct_url.json`.
698    fn discover_editable_installs(&self, site_packages: &Path) {
699        info!("Scanning for editable installs in: {:?}", site_packages);
700
701        // Validate the site-packages path is a real directory before reading from it
702        if !site_packages.is_dir() {
703            warn!(
704                "site-packages path is not a directory, skipping editable install scan: {:?}",
705                site_packages
706            );
707            return;
708        }
709
710        // Clear previous editable installs to avoid duplicates on re-scan
711        self.editable_install_roots.lock().unwrap().clear();
712
713        // Index all .pth files once (stem → full path) to avoid re-reading site-packages per package
714        let pth_index = Self::build_pth_index(site_packages);
715
716        let entries = match std::fs::read_dir(site_packages) {
717            Ok(e) => e,
718            Err(_) => return,
719        };
720
721        for entry in entries.flatten() {
722            let path = entry.path();
723            let filename = path.file_name().unwrap_or_default().to_string_lossy();
724
725            if !filename.ends_with(".dist-info") {
726                continue;
727            }
728
729            let direct_url_path = path.join("direct_url.json");
730            let content = match std::fs::read_to_string(&direct_url_path) {
731                Ok(c) => c,
732                Err(_) => continue,
733            };
734
735            // Parse direct_url.json to check for editable installs
736            let json: serde_json::Value = match serde_json::from_str(&content) {
737                Ok(v) => v,
738                Err(_) => continue,
739            };
740
741            // Check if dir_info.editable is true
742            let is_editable = json
743                .get("dir_info")
744                .and_then(|d| d.get("editable"))
745                .and_then(|e| e.as_bool())
746                .unwrap_or(false);
747
748            if !is_editable {
749                continue;
750            }
751
752            let Some((raw_name, normalized_name)) =
753                Self::extract_package_name_from_dist_info(&filename)
754            else {
755                continue;
756            };
757
758            // Find the .pth file that points to the source root
759            let source_root = Self::find_editable_pth_source_root(
760                &pth_index,
761                &raw_name,
762                &normalized_name,
763                site_packages,
764            );
765            let Some(source_root) = source_root else {
766                debug!(
767                    "No .pth file found for editable install: {}",
768                    normalized_name
769                );
770                continue;
771            };
772
773            info!(
774                "Discovered editable install: {} -> {:?}",
775                normalized_name, source_root
776            );
777            self.editable_install_roots
778                .lock()
779                .unwrap()
780                .push(super::EditableInstall {
781                    package_name: normalized_name,
782                    raw_package_name: raw_name,
783                    source_root,
784                    site_packages: site_packages.to_path_buf(),
785                });
786        }
787
788        let count = self.editable_install_roots.lock().unwrap().len();
789        info!("Discovered {} editable install(s)", count);
790    }
791
792    /// Build an index of `.pth` file stems to their full paths.
793    /// Read site-packages once and store `stem → path` for O(1) lookup.
794    fn build_pth_index(site_packages: &Path) -> std::collections::HashMap<String, PathBuf> {
795        let mut index = std::collections::HashMap::new();
796        if !site_packages.is_dir() {
797            return index;
798        }
799        let entries = match std::fs::read_dir(site_packages) {
800            Ok(e) => e,
801            Err(_) => return index,
802        };
803        for entry in entries.flatten() {
804            let fname = entry.file_name();
805            let fname_str = fname.to_string_lossy();
806            if fname_str.ends_with(".pth") {
807                let stem = fname_str.strip_suffix(".pth").unwrap_or(&fname_str);
808                index.insert(stem.to_string(), entry.path());
809            }
810        }
811        index
812    }
813
814    /// Find the source root from a `.pth` file for an editable install.
815    /// Uses both raw and normalized package names to handle pip's varying naming conventions.
816    /// Looks for both old-style (`_<pkg>.pth`) and new-style (`__editable__.<pkg>.pth`) naming.
817    fn find_editable_pth_source_root(
818        pth_index: &std::collections::HashMap<String, PathBuf>,
819        raw_name: &str,
820        normalized_name: &str,
821        site_packages: &Path,
822    ) -> Option<PathBuf> {
823        // Build candidates from both raw and normalized names.
824        // Raw name preserves original dashes/dots (e.g., "my-package"),
825        // normalized uses underscores (e.g., "my_package").
826        let mut candidates: Vec<String> = vec![
827            format!("__editable__.{}", normalized_name),
828            format!("_{}", normalized_name),
829            normalized_name.to_string(),
830        ];
831        if raw_name != normalized_name {
832            candidates.push(format!("__editable__.{}", raw_name));
833            candidates.push(format!("_{}", raw_name));
834            candidates.push(raw_name.to_string());
835        }
836
837        // Search the pre-built index for matching .pth stems
838        for (stem, pth_path) in pth_index {
839            let matches = candidates.iter().any(|c| {
840                stem == c
841                    || stem.strip_prefix(c).is_some_and(|rest| {
842                        rest.starts_with('-')
843                            && rest[1..].starts_with(|ch: char| ch.is_ascii_digit())
844                    })
845            });
846            if !matches {
847                continue;
848            }
849
850            // Parse the .pth file: first non-comment, non-import line is the path
851            let content = match std::fs::read_to_string(pth_path) {
852                Ok(c) => c,
853                Err(_) => continue,
854            };
855
856            for line in content.lines() {
857                let line = line.trim();
858                if line.is_empty() || line.starts_with('#') || line.starts_with("import ") {
859                    continue;
860                }
861                // Validate: reject lines with null bytes, control characters,
862                // or path traversal sequences
863                if line.contains('\0')
864                    || line.bytes().any(|b| b < 0x20 && b != b'\t')
865                    || line.contains("..")
866                {
867                    debug!("Skipping .pth line with invalid characters: {:?}", line);
868                    continue;
869                }
870                let candidate = PathBuf::from(line);
871                let resolved = if candidate.is_absolute() {
872                    candidate
873                } else {
874                    site_packages.join(&candidate)
875                };
876                // Canonicalize to resolve symlinks and validate existence,
877                // then verify it's an actual directory
878                match resolved.canonicalize() {
879                    Ok(canonical) if canonical.is_dir() => return Some(canonical),
880                    Ok(canonical) => {
881                        debug!(".pth path is not a directory: {:?}", canonical);
882                        continue;
883                    }
884                    Err(_) => {
885                        debug!("Could not canonicalize .pth path: {:?}", resolved);
886                        continue;
887                    }
888                }
889            }
890        }
891
892        None
893    }
894
895    /// Try to resolve an entry point module path through editable install source roots.
896    fn resolve_entry_point_in_editable_installs(&self, module_path: &str) -> Option<PathBuf> {
897        let installs = self.editable_install_roots.lock().unwrap();
898        for install in installs.iter() {
899            if let Some(path) =
900                Self::resolve_entry_point_module_to_path(&install.source_root, module_path)
901            {
902                return Some(path);
903            }
904        }
905        None
906    }
907
908    fn scan_pytest_plugins(&self, site_packages: &Path) {
909        info!(
910            "Scanning for pytest plugins via entry points in: {:?}",
911            site_packages
912        );
913
914        // Discover editable installs before scanning entry points
915        self.discover_editable_installs(site_packages);
916
917        let mut plugin_count = 0;
918
919        // First, scan pytest's internal fixtures (special case)
920        self.scan_pytest_internal_fixtures(site_packages);
921
922        // Iterate over ALL dist-info directories and check for pytest11 entry points
923        for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
924            let entry = match entry {
925                Ok(e) => e,
926                Err(_) => continue,
927            };
928
929            let path = entry.path();
930            let filename = path.file_name().unwrap_or_default().to_string_lossy();
931
932            // Only process dist metadata directories
933            if !filename.ends_with(".dist-info") && !filename.ends_with(".egg-info") {
934                continue;
935            }
936
937            // Try to load plugins from this package's entry points
938            let scanned = self.load_plugin_from_entry_point(&path, site_packages);
939            if scanned > 0 {
940                plugin_count += scanned;
941                debug!("Loaded {} plugin module(s) from {}", scanned, filename);
942            }
943        }
944
945        info!(
946            "Discovered fixtures from {} pytest plugin modules",
947            plugin_count
948        );
949    }
950
951    fn scan_plugin_directory(&self, plugin_dir: &Path) {
952        // Recursively scan for Python files with fixtures
953        for entry in WalkDir::new(plugin_dir)
954            .max_depth(3) // Limit depth to avoid scanning too much
955            .into_iter()
956            .filter_map(|e| e.ok())
957        {
958            let path = entry.path();
959
960            if path.extension().and_then(|s| s.to_str()) == Some("py") {
961                // Only scan files that might have fixtures (not test files)
962                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
963                    // Skip test files and __pycache__
964                    if filename.starts_with("test_") || filename.contains("__pycache__") {
965                        continue;
966                    }
967
968                    debug!("Scanning plugin file: {:?}", path);
969                    if let Ok(content) = std::fs::read_to_string(path) {
970                        self.analyze_file(path.to_path_buf(), &content);
971                    }
972                }
973            }
974        }
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use std::fs;
982    use tempfile::tempdir;
983
984    #[test]
985    fn test_parse_pytest11_entry_points_basic() {
986        let content = r#"
987[console_scripts]
988my-cli = my_package:main
989
990[pytest11]
991my_plugin = my_package.plugin
992another = another_pkg
993
994[other_section]
995foo = bar
996"#;
997
998        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
999        assert_eq!(entries.len(), 2);
1000        assert_eq!(entries[0].name, "my_plugin");
1001        assert_eq!(entries[0].module_path, "my_package.plugin");
1002        assert_eq!(entries[1].name, "another");
1003        assert_eq!(entries[1].module_path, "another_pkg");
1004    }
1005
1006    #[test]
1007    fn test_parse_pytest11_entry_points_empty_file() {
1008        let entries = FixtureDatabase::parse_pytest11_entry_points("");
1009        assert!(entries.is_empty());
1010    }
1011
1012    #[test]
1013    fn test_parse_pytest11_entry_points_no_pytest11_section() {
1014        let content = r#"
1015[console_scripts]
1016my-cli = my_package:main
1017"#;
1018        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1019        assert!(entries.is_empty());
1020    }
1021
1022    #[test]
1023    fn test_parse_pytest11_entry_points_with_comments() {
1024        let content = r#"
1025[pytest11]
1026# This is a comment
1027my_plugin = my_package.plugin
1028# Another comment
1029"#;
1030        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1031        assert_eq!(entries.len(), 1);
1032        assert_eq!(entries[0].name, "my_plugin");
1033    }
1034
1035    #[test]
1036    fn test_parse_pytest11_entry_points_with_whitespace() {
1037        let content = r#"
1038[pytest11]
1039   my_plugin   =   my_package.plugin
1040another=another_pkg
1041"#;
1042        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1043        assert_eq!(entries.len(), 2);
1044        assert_eq!(entries[0].name, "my_plugin");
1045        assert_eq!(entries[0].module_path, "my_package.plugin");
1046        assert_eq!(entries[1].name, "another");
1047        assert_eq!(entries[1].module_path, "another_pkg");
1048    }
1049
1050    #[test]
1051    fn test_parse_pytest11_entry_points_with_attr() {
1052        // Some entry points have :attr suffix (e.g., module:function)
1053        let content = r#"
1054[pytest11]
1055my_plugin = my_package.module:plugin_entry
1056"#;
1057        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1058        assert_eq!(entries.len(), 1);
1059        assert_eq!(entries[0].module_path, "my_package.module:plugin_entry");
1060    }
1061
1062    #[test]
1063    fn test_parse_pytest11_entry_points_multiple_sections_before_pytest11() {
1064        let content = r#"
1065[console_scripts]
1066cli = pkg:main
1067
1068[gui_scripts]
1069gui = pkg:gui_main
1070
1071[pytest11]
1072my_plugin = my_package.plugin
1073
1074[other]
1075extra = something
1076"#;
1077        let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1078        assert_eq!(entries.len(), 1);
1079        assert_eq!(entries[0].name, "my_plugin");
1080    }
1081
1082    #[test]
1083    fn test_resolve_entry_point_module_to_path_package() {
1084        let temp = tempdir().unwrap();
1085        let site_packages = temp.path();
1086
1087        // Create a package with __init__.py
1088        let pkg_dir = site_packages.join("my_plugin");
1089        fs::create_dir_all(&pkg_dir).unwrap();
1090        fs::write(pkg_dir.join("__init__.py"), "# plugin code").unwrap();
1091
1092        // Should resolve to __init__.py (canonicalized)
1093        let result =
1094            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1095        assert!(result.is_some());
1096        assert_eq!(
1097            result.unwrap(),
1098            pkg_dir.join("__init__.py").canonicalize().unwrap()
1099        );
1100    }
1101
1102    #[test]
1103    fn test_resolve_entry_point_module_to_path_submodule() {
1104        let temp = tempdir().unwrap();
1105        let site_packages = temp.path();
1106
1107        // Create a package with a submodule
1108        let pkg_dir = site_packages.join("my_plugin");
1109        fs::create_dir_all(&pkg_dir).unwrap();
1110        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1111        fs::write(pkg_dir.join("plugin.py"), "# plugin code").unwrap();
1112
1113        // Should resolve to plugin.py (canonicalized)
1114        let result =
1115            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin.plugin");
1116        assert!(result.is_some());
1117        assert_eq!(
1118            result.unwrap(),
1119            pkg_dir.join("plugin.py").canonicalize().unwrap()
1120        );
1121    }
1122
1123    #[test]
1124    fn test_resolve_entry_point_module_to_path_single_file() {
1125        let temp = tempdir().unwrap();
1126        let site_packages = temp.path();
1127
1128        // Create a single-file module
1129        fs::write(site_packages.join("my_plugin.py"), "# plugin code").unwrap();
1130
1131        // Should resolve to my_plugin.py (canonicalized)
1132        let result =
1133            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1134        assert!(result.is_some());
1135        assert_eq!(
1136            result.unwrap(),
1137            site_packages.join("my_plugin.py").canonicalize().unwrap()
1138        );
1139    }
1140
1141    #[test]
1142    fn test_resolve_entry_point_module_to_path_not_found() {
1143        let temp = tempdir().unwrap();
1144        let site_packages = temp.path();
1145
1146        // Nothing exists
1147        let result = FixtureDatabase::resolve_entry_point_module_to_path(
1148            site_packages,
1149            "nonexistent_plugin",
1150        );
1151        assert!(result.is_none());
1152    }
1153
1154    #[test]
1155    fn test_resolve_entry_point_module_strips_attr() {
1156        let temp = tempdir().unwrap();
1157        let site_packages = temp.path();
1158
1159        // Create a package with a submodule
1160        let pkg_dir = site_packages.join("my_plugin");
1161        fs::create_dir_all(&pkg_dir).unwrap();
1162        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1163        fs::write(pkg_dir.join("module.py"), "# plugin code").unwrap();
1164
1165        // Should resolve even with :attr suffix (canonicalized)
1166        let result = FixtureDatabase::resolve_entry_point_module_to_path(
1167            site_packages,
1168            "my_plugin.module:entry_function",
1169        );
1170        assert!(result.is_some());
1171        assert_eq!(
1172            result.unwrap(),
1173            pkg_dir.join("module.py").canonicalize().unwrap()
1174        );
1175    }
1176
1177    #[test]
1178    fn test_resolve_entry_point_rejects_path_traversal() {
1179        let temp = tempdir().unwrap();
1180        let site_packages = temp.path();
1181
1182        // Create a valid module so the path would resolve if not for validation
1183        fs::write(site_packages.join("valid.py"), "# code").unwrap();
1184
1185        // After splitting on '.', this yields empty segments ["", "", "%2Fetc%2Fpasswd"]
1186        // and is rejected by the empty-segment validation
1187        let result =
1188            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "..%2Fetc%2Fpasswd");
1189        assert!(result.is_none(), "should reject traversal-like pattern");
1190
1191        // "valid...secret" splits to ["valid", "", "", "secret"] — caught by empty segments
1192        let result =
1193            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "valid...secret");
1194        assert!(
1195            result.is_none(),
1196            "should reject module names with consecutive dots (empty segments)"
1197        );
1198
1199        // "pkg..evil" splits to ["pkg", "", "evil"] — also caught by empty segments
1200        let result =
1201            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "pkg..evil");
1202        assert!(
1203            result.is_none(),
1204            "should reject module names with consecutive dots"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_resolve_entry_point_rejects_null_bytes() {
1210        let temp = tempdir().unwrap();
1211        let site_packages = temp.path();
1212
1213        let result =
1214            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "module\0name");
1215        assert!(result.is_none(), "should reject null bytes");
1216    }
1217
1218    #[test]
1219    fn test_resolve_entry_point_rejects_empty_segments() {
1220        let temp = tempdir().unwrap();
1221        let site_packages = temp.path();
1222
1223        // "foo..bar" splits on '.' to ["foo", "", "bar"]
1224        let result = FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "foo..bar");
1225        assert!(result.is_none(), "should reject empty path segments");
1226    }
1227
1228    #[cfg(unix)]
1229    #[test]
1230    fn test_resolve_entry_point_rejects_symlink_escape() {
1231        let temp = tempdir().unwrap();
1232        let site_packages = temp.path();
1233
1234        // Create an outside directory with a .py file
1235        let outside = tempdir().unwrap();
1236        fs::write(outside.path().join("evil.py"), "# malicious").unwrap();
1237
1238        // Create a symlink inside site-packages pointing outside
1239        std::os::unix::fs::symlink(outside.path(), site_packages.join("escaped")).unwrap();
1240
1241        let result =
1242            FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "escaped.evil");
1243        assert!(
1244            result.is_none(),
1245            "should reject paths that escape site-packages via symlink"
1246        );
1247    }
1248
1249    #[test]
1250    fn test_entry_point_plugin_discovery_integration() {
1251        // Create mock site-packages structure
1252        let temp = tempdir().unwrap();
1253        let site_packages = temp.path();
1254
1255        // Create a mock plugin package
1256        let plugin_dir = site_packages.join("my_pytest_plugin");
1257        fs::create_dir_all(&plugin_dir).unwrap();
1258
1259        let plugin_content = r#"
1260import pytest
1261
1262@pytest.fixture
1263def my_dynamic_fixture():
1264    """A fixture discovered via entry points."""
1265    return "discovered via entry point"
1266
1267@pytest.fixture
1268def another_dynamic_fixture():
1269    return 42
1270"#;
1271        fs::write(plugin_dir.join("__init__.py"), plugin_content).unwrap();
1272
1273        // Create dist-info with entry points
1274        let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1275        fs::create_dir_all(&dist_info).unwrap();
1276
1277        let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1278        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1279
1280        // Scan and verify
1281        let db = FixtureDatabase::new();
1282        db.scan_pytest_plugins(site_packages);
1283
1284        assert!(
1285            db.definitions.contains_key("my_dynamic_fixture"),
1286            "my_dynamic_fixture should be discovered"
1287        );
1288        assert!(
1289            db.definitions.contains_key("another_dynamic_fixture"),
1290            "another_dynamic_fixture should be discovered"
1291        );
1292    }
1293
1294    #[test]
1295    fn test_entry_point_discovery_submodule() {
1296        let temp = tempdir().unwrap();
1297        let site_packages = temp.path();
1298
1299        // Create package with plugin in submodule (like pytest_asyncio.plugin)
1300        let plugin_dir = site_packages.join("my_pytest_plugin");
1301        fs::create_dir_all(&plugin_dir).unwrap();
1302        fs::write(plugin_dir.join("__init__.py"), "# main init").unwrap();
1303
1304        let plugin_content = r#"
1305import pytest
1306
1307@pytest.fixture
1308def submodule_fixture():
1309    return "from submodule"
1310"#;
1311        fs::write(plugin_dir.join("plugin.py"), plugin_content).unwrap();
1312
1313        // Create dist-info with entry points pointing to submodule
1314        let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1315        fs::create_dir_all(&dist_info).unwrap();
1316
1317        let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin.plugin\n";
1318        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1319
1320        // Scan and verify
1321        let db = FixtureDatabase::new();
1322        db.scan_pytest_plugins(site_packages);
1323
1324        assert!(
1325            db.definitions.contains_key("submodule_fixture"),
1326            "submodule_fixture should be discovered"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_entry_point_discovery_package_scans_submodules() {
1332        let temp = tempdir().unwrap();
1333        let site_packages = temp.path();
1334
1335        // Create package with fixtures in a submodule
1336        let plugin_dir = site_packages.join("my_pytest_plugin");
1337        fs::create_dir_all(&plugin_dir).unwrap();
1338        fs::write(plugin_dir.join("__init__.py"), "# package init").unwrap();
1339
1340        let plugin_content = r#"
1341import pytest
1342
1343@pytest.fixture
1344def package_submodule_fixture():
1345    return "from package submodule"
1346"#;
1347        fs::write(plugin_dir.join("fixtures.py"), plugin_content).unwrap();
1348
1349        // Create dist-info with entry points pointing to package
1350        let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1351        fs::create_dir_all(&dist_info).unwrap();
1352
1353        let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1354        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1355
1356        // Scan and verify submodule fixtures are discovered
1357        let db = FixtureDatabase::new();
1358        db.scan_pytest_plugins(site_packages);
1359
1360        assert!(
1361            db.definitions.contains_key("package_submodule_fixture"),
1362            "package_submodule_fixture should be discovered"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_entry_point_discovery_no_pytest11_section() {
1368        let temp = tempdir().unwrap();
1369        let site_packages = temp.path();
1370
1371        // Create a package that's NOT a pytest plugin
1372        let pkg_dir = site_packages.join("some_package");
1373        fs::create_dir_all(&pkg_dir).unwrap();
1374
1375        let pkg_content = r#"
1376import pytest
1377
1378@pytest.fixture
1379def should_not_be_found():
1380    return "this package is not a pytest plugin"
1381"#;
1382        fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1383
1384        // Create dist-info WITHOUT pytest11 section
1385        let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1386        fs::create_dir_all(&dist_info).unwrap();
1387
1388        let entry_points = "[console_scripts]\nsome_cli = some_package:main\n";
1389        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1390
1391        // Scan and verify
1392        let db = FixtureDatabase::new();
1393        db.scan_pytest_plugins(site_packages);
1394
1395        assert!(
1396            !db.definitions.contains_key("should_not_be_found"),
1397            "should_not_be_found should NOT be discovered (not a pytest plugin)"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_entry_point_discovery_missing_entry_points_txt() {
1403        let temp = tempdir().unwrap();
1404        let site_packages = temp.path();
1405
1406        // Create a package
1407        let pkg_dir = site_packages.join("some_package");
1408        fs::create_dir_all(&pkg_dir).unwrap();
1409
1410        let pkg_content = r#"
1411import pytest
1412
1413@pytest.fixture
1414def should_not_be_found():
1415    return "no entry_points.txt"
1416"#;
1417        fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1418
1419        // Create dist-info WITHOUT entry_points.txt file
1420        let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1421        fs::create_dir_all(&dist_info).unwrap();
1422        // Don't create entry_points.txt
1423
1424        // Scan and verify
1425        let db = FixtureDatabase::new();
1426        db.scan_pytest_plugins(site_packages);
1427
1428        assert!(
1429            !db.definitions.contains_key("should_not_be_found"),
1430            "should_not_be_found should NOT be discovered (no entry_points.txt)"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_entry_point_discovery_egg_info() {
1436        let temp = tempdir().unwrap();
1437        let site_packages = temp.path();
1438
1439        // Create a package
1440        let pkg_dir = site_packages.join("legacy_plugin");
1441        fs::create_dir_all(&pkg_dir).unwrap();
1442        fs::write(
1443            pkg_dir.join("__init__.py"),
1444            r#"
1445import pytest
1446
1447@pytest.fixture
1448def legacy_plugin_fixture():
1449    return "from egg-info"
1450"#,
1451        )
1452        .unwrap();
1453
1454        // Create egg-info with entry points
1455        let egg_info = site_packages.join("legacy_plugin-1.0.0.egg-info");
1456        fs::create_dir_all(&egg_info).unwrap();
1457        let entry_points = "[pytest11]\nlegacy_plugin = legacy_plugin\n";
1458        fs::write(egg_info.join("entry_points.txt"), entry_points).unwrap();
1459
1460        // Scan and verify
1461        let db = FixtureDatabase::new();
1462        db.scan_pytest_plugins(site_packages);
1463
1464        assert!(
1465            db.definitions.contains_key("legacy_plugin_fixture"),
1466            "legacy_plugin_fixture should be discovered"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_entry_point_discovery_multiple_plugins() {
1472        let temp = tempdir().unwrap();
1473        let site_packages = temp.path();
1474
1475        // Create first plugin
1476        let plugin1_dir = site_packages.join("plugin_one");
1477        fs::create_dir_all(&plugin1_dir).unwrap();
1478        fs::write(
1479            plugin1_dir.join("__init__.py"),
1480            r#"
1481import pytest
1482
1483@pytest.fixture
1484def fixture_from_plugin_one():
1485    return 1
1486"#,
1487        )
1488        .unwrap();
1489
1490        let dist_info1 = site_packages.join("plugin_one-1.0.0.dist-info");
1491        fs::create_dir_all(&dist_info1).unwrap();
1492        fs::write(
1493            dist_info1.join("entry_points.txt"),
1494            "[pytest11]\nplugin_one = plugin_one\n",
1495        )
1496        .unwrap();
1497
1498        // Create second plugin
1499        let plugin2_dir = site_packages.join("plugin_two");
1500        fs::create_dir_all(&plugin2_dir).unwrap();
1501        fs::write(
1502            plugin2_dir.join("__init__.py"),
1503            r#"
1504import pytest
1505
1506@pytest.fixture
1507def fixture_from_plugin_two():
1508    return 2
1509"#,
1510        )
1511        .unwrap();
1512
1513        let dist_info2 = site_packages.join("plugin_two-2.0.0.dist-info");
1514        fs::create_dir_all(&dist_info2).unwrap();
1515        fs::write(
1516            dist_info2.join("entry_points.txt"),
1517            "[pytest11]\nplugin_two = plugin_two\n",
1518        )
1519        .unwrap();
1520
1521        // Scan and verify both are discovered
1522        let db = FixtureDatabase::new();
1523        db.scan_pytest_plugins(site_packages);
1524
1525        assert!(
1526            db.definitions.contains_key("fixture_from_plugin_one"),
1527            "fixture_from_plugin_one should be discovered"
1528        );
1529        assert!(
1530            db.definitions.contains_key("fixture_from_plugin_two"),
1531            "fixture_from_plugin_two should be discovered"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_entry_point_discovery_multiple_entries_in_one_package() {
1537        let temp = tempdir().unwrap();
1538        let site_packages = temp.path();
1539
1540        // Create a package with multiple plugin modules
1541        let plugin_dir = site_packages.join("multi_plugin");
1542        fs::create_dir_all(&plugin_dir).unwrap();
1543        fs::write(plugin_dir.join("__init__.py"), "").unwrap();
1544
1545        fs::write(
1546            plugin_dir.join("fixtures_a.py"),
1547            r#"
1548import pytest
1549
1550@pytest.fixture
1551def fixture_a():
1552    return "A"
1553"#,
1554        )
1555        .unwrap();
1556
1557        fs::write(
1558            plugin_dir.join("fixtures_b.py"),
1559            r#"
1560import pytest
1561
1562@pytest.fixture
1563def fixture_b():
1564    return "B"
1565"#,
1566        )
1567        .unwrap();
1568
1569        // Create dist-info with multiple pytest11 entries
1570        let dist_info = site_packages.join("multi_plugin-1.0.0.dist-info");
1571        fs::create_dir_all(&dist_info).unwrap();
1572        fs::write(
1573            dist_info.join("entry_points.txt"),
1574            r#"[pytest11]
1575fixtures_a = multi_plugin.fixtures_a
1576fixtures_b = multi_plugin.fixtures_b
1577"#,
1578        )
1579        .unwrap();
1580
1581        // Scan and verify both modules are scanned
1582        let db = FixtureDatabase::new();
1583        db.scan_pytest_plugins(site_packages);
1584
1585        assert!(
1586            db.definitions.contains_key("fixture_a"),
1587            "fixture_a should be discovered"
1588        );
1589        assert!(
1590            db.definitions.contains_key("fixture_b"),
1591            "fixture_b should be discovered"
1592        );
1593    }
1594
1595    #[test]
1596    fn test_pytest_internal_fixtures_scanned() {
1597        let temp = tempdir().unwrap();
1598        let site_packages = temp.path();
1599
1600        // Create mock _pytest directory (pytest's internal package)
1601        let pytest_internal = site_packages.join("_pytest");
1602        fs::create_dir_all(&pytest_internal).unwrap();
1603
1604        let internal_fixtures = r#"
1605import pytest
1606
1607@pytest.fixture
1608def tmp_path():
1609    """Pytest's built-in tmp_path fixture."""
1610    pass
1611
1612@pytest.fixture
1613def capsys():
1614    """Pytest's built-in capsys fixture."""
1615    pass
1616"#;
1617        fs::write(pytest_internal.join("fixtures.py"), internal_fixtures).unwrap();
1618
1619        // Scan and verify internal fixtures are discovered
1620        let db = FixtureDatabase::new();
1621        db.scan_pytest_plugins(site_packages);
1622
1623        // Note: We're checking that _pytest is scanned as a special case
1624        // even without entry points
1625        assert!(
1626            db.definitions.contains_key("tmp_path"),
1627            "tmp_path should be discovered from _pytest"
1628        );
1629        assert!(
1630            db.definitions.contains_key("capsys"),
1631            "capsys should be discovered from _pytest"
1632        );
1633    }
1634
1635    #[test]
1636    fn test_extract_package_name_from_dist_info() {
1637        assert_eq!(
1638            FixtureDatabase::extract_package_name_from_dist_info("mypackage-1.0.0.dist-info"),
1639            Some(("mypackage".to_string(), "mypackage".to_string()))
1640        );
1641        assert_eq!(
1642            FixtureDatabase::extract_package_name_from_dist_info("my-package-1.0.0.dist-info"),
1643            Some(("my-package".to_string(), "my_package".to_string()))
1644        );
1645        assert_eq!(
1646            FixtureDatabase::extract_package_name_from_dist_info("My.Package-2.3.4.dist-info"),
1647            Some(("My.Package".to_string(), "my_package".to_string()))
1648        );
1649        assert_eq!(
1650            FixtureDatabase::extract_package_name_from_dist_info("pytest_mock-3.12.0.dist-info"),
1651            Some(("pytest_mock".to_string(), "pytest_mock".to_string()))
1652        );
1653        assert_eq!(
1654            FixtureDatabase::extract_package_name_from_dist_info("mypackage-0.1.0.egg-info"),
1655            Some(("mypackage".to_string(), "mypackage".to_string()))
1656        );
1657        // Edge case: no version
1658        assert_eq!(
1659            FixtureDatabase::extract_package_name_from_dist_info("mypackage.dist-info"),
1660            Some(("mypackage".to_string(), "mypackage".to_string()))
1661        );
1662    }
1663
1664    #[test]
1665    fn test_discover_editable_installs() {
1666        let temp = tempdir().unwrap();
1667        let site_packages = temp.path();
1668
1669        // Create a source root for the editable package
1670        let source_root = tempdir().unwrap();
1671        let pkg_dir = source_root.path().join("mypackage");
1672        fs::create_dir_all(&pkg_dir).unwrap();
1673        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1674
1675        // Create dist-info with direct_url.json indicating editable
1676        let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
1677        fs::create_dir_all(&dist_info).unwrap();
1678
1679        let direct_url = serde_json::json!({
1680            "url": format!("file://{}", source_root.path().display()),
1681            "dir_info": {
1682                "editable": true
1683            }
1684        });
1685        fs::write(
1686            dist_info.join("direct_url.json"),
1687            serde_json::to_string(&direct_url).unwrap(),
1688        )
1689        .unwrap();
1690
1691        // Create a .pth file pointing to the source root
1692        let pth_content = format!("{}\n", source_root.path().display());
1693        fs::write(
1694            site_packages.join("__editable__.mypackage-0.1.0.pth"),
1695            &pth_content,
1696        )
1697        .unwrap();
1698
1699        let db = FixtureDatabase::new();
1700        db.discover_editable_installs(site_packages);
1701
1702        let installs = db.editable_install_roots.lock().unwrap();
1703        assert_eq!(installs.len(), 1, "Should discover one editable install");
1704        assert_eq!(installs[0].package_name, "mypackage");
1705        assert_eq!(
1706            installs[0].source_root,
1707            source_root.path().canonicalize().unwrap()
1708        );
1709    }
1710
1711    #[test]
1712    fn test_discover_editable_installs_pth_with_dashes() {
1713        let temp = tempdir().unwrap();
1714        let site_packages = temp.path();
1715
1716        // Create a source root
1717        let source_root = tempdir().unwrap();
1718        let pkg_dir = source_root.path().join("my_package");
1719        fs::create_dir_all(&pkg_dir).unwrap();
1720        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1721
1722        // dist-info uses dashes (PEP 427): my-package-0.1.0.dist-info
1723        let dist_info = site_packages.join("my-package-0.1.0.dist-info");
1724        fs::create_dir_all(&dist_info).unwrap();
1725        let direct_url = serde_json::json!({
1726            "url": format!("file://{}", source_root.path().display()),
1727            "dir_info": { "editable": true }
1728        });
1729        fs::write(
1730            dist_info.join("direct_url.json"),
1731            serde_json::to_string(&direct_url).unwrap(),
1732        )
1733        .unwrap();
1734
1735        // .pth file keeps dashes (matches pip's actual behavior)
1736        let pth_content = format!("{}\n", source_root.path().display());
1737        fs::write(
1738            site_packages.join("__editable__.my-package-0.1.0.pth"),
1739            &pth_content,
1740        )
1741        .unwrap();
1742
1743        let db = FixtureDatabase::new();
1744        db.discover_editable_installs(site_packages);
1745
1746        let installs = db.editable_install_roots.lock().unwrap();
1747        assert_eq!(
1748            installs.len(),
1749            1,
1750            "Should discover editable install from .pth with dashes"
1751        );
1752        assert_eq!(installs[0].package_name, "my_package");
1753        assert_eq!(
1754            installs[0].source_root,
1755            source_root.path().canonicalize().unwrap()
1756        );
1757    }
1758
1759    #[test]
1760    fn test_discover_editable_installs_pth_with_dots() {
1761        let temp = tempdir().unwrap();
1762        let site_packages = temp.path();
1763
1764        // Create a source root
1765        let source_root = tempdir().unwrap();
1766        fs::create_dir_all(source_root.path().join("my_package")).unwrap();
1767        fs::write(source_root.path().join("my_package/__init__.py"), "").unwrap();
1768
1769        // dist-info uses dots: My.Package-1.0.0.dist-info
1770        let dist_info = site_packages.join("My.Package-1.0.0.dist-info");
1771        fs::create_dir_all(&dist_info).unwrap();
1772        let direct_url = serde_json::json!({
1773            "url": format!("file://{}", source_root.path().display()),
1774            "dir_info": { "editable": true }
1775        });
1776        fs::write(
1777            dist_info.join("direct_url.json"),
1778            serde_json::to_string(&direct_url).unwrap(),
1779        )
1780        .unwrap();
1781
1782        // .pth file keeps dots
1783        let pth_content = format!("{}\n", source_root.path().display());
1784        fs::write(
1785            site_packages.join("__editable__.My.Package-1.0.0.pth"),
1786            &pth_content,
1787        )
1788        .unwrap();
1789
1790        let db = FixtureDatabase::new();
1791        db.discover_editable_installs(site_packages);
1792
1793        let installs = db.editable_install_roots.lock().unwrap();
1794        assert_eq!(
1795            installs.len(),
1796            1,
1797            "Should discover editable install from .pth with dots"
1798        );
1799        assert_eq!(installs[0].package_name, "my_package");
1800        assert_eq!(
1801            installs[0].source_root,
1802            source_root.path().canonicalize().unwrap()
1803        );
1804    }
1805
1806    #[test]
1807    fn test_discover_editable_installs_dedup_on_rescan() {
1808        let temp = tempdir().unwrap();
1809        let site_packages = temp.path();
1810
1811        let source_root = tempdir().unwrap();
1812        fs::create_dir_all(source_root.path().join("pkg")).unwrap();
1813        fs::write(source_root.path().join("pkg/__init__.py"), "").unwrap();
1814
1815        let dist_info = site_packages.join("pkg-0.1.0.dist-info");
1816        fs::create_dir_all(&dist_info).unwrap();
1817        let direct_url = serde_json::json!({
1818            "url": format!("file://{}", source_root.path().display()),
1819            "dir_info": { "editable": true }
1820        });
1821        fs::write(
1822            dist_info.join("direct_url.json"),
1823            serde_json::to_string(&direct_url).unwrap(),
1824        )
1825        .unwrap();
1826
1827        let pth_content = format!("{}\n", source_root.path().display());
1828        fs::write(site_packages.join("pkg.pth"), &pth_content).unwrap();
1829
1830        let db = FixtureDatabase::new();
1831
1832        // Scan twice
1833        db.discover_editable_installs(site_packages);
1834        db.discover_editable_installs(site_packages);
1835
1836        let installs = db.editable_install_roots.lock().unwrap();
1837        assert_eq!(
1838            installs.len(),
1839            1,
1840            "Re-scanning should not produce duplicates"
1841        );
1842    }
1843
1844    #[test]
1845    fn test_editable_install_entry_point_resolution() {
1846        let temp = tempdir().unwrap();
1847        let site_packages = temp.path();
1848
1849        // Create a source root with a plugin module
1850        let source_root = tempdir().unwrap();
1851        let pkg_dir = source_root.path().join("mypackage");
1852        fs::create_dir_all(&pkg_dir).unwrap();
1853
1854        let plugin_content = r#"
1855import pytest
1856
1857@pytest.fixture
1858def editable_fixture():
1859    return "from editable install"
1860"#;
1861        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1862        fs::write(pkg_dir.join("plugin.py"), plugin_content).unwrap();
1863
1864        // Create dist-info with direct_url.json and entry_points.txt
1865        let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
1866        fs::create_dir_all(&dist_info).unwrap();
1867
1868        let direct_url = serde_json::json!({
1869            "url": format!("file://{}", source_root.path().display()),
1870            "dir_info": { "editable": true }
1871        });
1872        fs::write(
1873            dist_info.join("direct_url.json"),
1874            serde_json::to_string(&direct_url).unwrap(),
1875        )
1876        .unwrap();
1877
1878        let entry_points = "[pytest11]\nmypackage = mypackage.plugin\n";
1879        fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1880
1881        // Create .pth file
1882        let pth_content = format!("{}\n", source_root.path().display());
1883        fs::write(
1884            site_packages.join("__editable__.mypackage-0.1.0.pth"),
1885            &pth_content,
1886        )
1887        .unwrap();
1888
1889        let db = FixtureDatabase::new();
1890        db.scan_pytest_plugins(site_packages);
1891
1892        assert!(
1893            db.definitions.contains_key("editable_fixture"),
1894            "editable_fixture should be discovered via entry point fallback"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_discover_editable_installs_namespace_package() {
1900        let temp = tempdir().unwrap();
1901        let site_packages = temp.path();
1902
1903        let source_root = tempdir().unwrap();
1904        let pkg_dir = source_root.path().join("namespace").join("pkg");
1905        fs::create_dir_all(&pkg_dir).unwrap();
1906        fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1907
1908        let dist_info = site_packages.join("namespace.pkg-1.0.0.dist-info");
1909        fs::create_dir_all(&dist_info).unwrap();
1910        let direct_url = serde_json::json!({
1911            "url": format!("file://{}", source_root.path().display()),
1912            "dir_info": { "editable": true }
1913        });
1914        fs::write(
1915            dist_info.join("direct_url.json"),
1916            serde_json::to_string(&direct_url).unwrap(),
1917        )
1918        .unwrap();
1919
1920        let pth_content = format!("{}\n", source_root.path().display());
1921        fs::write(
1922            site_packages.join("__editable__.namespace.pkg-1.0.0.pth"),
1923            &pth_content,
1924        )
1925        .unwrap();
1926
1927        let db = FixtureDatabase::new();
1928        db.discover_editable_installs(site_packages);
1929
1930        let installs = db.editable_install_roots.lock().unwrap();
1931        assert_eq!(
1932            installs.len(),
1933            1,
1934            "Should discover namespace editable install"
1935        );
1936        assert_eq!(installs[0].package_name, "namespace_pkg");
1937        assert_eq!(installs[0].raw_package_name, "namespace.pkg");
1938        assert_eq!(
1939            installs[0].source_root,
1940            source_root.path().canonicalize().unwrap()
1941        );
1942    }
1943
1944    #[test]
1945    fn test_pth_prefix_matching_no_false_positive() {
1946        // "foo" candidate should NOT match "foo-bar.pth" (different package)
1947        let temp = tempdir().unwrap();
1948        let site_packages = temp.path();
1949
1950        let source_root_foo = tempdir().unwrap();
1951        fs::create_dir_all(source_root_foo.path()).unwrap();
1952
1953        let source_root_foobar = tempdir().unwrap();
1954        fs::create_dir_all(source_root_foobar.path()).unwrap();
1955
1956        // Create foo-bar.pth pointing to foobar source
1957        fs::write(
1958            site_packages.join("foo-bar.pth"),
1959            format!("{}\n", source_root_foobar.path().display()),
1960        )
1961        .unwrap();
1962
1963        let pth_index = FixtureDatabase::build_pth_index(site_packages);
1964
1965        // "foo" should NOT match "foo-bar" (different package, not a version suffix)
1966        let result =
1967            FixtureDatabase::find_editable_pth_source_root(&pth_index, "foo", "foo", site_packages);
1968        assert!(
1969            result.is_none(),
1970            "foo should not match foo-bar.pth (different package)"
1971        );
1972
1973        // "foo-bar" exact match should work
1974        let result = FixtureDatabase::find_editable_pth_source_root(
1975            &pth_index,
1976            "foo-bar",
1977            "foo_bar",
1978            site_packages,
1979        );
1980        assert!(result.is_some(), "foo-bar should match foo-bar.pth exactly");
1981    }
1982}