1pub mod bun;
20pub mod bundler;
21pub mod cargo_adapter;
22pub mod cocoapods;
23pub mod composer;
24pub mod go;
25pub mod gradle;
26pub mod maven;
27pub mod mix;
28pub mod npm;
29pub mod pdm;
30pub mod pipenv;
31pub mod pnpm;
32pub mod poetry;
33pub mod swift;
34pub mod uv;
35pub mod venv;
36pub mod yarn;
37
38use std::collections::HashMap;
39use std::fmt;
40use std::path::{Path, PathBuf};
41use std::sync::{Mutex, OnceLock};
42
43use anyhow::{Context as _, Result};
44use walkdir::WalkDir;
45
46#[derive(Debug, Clone)]
48pub struct BloatDir {
49 pub name: String,
51 pub path: PathBuf,
53 pub size_bytes: u64,
55 pub shared_bytes: u64,
59}
60
61impl fmt::Display for BloatDir {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 write!(f, "{} ({})", self.name, self.path.display())
64 }
65}
66
67#[derive(Debug, Clone)]
70pub struct DriftReport {
71 pub directory: String,
73 pub unrecorded: Vec<String>,
75 pub record_command: &'static str,
77}
78
79pub trait PackageManager: Send + Sync {
87 fn name(&self) -> &'static str;
89
90 fn detect(&self, project_path: &Path) -> bool;
94
95 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
99
100 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
107
108 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
115
116 fn restore_named(
127 &self,
128 project_path: &Path,
129 dir_name: &str,
130 runtime: Option<&str>,
131 timeout: std::time::Duration,
132 ) -> Result<()> {
133 let _ = (dir_name, runtime);
134 self.restore(project_path, timeout)
135 }
136
137 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
147 let _ = (project_path, dir_name);
148 None
149 }
150
151 fn lockfiles(&self) -> &'static [&'static str] {
161 &[]
162 }
163
164 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
172 let _ = project_path;
173 Vec::new()
174 }
175
176 fn opt_in(&self) -> bool {
183 false
184 }
185}
186
187const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
189
190const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
198 ("pnpm", &[".pnpm", ".modules.yaml"]),
199 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
200 ("npm", &[".package-lock.json"]),
201];
202
203pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
207 vec![
208 Box::new(npm::Npm),
209 Box::new(pnpm::Pnpm),
210 Box::new(yarn::Yarn),
211 Box::new(bun::Bun),
212 Box::new(uv::Uv),
213 Box::new(poetry::Poetry),
214 Box::new(pdm::Pdm),
215 Box::new(pipenv::Pipenv),
216 Box::new(venv::Venv),
217 Box::new(cargo_adapter::Cargo),
218 Box::new(go::Go),
219 Box::new(composer::Composer),
220 Box::new(bundler::Bundler),
221 Box::new(cocoapods::CocoaPods),
222 Box::new(mix::Mix),
223 Box::new(gradle::Gradle),
224 Box::new(maven::Maven),
225 Box::new(swift::Swift),
226 ]
227}
228
229fn opt_in_enabled() -> &'static [String] {
237 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
238 ENABLED.get_or_init(|| {
239 crate::config::Registry::load()
240 .map(|r| {
241 let mut names = Vec::new();
242 if r.settings.enable_gradle {
243 names.push("gradle".to_string());
244 }
245 if r.settings.enable_maven {
246 names.push("maven".to_string());
247 }
248 if r.settings.enable_swift {
249 names.push("swift".to_string());
250 }
251 names
252 })
253 .unwrap_or_default()
254 })
255}
256
257fn user_disabled() -> &'static [String] {
264 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
265 DISABLED.get_or_init(|| {
266 crate::config::Registry::load()
267 .map(|r| {
268 r.settings
269 .disabled_adapters
270 .iter()
271 .map(|n| n.trim().to_ascii_lowercase())
272 .filter(|n| !n.is_empty())
273 .collect()
274 })
275 .unwrap_or_default()
276 })
277}
278
279pub fn is_adapter_name(name: &str) -> bool {
281 get_all_adapters().iter().any(|a| a.name() == name)
282}
283
284pub fn all_adapter_names() -> Vec<&'static str> {
286 get_all_adapters().iter().map(|a| a.name()).collect()
287}
288
289pub fn opt_in_adapter_names() -> Vec<&'static str> {
294 get_all_adapters()
295 .iter()
296 .filter(|a| a.opt_in())
297 .map(|a| a.name())
298 .collect()
299}
300
301pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
308 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
309 .into_iter()
310 .filter(|adapter| !adapter.opt_in() || opt_in_enabled().iter().any(|n| n == adapter.name()))
311 .filter(|adapter| !user_disabled().iter().any(|n| n == adapter.name()))
312 .filter(|adapter| adapter.detect(project_path))
313 .collect();
314 resolve_conflicts(project_path, &mut detected);
315 detected
316}
317
318fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
320 resolve_js_conflict(project_path, detected);
321 resolve_python_conflict(project_path, detected);
322}
323
324fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
337 if detected
338 .iter()
339 .filter(|a| JS_MANAGERS.contains(&a.name()))
340 .count()
341 < 2
342 {
343 return;
344 }
345
346 let winner = declared_package_manager(project_path)
347 .filter(|name| detected.iter().any(|a| a.name() == name))
348 .or_else(|| installed_manager(project_path, detected))
349 .or_else(|| newest_lockfile_owner(project_path, detected));
350
351 let Some(winner) = winner else { return };
352 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
353}
354
355const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
368 ("uv", "uv.lock"),
369 ("poetry", "poetry.lock"),
370 ("pdm", "pdm.lock"),
371 ("pipenv", "Pipfile.lock"),
372];
373
374fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
375 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
376 .iter()
377 .copied()
378 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
379 .collect();
380 let Some(&(first, _)) = claimants.first() else {
381 return;
382 };
383 detected.retain(|a| a.name() != "venv");
384 if claimants.len() < 2 {
385 return;
386 }
387 let winner = claimants
388 .iter()
389 .find(|(_, lockfile)| project_path.join(lockfile).exists())
390 .map_or(first, |(name, _)| *name);
391 detected.retain(|a| {
392 a.name() == winner
393 || !PYTHON_ENV_MANAGERS
394 .iter()
395 .any(|(name, _)| *name == a.name())
396 });
397}
398
399fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
401 let node_modules = project_path.join("node_modules");
402 if !node_modules.is_dir() {
403 return None;
404 }
405
406 JS_INSTALL_MARKERS
407 .iter()
408 .find(|(name, markers)| {
409 detected.iter().any(|a| a.name() == *name)
410 && markers.iter().any(|m| node_modules.join(m).exists())
411 })
412 .map(|(name, _)| (*name).to_string())
413}
414
415fn declared_package_manager(project_path: &Path) -> Option<String> {
417 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
418 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
419 let declared = json.get("packageManager")?.as_str()?;
420 let name = declared.split('@').next().unwrap_or_default();
421 JS_MANAGERS
422 .iter()
423 .find(|m| **m == name)
424 .map(|m| (*m).to_string())
425}
426
427fn newest_lockfile_owner(
429 project_path: &Path,
430 detected: &[Box<dyn PackageManager>],
431) -> Option<String> {
432 detected
433 .iter()
434 .filter(|a| JS_MANAGERS.contains(&a.name()))
435 .filter_map(|a| {
436 let newest = a
437 .lockfiles()
438 .iter()
439 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
440 .max()?;
441 Some((newest, a.name().to_string()))
442 })
443 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
446 match best {
447 Some(b) if b.0 >= cur.0 => Some(b),
448 _ => Some(cur),
449 }
450 })
451 .map(|(_, name)| name)
452}
453
454pub fn dir_size(path: &Path) -> u64 {
456 if !path.exists() {
457 return 0;
458 }
459 WalkDir::new(path)
460 .follow_links(false)
461 .into_iter()
462 .flatten()
463 .filter_map(|entry| entry.metadata().ok())
464 .filter(|meta| meta.is_file())
465 .map(|meta| meta.len())
466 .sum()
467}
468
469#[derive(Debug, Clone, Copy, Default)]
471pub struct DirSizeBreakdown {
472 pub freed_bytes: u64,
474 pub shared_bytes: u64,
477}
478
479pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
490 let mut out = DirSizeBreakdown::default();
491 if !path.exists() {
492 return out;
493 }
494 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
496 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
497 let Ok(meta) = entry.metadata() else { continue };
498 if !meta.is_file() {
499 continue;
500 }
501 match file_link_identity(entry.path(), &meta) {
502 Some((dev, ino, nlink)) if nlink > 1 => {
503 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
504 }
505 _ => out.freed_bytes += meta.len(),
506 }
507 }
508 for (bytes, nlink, seen) in linked.into_values() {
509 if seen >= nlink {
510 out.freed_bytes += bytes;
511 } else {
512 out.shared_bytes += bytes;
513 }
514 }
515 out
516}
517
518#[cfg(unix)]
520fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
521 use std::os::unix::fs::MetadataExt as _;
522 Some((meta.dev(), meta.ino(), meta.nlink()))
523}
524
525#[cfg(windows)]
529fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
530 use std::os::windows::fs::OpenOptionsExt as _;
531 use std::os::windows::io::AsRawHandle as _;
532 use windows_sys::Win32::Storage::FileSystem::{
533 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
534 };
535
536 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
539 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
540 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
543 return None;
544 }
545 Some((
546 u64::from(info.dwVolumeSerialNumber),
547 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
548 u64::from(info.nNumberOfLinks),
549 ))
550}
551
552#[cfg(not(any(unix, windows)))]
553fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
554 None
555}
556
557pub fn resolve_program(program: &str) -> String {
567 #[cfg(windows)]
568 {
569 if Path::new(program).components().count() > 1 {
570 return program.to_string();
571 }
572 let Some(path_var) = std::env::var_os("PATH") else {
573 return program.to_string();
574 };
575 for dir in std::env::split_paths(&path_var) {
576 for ext in ["exe", "cmd", "bat"] {
577 let candidate = dir.join(format!("{program}.{ext}"));
578 if candidate.is_file() {
579 return candidate.to_string_lossy().into_owned();
580 }
581 }
582 }
583 }
584 program.to_string()
585}
586
587pub fn binary_available(program: &str) -> bool {
595 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
596 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
597
598 let mut guard = match cache.lock() {
601 Ok(g) => g,
602 Err(_) => return probe_binary(program),
605 };
606 if let Some(known) = guard.get(program) {
607 return *known;
608 }
609 let available = probe_binary(program);
610 guard.insert(program.to_string(), available);
611 available
612}
613
614const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
622
623fn version_probe_args(program: &str) -> &'static [&'static str] {
625 VERSION_PROBE_ARGS
626 .iter()
627 .find(|(name, _)| *name == program)
628 .map_or(&["--version"], |(_, args)| *args)
629}
630
631fn probe_binary(program: &str) -> bool {
633 crate::spawn::command(resolve_program(program))
634 .args(version_probe_args(program))
635 .stdin(std::process::Stdio::null())
636 .output()
637 .map(|o| o.status.success())
638 .unwrap_or(false)
639}
640
641struct CommandOutput {
643 status: std::process::ExitStatus,
644 stdout: String,
645 stderr: String,
646}
647
648fn spawn_capture(
655 program: &str,
656 args: &[&str],
657 cwd: &Path,
658 timeout: std::time::Duration,
659) -> Result<CommandOutput> {
660 use std::io::Read;
661 use std::process::Stdio;
662 use std::thread;
663 use std::time::Instant;
664
665 let resolved = resolve_program(program);
666 let mut child = crate::spawn::command(&resolved)
667 .args(args)
668 .current_dir(cwd)
669 .stdin(Stdio::null())
670 .stdout(Stdio::piped())
671 .stderr(Stdio::piped())
672 .spawn()
673 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
674
675 let mut stdout_pipe = child.stdout.take();
679 let mut stderr_pipe = child.stderr.take();
680 let stdout_reader = thread::spawn(move || {
681 let mut buf = Vec::new();
682 if let Some(pipe) = stdout_pipe.as_mut() {
683 let _ = pipe.read_to_end(&mut buf);
684 }
685 buf
686 });
687 let stderr_reader = thread::spawn(move || {
688 let mut buf = Vec::new();
689 if let Some(pipe) = stderr_pipe.as_mut() {
690 let _ = pipe.read_to_end(&mut buf);
691 }
692 buf
693 });
694
695 let start = Instant::now();
696 let status = loop {
697 match child.try_wait()? {
698 Some(status) => break status,
699 None => {
700 if start.elapsed() >= timeout {
701 let _ = child.kill();
702 let _ = child.wait();
703 anyhow::bail!(
704 "Command timed out after {}s: {} {}\n\
705 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
706 timeout.as_secs(),
707 program,
708 args.join(" ")
709 );
710 }
711 thread::sleep(std::time::Duration::from_millis(100));
712 }
713 }
714 };
715
716 let stderr = stderr_reader
717 .join()
718 .map(|b| String::from_utf8_lossy(&b).into_owned())
719 .unwrap_or_default();
720 let stdout = stdout_reader
721 .join()
722 .map(|b| String::from_utf8_lossy(&b).into_owned())
723 .unwrap_or_default();
724
725 Ok(CommandOutput {
726 status,
727 stdout,
728 stderr,
729 })
730}
731
732pub fn run_command_with_timeout(
734 program: &str,
735 args: &[&str],
736 cwd: &Path,
737 timeout: std::time::Duration,
738) -> Result<()> {
739 let out = spawn_capture(program, args, cwd, timeout)?;
740 if out.status.success() {
741 Ok(())
742 } else {
743 anyhow::bail!(
744 "{} {} failed (exit code {:?}):\n{}",
745 program,
746 args.join(" "),
747 out.status.code(),
748 crate::output::condense_tool_output(
749 &out.stderr,
750 crate::constants::TOOL_OUTPUT_MAX_LINES
751 )
752 )
753 }
754}
755
756pub fn capture_command_with_timeout(
762 program: &str,
763 args: &[&str],
764 cwd: &Path,
765 timeout: std::time::Duration,
766) -> Result<String> {
767 let out = spawn_capture(program, args, cwd, timeout)?;
768 if out.status.success() {
769 Ok(out.stdout)
770 } else {
771 anyhow::bail!(
772 "{} {} failed (exit code {:?}):\n{}",
773 program,
774 args.join(" "),
775 out.status.code(),
776 crate::output::condense_tool_output(
777 &out.stderr,
778 crate::constants::TOOL_OUTPUT_MAX_LINES
779 )
780 )
781 }
782}
783
784pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
786 crate::spawn::command(resolve_program(program))
787 .args(args)
788 .current_dir(cwd)
789 .stdin(std::process::Stdio::null())
790 .output()
791 .map(|o| o.status.success())
792 .unwrap_or(false)
793}
794
795const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
803
804fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
811 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
812 Some("Cargo.lock") => "Cargo.toml",
813 Some("package-lock.json")
814 | Some("yarn.lock")
815 | Some("pnpm-lock.yaml")
816 | Some("bun.lockb")
817 | Some("bun.lock") => "package.json",
818 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
819 Some("go.sum") => "go.mod",
820 Some("composer.lock") => "composer.json",
821 Some("Gemfile.lock") => "Gemfile",
822 Some("Pipfile.lock") => "Pipfile",
823 _ => return Ok(()),
824 };
825 let manifest = cwd.join(manifest_name);
826 let (Ok(manifest_meta), Ok(lock_meta)) =
827 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
828 else {
829 return Ok(());
830 };
831 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
832 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
833 {
834 anyhow::bail!(
835 "`{program}` is not available, and `{manifest_name}` has been edited more \
836 recently than `{}` — the lockfile may no longer record the current \
837 dependencies, and without `{program}` that cannot be verified. Install \
838 {program} and run its lockfile sync, then prune again.",
839 lockfile.display()
840 );
841 }
842 Ok(())
843}
844
845pub fn refuse_if_manifest_stale(
856 manifest: &Path,
857 lockfile: &Path,
858 sync_command: &str,
859) -> Result<()> {
860 let (Ok(manifest_meta), Ok(lock_meta)) =
861 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
862 else {
863 return Ok(());
864 };
865 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
866 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
867 {
868 anyhow::bail!(
869 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
870 record the current dependencies. Run `{sync_command}` and prune again.",
871 manifest.display(),
872 lockfile.display()
873 );
874 }
875 Ok(())
876}
877
878pub fn lock_sync_or_verify_with_timeout(
880 lockfile: &Path,
881 program: &str,
882 sync_args: &[&str],
883 cwd: &Path,
884 timeout: std::time::Duration,
885) -> Result<()> {
886 let lockfile_exists = lockfile.exists();
887
888 if !binary_available(program) {
889 if lockfile_exists {
890 refuse_if_manifest_newer(lockfile, program, cwd)?;
891 return Ok(());
892 } else {
893 anyhow::bail!(
894 "`{program}` is not available and no lockfile was found at `{}`. \
895 Cannot safely delete dependencies — install {program} first, \
896 or commit a lockfile.",
897 lockfile.display()
898 );
899 }
900 }
901
902 run_command_with_timeout(program, sync_args, cwd, timeout)
904}
905
906#[derive(Debug, Clone, Copy)]
912pub struct EnforcePolicy {
913 pub allow_rewrite: bool,
919 pub timeout: std::time::Duration,
921}
922
923impl Default for EnforcePolicy {
924 fn default() -> Self {
925 Self {
926 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
927 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
928 }
929 }
930}
931
932impl EnforcePolicy {
933 pub fn from_settings(settings: &crate::config::Settings) -> Self {
935 Self {
936 allow_rewrite: settings.allow_manifest_rewrite,
937 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
938 }
939 }
940}
941
942pub fn enforce_two_tier(
958 lockfile: &Path,
959 program: &str,
960 verify_args: &[&str],
961 write_args: &[&str],
962 cwd: &Path,
963 policy: EnforcePolicy,
964) -> Result<()> {
965 if policy.allow_rewrite {
966 return lock_sync_or_verify_with_timeout(
967 lockfile,
968 program,
969 write_args,
970 cwd,
971 policy.timeout,
972 );
973 }
974 lock_verify_or_generate(
975 lockfile,
976 program,
977 verify_args,
978 write_args,
979 cwd,
980 policy.timeout,
981 )
982}
983
984pub fn lock_verify_or_generate(
994 lockfile: &Path,
995 program: &str,
996 verify_args: &[&str],
997 generate_args: &[&str],
998 cwd: &Path,
999 timeout: std::time::Duration,
1000) -> Result<()> {
1001 let lockfile_exists = lockfile.exists();
1002
1003 if !binary_available(program) {
1004 if lockfile_exists {
1005 refuse_if_manifest_newer(lockfile, program, cwd)?;
1006 return Ok(());
1007 }
1008 anyhow::bail!(
1009 "`{program}` is not available and no lockfile was found at `{}`. \
1010 Cannot safely delete dependencies — install {program} first, \
1011 or commit a lockfile.",
1012 lockfile.display()
1013 );
1014 }
1015
1016 if lockfile_exists {
1017 run_command_with_timeout(program, verify_args, cwd, timeout)
1018 } else {
1019 run_command_with_timeout(program, generate_args, cwd, timeout)
1020 }
1021}
1022
1023pub fn lock_sync_or_verify(
1025 lockfile: &Path,
1026 program: &str,
1027 sync_args: &[&str],
1028 cwd: &Path,
1029) -> Result<()> {
1030 lock_sync_or_verify_with_timeout(
1031 lockfile,
1032 program,
1033 sync_args,
1034 cwd,
1035 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1036 )
1037}
1038
1039const PYVENV_CFG: &str = "pyvenv.cfg";
1045
1046pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1053 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1054 for line in cfg.lines() {
1055 let Some((key, value)) = line.split_once('=') else {
1056 continue;
1057 };
1058 if matches!(key.trim(), "version" | "version_info") {
1059 let mut parts = value.trim().split('.');
1060 let major: u64 = parts.next()?.parse().ok()?;
1061 let minor: u64 = parts.next()?.parse().ok()?;
1062 return Some(format!("{major}.{minor}"));
1063 }
1064 }
1065 None
1066}
1067
1068pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1072 let mut parts = tag.split('.');
1073 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1074 return false;
1075 };
1076 !major.is_empty()
1077 && !minor.is_empty()
1078 && major.len() <= 2
1079 && minor.len() <= 3
1080 && major.bytes().all(|b| b.is_ascii_digit())
1081 && minor.bytes().all(|b| b.is_ascii_digit())
1082}
1083
1084pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1092 if !is_valid_runtime_tag(tag) {
1093 return None;
1094 }
1095 #[cfg(windows)]
1096 {
1097 Some(("py".to_string(), vec![format!("-{tag}")]))
1098 }
1099 #[cfg(not(windows))]
1100 {
1101 Some((format!("python{tag}"), Vec::new()))
1102 }
1103}
1104
1105pub(crate) fn python_executable(tag: &str) -> Option<String> {
1113 let (program, prefix) = python_launcher(tag)?;
1114 let out = crate::spawn::command(resolve_program(&program))
1115 .args(&prefix)
1116 .args(["-c", "import sys; print(sys.executable)"])
1117 .stdin(std::process::Stdio::null())
1118 .stderr(std::process::Stdio::null())
1119 .output()
1120 .ok()?;
1121 if !out.status.success() {
1122 return None;
1123 }
1124 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1125 (!path.is_empty()).then_some(path)
1126}
1127
1128pub(crate) fn python_runtime_available(tag: &str) -> bool {
1133 let Some((program, prefix)) = python_launcher(tag) else {
1134 return false;
1135 };
1136 crate::spawn::command(resolve_program(&program))
1137 .args(&prefix)
1138 .arg("--version")
1139 .stdin(std::process::Stdio::null())
1140 .stdout(std::process::Stdio::null())
1141 .stderr(std::process::Stdio::null())
1142 .status()
1143 .is_ok_and(|s| s.success())
1144}
1145
1146const NO_RESTORE_BINARY: [&str; 4] = ["venv", "gradle", "maven", "swift"];
1147
1148const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1150
1151pub fn adapter_binary(adapter: &str) -> &str {
1153 ADAPTER_BINARIES
1154 .iter()
1155 .find(|(name, _)| *name == adapter)
1156 .map_or(adapter, |(_, binary)| *binary)
1157}
1158
1159const INSTALL_HINTS: [(&str, &str); 14] = [
1164 ("npm", "ships with Node.js — https://nodejs.org"),
1165 (
1166 "pnpm",
1167 "`npm install -g pnpm` — https://pnpm.io/installation",
1168 ),
1169 (
1170 "yarn",
1171 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1172 ),
1173 ("bun", "https://bun.sh/docs/installation"),
1174 (
1175 "uv",
1176 "https://docs.astral.sh/uv/getting-started/installation/",
1177 ),
1178 ("poetry", "https://python-poetry.org/docs/#installation"),
1179 (
1180 "pdm",
1181 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1182 ),
1183 (
1184 "pipenv",
1185 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1186 ),
1187 ("cargo", "ships with Rust — https://rustup.rs"),
1188 ("go", "https://go.dev/dl/"),
1189 ("composer", "https://getcomposer.org/download/"),
1190 ("bundler", "`gem install bundler` — https://bundler.io"),
1191 (
1192 "cocoapods",
1193 "`gem install cocoapods` — https://cocoapods.org",
1194 ),
1195 (
1196 "mix",
1197 "ships with Elixir — https://elixir-lang.org/install.html",
1198 ),
1199];
1200
1201pub fn install_hint(adapter: &str) -> Option<&'static str> {
1203 INSTALL_HINTS
1204 .iter()
1205 .find(|(name, _)| *name == adapter)
1206 .map(|(_, hint)| *hint)
1207}
1208
1209#[derive(Debug, Clone)]
1211pub struct BinaryCheckStatus {
1212 pub name: String,
1213 pub available: bool,
1214 pub version: Option<String>,
1215}
1216
1217pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1219 let mut unique: Vec<String> = adapter_names
1220 .iter()
1221 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1224 .cloned()
1225 .collect();
1226 unique.sort();
1227 unique.dedup();
1228
1229 unique
1230 .into_iter()
1231 .map(|name| {
1232 let binary = adapter_binary(&name);
1233 let output = crate::spawn::command(resolve_program(binary))
1234 .args(version_probe_args(binary))
1235 .stdin(std::process::Stdio::null())
1236 .output();
1237 match output {
1238 Ok(out) if out.status.success() => {
1239 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1240 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1241 BinaryCheckStatus {
1242 name,
1243 available: true,
1244 version: if first_line.is_empty() {
1245 None
1246 } else {
1247 Some(first_line)
1248 },
1249 }
1250 }
1251 _ => BinaryCheckStatus {
1252 name,
1253 available: false,
1254 version: None,
1255 },
1256 }
1257 })
1258 .collect()
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264 use std::fs;
1265 use tempfile::TempDir;
1266
1267 #[test]
1268 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1269 assert_eq!(version_probe_args("go"), &["version"]);
1274 assert_eq!(version_probe_args("npm"), &["--version"]);
1275 }
1276
1277 #[test]
1278 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1279 for adapter in get_all_adapters() {
1282 let name = adapter.name();
1283 if NO_RESTORE_BINARY.contains(&name) {
1284 continue;
1285 }
1286 assert!(
1287 install_hint(name).is_some(),
1288 "adapter `{name}` has no install hint"
1289 );
1290 }
1291 }
1292
1293 #[test]
1294 fn test_bloat_dir_display() {
1295 let bd = BloatDir {
1296 name: "node_modules".to_string(),
1297 path: PathBuf::from("/test/node_modules"),
1298 size_bytes: 1024,
1299 shared_bytes: 0,
1300 };
1301 assert!(bd.to_string().contains("node_modules"));
1302 }
1303
1304 #[test]
1305 fn test_hardlink_size_counts_a_plain_file_in_full() {
1306 let tmp = TempDir::new().unwrap();
1307 let tree = tmp.path().join("tree");
1308 fs::create_dir(&tree).unwrap();
1309 fs::write(tree.join("copied.txt"), "12345").unwrap();
1310 let size = dir_size_with_hardlinks(&tree);
1311 assert_eq!(size.freed_bytes, 5);
1312 assert_eq!(size.shared_bytes, 0);
1313 }
1314
1315 #[test]
1316 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1317 let tmp = TempDir::new().unwrap();
1320 let store = tmp.path().join("store");
1321 let tree = tmp.path().join("tree");
1322 fs::create_dir(&store).unwrap();
1323 fs::create_dir(&tree).unwrap();
1324 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1325 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1326 let size = dir_size_with_hardlinks(&tree);
1327 assert_eq!(size.freed_bytes, 0);
1328 assert_eq!(size.shared_bytes, 10);
1329 }
1330
1331 #[test]
1332 fn test_hardlink_size_counts_an_internal_pair_once() {
1333 let tmp = TempDir::new().unwrap();
1336 let tree = tmp.path().join("tree");
1337 fs::create_dir(&tree).unwrap();
1338 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1339 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1340 let size = dir_size_with_hardlinks(&tree);
1341 assert_eq!(size.freed_bytes, 7);
1342 assert_eq!(size.shared_bytes, 0);
1343 }
1344
1345 #[test]
1346 fn test_dir_size_empty() {
1347 let tmp = TempDir::new().unwrap();
1348 assert_eq!(dir_size(tmp.path()), 0);
1349 }
1350
1351 #[test]
1352 fn test_dir_size_with_files() {
1353 let tmp = TempDir::new().unwrap();
1354 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1355 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1356 assert_eq!(dir_size(tmp.path()), 11); }
1358
1359 #[test]
1360 fn test_dir_size_nonexistent() {
1361 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1362 }
1363
1364 #[test]
1365 fn test_get_all_adapters_not_empty() {
1366 let adapters = get_all_adapters();
1367 assert!(adapters.len() >= 6);
1368 }
1369
1370 #[test]
1371 fn test_detect_adapters_npm() {
1372 let tmp = TempDir::new().unwrap();
1373 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1374 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1375 let adapters = detect_adapters(tmp.path());
1376 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1377 assert!(names.contains(&"npm"));
1378 }
1379
1380 #[test]
1381 fn test_detect_adapters_empty_dir() {
1382 let tmp = TempDir::new().unwrap();
1383 let adapters = detect_adapters(tmp.path());
1384 assert!(adapters.is_empty());
1385 }
1386
1387 fn detected_names(dir: &Path) -> Vec<&'static str> {
1389 let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
1390 names.sort_unstable();
1391 names
1392 }
1393
1394 #[test]
1395 fn test_detect_adapters_multiple_ecosystems_coexist() {
1396 let tmp = TempDir::new().unwrap();
1398 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1399 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1400 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1401 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1402 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1403
1404 assert_eq!(detected_names(tmp.path()), vec!["cargo", "go", "npm", "uv"]);
1405 }
1406
1407 #[test]
1408 fn test_js_conflict_resolved_by_package_manager_field() {
1409 let tmp = TempDir::new().unwrap();
1410 fs::write(
1411 tmp.path().join("package.json"),
1412 r#"{"packageManager":"yarn@4.1.0"}"#,
1413 )
1414 .unwrap();
1415 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1416 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1417 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1418
1419 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1420 }
1421
1422 #[test]
1423 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1424 let tmp = TempDir::new().unwrap();
1427 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1428 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1429 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1430 std::thread::sleep(std::time::Duration::from_millis(20));
1431 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1432
1433 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1434 }
1435
1436 #[test]
1437 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1438 let tmp = TempDir::new().unwrap();
1440 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1441 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1442 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1443 let nm = tmp.path().join("node_modules");
1444 fs::create_dir_all(&nm).unwrap();
1445 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1446 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1447
1448 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1449 }
1450
1451 #[test]
1452 fn test_declared_package_manager_outranks_what_is_installed() {
1453 let tmp = TempDir::new().unwrap();
1455 fs::write(
1456 tmp.path().join("package.json"),
1457 r#"{"packageManager":"pnpm@9.1.0"}"#,
1458 )
1459 .unwrap();
1460 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1461 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1462 let nm = tmp.path().join("node_modules");
1463 fs::create_dir_all(&nm).unwrap();
1464 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1465
1466 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1467 }
1468
1469 #[test]
1470 fn test_uv_takes_precedence_over_plain_venv() {
1471 let tmp = TempDir::new().unwrap();
1474 fs::write(
1475 tmp.path().join("pyproject.toml"),
1476 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1477 )
1478 .unwrap();
1479 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1480 let venv = tmp.path().join(".venv");
1481 fs::create_dir_all(&venv).unwrap();
1482 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1483
1484 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1485 }
1486
1487 #[test]
1488 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1489 let tmp = TempDir::new().unwrap();
1490 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1491 let venv = tmp.path().join("venv");
1492 fs::create_dir_all(&venv).unwrap();
1493 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1494
1495 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1496 }
1497
1498 #[test]
1499 fn test_js_conflict_falls_back_to_newest_lockfile() {
1500 let tmp = TempDir::new().unwrap();
1501 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1502 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1503 std::thread::sleep(std::time::Duration::from_millis(20));
1506 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1507
1508 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1509 }
1510
1511 #[test]
1512 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1513 let tmp = TempDir::new().unwrap();
1516 fs::write(
1517 tmp.path().join("package.json"),
1518 r#"{"packageManager":"deno@2.0.0"}"#,
1519 )
1520 .unwrap();
1521 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1522 std::thread::sleep(std::time::Duration::from_millis(20));
1523 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1524
1525 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1526 }
1527
1528 #[test]
1529 fn test_js_conflict_does_not_disturb_a_single_manager() {
1530 let tmp = TempDir::new().unwrap();
1531 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1532 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1533 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1534 }
1535
1536 #[test]
1537 fn test_js_adapters_declare_their_lockfiles() {
1538 for adapter in get_all_adapters() {
1539 if JS_MANAGERS.contains(&adapter.name()) {
1540 assert!(
1541 !adapter.lockfiles().is_empty(),
1542 "{} shares node_modules and must declare its lockfiles for \
1543 conflict resolution",
1544 adapter.name()
1545 );
1546 }
1547 }
1548 }
1549
1550 #[test]
1551 fn test_adapter_names_unique() {
1552 let adapters = get_all_adapters();
1553 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1554 let mut unique = names.clone();
1555 unique.sort();
1556 unique.dedup();
1557 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1558 }
1559
1560 #[test]
1561 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1562 assert!(is_valid_runtime_tag("3.12"));
1565 assert!(is_valid_runtime_tag("3.9"));
1566 for bad in [
1567 "",
1568 "3",
1569 "3.12.1",
1570 "3.x",
1571 "3.12; rm -rf /",
1572 "-3.12",
1573 "../python",
1574 "3.1234",
1575 "300.1",
1576 ] {
1577 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1578 }
1579 }
1580
1581 #[test]
1582 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1583 let tmp = tempfile::tempdir().unwrap();
1584 let venv = tmp.path().join(".venv");
1585 std::fs::create_dir_all(&venv).unwrap();
1586 std::fs::write(
1587 venv.join("pyvenv.cfg"),
1588 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1589 )
1590 .unwrap();
1591 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1592 }
1593
1594 #[test]
1595 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1596 let tmp = tempfile::tempdir().unwrap();
1597 assert_eq!(venv_runtime_tag(tmp.path()), None);
1598 }
1599}