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 {
188 false
189 }
190}
191
192const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
194
195const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
203 ("pnpm", &[".pnpm", ".modules.yaml"]),
204 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
205 ("npm", &[".package-lock.json"]),
206];
207
208pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
212 vec![
213 Box::new(npm::Npm),
214 Box::new(pnpm::Pnpm),
215 Box::new(yarn::Yarn),
216 Box::new(bun::Bun),
217 Box::new(uv::Uv),
218 Box::new(poetry::Poetry),
219 Box::new(pdm::Pdm),
220 Box::new(pipenv::Pipenv),
221 Box::new(venv::Venv),
222 Box::new(cargo_adapter::Cargo),
223 Box::new(go::Go),
224 Box::new(composer::Composer),
225 Box::new(bundler::Bundler),
226 Box::new(cocoapods::CocoaPods),
227 Box::new(mix::Mix),
228 Box::new(gradle::Gradle),
229 Box::new(maven::Maven),
230 Box::new(swift::Swift),
231 ]
232}
233
234fn opt_in_enabled() -> &'static [String] {
242 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
243 ENABLED.get_or_init(|| {
244 crate::config::Registry::load()
245 .map(|r| {
246 let mut names = Vec::new();
247 if r.settings.enable_cargo {
248 names.push("cargo".to_string());
249 }
250 if r.settings.enable_gradle {
251 names.push("gradle".to_string());
252 }
253 if r.settings.enable_maven {
254 names.push("maven".to_string());
255 }
256 if r.settings.enable_swift {
257 names.push("swift".to_string());
258 }
259 names
260 })
261 .unwrap_or_default()
262 })
263}
264
265fn user_disabled() -> &'static [String] {
272 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
273 DISABLED.get_or_init(|| {
274 crate::config::Registry::load()
275 .map(|r| {
276 r.settings
277 .disabled_adapters
278 .iter()
279 .map(|n| n.trim().to_ascii_lowercase())
280 .filter(|n| !n.is_empty())
281 .collect()
282 })
283 .unwrap_or_default()
284 })
285}
286
287pub fn is_adapter_name(name: &str) -> bool {
289 get_all_adapters().iter().any(|a| a.name() == name)
290}
291
292pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
305 ("JavaScript", &["npm", "pnpm", "yarn", "bun"]),
306 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
307 ("Rust", &["cargo"]),
308 ("Go", &["go"]),
309 ("JVM", &["gradle", "maven"]),
310 ("PHP", &["composer"]),
311 ("Ruby", &["bundler"]),
312 ("Swift & Objective-C", &["swift", "cocoapods"]),
313 ("Elixir", &["mix"]),
314];
315
316pub fn adapter_group(name: &str) -> &'static str {
322 ADAPTER_GROUPS
323 .iter()
324 .find(|(_, names)| names.contains(&name))
325 .map(|(group, _)| *group)
326 .unwrap_or("Other")
327}
328
329pub fn all_adapter_names() -> Vec<&'static str> {
331 get_all_adapters().iter().map(|a| a.name()).collect()
332}
333
334pub fn opt_in_adapter_names() -> Vec<&'static str> {
339 get_all_adapters()
340 .iter()
341 .filter(|a| a.opt_in())
342 .map(|a| a.name())
343 .collect()
344}
345
346pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
353 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
354 .into_iter()
355 .filter(|adapter| !adapter.opt_in() || opt_in_enabled().iter().any(|n| n == adapter.name()))
356 .filter(|adapter| !user_disabled().iter().any(|n| n == adapter.name()))
357 .filter(|adapter| adapter.detect(project_path))
358 .collect();
359 resolve_conflicts(project_path, &mut detected);
360 detected
361}
362
363fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
365 resolve_js_conflict(project_path, detected);
366 resolve_python_conflict(project_path, detected);
367}
368
369fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
382 if detected
383 .iter()
384 .filter(|a| JS_MANAGERS.contains(&a.name()))
385 .count()
386 < 2
387 {
388 return;
389 }
390
391 let winner = declared_package_manager(project_path)
392 .filter(|name| detected.iter().any(|a| a.name() == name))
393 .or_else(|| installed_manager(project_path, detected))
394 .or_else(|| newest_lockfile_owner(project_path, detected));
395
396 let Some(winner) = winner else { return };
397 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
398}
399
400const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
413 ("uv", "uv.lock"),
414 ("poetry", "poetry.lock"),
415 ("pdm", "pdm.lock"),
416 ("pipenv", "Pipfile.lock"),
417];
418
419fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
420 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
421 .iter()
422 .copied()
423 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
424 .collect();
425 let Some(&(first, _)) = claimants.first() else {
426 return;
427 };
428 detected.retain(|a| a.name() != "venv");
429 if claimants.len() < 2 {
430 return;
431 }
432 let winner = claimants
433 .iter()
434 .find(|(_, lockfile)| project_path.join(lockfile).exists())
435 .map_or(first, |(name, _)| *name);
436 detected.retain(|a| {
437 a.name() == winner
438 || !PYTHON_ENV_MANAGERS
439 .iter()
440 .any(|(name, _)| *name == a.name())
441 });
442}
443
444fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
446 let node_modules = project_path.join("node_modules");
447 if !node_modules.is_dir() {
448 return None;
449 }
450
451 JS_INSTALL_MARKERS
452 .iter()
453 .find(|(name, markers)| {
454 detected.iter().any(|a| a.name() == *name)
455 && markers.iter().any(|m| node_modules.join(m).exists())
456 })
457 .map(|(name, _)| (*name).to_string())
458}
459
460fn declared_package_manager(project_path: &Path) -> Option<String> {
462 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
463 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
464 let declared = json.get("packageManager")?.as_str()?;
465 let name = declared.split('@').next().unwrap_or_default();
466 JS_MANAGERS
467 .iter()
468 .find(|m| **m == name)
469 .map(|m| (*m).to_string())
470}
471
472fn newest_lockfile_owner(
474 project_path: &Path,
475 detected: &[Box<dyn PackageManager>],
476) -> Option<String> {
477 detected
478 .iter()
479 .filter(|a| JS_MANAGERS.contains(&a.name()))
480 .filter_map(|a| {
481 let newest = a
482 .lockfiles()
483 .iter()
484 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
485 .max()?;
486 Some((newest, a.name().to_string()))
487 })
488 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
491 match best {
492 Some(b) if b.0 >= cur.0 => Some(b),
493 _ => Some(cur),
494 }
495 })
496 .map(|(_, name)| name)
497}
498
499pub fn dir_size(path: &Path) -> u64 {
501 if !path.exists() {
502 return 0;
503 }
504 WalkDir::new(path)
505 .follow_links(false)
506 .into_iter()
507 .flatten()
508 .filter_map(|entry| entry.metadata().ok())
509 .filter(|meta| meta.is_file())
510 .map(|meta| meta.len())
511 .sum()
512}
513
514#[derive(Debug, Clone, Copy, Default)]
516pub struct DirSizeBreakdown {
517 pub freed_bytes: u64,
519 pub shared_bytes: u64,
522}
523
524pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
535 let mut out = DirSizeBreakdown::default();
536 if !path.exists() {
537 return out;
538 }
539 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
541 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
542 let Ok(meta) = entry.metadata() else { continue };
543 if !meta.is_file() {
544 continue;
545 }
546 match file_link_identity(entry.path(), &meta) {
547 Some((dev, ino, nlink)) if nlink > 1 => {
548 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
549 }
550 _ => out.freed_bytes += meta.len(),
551 }
552 }
553 for (bytes, nlink, seen) in linked.into_values() {
554 if seen >= nlink {
555 out.freed_bytes += bytes;
556 } else {
557 out.shared_bytes += bytes;
558 }
559 }
560 out
561}
562
563#[cfg(unix)]
565fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
566 use std::os::unix::fs::MetadataExt as _;
567 Some((meta.dev(), meta.ino(), meta.nlink()))
568}
569
570#[cfg(windows)]
574fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
575 use std::os::windows::fs::OpenOptionsExt as _;
576 use std::os::windows::io::AsRawHandle as _;
577 use windows_sys::Win32::Storage::FileSystem::{
578 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
579 };
580
581 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
584 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
585 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
588 return None;
589 }
590 Some((
591 u64::from(info.dwVolumeSerialNumber),
592 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
593 u64::from(info.nNumberOfLinks),
594 ))
595}
596
597#[cfg(not(any(unix, windows)))]
598fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
599 None
600}
601
602pub fn resolve_program(program: &str) -> String {
612 #[cfg(windows)]
613 {
614 if Path::new(program).components().count() > 1 {
615 return program.to_string();
616 }
617 let Some(path_var) = std::env::var_os("PATH") else {
618 return program.to_string();
619 };
620 for dir in std::env::split_paths(&path_var) {
621 for ext in ["exe", "cmd", "bat"] {
622 let candidate = dir.join(format!("{program}.{ext}"));
623 if candidate.is_file() {
624 return candidate.to_string_lossy().into_owned();
625 }
626 }
627 }
628 }
629 program.to_string()
630}
631
632pub fn binary_available(program: &str) -> bool {
640 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
641 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
642
643 let mut guard = match cache.lock() {
646 Ok(g) => g,
647 Err(_) => return probe_binary(program),
650 };
651 if let Some(known) = guard.get(program) {
652 return *known;
653 }
654 let available = probe_binary(program);
655 guard.insert(program.to_string(), available);
656 available
657}
658
659const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
667
668fn version_probe_args(program: &str) -> &'static [&'static str] {
670 VERSION_PROBE_ARGS
671 .iter()
672 .find(|(name, _)| *name == program)
673 .map_or(&["--version"], |(_, args)| *args)
674}
675
676fn probe_binary(program: &str) -> bool {
678 crate::spawn::command(resolve_program(program))
679 .args(version_probe_args(program))
680 .stdin(std::process::Stdio::null())
681 .output()
682 .map(|o| o.status.success())
683 .unwrap_or(false)
684}
685
686struct CommandOutput {
688 status: std::process::ExitStatus,
689 stdout: String,
690 stderr: String,
691}
692
693fn spawn_capture(
700 program: &str,
701 args: &[&str],
702 cwd: &Path,
703 timeout: std::time::Duration,
704) -> Result<CommandOutput> {
705 use std::io::Read;
706 use std::process::Stdio;
707 use std::thread;
708 use std::time::Instant;
709
710 let resolved = resolve_program(program);
711 let mut child = crate::spawn::command(&resolved)
712 .args(args)
713 .current_dir(cwd)
714 .stdin(Stdio::null())
715 .stdout(Stdio::piped())
716 .stderr(Stdio::piped())
717 .spawn()
718 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
719
720 let mut stdout_pipe = child.stdout.take();
724 let mut stderr_pipe = child.stderr.take();
725 let stdout_reader = thread::spawn(move || {
726 let mut buf = Vec::new();
727 if let Some(pipe) = stdout_pipe.as_mut() {
728 let _ = pipe.read_to_end(&mut buf);
729 }
730 buf
731 });
732 let stderr_reader = thread::spawn(move || {
733 let mut buf = Vec::new();
734 if let Some(pipe) = stderr_pipe.as_mut() {
735 let _ = pipe.read_to_end(&mut buf);
736 }
737 buf
738 });
739
740 let start = Instant::now();
741 let status = loop {
742 match child.try_wait()? {
743 Some(status) => break status,
744 None => {
745 if start.elapsed() >= timeout {
746 let _ = child.kill();
747 let _ = child.wait();
748 anyhow::bail!(
749 "Command timed out after {}s: {} {}\n\
750 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
751 timeout.as_secs(),
752 program,
753 args.join(" ")
754 );
755 }
756 thread::sleep(std::time::Duration::from_millis(100));
757 }
758 }
759 };
760
761 let stderr = stderr_reader
762 .join()
763 .map(|b| String::from_utf8_lossy(&b).into_owned())
764 .unwrap_or_default();
765 let stdout = stdout_reader
766 .join()
767 .map(|b| String::from_utf8_lossy(&b).into_owned())
768 .unwrap_or_default();
769
770 Ok(CommandOutput {
771 status,
772 stdout,
773 stderr,
774 })
775}
776
777pub fn run_command_with_timeout(
779 program: &str,
780 args: &[&str],
781 cwd: &Path,
782 timeout: std::time::Duration,
783) -> Result<()> {
784 let out = spawn_capture(program, args, cwd, timeout)?;
785 if out.status.success() {
786 Ok(())
787 } else {
788 anyhow::bail!(
789 "{} {} failed (exit code {:?}):\n{}",
790 program,
791 args.join(" "),
792 out.status.code(),
793 crate::output::condense_tool_output(
794 &out.stderr,
795 crate::constants::TOOL_OUTPUT_MAX_LINES
796 )
797 )
798 }
799}
800
801pub fn capture_command_with_timeout(
807 program: &str,
808 args: &[&str],
809 cwd: &Path,
810 timeout: std::time::Duration,
811) -> Result<String> {
812 let out = spawn_capture(program, args, cwd, timeout)?;
813 if out.status.success() {
814 Ok(out.stdout)
815 } else {
816 anyhow::bail!(
817 "{} {} failed (exit code {:?}):\n{}",
818 program,
819 args.join(" "),
820 out.status.code(),
821 crate::output::condense_tool_output(
822 &out.stderr,
823 crate::constants::TOOL_OUTPUT_MAX_LINES
824 )
825 )
826 }
827}
828
829pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
831 crate::spawn::command(resolve_program(program))
832 .args(args)
833 .current_dir(cwd)
834 .stdin(std::process::Stdio::null())
835 .output()
836 .map(|o| o.status.success())
837 .unwrap_or(false)
838}
839
840const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
848
849fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
856 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
857 Some("Cargo.lock") => "Cargo.toml",
858 Some("package-lock.json")
859 | Some("yarn.lock")
860 | Some("pnpm-lock.yaml")
861 | Some("bun.lockb")
862 | Some("bun.lock") => "package.json",
863 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
864 Some("go.sum") => "go.mod",
865 Some("composer.lock") => "composer.json",
866 Some("Gemfile.lock") => "Gemfile",
867 Some("Pipfile.lock") => "Pipfile",
868 _ => return Ok(()),
869 };
870 let manifest = cwd.join(manifest_name);
871 let (Ok(manifest_meta), Ok(lock_meta)) =
872 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
873 else {
874 return Ok(());
875 };
876 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
877 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
878 {
879 anyhow::bail!(
880 "`{program}` is not available, and `{manifest_name}` has been edited more \
881 recently than `{}` — the lockfile may no longer record the current \
882 dependencies, and without `{program}` that cannot be verified. Install \
883 {program} and run its lockfile sync, then prune again.",
884 lockfile.display()
885 );
886 }
887 Ok(())
888}
889
890pub fn refuse_if_manifest_stale(
901 manifest: &Path,
902 lockfile: &Path,
903 sync_command: &str,
904) -> Result<()> {
905 let (Ok(manifest_meta), Ok(lock_meta)) =
906 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
907 else {
908 return Ok(());
909 };
910 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
911 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
912 {
913 anyhow::bail!(
914 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
915 record the current dependencies. Run `{sync_command}` and prune again.",
916 manifest.display(),
917 lockfile.display()
918 );
919 }
920 Ok(())
921}
922
923pub fn lock_sync_or_verify_with_timeout(
925 lockfile: &Path,
926 program: &str,
927 sync_args: &[&str],
928 cwd: &Path,
929 timeout: std::time::Duration,
930) -> Result<()> {
931 let lockfile_exists = lockfile.exists();
932
933 if !binary_available(program) {
934 if lockfile_exists {
935 refuse_if_manifest_newer(lockfile, program, cwd)?;
936 return Ok(());
937 } else {
938 anyhow::bail!(
939 "`{program}` is not available and no lockfile was found at `{}`. \
940 Cannot safely delete dependencies — install {program} first, \
941 or commit a lockfile.",
942 lockfile.display()
943 );
944 }
945 }
946
947 run_command_with_timeout(program, sync_args, cwd, timeout)
949}
950
951#[derive(Debug, Clone, Copy)]
957pub struct EnforcePolicy {
958 pub allow_rewrite: bool,
964 pub timeout: std::time::Duration,
966}
967
968impl Default for EnforcePolicy {
969 fn default() -> Self {
970 Self {
971 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
972 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
973 }
974 }
975}
976
977impl EnforcePolicy {
978 pub fn from_settings(settings: &crate::config::Settings) -> Self {
980 Self {
981 allow_rewrite: settings.allow_manifest_rewrite,
982 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
983 }
984 }
985}
986
987pub fn enforce_two_tier(
1003 lockfile: &Path,
1004 program: &str,
1005 verify_args: &[&str],
1006 write_args: &[&str],
1007 cwd: &Path,
1008 policy: EnforcePolicy,
1009) -> Result<()> {
1010 if policy.allow_rewrite {
1011 return lock_sync_or_verify_with_timeout(
1012 lockfile,
1013 program,
1014 write_args,
1015 cwd,
1016 policy.timeout,
1017 );
1018 }
1019 lock_verify_or_generate(
1020 lockfile,
1021 program,
1022 verify_args,
1023 write_args,
1024 cwd,
1025 policy.timeout,
1026 )
1027}
1028
1029pub fn lock_verify_or_generate(
1039 lockfile: &Path,
1040 program: &str,
1041 verify_args: &[&str],
1042 generate_args: &[&str],
1043 cwd: &Path,
1044 timeout: std::time::Duration,
1045) -> Result<()> {
1046 let lockfile_exists = lockfile.exists();
1047
1048 if !binary_available(program) {
1049 if lockfile_exists {
1050 refuse_if_manifest_newer(lockfile, program, cwd)?;
1051 return Ok(());
1052 }
1053 anyhow::bail!(
1054 "`{program}` is not available and no lockfile was found at `{}`. \
1055 Cannot safely delete dependencies — install {program} first, \
1056 or commit a lockfile.",
1057 lockfile.display()
1058 );
1059 }
1060
1061 if lockfile_exists {
1062 run_command_with_timeout(program, verify_args, cwd, timeout)
1063 } else {
1064 run_command_with_timeout(program, generate_args, cwd, timeout)
1065 }
1066}
1067
1068pub fn lock_sync_or_verify(
1070 lockfile: &Path,
1071 program: &str,
1072 sync_args: &[&str],
1073 cwd: &Path,
1074) -> Result<()> {
1075 lock_sync_or_verify_with_timeout(
1076 lockfile,
1077 program,
1078 sync_args,
1079 cwd,
1080 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1081 )
1082}
1083
1084const PYVENV_CFG: &str = "pyvenv.cfg";
1090
1091pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1098 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1099 for line in cfg.lines() {
1100 let Some((key, value)) = line.split_once('=') else {
1101 continue;
1102 };
1103 if matches!(key.trim(), "version" | "version_info") {
1104 let mut parts = value.trim().split('.');
1105 let major: u64 = parts.next()?.parse().ok()?;
1106 let minor: u64 = parts.next()?.parse().ok()?;
1107 return Some(format!("{major}.{minor}"));
1108 }
1109 }
1110 None
1111}
1112
1113pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1117 let mut parts = tag.split('.');
1118 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1119 return false;
1120 };
1121 !major.is_empty()
1122 && !minor.is_empty()
1123 && major.len() <= 2
1124 && minor.len() <= 3
1125 && major.bytes().all(|b| b.is_ascii_digit())
1126 && minor.bytes().all(|b| b.is_ascii_digit())
1127}
1128
1129pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1137 if !is_valid_runtime_tag(tag) {
1138 return None;
1139 }
1140 #[cfg(windows)]
1141 {
1142 Some(("py".to_string(), vec![format!("-{tag}")]))
1143 }
1144 #[cfg(not(windows))]
1145 {
1146 Some((format!("python{tag}"), Vec::new()))
1147 }
1148}
1149
1150pub(crate) fn python_executable(tag: &str) -> Option<String> {
1158 let (program, prefix) = python_launcher(tag)?;
1159 let out = crate::spawn::command(resolve_program(&program))
1160 .args(&prefix)
1161 .args(["-c", "import sys; print(sys.executable)"])
1162 .stdin(std::process::Stdio::null())
1163 .stderr(std::process::Stdio::null())
1164 .output()
1165 .ok()?;
1166 if !out.status.success() {
1167 return None;
1168 }
1169 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1170 (!path.is_empty()).then_some(path)
1171}
1172
1173pub(crate) fn python_runtime_available(tag: &str) -> bool {
1178 let Some((program, prefix)) = python_launcher(tag) else {
1179 return false;
1180 };
1181 crate::spawn::command(resolve_program(&program))
1182 .args(&prefix)
1183 .arg("--version")
1184 .stdin(std::process::Stdio::null())
1185 .stdout(std::process::Stdio::null())
1186 .stderr(std::process::Stdio::null())
1187 .status()
1188 .is_ok_and(|s| s.success())
1189}
1190
1191const NO_RESTORE_BINARY: [&str; 4] = ["venv", "gradle", "maven", "swift"];
1192
1193const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1195
1196pub fn adapter_binary(adapter: &str) -> &str {
1198 ADAPTER_BINARIES
1199 .iter()
1200 .find(|(name, _)| *name == adapter)
1201 .map_or(adapter, |(_, binary)| *binary)
1202}
1203
1204const INSTALL_HINTS: [(&str, &str); 14] = [
1209 ("npm", "ships with Node.js — https://nodejs.org"),
1210 (
1211 "pnpm",
1212 "`npm install -g pnpm` — https://pnpm.io/installation",
1213 ),
1214 (
1215 "yarn",
1216 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1217 ),
1218 ("bun", "https://bun.sh/docs/installation"),
1219 (
1220 "uv",
1221 "https://docs.astral.sh/uv/getting-started/installation/",
1222 ),
1223 ("poetry", "https://python-poetry.org/docs/#installation"),
1224 (
1225 "pdm",
1226 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1227 ),
1228 (
1229 "pipenv",
1230 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1231 ),
1232 ("cargo", "ships with Rust — https://rustup.rs"),
1233 ("go", "https://go.dev/dl/"),
1234 ("composer", "https://getcomposer.org/download/"),
1235 ("bundler", "`gem install bundler` — https://bundler.io"),
1236 (
1237 "cocoapods",
1238 "`gem install cocoapods` — https://cocoapods.org",
1239 ),
1240 (
1241 "mix",
1242 "ships with Elixir — https://elixir-lang.org/install.html",
1243 ),
1244];
1245
1246pub fn install_hint(adapter: &str) -> Option<&'static str> {
1248 INSTALL_HINTS
1249 .iter()
1250 .find(|(name, _)| *name == adapter)
1251 .map(|(_, hint)| *hint)
1252}
1253
1254#[derive(Debug, Clone)]
1256pub struct BinaryCheckStatus {
1257 pub name: String,
1258 pub available: bool,
1259 pub version: Option<String>,
1260}
1261
1262pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1264 let mut unique: Vec<String> = adapter_names
1265 .iter()
1266 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1269 .cloned()
1270 .collect();
1271 unique.sort();
1272 unique.dedup();
1273
1274 unique
1275 .into_iter()
1276 .map(|name| {
1277 let binary = adapter_binary(&name);
1278 let output = crate::spawn::command(resolve_program(binary))
1279 .args(version_probe_args(binary))
1280 .stdin(std::process::Stdio::null())
1281 .output();
1282 match output {
1283 Ok(out) if out.status.success() => {
1284 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1285 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1286 BinaryCheckStatus {
1287 name,
1288 available: true,
1289 version: if first_line.is_empty() {
1290 None
1291 } else {
1292 Some(first_line)
1293 },
1294 }
1295 }
1296 _ => BinaryCheckStatus {
1297 name,
1298 available: false,
1299 version: None,
1300 },
1301 }
1302 })
1303 .collect()
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use std::fs;
1310 use tempfile::TempDir;
1311
1312 #[test]
1313 fn every_adapter_is_grouped_exactly_once() {
1314 let registered = all_adapter_names();
1317 let grouped: Vec<&str> = ADAPTER_GROUPS
1318 .iter()
1319 .flat_map(|(_, names)| names.iter().copied())
1320 .collect();
1321
1322 for name in ®istered {
1323 assert_eq!(
1324 grouped.iter().filter(|g| *g == name).count(),
1325 1,
1326 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1327 );
1328 }
1329 for name in &grouped {
1330 assert!(
1331 registered.contains(name),
1332 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1333 );
1334 }
1335 assert_eq!(registered.len(), grouped.len());
1336 }
1337
1338 #[test]
1339 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1340 let mut opt_in = opt_in_adapter_names();
1345 opt_in.sort_unstable();
1346 assert_eq!(opt_in, vec!["cargo", "gradle", "maven", "swift"]);
1347 }
1348
1349 #[test]
1350 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1351 assert_eq!(version_probe_args("go"), &["version"]);
1356 assert_eq!(version_probe_args("npm"), &["--version"]);
1357 }
1358
1359 #[test]
1360 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1361 for adapter in get_all_adapters() {
1364 let name = adapter.name();
1365 if NO_RESTORE_BINARY.contains(&name) {
1366 continue;
1367 }
1368 assert!(
1369 install_hint(name).is_some(),
1370 "adapter `{name}` has no install hint"
1371 );
1372 }
1373 }
1374
1375 #[test]
1376 fn test_bloat_dir_display() {
1377 let bd = BloatDir {
1378 name: "node_modules".to_string(),
1379 path: PathBuf::from("/test/node_modules"),
1380 size_bytes: 1024,
1381 shared_bytes: 0,
1382 };
1383 assert!(bd.to_string().contains("node_modules"));
1384 }
1385
1386 #[test]
1387 fn test_hardlink_size_counts_a_plain_file_in_full() {
1388 let tmp = TempDir::new().unwrap();
1389 let tree = tmp.path().join("tree");
1390 fs::create_dir(&tree).unwrap();
1391 fs::write(tree.join("copied.txt"), "12345").unwrap();
1392 let size = dir_size_with_hardlinks(&tree);
1393 assert_eq!(size.freed_bytes, 5);
1394 assert_eq!(size.shared_bytes, 0);
1395 }
1396
1397 #[test]
1398 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1399 let tmp = TempDir::new().unwrap();
1402 let store = tmp.path().join("store");
1403 let tree = tmp.path().join("tree");
1404 fs::create_dir(&store).unwrap();
1405 fs::create_dir(&tree).unwrap();
1406 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1407 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1408 let size = dir_size_with_hardlinks(&tree);
1409 assert_eq!(size.freed_bytes, 0);
1410 assert_eq!(size.shared_bytes, 10);
1411 }
1412
1413 #[test]
1414 fn test_hardlink_size_counts_an_internal_pair_once() {
1415 let tmp = TempDir::new().unwrap();
1418 let tree = tmp.path().join("tree");
1419 fs::create_dir(&tree).unwrap();
1420 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1421 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1422 let size = dir_size_with_hardlinks(&tree);
1423 assert_eq!(size.freed_bytes, 7);
1424 assert_eq!(size.shared_bytes, 0);
1425 }
1426
1427 #[test]
1428 fn test_dir_size_empty() {
1429 let tmp = TempDir::new().unwrap();
1430 assert_eq!(dir_size(tmp.path()), 0);
1431 }
1432
1433 #[test]
1434 fn test_dir_size_with_files() {
1435 let tmp = TempDir::new().unwrap();
1436 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1437 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1438 assert_eq!(dir_size(tmp.path()), 11); }
1440
1441 #[test]
1442 fn test_dir_size_nonexistent() {
1443 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1444 }
1445
1446 #[test]
1447 fn test_get_all_adapters_not_empty() {
1448 let adapters = get_all_adapters();
1449 assert!(adapters.len() >= 6);
1450 }
1451
1452 #[test]
1453 fn test_detect_adapters_npm() {
1454 let tmp = TempDir::new().unwrap();
1455 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1456 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1457 let adapters = detect_adapters(tmp.path());
1458 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1459 assert!(names.contains(&"npm"));
1460 }
1461
1462 #[test]
1463 fn test_detect_adapters_empty_dir() {
1464 let tmp = TempDir::new().unwrap();
1465 let adapters = detect_adapters(tmp.path());
1466 assert!(adapters.is_empty());
1467 }
1468
1469 fn detected_names(dir: &Path) -> Vec<&'static str> {
1471 let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
1472 names.sort_unstable();
1473 names
1474 }
1475
1476 #[test]
1477 fn test_detect_adapters_multiple_ecosystems_coexist() {
1478 let tmp = TempDir::new().unwrap();
1480 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1481 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1482 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1483 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1484 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1485
1486 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1491 }
1492
1493 #[test]
1494 fn test_js_conflict_resolved_by_package_manager_field() {
1495 let tmp = TempDir::new().unwrap();
1496 fs::write(
1497 tmp.path().join("package.json"),
1498 r#"{"packageManager":"yarn@4.1.0"}"#,
1499 )
1500 .unwrap();
1501 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1502 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1503 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1504
1505 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1506 }
1507
1508 #[test]
1509 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1510 let tmp = TempDir::new().unwrap();
1513 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1514 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1515 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1516 std::thread::sleep(std::time::Duration::from_millis(20));
1517 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1518
1519 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1520 }
1521
1522 #[test]
1523 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1524 let tmp = TempDir::new().unwrap();
1526 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1527 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1528 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1529 let nm = tmp.path().join("node_modules");
1530 fs::create_dir_all(&nm).unwrap();
1531 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1532 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1533
1534 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1535 }
1536
1537 #[test]
1538 fn test_declared_package_manager_outranks_what_is_installed() {
1539 let tmp = TempDir::new().unwrap();
1541 fs::write(
1542 tmp.path().join("package.json"),
1543 r#"{"packageManager":"pnpm@9.1.0"}"#,
1544 )
1545 .unwrap();
1546 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1547 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1548 let nm = tmp.path().join("node_modules");
1549 fs::create_dir_all(&nm).unwrap();
1550 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1551
1552 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1553 }
1554
1555 #[test]
1556 fn test_uv_takes_precedence_over_plain_venv() {
1557 let tmp = TempDir::new().unwrap();
1560 fs::write(
1561 tmp.path().join("pyproject.toml"),
1562 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1563 )
1564 .unwrap();
1565 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1566 let venv = tmp.path().join(".venv");
1567 fs::create_dir_all(&venv).unwrap();
1568 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1569
1570 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1571 }
1572
1573 #[test]
1574 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1575 let tmp = TempDir::new().unwrap();
1576 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1577 let venv = tmp.path().join("venv");
1578 fs::create_dir_all(&venv).unwrap();
1579 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1580
1581 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1582 }
1583
1584 #[test]
1585 fn test_js_conflict_falls_back_to_newest_lockfile() {
1586 let tmp = TempDir::new().unwrap();
1587 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1588 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1589 std::thread::sleep(std::time::Duration::from_millis(20));
1592 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1593
1594 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1595 }
1596
1597 #[test]
1598 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1599 let tmp = TempDir::new().unwrap();
1602 fs::write(
1603 tmp.path().join("package.json"),
1604 r#"{"packageManager":"deno@2.0.0"}"#,
1605 )
1606 .unwrap();
1607 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1608 std::thread::sleep(std::time::Duration::from_millis(20));
1609 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1610
1611 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1612 }
1613
1614 #[test]
1615 fn test_js_conflict_does_not_disturb_a_single_manager() {
1616 let tmp = TempDir::new().unwrap();
1617 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1618 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1619 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1620 }
1621
1622 #[test]
1623 fn test_js_adapters_declare_their_lockfiles() {
1624 for adapter in get_all_adapters() {
1625 if JS_MANAGERS.contains(&adapter.name()) {
1626 assert!(
1627 !adapter.lockfiles().is_empty(),
1628 "{} shares node_modules and must declare its lockfiles for \
1629 conflict resolution",
1630 adapter.name()
1631 );
1632 }
1633 }
1634 }
1635
1636 #[test]
1637 fn test_adapter_names_unique() {
1638 let adapters = get_all_adapters();
1639 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1640 let mut unique = names.clone();
1641 unique.sort();
1642 unique.dedup();
1643 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1644 }
1645
1646 #[test]
1647 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1648 assert!(is_valid_runtime_tag("3.12"));
1651 assert!(is_valid_runtime_tag("3.9"));
1652 for bad in [
1653 "",
1654 "3",
1655 "3.12.1",
1656 "3.x",
1657 "3.12; rm -rf /",
1658 "-3.12",
1659 "../python",
1660 "3.1234",
1661 "300.1",
1662 ] {
1663 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1664 }
1665 }
1666
1667 #[test]
1668 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1669 let tmp = tempfile::tempdir().unwrap();
1670 let venv = tmp.path().join(".venv");
1671 std::fs::create_dir_all(&venv).unwrap();
1672 std::fs::write(
1673 venv.join("pyvenv.cfg"),
1674 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1675 )
1676 .unwrap();
1677 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1678 }
1679
1680 #[test]
1681 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1682 let tmp = tempfile::tempdir().unwrap();
1683 assert_eq!(venv_runtime_tag(tmp.path()), None);
1684 }
1685}