1pub mod bun;
20pub mod bundler;
21pub mod cabal;
22pub mod cargo_adapter;
23pub mod cmake_build;
24pub mod cocoapods;
25pub mod cocos;
26pub mod composer;
27pub mod dart;
28pub mod defold;
29pub mod deno;
30pub mod dotnet_build;
31pub mod go;
32pub mod godot;
33pub mod gradle;
34pub mod maven;
35pub mod mix;
36pub mod mix_build;
37pub mod npm;
38pub mod pdm;
39pub mod pipenv;
40pub mod pixi;
41pub mod pnpm;
42pub mod poetry;
43pub mod sbt;
44pub mod stack;
45pub mod swift;
46pub mod terraform;
47pub mod unity;
48pub mod unreal;
49pub mod uv;
50pub mod vcpkg;
51pub mod venv;
52pub mod yarn;
53pub mod zig;
54
55use std::collections::HashMap;
56use std::fmt;
57use std::path::{Path, PathBuf};
58use std::sync::{Mutex, OnceLock};
59
60use anyhow::{Context as _, Result};
61use walkdir::WalkDir;
62
63#[derive(Debug, Clone)]
65pub struct BloatDir {
66 pub name: String,
68 pub path: PathBuf,
70 pub size_bytes: u64,
72 pub shared_bytes: u64,
76}
77
78impl fmt::Display for BloatDir {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 write!(f, "{} ({})", self.name, self.path.display())
81 }
82}
83
84#[derive(Debug, Clone)]
87pub struct DriftReport {
88 pub directory: String,
90 pub unrecorded: Vec<String>,
92 pub record_command: &'static str,
94}
95
96pub trait PackageManager: Send + Sync {
104 fn name(&self) -> &'static str;
106
107 fn detect(&self, project_path: &Path) -> bool;
111
112 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
116
117 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
124
125 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
132
133 fn restore_named(
144 &self,
145 project_path: &Path,
146 dir_name: &str,
147 runtime: Option<&str>,
148 timeout: std::time::Duration,
149 ) -> Result<()> {
150 let _ = (dir_name, runtime);
151 self.restore(project_path, timeout)
152 }
153
154 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
164 let _ = (project_path, dir_name);
165 None
166 }
167
168 fn lockfiles(&self) -> &'static [&'static str] {
178 &[]
179 }
180
181 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
189 let _ = project_path;
190 Vec::new()
191 }
192
193 fn opt_in(&self) -> bool {
208 false
209 }
210}
211
212const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
222
223const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
231 ("pnpm", &[".pnpm", ".modules.yaml"]),
232 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
233 ("npm", &[".package-lock.json"]),
234];
235
236pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
240 vec![
241 Box::new(npm::Npm),
242 Box::new(pnpm::Pnpm),
243 Box::new(yarn::Yarn),
244 Box::new(bun::Bun),
245 Box::new(deno::Deno),
246 Box::new(uv::Uv),
247 Box::new(poetry::Poetry),
248 Box::new(pdm::Pdm),
249 Box::new(pipenv::Pipenv),
250 Box::new(venv::Venv),
251 Box::new(pixi::Pixi),
252 Box::new(cargo_adapter::Cargo),
253 Box::new(go::Go),
254 Box::new(composer::Composer),
255 Box::new(bundler::Bundler),
256 Box::new(cocoapods::CocoaPods),
257 Box::new(mix::Mix),
258 Box::new(mix_build::MixBuild),
259 Box::new(gradle::Gradle),
260 Box::new(maven::Maven),
261 Box::new(swift::Swift),
262 Box::new(terraform::Terraform),
263 Box::new(dart::Dart),
264 Box::new(vcpkg::Vcpkg),
265 Box::new(cmake_build::CmakeBuild),
266 Box::new(dotnet_build::DotnetBuild),
267 Box::new(godot::Godot),
268 Box::new(unity::Unity),
269 Box::new(unreal::Unreal),
270 Box::new(defold::Defold),
271 Box::new(cocos::Cocos),
272 Box::new(zig::Zig),
273 Box::new(stack::Stack),
274 Box::new(cabal::Cabal),
275 Box::new(sbt::Sbt),
276 ]
277}
278
279fn opt_in_enabled() -> &'static [String] {
287 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
288 ENABLED.get_or_init(|| {
289 crate::config::Registry::load()
290 .map(|r| {
291 let mut names = Vec::new();
292 if r.settings.enable_cargo {
293 names.push("cargo".to_string());
294 }
295 if r.settings.enable_gradle {
296 names.push("gradle".to_string());
297 }
298 if r.settings.enable_maven {
299 names.push("maven".to_string());
300 }
301 if r.settings.enable_swift {
302 names.push("swift".to_string());
303 }
304 if r.settings.enable_dart {
305 names.push("dart".to_string());
306 }
307 if r.settings.enable_mix_build {
308 names.push("mix_build".to_string());
309 }
310 if r.settings.enable_vcpkg {
311 names.push("vcpkg".to_string());
312 }
313 if r.settings.enable_cmake_build {
314 names.push("cmake_build".to_string());
315 }
316 if r.settings.enable_dotnet_build {
317 names.push("dotnet_build".to_string());
318 }
319 if r.settings.enable_godot {
320 names.push("godot".to_string());
321 }
322 if r.settings.enable_unity {
323 names.push("unity".to_string());
324 }
325 if r.settings.enable_unreal {
326 names.push("unreal".to_string());
327 }
328 if r.settings.enable_defold {
329 names.push("defold".to_string());
330 }
331 if r.settings.enable_cocos {
332 names.push("cocos".to_string());
333 }
334 if r.settings.enable_zig {
335 names.push("zig".to_string());
336 }
337 if r.settings.enable_stack {
338 names.push("stack".to_string());
339 }
340 if r.settings.enable_cabal {
341 names.push("cabal".to_string());
342 }
343 if r.settings.enable_sbt {
344 names.push("sbt".to_string());
345 }
346 names
347 })
348 .unwrap_or_default()
349 })
350}
351
352fn user_disabled() -> &'static [String] {
359 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
360 DISABLED.get_or_init(|| {
361 crate::config::Registry::load()
362 .map(|r| {
363 r.settings
364 .disabled_adapters
365 .iter()
366 .map(|n| n.trim().to_ascii_lowercase())
367 .filter(|n| !n.is_empty())
368 .collect()
369 })
370 .unwrap_or_default()
371 })
372}
373
374pub fn is_adapter_name(name: &str) -> bool {
376 get_all_adapters().iter().any(|a| a.name() == name)
377}
378
379pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
392 ("JavaScript", &["npm", "pnpm", "yarn", "bun", "deno"]),
393 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv", "pixi"]),
394 ("Rust", &["cargo"]),
395 ("Go", &["go"]),
396 ("JVM", &["gradle", "maven", "sbt"]),
397 ("PHP", &["composer"]),
398 ("Ruby", &["bundler"]),
399 ("Swift & Objective-C", &["swift", "cocoapods"]),
400 ("Elixir", &["mix", "mix_build"]),
401 ("Infrastructure", &["terraform"]),
402 ("Dart & Flutter", &["dart"]),
403 ("C & C++", &["vcpkg", "cmake_build"]),
404 (".NET", &["dotnet_build"]),
405 ("Zig", &["zig"]),
406 ("Haskell", &["stack", "cabal"]),
407 (
408 "Game engines",
409 &["godot", "unity", "unreal", "defold", "cocos"],
410 ),
411];
412
413pub fn adapter_group(name: &str) -> &'static str {
419 ADAPTER_GROUPS
420 .iter()
421 .find(|(_, names)| names.contains(&name))
422 .map(|(group, _)| *group)
423 .unwrap_or("Other")
424}
425
426pub fn all_adapter_names() -> Vec<&'static str> {
428 get_all_adapters().iter().map(|a| a.name()).collect()
429}
430
431pub fn opt_in_adapter_names() -> Vec<&'static str> {
436 get_all_adapters()
437 .iter()
438 .filter(|a| a.opt_in())
439 .map(|a| a.name())
440 .collect()
441}
442
443pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
450 detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
451}
452
453pub fn detect_all_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
463 let opt_in: Vec<String> = opt_in_adapter_names()
464 .into_iter()
465 .map(str::to_string)
466 .collect();
467 detect_adapters_with(project_path, &opt_in, &[])
468}
469
470pub fn detect_dormant_opt_in(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
478 let enabled = opt_in_enabled();
479 let disabled = user_disabled();
480 get_all_adapters()
481 .into_iter()
482 .filter(|a| a.opt_in())
483 .filter(|a| !enabled.iter().any(|n| n == a.name()))
484 .filter(|a| !disabled.iter().any(|n| n == a.name()))
485 .filter(|a| a.detect(project_path))
486 .collect()
487}
488
489fn detect_adapters_with(
497 project_path: &Path,
498 opt_in: &[String],
499 disabled: &[String],
500) -> Vec<Box<dyn PackageManager>> {
501 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
502 .into_iter()
503 .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
504 .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
505 .filter(|adapter| adapter.detect(project_path))
506 .collect();
507 resolve_conflicts(project_path, &mut detected);
508 detected
509}
510
511fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
513 resolve_js_conflict(project_path, detected);
514 resolve_python_conflict(project_path, detected);
515}
516
517fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
530 if detected
531 .iter()
532 .filter(|a| JS_MANAGERS.contains(&a.name()))
533 .count()
534 < 2
535 {
536 return;
537 }
538
539 let winner = declared_package_manager(project_path)
540 .filter(|name| detected.iter().any(|a| a.name() == name))
541 .or_else(|| installed_manager(project_path, detected))
542 .or_else(|| newest_lockfile_owner(project_path, detected));
543
544 let Some(winner) = winner else { return };
545 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
546}
547
548const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
561 ("uv", "uv.lock"),
562 ("poetry", "poetry.lock"),
563 ("pdm", "pdm.lock"),
564 ("pipenv", "Pipfile.lock"),
565];
566
567fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
568 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
569 .iter()
570 .copied()
571 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
572 .collect();
573 let Some(&(first, _)) = claimants.first() else {
574 return;
575 };
576 detected.retain(|a| a.name() != "venv");
577 if claimants.len() < 2 {
578 return;
579 }
580 let winner = claimants
581 .iter()
582 .find(|(_, lockfile)| project_path.join(lockfile).exists())
583 .map_or(first, |(name, _)| *name);
584 detected.retain(|a| {
585 a.name() == winner
586 || !PYTHON_ENV_MANAGERS
587 .iter()
588 .any(|(name, _)| *name == a.name())
589 });
590}
591
592fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
594 let node_modules = project_path.join("node_modules");
595 if !node_modules.is_dir() {
596 return None;
597 }
598
599 JS_INSTALL_MARKERS
600 .iter()
601 .find(|(name, markers)| {
602 detected.iter().any(|a| a.name() == *name)
603 && markers.iter().any(|m| node_modules.join(m).exists())
604 })
605 .map(|(name, _)| (*name).to_string())
606}
607
608fn declared_package_manager(project_path: &Path) -> Option<String> {
610 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
611 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
612 let declared = json.get("packageManager")?.as_str()?;
613 let name = declared.split('@').next().unwrap_or_default();
614 JS_MANAGERS
615 .iter()
616 .find(|m| **m == name)
617 .map(|m| (*m).to_string())
618}
619
620fn newest_lockfile_owner(
622 project_path: &Path,
623 detected: &[Box<dyn PackageManager>],
624) -> Option<String> {
625 detected
626 .iter()
627 .filter(|a| JS_MANAGERS.contains(&a.name()))
628 .filter_map(|a| {
629 let newest = a
630 .lockfiles()
631 .iter()
632 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
633 .max()?;
634 Some((newest, a.name().to_string()))
635 })
636 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
639 match best {
640 Some(b) if b.0 >= cur.0 => Some(b),
641 _ => Some(cur),
642 }
643 })
644 .map(|(_, name)| name)
645}
646
647pub fn dir_size(path: &Path) -> u64 {
649 if !path.exists() {
650 return 0;
651 }
652 WalkDir::new(path)
653 .follow_links(false)
654 .into_iter()
655 .flatten()
656 .filter_map(|entry| entry.metadata().ok())
657 .filter(|meta| meta.is_file())
658 .map(|meta| meta.len())
659 .sum()
660}
661
662#[derive(Debug, Clone, Copy, Default)]
664pub struct DirSizeBreakdown {
665 pub freed_bytes: u64,
667 pub shared_bytes: u64,
670}
671
672pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
683 let mut out = DirSizeBreakdown::default();
684 if !path.exists() {
685 return out;
686 }
687 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
689 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
690 let Ok(meta) = entry.metadata() else { continue };
691 if !meta.is_file() {
692 continue;
693 }
694 match file_link_identity(entry.path(), &meta) {
695 Some((dev, ino, nlink)) if nlink > 1 => {
696 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
697 }
698 _ => out.freed_bytes += meta.len(),
699 }
700 }
701 for (bytes, nlink, seen) in linked.into_values() {
702 if seen >= nlink {
703 out.freed_bytes += bytes;
704 } else {
705 out.shared_bytes += bytes;
706 }
707 }
708 out
709}
710
711#[cfg(unix)]
713fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
714 use std::os::unix::fs::MetadataExt as _;
715 Some((meta.dev(), meta.ino(), meta.nlink()))
716}
717
718#[cfg(windows)]
722fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
723 use std::os::windows::fs::OpenOptionsExt as _;
724 use std::os::windows::io::AsRawHandle as _;
725 use windows_sys::Win32::Storage::FileSystem::{
726 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
727 };
728
729 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
732 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
733 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
736 return None;
737 }
738 Some((
739 u64::from(info.dwVolumeSerialNumber),
740 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
741 u64::from(info.nNumberOfLinks),
742 ))
743}
744
745#[cfg(not(any(unix, windows)))]
746fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
747 None
748}
749
750pub fn resolve_program(program: &str) -> String {
760 #[cfg(windows)]
761 {
762 if Path::new(program).components().count() > 1 {
763 return program.to_string();
764 }
765 let Some(path_var) = std::env::var_os("PATH") else {
766 return program.to_string();
767 };
768 for dir in std::env::split_paths(&path_var) {
769 for ext in ["exe", "cmd", "bat"] {
770 let candidate = dir.join(format!("{program}.{ext}"));
771 if candidate.is_file() {
772 return candidate.to_string_lossy().into_owned();
773 }
774 }
775 }
776 }
777 program.to_string()
778}
779
780pub fn binary_available(program: &str) -> bool {
788 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
789 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
790
791 let mut guard = match cache.lock() {
794 Ok(g) => g,
795 Err(_) => return probe_binary(program),
798 };
799 if let Some(known) = guard.get(program) {
800 return *known;
801 }
802 let available = probe_binary(program);
803 guard.insert(program.to_string(), available);
804 available
805}
806
807const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
815
816fn version_probe_args(program: &str) -> &'static [&'static str] {
818 VERSION_PROBE_ARGS
819 .iter()
820 .find(|(name, _)| *name == program)
821 .map_or(&["--version"], |(_, args)| *args)
822}
823
824fn probe_binary(program: &str) -> bool {
826 crate::spawn::command(resolve_program(program))
827 .args(version_probe_args(program))
828 .stdin(std::process::Stdio::null())
829 .output()
830 .map(|o| o.status.success())
831 .unwrap_or(false)
832}
833
834struct CommandOutput {
836 status: std::process::ExitStatus,
837 stdout: String,
838 stderr: String,
839}
840
841fn spawn_capture(
848 program: &str,
849 args: &[&str],
850 cwd: &Path,
851 timeout: std::time::Duration,
852) -> Result<CommandOutput> {
853 use std::io::Read;
854 use std::process::Stdio;
855 use std::thread;
856 use std::time::Instant;
857
858 let resolved = resolve_program(program);
859 let mut child = crate::spawn::command(&resolved)
860 .args(args)
861 .current_dir(cwd)
862 .stdin(Stdio::null())
863 .stdout(Stdio::piped())
864 .stderr(Stdio::piped())
865 .spawn()
866 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
867
868 let mut stdout_pipe = child.stdout.take();
872 let mut stderr_pipe = child.stderr.take();
873 let stdout_reader = thread::spawn(move || {
874 let mut buf = Vec::new();
875 if let Some(pipe) = stdout_pipe.as_mut() {
876 let _ = pipe.read_to_end(&mut buf);
877 }
878 buf
879 });
880 let stderr_reader = thread::spawn(move || {
881 let mut buf = Vec::new();
882 if let Some(pipe) = stderr_pipe.as_mut() {
883 let _ = pipe.read_to_end(&mut buf);
884 }
885 buf
886 });
887
888 let start = Instant::now();
889 let status = loop {
890 match child.try_wait()? {
891 Some(status) => break status,
892 None => {
893 if start.elapsed() >= timeout {
894 let _ = child.kill();
895 let _ = child.wait();
896 anyhow::bail!(
897 "Command timed out after {}s: {} {}\n\
898 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
899 timeout.as_secs(),
900 program,
901 args.join(" ")
902 );
903 }
904 thread::sleep(std::time::Duration::from_millis(100));
905 }
906 }
907 };
908
909 let stderr = stderr_reader
910 .join()
911 .map(|b| String::from_utf8_lossy(&b).into_owned())
912 .unwrap_or_default();
913 let stdout = stdout_reader
914 .join()
915 .map(|b| String::from_utf8_lossy(&b).into_owned())
916 .unwrap_or_default();
917
918 Ok(CommandOutput {
919 status,
920 stdout,
921 stderr,
922 })
923}
924
925pub fn run_command_with_timeout(
927 program: &str,
928 args: &[&str],
929 cwd: &Path,
930 timeout: std::time::Duration,
931) -> Result<()> {
932 let out = spawn_capture(program, args, cwd, timeout)?;
933 if out.status.success() {
934 Ok(())
935 } else {
936 anyhow::bail!(
937 "{} {} failed (exit code {:?}):\n{}",
938 program,
939 args.join(" "),
940 out.status.code(),
941 crate::output::condense_tool_output(
942 &out.stderr,
943 crate::constants::TOOL_OUTPUT_MAX_LINES
944 )
945 )
946 }
947}
948
949pub struct CapturedCommand {
952 pub ok: bool,
954 pub stdout: String,
956 pub stderr: String,
958}
959
960pub fn capture_allowing_failure(
970 program: &str,
971 args: &[&str],
972 cwd: &Path,
973 timeout: std::time::Duration,
974) -> Result<CapturedCommand> {
975 let out = spawn_capture(program, args, cwd, timeout)?;
976 Ok(CapturedCommand {
977 ok: out.status.success(),
978 stdout: out.stdout,
979 stderr: out.stderr,
980 })
981}
982
983pub fn capture_command_with_timeout(
989 program: &str,
990 args: &[&str],
991 cwd: &Path,
992 timeout: std::time::Duration,
993) -> Result<String> {
994 let out = spawn_capture(program, args, cwd, timeout)?;
995 if out.status.success() {
996 Ok(out.stdout)
997 } else {
998 anyhow::bail!(
999 "{} {} failed (exit code {:?}):\n{}",
1000 program,
1001 args.join(" "),
1002 out.status.code(),
1003 crate::output::condense_tool_output(
1004 &out.stderr,
1005 crate::constants::TOOL_OUTPUT_MAX_LINES
1006 )
1007 )
1008 }
1009}
1010
1011pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
1013 crate::spawn::command(resolve_program(program))
1014 .args(args)
1015 .current_dir(cwd)
1016 .stdin(std::process::Stdio::null())
1017 .output()
1018 .map(|o| o.status.success())
1019 .unwrap_or(false)
1020}
1021
1022const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
1030
1031fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
1038 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
1039 Some("Cargo.lock") => "Cargo.toml",
1040 Some("package-lock.json")
1041 | Some("yarn.lock")
1042 | Some("pnpm-lock.yaml")
1043 | Some("bun.lockb")
1044 | Some("bun.lock") => "package.json",
1045 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
1046 Some("go.sum") => "go.mod",
1047 Some("composer.lock") => "composer.json",
1048 Some("Gemfile.lock") => "Gemfile",
1049 Some("Pipfile.lock") => "Pipfile",
1050 _ => return Ok(()),
1051 };
1052 let manifest = cwd.join(manifest_name);
1053 let (Ok(manifest_meta), Ok(lock_meta)) =
1054 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
1055 else {
1056 return Ok(());
1057 };
1058 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1059 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1060 {
1061 anyhow::bail!(
1062 "`{program}` is not available, and `{manifest_name}` has been edited more \
1063 recently than `{}` — the lockfile may no longer record the current \
1064 dependencies, and without `{program}` that cannot be verified. Install \
1065 {program} and run its lockfile sync, then prune again.",
1066 lockfile.display()
1067 );
1068 }
1069 Ok(())
1070}
1071
1072pub fn refuse_if_manifest_stale(
1083 manifest: &Path,
1084 lockfile: &Path,
1085 sync_command: &str,
1086) -> Result<()> {
1087 let (Ok(manifest_meta), Ok(lock_meta)) =
1088 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
1089 else {
1090 return Ok(());
1091 };
1092 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1093 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1094 {
1095 anyhow::bail!(
1096 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
1097 record the current dependencies. Run `{sync_command}` and prune again.",
1098 manifest.display(),
1099 lockfile.display()
1100 );
1101 }
1102 Ok(())
1103}
1104
1105pub fn lock_sync_or_verify_with_timeout(
1107 lockfile: &Path,
1108 program: &str,
1109 sync_args: &[&str],
1110 cwd: &Path,
1111 timeout: std::time::Duration,
1112) -> Result<()> {
1113 let lockfile_exists = lockfile.exists();
1114
1115 if !binary_available(program) {
1116 if lockfile_exists {
1117 refuse_if_manifest_newer(lockfile, program, cwd)?;
1118 return Ok(());
1119 } else {
1120 anyhow::bail!(
1121 "`{program}` is not available and no lockfile was found at `{}`. \
1122 Cannot safely delete dependencies — install {program} first, \
1123 or commit a lockfile.",
1124 lockfile.display()
1125 );
1126 }
1127 }
1128
1129 run_command_with_timeout(program, sync_args, cwd, timeout)
1131}
1132
1133#[derive(Debug, Clone, Copy)]
1139pub struct EnforcePolicy {
1140 pub allow_rewrite: bool,
1146 pub timeout: std::time::Duration,
1148}
1149
1150impl Default for EnforcePolicy {
1151 fn default() -> Self {
1152 Self {
1153 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1154 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1155 }
1156 }
1157}
1158
1159impl EnforcePolicy {
1160 pub fn from_settings(settings: &crate::config::Settings) -> Self {
1162 Self {
1163 allow_rewrite: settings.allow_manifest_rewrite,
1164 timeout: command_timeout(settings.command_timeout_secs),
1165 }
1166 }
1167}
1168
1169pub fn command_timeout(secs: u64) -> std::time::Duration {
1177 std::time::Duration::from_secs(secs.max(1))
1178}
1179
1180pub fn enforce_two_tier(
1196 lockfile: &Path,
1197 program: &str,
1198 verify_args: &[&str],
1199 write_args: &[&str],
1200 cwd: &Path,
1201 policy: EnforcePolicy,
1202) -> Result<()> {
1203 if policy.allow_rewrite {
1204 return lock_sync_or_verify_with_timeout(
1205 lockfile,
1206 program,
1207 write_args,
1208 cwd,
1209 policy.timeout,
1210 );
1211 }
1212 lock_verify_or_generate(
1213 lockfile,
1214 program,
1215 verify_args,
1216 write_args,
1217 cwd,
1218 policy.timeout,
1219 )
1220}
1221
1222pub fn lock_verify_or_generate(
1232 lockfile: &Path,
1233 program: &str,
1234 verify_args: &[&str],
1235 generate_args: &[&str],
1236 cwd: &Path,
1237 timeout: std::time::Duration,
1238) -> Result<()> {
1239 let lockfile_exists = lockfile.exists();
1240
1241 if !binary_available(program) {
1242 if lockfile_exists {
1243 refuse_if_manifest_newer(lockfile, program, cwd)?;
1244 return Ok(());
1245 }
1246 anyhow::bail!(
1247 "`{program}` is not available and no lockfile was found at `{}`. \
1248 Cannot safely delete dependencies — install {program} first, \
1249 or commit a lockfile.",
1250 lockfile.display()
1251 );
1252 }
1253
1254 if lockfile_exists {
1255 run_command_with_timeout(program, verify_args, cwd, timeout)
1256 } else {
1257 run_command_with_timeout(program, generate_args, cwd, timeout)
1258 }
1259}
1260
1261pub fn lock_sync_or_verify(
1263 lockfile: &Path,
1264 program: &str,
1265 sync_args: &[&str],
1266 cwd: &Path,
1267) -> Result<()> {
1268 lock_sync_or_verify_with_timeout(
1269 lockfile,
1270 program,
1271 sync_args,
1272 cwd,
1273 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1274 )
1275}
1276
1277const PYVENV_CFG: &str = "pyvenv.cfg";
1283
1284pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1291 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1292 for line in cfg.lines() {
1293 let Some((key, value)) = line.split_once('=') else {
1294 continue;
1295 };
1296 if matches!(key.trim(), "version" | "version_info") {
1297 let mut parts = value.trim().split('.');
1298 let major: u64 = parts.next()?.parse().ok()?;
1299 let minor: u64 = parts.next()?.parse().ok()?;
1300 return Some(format!("{major}.{minor}"));
1301 }
1302 }
1303 None
1304}
1305
1306pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1310 let mut parts = tag.split('.');
1311 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1312 return false;
1313 };
1314 !major.is_empty()
1315 && !minor.is_empty()
1316 && major.len() <= 2
1317 && minor.len() <= 3
1318 && major.bytes().all(|b| b.is_ascii_digit())
1319 && minor.bytes().all(|b| b.is_ascii_digit())
1320}
1321
1322pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1330 if !is_valid_runtime_tag(tag) {
1331 return None;
1332 }
1333 #[cfg(windows)]
1334 {
1335 Some(("py".to_string(), vec![format!("-{tag}")]))
1336 }
1337 #[cfg(not(windows))]
1338 {
1339 Some((format!("python{tag}"), Vec::new()))
1340 }
1341}
1342
1343pub(crate) fn python_executable(tag: &str) -> Option<String> {
1351 let (program, prefix) = python_launcher(tag)?;
1352 let out = crate::spawn::command(resolve_program(&program))
1353 .args(&prefix)
1354 .args(["-c", "import sys; print(sys.executable)"])
1355 .stdin(std::process::Stdio::null())
1356 .stderr(std::process::Stdio::null())
1357 .output()
1358 .ok()?;
1359 if !out.status.success() {
1360 return None;
1361 }
1362 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1363 (!path.is_empty()).then_some(path)
1364}
1365
1366pub(crate) fn python_runtime_available(tag: &str) -> bool {
1371 let Some((program, prefix)) = python_launcher(tag) else {
1372 return false;
1373 };
1374 crate::spawn::command(resolve_program(&program))
1375 .args(&prefix)
1376 .arg("--version")
1377 .stdin(std::process::Stdio::null())
1378 .stdout(std::process::Stdio::null())
1379 .stderr(std::process::Stdio::null())
1380 .status()
1381 .is_ok_and(|s| s.success())
1382}
1383
1384const NO_RESTORE_BINARY: [&str; 17] = [
1385 "venv",
1386 "gradle",
1387 "maven",
1388 "mix_build",
1389 "swift",
1390 "vcpkg",
1391 "cmake_build",
1392 "dotnet_build",
1393 "godot",
1394 "unity",
1395 "unreal",
1396 "defold",
1397 "cocos",
1398 "zig",
1399 "stack",
1400 "cabal",
1401 "sbt",
1402];
1403
1404const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1406
1407pub fn adapter_binary(adapter: &str) -> &str {
1409 ADAPTER_BINARIES
1410 .iter()
1411 .find(|(name, _)| *name == adapter)
1412 .map_or(adapter, |(_, binary)| *binary)
1413}
1414
1415const INSTALL_HINTS: [(&str, &str); 18] = [
1420 ("npm", "ships with Node.js — https://nodejs.org"),
1421 (
1422 "pnpm",
1423 "`npm install -g pnpm` — https://pnpm.io/installation",
1424 ),
1425 (
1426 "yarn",
1427 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1428 ),
1429 ("bun", "https://bun.sh/docs/installation"),
1430 (
1431 "deno",
1432 "https://docs.deno.com/runtime/getting_started/installation/",
1433 ),
1434 (
1435 "uv",
1436 "https://docs.astral.sh/uv/getting-started/installation/",
1437 ),
1438 ("poetry", "https://python-poetry.org/docs/#installation"),
1439 (
1440 "pdm",
1441 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1442 ),
1443 (
1444 "pipenv",
1445 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1446 ),
1447 ("cargo", "ships with Rust — https://rustup.rs"),
1448 ("go", "https://go.dev/dl/"),
1449 ("composer", "https://getcomposer.org/download/"),
1450 ("bundler", "`gem install bundler` — https://bundler.io"),
1451 (
1452 "cocoapods",
1453 "`gem install cocoapods` — https://cocoapods.org",
1454 ),
1455 (
1456 "mix",
1457 "ships with Elixir — https://elixir-lang.org/install.html",
1458 ),
1459 (
1460 "terraform",
1461 "https://developer.hashicorp.com/terraform/install",
1462 ),
1463 (
1464 "dart",
1465 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1466 ),
1467 ("pixi", "https://pixi.sh/latest/installation/"),
1468];
1469
1470pub fn install_hint(adapter: &str) -> Option<&'static str> {
1472 INSTALL_HINTS
1473 .iter()
1474 .find(|(name, _)| *name == adapter)
1475 .map(|(_, hint)| *hint)
1476}
1477
1478#[derive(Debug, Clone)]
1480pub struct BinaryCheckStatus {
1481 pub name: String,
1482 pub available: bool,
1483 pub version: Option<String>,
1484}
1485
1486pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1488 let mut unique: Vec<String> = adapter_names
1489 .iter()
1490 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1493 .cloned()
1494 .collect();
1495 unique.sort();
1496 unique.dedup();
1497
1498 unique
1499 .into_iter()
1500 .map(|name| {
1501 let binary = adapter_binary(&name);
1502 let output = crate::spawn::command(resolve_program(binary))
1503 .args(version_probe_args(binary))
1504 .stdin(std::process::Stdio::null())
1505 .output();
1506 match output {
1507 Ok(out) if out.status.success() => {
1508 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1509 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1510 BinaryCheckStatus {
1511 name,
1512 available: true,
1513 version: if first_line.is_empty() {
1514 None
1515 } else {
1516 Some(first_line)
1517 },
1518 }
1519 }
1520 _ => BinaryCheckStatus {
1521 name,
1522 available: false,
1523 version: None,
1524 },
1525 }
1526 })
1527 .collect()
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533 use std::fs;
1534 use tempfile::TempDir;
1535
1536 #[test]
1537 fn every_adapter_is_grouped_exactly_once() {
1538 let registered = all_adapter_names();
1541 let grouped: Vec<&str> = ADAPTER_GROUPS
1542 .iter()
1543 .flat_map(|(_, names)| names.iter().copied())
1544 .collect();
1545
1546 for name in ®istered {
1547 assert_eq!(
1548 grouped.iter().filter(|g| *g == name).count(),
1549 1,
1550 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1551 );
1552 }
1553 for name in &grouped {
1554 assert!(
1555 registered.contains(name),
1556 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1557 );
1558 }
1559 assert_eq!(registered.len(), grouped.len());
1560 }
1561
1562 #[test]
1563 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1564 let mut opt_in = opt_in_adapter_names();
1569 opt_in.sort_unstable();
1570 assert_eq!(
1571 opt_in,
1572 vec![
1573 "cabal",
1574 "cargo",
1575 "cmake_build",
1576 "cocos",
1577 "dart",
1578 "defold",
1579 "dotnet_build",
1580 "godot",
1581 "gradle",
1582 "maven",
1583 "mix_build",
1584 "sbt",
1585 "stack",
1586 "swift",
1587 "unity",
1588 "unreal",
1589 "vcpkg",
1590 "zig"
1591 ]
1592 );
1593 }
1594
1595 #[test]
1596 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1597 assert_eq!(version_probe_args("go"), &["version"]);
1602 assert_eq!(version_probe_args("npm"), &["--version"]);
1603 }
1604
1605 #[test]
1606 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1607 for adapter in get_all_adapters() {
1610 let name = adapter.name();
1611 if NO_RESTORE_BINARY.contains(&name) {
1612 continue;
1613 }
1614 assert!(
1615 install_hint(name).is_some(),
1616 "adapter `{name}` has no install hint"
1617 );
1618 }
1619 }
1620
1621 #[test]
1622 fn test_bloat_dir_display() {
1623 let bd = BloatDir {
1624 name: "node_modules".to_string(),
1625 path: PathBuf::from("/test/node_modules"),
1626 size_bytes: 1024,
1627 shared_bytes: 0,
1628 };
1629 assert!(bd.to_string().contains("node_modules"));
1630 }
1631
1632 #[test]
1633 fn test_hardlink_size_counts_a_plain_file_in_full() {
1634 let tmp = TempDir::new().unwrap();
1635 let tree = tmp.path().join("tree");
1636 fs::create_dir(&tree).unwrap();
1637 fs::write(tree.join("copied.txt"), "12345").unwrap();
1638 let size = dir_size_with_hardlinks(&tree);
1639 assert_eq!(size.freed_bytes, 5);
1640 assert_eq!(size.shared_bytes, 0);
1641 }
1642
1643 #[test]
1644 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1645 let tmp = TempDir::new().unwrap();
1648 let store = tmp.path().join("store");
1649 let tree = tmp.path().join("tree");
1650 fs::create_dir(&store).unwrap();
1651 fs::create_dir(&tree).unwrap();
1652 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1653 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1654 let size = dir_size_with_hardlinks(&tree);
1655 assert_eq!(size.freed_bytes, 0);
1656 assert_eq!(size.shared_bytes, 10);
1657 }
1658
1659 #[test]
1660 fn test_hardlink_size_counts_an_internal_pair_once() {
1661 let tmp = TempDir::new().unwrap();
1664 let tree = tmp.path().join("tree");
1665 fs::create_dir(&tree).unwrap();
1666 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1667 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1668 let size = dir_size_with_hardlinks(&tree);
1669 assert_eq!(size.freed_bytes, 7);
1670 assert_eq!(size.shared_bytes, 0);
1671 }
1672
1673 #[test]
1674 fn test_dir_size_empty() {
1675 let tmp = TempDir::new().unwrap();
1676 assert_eq!(dir_size(tmp.path()), 0);
1677 }
1678
1679 #[test]
1680 fn test_dir_size_with_files() {
1681 let tmp = TempDir::new().unwrap();
1682 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1683 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1684 assert_eq!(dir_size(tmp.path()), 11); }
1686
1687 #[test]
1688 fn test_dir_size_nonexistent() {
1689 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1690 }
1691
1692 #[test]
1693 fn test_get_all_adapters_not_empty() {
1694 let adapters = get_all_adapters();
1695 assert!(adapters.len() >= 6);
1696 }
1697
1698 #[test]
1699 fn test_detect_adapters_npm() {
1700 let tmp = TempDir::new().unwrap();
1701 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1702 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1703 let adapters = detect_adapters(tmp.path());
1704 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1705 assert!(names.contains(&"npm"));
1706 }
1707
1708 #[test]
1709 fn test_detect_adapters_empty_dir() {
1710 let tmp = TempDir::new().unwrap();
1711 let adapters = detect_adapters(tmp.path());
1712 assert!(adapters.is_empty());
1713 }
1714
1715 fn detected_names(dir: &Path) -> Vec<&'static str> {
1721 let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1722 .iter()
1723 .map(|a| a.name())
1724 .collect();
1725 names.sort_unstable();
1726 names
1727 }
1728
1729 #[test]
1730 fn test_detect_adapters_multiple_ecosystems_coexist() {
1731 let tmp = TempDir::new().unwrap();
1733 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1734 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1735 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1736 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1737 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1738
1739 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1744 }
1745
1746 #[test]
1747 fn test_detect_adapters_opt_in_appears_once_enabled() {
1748 let tmp = TempDir::new().unwrap();
1751 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1752
1753 let on = [String::from("cargo")];
1754 let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1755 .iter()
1756 .map(|a| a.name())
1757 .collect();
1758 names.sort_unstable();
1759 assert_eq!(names, vec!["cargo"]);
1760 }
1761
1762 #[test]
1763 fn every_adapter_counts_as_used_even_when_it_is_switched_off() {
1764 let tmp = TempDir::new().unwrap();
1769 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1770
1771 assert!(
1772 detect_adapters_with(tmp.path(), &[], &[]).is_empty(),
1773 "cargo is opt-in, so the prune-facing detector must not see it here"
1774 );
1775 let names: Vec<&str> = detect_all_adapters(tmp.path())
1776 .iter()
1777 .map(|a| a.name())
1778 .collect();
1779 assert_eq!(names, vec!["cargo"]);
1780 }
1781
1782 #[test]
1783 fn test_detect_adapters_disabled_adapter_is_invisible() {
1784 let tmp = TempDir::new().unwrap();
1786 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1787
1788 let off = [String::from("go")];
1789 assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1790 }
1791
1792 #[test]
1793 fn test_js_conflict_resolved_by_package_manager_field() {
1794 let tmp = TempDir::new().unwrap();
1795 fs::write(
1796 tmp.path().join("package.json"),
1797 r#"{"packageManager":"yarn@4.1.0"}"#,
1798 )
1799 .unwrap();
1800 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1801 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1802 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1803
1804 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1805 }
1806
1807 #[test]
1808 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1809 let tmp = TempDir::new().unwrap();
1812 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1813 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1814 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1815 std::thread::sleep(std::time::Duration::from_millis(20));
1816 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1817
1818 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1819 }
1820
1821 #[test]
1822 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1823 let tmp = TempDir::new().unwrap();
1825 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1826 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1827 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1828 let nm = tmp.path().join("node_modules");
1829 fs::create_dir_all(&nm).unwrap();
1830 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1831 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1832
1833 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1834 }
1835
1836 #[test]
1837 fn test_declared_package_manager_outranks_what_is_installed() {
1838 let tmp = TempDir::new().unwrap();
1840 fs::write(
1841 tmp.path().join("package.json"),
1842 r#"{"packageManager":"pnpm@9.1.0"}"#,
1843 )
1844 .unwrap();
1845 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1846 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1847 let nm = tmp.path().join("node_modules");
1848 fs::create_dir_all(&nm).unwrap();
1849 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1850
1851 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1852 }
1853
1854 #[test]
1855 fn test_uv_takes_precedence_over_plain_venv() {
1856 let tmp = TempDir::new().unwrap();
1859 fs::write(
1860 tmp.path().join("pyproject.toml"),
1861 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1862 )
1863 .unwrap();
1864 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1865 let venv = tmp.path().join(".venv");
1866 fs::create_dir_all(&venv).unwrap();
1867 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1868
1869 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1870 }
1871
1872 #[test]
1873 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1874 let tmp = TempDir::new().unwrap();
1875 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1876 let venv = tmp.path().join("venv");
1877 fs::create_dir_all(&venv).unwrap();
1878 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1879
1880 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1881 }
1882
1883 #[test]
1884 fn test_js_conflict_falls_back_to_newest_lockfile() {
1885 let tmp = TempDir::new().unwrap();
1886 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1887 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1888 std::thread::sleep(std::time::Duration::from_millis(20));
1891 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1892
1893 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1894 }
1895
1896 #[test]
1897 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1898 let tmp = TempDir::new().unwrap();
1903 fs::write(
1904 tmp.path().join("package.json"),
1905 r#"{"packageManager":"deno@2.0.0"}"#,
1906 )
1907 .unwrap();
1908 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1909 std::thread::sleep(std::time::Duration::from_millis(20));
1910 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1911
1912 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1913 }
1914
1915 #[test]
1916 fn test_js_conflict_does_not_disturb_a_single_manager() {
1917 let tmp = TempDir::new().unwrap();
1918 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1919 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1920 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1921 }
1922
1923 #[test]
1924 fn test_js_adapters_declare_their_lockfiles() {
1925 for adapter in get_all_adapters() {
1926 if JS_MANAGERS.contains(&adapter.name()) {
1927 assert!(
1928 !adapter.lockfiles().is_empty(),
1929 "{} shares node_modules and must declare its lockfiles for \
1930 conflict resolution",
1931 adapter.name()
1932 );
1933 }
1934 }
1935 }
1936
1937 #[test]
1938 fn test_adapter_names_unique() {
1939 let adapters = get_all_adapters();
1940 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1941 let mut unique = names.clone();
1942 unique.sort();
1943 unique.dedup();
1944 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1945 }
1946
1947 #[test]
1948 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1949 assert!(is_valid_runtime_tag("3.12"));
1952 assert!(is_valid_runtime_tag("3.9"));
1953 for bad in [
1954 "",
1955 "3",
1956 "3.12.1",
1957 "3.x",
1958 "3.12; rm -rf /",
1959 "-3.12",
1960 "../python",
1961 "3.1234",
1962 "300.1",
1963 ] {
1964 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1965 }
1966 }
1967
1968 #[test]
1969 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1970 let tmp = tempfile::tempdir().unwrap();
1971 let venv = tmp.path().join(".venv");
1972 std::fs::create_dir_all(&venv).unwrap();
1973 std::fs::write(
1974 venv.join("pyvenv.cfg"),
1975 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1976 )
1977 .unwrap();
1978 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1979 }
1980
1981 #[test]
1982 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1983 let tmp = tempfile::tempdir().unwrap();
1984 assert_eq!(venv_runtime_tag(tmp.path()), None);
1985 }
1986}