1pub mod bun;
20pub mod bundler;
21pub mod cargo_adapter;
22pub mod cmake_build;
23pub mod cocoapods;
24pub mod composer;
25pub mod dart;
26pub mod go;
27pub mod gradle;
28pub mod maven;
29pub mod mix;
30pub mod mix_build;
31pub mod npm;
32pub mod pdm;
33pub mod pipenv;
34pub mod pnpm;
35pub mod poetry;
36pub mod swift;
37pub mod terraform;
38pub mod uv;
39pub mod vcpkg;
40pub mod venv;
41pub mod yarn;
42
43use std::collections::HashMap;
44use std::fmt;
45use std::path::{Path, PathBuf};
46use std::sync::{Mutex, OnceLock};
47
48use anyhow::{Context as _, Result};
49use walkdir::WalkDir;
50
51#[derive(Debug, Clone)]
53pub struct BloatDir {
54 pub name: String,
56 pub path: PathBuf,
58 pub size_bytes: u64,
60 pub shared_bytes: u64,
64}
65
66impl fmt::Display for BloatDir {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 write!(f, "{} ({})", self.name, self.path.display())
69 }
70}
71
72#[derive(Debug, Clone)]
75pub struct DriftReport {
76 pub directory: String,
78 pub unrecorded: Vec<String>,
80 pub record_command: &'static str,
82}
83
84pub trait PackageManager: Send + Sync {
92 fn name(&self) -> &'static str;
94
95 fn detect(&self, project_path: &Path) -> bool;
99
100 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
104
105 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
112
113 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
120
121 fn restore_named(
132 &self,
133 project_path: &Path,
134 dir_name: &str,
135 runtime: Option<&str>,
136 timeout: std::time::Duration,
137 ) -> Result<()> {
138 let _ = (dir_name, runtime);
139 self.restore(project_path, timeout)
140 }
141
142 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
152 let _ = (project_path, dir_name);
153 None
154 }
155
156 fn lockfiles(&self) -> &'static [&'static str] {
166 &[]
167 }
168
169 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
177 let _ = project_path;
178 Vec::new()
179 }
180
181 fn opt_in(&self) -> bool {
194 false
195 }
196}
197
198const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
200
201const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
209 ("pnpm", &[".pnpm", ".modules.yaml"]),
210 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
211 ("npm", &[".package-lock.json"]),
212];
213
214pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
218 vec![
219 Box::new(npm::Npm),
220 Box::new(pnpm::Pnpm),
221 Box::new(yarn::Yarn),
222 Box::new(bun::Bun),
223 Box::new(uv::Uv),
224 Box::new(poetry::Poetry),
225 Box::new(pdm::Pdm),
226 Box::new(pipenv::Pipenv),
227 Box::new(venv::Venv),
228 Box::new(cargo_adapter::Cargo),
229 Box::new(go::Go),
230 Box::new(composer::Composer),
231 Box::new(bundler::Bundler),
232 Box::new(cocoapods::CocoaPods),
233 Box::new(mix::Mix),
234 Box::new(mix_build::MixBuild),
235 Box::new(gradle::Gradle),
236 Box::new(maven::Maven),
237 Box::new(swift::Swift),
238 Box::new(terraform::Terraform),
239 Box::new(dart::Dart),
240 Box::new(vcpkg::Vcpkg),
241 Box::new(cmake_build::CmakeBuild),
242 ]
243}
244
245fn opt_in_enabled() -> &'static [String] {
253 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
254 ENABLED.get_or_init(|| {
255 crate::config::Registry::load()
256 .map(|r| {
257 let mut names = Vec::new();
258 if r.settings.enable_cargo {
259 names.push("cargo".to_string());
260 }
261 if r.settings.enable_gradle {
262 names.push("gradle".to_string());
263 }
264 if r.settings.enable_maven {
265 names.push("maven".to_string());
266 }
267 if r.settings.enable_swift {
268 names.push("swift".to_string());
269 }
270 if r.settings.enable_dart {
271 names.push("dart".to_string());
272 }
273 if r.settings.enable_mix_build {
274 names.push("mix_build".to_string());
275 }
276 if r.settings.enable_vcpkg {
277 names.push("vcpkg".to_string());
278 }
279 if r.settings.enable_cmake_build {
280 names.push("cmake_build".to_string());
281 }
282 names
283 })
284 .unwrap_or_default()
285 })
286}
287
288fn user_disabled() -> &'static [String] {
295 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
296 DISABLED.get_or_init(|| {
297 crate::config::Registry::load()
298 .map(|r| {
299 r.settings
300 .disabled_adapters
301 .iter()
302 .map(|n| n.trim().to_ascii_lowercase())
303 .filter(|n| !n.is_empty())
304 .collect()
305 })
306 .unwrap_or_default()
307 })
308}
309
310pub fn is_adapter_name(name: &str) -> bool {
312 get_all_adapters().iter().any(|a| a.name() == name)
313}
314
315pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
328 ("JavaScript", &["npm", "pnpm", "yarn", "bun"]),
329 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
330 ("Rust", &["cargo"]),
331 ("Go", &["go"]),
332 ("JVM", &["gradle", "maven"]),
333 ("PHP", &["composer"]),
334 ("Ruby", &["bundler"]),
335 ("Swift & Objective-C", &["swift", "cocoapods"]),
336 ("Elixir", &["mix", "mix_build"]),
337 ("Infrastructure", &["terraform"]),
338 ("Dart & Flutter", &["dart"]),
339 ("C & C++", &["vcpkg", "cmake_build"]),
340];
341
342pub fn adapter_group(name: &str) -> &'static str {
348 ADAPTER_GROUPS
349 .iter()
350 .find(|(_, names)| names.contains(&name))
351 .map(|(group, _)| *group)
352 .unwrap_or("Other")
353}
354
355pub fn all_adapter_names() -> Vec<&'static str> {
357 get_all_adapters().iter().map(|a| a.name()).collect()
358}
359
360pub fn opt_in_adapter_names() -> Vec<&'static str> {
365 get_all_adapters()
366 .iter()
367 .filter(|a| a.opt_in())
368 .map(|a| a.name())
369 .collect()
370}
371
372pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
379 detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
380}
381
382pub fn detect_all_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
392 let opt_in: Vec<String> = opt_in_adapter_names()
393 .into_iter()
394 .map(str::to_string)
395 .collect();
396 detect_adapters_with(project_path, &opt_in, &[])
397}
398
399fn detect_adapters_with(
407 project_path: &Path,
408 opt_in: &[String],
409 disabled: &[String],
410) -> Vec<Box<dyn PackageManager>> {
411 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
412 .into_iter()
413 .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
414 .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
415 .filter(|adapter| adapter.detect(project_path))
416 .collect();
417 resolve_conflicts(project_path, &mut detected);
418 detected
419}
420
421fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
423 resolve_js_conflict(project_path, detected);
424 resolve_python_conflict(project_path, detected);
425}
426
427fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
440 if detected
441 .iter()
442 .filter(|a| JS_MANAGERS.contains(&a.name()))
443 .count()
444 < 2
445 {
446 return;
447 }
448
449 let winner = declared_package_manager(project_path)
450 .filter(|name| detected.iter().any(|a| a.name() == name))
451 .or_else(|| installed_manager(project_path, detected))
452 .or_else(|| newest_lockfile_owner(project_path, detected));
453
454 let Some(winner) = winner else { return };
455 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
456}
457
458const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
471 ("uv", "uv.lock"),
472 ("poetry", "poetry.lock"),
473 ("pdm", "pdm.lock"),
474 ("pipenv", "Pipfile.lock"),
475];
476
477fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
478 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
479 .iter()
480 .copied()
481 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
482 .collect();
483 let Some(&(first, _)) = claimants.first() else {
484 return;
485 };
486 detected.retain(|a| a.name() != "venv");
487 if claimants.len() < 2 {
488 return;
489 }
490 let winner = claimants
491 .iter()
492 .find(|(_, lockfile)| project_path.join(lockfile).exists())
493 .map_or(first, |(name, _)| *name);
494 detected.retain(|a| {
495 a.name() == winner
496 || !PYTHON_ENV_MANAGERS
497 .iter()
498 .any(|(name, _)| *name == a.name())
499 });
500}
501
502fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
504 let node_modules = project_path.join("node_modules");
505 if !node_modules.is_dir() {
506 return None;
507 }
508
509 JS_INSTALL_MARKERS
510 .iter()
511 .find(|(name, markers)| {
512 detected.iter().any(|a| a.name() == *name)
513 && markers.iter().any(|m| node_modules.join(m).exists())
514 })
515 .map(|(name, _)| (*name).to_string())
516}
517
518fn declared_package_manager(project_path: &Path) -> Option<String> {
520 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
521 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
522 let declared = json.get("packageManager")?.as_str()?;
523 let name = declared.split('@').next().unwrap_or_default();
524 JS_MANAGERS
525 .iter()
526 .find(|m| **m == name)
527 .map(|m| (*m).to_string())
528}
529
530fn newest_lockfile_owner(
532 project_path: &Path,
533 detected: &[Box<dyn PackageManager>],
534) -> Option<String> {
535 detected
536 .iter()
537 .filter(|a| JS_MANAGERS.contains(&a.name()))
538 .filter_map(|a| {
539 let newest = a
540 .lockfiles()
541 .iter()
542 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
543 .max()?;
544 Some((newest, a.name().to_string()))
545 })
546 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
549 match best {
550 Some(b) if b.0 >= cur.0 => Some(b),
551 _ => Some(cur),
552 }
553 })
554 .map(|(_, name)| name)
555}
556
557pub fn dir_size(path: &Path) -> u64 {
559 if !path.exists() {
560 return 0;
561 }
562 WalkDir::new(path)
563 .follow_links(false)
564 .into_iter()
565 .flatten()
566 .filter_map(|entry| entry.metadata().ok())
567 .filter(|meta| meta.is_file())
568 .map(|meta| meta.len())
569 .sum()
570}
571
572#[derive(Debug, Clone, Copy, Default)]
574pub struct DirSizeBreakdown {
575 pub freed_bytes: u64,
577 pub shared_bytes: u64,
580}
581
582pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
593 let mut out = DirSizeBreakdown::default();
594 if !path.exists() {
595 return out;
596 }
597 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
599 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
600 let Ok(meta) = entry.metadata() else { continue };
601 if !meta.is_file() {
602 continue;
603 }
604 match file_link_identity(entry.path(), &meta) {
605 Some((dev, ino, nlink)) if nlink > 1 => {
606 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
607 }
608 _ => out.freed_bytes += meta.len(),
609 }
610 }
611 for (bytes, nlink, seen) in linked.into_values() {
612 if seen >= nlink {
613 out.freed_bytes += bytes;
614 } else {
615 out.shared_bytes += bytes;
616 }
617 }
618 out
619}
620
621#[cfg(unix)]
623fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
624 use std::os::unix::fs::MetadataExt as _;
625 Some((meta.dev(), meta.ino(), meta.nlink()))
626}
627
628#[cfg(windows)]
632fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
633 use std::os::windows::fs::OpenOptionsExt as _;
634 use std::os::windows::io::AsRawHandle as _;
635 use windows_sys::Win32::Storage::FileSystem::{
636 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
637 };
638
639 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
642 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
643 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
646 return None;
647 }
648 Some((
649 u64::from(info.dwVolumeSerialNumber),
650 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
651 u64::from(info.nNumberOfLinks),
652 ))
653}
654
655#[cfg(not(any(unix, windows)))]
656fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
657 None
658}
659
660pub fn resolve_program(program: &str) -> String {
670 #[cfg(windows)]
671 {
672 if Path::new(program).components().count() > 1 {
673 return program.to_string();
674 }
675 let Some(path_var) = std::env::var_os("PATH") else {
676 return program.to_string();
677 };
678 for dir in std::env::split_paths(&path_var) {
679 for ext in ["exe", "cmd", "bat"] {
680 let candidate = dir.join(format!("{program}.{ext}"));
681 if candidate.is_file() {
682 return candidate.to_string_lossy().into_owned();
683 }
684 }
685 }
686 }
687 program.to_string()
688}
689
690pub fn binary_available(program: &str) -> bool {
698 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
699 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
700
701 let mut guard = match cache.lock() {
704 Ok(g) => g,
705 Err(_) => return probe_binary(program),
708 };
709 if let Some(known) = guard.get(program) {
710 return *known;
711 }
712 let available = probe_binary(program);
713 guard.insert(program.to_string(), available);
714 available
715}
716
717const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
725
726fn version_probe_args(program: &str) -> &'static [&'static str] {
728 VERSION_PROBE_ARGS
729 .iter()
730 .find(|(name, _)| *name == program)
731 .map_or(&["--version"], |(_, args)| *args)
732}
733
734fn probe_binary(program: &str) -> bool {
736 crate::spawn::command(resolve_program(program))
737 .args(version_probe_args(program))
738 .stdin(std::process::Stdio::null())
739 .output()
740 .map(|o| o.status.success())
741 .unwrap_or(false)
742}
743
744struct CommandOutput {
746 status: std::process::ExitStatus,
747 stdout: String,
748 stderr: String,
749}
750
751fn spawn_capture(
758 program: &str,
759 args: &[&str],
760 cwd: &Path,
761 timeout: std::time::Duration,
762) -> Result<CommandOutput> {
763 use std::io::Read;
764 use std::process::Stdio;
765 use std::thread;
766 use std::time::Instant;
767
768 let resolved = resolve_program(program);
769 let mut child = crate::spawn::command(&resolved)
770 .args(args)
771 .current_dir(cwd)
772 .stdin(Stdio::null())
773 .stdout(Stdio::piped())
774 .stderr(Stdio::piped())
775 .spawn()
776 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
777
778 let mut stdout_pipe = child.stdout.take();
782 let mut stderr_pipe = child.stderr.take();
783 let stdout_reader = thread::spawn(move || {
784 let mut buf = Vec::new();
785 if let Some(pipe) = stdout_pipe.as_mut() {
786 let _ = pipe.read_to_end(&mut buf);
787 }
788 buf
789 });
790 let stderr_reader = thread::spawn(move || {
791 let mut buf = Vec::new();
792 if let Some(pipe) = stderr_pipe.as_mut() {
793 let _ = pipe.read_to_end(&mut buf);
794 }
795 buf
796 });
797
798 let start = Instant::now();
799 let status = loop {
800 match child.try_wait()? {
801 Some(status) => break status,
802 None => {
803 if start.elapsed() >= timeout {
804 let _ = child.kill();
805 let _ = child.wait();
806 anyhow::bail!(
807 "Command timed out after {}s: {} {}\n\
808 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
809 timeout.as_secs(),
810 program,
811 args.join(" ")
812 );
813 }
814 thread::sleep(std::time::Duration::from_millis(100));
815 }
816 }
817 };
818
819 let stderr = stderr_reader
820 .join()
821 .map(|b| String::from_utf8_lossy(&b).into_owned())
822 .unwrap_or_default();
823 let stdout = stdout_reader
824 .join()
825 .map(|b| String::from_utf8_lossy(&b).into_owned())
826 .unwrap_or_default();
827
828 Ok(CommandOutput {
829 status,
830 stdout,
831 stderr,
832 })
833}
834
835pub fn run_command_with_timeout(
837 program: &str,
838 args: &[&str],
839 cwd: &Path,
840 timeout: std::time::Duration,
841) -> Result<()> {
842 let out = spawn_capture(program, args, cwd, timeout)?;
843 if out.status.success() {
844 Ok(())
845 } else {
846 anyhow::bail!(
847 "{} {} failed (exit code {:?}):\n{}",
848 program,
849 args.join(" "),
850 out.status.code(),
851 crate::output::condense_tool_output(
852 &out.stderr,
853 crate::constants::TOOL_OUTPUT_MAX_LINES
854 )
855 )
856 }
857}
858
859pub struct CapturedCommand {
862 pub ok: bool,
864 pub stdout: String,
866 pub stderr: String,
868}
869
870pub fn capture_allowing_failure(
880 program: &str,
881 args: &[&str],
882 cwd: &Path,
883 timeout: std::time::Duration,
884) -> Result<CapturedCommand> {
885 let out = spawn_capture(program, args, cwd, timeout)?;
886 Ok(CapturedCommand {
887 ok: out.status.success(),
888 stdout: out.stdout,
889 stderr: out.stderr,
890 })
891}
892
893pub fn capture_command_with_timeout(
899 program: &str,
900 args: &[&str],
901 cwd: &Path,
902 timeout: std::time::Duration,
903) -> Result<String> {
904 let out = spawn_capture(program, args, cwd, timeout)?;
905 if out.status.success() {
906 Ok(out.stdout)
907 } else {
908 anyhow::bail!(
909 "{} {} failed (exit code {:?}):\n{}",
910 program,
911 args.join(" "),
912 out.status.code(),
913 crate::output::condense_tool_output(
914 &out.stderr,
915 crate::constants::TOOL_OUTPUT_MAX_LINES
916 )
917 )
918 }
919}
920
921pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
923 crate::spawn::command(resolve_program(program))
924 .args(args)
925 .current_dir(cwd)
926 .stdin(std::process::Stdio::null())
927 .output()
928 .map(|o| o.status.success())
929 .unwrap_or(false)
930}
931
932const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
940
941fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
948 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
949 Some("Cargo.lock") => "Cargo.toml",
950 Some("package-lock.json")
951 | Some("yarn.lock")
952 | Some("pnpm-lock.yaml")
953 | Some("bun.lockb")
954 | Some("bun.lock") => "package.json",
955 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
956 Some("go.sum") => "go.mod",
957 Some("composer.lock") => "composer.json",
958 Some("Gemfile.lock") => "Gemfile",
959 Some("Pipfile.lock") => "Pipfile",
960 _ => return Ok(()),
961 };
962 let manifest = cwd.join(manifest_name);
963 let (Ok(manifest_meta), Ok(lock_meta)) =
964 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
965 else {
966 return Ok(());
967 };
968 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
969 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
970 {
971 anyhow::bail!(
972 "`{program}` is not available, and `{manifest_name}` has been edited more \
973 recently than `{}` — the lockfile may no longer record the current \
974 dependencies, and without `{program}` that cannot be verified. Install \
975 {program} and run its lockfile sync, then prune again.",
976 lockfile.display()
977 );
978 }
979 Ok(())
980}
981
982pub fn refuse_if_manifest_stale(
993 manifest: &Path,
994 lockfile: &Path,
995 sync_command: &str,
996) -> Result<()> {
997 let (Ok(manifest_meta), Ok(lock_meta)) =
998 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
999 else {
1000 return Ok(());
1001 };
1002 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1003 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1004 {
1005 anyhow::bail!(
1006 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
1007 record the current dependencies. Run `{sync_command}` and prune again.",
1008 manifest.display(),
1009 lockfile.display()
1010 );
1011 }
1012 Ok(())
1013}
1014
1015pub fn lock_sync_or_verify_with_timeout(
1017 lockfile: &Path,
1018 program: &str,
1019 sync_args: &[&str],
1020 cwd: &Path,
1021 timeout: std::time::Duration,
1022) -> Result<()> {
1023 let lockfile_exists = lockfile.exists();
1024
1025 if !binary_available(program) {
1026 if lockfile_exists {
1027 refuse_if_manifest_newer(lockfile, program, cwd)?;
1028 return Ok(());
1029 } else {
1030 anyhow::bail!(
1031 "`{program}` is not available and no lockfile was found at `{}`. \
1032 Cannot safely delete dependencies — install {program} first, \
1033 or commit a lockfile.",
1034 lockfile.display()
1035 );
1036 }
1037 }
1038
1039 run_command_with_timeout(program, sync_args, cwd, timeout)
1041}
1042
1043#[derive(Debug, Clone, Copy)]
1049pub struct EnforcePolicy {
1050 pub allow_rewrite: bool,
1056 pub timeout: std::time::Duration,
1058}
1059
1060impl Default for EnforcePolicy {
1061 fn default() -> Self {
1062 Self {
1063 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1064 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1065 }
1066 }
1067}
1068
1069impl EnforcePolicy {
1070 pub fn from_settings(settings: &crate::config::Settings) -> Self {
1072 Self {
1073 allow_rewrite: settings.allow_manifest_rewrite,
1074 timeout: command_timeout(settings.command_timeout_secs),
1075 }
1076 }
1077}
1078
1079pub fn command_timeout(secs: u64) -> std::time::Duration {
1087 std::time::Duration::from_secs(secs.max(1))
1088}
1089
1090pub fn enforce_two_tier(
1106 lockfile: &Path,
1107 program: &str,
1108 verify_args: &[&str],
1109 write_args: &[&str],
1110 cwd: &Path,
1111 policy: EnforcePolicy,
1112) -> Result<()> {
1113 if policy.allow_rewrite {
1114 return lock_sync_or_verify_with_timeout(
1115 lockfile,
1116 program,
1117 write_args,
1118 cwd,
1119 policy.timeout,
1120 );
1121 }
1122 lock_verify_or_generate(
1123 lockfile,
1124 program,
1125 verify_args,
1126 write_args,
1127 cwd,
1128 policy.timeout,
1129 )
1130}
1131
1132pub fn lock_verify_or_generate(
1142 lockfile: &Path,
1143 program: &str,
1144 verify_args: &[&str],
1145 generate_args: &[&str],
1146 cwd: &Path,
1147 timeout: std::time::Duration,
1148) -> Result<()> {
1149 let lockfile_exists = lockfile.exists();
1150
1151 if !binary_available(program) {
1152 if lockfile_exists {
1153 refuse_if_manifest_newer(lockfile, program, cwd)?;
1154 return Ok(());
1155 }
1156 anyhow::bail!(
1157 "`{program}` is not available and no lockfile was found at `{}`. \
1158 Cannot safely delete dependencies — install {program} first, \
1159 or commit a lockfile.",
1160 lockfile.display()
1161 );
1162 }
1163
1164 if lockfile_exists {
1165 run_command_with_timeout(program, verify_args, cwd, timeout)
1166 } else {
1167 run_command_with_timeout(program, generate_args, cwd, timeout)
1168 }
1169}
1170
1171pub fn lock_sync_or_verify(
1173 lockfile: &Path,
1174 program: &str,
1175 sync_args: &[&str],
1176 cwd: &Path,
1177) -> Result<()> {
1178 lock_sync_or_verify_with_timeout(
1179 lockfile,
1180 program,
1181 sync_args,
1182 cwd,
1183 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1184 )
1185}
1186
1187const PYVENV_CFG: &str = "pyvenv.cfg";
1193
1194pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1201 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1202 for line in cfg.lines() {
1203 let Some((key, value)) = line.split_once('=') else {
1204 continue;
1205 };
1206 if matches!(key.trim(), "version" | "version_info") {
1207 let mut parts = value.trim().split('.');
1208 let major: u64 = parts.next()?.parse().ok()?;
1209 let minor: u64 = parts.next()?.parse().ok()?;
1210 return Some(format!("{major}.{minor}"));
1211 }
1212 }
1213 None
1214}
1215
1216pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1220 let mut parts = tag.split('.');
1221 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1222 return false;
1223 };
1224 !major.is_empty()
1225 && !minor.is_empty()
1226 && major.len() <= 2
1227 && minor.len() <= 3
1228 && major.bytes().all(|b| b.is_ascii_digit())
1229 && minor.bytes().all(|b| b.is_ascii_digit())
1230}
1231
1232pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1240 if !is_valid_runtime_tag(tag) {
1241 return None;
1242 }
1243 #[cfg(windows)]
1244 {
1245 Some(("py".to_string(), vec![format!("-{tag}")]))
1246 }
1247 #[cfg(not(windows))]
1248 {
1249 Some((format!("python{tag}"), Vec::new()))
1250 }
1251}
1252
1253pub(crate) fn python_executable(tag: &str) -> Option<String> {
1261 let (program, prefix) = python_launcher(tag)?;
1262 let out = crate::spawn::command(resolve_program(&program))
1263 .args(&prefix)
1264 .args(["-c", "import sys; print(sys.executable)"])
1265 .stdin(std::process::Stdio::null())
1266 .stderr(std::process::Stdio::null())
1267 .output()
1268 .ok()?;
1269 if !out.status.success() {
1270 return None;
1271 }
1272 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1273 (!path.is_empty()).then_some(path)
1274}
1275
1276pub(crate) fn python_runtime_available(tag: &str) -> bool {
1281 let Some((program, prefix)) = python_launcher(tag) else {
1282 return false;
1283 };
1284 crate::spawn::command(resolve_program(&program))
1285 .args(&prefix)
1286 .arg("--version")
1287 .stdin(std::process::Stdio::null())
1288 .stdout(std::process::Stdio::null())
1289 .stderr(std::process::Stdio::null())
1290 .status()
1291 .is_ok_and(|s| s.success())
1292}
1293
1294const NO_RESTORE_BINARY: [&str; 7] = [
1295 "venv",
1296 "gradle",
1297 "maven",
1298 "mix_build",
1299 "swift",
1300 "vcpkg",
1301 "cmake_build",
1302];
1303
1304const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1306
1307pub fn adapter_binary(adapter: &str) -> &str {
1309 ADAPTER_BINARIES
1310 .iter()
1311 .find(|(name, _)| *name == adapter)
1312 .map_or(adapter, |(_, binary)| *binary)
1313}
1314
1315const INSTALL_HINTS: [(&str, &str); 16] = [
1320 ("npm", "ships with Node.js — https://nodejs.org"),
1321 (
1322 "pnpm",
1323 "`npm install -g pnpm` — https://pnpm.io/installation",
1324 ),
1325 (
1326 "yarn",
1327 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1328 ),
1329 ("bun", "https://bun.sh/docs/installation"),
1330 (
1331 "uv",
1332 "https://docs.astral.sh/uv/getting-started/installation/",
1333 ),
1334 ("poetry", "https://python-poetry.org/docs/#installation"),
1335 (
1336 "pdm",
1337 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1338 ),
1339 (
1340 "pipenv",
1341 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1342 ),
1343 ("cargo", "ships with Rust — https://rustup.rs"),
1344 ("go", "https://go.dev/dl/"),
1345 ("composer", "https://getcomposer.org/download/"),
1346 ("bundler", "`gem install bundler` — https://bundler.io"),
1347 (
1348 "cocoapods",
1349 "`gem install cocoapods` — https://cocoapods.org",
1350 ),
1351 (
1352 "mix",
1353 "ships with Elixir — https://elixir-lang.org/install.html",
1354 ),
1355 (
1356 "terraform",
1357 "https://developer.hashicorp.com/terraform/install",
1358 ),
1359 (
1360 "dart",
1361 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1362 ),
1363];
1364
1365pub fn install_hint(adapter: &str) -> Option<&'static str> {
1367 INSTALL_HINTS
1368 .iter()
1369 .find(|(name, _)| *name == adapter)
1370 .map(|(_, hint)| *hint)
1371}
1372
1373#[derive(Debug, Clone)]
1375pub struct BinaryCheckStatus {
1376 pub name: String,
1377 pub available: bool,
1378 pub version: Option<String>,
1379}
1380
1381pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1383 let mut unique: Vec<String> = adapter_names
1384 .iter()
1385 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1388 .cloned()
1389 .collect();
1390 unique.sort();
1391 unique.dedup();
1392
1393 unique
1394 .into_iter()
1395 .map(|name| {
1396 let binary = adapter_binary(&name);
1397 let output = crate::spawn::command(resolve_program(binary))
1398 .args(version_probe_args(binary))
1399 .stdin(std::process::Stdio::null())
1400 .output();
1401 match output {
1402 Ok(out) if out.status.success() => {
1403 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1404 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1405 BinaryCheckStatus {
1406 name,
1407 available: true,
1408 version: if first_line.is_empty() {
1409 None
1410 } else {
1411 Some(first_line)
1412 },
1413 }
1414 }
1415 _ => BinaryCheckStatus {
1416 name,
1417 available: false,
1418 version: None,
1419 },
1420 }
1421 })
1422 .collect()
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use super::*;
1428 use std::fs;
1429 use tempfile::TempDir;
1430
1431 #[test]
1432 fn every_adapter_is_grouped_exactly_once() {
1433 let registered = all_adapter_names();
1436 let grouped: Vec<&str> = ADAPTER_GROUPS
1437 .iter()
1438 .flat_map(|(_, names)| names.iter().copied())
1439 .collect();
1440
1441 for name in ®istered {
1442 assert_eq!(
1443 grouped.iter().filter(|g| *g == name).count(),
1444 1,
1445 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1446 );
1447 }
1448 for name in &grouped {
1449 assert!(
1450 registered.contains(name),
1451 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1452 );
1453 }
1454 assert_eq!(registered.len(), grouped.len());
1455 }
1456
1457 #[test]
1458 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1459 let mut opt_in = opt_in_adapter_names();
1464 opt_in.sort_unstable();
1465 assert_eq!(
1466 opt_in,
1467 vec![
1468 "cargo",
1469 "cmake_build",
1470 "dart",
1471 "gradle",
1472 "maven",
1473 "mix_build",
1474 "swift",
1475 "vcpkg"
1476 ]
1477 );
1478 }
1479
1480 #[test]
1481 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1482 assert_eq!(version_probe_args("go"), &["version"]);
1487 assert_eq!(version_probe_args("npm"), &["--version"]);
1488 }
1489
1490 #[test]
1491 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1492 for adapter in get_all_adapters() {
1495 let name = adapter.name();
1496 if NO_RESTORE_BINARY.contains(&name) {
1497 continue;
1498 }
1499 assert!(
1500 install_hint(name).is_some(),
1501 "adapter `{name}` has no install hint"
1502 );
1503 }
1504 }
1505
1506 #[test]
1507 fn test_bloat_dir_display() {
1508 let bd = BloatDir {
1509 name: "node_modules".to_string(),
1510 path: PathBuf::from("/test/node_modules"),
1511 size_bytes: 1024,
1512 shared_bytes: 0,
1513 };
1514 assert!(bd.to_string().contains("node_modules"));
1515 }
1516
1517 #[test]
1518 fn test_hardlink_size_counts_a_plain_file_in_full() {
1519 let tmp = TempDir::new().unwrap();
1520 let tree = tmp.path().join("tree");
1521 fs::create_dir(&tree).unwrap();
1522 fs::write(tree.join("copied.txt"), "12345").unwrap();
1523 let size = dir_size_with_hardlinks(&tree);
1524 assert_eq!(size.freed_bytes, 5);
1525 assert_eq!(size.shared_bytes, 0);
1526 }
1527
1528 #[test]
1529 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1530 let tmp = TempDir::new().unwrap();
1533 let store = tmp.path().join("store");
1534 let tree = tmp.path().join("tree");
1535 fs::create_dir(&store).unwrap();
1536 fs::create_dir(&tree).unwrap();
1537 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1538 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1539 let size = dir_size_with_hardlinks(&tree);
1540 assert_eq!(size.freed_bytes, 0);
1541 assert_eq!(size.shared_bytes, 10);
1542 }
1543
1544 #[test]
1545 fn test_hardlink_size_counts_an_internal_pair_once() {
1546 let tmp = TempDir::new().unwrap();
1549 let tree = tmp.path().join("tree");
1550 fs::create_dir(&tree).unwrap();
1551 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1552 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1553 let size = dir_size_with_hardlinks(&tree);
1554 assert_eq!(size.freed_bytes, 7);
1555 assert_eq!(size.shared_bytes, 0);
1556 }
1557
1558 #[test]
1559 fn test_dir_size_empty() {
1560 let tmp = TempDir::new().unwrap();
1561 assert_eq!(dir_size(tmp.path()), 0);
1562 }
1563
1564 #[test]
1565 fn test_dir_size_with_files() {
1566 let tmp = TempDir::new().unwrap();
1567 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1568 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1569 assert_eq!(dir_size(tmp.path()), 11); }
1571
1572 #[test]
1573 fn test_dir_size_nonexistent() {
1574 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1575 }
1576
1577 #[test]
1578 fn test_get_all_adapters_not_empty() {
1579 let adapters = get_all_adapters();
1580 assert!(adapters.len() >= 6);
1581 }
1582
1583 #[test]
1584 fn test_detect_adapters_npm() {
1585 let tmp = TempDir::new().unwrap();
1586 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1587 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1588 let adapters = detect_adapters(tmp.path());
1589 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1590 assert!(names.contains(&"npm"));
1591 }
1592
1593 #[test]
1594 fn test_detect_adapters_empty_dir() {
1595 let tmp = TempDir::new().unwrap();
1596 let adapters = detect_adapters(tmp.path());
1597 assert!(adapters.is_empty());
1598 }
1599
1600 fn detected_names(dir: &Path) -> Vec<&'static str> {
1606 let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1607 .iter()
1608 .map(|a| a.name())
1609 .collect();
1610 names.sort_unstable();
1611 names
1612 }
1613
1614 #[test]
1615 fn test_detect_adapters_multiple_ecosystems_coexist() {
1616 let tmp = TempDir::new().unwrap();
1618 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1619 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1620 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1621 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1622 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1623
1624 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1629 }
1630
1631 #[test]
1632 fn test_detect_adapters_opt_in_appears_once_enabled() {
1633 let tmp = TempDir::new().unwrap();
1636 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1637
1638 let on = [String::from("cargo")];
1639 let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1640 .iter()
1641 .map(|a| a.name())
1642 .collect();
1643 names.sort_unstable();
1644 assert_eq!(names, vec!["cargo"]);
1645 }
1646
1647 #[test]
1648 fn every_adapter_counts_as_used_even_when_it_is_switched_off() {
1649 let tmp = TempDir::new().unwrap();
1654 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1655
1656 assert!(
1657 detect_adapters_with(tmp.path(), &[], &[]).is_empty(),
1658 "cargo is opt-in, so the prune-facing detector must not see it here"
1659 );
1660 let names: Vec<&str> = detect_all_adapters(tmp.path())
1661 .iter()
1662 .map(|a| a.name())
1663 .collect();
1664 assert_eq!(names, vec!["cargo"]);
1665 }
1666
1667 #[test]
1668 fn test_detect_adapters_disabled_adapter_is_invisible() {
1669 let tmp = TempDir::new().unwrap();
1671 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1672
1673 let off = [String::from("go")];
1674 assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1675 }
1676
1677 #[test]
1678 fn test_js_conflict_resolved_by_package_manager_field() {
1679 let tmp = TempDir::new().unwrap();
1680 fs::write(
1681 tmp.path().join("package.json"),
1682 r#"{"packageManager":"yarn@4.1.0"}"#,
1683 )
1684 .unwrap();
1685 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1686 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1687 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1688
1689 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1690 }
1691
1692 #[test]
1693 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1694 let tmp = TempDir::new().unwrap();
1697 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1698 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1699 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1700 std::thread::sleep(std::time::Duration::from_millis(20));
1701 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1702
1703 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1704 }
1705
1706 #[test]
1707 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1708 let tmp = TempDir::new().unwrap();
1710 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1711 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1712 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1713 let nm = tmp.path().join("node_modules");
1714 fs::create_dir_all(&nm).unwrap();
1715 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1716 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1717
1718 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1719 }
1720
1721 #[test]
1722 fn test_declared_package_manager_outranks_what_is_installed() {
1723 let tmp = TempDir::new().unwrap();
1725 fs::write(
1726 tmp.path().join("package.json"),
1727 r#"{"packageManager":"pnpm@9.1.0"}"#,
1728 )
1729 .unwrap();
1730 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1731 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1732 let nm = tmp.path().join("node_modules");
1733 fs::create_dir_all(&nm).unwrap();
1734 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1735
1736 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1737 }
1738
1739 #[test]
1740 fn test_uv_takes_precedence_over_plain_venv() {
1741 let tmp = TempDir::new().unwrap();
1744 fs::write(
1745 tmp.path().join("pyproject.toml"),
1746 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1747 )
1748 .unwrap();
1749 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1750 let venv = tmp.path().join(".venv");
1751 fs::create_dir_all(&venv).unwrap();
1752 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1753
1754 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1755 }
1756
1757 #[test]
1758 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1759 let tmp = TempDir::new().unwrap();
1760 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1761 let venv = tmp.path().join("venv");
1762 fs::create_dir_all(&venv).unwrap();
1763 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1764
1765 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1766 }
1767
1768 #[test]
1769 fn test_js_conflict_falls_back_to_newest_lockfile() {
1770 let tmp = TempDir::new().unwrap();
1771 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1772 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1773 std::thread::sleep(std::time::Duration::from_millis(20));
1776 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1777
1778 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1779 }
1780
1781 #[test]
1782 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1783 let tmp = TempDir::new().unwrap();
1786 fs::write(
1787 tmp.path().join("package.json"),
1788 r#"{"packageManager":"deno@2.0.0"}"#,
1789 )
1790 .unwrap();
1791 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1792 std::thread::sleep(std::time::Duration::from_millis(20));
1793 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1794
1795 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1796 }
1797
1798 #[test]
1799 fn test_js_conflict_does_not_disturb_a_single_manager() {
1800 let tmp = TempDir::new().unwrap();
1801 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1802 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1803 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1804 }
1805
1806 #[test]
1807 fn test_js_adapters_declare_their_lockfiles() {
1808 for adapter in get_all_adapters() {
1809 if JS_MANAGERS.contains(&adapter.name()) {
1810 assert!(
1811 !adapter.lockfiles().is_empty(),
1812 "{} shares node_modules and must declare its lockfiles for \
1813 conflict resolution",
1814 adapter.name()
1815 );
1816 }
1817 }
1818 }
1819
1820 #[test]
1821 fn test_adapter_names_unique() {
1822 let adapters = get_all_adapters();
1823 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1824 let mut unique = names.clone();
1825 unique.sort();
1826 unique.dedup();
1827 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1828 }
1829
1830 #[test]
1831 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1832 assert!(is_valid_runtime_tag("3.12"));
1835 assert!(is_valid_runtime_tag("3.9"));
1836 for bad in [
1837 "",
1838 "3",
1839 "3.12.1",
1840 "3.x",
1841 "3.12; rm -rf /",
1842 "-3.12",
1843 "../python",
1844 "3.1234",
1845 "300.1",
1846 ] {
1847 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1848 }
1849 }
1850
1851 #[test]
1852 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1853 let tmp = tempfile::tempdir().unwrap();
1854 let venv = tmp.path().join(".venv");
1855 std::fs::create_dir_all(&venv).unwrap();
1856 std::fs::write(
1857 venv.join("pyvenv.cfg"),
1858 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1859 )
1860 .unwrap();
1861 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1862 }
1863
1864 #[test]
1865 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1866 let tmp = tempfile::tempdir().unwrap();
1867 assert_eq!(venv_runtime_tag(tmp.path()), None);
1868 }
1869}