1use 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#[derive(Debug, Clone)]
13pub(crate) struct Pytest11EntryPoint {
14 pub(crate) name: String,
16 pub(crate) module_path: String,
18}
19
20impl FixtureDatabase {
21 const SKIP_DIRECTORIES: &'static [&'static str] = &[
24 ".git",
26 ".hg",
27 ".svn",
28 ".venv",
30 "venv",
31 "env",
32 ".env",
33 "__pycache__",
35 ".pytest_cache",
36 ".mypy_cache",
37 ".ruff_cache",
38 ".tox",
39 ".nox",
40 "build",
41 "dist",
42 ".eggs",
43 "node_modules",
45 "bower_components",
46 "target",
48 ".idea",
50 ".vscode",
51 ".cache",
53 ".local",
54 "vendor",
55 "site-packages",
56 ];
57
58 pub(crate) fn should_skip_directory(dir_name: &str) -> bool {
60 if Self::SKIP_DIRECTORIES.contains(&dir_name) {
62 return true;
63 }
64 if dir_name.ends_with(".egg-info") {
66 return true;
67 }
68 false
69 }
70
71 pub fn scan_workspace(&self, root_path: &Path) {
74 self.scan_workspace_with_excludes(root_path, &[]);
75 }
76
77 pub fn scan_workspace_with_excludes(&self, root_path: &Path, exclude_patterns: &[Pattern]) {
79 let root_path_buf = root_path
80 .canonicalize()
81 .unwrap_or_else(|_| root_path.to_path_buf());
82 let root_path = root_path_buf.as_path();
83
84 info!("Scanning workspace: {:?}", root_path);
85
86 *self.workspace_root.lock().unwrap() = Some(root_path.to_path_buf());
87
88 if !root_path.exists() {
89 warn!(
90 "Workspace path does not exist, skipping scan: {:?}",
91 root_path
92 );
93 return;
94 }
95
96 let mut files_to_process: Vec<std::path::PathBuf> = Vec::new();
98 let mut skipped_dirs = 0;
99
100 let walker = WalkDir::new(root_path).into_iter().filter_entry(|entry| {
102 if entry.file_type().is_file() {
104 return true;
105 }
106 if let Some(dir_name) = entry.file_name().to_str() {
108 !Self::should_skip_directory(dir_name)
109 } else {
110 true
111 }
112 });
113
114 for entry in walker {
115 let entry = match entry {
116 Ok(e) => e,
117 Err(err) => {
118 if err
120 .io_error()
121 .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
122 {
123 warn!(
124 "Permission denied accessing path during workspace scan: {}",
125 err
126 );
127 } else {
128 debug!("Error during workspace scan: {}", err);
129 }
130 continue;
131 }
132 };
133
134 let path = entry.path();
135
136 if let Ok(relative) = path.strip_prefix(root_path) {
137 if relative.components().any(|c| {
138 c.as_os_str()
139 .to_str()
140 .is_some_and(Self::should_skip_directory)
141 }) {
142 skipped_dirs += 1;
143 continue;
144 }
145 }
146
147 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 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 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 self.scan_venv_fixtures(root_path);
221
222 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 fn scan_imported_fixture_modules(&self, _root_path: &Path) {
235 use std::collections::HashSet;
236
237 info!("Scanning for imported fixture modules");
238
239 let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
241
242 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 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 let importer_is_plugin = self.plugin_fixture_files.contains_key(file_path);
310
311 let Some(content) = self.get_file_content(file_path) else {
313 continue;
314 };
315
316 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 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 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 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 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 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 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 files_to_check = new_modules.into_iter().collect();
420 }
421
422 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 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 fn scan_venv_fixtures(&self, root_path: &Path) {
448 info!("Scanning for pytest plugins in virtual environment");
449
450 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 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 let lib_path = venv_path.join("lib");
493 debug!("Checking lib path: {:?}", lib_path);
494
495 if lib_path.exists() {
496 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 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 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 if line.starts_with('[') && line.ends_with(']') {
556 in_pytest11_section = line == "[pytest11]";
557 continue;
558 }
559
560 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 fn resolve_entry_point_module_to_path(
581 site_packages: &Path,
582 module_path: &str,
583 ) -> Option<PathBuf> {
584 let module_path = module_path.split(':').next().unwrap_or(module_path);
586
587 let parts: Vec<&str> = module_path.split('.').collect();
589
590 if parts.is_empty() {
591 return None;
592 }
593
594 if parts
596 .iter()
597 .any(|p| p.contains("..") || p.contains('\0') || p.is_empty())
598 {
599 return None;
600 }
601
602 let mut path = site_packages.to_path_buf();
604 for part in &parts {
605 path.push(part);
606 }
607
608 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 let py_file = path.with_extension("py");
621 if py_file.exists() {
622 return check_bounded(&py_file);
623 }
624
625 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 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 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 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, };
668
669 let entries = Self::parse_pytest11_entry_points(&content);
670
671 if entries.is_empty() {
672 return 0; }
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 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 fn extract_package_name_from_dist_info(dir_name: &str) -> Option<(String, String)> {
743 let name_version = dir_name
745 .strip_suffix(".dist-info")
746 .or_else(|| dir_name.strip_suffix(".egg-info"))?;
747
748 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 let normalized = name.replace(['-', '.'], "_").to_lowercase();
762 Some((raw, normalized))
763 }
764
765 fn discover_editable_installs(&self, site_packages: &Path) {
767 info!("Scanning for editable installs in: {:?}", site_packages);
768
769 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 self.editable_install_roots.lock().unwrap().clear();
780
781 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 let json: serde_json::Value = match serde_json::from_str(&content) {
805 Ok(v) => v,
806 Err(_) => continue,
807 };
808
809 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 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 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 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 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 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 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 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 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 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 self.discover_editable_installs(site_packages);
984
985 let mut plugin_count = 0;
986
987 self.scan_pytest_internal_fixtures(site_packages);
989
990 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 if !filename.ends_with(".dist-info") && !filename.ends_with(".egg-info") {
1002 continue;
1003 }
1004
1005 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 for entry in WalkDir::new(plugin_dir)
1022 .max_depth(3) .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 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
1031 if filename.starts_with("test_") || filename.contains("__pycache__") {
1033 continue;
1034 }
1035
1036 debug!("Scanning plugin file: {:?}", path);
1037
1038 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 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 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 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 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 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 fs::write(site_packages.join("my_plugin.py"), "# plugin code").unwrap();
1203
1204 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 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 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 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 fs::write(site_packages.join("valid.py"), "# code").unwrap();
1257
1258 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 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 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 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 let outside = tempdir().unwrap();
1309 fs::write(outside.path().join("evil.py"), "# malicious").unwrap();
1310
1311 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 let temp = tempdir().unwrap();
1326 let site_packages = temp.path();
1327
1328 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 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 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 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 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 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 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 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 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 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 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 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 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 let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1494 fs::create_dir_all(&dist_info).unwrap();
1495 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 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 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 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 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 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 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 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 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 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 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 let db = FixtureDatabase::new();
1694 db.scan_pytest_plugins(site_packages);
1695
1696 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let canonical_plugin = plugin_file.canonicalize().unwrap();
2097 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2098
2099 db.analyze_file(canonical_plugin.clone(), plugin_content);
2101
2102 db.scan_imported_fixture_modules(&workspace_canonical);
2105
2106 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 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 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 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 let canonical_plugin = plugin_file.canonicalize().unwrap();
2201 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2202
2203 db.analyze_file(canonical_plugin.clone(), plugin_content);
2205
2206 db.scan_imported_fixture_modules(&workspace_canonical);
2208
2209 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 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 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 db.analyze_file(canonical_conftest.clone(), conftest_content);
2271
2272 db.scan_imported_fixture_modules(&workspace_canonical);
2273
2274 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 }
2289
2290 #[test]
2291 fn test_already_cached_module_marked_plugin_via_pytest_plugins() {
2292 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 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 db.analyze_file(canonical_helpers.clone(), helpers_content);
2324
2325 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 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 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2352 db.analyze_file(canonical_plugin.clone(), plugin_content);
2353
2354 db.scan_imported_fixture_modules(&workspace_canonical);
2357
2358 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 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 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 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 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 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 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 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 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 }
2512}