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 pycache;
44pub mod sbt;
45pub mod stack;
46pub mod swift;
47pub mod terraform;
48pub mod unity;
49pub mod unreal;
50pub mod uv;
51pub mod vcpkg;
52pub mod venv;
53pub mod yarn;
54pub mod zig;
55
56use std::collections::HashMap;
57use std::fmt;
58use std::path::{Path, PathBuf};
59use std::sync::{Mutex, OnceLock};
60
61use anyhow::{Context as _, Result};
62use walkdir::WalkDir;
63
64#[derive(Debug, Clone)]
66pub struct BloatDir {
67 pub name: String,
69 pub path: PathBuf,
71 pub size_bytes: u64,
73 pub shared_bytes: u64,
77}
78
79impl fmt::Display for BloatDir {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 write!(f, "{} ({})", self.name, self.path.display())
82 }
83}
84
85#[derive(Debug, Clone)]
88pub struct DriftReport {
89 pub directory: String,
91 pub unrecorded: Vec<String>,
93 pub record_command: &'static str,
95}
96
97pub trait PackageManager: Send + Sync {
105 fn name(&self) -> &'static str;
107
108 fn detect(&self, project_path: &Path) -> bool;
112
113 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
117
118 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
125
126 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
133
134 fn restore_named(
145 &self,
146 project_path: &Path,
147 dir_name: &str,
148 runtime: Option<&str>,
149 timeout: std::time::Duration,
150 ) -> Result<()> {
151 let _ = (dir_name, runtime);
152 self.restore(project_path, timeout)
153 }
154
155 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
165 let _ = (project_path, dir_name);
166 None
167 }
168
169 fn lockfiles(&self) -> &'static [&'static str] {
179 &[]
180 }
181
182 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
190 let _ = project_path;
191 Vec::new()
192 }
193
194 fn opt_in(&self) -> bool {
209 false
210 }
211}
212
213const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
223
224const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
232 ("pnpm", &[".pnpm", ".modules.yaml"]),
233 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
234 ("npm", &[".package-lock.json"]),
235];
236
237pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
241 vec![
242 Box::new(npm::Npm),
243 Box::new(pnpm::Pnpm),
244 Box::new(yarn::Yarn),
245 Box::new(bun::Bun),
246 Box::new(deno::Deno),
247 Box::new(uv::Uv),
248 Box::new(poetry::Poetry),
249 Box::new(pdm::Pdm),
250 Box::new(pipenv::Pipenv),
251 Box::new(venv::Venv),
252 Box::new(pycache::Pycache),
253 Box::new(pixi::Pixi),
254 Box::new(cargo_adapter::Cargo),
255 Box::new(go::Go),
256 Box::new(composer::Composer),
257 Box::new(bundler::Bundler),
258 Box::new(cocoapods::CocoaPods),
259 Box::new(mix::Mix),
260 Box::new(mix_build::MixBuild),
261 Box::new(gradle::Gradle),
262 Box::new(maven::Maven),
263 Box::new(swift::Swift),
264 Box::new(terraform::Terraform),
265 Box::new(dart::Dart),
266 Box::new(vcpkg::Vcpkg),
267 Box::new(cmake_build::CmakeBuild),
268 Box::new(dotnet_build::DotnetBuild),
269 Box::new(godot::Godot),
270 Box::new(unity::Unity),
271 Box::new(unreal::Unreal),
272 Box::new(defold::Defold),
273 Box::new(cocos::Cocos),
274 Box::new(zig::Zig),
275 Box::new(stack::Stack),
276 Box::new(cabal::Cabal),
277 Box::new(sbt::Sbt),
278 ]
279}
280
281fn opt_in_enabled() -> &'static [String] {
289 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
290 ENABLED.get_or_init(|| {
291 crate::config::Registry::load()
292 .map(|r| {
293 let mut names = Vec::new();
294 if r.settings.enable_cargo {
295 names.push("cargo".to_string());
296 }
297 if r.settings.enable_gradle {
298 names.push("gradle".to_string());
299 }
300 if r.settings.enable_maven {
301 names.push("maven".to_string());
302 }
303 if r.settings.enable_swift {
304 names.push("swift".to_string());
305 }
306 if r.settings.enable_dart {
307 names.push("dart".to_string());
308 }
309 if r.settings.enable_mix_build {
310 names.push("mix_build".to_string());
311 }
312 if r.settings.enable_vcpkg {
313 names.push("vcpkg".to_string());
314 }
315 if r.settings.enable_cmake_build {
316 names.push("cmake_build".to_string());
317 }
318 if r.settings.enable_dotnet_build {
319 names.push("dotnet_build".to_string());
320 }
321 if r.settings.enable_godot {
322 names.push("godot".to_string());
323 }
324 if r.settings.enable_unity {
325 names.push("unity".to_string());
326 }
327 if r.settings.enable_unreal {
328 names.push("unreal".to_string());
329 }
330 if r.settings.enable_defold {
331 names.push("defold".to_string());
332 }
333 if r.settings.enable_cocos {
334 names.push("cocos".to_string());
335 }
336 if r.settings.enable_zig {
337 names.push("zig".to_string());
338 }
339 if r.settings.enable_stack {
340 names.push("stack".to_string());
341 }
342 if r.settings.enable_cabal {
343 names.push("cabal".to_string());
344 }
345 if r.settings.enable_sbt {
346 names.push("sbt".to_string());
347 }
348 names
349 })
350 .unwrap_or_default()
351 })
352}
353
354fn user_disabled() -> &'static [String] {
361 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
362 DISABLED.get_or_init(|| {
363 crate::config::Registry::load()
364 .map(|r| {
365 r.settings
366 .disabled_adapters
367 .iter()
368 .map(|n| n.trim().to_ascii_lowercase())
369 .filter(|n| !n.is_empty())
370 .collect()
371 })
372 .unwrap_or_default()
373 })
374}
375
376pub fn is_adapter_name(name: &str) -> bool {
378 get_all_adapters().iter().any(|a| a.name() == name)
379}
380
381pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
394 ("JavaScript", &["npm", "pnpm", "yarn", "bun", "deno"]),
395 (
396 "Python",
397 &["uv", "poetry", "pdm", "pipenv", "venv", "pycache", "pixi"],
398 ),
399 ("Rust", &["cargo"]),
400 ("Go", &["go"]),
401 ("JVM", &["gradle", "maven", "sbt"]),
402 ("PHP", &["composer"]),
403 ("Ruby", &["bundler"]),
404 ("Swift & Objective-C", &["swift", "cocoapods"]),
405 ("Elixir", &["mix", "mix_build"]),
406 ("Infrastructure", &["terraform"]),
407 ("Dart & Flutter", &["dart"]),
408 ("C & C++", &["vcpkg", "cmake_build"]),
409 (".NET", &["dotnet_build"]),
410 ("Zig", &["zig"]),
411 ("Haskell", &["stack", "cabal"]),
412 (
413 "Game engines",
414 &["godot", "unity", "unreal", "defold", "cocos"],
415 ),
416];
417
418pub fn adapter_group(name: &str) -> &'static str {
424 ADAPTER_GROUPS
425 .iter()
426 .find(|(_, names)| names.contains(&name))
427 .map(|(group, _)| *group)
428 .unwrap_or("Other")
429}
430
431pub fn all_adapter_names() -> Vec<&'static str> {
433 get_all_adapters().iter().map(|a| a.name()).collect()
434}
435
436pub fn opt_in_adapter_names() -> Vec<&'static str> {
441 get_all_adapters()
442 .iter()
443 .filter(|a| a.opt_in())
444 .map(|a| a.name())
445 .collect()
446}
447
448pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
455 detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
456}
457
458pub fn detect_all_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
468 let opt_in: Vec<String> = opt_in_adapter_names()
469 .into_iter()
470 .map(str::to_string)
471 .collect();
472 detect_adapters_with(project_path, &opt_in, &[])
473}
474
475pub fn detect_dormant_opt_in(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
483 let enabled = opt_in_enabled();
484 let disabled = user_disabled();
485 get_all_adapters()
486 .into_iter()
487 .filter(|a| a.opt_in())
488 .filter(|a| !enabled.iter().any(|n| n == a.name()))
489 .filter(|a| !disabled.iter().any(|n| n == a.name()))
490 .filter(|a| a.detect(project_path))
491 .collect()
492}
493
494fn detect_adapters_with(
502 project_path: &Path,
503 opt_in: &[String],
504 disabled: &[String],
505) -> Vec<Box<dyn PackageManager>> {
506 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
507 .into_iter()
508 .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
509 .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
510 .filter(|adapter| adapter.detect(project_path))
511 .collect();
512 resolve_conflicts(project_path, &mut detected);
513 detected
514}
515
516fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
518 resolve_js_conflict(project_path, detected);
519 resolve_python_conflict(project_path, detected);
520}
521
522fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
535 if detected
536 .iter()
537 .filter(|a| JS_MANAGERS.contains(&a.name()))
538 .count()
539 < 2
540 {
541 return;
542 }
543
544 let winner = declared_package_manager(project_path)
545 .filter(|name| detected.iter().any(|a| a.name() == name))
546 .or_else(|| installed_manager(project_path, detected))
547 .or_else(|| newest_lockfile_owner(project_path, detected));
548
549 let Some(winner) = winner else { return };
550 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
551}
552
553const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
566 ("uv", "uv.lock"),
567 ("poetry", "poetry.lock"),
568 ("pdm", "pdm.lock"),
569 ("pipenv", "Pipfile.lock"),
570];
571
572fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
573 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
574 .iter()
575 .copied()
576 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
577 .collect();
578 let Some(&(first, _)) = claimants.first() else {
579 return;
580 };
581 detected.retain(|a| a.name() != "venv");
582 if claimants.len() < 2 {
583 return;
584 }
585 let winner = claimants
586 .iter()
587 .find(|(_, lockfile)| project_path.join(lockfile).exists())
588 .map_or(first, |(name, _)| *name);
589 detected.retain(|a| {
590 a.name() == winner
591 || !PYTHON_ENV_MANAGERS
592 .iter()
593 .any(|(name, _)| *name == a.name())
594 });
595}
596
597fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
599 let node_modules = project_path.join("node_modules");
600 if !node_modules.is_dir() {
601 return None;
602 }
603
604 JS_INSTALL_MARKERS
605 .iter()
606 .find(|(name, markers)| {
607 detected.iter().any(|a| a.name() == *name)
608 && markers.iter().any(|m| node_modules.join(m).exists())
609 })
610 .map(|(name, _)| (*name).to_string())
611}
612
613fn declared_package_manager(project_path: &Path) -> Option<String> {
615 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
616 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
617 let declared = json.get("packageManager")?.as_str()?;
618 let name = declared.split('@').next().unwrap_or_default();
619 JS_MANAGERS
620 .iter()
621 .find(|m| **m == name)
622 .map(|m| (*m).to_string())
623}
624
625fn newest_lockfile_owner(
627 project_path: &Path,
628 detected: &[Box<dyn PackageManager>],
629) -> Option<String> {
630 detected
631 .iter()
632 .filter(|a| JS_MANAGERS.contains(&a.name()))
633 .filter_map(|a| {
634 let newest = a
635 .lockfiles()
636 .iter()
637 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
638 .max()?;
639 Some((newest, a.name().to_string()))
640 })
641 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
644 match best {
645 Some(b) if b.0 >= cur.0 => Some(b),
646 _ => Some(cur),
647 }
648 })
649 .map(|(_, name)| name)
650}
651
652pub fn dir_size(path: &Path) -> u64 {
654 if !path.exists() {
655 return 0;
656 }
657 WalkDir::new(path)
658 .follow_links(false)
659 .into_iter()
660 .flatten()
661 .filter_map(|entry| entry.metadata().ok())
662 .filter(|meta| meta.is_file())
663 .map(|meta| meta.len())
664 .sum()
665}
666
667#[derive(Debug, Clone, Copy, Default)]
669pub struct DirSizeBreakdown {
670 pub freed_bytes: u64,
672 pub shared_bytes: u64,
675}
676
677pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
688 let mut out = DirSizeBreakdown::default();
689 if !path.exists() {
690 return out;
691 }
692 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
694 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
695 let Ok(meta) = entry.metadata() else { continue };
696 if !meta.is_file() {
697 continue;
698 }
699 match file_link_identity(entry.path(), &meta) {
700 Some((dev, ino, nlink)) if nlink > 1 => {
701 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
702 }
703 _ => out.freed_bytes += meta.len(),
704 }
705 }
706 for (bytes, nlink, seen) in linked.into_values() {
707 if seen >= nlink {
708 out.freed_bytes += bytes;
709 } else {
710 out.shared_bytes += bytes;
711 }
712 }
713 out
714}
715
716#[cfg(unix)]
718fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
719 use std::os::unix::fs::MetadataExt as _;
720 Some((meta.dev(), meta.ino(), meta.nlink()))
721}
722
723#[cfg(windows)]
727fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
728 use std::os::windows::fs::OpenOptionsExt as _;
729 use std::os::windows::io::AsRawHandle as _;
730 use windows_sys::Win32::Storage::FileSystem::{
731 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
732 };
733
734 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
737 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
738 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
741 return None;
742 }
743 Some((
744 u64::from(info.dwVolumeSerialNumber),
745 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
746 u64::from(info.nNumberOfLinks),
747 ))
748}
749
750#[cfg(not(any(unix, windows)))]
751fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
752 None
753}
754
755pub fn resolve_program(program: &str) -> String {
765 #[cfg(windows)]
766 {
767 if Path::new(program).components().count() > 1 {
768 return program.to_string();
769 }
770 let Some(path_var) = std::env::var_os("PATH") else {
771 return program.to_string();
772 };
773 for dir in std::env::split_paths(&path_var) {
774 for ext in ["exe", "cmd", "bat"] {
775 let candidate = dir.join(format!("{program}.{ext}"));
776 if candidate.is_file() {
777 return candidate.to_string_lossy().into_owned();
778 }
779 }
780 }
781 }
782 program.to_string()
783}
784
785pub fn binary_available(program: &str) -> bool {
793 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
794 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
795
796 let mut guard = match cache.lock() {
799 Ok(g) => g,
800 Err(_) => return probe_binary(program),
803 };
804 if let Some(known) = guard.get(program) {
805 return *known;
806 }
807 let available = probe_binary(program);
808 guard.insert(program.to_string(), available);
809 available
810}
811
812const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
820
821fn version_probe_args(program: &str) -> &'static [&'static str] {
823 VERSION_PROBE_ARGS
824 .iter()
825 .find(|(name, _)| *name == program)
826 .map_or(&["--version"], |(_, args)| *args)
827}
828
829fn probe_binary(program: &str) -> bool {
831 crate::spawn::command(resolve_program(program))
832 .args(version_probe_args(program))
833 .stdin(std::process::Stdio::null())
834 .output()
835 .map(|o| o.status.success())
836 .unwrap_or(false)
837}
838
839struct CommandOutput {
841 status: std::process::ExitStatus,
842 stdout: String,
843 stderr: String,
844}
845
846fn spawn_capture(
853 program: &str,
854 args: &[&str],
855 cwd: &Path,
856 timeout: std::time::Duration,
857) -> Result<CommandOutput> {
858 use std::io::Read;
859 use std::process::Stdio;
860 use std::thread;
861 use std::time::Instant;
862
863 let resolved = resolve_program(program);
864 let mut child = crate::spawn::command(&resolved)
865 .args(args)
866 .current_dir(cwd)
867 .stdin(Stdio::null())
868 .stdout(Stdio::piped())
869 .stderr(Stdio::piped())
870 .spawn()
871 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
872
873 let mut stdout_pipe = child.stdout.take();
877 let mut stderr_pipe = child.stderr.take();
878 let stdout_reader = thread::spawn(move || {
879 let mut buf = Vec::new();
880 if let Some(pipe) = stdout_pipe.as_mut() {
881 let _ = pipe.read_to_end(&mut buf);
882 }
883 buf
884 });
885 let stderr_reader = thread::spawn(move || {
886 let mut buf = Vec::new();
887 if let Some(pipe) = stderr_pipe.as_mut() {
888 let _ = pipe.read_to_end(&mut buf);
889 }
890 buf
891 });
892
893 let start = Instant::now();
894 let status = loop {
895 match child.try_wait()? {
896 Some(status) => break status,
897 None => {
898 if start.elapsed() >= timeout {
899 let _ = child.kill();
900 let _ = child.wait();
901 anyhow::bail!(
902 "Command timed out after {}s: {} {}\n\
903 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
904 timeout.as_secs(),
905 program,
906 args.join(" ")
907 );
908 }
909 thread::sleep(std::time::Duration::from_millis(100));
910 }
911 }
912 };
913
914 let stderr = stderr_reader
915 .join()
916 .map(|b| String::from_utf8_lossy(&b).into_owned())
917 .unwrap_or_default();
918 let stdout = stdout_reader
919 .join()
920 .map(|b| String::from_utf8_lossy(&b).into_owned())
921 .unwrap_or_default();
922
923 Ok(CommandOutput {
924 status,
925 stdout,
926 stderr,
927 })
928}
929
930pub fn run_command_with_timeout(
932 program: &str,
933 args: &[&str],
934 cwd: &Path,
935 timeout: std::time::Duration,
936) -> Result<()> {
937 let out = spawn_capture(program, args, cwd, timeout)?;
938 if out.status.success() {
939 Ok(())
940 } else {
941 anyhow::bail!(
942 "{} {} failed (exit code {:?}):\n{}",
943 program,
944 args.join(" "),
945 out.status.code(),
946 crate::output::condense_tool_output(
947 &out.stderr,
948 crate::constants::TOOL_OUTPUT_MAX_LINES
949 )
950 )
951 }
952}
953
954pub struct CapturedCommand {
957 pub ok: bool,
959 pub stdout: String,
961 pub stderr: String,
963}
964
965pub fn capture_allowing_failure(
975 program: &str,
976 args: &[&str],
977 cwd: &Path,
978 timeout: std::time::Duration,
979) -> Result<CapturedCommand> {
980 let out = spawn_capture(program, args, cwd, timeout)?;
981 Ok(CapturedCommand {
982 ok: out.status.success(),
983 stdout: out.stdout,
984 stderr: out.stderr,
985 })
986}
987
988pub fn capture_command_with_timeout(
994 program: &str,
995 args: &[&str],
996 cwd: &Path,
997 timeout: std::time::Duration,
998) -> Result<String> {
999 let out = spawn_capture(program, args, cwd, timeout)?;
1000 if out.status.success() {
1001 Ok(out.stdout)
1002 } else {
1003 anyhow::bail!(
1004 "{} {} failed (exit code {:?}):\n{}",
1005 program,
1006 args.join(" "),
1007 out.status.code(),
1008 crate::output::condense_tool_output(
1009 &out.stderr,
1010 crate::constants::TOOL_OUTPUT_MAX_LINES
1011 )
1012 )
1013 }
1014}
1015
1016pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
1018 crate::spawn::command(resolve_program(program))
1019 .args(args)
1020 .current_dir(cwd)
1021 .stdin(std::process::Stdio::null())
1022 .output()
1023 .map(|o| o.status.success())
1024 .unwrap_or(false)
1025}
1026
1027const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
1035
1036fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
1043 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
1044 Some("Cargo.lock") => "Cargo.toml",
1045 Some("package-lock.json")
1046 | Some("yarn.lock")
1047 | Some("pnpm-lock.yaml")
1048 | Some("bun.lockb")
1049 | Some("bun.lock") => "package.json",
1050 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
1051 Some("go.sum") => "go.mod",
1052 Some("composer.lock") => "composer.json",
1053 Some("Gemfile.lock") => "Gemfile",
1054 Some("Pipfile.lock") => "Pipfile",
1055 _ => return Ok(()),
1056 };
1057 let manifest = cwd.join(manifest_name);
1058 let (Ok(manifest_meta), Ok(lock_meta)) =
1059 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
1060 else {
1061 return Ok(());
1062 };
1063 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1064 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1065 {
1066 anyhow::bail!(
1067 "`{program}` is not available, and `{manifest_name}` has been edited more \
1068 recently than `{}` — the lockfile may no longer record the current \
1069 dependencies, and without `{program}` that cannot be verified. Install \
1070 {program} and run its lockfile sync, then prune again.",
1071 lockfile.display()
1072 );
1073 }
1074 Ok(())
1075}
1076
1077pub fn refuse_if_manifest_stale(
1088 manifest: &Path,
1089 lockfile: &Path,
1090 sync_command: &str,
1091) -> Result<()> {
1092 let (Ok(manifest_meta), Ok(lock_meta)) =
1093 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
1094 else {
1095 return Ok(());
1096 };
1097 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1098 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1099 {
1100 anyhow::bail!(
1101 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
1102 record the current dependencies. Run `{sync_command}` and prune again.",
1103 manifest.display(),
1104 lockfile.display()
1105 );
1106 }
1107 Ok(())
1108}
1109
1110pub fn lock_sync_or_verify_with_timeout(
1112 lockfile: &Path,
1113 program: &str,
1114 sync_args: &[&str],
1115 cwd: &Path,
1116 timeout: std::time::Duration,
1117) -> Result<()> {
1118 let lockfile_exists = lockfile.exists();
1119
1120 if !binary_available(program) {
1121 if lockfile_exists {
1122 refuse_if_manifest_newer(lockfile, program, cwd)?;
1123 return Ok(());
1124 } else {
1125 anyhow::bail!(
1126 "`{program}` is not available and no lockfile was found at `{}`. \
1127 Cannot safely delete dependencies — install {program} first, \
1128 or commit a lockfile.",
1129 lockfile.display()
1130 );
1131 }
1132 }
1133
1134 run_command_with_timeout(program, sync_args, cwd, timeout)
1136}
1137
1138#[derive(Debug, Clone, Copy)]
1144pub struct EnforcePolicy {
1145 pub allow_rewrite: bool,
1151 pub timeout: std::time::Duration,
1153}
1154
1155impl Default for EnforcePolicy {
1156 fn default() -> Self {
1157 Self {
1158 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1159 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1160 }
1161 }
1162}
1163
1164impl EnforcePolicy {
1165 pub fn from_settings(settings: &crate::config::Settings) -> Self {
1167 Self {
1168 allow_rewrite: settings.allow_manifest_rewrite,
1169 timeout: command_timeout(settings.command_timeout_secs),
1170 }
1171 }
1172}
1173
1174pub fn command_timeout(secs: u64) -> std::time::Duration {
1182 std::time::Duration::from_secs(secs.max(1))
1183}
1184
1185pub fn enforce_two_tier(
1201 lockfile: &Path,
1202 program: &str,
1203 verify_args: &[&str],
1204 write_args: &[&str],
1205 cwd: &Path,
1206 policy: EnforcePolicy,
1207) -> Result<()> {
1208 if policy.allow_rewrite {
1209 return lock_sync_or_verify_with_timeout(
1210 lockfile,
1211 program,
1212 write_args,
1213 cwd,
1214 policy.timeout,
1215 );
1216 }
1217 lock_verify_or_generate(
1218 lockfile,
1219 program,
1220 verify_args,
1221 write_args,
1222 cwd,
1223 policy.timeout,
1224 )
1225}
1226
1227pub fn lock_verify_or_generate(
1237 lockfile: &Path,
1238 program: &str,
1239 verify_args: &[&str],
1240 generate_args: &[&str],
1241 cwd: &Path,
1242 timeout: std::time::Duration,
1243) -> Result<()> {
1244 let lockfile_exists = lockfile.exists();
1245
1246 if !binary_available(program) {
1247 if lockfile_exists {
1248 refuse_if_manifest_newer(lockfile, program, cwd)?;
1249 return Ok(());
1250 }
1251 anyhow::bail!(
1252 "`{program}` is not available and no lockfile was found at `{}`. \
1253 Cannot safely delete dependencies — install {program} first, \
1254 or commit a lockfile.",
1255 lockfile.display()
1256 );
1257 }
1258
1259 if lockfile_exists {
1260 run_command_with_timeout(program, verify_args, cwd, timeout)
1261 } else {
1262 run_command_with_timeout(program, generate_args, cwd, timeout)
1263 }
1264}
1265
1266pub fn lock_sync_or_verify(
1268 lockfile: &Path,
1269 program: &str,
1270 sync_args: &[&str],
1271 cwd: &Path,
1272) -> Result<()> {
1273 lock_sync_or_verify_with_timeout(
1274 lockfile,
1275 program,
1276 sync_args,
1277 cwd,
1278 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1279 )
1280}
1281
1282const PYVENV_CFG: &str = "pyvenv.cfg";
1288
1289pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1296 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1297 for line in cfg.lines() {
1298 let Some((key, value)) = line.split_once('=') else {
1299 continue;
1300 };
1301 if matches!(key.trim(), "version" | "version_info") {
1302 let mut parts = value.trim().split('.');
1303 let major: u64 = parts.next()?.parse().ok()?;
1304 let minor: u64 = parts.next()?.parse().ok()?;
1305 return Some(format!("{major}.{minor}"));
1306 }
1307 }
1308 None
1309}
1310
1311pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1315 let mut parts = tag.split('.');
1316 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1317 return false;
1318 };
1319 !major.is_empty()
1320 && !minor.is_empty()
1321 && major.len() <= 2
1322 && minor.len() <= 3
1323 && major.bytes().all(|b| b.is_ascii_digit())
1324 && minor.bytes().all(|b| b.is_ascii_digit())
1325}
1326
1327pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1335 if !is_valid_runtime_tag(tag) {
1336 return None;
1337 }
1338 #[cfg(windows)]
1339 {
1340 Some(("py".to_string(), vec![format!("-{tag}")]))
1341 }
1342 #[cfg(not(windows))]
1343 {
1344 Some((format!("python{tag}"), Vec::new()))
1345 }
1346}
1347
1348pub(crate) fn python_executable(tag: &str) -> Option<String> {
1356 let (program, prefix) = python_launcher(tag)?;
1357 let out = crate::spawn::command(resolve_program(&program))
1358 .args(&prefix)
1359 .args(["-c", "import sys; print(sys.executable)"])
1360 .stdin(std::process::Stdio::null())
1361 .stderr(std::process::Stdio::null())
1362 .output()
1363 .ok()?;
1364 if !out.status.success() {
1365 return None;
1366 }
1367 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1368 (!path.is_empty()).then_some(path)
1369}
1370
1371pub(crate) fn python_runtime_available(tag: &str) -> bool {
1376 let Some((program, prefix)) = python_launcher(tag) else {
1377 return false;
1378 };
1379 crate::spawn::command(resolve_program(&program))
1380 .args(&prefix)
1381 .arg("--version")
1382 .stdin(std::process::Stdio::null())
1383 .stdout(std::process::Stdio::null())
1384 .stderr(std::process::Stdio::null())
1385 .status()
1386 .is_ok_and(|s| s.success())
1387}
1388
1389const NO_RESTORE_BINARY: [&str; 18] = [
1390 "venv",
1391 "gradle",
1392 "maven",
1393 "mix_build",
1394 "swift",
1395 "vcpkg",
1396 "cmake_build",
1397 "dotnet_build",
1398 "pycache",
1399 "godot",
1400 "unity",
1401 "unreal",
1402 "defold",
1403 "cocos",
1404 "zig",
1405 "stack",
1406 "cabal",
1407 "sbt",
1408];
1409
1410const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1412
1413pub fn adapter_binary(adapter: &str) -> &str {
1415 ADAPTER_BINARIES
1416 .iter()
1417 .find(|(name, _)| *name == adapter)
1418 .map_or(adapter, |(_, binary)| *binary)
1419}
1420
1421const INSTALL_HINTS: [(&str, &str); 18] = [
1426 ("npm", "ships with Node.js — https://nodejs.org"),
1427 (
1428 "pnpm",
1429 "`npm install -g pnpm` — https://pnpm.io/installation",
1430 ),
1431 (
1432 "yarn",
1433 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1434 ),
1435 ("bun", "https://bun.sh/docs/installation"),
1436 (
1437 "deno",
1438 "https://docs.deno.com/runtime/getting_started/installation/",
1439 ),
1440 (
1441 "uv",
1442 "https://docs.astral.sh/uv/getting-started/installation/",
1443 ),
1444 ("poetry", "https://python-poetry.org/docs/#installation"),
1445 (
1446 "pdm",
1447 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1448 ),
1449 (
1450 "pipenv",
1451 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1452 ),
1453 ("cargo", "ships with Rust — https://rustup.rs"),
1454 ("go", "https://go.dev/dl/"),
1455 ("composer", "https://getcomposer.org/download/"),
1456 ("bundler", "`gem install bundler` — https://bundler.io"),
1457 (
1458 "cocoapods",
1459 "`gem install cocoapods` — https://cocoapods.org",
1460 ),
1461 (
1462 "mix",
1463 "ships with Elixir — https://elixir-lang.org/install.html",
1464 ),
1465 (
1466 "terraform",
1467 "https://developer.hashicorp.com/terraform/install",
1468 ),
1469 (
1470 "dart",
1471 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1472 ),
1473 ("pixi", "https://pixi.sh/latest/installation/"),
1474];
1475
1476pub fn install_hint(adapter: &str) -> Option<&'static str> {
1478 INSTALL_HINTS
1479 .iter()
1480 .find(|(name, _)| *name == adapter)
1481 .map(|(_, hint)| *hint)
1482}
1483
1484#[derive(Debug, Clone)]
1486pub struct BinaryCheckStatus {
1487 pub name: String,
1488 pub available: bool,
1489 pub version: Option<String>,
1490}
1491
1492pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1494 let mut unique: Vec<String> = adapter_names
1495 .iter()
1496 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1499 .cloned()
1500 .collect();
1501 unique.sort();
1502 unique.dedup();
1503
1504 unique
1505 .into_iter()
1506 .map(|name| {
1507 let binary = adapter_binary(&name);
1508 let output = crate::spawn::command(resolve_program(binary))
1509 .args(version_probe_args(binary))
1510 .stdin(std::process::Stdio::null())
1511 .output();
1512 match output {
1513 Ok(out) if out.status.success() => {
1514 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1515 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1516 BinaryCheckStatus {
1517 name,
1518 available: true,
1519 version: if first_line.is_empty() {
1520 None
1521 } else {
1522 Some(first_line)
1523 },
1524 }
1525 }
1526 _ => BinaryCheckStatus {
1527 name,
1528 available: false,
1529 version: None,
1530 },
1531 }
1532 })
1533 .collect()
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538 use super::*;
1539 use std::fs;
1540 use tempfile::TempDir;
1541
1542 #[test]
1543 fn every_adapter_is_grouped_exactly_once() {
1544 let registered = all_adapter_names();
1547 let grouped: Vec<&str> = ADAPTER_GROUPS
1548 .iter()
1549 .flat_map(|(_, names)| names.iter().copied())
1550 .collect();
1551
1552 for name in ®istered {
1553 assert_eq!(
1554 grouped.iter().filter(|g| *g == name).count(),
1555 1,
1556 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1557 );
1558 }
1559 for name in &grouped {
1560 assert!(
1561 registered.contains(name),
1562 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1563 );
1564 }
1565 assert_eq!(registered.len(), grouped.len());
1566 }
1567
1568 #[test]
1569 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1570 let mut opt_in = opt_in_adapter_names();
1575 opt_in.sort_unstable();
1576 assert_eq!(
1577 opt_in,
1578 vec![
1579 "cabal",
1580 "cargo",
1581 "cmake_build",
1582 "cocos",
1583 "dart",
1584 "defold",
1585 "dotnet_build",
1586 "godot",
1587 "gradle",
1588 "maven",
1589 "mix_build",
1590 "sbt",
1591 "stack",
1592 "swift",
1593 "unity",
1594 "unreal",
1595 "vcpkg",
1596 "zig"
1597 ]
1598 );
1599 }
1600
1601 #[test]
1602 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1603 assert_eq!(version_probe_args("go"), &["version"]);
1608 assert_eq!(version_probe_args("npm"), &["--version"]);
1609 }
1610
1611 #[test]
1612 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1613 for adapter in get_all_adapters() {
1616 let name = adapter.name();
1617 if NO_RESTORE_BINARY.contains(&name) {
1618 continue;
1619 }
1620 assert!(
1621 install_hint(name).is_some(),
1622 "adapter `{name}` has no install hint"
1623 );
1624 }
1625 }
1626
1627 #[test]
1628 fn test_bloat_dir_display() {
1629 let bd = BloatDir {
1630 name: "node_modules".to_string(),
1631 path: PathBuf::from("/test/node_modules"),
1632 size_bytes: 1024,
1633 shared_bytes: 0,
1634 };
1635 assert!(bd.to_string().contains("node_modules"));
1636 }
1637
1638 #[test]
1639 fn test_hardlink_size_counts_a_plain_file_in_full() {
1640 let tmp = TempDir::new().unwrap();
1641 let tree = tmp.path().join("tree");
1642 fs::create_dir(&tree).unwrap();
1643 fs::write(tree.join("copied.txt"), "12345").unwrap();
1644 let size = dir_size_with_hardlinks(&tree);
1645 assert_eq!(size.freed_bytes, 5);
1646 assert_eq!(size.shared_bytes, 0);
1647 }
1648
1649 #[test]
1650 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1651 let tmp = TempDir::new().unwrap();
1654 let store = tmp.path().join("store");
1655 let tree = tmp.path().join("tree");
1656 fs::create_dir(&store).unwrap();
1657 fs::create_dir(&tree).unwrap();
1658 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1659 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1660 let size = dir_size_with_hardlinks(&tree);
1661 assert_eq!(size.freed_bytes, 0);
1662 assert_eq!(size.shared_bytes, 10);
1663 }
1664
1665 #[test]
1666 fn test_hardlink_size_counts_an_internal_pair_once() {
1667 let tmp = TempDir::new().unwrap();
1670 let tree = tmp.path().join("tree");
1671 fs::create_dir(&tree).unwrap();
1672 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1673 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1674 let size = dir_size_with_hardlinks(&tree);
1675 assert_eq!(size.freed_bytes, 7);
1676 assert_eq!(size.shared_bytes, 0);
1677 }
1678
1679 #[test]
1680 fn test_dir_size_empty() {
1681 let tmp = TempDir::new().unwrap();
1682 assert_eq!(dir_size(tmp.path()), 0);
1683 }
1684
1685 #[test]
1686 fn test_dir_size_with_files() {
1687 let tmp = TempDir::new().unwrap();
1688 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1689 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1690 assert_eq!(dir_size(tmp.path()), 11); }
1692
1693 #[test]
1694 fn test_dir_size_nonexistent() {
1695 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1696 }
1697
1698 #[test]
1699 fn test_get_all_adapters_not_empty() {
1700 let adapters = get_all_adapters();
1701 assert!(adapters.len() >= 6);
1702 }
1703
1704 #[test]
1705 fn test_detect_adapters_npm() {
1706 let tmp = TempDir::new().unwrap();
1707 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1708 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1709 let adapters = detect_adapters(tmp.path());
1710 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1711 assert!(names.contains(&"npm"));
1712 }
1713
1714 #[test]
1715 fn test_detect_adapters_empty_dir() {
1716 let tmp = TempDir::new().unwrap();
1717 let adapters = detect_adapters(tmp.path());
1718 assert!(adapters.is_empty());
1719 }
1720
1721 fn detected_names(dir: &Path) -> Vec<&'static str> {
1727 let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1728 .iter()
1729 .map(|a| a.name())
1730 .collect();
1731 names.sort_unstable();
1732 names
1733 }
1734
1735 #[test]
1736 fn test_detect_adapters_multiple_ecosystems_coexist() {
1737 let tmp = TempDir::new().unwrap();
1739 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1740 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1741 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1742 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1743 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1744
1745 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1750 }
1751
1752 #[test]
1753 fn test_detect_adapters_opt_in_appears_once_enabled() {
1754 let tmp = TempDir::new().unwrap();
1757 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1758
1759 let on = [String::from("cargo")];
1760 let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1761 .iter()
1762 .map(|a| a.name())
1763 .collect();
1764 names.sort_unstable();
1765 assert_eq!(names, vec!["cargo"]);
1766 }
1767
1768 #[test]
1769 fn every_adapter_counts_as_used_even_when_it_is_switched_off() {
1770 let tmp = TempDir::new().unwrap();
1775 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1776
1777 assert!(
1778 detect_adapters_with(tmp.path(), &[], &[]).is_empty(),
1779 "cargo is opt-in, so the prune-facing detector must not see it here"
1780 );
1781 let names: Vec<&str> = detect_all_adapters(tmp.path())
1782 .iter()
1783 .map(|a| a.name())
1784 .collect();
1785 assert_eq!(names, vec!["cargo"]);
1786 }
1787
1788 #[test]
1789 fn test_detect_adapters_disabled_adapter_is_invisible() {
1790 let tmp = TempDir::new().unwrap();
1792 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1793
1794 let off = [String::from("go")];
1795 assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1796 }
1797
1798 #[test]
1799 fn test_js_conflict_resolved_by_package_manager_field() {
1800 let tmp = TempDir::new().unwrap();
1801 fs::write(
1802 tmp.path().join("package.json"),
1803 r#"{"packageManager":"yarn@4.1.0"}"#,
1804 )
1805 .unwrap();
1806 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1807 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1808 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1809
1810 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1811 }
1812
1813 #[test]
1814 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1815 let tmp = TempDir::new().unwrap();
1818 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1819 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1820 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1821 std::thread::sleep(std::time::Duration::from_millis(20));
1822 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1823
1824 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1825 }
1826
1827 #[test]
1828 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1829 let tmp = TempDir::new().unwrap();
1831 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1832 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1833 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1834 let nm = tmp.path().join("node_modules");
1835 fs::create_dir_all(&nm).unwrap();
1836 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1837 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1838
1839 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1840 }
1841
1842 #[test]
1843 fn test_declared_package_manager_outranks_what_is_installed() {
1844 let tmp = TempDir::new().unwrap();
1846 fs::write(
1847 tmp.path().join("package.json"),
1848 r#"{"packageManager":"pnpm@9.1.0"}"#,
1849 )
1850 .unwrap();
1851 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1852 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1853 let nm = tmp.path().join("node_modules");
1854 fs::create_dir_all(&nm).unwrap();
1855 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1856
1857 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1858 }
1859
1860 #[test]
1861 fn test_uv_takes_precedence_over_plain_venv() {
1862 let tmp = TempDir::new().unwrap();
1865 fs::write(
1866 tmp.path().join("pyproject.toml"),
1867 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1868 )
1869 .unwrap();
1870 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1871 let venv = tmp.path().join(".venv");
1872 fs::create_dir_all(&venv).unwrap();
1873 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1874
1875 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1876 }
1877
1878 #[test]
1879 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1880 let tmp = TempDir::new().unwrap();
1881 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1882 let venv = tmp.path().join("venv");
1883 fs::create_dir_all(&venv).unwrap();
1884 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1885
1886 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1887 }
1888
1889 #[test]
1890 fn test_js_conflict_falls_back_to_newest_lockfile() {
1891 let tmp = TempDir::new().unwrap();
1892 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1893 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1894 std::thread::sleep(std::time::Duration::from_millis(20));
1897 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1898
1899 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1900 }
1901
1902 #[test]
1903 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1904 let tmp = TempDir::new().unwrap();
1909 fs::write(
1910 tmp.path().join("package.json"),
1911 r#"{"packageManager":"deno@2.0.0"}"#,
1912 )
1913 .unwrap();
1914 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1915 std::thread::sleep(std::time::Duration::from_millis(20));
1916 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1917
1918 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1919 }
1920
1921 #[test]
1922 fn test_js_conflict_does_not_disturb_a_single_manager() {
1923 let tmp = TempDir::new().unwrap();
1924 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1925 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1926 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1927 }
1928
1929 #[test]
1930 fn test_js_adapters_declare_their_lockfiles() {
1931 for adapter in get_all_adapters() {
1932 if JS_MANAGERS.contains(&adapter.name()) {
1933 assert!(
1934 !adapter.lockfiles().is_empty(),
1935 "{} shares node_modules and must declare its lockfiles for \
1936 conflict resolution",
1937 adapter.name()
1938 );
1939 }
1940 }
1941 }
1942
1943 #[test]
1944 fn test_adapter_names_unique() {
1945 let adapters = get_all_adapters();
1946 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1947 let mut unique = names.clone();
1948 unique.sort();
1949 unique.dedup();
1950 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1951 }
1952
1953 #[test]
1954 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1955 assert!(is_valid_runtime_tag("3.12"));
1958 assert!(is_valid_runtime_tag("3.9"));
1959 for bad in [
1960 "",
1961 "3",
1962 "3.12.1",
1963 "3.x",
1964 "3.12; rm -rf /",
1965 "-3.12",
1966 "../python",
1967 "3.1234",
1968 "300.1",
1969 ] {
1970 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1971 }
1972 }
1973
1974 #[test]
1975 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1976 let tmp = tempfile::tempdir().unwrap();
1977 let venv = tmp.path().join(".venv");
1978 std::fs::create_dir_all(&venv).unwrap();
1979 std::fs::write(
1980 venv.join("pyvenv.cfg"),
1981 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1982 )
1983 .unwrap();
1984 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1985 }
1986
1987 #[test]
1988 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1989 let tmp = tempfile::tempdir().unwrap();
1990 assert_eq!(venv_runtime_tag(tmp.path()), None);
1991 }
1992}