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