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 deno;
27pub mod go;
28pub mod gradle;
29pub mod maven;
30pub mod mix;
31pub mod mix_build;
32pub mod npm;
33pub mod pdm;
34pub mod pipenv;
35pub mod pnpm;
36pub mod poetry;
37pub mod swift;
38pub mod terraform;
39pub mod uv;
40pub mod vcpkg;
41pub mod venv;
42pub mod yarn;
43
44use std::collections::HashMap;
45use std::fmt;
46use std::path::{Path, PathBuf};
47use std::sync::{Mutex, OnceLock};
48
49use anyhow::{Context as _, Result};
50use walkdir::WalkDir;
51
52#[derive(Debug, Clone)]
54pub struct BloatDir {
55 pub name: String,
57 pub path: PathBuf,
59 pub size_bytes: u64,
61 pub shared_bytes: u64,
65}
66
67impl fmt::Display for BloatDir {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(f, "{} ({})", self.name, self.path.display())
70 }
71}
72
73#[derive(Debug, Clone)]
76pub struct DriftReport {
77 pub directory: String,
79 pub unrecorded: Vec<String>,
81 pub record_command: &'static str,
83}
84
85pub trait PackageManager: Send + Sync {
93 fn name(&self) -> &'static str;
95
96 fn detect(&self, project_path: &Path) -> bool;
100
101 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
105
106 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
113
114 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
121
122 fn restore_named(
133 &self,
134 project_path: &Path,
135 dir_name: &str,
136 runtime: Option<&str>,
137 timeout: std::time::Duration,
138 ) -> Result<()> {
139 let _ = (dir_name, runtime);
140 self.restore(project_path, timeout)
141 }
142
143 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
153 let _ = (project_path, dir_name);
154 None
155 }
156
157 fn lockfiles(&self) -> &'static [&'static str] {
167 &[]
168 }
169
170 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
178 let _ = project_path;
179 Vec::new()
180 }
181
182 fn opt_in(&self) -> bool {
195 false
196 }
197}
198
199const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
209
210const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
218 ("pnpm", &[".pnpm", ".modules.yaml"]),
219 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
220 ("npm", &[".package-lock.json"]),
221];
222
223pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
227 vec![
228 Box::new(npm::Npm),
229 Box::new(pnpm::Pnpm),
230 Box::new(yarn::Yarn),
231 Box::new(bun::Bun),
232 Box::new(deno::Deno),
233 Box::new(uv::Uv),
234 Box::new(poetry::Poetry),
235 Box::new(pdm::Pdm),
236 Box::new(pipenv::Pipenv),
237 Box::new(venv::Venv),
238 Box::new(cargo_adapter::Cargo),
239 Box::new(go::Go),
240 Box::new(composer::Composer),
241 Box::new(bundler::Bundler),
242 Box::new(cocoapods::CocoaPods),
243 Box::new(mix::Mix),
244 Box::new(mix_build::MixBuild),
245 Box::new(gradle::Gradle),
246 Box::new(maven::Maven),
247 Box::new(swift::Swift),
248 Box::new(terraform::Terraform),
249 Box::new(dart::Dart),
250 Box::new(vcpkg::Vcpkg),
251 Box::new(cmake_build::CmakeBuild),
252 ]
253}
254
255fn opt_in_enabled() -> &'static [String] {
263 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
264 ENABLED.get_or_init(|| {
265 crate::config::Registry::load()
266 .map(|r| {
267 let mut names = Vec::new();
268 if r.settings.enable_cargo {
269 names.push("cargo".to_string());
270 }
271 if r.settings.enable_gradle {
272 names.push("gradle".to_string());
273 }
274 if r.settings.enable_maven {
275 names.push("maven".to_string());
276 }
277 if r.settings.enable_swift {
278 names.push("swift".to_string());
279 }
280 if r.settings.enable_dart {
281 names.push("dart".to_string());
282 }
283 if r.settings.enable_mix_build {
284 names.push("mix_build".to_string());
285 }
286 if r.settings.enable_vcpkg {
287 names.push("vcpkg".to_string());
288 }
289 if r.settings.enable_cmake_build {
290 names.push("cmake_build".to_string());
291 }
292 names
293 })
294 .unwrap_or_default()
295 })
296}
297
298fn user_disabled() -> &'static [String] {
305 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
306 DISABLED.get_or_init(|| {
307 crate::config::Registry::load()
308 .map(|r| {
309 r.settings
310 .disabled_adapters
311 .iter()
312 .map(|n| n.trim().to_ascii_lowercase())
313 .filter(|n| !n.is_empty())
314 .collect()
315 })
316 .unwrap_or_default()
317 })
318}
319
320pub fn is_adapter_name(name: &str) -> bool {
322 get_all_adapters().iter().any(|a| a.name() == name)
323}
324
325pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
338 ("JavaScript", &["npm", "pnpm", "yarn", "bun", "deno"]),
339 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
340 ("Rust", &["cargo"]),
341 ("Go", &["go"]),
342 ("JVM", &["gradle", "maven"]),
343 ("PHP", &["composer"]),
344 ("Ruby", &["bundler"]),
345 ("Swift & Objective-C", &["swift", "cocoapods"]),
346 ("Elixir", &["mix", "mix_build"]),
347 ("Infrastructure", &["terraform"]),
348 ("Dart & Flutter", &["dart"]),
349 ("C & C++", &["vcpkg", "cmake_build"]),
350];
351
352pub fn adapter_group(name: &str) -> &'static str {
358 ADAPTER_GROUPS
359 .iter()
360 .find(|(_, names)| names.contains(&name))
361 .map(|(group, _)| *group)
362 .unwrap_or("Other")
363}
364
365pub fn all_adapter_names() -> Vec<&'static str> {
367 get_all_adapters().iter().map(|a| a.name()).collect()
368}
369
370pub fn opt_in_adapter_names() -> Vec<&'static str> {
375 get_all_adapters()
376 .iter()
377 .filter(|a| a.opt_in())
378 .map(|a| a.name())
379 .collect()
380}
381
382pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
389 detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
390}
391
392pub fn detect_all_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
402 let opt_in: Vec<String> = opt_in_adapter_names()
403 .into_iter()
404 .map(str::to_string)
405 .collect();
406 detect_adapters_with(project_path, &opt_in, &[])
407}
408
409fn detect_adapters_with(
417 project_path: &Path,
418 opt_in: &[String],
419 disabled: &[String],
420) -> Vec<Box<dyn PackageManager>> {
421 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
422 .into_iter()
423 .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
424 .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
425 .filter(|adapter| adapter.detect(project_path))
426 .collect();
427 resolve_conflicts(project_path, &mut detected);
428 detected
429}
430
431fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
433 resolve_js_conflict(project_path, detected);
434 resolve_python_conflict(project_path, detected);
435}
436
437fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
450 if detected
451 .iter()
452 .filter(|a| JS_MANAGERS.contains(&a.name()))
453 .count()
454 < 2
455 {
456 return;
457 }
458
459 let winner = declared_package_manager(project_path)
460 .filter(|name| detected.iter().any(|a| a.name() == name))
461 .or_else(|| installed_manager(project_path, detected))
462 .or_else(|| newest_lockfile_owner(project_path, detected));
463
464 let Some(winner) = winner else { return };
465 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
466}
467
468const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
481 ("uv", "uv.lock"),
482 ("poetry", "poetry.lock"),
483 ("pdm", "pdm.lock"),
484 ("pipenv", "Pipfile.lock"),
485];
486
487fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
488 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
489 .iter()
490 .copied()
491 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
492 .collect();
493 let Some(&(first, _)) = claimants.first() else {
494 return;
495 };
496 detected.retain(|a| a.name() != "venv");
497 if claimants.len() < 2 {
498 return;
499 }
500 let winner = claimants
501 .iter()
502 .find(|(_, lockfile)| project_path.join(lockfile).exists())
503 .map_or(first, |(name, _)| *name);
504 detected.retain(|a| {
505 a.name() == winner
506 || !PYTHON_ENV_MANAGERS
507 .iter()
508 .any(|(name, _)| *name == a.name())
509 });
510}
511
512fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
514 let node_modules = project_path.join("node_modules");
515 if !node_modules.is_dir() {
516 return None;
517 }
518
519 JS_INSTALL_MARKERS
520 .iter()
521 .find(|(name, markers)| {
522 detected.iter().any(|a| a.name() == *name)
523 && markers.iter().any(|m| node_modules.join(m).exists())
524 })
525 .map(|(name, _)| (*name).to_string())
526}
527
528fn declared_package_manager(project_path: &Path) -> Option<String> {
530 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
531 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
532 let declared = json.get("packageManager")?.as_str()?;
533 let name = declared.split('@').next().unwrap_or_default();
534 JS_MANAGERS
535 .iter()
536 .find(|m| **m == name)
537 .map(|m| (*m).to_string())
538}
539
540fn newest_lockfile_owner(
542 project_path: &Path,
543 detected: &[Box<dyn PackageManager>],
544) -> Option<String> {
545 detected
546 .iter()
547 .filter(|a| JS_MANAGERS.contains(&a.name()))
548 .filter_map(|a| {
549 let newest = a
550 .lockfiles()
551 .iter()
552 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
553 .max()?;
554 Some((newest, a.name().to_string()))
555 })
556 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
559 match best {
560 Some(b) if b.0 >= cur.0 => Some(b),
561 _ => Some(cur),
562 }
563 })
564 .map(|(_, name)| name)
565}
566
567pub fn dir_size(path: &Path) -> u64 {
569 if !path.exists() {
570 return 0;
571 }
572 WalkDir::new(path)
573 .follow_links(false)
574 .into_iter()
575 .flatten()
576 .filter_map(|entry| entry.metadata().ok())
577 .filter(|meta| meta.is_file())
578 .map(|meta| meta.len())
579 .sum()
580}
581
582#[derive(Debug, Clone, Copy, Default)]
584pub struct DirSizeBreakdown {
585 pub freed_bytes: u64,
587 pub shared_bytes: u64,
590}
591
592pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
603 let mut out = DirSizeBreakdown::default();
604 if !path.exists() {
605 return out;
606 }
607 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
609 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
610 let Ok(meta) = entry.metadata() else { continue };
611 if !meta.is_file() {
612 continue;
613 }
614 match file_link_identity(entry.path(), &meta) {
615 Some((dev, ino, nlink)) if nlink > 1 => {
616 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
617 }
618 _ => out.freed_bytes += meta.len(),
619 }
620 }
621 for (bytes, nlink, seen) in linked.into_values() {
622 if seen >= nlink {
623 out.freed_bytes += bytes;
624 } else {
625 out.shared_bytes += bytes;
626 }
627 }
628 out
629}
630
631#[cfg(unix)]
633fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
634 use std::os::unix::fs::MetadataExt as _;
635 Some((meta.dev(), meta.ino(), meta.nlink()))
636}
637
638#[cfg(windows)]
642fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
643 use std::os::windows::fs::OpenOptionsExt as _;
644 use std::os::windows::io::AsRawHandle as _;
645 use windows_sys::Win32::Storage::FileSystem::{
646 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
647 };
648
649 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
652 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
653 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
656 return None;
657 }
658 Some((
659 u64::from(info.dwVolumeSerialNumber),
660 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
661 u64::from(info.nNumberOfLinks),
662 ))
663}
664
665#[cfg(not(any(unix, windows)))]
666fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
667 None
668}
669
670pub fn resolve_program(program: &str) -> String {
680 #[cfg(windows)]
681 {
682 if Path::new(program).components().count() > 1 {
683 return program.to_string();
684 }
685 let Some(path_var) = std::env::var_os("PATH") else {
686 return program.to_string();
687 };
688 for dir in std::env::split_paths(&path_var) {
689 for ext in ["exe", "cmd", "bat"] {
690 let candidate = dir.join(format!("{program}.{ext}"));
691 if candidate.is_file() {
692 return candidate.to_string_lossy().into_owned();
693 }
694 }
695 }
696 }
697 program.to_string()
698}
699
700pub fn binary_available(program: &str) -> bool {
708 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
709 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
710
711 let mut guard = match cache.lock() {
714 Ok(g) => g,
715 Err(_) => return probe_binary(program),
718 };
719 if let Some(known) = guard.get(program) {
720 return *known;
721 }
722 let available = probe_binary(program);
723 guard.insert(program.to_string(), available);
724 available
725}
726
727const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
735
736fn version_probe_args(program: &str) -> &'static [&'static str] {
738 VERSION_PROBE_ARGS
739 .iter()
740 .find(|(name, _)| *name == program)
741 .map_or(&["--version"], |(_, args)| *args)
742}
743
744fn probe_binary(program: &str) -> bool {
746 crate::spawn::command(resolve_program(program))
747 .args(version_probe_args(program))
748 .stdin(std::process::Stdio::null())
749 .output()
750 .map(|o| o.status.success())
751 .unwrap_or(false)
752}
753
754struct CommandOutput {
756 status: std::process::ExitStatus,
757 stdout: String,
758 stderr: String,
759}
760
761fn spawn_capture(
768 program: &str,
769 args: &[&str],
770 cwd: &Path,
771 timeout: std::time::Duration,
772) -> Result<CommandOutput> {
773 use std::io::Read;
774 use std::process::Stdio;
775 use std::thread;
776 use std::time::Instant;
777
778 let resolved = resolve_program(program);
779 let mut child = crate::spawn::command(&resolved)
780 .args(args)
781 .current_dir(cwd)
782 .stdin(Stdio::null())
783 .stdout(Stdio::piped())
784 .stderr(Stdio::piped())
785 .spawn()
786 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
787
788 let mut stdout_pipe = child.stdout.take();
792 let mut stderr_pipe = child.stderr.take();
793 let stdout_reader = thread::spawn(move || {
794 let mut buf = Vec::new();
795 if let Some(pipe) = stdout_pipe.as_mut() {
796 let _ = pipe.read_to_end(&mut buf);
797 }
798 buf
799 });
800 let stderr_reader = thread::spawn(move || {
801 let mut buf = Vec::new();
802 if let Some(pipe) = stderr_pipe.as_mut() {
803 let _ = pipe.read_to_end(&mut buf);
804 }
805 buf
806 });
807
808 let start = Instant::now();
809 let status = loop {
810 match child.try_wait()? {
811 Some(status) => break status,
812 None => {
813 if start.elapsed() >= timeout {
814 let _ = child.kill();
815 let _ = child.wait();
816 anyhow::bail!(
817 "Command timed out after {}s: {} {}\n\
818 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
819 timeout.as_secs(),
820 program,
821 args.join(" ")
822 );
823 }
824 thread::sleep(std::time::Duration::from_millis(100));
825 }
826 }
827 };
828
829 let stderr = stderr_reader
830 .join()
831 .map(|b| String::from_utf8_lossy(&b).into_owned())
832 .unwrap_or_default();
833 let stdout = stdout_reader
834 .join()
835 .map(|b| String::from_utf8_lossy(&b).into_owned())
836 .unwrap_or_default();
837
838 Ok(CommandOutput {
839 status,
840 stdout,
841 stderr,
842 })
843}
844
845pub fn run_command_with_timeout(
847 program: &str,
848 args: &[&str],
849 cwd: &Path,
850 timeout: std::time::Duration,
851) -> Result<()> {
852 let out = spawn_capture(program, args, cwd, timeout)?;
853 if out.status.success() {
854 Ok(())
855 } else {
856 anyhow::bail!(
857 "{} {} failed (exit code {:?}):\n{}",
858 program,
859 args.join(" "),
860 out.status.code(),
861 crate::output::condense_tool_output(
862 &out.stderr,
863 crate::constants::TOOL_OUTPUT_MAX_LINES
864 )
865 )
866 }
867}
868
869pub struct CapturedCommand {
872 pub ok: bool,
874 pub stdout: String,
876 pub stderr: String,
878}
879
880pub fn capture_allowing_failure(
890 program: &str,
891 args: &[&str],
892 cwd: &Path,
893 timeout: std::time::Duration,
894) -> Result<CapturedCommand> {
895 let out = spawn_capture(program, args, cwd, timeout)?;
896 Ok(CapturedCommand {
897 ok: out.status.success(),
898 stdout: out.stdout,
899 stderr: out.stderr,
900 })
901}
902
903pub fn capture_command_with_timeout(
909 program: &str,
910 args: &[&str],
911 cwd: &Path,
912 timeout: std::time::Duration,
913) -> Result<String> {
914 let out = spawn_capture(program, args, cwd, timeout)?;
915 if out.status.success() {
916 Ok(out.stdout)
917 } else {
918 anyhow::bail!(
919 "{} {} failed (exit code {:?}):\n{}",
920 program,
921 args.join(" "),
922 out.status.code(),
923 crate::output::condense_tool_output(
924 &out.stderr,
925 crate::constants::TOOL_OUTPUT_MAX_LINES
926 )
927 )
928 }
929}
930
931pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
933 crate::spawn::command(resolve_program(program))
934 .args(args)
935 .current_dir(cwd)
936 .stdin(std::process::Stdio::null())
937 .output()
938 .map(|o| o.status.success())
939 .unwrap_or(false)
940}
941
942const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
950
951fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
958 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
959 Some("Cargo.lock") => "Cargo.toml",
960 Some("package-lock.json")
961 | Some("yarn.lock")
962 | Some("pnpm-lock.yaml")
963 | Some("bun.lockb")
964 | Some("bun.lock") => "package.json",
965 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
966 Some("go.sum") => "go.mod",
967 Some("composer.lock") => "composer.json",
968 Some("Gemfile.lock") => "Gemfile",
969 Some("Pipfile.lock") => "Pipfile",
970 _ => return Ok(()),
971 };
972 let manifest = cwd.join(manifest_name);
973 let (Ok(manifest_meta), Ok(lock_meta)) =
974 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
975 else {
976 return Ok(());
977 };
978 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
979 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
980 {
981 anyhow::bail!(
982 "`{program}` is not available, and `{manifest_name}` has been edited more \
983 recently than `{}` — the lockfile may no longer record the current \
984 dependencies, and without `{program}` that cannot be verified. Install \
985 {program} and run its lockfile sync, then prune again.",
986 lockfile.display()
987 );
988 }
989 Ok(())
990}
991
992pub fn refuse_if_manifest_stale(
1003 manifest: &Path,
1004 lockfile: &Path,
1005 sync_command: &str,
1006) -> Result<()> {
1007 let (Ok(manifest_meta), Ok(lock_meta)) =
1008 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
1009 else {
1010 return Ok(());
1011 };
1012 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1013 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1014 {
1015 anyhow::bail!(
1016 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
1017 record the current dependencies. Run `{sync_command}` and prune again.",
1018 manifest.display(),
1019 lockfile.display()
1020 );
1021 }
1022 Ok(())
1023}
1024
1025pub fn lock_sync_or_verify_with_timeout(
1027 lockfile: &Path,
1028 program: &str,
1029 sync_args: &[&str],
1030 cwd: &Path,
1031 timeout: std::time::Duration,
1032) -> Result<()> {
1033 let lockfile_exists = lockfile.exists();
1034
1035 if !binary_available(program) {
1036 if lockfile_exists {
1037 refuse_if_manifest_newer(lockfile, program, cwd)?;
1038 return Ok(());
1039 } else {
1040 anyhow::bail!(
1041 "`{program}` is not available and no lockfile was found at `{}`. \
1042 Cannot safely delete dependencies — install {program} first, \
1043 or commit a lockfile.",
1044 lockfile.display()
1045 );
1046 }
1047 }
1048
1049 run_command_with_timeout(program, sync_args, cwd, timeout)
1051}
1052
1053#[derive(Debug, Clone, Copy)]
1059pub struct EnforcePolicy {
1060 pub allow_rewrite: bool,
1066 pub timeout: std::time::Duration,
1068}
1069
1070impl Default for EnforcePolicy {
1071 fn default() -> Self {
1072 Self {
1073 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1074 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1075 }
1076 }
1077}
1078
1079impl EnforcePolicy {
1080 pub fn from_settings(settings: &crate::config::Settings) -> Self {
1082 Self {
1083 allow_rewrite: settings.allow_manifest_rewrite,
1084 timeout: command_timeout(settings.command_timeout_secs),
1085 }
1086 }
1087}
1088
1089pub fn command_timeout(secs: u64) -> std::time::Duration {
1097 std::time::Duration::from_secs(secs.max(1))
1098}
1099
1100pub fn enforce_two_tier(
1116 lockfile: &Path,
1117 program: &str,
1118 verify_args: &[&str],
1119 write_args: &[&str],
1120 cwd: &Path,
1121 policy: EnforcePolicy,
1122) -> Result<()> {
1123 if policy.allow_rewrite {
1124 return lock_sync_or_verify_with_timeout(
1125 lockfile,
1126 program,
1127 write_args,
1128 cwd,
1129 policy.timeout,
1130 );
1131 }
1132 lock_verify_or_generate(
1133 lockfile,
1134 program,
1135 verify_args,
1136 write_args,
1137 cwd,
1138 policy.timeout,
1139 )
1140}
1141
1142pub fn lock_verify_or_generate(
1152 lockfile: &Path,
1153 program: &str,
1154 verify_args: &[&str],
1155 generate_args: &[&str],
1156 cwd: &Path,
1157 timeout: std::time::Duration,
1158) -> Result<()> {
1159 let lockfile_exists = lockfile.exists();
1160
1161 if !binary_available(program) {
1162 if lockfile_exists {
1163 refuse_if_manifest_newer(lockfile, program, cwd)?;
1164 return Ok(());
1165 }
1166 anyhow::bail!(
1167 "`{program}` is not available and no lockfile was found at `{}`. \
1168 Cannot safely delete dependencies — install {program} first, \
1169 or commit a lockfile.",
1170 lockfile.display()
1171 );
1172 }
1173
1174 if lockfile_exists {
1175 run_command_with_timeout(program, verify_args, cwd, timeout)
1176 } else {
1177 run_command_with_timeout(program, generate_args, cwd, timeout)
1178 }
1179}
1180
1181pub fn lock_sync_or_verify(
1183 lockfile: &Path,
1184 program: &str,
1185 sync_args: &[&str],
1186 cwd: &Path,
1187) -> Result<()> {
1188 lock_sync_or_verify_with_timeout(
1189 lockfile,
1190 program,
1191 sync_args,
1192 cwd,
1193 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1194 )
1195}
1196
1197const PYVENV_CFG: &str = "pyvenv.cfg";
1203
1204pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1211 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1212 for line in cfg.lines() {
1213 let Some((key, value)) = line.split_once('=') else {
1214 continue;
1215 };
1216 if matches!(key.trim(), "version" | "version_info") {
1217 let mut parts = value.trim().split('.');
1218 let major: u64 = parts.next()?.parse().ok()?;
1219 let minor: u64 = parts.next()?.parse().ok()?;
1220 return Some(format!("{major}.{minor}"));
1221 }
1222 }
1223 None
1224}
1225
1226pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1230 let mut parts = tag.split('.');
1231 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1232 return false;
1233 };
1234 !major.is_empty()
1235 && !minor.is_empty()
1236 && major.len() <= 2
1237 && minor.len() <= 3
1238 && major.bytes().all(|b| b.is_ascii_digit())
1239 && minor.bytes().all(|b| b.is_ascii_digit())
1240}
1241
1242pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1250 if !is_valid_runtime_tag(tag) {
1251 return None;
1252 }
1253 #[cfg(windows)]
1254 {
1255 Some(("py".to_string(), vec![format!("-{tag}")]))
1256 }
1257 #[cfg(not(windows))]
1258 {
1259 Some((format!("python{tag}"), Vec::new()))
1260 }
1261}
1262
1263pub(crate) fn python_executable(tag: &str) -> Option<String> {
1271 let (program, prefix) = python_launcher(tag)?;
1272 let out = crate::spawn::command(resolve_program(&program))
1273 .args(&prefix)
1274 .args(["-c", "import sys; print(sys.executable)"])
1275 .stdin(std::process::Stdio::null())
1276 .stderr(std::process::Stdio::null())
1277 .output()
1278 .ok()?;
1279 if !out.status.success() {
1280 return None;
1281 }
1282 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1283 (!path.is_empty()).then_some(path)
1284}
1285
1286pub(crate) fn python_runtime_available(tag: &str) -> bool {
1291 let Some((program, prefix)) = python_launcher(tag) else {
1292 return false;
1293 };
1294 crate::spawn::command(resolve_program(&program))
1295 .args(&prefix)
1296 .arg("--version")
1297 .stdin(std::process::Stdio::null())
1298 .stdout(std::process::Stdio::null())
1299 .stderr(std::process::Stdio::null())
1300 .status()
1301 .is_ok_and(|s| s.success())
1302}
1303
1304const NO_RESTORE_BINARY: [&str; 7] = [
1305 "venv",
1306 "gradle",
1307 "maven",
1308 "mix_build",
1309 "swift",
1310 "vcpkg",
1311 "cmake_build",
1312];
1313
1314const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1316
1317pub fn adapter_binary(adapter: &str) -> &str {
1319 ADAPTER_BINARIES
1320 .iter()
1321 .find(|(name, _)| *name == adapter)
1322 .map_or(adapter, |(_, binary)| *binary)
1323}
1324
1325const INSTALL_HINTS: [(&str, &str); 17] = [
1330 ("npm", "ships with Node.js — https://nodejs.org"),
1331 (
1332 "pnpm",
1333 "`npm install -g pnpm` — https://pnpm.io/installation",
1334 ),
1335 (
1336 "yarn",
1337 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1338 ),
1339 ("bun", "https://bun.sh/docs/installation"),
1340 (
1341 "deno",
1342 "https://docs.deno.com/runtime/getting_started/installation/",
1343 ),
1344 (
1345 "uv",
1346 "https://docs.astral.sh/uv/getting-started/installation/",
1347 ),
1348 ("poetry", "https://python-poetry.org/docs/#installation"),
1349 (
1350 "pdm",
1351 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1352 ),
1353 (
1354 "pipenv",
1355 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1356 ),
1357 ("cargo", "ships with Rust — https://rustup.rs"),
1358 ("go", "https://go.dev/dl/"),
1359 ("composer", "https://getcomposer.org/download/"),
1360 ("bundler", "`gem install bundler` — https://bundler.io"),
1361 (
1362 "cocoapods",
1363 "`gem install cocoapods` — https://cocoapods.org",
1364 ),
1365 (
1366 "mix",
1367 "ships with Elixir — https://elixir-lang.org/install.html",
1368 ),
1369 (
1370 "terraform",
1371 "https://developer.hashicorp.com/terraform/install",
1372 ),
1373 (
1374 "dart",
1375 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1376 ),
1377];
1378
1379pub fn install_hint(adapter: &str) -> Option<&'static str> {
1381 INSTALL_HINTS
1382 .iter()
1383 .find(|(name, _)| *name == adapter)
1384 .map(|(_, hint)| *hint)
1385}
1386
1387#[derive(Debug, Clone)]
1389pub struct BinaryCheckStatus {
1390 pub name: String,
1391 pub available: bool,
1392 pub version: Option<String>,
1393}
1394
1395pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1397 let mut unique: Vec<String> = adapter_names
1398 .iter()
1399 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1402 .cloned()
1403 .collect();
1404 unique.sort();
1405 unique.dedup();
1406
1407 unique
1408 .into_iter()
1409 .map(|name| {
1410 let binary = adapter_binary(&name);
1411 let output = crate::spawn::command(resolve_program(binary))
1412 .args(version_probe_args(binary))
1413 .stdin(std::process::Stdio::null())
1414 .output();
1415 match output {
1416 Ok(out) if out.status.success() => {
1417 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1418 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1419 BinaryCheckStatus {
1420 name,
1421 available: true,
1422 version: if first_line.is_empty() {
1423 None
1424 } else {
1425 Some(first_line)
1426 },
1427 }
1428 }
1429 _ => BinaryCheckStatus {
1430 name,
1431 available: false,
1432 version: None,
1433 },
1434 }
1435 })
1436 .collect()
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441 use super::*;
1442 use std::fs;
1443 use tempfile::TempDir;
1444
1445 #[test]
1446 fn every_adapter_is_grouped_exactly_once() {
1447 let registered = all_adapter_names();
1450 let grouped: Vec<&str> = ADAPTER_GROUPS
1451 .iter()
1452 .flat_map(|(_, names)| names.iter().copied())
1453 .collect();
1454
1455 for name in ®istered {
1456 assert_eq!(
1457 grouped.iter().filter(|g| *g == name).count(),
1458 1,
1459 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1460 );
1461 }
1462 for name in &grouped {
1463 assert!(
1464 registered.contains(name),
1465 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1466 );
1467 }
1468 assert_eq!(registered.len(), grouped.len());
1469 }
1470
1471 #[test]
1472 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1473 let mut opt_in = opt_in_adapter_names();
1478 opt_in.sort_unstable();
1479 assert_eq!(
1480 opt_in,
1481 vec![
1482 "cargo",
1483 "cmake_build",
1484 "dart",
1485 "gradle",
1486 "maven",
1487 "mix_build",
1488 "swift",
1489 "vcpkg"
1490 ]
1491 );
1492 }
1493
1494 #[test]
1495 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1496 assert_eq!(version_probe_args("go"), &["version"]);
1501 assert_eq!(version_probe_args("npm"), &["--version"]);
1502 }
1503
1504 #[test]
1505 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1506 for adapter in get_all_adapters() {
1509 let name = adapter.name();
1510 if NO_RESTORE_BINARY.contains(&name) {
1511 continue;
1512 }
1513 assert!(
1514 install_hint(name).is_some(),
1515 "adapter `{name}` has no install hint"
1516 );
1517 }
1518 }
1519
1520 #[test]
1521 fn test_bloat_dir_display() {
1522 let bd = BloatDir {
1523 name: "node_modules".to_string(),
1524 path: PathBuf::from("/test/node_modules"),
1525 size_bytes: 1024,
1526 shared_bytes: 0,
1527 };
1528 assert!(bd.to_string().contains("node_modules"));
1529 }
1530
1531 #[test]
1532 fn test_hardlink_size_counts_a_plain_file_in_full() {
1533 let tmp = TempDir::new().unwrap();
1534 let tree = tmp.path().join("tree");
1535 fs::create_dir(&tree).unwrap();
1536 fs::write(tree.join("copied.txt"), "12345").unwrap();
1537 let size = dir_size_with_hardlinks(&tree);
1538 assert_eq!(size.freed_bytes, 5);
1539 assert_eq!(size.shared_bytes, 0);
1540 }
1541
1542 #[test]
1543 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1544 let tmp = TempDir::new().unwrap();
1547 let store = tmp.path().join("store");
1548 let tree = tmp.path().join("tree");
1549 fs::create_dir(&store).unwrap();
1550 fs::create_dir(&tree).unwrap();
1551 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1552 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1553 let size = dir_size_with_hardlinks(&tree);
1554 assert_eq!(size.freed_bytes, 0);
1555 assert_eq!(size.shared_bytes, 10);
1556 }
1557
1558 #[test]
1559 fn test_hardlink_size_counts_an_internal_pair_once() {
1560 let tmp = TempDir::new().unwrap();
1563 let tree = tmp.path().join("tree");
1564 fs::create_dir(&tree).unwrap();
1565 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1566 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1567 let size = dir_size_with_hardlinks(&tree);
1568 assert_eq!(size.freed_bytes, 7);
1569 assert_eq!(size.shared_bytes, 0);
1570 }
1571
1572 #[test]
1573 fn test_dir_size_empty() {
1574 let tmp = TempDir::new().unwrap();
1575 assert_eq!(dir_size(tmp.path()), 0);
1576 }
1577
1578 #[test]
1579 fn test_dir_size_with_files() {
1580 let tmp = TempDir::new().unwrap();
1581 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1582 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1583 assert_eq!(dir_size(tmp.path()), 11); }
1585
1586 #[test]
1587 fn test_dir_size_nonexistent() {
1588 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1589 }
1590
1591 #[test]
1592 fn test_get_all_adapters_not_empty() {
1593 let adapters = get_all_adapters();
1594 assert!(adapters.len() >= 6);
1595 }
1596
1597 #[test]
1598 fn test_detect_adapters_npm() {
1599 let tmp = TempDir::new().unwrap();
1600 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1601 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1602 let adapters = detect_adapters(tmp.path());
1603 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1604 assert!(names.contains(&"npm"));
1605 }
1606
1607 #[test]
1608 fn test_detect_adapters_empty_dir() {
1609 let tmp = TempDir::new().unwrap();
1610 let adapters = detect_adapters(tmp.path());
1611 assert!(adapters.is_empty());
1612 }
1613
1614 fn detected_names(dir: &Path) -> Vec<&'static str> {
1620 let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1621 .iter()
1622 .map(|a| a.name())
1623 .collect();
1624 names.sort_unstable();
1625 names
1626 }
1627
1628 #[test]
1629 fn test_detect_adapters_multiple_ecosystems_coexist() {
1630 let tmp = TempDir::new().unwrap();
1632 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1633 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1634 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1635 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1636 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1637
1638 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1643 }
1644
1645 #[test]
1646 fn test_detect_adapters_opt_in_appears_once_enabled() {
1647 let tmp = TempDir::new().unwrap();
1650 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1651
1652 let on = [String::from("cargo")];
1653 let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1654 .iter()
1655 .map(|a| a.name())
1656 .collect();
1657 names.sort_unstable();
1658 assert_eq!(names, vec!["cargo"]);
1659 }
1660
1661 #[test]
1662 fn every_adapter_counts_as_used_even_when_it_is_switched_off() {
1663 let tmp = TempDir::new().unwrap();
1668 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1669
1670 assert!(
1671 detect_adapters_with(tmp.path(), &[], &[]).is_empty(),
1672 "cargo is opt-in, so the prune-facing detector must not see it here"
1673 );
1674 let names: Vec<&str> = detect_all_adapters(tmp.path())
1675 .iter()
1676 .map(|a| a.name())
1677 .collect();
1678 assert_eq!(names, vec!["cargo"]);
1679 }
1680
1681 #[test]
1682 fn test_detect_adapters_disabled_adapter_is_invisible() {
1683 let tmp = TempDir::new().unwrap();
1685 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1686
1687 let off = [String::from("go")];
1688 assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1689 }
1690
1691 #[test]
1692 fn test_js_conflict_resolved_by_package_manager_field() {
1693 let tmp = TempDir::new().unwrap();
1694 fs::write(
1695 tmp.path().join("package.json"),
1696 r#"{"packageManager":"yarn@4.1.0"}"#,
1697 )
1698 .unwrap();
1699 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1700 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1701 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1702
1703 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1704 }
1705
1706 #[test]
1707 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1708 let tmp = TempDir::new().unwrap();
1711 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1712 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1713 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1714 std::thread::sleep(std::time::Duration::from_millis(20));
1715 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1716
1717 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1718 }
1719
1720 #[test]
1721 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1722 let tmp = TempDir::new().unwrap();
1724 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1725 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1726 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1727 let nm = tmp.path().join("node_modules");
1728 fs::create_dir_all(&nm).unwrap();
1729 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1730 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1731
1732 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1733 }
1734
1735 #[test]
1736 fn test_declared_package_manager_outranks_what_is_installed() {
1737 let tmp = TempDir::new().unwrap();
1739 fs::write(
1740 tmp.path().join("package.json"),
1741 r#"{"packageManager":"pnpm@9.1.0"}"#,
1742 )
1743 .unwrap();
1744 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1745 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1746 let nm = tmp.path().join("node_modules");
1747 fs::create_dir_all(&nm).unwrap();
1748 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1749
1750 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1751 }
1752
1753 #[test]
1754 fn test_uv_takes_precedence_over_plain_venv() {
1755 let tmp = TempDir::new().unwrap();
1758 fs::write(
1759 tmp.path().join("pyproject.toml"),
1760 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1761 )
1762 .unwrap();
1763 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1764 let venv = tmp.path().join(".venv");
1765 fs::create_dir_all(&venv).unwrap();
1766 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1767
1768 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1769 }
1770
1771 #[test]
1772 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1773 let tmp = TempDir::new().unwrap();
1774 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1775 let venv = tmp.path().join("venv");
1776 fs::create_dir_all(&venv).unwrap();
1777 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1778
1779 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1780 }
1781
1782 #[test]
1783 fn test_js_conflict_falls_back_to_newest_lockfile() {
1784 let tmp = TempDir::new().unwrap();
1785 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1786 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1787 std::thread::sleep(std::time::Duration::from_millis(20));
1790 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1791
1792 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1793 }
1794
1795 #[test]
1796 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1797 let tmp = TempDir::new().unwrap();
1802 fs::write(
1803 tmp.path().join("package.json"),
1804 r#"{"packageManager":"deno@2.0.0"}"#,
1805 )
1806 .unwrap();
1807 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1808 std::thread::sleep(std::time::Duration::from_millis(20));
1809 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1810
1811 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1812 }
1813
1814 #[test]
1815 fn test_js_conflict_does_not_disturb_a_single_manager() {
1816 let tmp = TempDir::new().unwrap();
1817 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1818 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1819 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1820 }
1821
1822 #[test]
1823 fn test_js_adapters_declare_their_lockfiles() {
1824 for adapter in get_all_adapters() {
1825 if JS_MANAGERS.contains(&adapter.name()) {
1826 assert!(
1827 !adapter.lockfiles().is_empty(),
1828 "{} shares node_modules and must declare its lockfiles for \
1829 conflict resolution",
1830 adapter.name()
1831 );
1832 }
1833 }
1834 }
1835
1836 #[test]
1837 fn test_adapter_names_unique() {
1838 let adapters = get_all_adapters();
1839 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1840 let mut unique = names.clone();
1841 unique.sort();
1842 unique.dedup();
1843 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1844 }
1845
1846 #[test]
1847 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1848 assert!(is_valid_runtime_tag("3.12"));
1851 assert!(is_valid_runtime_tag("3.9"));
1852 for bad in [
1853 "",
1854 "3",
1855 "3.12.1",
1856 "3.x",
1857 "3.12; rm -rf /",
1858 "-3.12",
1859 "../python",
1860 "3.1234",
1861 "300.1",
1862 ] {
1863 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1864 }
1865 }
1866
1867 #[test]
1868 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1869 let tmp = tempfile::tempdir().unwrap();
1870 let venv = tmp.path().join(".venv");
1871 std::fs::create_dir_all(&venv).unwrap();
1872 std::fs::write(
1873 venv.join("pyvenv.cfg"),
1874 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1875 )
1876 .unwrap();
1877 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1878 }
1879
1880 #[test]
1881 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1882 let tmp = tempfile::tempdir().unwrap();
1883 assert_eq!(venv_runtime_tag(tmp.path()), None);
1884 }
1885}