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 dotnet_build;
28pub mod go;
29pub mod gradle;
30pub mod maven;
31pub mod mix;
32pub mod mix_build;
33pub mod npm;
34pub mod pdm;
35pub mod pipenv;
36pub mod pnpm;
37pub mod poetry;
38pub mod swift;
39pub mod terraform;
40pub mod uv;
41pub mod vcpkg;
42pub mod venv;
43pub mod yarn;
44
45use std::collections::HashMap;
46use std::fmt;
47use std::path::{Path, PathBuf};
48use std::sync::{Mutex, OnceLock};
49
50use anyhow::{Context as _, Result};
51use walkdir::WalkDir;
52
53#[derive(Debug, Clone)]
55pub struct BloatDir {
56 pub name: String,
58 pub path: PathBuf,
60 pub size_bytes: u64,
62 pub shared_bytes: u64,
66}
67
68impl fmt::Display for BloatDir {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(f, "{} ({})", self.name, self.path.display())
71 }
72}
73
74#[derive(Debug, Clone)]
77pub struct DriftReport {
78 pub directory: String,
80 pub unrecorded: Vec<String>,
82 pub record_command: &'static str,
84}
85
86pub trait PackageManager: Send + Sync {
94 fn name(&self) -> &'static str;
96
97 fn detect(&self, project_path: &Path) -> bool;
101
102 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
106
107 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
114
115 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
122
123 fn restore_named(
134 &self,
135 project_path: &Path,
136 dir_name: &str,
137 runtime: Option<&str>,
138 timeout: std::time::Duration,
139 ) -> Result<()> {
140 let _ = (dir_name, runtime);
141 self.restore(project_path, timeout)
142 }
143
144 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
154 let _ = (project_path, dir_name);
155 None
156 }
157
158 fn lockfiles(&self) -> &'static [&'static str] {
168 &[]
169 }
170
171 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
179 let _ = project_path;
180 Vec::new()
181 }
182
183 fn opt_in(&self) -> bool {
196 false
197 }
198}
199
200const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
210
211const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
219 ("pnpm", &[".pnpm", ".modules.yaml"]),
220 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
221 ("npm", &[".package-lock.json"]),
222];
223
224pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
228 vec![
229 Box::new(npm::Npm),
230 Box::new(pnpm::Pnpm),
231 Box::new(yarn::Yarn),
232 Box::new(bun::Bun),
233 Box::new(deno::Deno),
234 Box::new(uv::Uv),
235 Box::new(poetry::Poetry),
236 Box::new(pdm::Pdm),
237 Box::new(pipenv::Pipenv),
238 Box::new(venv::Venv),
239 Box::new(cargo_adapter::Cargo),
240 Box::new(go::Go),
241 Box::new(composer::Composer),
242 Box::new(bundler::Bundler),
243 Box::new(cocoapods::CocoaPods),
244 Box::new(mix::Mix),
245 Box::new(mix_build::MixBuild),
246 Box::new(gradle::Gradle),
247 Box::new(maven::Maven),
248 Box::new(swift::Swift),
249 Box::new(terraform::Terraform),
250 Box::new(dart::Dart),
251 Box::new(vcpkg::Vcpkg),
252 Box::new(cmake_build::CmakeBuild),
253 Box::new(dotnet_build::DotnetBuild),
254 ]
255}
256
257fn opt_in_enabled() -> &'static [String] {
265 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
266 ENABLED.get_or_init(|| {
267 crate::config::Registry::load()
268 .map(|r| {
269 let mut names = Vec::new();
270 if r.settings.enable_cargo {
271 names.push("cargo".to_string());
272 }
273 if r.settings.enable_gradle {
274 names.push("gradle".to_string());
275 }
276 if r.settings.enable_maven {
277 names.push("maven".to_string());
278 }
279 if r.settings.enable_swift {
280 names.push("swift".to_string());
281 }
282 if r.settings.enable_dart {
283 names.push("dart".to_string());
284 }
285 if r.settings.enable_mix_build {
286 names.push("mix_build".to_string());
287 }
288 if r.settings.enable_vcpkg {
289 names.push("vcpkg".to_string());
290 }
291 if r.settings.enable_cmake_build {
292 names.push("cmake_build".to_string());
293 }
294 if r.settings.enable_dotnet_build {
295 names.push("dotnet_build".to_string());
296 }
297 names
298 })
299 .unwrap_or_default()
300 })
301}
302
303fn user_disabled() -> &'static [String] {
310 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
311 DISABLED.get_or_init(|| {
312 crate::config::Registry::load()
313 .map(|r| {
314 r.settings
315 .disabled_adapters
316 .iter()
317 .map(|n| n.trim().to_ascii_lowercase())
318 .filter(|n| !n.is_empty())
319 .collect()
320 })
321 .unwrap_or_default()
322 })
323}
324
325pub fn is_adapter_name(name: &str) -> bool {
327 get_all_adapters().iter().any(|a| a.name() == name)
328}
329
330pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
343 ("JavaScript", &["npm", "pnpm", "yarn", "bun", "deno"]),
344 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
345 ("Rust", &["cargo"]),
346 ("Go", &["go"]),
347 ("JVM", &["gradle", "maven"]),
348 ("PHP", &["composer"]),
349 ("Ruby", &["bundler"]),
350 ("Swift & Objective-C", &["swift", "cocoapods"]),
351 ("Elixir", &["mix", "mix_build"]),
352 ("Infrastructure", &["terraform"]),
353 ("Dart & Flutter", &["dart"]),
354 ("C & C++", &["vcpkg", "cmake_build"]),
355 (".NET", &["dotnet_build"]),
356];
357
358pub fn adapter_group(name: &str) -> &'static str {
364 ADAPTER_GROUPS
365 .iter()
366 .find(|(_, names)| names.contains(&name))
367 .map(|(group, _)| *group)
368 .unwrap_or("Other")
369}
370
371pub fn all_adapter_names() -> Vec<&'static str> {
373 get_all_adapters().iter().map(|a| a.name()).collect()
374}
375
376pub fn opt_in_adapter_names() -> Vec<&'static str> {
381 get_all_adapters()
382 .iter()
383 .filter(|a| a.opt_in())
384 .map(|a| a.name())
385 .collect()
386}
387
388pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
395 detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
396}
397
398pub fn detect_all_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
408 let opt_in: Vec<String> = opt_in_adapter_names()
409 .into_iter()
410 .map(str::to_string)
411 .collect();
412 detect_adapters_with(project_path, &opt_in, &[])
413}
414
415fn detect_adapters_with(
423 project_path: &Path,
424 opt_in: &[String],
425 disabled: &[String],
426) -> Vec<Box<dyn PackageManager>> {
427 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
428 .into_iter()
429 .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
430 .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
431 .filter(|adapter| adapter.detect(project_path))
432 .collect();
433 resolve_conflicts(project_path, &mut detected);
434 detected
435}
436
437fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
439 resolve_js_conflict(project_path, detected);
440 resolve_python_conflict(project_path, detected);
441}
442
443fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
456 if detected
457 .iter()
458 .filter(|a| JS_MANAGERS.contains(&a.name()))
459 .count()
460 < 2
461 {
462 return;
463 }
464
465 let winner = declared_package_manager(project_path)
466 .filter(|name| detected.iter().any(|a| a.name() == name))
467 .or_else(|| installed_manager(project_path, detected))
468 .or_else(|| newest_lockfile_owner(project_path, detected));
469
470 let Some(winner) = winner else { return };
471 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
472}
473
474const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
487 ("uv", "uv.lock"),
488 ("poetry", "poetry.lock"),
489 ("pdm", "pdm.lock"),
490 ("pipenv", "Pipfile.lock"),
491];
492
493fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
494 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
495 .iter()
496 .copied()
497 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
498 .collect();
499 let Some(&(first, _)) = claimants.first() else {
500 return;
501 };
502 detected.retain(|a| a.name() != "venv");
503 if claimants.len() < 2 {
504 return;
505 }
506 let winner = claimants
507 .iter()
508 .find(|(_, lockfile)| project_path.join(lockfile).exists())
509 .map_or(first, |(name, _)| *name);
510 detected.retain(|a| {
511 a.name() == winner
512 || !PYTHON_ENV_MANAGERS
513 .iter()
514 .any(|(name, _)| *name == a.name())
515 });
516}
517
518fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
520 let node_modules = project_path.join("node_modules");
521 if !node_modules.is_dir() {
522 return None;
523 }
524
525 JS_INSTALL_MARKERS
526 .iter()
527 .find(|(name, markers)| {
528 detected.iter().any(|a| a.name() == *name)
529 && markers.iter().any(|m| node_modules.join(m).exists())
530 })
531 .map(|(name, _)| (*name).to_string())
532}
533
534fn declared_package_manager(project_path: &Path) -> Option<String> {
536 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
537 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
538 let declared = json.get("packageManager")?.as_str()?;
539 let name = declared.split('@').next().unwrap_or_default();
540 JS_MANAGERS
541 .iter()
542 .find(|m| **m == name)
543 .map(|m| (*m).to_string())
544}
545
546fn newest_lockfile_owner(
548 project_path: &Path,
549 detected: &[Box<dyn PackageManager>],
550) -> Option<String> {
551 detected
552 .iter()
553 .filter(|a| JS_MANAGERS.contains(&a.name()))
554 .filter_map(|a| {
555 let newest = a
556 .lockfiles()
557 .iter()
558 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
559 .max()?;
560 Some((newest, a.name().to_string()))
561 })
562 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
565 match best {
566 Some(b) if b.0 >= cur.0 => Some(b),
567 _ => Some(cur),
568 }
569 })
570 .map(|(_, name)| name)
571}
572
573pub fn dir_size(path: &Path) -> u64 {
575 if !path.exists() {
576 return 0;
577 }
578 WalkDir::new(path)
579 .follow_links(false)
580 .into_iter()
581 .flatten()
582 .filter_map(|entry| entry.metadata().ok())
583 .filter(|meta| meta.is_file())
584 .map(|meta| meta.len())
585 .sum()
586}
587
588#[derive(Debug, Clone, Copy, Default)]
590pub struct DirSizeBreakdown {
591 pub freed_bytes: u64,
593 pub shared_bytes: u64,
596}
597
598pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
609 let mut out = DirSizeBreakdown::default();
610 if !path.exists() {
611 return out;
612 }
613 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
615 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
616 let Ok(meta) = entry.metadata() else { continue };
617 if !meta.is_file() {
618 continue;
619 }
620 match file_link_identity(entry.path(), &meta) {
621 Some((dev, ino, nlink)) if nlink > 1 => {
622 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
623 }
624 _ => out.freed_bytes += meta.len(),
625 }
626 }
627 for (bytes, nlink, seen) in linked.into_values() {
628 if seen >= nlink {
629 out.freed_bytes += bytes;
630 } else {
631 out.shared_bytes += bytes;
632 }
633 }
634 out
635}
636
637#[cfg(unix)]
639fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
640 use std::os::unix::fs::MetadataExt as _;
641 Some((meta.dev(), meta.ino(), meta.nlink()))
642}
643
644#[cfg(windows)]
648fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
649 use std::os::windows::fs::OpenOptionsExt as _;
650 use std::os::windows::io::AsRawHandle as _;
651 use windows_sys::Win32::Storage::FileSystem::{
652 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
653 };
654
655 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
658 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
659 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
662 return None;
663 }
664 Some((
665 u64::from(info.dwVolumeSerialNumber),
666 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
667 u64::from(info.nNumberOfLinks),
668 ))
669}
670
671#[cfg(not(any(unix, windows)))]
672fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
673 None
674}
675
676pub fn resolve_program(program: &str) -> String {
686 #[cfg(windows)]
687 {
688 if Path::new(program).components().count() > 1 {
689 return program.to_string();
690 }
691 let Some(path_var) = std::env::var_os("PATH") else {
692 return program.to_string();
693 };
694 for dir in std::env::split_paths(&path_var) {
695 for ext in ["exe", "cmd", "bat"] {
696 let candidate = dir.join(format!("{program}.{ext}"));
697 if candidate.is_file() {
698 return candidate.to_string_lossy().into_owned();
699 }
700 }
701 }
702 }
703 program.to_string()
704}
705
706pub fn binary_available(program: &str) -> bool {
714 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
715 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
716
717 let mut guard = match cache.lock() {
720 Ok(g) => g,
721 Err(_) => return probe_binary(program),
724 };
725 if let Some(known) = guard.get(program) {
726 return *known;
727 }
728 let available = probe_binary(program);
729 guard.insert(program.to_string(), available);
730 available
731}
732
733const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
741
742fn version_probe_args(program: &str) -> &'static [&'static str] {
744 VERSION_PROBE_ARGS
745 .iter()
746 .find(|(name, _)| *name == program)
747 .map_or(&["--version"], |(_, args)| *args)
748}
749
750fn probe_binary(program: &str) -> bool {
752 crate::spawn::command(resolve_program(program))
753 .args(version_probe_args(program))
754 .stdin(std::process::Stdio::null())
755 .output()
756 .map(|o| o.status.success())
757 .unwrap_or(false)
758}
759
760struct CommandOutput {
762 status: std::process::ExitStatus,
763 stdout: String,
764 stderr: String,
765}
766
767fn spawn_capture(
774 program: &str,
775 args: &[&str],
776 cwd: &Path,
777 timeout: std::time::Duration,
778) -> Result<CommandOutput> {
779 use std::io::Read;
780 use std::process::Stdio;
781 use std::thread;
782 use std::time::Instant;
783
784 let resolved = resolve_program(program);
785 let mut child = crate::spawn::command(&resolved)
786 .args(args)
787 .current_dir(cwd)
788 .stdin(Stdio::null())
789 .stdout(Stdio::piped())
790 .stderr(Stdio::piped())
791 .spawn()
792 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
793
794 let mut stdout_pipe = child.stdout.take();
798 let mut stderr_pipe = child.stderr.take();
799 let stdout_reader = thread::spawn(move || {
800 let mut buf = Vec::new();
801 if let Some(pipe) = stdout_pipe.as_mut() {
802 let _ = pipe.read_to_end(&mut buf);
803 }
804 buf
805 });
806 let stderr_reader = thread::spawn(move || {
807 let mut buf = Vec::new();
808 if let Some(pipe) = stderr_pipe.as_mut() {
809 let _ = pipe.read_to_end(&mut buf);
810 }
811 buf
812 });
813
814 let start = Instant::now();
815 let status = loop {
816 match child.try_wait()? {
817 Some(status) => break status,
818 None => {
819 if start.elapsed() >= timeout {
820 let _ = child.kill();
821 let _ = child.wait();
822 anyhow::bail!(
823 "Command timed out after {}s: {} {}\n\
824 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
825 timeout.as_secs(),
826 program,
827 args.join(" ")
828 );
829 }
830 thread::sleep(std::time::Duration::from_millis(100));
831 }
832 }
833 };
834
835 let stderr = stderr_reader
836 .join()
837 .map(|b| String::from_utf8_lossy(&b).into_owned())
838 .unwrap_or_default();
839 let stdout = stdout_reader
840 .join()
841 .map(|b| String::from_utf8_lossy(&b).into_owned())
842 .unwrap_or_default();
843
844 Ok(CommandOutput {
845 status,
846 stdout,
847 stderr,
848 })
849}
850
851pub fn run_command_with_timeout(
853 program: &str,
854 args: &[&str],
855 cwd: &Path,
856 timeout: std::time::Duration,
857) -> Result<()> {
858 let out = spawn_capture(program, args, cwd, timeout)?;
859 if out.status.success() {
860 Ok(())
861 } else {
862 anyhow::bail!(
863 "{} {} failed (exit code {:?}):\n{}",
864 program,
865 args.join(" "),
866 out.status.code(),
867 crate::output::condense_tool_output(
868 &out.stderr,
869 crate::constants::TOOL_OUTPUT_MAX_LINES
870 )
871 )
872 }
873}
874
875pub struct CapturedCommand {
878 pub ok: bool,
880 pub stdout: String,
882 pub stderr: String,
884}
885
886pub fn capture_allowing_failure(
896 program: &str,
897 args: &[&str],
898 cwd: &Path,
899 timeout: std::time::Duration,
900) -> Result<CapturedCommand> {
901 let out = spawn_capture(program, args, cwd, timeout)?;
902 Ok(CapturedCommand {
903 ok: out.status.success(),
904 stdout: out.stdout,
905 stderr: out.stderr,
906 })
907}
908
909pub fn capture_command_with_timeout(
915 program: &str,
916 args: &[&str],
917 cwd: &Path,
918 timeout: std::time::Duration,
919) -> Result<String> {
920 let out = spawn_capture(program, args, cwd, timeout)?;
921 if out.status.success() {
922 Ok(out.stdout)
923 } else {
924 anyhow::bail!(
925 "{} {} failed (exit code {:?}):\n{}",
926 program,
927 args.join(" "),
928 out.status.code(),
929 crate::output::condense_tool_output(
930 &out.stderr,
931 crate::constants::TOOL_OUTPUT_MAX_LINES
932 )
933 )
934 }
935}
936
937pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
939 crate::spawn::command(resolve_program(program))
940 .args(args)
941 .current_dir(cwd)
942 .stdin(std::process::Stdio::null())
943 .output()
944 .map(|o| o.status.success())
945 .unwrap_or(false)
946}
947
948const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
956
957fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
964 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
965 Some("Cargo.lock") => "Cargo.toml",
966 Some("package-lock.json")
967 | Some("yarn.lock")
968 | Some("pnpm-lock.yaml")
969 | Some("bun.lockb")
970 | Some("bun.lock") => "package.json",
971 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
972 Some("go.sum") => "go.mod",
973 Some("composer.lock") => "composer.json",
974 Some("Gemfile.lock") => "Gemfile",
975 Some("Pipfile.lock") => "Pipfile",
976 _ => return Ok(()),
977 };
978 let manifest = cwd.join(manifest_name);
979 let (Ok(manifest_meta), Ok(lock_meta)) =
980 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
981 else {
982 return Ok(());
983 };
984 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
985 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
986 {
987 anyhow::bail!(
988 "`{program}` is not available, and `{manifest_name}` has been edited more \
989 recently than `{}` — the lockfile may no longer record the current \
990 dependencies, and without `{program}` that cannot be verified. Install \
991 {program} and run its lockfile sync, then prune again.",
992 lockfile.display()
993 );
994 }
995 Ok(())
996}
997
998pub fn refuse_if_manifest_stale(
1009 manifest: &Path,
1010 lockfile: &Path,
1011 sync_command: &str,
1012) -> Result<()> {
1013 let (Ok(manifest_meta), Ok(lock_meta)) =
1014 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
1015 else {
1016 return Ok(());
1017 };
1018 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1019 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1020 {
1021 anyhow::bail!(
1022 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
1023 record the current dependencies. Run `{sync_command}` and prune again.",
1024 manifest.display(),
1025 lockfile.display()
1026 );
1027 }
1028 Ok(())
1029}
1030
1031pub fn lock_sync_or_verify_with_timeout(
1033 lockfile: &Path,
1034 program: &str,
1035 sync_args: &[&str],
1036 cwd: &Path,
1037 timeout: std::time::Duration,
1038) -> Result<()> {
1039 let lockfile_exists = lockfile.exists();
1040
1041 if !binary_available(program) {
1042 if lockfile_exists {
1043 refuse_if_manifest_newer(lockfile, program, cwd)?;
1044 return Ok(());
1045 } else {
1046 anyhow::bail!(
1047 "`{program}` is not available and no lockfile was found at `{}`. \
1048 Cannot safely delete dependencies — install {program} first, \
1049 or commit a lockfile.",
1050 lockfile.display()
1051 );
1052 }
1053 }
1054
1055 run_command_with_timeout(program, sync_args, cwd, timeout)
1057}
1058
1059#[derive(Debug, Clone, Copy)]
1065pub struct EnforcePolicy {
1066 pub allow_rewrite: bool,
1072 pub timeout: std::time::Duration,
1074}
1075
1076impl Default for EnforcePolicy {
1077 fn default() -> Self {
1078 Self {
1079 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1080 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1081 }
1082 }
1083}
1084
1085impl EnforcePolicy {
1086 pub fn from_settings(settings: &crate::config::Settings) -> Self {
1088 Self {
1089 allow_rewrite: settings.allow_manifest_rewrite,
1090 timeout: command_timeout(settings.command_timeout_secs),
1091 }
1092 }
1093}
1094
1095pub fn command_timeout(secs: u64) -> std::time::Duration {
1103 std::time::Duration::from_secs(secs.max(1))
1104}
1105
1106pub fn enforce_two_tier(
1122 lockfile: &Path,
1123 program: &str,
1124 verify_args: &[&str],
1125 write_args: &[&str],
1126 cwd: &Path,
1127 policy: EnforcePolicy,
1128) -> Result<()> {
1129 if policy.allow_rewrite {
1130 return lock_sync_or_verify_with_timeout(
1131 lockfile,
1132 program,
1133 write_args,
1134 cwd,
1135 policy.timeout,
1136 );
1137 }
1138 lock_verify_or_generate(
1139 lockfile,
1140 program,
1141 verify_args,
1142 write_args,
1143 cwd,
1144 policy.timeout,
1145 )
1146}
1147
1148pub fn lock_verify_or_generate(
1158 lockfile: &Path,
1159 program: &str,
1160 verify_args: &[&str],
1161 generate_args: &[&str],
1162 cwd: &Path,
1163 timeout: std::time::Duration,
1164) -> Result<()> {
1165 let lockfile_exists = lockfile.exists();
1166
1167 if !binary_available(program) {
1168 if lockfile_exists {
1169 refuse_if_manifest_newer(lockfile, program, cwd)?;
1170 return Ok(());
1171 }
1172 anyhow::bail!(
1173 "`{program}` is not available and no lockfile was found at `{}`. \
1174 Cannot safely delete dependencies — install {program} first, \
1175 or commit a lockfile.",
1176 lockfile.display()
1177 );
1178 }
1179
1180 if lockfile_exists {
1181 run_command_with_timeout(program, verify_args, cwd, timeout)
1182 } else {
1183 run_command_with_timeout(program, generate_args, cwd, timeout)
1184 }
1185}
1186
1187pub fn lock_sync_or_verify(
1189 lockfile: &Path,
1190 program: &str,
1191 sync_args: &[&str],
1192 cwd: &Path,
1193) -> Result<()> {
1194 lock_sync_or_verify_with_timeout(
1195 lockfile,
1196 program,
1197 sync_args,
1198 cwd,
1199 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1200 )
1201}
1202
1203const PYVENV_CFG: &str = "pyvenv.cfg";
1209
1210pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1217 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1218 for line in cfg.lines() {
1219 let Some((key, value)) = line.split_once('=') else {
1220 continue;
1221 };
1222 if matches!(key.trim(), "version" | "version_info") {
1223 let mut parts = value.trim().split('.');
1224 let major: u64 = parts.next()?.parse().ok()?;
1225 let minor: u64 = parts.next()?.parse().ok()?;
1226 return Some(format!("{major}.{minor}"));
1227 }
1228 }
1229 None
1230}
1231
1232pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1236 let mut parts = tag.split('.');
1237 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1238 return false;
1239 };
1240 !major.is_empty()
1241 && !minor.is_empty()
1242 && major.len() <= 2
1243 && minor.len() <= 3
1244 && major.bytes().all(|b| b.is_ascii_digit())
1245 && minor.bytes().all(|b| b.is_ascii_digit())
1246}
1247
1248pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1256 if !is_valid_runtime_tag(tag) {
1257 return None;
1258 }
1259 #[cfg(windows)]
1260 {
1261 Some(("py".to_string(), vec![format!("-{tag}")]))
1262 }
1263 #[cfg(not(windows))]
1264 {
1265 Some((format!("python{tag}"), Vec::new()))
1266 }
1267}
1268
1269pub(crate) fn python_executable(tag: &str) -> Option<String> {
1277 let (program, prefix) = python_launcher(tag)?;
1278 let out = crate::spawn::command(resolve_program(&program))
1279 .args(&prefix)
1280 .args(["-c", "import sys; print(sys.executable)"])
1281 .stdin(std::process::Stdio::null())
1282 .stderr(std::process::Stdio::null())
1283 .output()
1284 .ok()?;
1285 if !out.status.success() {
1286 return None;
1287 }
1288 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1289 (!path.is_empty()).then_some(path)
1290}
1291
1292pub(crate) fn python_runtime_available(tag: &str) -> bool {
1297 let Some((program, prefix)) = python_launcher(tag) else {
1298 return false;
1299 };
1300 crate::spawn::command(resolve_program(&program))
1301 .args(&prefix)
1302 .arg("--version")
1303 .stdin(std::process::Stdio::null())
1304 .stdout(std::process::Stdio::null())
1305 .stderr(std::process::Stdio::null())
1306 .status()
1307 .is_ok_and(|s| s.success())
1308}
1309
1310const NO_RESTORE_BINARY: [&str; 8] = [
1311 "venv",
1312 "gradle",
1313 "maven",
1314 "mix_build",
1315 "swift",
1316 "vcpkg",
1317 "cmake_build",
1318 "dotnet_build",
1319];
1320
1321const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1323
1324pub fn adapter_binary(adapter: &str) -> &str {
1326 ADAPTER_BINARIES
1327 .iter()
1328 .find(|(name, _)| *name == adapter)
1329 .map_or(adapter, |(_, binary)| *binary)
1330}
1331
1332const INSTALL_HINTS: [(&str, &str); 17] = [
1337 ("npm", "ships with Node.js — https://nodejs.org"),
1338 (
1339 "pnpm",
1340 "`npm install -g pnpm` — https://pnpm.io/installation",
1341 ),
1342 (
1343 "yarn",
1344 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1345 ),
1346 ("bun", "https://bun.sh/docs/installation"),
1347 (
1348 "deno",
1349 "https://docs.deno.com/runtime/getting_started/installation/",
1350 ),
1351 (
1352 "uv",
1353 "https://docs.astral.sh/uv/getting-started/installation/",
1354 ),
1355 ("poetry", "https://python-poetry.org/docs/#installation"),
1356 (
1357 "pdm",
1358 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1359 ),
1360 (
1361 "pipenv",
1362 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1363 ),
1364 ("cargo", "ships with Rust — https://rustup.rs"),
1365 ("go", "https://go.dev/dl/"),
1366 ("composer", "https://getcomposer.org/download/"),
1367 ("bundler", "`gem install bundler` — https://bundler.io"),
1368 (
1369 "cocoapods",
1370 "`gem install cocoapods` — https://cocoapods.org",
1371 ),
1372 (
1373 "mix",
1374 "ships with Elixir — https://elixir-lang.org/install.html",
1375 ),
1376 (
1377 "terraform",
1378 "https://developer.hashicorp.com/terraform/install",
1379 ),
1380 (
1381 "dart",
1382 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1383 ),
1384];
1385
1386pub fn install_hint(adapter: &str) -> Option<&'static str> {
1388 INSTALL_HINTS
1389 .iter()
1390 .find(|(name, _)| *name == adapter)
1391 .map(|(_, hint)| *hint)
1392}
1393
1394#[derive(Debug, Clone)]
1396pub struct BinaryCheckStatus {
1397 pub name: String,
1398 pub available: bool,
1399 pub version: Option<String>,
1400}
1401
1402pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1404 let mut unique: Vec<String> = adapter_names
1405 .iter()
1406 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1409 .cloned()
1410 .collect();
1411 unique.sort();
1412 unique.dedup();
1413
1414 unique
1415 .into_iter()
1416 .map(|name| {
1417 let binary = adapter_binary(&name);
1418 let output = crate::spawn::command(resolve_program(binary))
1419 .args(version_probe_args(binary))
1420 .stdin(std::process::Stdio::null())
1421 .output();
1422 match output {
1423 Ok(out) if out.status.success() => {
1424 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1425 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1426 BinaryCheckStatus {
1427 name,
1428 available: true,
1429 version: if first_line.is_empty() {
1430 None
1431 } else {
1432 Some(first_line)
1433 },
1434 }
1435 }
1436 _ => BinaryCheckStatus {
1437 name,
1438 available: false,
1439 version: None,
1440 },
1441 }
1442 })
1443 .collect()
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use super::*;
1449 use std::fs;
1450 use tempfile::TempDir;
1451
1452 #[test]
1453 fn every_adapter_is_grouped_exactly_once() {
1454 let registered = all_adapter_names();
1457 let grouped: Vec<&str> = ADAPTER_GROUPS
1458 .iter()
1459 .flat_map(|(_, names)| names.iter().copied())
1460 .collect();
1461
1462 for name in ®istered {
1463 assert_eq!(
1464 grouped.iter().filter(|g| *g == name).count(),
1465 1,
1466 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1467 );
1468 }
1469 for name in &grouped {
1470 assert!(
1471 registered.contains(name),
1472 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1473 );
1474 }
1475 assert_eq!(registered.len(), grouped.len());
1476 }
1477
1478 #[test]
1479 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1480 let mut opt_in = opt_in_adapter_names();
1485 opt_in.sort_unstable();
1486 assert_eq!(
1487 opt_in,
1488 vec![
1489 "cargo",
1490 "cmake_build",
1491 "dart",
1492 "dotnet_build",
1493 "gradle",
1494 "maven",
1495 "mix_build",
1496 "swift",
1497 "vcpkg"
1498 ]
1499 );
1500 }
1501
1502 #[test]
1503 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1504 assert_eq!(version_probe_args("go"), &["version"]);
1509 assert_eq!(version_probe_args("npm"), &["--version"]);
1510 }
1511
1512 #[test]
1513 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1514 for adapter in get_all_adapters() {
1517 let name = adapter.name();
1518 if NO_RESTORE_BINARY.contains(&name) {
1519 continue;
1520 }
1521 assert!(
1522 install_hint(name).is_some(),
1523 "adapter `{name}` has no install hint"
1524 );
1525 }
1526 }
1527
1528 #[test]
1529 fn test_bloat_dir_display() {
1530 let bd = BloatDir {
1531 name: "node_modules".to_string(),
1532 path: PathBuf::from("/test/node_modules"),
1533 size_bytes: 1024,
1534 shared_bytes: 0,
1535 };
1536 assert!(bd.to_string().contains("node_modules"));
1537 }
1538
1539 #[test]
1540 fn test_hardlink_size_counts_a_plain_file_in_full() {
1541 let tmp = TempDir::new().unwrap();
1542 let tree = tmp.path().join("tree");
1543 fs::create_dir(&tree).unwrap();
1544 fs::write(tree.join("copied.txt"), "12345").unwrap();
1545 let size = dir_size_with_hardlinks(&tree);
1546 assert_eq!(size.freed_bytes, 5);
1547 assert_eq!(size.shared_bytes, 0);
1548 }
1549
1550 #[test]
1551 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1552 let tmp = TempDir::new().unwrap();
1555 let store = tmp.path().join("store");
1556 let tree = tmp.path().join("tree");
1557 fs::create_dir(&store).unwrap();
1558 fs::create_dir(&tree).unwrap();
1559 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1560 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1561 let size = dir_size_with_hardlinks(&tree);
1562 assert_eq!(size.freed_bytes, 0);
1563 assert_eq!(size.shared_bytes, 10);
1564 }
1565
1566 #[test]
1567 fn test_hardlink_size_counts_an_internal_pair_once() {
1568 let tmp = TempDir::new().unwrap();
1571 let tree = tmp.path().join("tree");
1572 fs::create_dir(&tree).unwrap();
1573 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1574 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1575 let size = dir_size_with_hardlinks(&tree);
1576 assert_eq!(size.freed_bytes, 7);
1577 assert_eq!(size.shared_bytes, 0);
1578 }
1579
1580 #[test]
1581 fn test_dir_size_empty() {
1582 let tmp = TempDir::new().unwrap();
1583 assert_eq!(dir_size(tmp.path()), 0);
1584 }
1585
1586 #[test]
1587 fn test_dir_size_with_files() {
1588 let tmp = TempDir::new().unwrap();
1589 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1590 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1591 assert_eq!(dir_size(tmp.path()), 11); }
1593
1594 #[test]
1595 fn test_dir_size_nonexistent() {
1596 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1597 }
1598
1599 #[test]
1600 fn test_get_all_adapters_not_empty() {
1601 let adapters = get_all_adapters();
1602 assert!(adapters.len() >= 6);
1603 }
1604
1605 #[test]
1606 fn test_detect_adapters_npm() {
1607 let tmp = TempDir::new().unwrap();
1608 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1609 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1610 let adapters = detect_adapters(tmp.path());
1611 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1612 assert!(names.contains(&"npm"));
1613 }
1614
1615 #[test]
1616 fn test_detect_adapters_empty_dir() {
1617 let tmp = TempDir::new().unwrap();
1618 let adapters = detect_adapters(tmp.path());
1619 assert!(adapters.is_empty());
1620 }
1621
1622 fn detected_names(dir: &Path) -> Vec<&'static str> {
1628 let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1629 .iter()
1630 .map(|a| a.name())
1631 .collect();
1632 names.sort_unstable();
1633 names
1634 }
1635
1636 #[test]
1637 fn test_detect_adapters_multiple_ecosystems_coexist() {
1638 let tmp = TempDir::new().unwrap();
1640 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1641 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1642 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1643 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1644 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1645
1646 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1651 }
1652
1653 #[test]
1654 fn test_detect_adapters_opt_in_appears_once_enabled() {
1655 let tmp = TempDir::new().unwrap();
1658 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1659
1660 let on = [String::from("cargo")];
1661 let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1662 .iter()
1663 .map(|a| a.name())
1664 .collect();
1665 names.sort_unstable();
1666 assert_eq!(names, vec!["cargo"]);
1667 }
1668
1669 #[test]
1670 fn every_adapter_counts_as_used_even_when_it_is_switched_off() {
1671 let tmp = TempDir::new().unwrap();
1676 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1677
1678 assert!(
1679 detect_adapters_with(tmp.path(), &[], &[]).is_empty(),
1680 "cargo is opt-in, so the prune-facing detector must not see it here"
1681 );
1682 let names: Vec<&str> = detect_all_adapters(tmp.path())
1683 .iter()
1684 .map(|a| a.name())
1685 .collect();
1686 assert_eq!(names, vec!["cargo"]);
1687 }
1688
1689 #[test]
1690 fn test_detect_adapters_disabled_adapter_is_invisible() {
1691 let tmp = TempDir::new().unwrap();
1693 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1694
1695 let off = [String::from("go")];
1696 assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1697 }
1698
1699 #[test]
1700 fn test_js_conflict_resolved_by_package_manager_field() {
1701 let tmp = TempDir::new().unwrap();
1702 fs::write(
1703 tmp.path().join("package.json"),
1704 r#"{"packageManager":"yarn@4.1.0"}"#,
1705 )
1706 .unwrap();
1707 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1708 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1709 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1710
1711 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1712 }
1713
1714 #[test]
1715 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1716 let tmp = TempDir::new().unwrap();
1719 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1720 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1721 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1722 std::thread::sleep(std::time::Duration::from_millis(20));
1723 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1724
1725 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1726 }
1727
1728 #[test]
1729 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1730 let tmp = TempDir::new().unwrap();
1732 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1733 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1734 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1735 let nm = tmp.path().join("node_modules");
1736 fs::create_dir_all(&nm).unwrap();
1737 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1738 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1739
1740 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1741 }
1742
1743 #[test]
1744 fn test_declared_package_manager_outranks_what_is_installed() {
1745 let tmp = TempDir::new().unwrap();
1747 fs::write(
1748 tmp.path().join("package.json"),
1749 r#"{"packageManager":"pnpm@9.1.0"}"#,
1750 )
1751 .unwrap();
1752 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1753 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1754 let nm = tmp.path().join("node_modules");
1755 fs::create_dir_all(&nm).unwrap();
1756 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1757
1758 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1759 }
1760
1761 #[test]
1762 fn test_uv_takes_precedence_over_plain_venv() {
1763 let tmp = TempDir::new().unwrap();
1766 fs::write(
1767 tmp.path().join("pyproject.toml"),
1768 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1769 )
1770 .unwrap();
1771 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1772 let venv = tmp.path().join(".venv");
1773 fs::create_dir_all(&venv).unwrap();
1774 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1775
1776 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1777 }
1778
1779 #[test]
1780 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1781 let tmp = TempDir::new().unwrap();
1782 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1783 let venv = tmp.path().join("venv");
1784 fs::create_dir_all(&venv).unwrap();
1785 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1786
1787 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1788 }
1789
1790 #[test]
1791 fn test_js_conflict_falls_back_to_newest_lockfile() {
1792 let tmp = TempDir::new().unwrap();
1793 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1794 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1795 std::thread::sleep(std::time::Duration::from_millis(20));
1798 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1799
1800 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1801 }
1802
1803 #[test]
1804 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1805 let tmp = TempDir::new().unwrap();
1810 fs::write(
1811 tmp.path().join("package.json"),
1812 r#"{"packageManager":"deno@2.0.0"}"#,
1813 )
1814 .unwrap();
1815 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1816 std::thread::sleep(std::time::Duration::from_millis(20));
1817 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1818
1819 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1820 }
1821
1822 #[test]
1823 fn test_js_conflict_does_not_disturb_a_single_manager() {
1824 let tmp = TempDir::new().unwrap();
1825 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1826 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1827 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1828 }
1829
1830 #[test]
1831 fn test_js_adapters_declare_their_lockfiles() {
1832 for adapter in get_all_adapters() {
1833 if JS_MANAGERS.contains(&adapter.name()) {
1834 assert!(
1835 !adapter.lockfiles().is_empty(),
1836 "{} shares node_modules and must declare its lockfiles for \
1837 conflict resolution",
1838 adapter.name()
1839 );
1840 }
1841 }
1842 }
1843
1844 #[test]
1845 fn test_adapter_names_unique() {
1846 let adapters = get_all_adapters();
1847 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1848 let mut unique = names.clone();
1849 unique.sort();
1850 unique.dedup();
1851 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1852 }
1853
1854 #[test]
1855 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1856 assert!(is_valid_runtime_tag("3.12"));
1859 assert!(is_valid_runtime_tag("3.9"));
1860 for bad in [
1861 "",
1862 "3",
1863 "3.12.1",
1864 "3.x",
1865 "3.12; rm -rf /",
1866 "-3.12",
1867 "../python",
1868 "3.1234",
1869 "300.1",
1870 ] {
1871 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1872 }
1873 }
1874
1875 #[test]
1876 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1877 let tmp = tempfile::tempdir().unwrap();
1878 let venv = tmp.path().join(".venv");
1879 std::fs::create_dir_all(&venv).unwrap();
1880 std::fs::write(
1881 venv.join("pyvenv.cfg"),
1882 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1883 )
1884 .unwrap();
1885 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1886 }
1887
1888 #[test]
1889 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1890 let tmp = tempfile::tempdir().unwrap();
1891 assert_eq!(venv_runtime_tag(tmp.path()), None);
1892 }
1893}