1use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, run_command_with_timeout};
14use anyhow::{Result, anyhow};
15use std::collections::{HashMap, HashSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const PYVENV_CFG: &str = "pyvenv.cfg";
21
22const FOREIGN_PYTHON_LOCKFILES: [&str; 3] = ["poetry.lock", "Pipfile.lock", "pdm.lock"];
29
30pub(super) const BASELINE_DISTRIBUTIONS: [&str; 4] =
33 ["pip", "setuptools", "wheel", "pkg-resources"];
34
35pub struct Venv;
37
38fn find_venv_dirs(path: &Path) -> Vec<std::path::PathBuf> {
42 let mut found = Vec::new();
43
44 let Ok(entries) = fs::read_dir(path) else {
45 return found;
46 };
47
48 for entry in entries.flatten() {
49 let entry_path = entry.path();
50 if entry_path.is_dir() && entry_path.join(PYVENV_CFG).exists() {
51 found.push(entry_path);
52 }
53 }
54
55 found
56}
57
58fn is_poetry_project(path: &Path) -> bool {
63 fs::read_to_string(path.join("pyproject.toml"))
64 .map(|c| {
65 c.lines()
66 .any(|l| l.trim_start().starts_with("[tool.poetry"))
67 })
68 .unwrap_or(false)
69}
70
71pub(super) fn normalize_package_name(name: &str) -> String {
74 let mut out = String::with_capacity(name.len());
75 let mut last_dash = false;
76 for c in name.chars() {
77 if c == '-' || c == '_' || c == '.' {
78 if !last_dash {
79 out.push('-');
80 }
81 last_dash = true;
82 } else {
83 out.push(c.to_ascii_lowercase());
84 last_dash = false;
85 }
86 }
87 out
88}
89
90fn requirement_name(spec: &str) -> Option<String> {
96 let name: String = spec
97 .chars()
98 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
99 .collect();
100 if name.is_empty() || name.starts_with('.') || spec.contains("://") && !spec.contains(" @ ") {
102 return None;
103 }
104 Some(normalize_package_name(&name))
105}
106
107pub(crate) fn requirement_names(
113 file: &Path,
114 visited: &mut Vec<PathBuf>,
115) -> Option<HashSet<String>> {
116 if visited.len() >= 8 || visited.iter().any(|p| p == file) {
119 return None;
120 }
121 visited.push(file.to_path_buf());
122
123 let content = fs::read_to_string(file).ok()?;
124 let dir = file.parent()?;
125 let mut names = HashSet::new();
126
127 for raw in content.lines() {
128 let mut line = raw.trim();
131 if let Some(idx) = line.find(" #") {
132 line = &line[..idx];
133 }
134 let line = line.trim_end_matches('\\').trim();
135 if line.is_empty() || line.starts_with('#') {
136 continue;
137 }
138
139 if let Some(included) = line
140 .strip_prefix("-r ")
141 .or_else(|| line.strip_prefix("--requirement "))
142 .or_else(|| line.strip_prefix("-c "))
143 .or_else(|| line.strip_prefix("--constraint "))
144 {
145 names.extend(requirement_names(&dir.join(included.trim()), visited)?);
146 continue;
147 }
148
149 if line.starts_with('-') {
150 if line.starts_with("-e") || line.starts_with("--editable") {
153 return None;
154 }
155 continue;
156 }
157
158 let spec = line.split(" @ ").next().unwrap_or(line).trim();
160 names.insert(requirement_name(spec)?);
161 }
162
163 Some(names)
164}
165
166pub(super) fn installed_distributions(venv: &Path) -> Option<HashMap<String, Vec<String>>> {
173 let mut site_packages: Vec<PathBuf> = Vec::new();
174 let windows_layout = venv.join("Lib").join("site-packages");
175 if windows_layout.is_dir() {
176 site_packages.push(windows_layout);
177 }
178 for lib in ["lib", "lib64"] {
181 let Ok(entries) = fs::read_dir(venv.join(lib)) else {
182 continue;
183 };
184 for entry in entries.flatten() {
185 let sp = entry.path().join("site-packages");
186 if sp.is_dir() {
187 site_packages.push(sp);
188 }
189 }
190 }
191 if site_packages.is_empty() {
192 return None;
193 }
194
195 let mut installed = HashMap::new();
196 for sp in site_packages {
197 let Ok(entries) = fs::read_dir(&sp) else {
198 continue;
199 };
200 for entry in entries.flatten() {
201 let file_name = entry.file_name().to_string_lossy().into_owned();
202 let Some(stem) = file_name
203 .strip_suffix(".dist-info")
204 .or_else(|| file_name.strip_suffix(".egg-info"))
205 else {
206 continue;
207 };
208 let name = stem
215 .match_indices('-')
216 .find(|(i, _)| {
217 stem[i + 1..]
218 .chars()
219 .next()
220 .is_some_and(|c| c.is_ascii_digit())
221 })
222 .map(|(i, _)| &stem[..i])
223 .unwrap_or(stem);
224 installed.insert(
225 normalize_package_name(name),
226 declared_dependencies(&entry.path()),
227 );
228 }
229 }
230 Some(installed)
231}
232
233fn declared_dependencies(dist_info: &Path) -> Vec<String> {
238 let Ok(metadata) = fs::read_to_string(dist_info.join("METADATA")) else {
239 return Vec::new();
240 };
241 let mut deps = Vec::new();
242 for line in metadata.lines() {
243 if line.is_empty() {
246 break;
247 }
248 if let Some(spec) = line.strip_prefix("Requires-Dist:")
249 && let Some(name) = requirement_name(spec.trim())
250 {
251 deps.push(name);
252 }
253 }
254 deps
255}
256
257fn venv_python_version(venv: &Path) -> Option<(u64, u64)> {
259 let tag = super::venv_runtime_tag(venv)?;
260 let (major, minor) = tag.split_once('.')?;
261 Some((major.parse().ok()?, minor.parse().ok()?))
262}
263
264fn path_python_version() -> Option<(u64, u64)> {
267 let output = crate::spawn::command(super::resolve_program("python"))
268 .arg("--version")
269 .stdin(std::process::Stdio::null())
270 .output()
271 .ok()?;
272 if !output.status.success() {
273 return None;
274 }
275 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
277 let text = if stdout.is_empty() {
278 String::from_utf8_lossy(&output.stderr).trim().to_string()
279 } else {
280 stdout
281 };
282 let version = text.split_whitespace().nth(1)?;
283 let mut parts = version.split('.');
284 Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
285}
286
287fn warn_about_restore_surprises(path: &Path, venvs: &[PathBuf]) {
291 if venvs.len() > 1 {
292 crate::output::print_warning(&format!(
293 "`{}` has {} virtual environments, all rebuilt from one requirements.txt. \
294 Each restores under its own recorded name; a plain `devp restore` with no \
295 record rebuilds only `.venv`.",
296 crate::output::clean_path(path),
297 venvs.len()
298 ));
299 } else if let Some(venv) = venvs.first() {
300 let name = venv.file_name().map(|n| n.to_string_lossy().into_owned());
301 if let Some(name) = name
302 && name != ".venv"
303 {
304 crate::output::print_info(&format!(
305 "The environment at `{}` is named `{name}` — `devp restore --last-run` \
306 recreates that name, but a restore with no record creates `.venv`.",
307 crate::output::clean_path(venv)
308 ));
309 }
310 }
311
312 let on_path = path_python_version();
313 for venv in venvs {
314 if let (Some(built_with), Some(available)) = (venv_python_version(venv), on_path)
315 && built_with != available
316 {
317 crate::output::print_warning(&format!(
318 "`{}` was built with Python {}.{}, but `python` on PATH is {}.{} — a \
319 restore would rebuild it on that interpreter instead, and pinned \
320 wheels may not exist for it.",
321 crate::output::clean_path(venv),
322 built_with.0,
323 built_with.1,
324 available.0,
325 available.1
326 ));
327 let dir = crate::output::clean_path(venv);
332 let (major, minor) = built_with;
333 #[cfg(windows)]
334 let native = format!("py -{major}.{minor} -m venv \"{dir}\"");
335 #[cfg(not(windows))]
336 let native = format!("python{major}.{minor} -m venv \"{dir}\"");
337 crate::output::print_info(&format!(
338 " Rebuild on {major}.{minor}: uv venv --python {major}.{minor} \"{dir}\" (or `{native}`)"
339 ));
340 }
341 }
342}
343
344pub(crate) fn is_dev_prune(name: &str) -> bool {
357 normalize_package_name(name) == crate::constants::APP_NAME
358}
359
360fn unrecorded_packages(
361 installed: &HashMap<String, Vec<String>>,
362 pinned: &HashSet<String>,
363) -> Vec<String> {
364 let mut reachable: HashSet<String> = HashSet::new();
365 let mut queue: Vec<String> = pinned.iter().cloned().collect();
366 queue.extend(BASELINE_DISTRIBUTIONS.iter().map(|s| (*s).to_string()));
367
368 while let Some(name) = queue.pop() {
369 if !reachable.insert(name.clone()) {
370 continue;
371 }
372 if let Some(deps) = installed.get(&name) {
373 queue.extend(deps.iter().cloned());
374 }
375 }
376
377 let mut extras: Vec<String> = installed
378 .keys()
379 .filter(|name| !reachable.contains(*name))
380 .cloned()
381 .collect();
382 extras.sort();
383 extras
384}
385
386impl PackageManager for Venv {
387 fn name(&self) -> &'static str {
388 "venv"
389 }
390
391 fn detect(&self, path: &Path) -> bool {
399 let req_txt = path.join("requirements.txt");
400 let uv_lock = path.join("uv.lock");
401
402 if !req_txt.exists() || uv_lock.exists() {
403 return false;
404 }
405
406 if FOREIGN_PYTHON_LOCKFILES
410 .iter()
411 .any(|f| path.join(f).exists())
412 || is_poetry_project(path)
413 {
414 return false;
415 }
416
417 !find_venv_dirs(path).is_empty()
418 }
419
420 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
422 find_venv_dirs(path)
423 .into_iter()
424 .map(|venv_path| {
425 let name = venv_path
426 .file_name()
427 .map(|n| n.to_string_lossy().to_string())
428 .unwrap_or_else(|| venv_path.display().to_string());
429 let size = dir_size(&venv_path);
430 BloatDir {
431 name,
432 path: venv_path,
433 size_bytes: size,
434 shared_bytes: 0,
435 }
436 })
437 .collect()
438 }
439
440 fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
443 let req_txt = path.join("requirements.txt");
444 if !req_txt.exists() {
445 return Err(anyhow!("requirements.txt missing"));
446 }
447 let has_requirements = fs::read_to_string(&req_txt)
450 .map(|c| {
451 c.lines()
452 .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
453 })
454 .unwrap_or(false);
455 if !has_requirements {
456 return Err(anyhow!(
457 "requirements.txt at `{}` lists no packages — the virtual environment \
458 could not be rebuilt after deletion. Populate it with `pip freeze > requirements.txt`.",
459 req_txt.display()
460 ));
461 }
462
463 let venvs = find_venv_dirs(path);
464 warn_about_restore_surprises(path, &venvs);
465
466 if let Some(pinned) = requirement_names(&req_txt, &mut Vec::new()) {
472 for venv in venvs {
473 let Some(installed) = installed_distributions(&venv) else {
474 continue;
475 };
476 let extras = unrecorded_packages(&installed, &pinned);
477 if extras.is_empty() {
478 continue;
479 }
480 let shown = extras
481 .iter()
482 .take(10)
483 .cloned()
484 .collect::<Vec<_>>()
485 .join(", ");
486 let suffix = if extras.len() > 10 {
487 format!(", … and {} more", extras.len() - 10)
488 } else {
489 String::new()
490 };
491 if extras.len() == 1 && is_dev_prune(&extras[0]) {
498 return Err(anyhow!(
499 "`{}` holds {app}, which requirements.txt does not account for. \
500 {app} is installed inside this project's virtual environment, \
501 and a tool install belongs outside a project. Either remove it \
502 — `pip uninstall {app}`, then `uv tool install {app}` — or \
503 record it as a deliberate dev dependency with `pip freeze > \
504 requirements.txt`. Either one makes the environment prunable. \
505 Nothing was deleted.",
506 venv.display(),
507 app = crate::constants::APP_NAME
508 ));
509 }
510 return Err(anyhow!(
511 "`{}` holds {} package(s) that requirements.txt does not account for \
512 ({shown}{suffix}). Deleting the environment would lose them with no \
513 way back. Record them first: `pip freeze > requirements.txt`.",
514 venv.display(),
515 extras.len()
516 ));
517 }
518 }
519 Ok(())
520 }
521
522 fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
526 self.restore_named(path, ".venv", None, timeout)
527 }
528
529 fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
531 super::venv_runtime_tag(&path.join(dir_name))
532 }
533
534 fn restore_named(
537 &self,
538 path: &Path,
539 dir_name: &str,
540 runtime: Option<&str>,
541 timeout: std::time::Duration,
542 ) -> Result<()> {
543 let dir_name = if dir_name.is_empty()
546 || dir_name == "."
547 || dir_name == ".."
548 || dir_name.contains(['/', '\\'])
549 {
550 ".venv"
551 } else {
552 dir_name
553 };
554 let launcher = runtime
561 .filter(|tag| super::python_runtime_available(tag))
562 .and_then(super::python_launcher);
563 match launcher {
564 Some((program, prefix)) => {
565 let mut args: Vec<&str> = prefix.iter().map(String::as_str).collect();
566 args.extend_from_slice(&["-m", "venv", dir_name]);
567 run_command_with_timeout(&program, &args, path, timeout)?;
568 }
569 None => {
570 run_command_with_timeout("python", &["-m", "venv", dir_name], path, timeout)?;
571 }
572 }
573 #[cfg(windows)]
576 let python = path.join(dir_name).join("Scripts").join("python.exe");
577 #[cfg(not(windows))]
578 let python = path.join(dir_name).join("bin").join("python");
579 run_command_with_timeout(
580 &python.to_string_lossy(),
581 &["-m", "pip", "install", "-r", "requirements.txt"],
582 path,
583 timeout,
584 )
585 }
586
587 fn lockfiles(&self) -> &'static [&'static str] {
591 &["requirements.txt"]
592 }
593
594 fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
597 let Some(pinned) = requirement_names(&path.join("requirements.txt"), &mut Vec::new())
598 else {
599 return Vec::new();
600 };
601 let mut reports = Vec::new();
602 for venv in find_venv_dirs(path) {
603 let Some(installed) = installed_distributions(&venv) else {
604 continue;
605 };
606 let extras = unrecorded_packages(&installed, &pinned);
607 if extras.is_empty() {
608 continue;
609 }
610 reports.push(super::DriftReport {
611 directory: venv
612 .file_name()
613 .map(|n| n.to_string_lossy().into_owned())
614 .unwrap_or_else(|| venv.display().to_string()),
615 unrecorded: extras,
616 record_command: "pip freeze > requirements.txt",
617 });
618 }
619 reports
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use std::fs::{self, File};
627 use tempfile::tempdir;
628
629 fn make_venv(dir: &Path, name: &str) {
630 let venv = dir.join(name);
631 fs::create_dir(&venv).unwrap();
632 File::create(venv.join(PYVENV_CFG)).unwrap();
633 }
634
635 #[test]
636 fn test_name() {
637 assert_eq!(Venv.name(), "venv");
638 }
639
640 #[test]
641 fn test_detect_positive_dot_venv() {
642 let dir = tempdir().unwrap();
643 File::create(dir.path().join("requirements.txt")).unwrap();
644 make_venv(dir.path(), ".venv");
645 assert!(Venv.detect(dir.path()));
646 }
647
648 #[test]
649 fn test_detect_positive_venv() {
650 let dir = tempdir().unwrap();
651 File::create(dir.path().join("requirements.txt")).unwrap();
652 make_venv(dir.path(), "venv");
653 assert!(Venv.detect(dir.path()));
654 }
655
656 #[test]
657 fn test_detect_positive_custom_name() {
658 let dir = tempdir().unwrap();
659 File::create(dir.path().join("requirements.txt")).unwrap();
660 make_venv(dir.path(), "my_env");
661 assert!(Venv.detect(dir.path()));
662 }
663
664 #[test]
665 fn test_detect_positive_env() {
666 let dir = tempdir().unwrap();
667 File::create(dir.path().join("requirements.txt")).unwrap();
668 make_venv(dir.path(), "env");
669 assert!(Venv.detect(dir.path()));
670 }
671
672 #[test]
673 fn test_detect_negative_no_req() {
674 let dir = tempdir().unwrap();
675 make_venv(dir.path(), ".venv");
676 assert!(!Venv.detect(dir.path()));
677 }
678
679 #[test]
680 fn test_detect_negative_no_env() {
681 let dir = tempdir().unwrap();
682 File::create(dir.path().join("requirements.txt")).unwrap();
683 fs::create_dir(dir.path().join("not_a_venv")).unwrap();
685 assert!(!Venv.detect(dir.path()));
686 }
687
688 #[test]
689 fn test_detect_negative_uv_lock() {
690 let dir = tempdir().unwrap();
691 File::create(dir.path().join("requirements.txt")).unwrap();
692 File::create(dir.path().join("uv.lock")).unwrap();
693 make_venv(dir.path(), ".venv");
694 assert!(!Venv.detect(dir.path()));
695 }
696
697 #[test]
698 fn test_bloat_dirs_present() {
699 let dir = tempdir().unwrap();
700 make_venv(dir.path(), ".venv");
701 make_venv(dir.path(), "my_env");
702 let dirs = Venv.bloat_dirs(dir.path());
703 assert_eq!(dirs.len(), 2);
704 let names: Vec<&str> = dirs.iter().map(|d| d.name.as_str()).collect();
705 assert!(names.contains(&".venv"));
706 assert!(names.contains(&"my_env"));
707 }
708
709 #[test]
710 fn test_bloat_dirs_absent() {
711 let dir = tempdir().unwrap();
712 let dirs = Venv.bloat_dirs(dir.path());
713 assert!(dirs.is_empty());
714 }
715
716 #[test]
717 fn test_bloat_dirs_ignores_non_venv_dirs() {
718 let dir = tempdir().unwrap();
719 fs::create_dir(dir.path().join("src")).unwrap();
721 make_venv(dir.path(), ".venv");
722 let dirs = Venv.bloat_dirs(dir.path());
723 assert_eq!(dirs.len(), 1);
724 assert_eq!(dirs[0].name, ".venv");
725 }
726
727 fn install_package(root: &Path, venv: &str, name: &str, requires: &[&str]) {
731 let dist_info = root
732 .join(venv)
733 .join("Lib")
734 .join("site-packages")
735 .join(format!("{name}-1.0.0.dist-info"));
736 fs::create_dir_all(&dist_info).unwrap();
737 let mut metadata = format!("Metadata-Version: 2.1\nName: {name}\nVersion: 1.0.0\n");
738 for dep in requires {
739 metadata.push_str(&format!("Requires-Dist: {dep}\n"));
740 }
741 fs::write(dist_info.join("METADATA"), metadata).unwrap();
742 }
743
744 #[test]
745 fn enforce_refuses_when_requirements_lists_nothing() {
746 let dir = tempdir().unwrap();
747 fs::write(dir.path().join("requirements.txt"), "# nothing pinned\n\n").unwrap();
748 make_venv(dir.path(), ".venv");
749
750 let err = Venv
751 .enforce_lockfile(dir.path(), EnforcePolicy::default())
752 .unwrap_err();
753 assert!(err.to_string().contains("lists no packages"));
754 }
755
756 #[test]
757 fn enforce_refuses_a_package_the_requirements_never_recorded() {
758 let dir = tempdir().unwrap();
761 fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
762 make_venv(dir.path(), ".venv");
763 install_package(dir.path(), ".venv", "flask", &[]);
764 install_package(dir.path(), ".venv", "requests", &[]);
765
766 let err = Venv
767 .enforce_lockfile(dir.path(), EnforcePolicy::default())
768 .unwrap_err()
769 .to_string();
770 assert!(
771 err.contains("requests"),
772 "names the unrecorded package: {err}"
773 );
774 assert!(
775 !err.contains("flask"),
776 "must not blame the pinned one: {err}"
777 );
778 assert!(err.contains("pip freeze"), "says how to record it: {err}");
779 }
780
781 #[test]
782 fn enforce_accepts_transitive_dependencies_of_pinned_packages() {
783 let dir = tempdir().unwrap();
786 fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
787 make_venv(dir.path(), ".venv");
788 install_package(
789 dir.path(),
790 ".venv",
791 "requests",
792 &["urllib3 (>=1.21.1)", "charset-normalizer"],
793 );
794 install_package(dir.path(), ".venv", "urllib3", &[]);
795 install_package(dir.path(), ".venv", "charset_normalizer", &[]);
797
798 assert!(
799 Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
800 .is_ok()
801 );
802 }
803
804 #[test]
805 fn enforce_skips_the_comparison_when_requirements_cannot_be_parsed() {
806 let dir = tempdir().unwrap();
809 fs::write(
810 dir.path().join("requirements.txt"),
811 "-e ./local-package\nflask==3.0.0\n",
812 )
813 .unwrap();
814 make_venv(dir.path(), ".venv");
815 install_package(dir.path(), ".venv", "left-behind", &[]);
816
817 assert!(
818 Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
819 .is_ok()
820 );
821 }
822
823 #[test]
824 fn drift_names_the_venv_and_the_unrecorded_packages() {
825 let dir = tempdir().unwrap();
826 fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
827 make_venv(dir.path(), ".venv");
828 install_package(dir.path(), ".venv", "requests", &[]);
829 install_package(dir.path(), ".venv", "sneaky-pkg", &[]);
830
831 let reports = Venv.drift(dir.path());
832 assert_eq!(reports.len(), 1);
833 assert_eq!(reports[0].directory, ".venv");
834 assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
835 assert_eq!(reports[0].record_command, "pip freeze > requirements.txt");
836 }
837
838 #[test]
841 fn drift_does_not_flag_transitive_dependencies_of_pinned_packages() {
842 let dir = tempdir().unwrap();
843 fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
844 make_venv(dir.path(), ".venv");
845 install_package(dir.path(), ".venv", "requests", &["urllib3"]);
846 install_package(dir.path(), ".venv", "urllib3", &[]);
847
848 assert!(Venv.drift(dir.path()).is_empty());
849 }
850
851 #[test]
852 fn enforce_names_dev_prune_as_the_situation_it_actually_is() {
853 let dir = tempdir().unwrap();
857 fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
858 make_venv(dir.path(), ".venv");
859 install_package(dir.path(), ".venv", "flask", &[]);
860 install_package(dir.path(), ".venv", "dev_prune", &[]);
862
863 let err = Venv
864 .enforce_lockfile(dir.path(), EnforcePolicy::default())
865 .unwrap_err()
866 .to_string();
867 assert!(
868 err.contains("pip uninstall dev-prune"),
869 "offers the removal: {err}"
870 );
871 assert!(
872 err.contains("uv tool install dev-prune"),
873 "offers the tool install: {err}"
874 );
875 assert!(err.contains("pip freeze"), "offers recording it: {err}");
876 assert!(
877 err.contains("Nothing was deleted"),
878 "is still a refusal: {err}"
879 );
880 }
881
882 #[test]
883 fn enforce_accepts_dev_prune_when_requirements_records_it() {
884 let dir = tempdir().unwrap();
888 fs::write(dir.path().join("requirements.txt"), "dev-prune==1.7.0\n").unwrap();
889 make_venv(dir.path(), ".venv");
890 install_package(dir.path(), ".venv", "dev_prune", &[]);
891
892 assert!(
893 Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
894 .is_ok()
895 );
896 }
897
898 #[test]
899 fn enforce_keeps_the_generic_message_when_dev_prune_is_not_alone() {
900 let dir = tempdir().unwrap();
904 fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
905 make_venv(dir.path(), ".venv");
906 install_package(dir.path(), ".venv", "flask", &[]);
907 install_package(dir.path(), ".venv", "dev_prune", &[]);
908 install_package(dir.path(), ".venv", "requests", &[]);
909
910 let err = Venv
911 .enforce_lockfile(dir.path(), EnforcePolicy::default())
912 .unwrap_err()
913 .to_string();
914 assert!(err.contains("requests"), "names the other stray: {err}");
915 assert!(err.contains("2 package(s)"), "counts both: {err}");
916 }
917
918 #[test]
919 fn is_dev_prune_accepts_every_spelling_pip_may_write() {
920 assert!(is_dev_prune("dev-prune"));
921 assert!(is_dev_prune("dev_prune"));
922 assert!(is_dev_prune("Dev-Prune"));
923 assert!(!is_dev_prune("dev-pruner"));
924 assert!(!is_dev_prune("prune"));
925 }
926}