1pub mod bun;
20pub mod bundler;
21pub mod cargo_adapter;
22pub mod cocoapods;
23pub mod composer;
24pub mod dart;
25pub mod go;
26pub mod gradle;
27pub mod maven;
28pub mod mix;
29pub mod mix_build;
30pub mod npm;
31pub mod pdm;
32pub mod pipenv;
33pub mod pnpm;
34pub mod poetry;
35pub mod swift;
36pub mod terraform;
37pub mod uv;
38pub mod venv;
39pub mod yarn;
40
41use std::collections::HashMap;
42use std::fmt;
43use std::path::{Path, PathBuf};
44use std::sync::{Mutex, OnceLock};
45
46use anyhow::{Context as _, Result};
47use walkdir::WalkDir;
48
49#[derive(Debug, Clone)]
51pub struct BloatDir {
52 pub name: String,
54 pub path: PathBuf,
56 pub size_bytes: u64,
58 pub shared_bytes: u64,
62}
63
64impl fmt::Display for BloatDir {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 write!(f, "{} ({})", self.name, self.path.display())
67 }
68}
69
70#[derive(Debug, Clone)]
73pub struct DriftReport {
74 pub directory: String,
76 pub unrecorded: Vec<String>,
78 pub record_command: &'static str,
80}
81
82pub trait PackageManager: Send + Sync {
90 fn name(&self) -> &'static str;
92
93 fn detect(&self, project_path: &Path) -> bool;
97
98 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
102
103 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
110
111 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
118
119 fn restore_named(
130 &self,
131 project_path: &Path,
132 dir_name: &str,
133 runtime: Option<&str>,
134 timeout: std::time::Duration,
135 ) -> Result<()> {
136 let _ = (dir_name, runtime);
137 self.restore(project_path, timeout)
138 }
139
140 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
150 let _ = (project_path, dir_name);
151 None
152 }
153
154 fn lockfiles(&self) -> &'static [&'static str] {
164 &[]
165 }
166
167 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
175 let _ = project_path;
176 Vec::new()
177 }
178
179 fn opt_in(&self) -> bool {
191 false
192 }
193}
194
195const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
197
198const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
206 ("pnpm", &[".pnpm", ".modules.yaml"]),
207 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
208 ("npm", &[".package-lock.json"]),
209];
210
211pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
215 vec![
216 Box::new(npm::Npm),
217 Box::new(pnpm::Pnpm),
218 Box::new(yarn::Yarn),
219 Box::new(bun::Bun),
220 Box::new(uv::Uv),
221 Box::new(poetry::Poetry),
222 Box::new(pdm::Pdm),
223 Box::new(pipenv::Pipenv),
224 Box::new(venv::Venv),
225 Box::new(cargo_adapter::Cargo),
226 Box::new(go::Go),
227 Box::new(composer::Composer),
228 Box::new(bundler::Bundler),
229 Box::new(cocoapods::CocoaPods),
230 Box::new(mix::Mix),
231 Box::new(mix_build::MixBuild),
232 Box::new(gradle::Gradle),
233 Box::new(maven::Maven),
234 Box::new(swift::Swift),
235 Box::new(terraform::Terraform),
236 Box::new(dart::Dart),
237 ]
238}
239
240fn opt_in_enabled() -> &'static [String] {
248 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
249 ENABLED.get_or_init(|| {
250 crate::config::Registry::load()
251 .map(|r| {
252 let mut names = Vec::new();
253 if r.settings.enable_cargo {
254 names.push("cargo".to_string());
255 }
256 if r.settings.enable_gradle {
257 names.push("gradle".to_string());
258 }
259 if r.settings.enable_maven {
260 names.push("maven".to_string());
261 }
262 if r.settings.enable_swift {
263 names.push("swift".to_string());
264 }
265 if r.settings.enable_dart {
266 names.push("dart".to_string());
267 }
268 if r.settings.enable_mix_build {
269 names.push("mix_build".to_string());
270 }
271 names
272 })
273 .unwrap_or_default()
274 })
275}
276
277fn user_disabled() -> &'static [String] {
284 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
285 DISABLED.get_or_init(|| {
286 crate::config::Registry::load()
287 .map(|r| {
288 r.settings
289 .disabled_adapters
290 .iter()
291 .map(|n| n.trim().to_ascii_lowercase())
292 .filter(|n| !n.is_empty())
293 .collect()
294 })
295 .unwrap_or_default()
296 })
297}
298
299pub fn is_adapter_name(name: &str) -> bool {
301 get_all_adapters().iter().any(|a| a.name() == name)
302}
303
304pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
317 ("JavaScript", &["npm", "pnpm", "yarn", "bun"]),
318 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
319 ("Rust", &["cargo"]),
320 ("Go", &["go"]),
321 ("JVM", &["gradle", "maven"]),
322 ("PHP", &["composer"]),
323 ("Ruby", &["bundler"]),
324 ("Swift & Objective-C", &["swift", "cocoapods"]),
325 ("Elixir", &["mix", "mix_build"]),
326 ("Infrastructure", &["terraform"]),
327 ("Dart & Flutter", &["dart"]),
328];
329
330pub fn adapter_group(name: &str) -> &'static str {
336 ADAPTER_GROUPS
337 .iter()
338 .find(|(_, names)| names.contains(&name))
339 .map(|(group, _)| *group)
340 .unwrap_or("Other")
341}
342
343pub fn all_adapter_names() -> Vec<&'static str> {
345 get_all_adapters().iter().map(|a| a.name()).collect()
346}
347
348pub fn opt_in_adapter_names() -> Vec<&'static str> {
353 get_all_adapters()
354 .iter()
355 .filter(|a| a.opt_in())
356 .map(|a| a.name())
357 .collect()
358}
359
360pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
367 detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
368}
369
370fn detect_adapters_with(
378 project_path: &Path,
379 opt_in: &[String],
380 disabled: &[String],
381) -> Vec<Box<dyn PackageManager>> {
382 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
383 .into_iter()
384 .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
385 .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
386 .filter(|adapter| adapter.detect(project_path))
387 .collect();
388 resolve_conflicts(project_path, &mut detected);
389 detected
390}
391
392fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
394 resolve_js_conflict(project_path, detected);
395 resolve_python_conflict(project_path, detected);
396}
397
398fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
411 if detected
412 .iter()
413 .filter(|a| JS_MANAGERS.contains(&a.name()))
414 .count()
415 < 2
416 {
417 return;
418 }
419
420 let winner = declared_package_manager(project_path)
421 .filter(|name| detected.iter().any(|a| a.name() == name))
422 .or_else(|| installed_manager(project_path, detected))
423 .or_else(|| newest_lockfile_owner(project_path, detected));
424
425 let Some(winner) = winner else { return };
426 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
427}
428
429const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
442 ("uv", "uv.lock"),
443 ("poetry", "poetry.lock"),
444 ("pdm", "pdm.lock"),
445 ("pipenv", "Pipfile.lock"),
446];
447
448fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
449 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
450 .iter()
451 .copied()
452 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
453 .collect();
454 let Some(&(first, _)) = claimants.first() else {
455 return;
456 };
457 detected.retain(|a| a.name() != "venv");
458 if claimants.len() < 2 {
459 return;
460 }
461 let winner = claimants
462 .iter()
463 .find(|(_, lockfile)| project_path.join(lockfile).exists())
464 .map_or(first, |(name, _)| *name);
465 detected.retain(|a| {
466 a.name() == winner
467 || !PYTHON_ENV_MANAGERS
468 .iter()
469 .any(|(name, _)| *name == a.name())
470 });
471}
472
473fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
475 let node_modules = project_path.join("node_modules");
476 if !node_modules.is_dir() {
477 return None;
478 }
479
480 JS_INSTALL_MARKERS
481 .iter()
482 .find(|(name, markers)| {
483 detected.iter().any(|a| a.name() == *name)
484 && markers.iter().any(|m| node_modules.join(m).exists())
485 })
486 .map(|(name, _)| (*name).to_string())
487}
488
489fn declared_package_manager(project_path: &Path) -> Option<String> {
491 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
492 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
493 let declared = json.get("packageManager")?.as_str()?;
494 let name = declared.split('@').next().unwrap_or_default();
495 JS_MANAGERS
496 .iter()
497 .find(|m| **m == name)
498 .map(|m| (*m).to_string())
499}
500
501fn newest_lockfile_owner(
503 project_path: &Path,
504 detected: &[Box<dyn PackageManager>],
505) -> Option<String> {
506 detected
507 .iter()
508 .filter(|a| JS_MANAGERS.contains(&a.name()))
509 .filter_map(|a| {
510 let newest = a
511 .lockfiles()
512 .iter()
513 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
514 .max()?;
515 Some((newest, a.name().to_string()))
516 })
517 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
520 match best {
521 Some(b) if b.0 >= cur.0 => Some(b),
522 _ => Some(cur),
523 }
524 })
525 .map(|(_, name)| name)
526}
527
528pub fn dir_size(path: &Path) -> u64 {
530 if !path.exists() {
531 return 0;
532 }
533 WalkDir::new(path)
534 .follow_links(false)
535 .into_iter()
536 .flatten()
537 .filter_map(|entry| entry.metadata().ok())
538 .filter(|meta| meta.is_file())
539 .map(|meta| meta.len())
540 .sum()
541}
542
543#[derive(Debug, Clone, Copy, Default)]
545pub struct DirSizeBreakdown {
546 pub freed_bytes: u64,
548 pub shared_bytes: u64,
551}
552
553pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
564 let mut out = DirSizeBreakdown::default();
565 if !path.exists() {
566 return out;
567 }
568 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
570 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
571 let Ok(meta) = entry.metadata() else { continue };
572 if !meta.is_file() {
573 continue;
574 }
575 match file_link_identity(entry.path(), &meta) {
576 Some((dev, ino, nlink)) if nlink > 1 => {
577 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
578 }
579 _ => out.freed_bytes += meta.len(),
580 }
581 }
582 for (bytes, nlink, seen) in linked.into_values() {
583 if seen >= nlink {
584 out.freed_bytes += bytes;
585 } else {
586 out.shared_bytes += bytes;
587 }
588 }
589 out
590}
591
592#[cfg(unix)]
594fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
595 use std::os::unix::fs::MetadataExt as _;
596 Some((meta.dev(), meta.ino(), meta.nlink()))
597}
598
599#[cfg(windows)]
603fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
604 use std::os::windows::fs::OpenOptionsExt as _;
605 use std::os::windows::io::AsRawHandle as _;
606 use windows_sys::Win32::Storage::FileSystem::{
607 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
608 };
609
610 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
613 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
614 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
617 return None;
618 }
619 Some((
620 u64::from(info.dwVolumeSerialNumber),
621 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
622 u64::from(info.nNumberOfLinks),
623 ))
624}
625
626#[cfg(not(any(unix, windows)))]
627fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
628 None
629}
630
631pub fn resolve_program(program: &str) -> String {
641 #[cfg(windows)]
642 {
643 if Path::new(program).components().count() > 1 {
644 return program.to_string();
645 }
646 let Some(path_var) = std::env::var_os("PATH") else {
647 return program.to_string();
648 };
649 for dir in std::env::split_paths(&path_var) {
650 for ext in ["exe", "cmd", "bat"] {
651 let candidate = dir.join(format!("{program}.{ext}"));
652 if candidate.is_file() {
653 return candidate.to_string_lossy().into_owned();
654 }
655 }
656 }
657 }
658 program.to_string()
659}
660
661pub fn binary_available(program: &str) -> bool {
669 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
670 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
671
672 let mut guard = match cache.lock() {
675 Ok(g) => g,
676 Err(_) => return probe_binary(program),
679 };
680 if let Some(known) = guard.get(program) {
681 return *known;
682 }
683 let available = probe_binary(program);
684 guard.insert(program.to_string(), available);
685 available
686}
687
688const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
696
697fn version_probe_args(program: &str) -> &'static [&'static str] {
699 VERSION_PROBE_ARGS
700 .iter()
701 .find(|(name, _)| *name == program)
702 .map_or(&["--version"], |(_, args)| *args)
703}
704
705fn probe_binary(program: &str) -> bool {
707 crate::spawn::command(resolve_program(program))
708 .args(version_probe_args(program))
709 .stdin(std::process::Stdio::null())
710 .output()
711 .map(|o| o.status.success())
712 .unwrap_or(false)
713}
714
715struct CommandOutput {
717 status: std::process::ExitStatus,
718 stdout: String,
719 stderr: String,
720}
721
722fn spawn_capture(
729 program: &str,
730 args: &[&str],
731 cwd: &Path,
732 timeout: std::time::Duration,
733) -> Result<CommandOutput> {
734 use std::io::Read;
735 use std::process::Stdio;
736 use std::thread;
737 use std::time::Instant;
738
739 let resolved = resolve_program(program);
740 let mut child = crate::spawn::command(&resolved)
741 .args(args)
742 .current_dir(cwd)
743 .stdin(Stdio::null())
744 .stdout(Stdio::piped())
745 .stderr(Stdio::piped())
746 .spawn()
747 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
748
749 let mut stdout_pipe = child.stdout.take();
753 let mut stderr_pipe = child.stderr.take();
754 let stdout_reader = thread::spawn(move || {
755 let mut buf = Vec::new();
756 if let Some(pipe) = stdout_pipe.as_mut() {
757 let _ = pipe.read_to_end(&mut buf);
758 }
759 buf
760 });
761 let stderr_reader = thread::spawn(move || {
762 let mut buf = Vec::new();
763 if let Some(pipe) = stderr_pipe.as_mut() {
764 let _ = pipe.read_to_end(&mut buf);
765 }
766 buf
767 });
768
769 let start = Instant::now();
770 let status = loop {
771 match child.try_wait()? {
772 Some(status) => break status,
773 None => {
774 if start.elapsed() >= timeout {
775 let _ = child.kill();
776 let _ = child.wait();
777 anyhow::bail!(
778 "Command timed out after {}s: {} {}\n\
779 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
780 timeout.as_secs(),
781 program,
782 args.join(" ")
783 );
784 }
785 thread::sleep(std::time::Duration::from_millis(100));
786 }
787 }
788 };
789
790 let stderr = stderr_reader
791 .join()
792 .map(|b| String::from_utf8_lossy(&b).into_owned())
793 .unwrap_or_default();
794 let stdout = stdout_reader
795 .join()
796 .map(|b| String::from_utf8_lossy(&b).into_owned())
797 .unwrap_or_default();
798
799 Ok(CommandOutput {
800 status,
801 stdout,
802 stderr,
803 })
804}
805
806pub fn run_command_with_timeout(
808 program: &str,
809 args: &[&str],
810 cwd: &Path,
811 timeout: std::time::Duration,
812) -> Result<()> {
813 let out = spawn_capture(program, args, cwd, timeout)?;
814 if out.status.success() {
815 Ok(())
816 } else {
817 anyhow::bail!(
818 "{} {} failed (exit code {:?}):\n{}",
819 program,
820 args.join(" "),
821 out.status.code(),
822 crate::output::condense_tool_output(
823 &out.stderr,
824 crate::constants::TOOL_OUTPUT_MAX_LINES
825 )
826 )
827 }
828}
829
830pub fn capture_command_with_timeout(
836 program: &str,
837 args: &[&str],
838 cwd: &Path,
839 timeout: std::time::Duration,
840) -> Result<String> {
841 let out = spawn_capture(program, args, cwd, timeout)?;
842 if out.status.success() {
843 Ok(out.stdout)
844 } else {
845 anyhow::bail!(
846 "{} {} failed (exit code {:?}):\n{}",
847 program,
848 args.join(" "),
849 out.status.code(),
850 crate::output::condense_tool_output(
851 &out.stderr,
852 crate::constants::TOOL_OUTPUT_MAX_LINES
853 )
854 )
855 }
856}
857
858pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
860 crate::spawn::command(resolve_program(program))
861 .args(args)
862 .current_dir(cwd)
863 .stdin(std::process::Stdio::null())
864 .output()
865 .map(|o| o.status.success())
866 .unwrap_or(false)
867}
868
869const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
877
878fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
885 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
886 Some("Cargo.lock") => "Cargo.toml",
887 Some("package-lock.json")
888 | Some("yarn.lock")
889 | Some("pnpm-lock.yaml")
890 | Some("bun.lockb")
891 | Some("bun.lock") => "package.json",
892 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
893 Some("go.sum") => "go.mod",
894 Some("composer.lock") => "composer.json",
895 Some("Gemfile.lock") => "Gemfile",
896 Some("Pipfile.lock") => "Pipfile",
897 _ => return Ok(()),
898 };
899 let manifest = cwd.join(manifest_name);
900 let (Ok(manifest_meta), Ok(lock_meta)) =
901 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
902 else {
903 return Ok(());
904 };
905 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
906 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
907 {
908 anyhow::bail!(
909 "`{program}` is not available, and `{manifest_name}` has been edited more \
910 recently than `{}` — the lockfile may no longer record the current \
911 dependencies, and without `{program}` that cannot be verified. Install \
912 {program} and run its lockfile sync, then prune again.",
913 lockfile.display()
914 );
915 }
916 Ok(())
917}
918
919pub fn refuse_if_manifest_stale(
930 manifest: &Path,
931 lockfile: &Path,
932 sync_command: &str,
933) -> Result<()> {
934 let (Ok(manifest_meta), Ok(lock_meta)) =
935 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
936 else {
937 return Ok(());
938 };
939 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
940 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
941 {
942 anyhow::bail!(
943 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
944 record the current dependencies. Run `{sync_command}` and prune again.",
945 manifest.display(),
946 lockfile.display()
947 );
948 }
949 Ok(())
950}
951
952pub fn lock_sync_or_verify_with_timeout(
954 lockfile: &Path,
955 program: &str,
956 sync_args: &[&str],
957 cwd: &Path,
958 timeout: std::time::Duration,
959) -> Result<()> {
960 let lockfile_exists = lockfile.exists();
961
962 if !binary_available(program) {
963 if lockfile_exists {
964 refuse_if_manifest_newer(lockfile, program, cwd)?;
965 return Ok(());
966 } else {
967 anyhow::bail!(
968 "`{program}` is not available and no lockfile was found at `{}`. \
969 Cannot safely delete dependencies — install {program} first, \
970 or commit a lockfile.",
971 lockfile.display()
972 );
973 }
974 }
975
976 run_command_with_timeout(program, sync_args, cwd, timeout)
978}
979
980#[derive(Debug, Clone, Copy)]
986pub struct EnforcePolicy {
987 pub allow_rewrite: bool,
993 pub timeout: std::time::Duration,
995}
996
997impl Default for EnforcePolicy {
998 fn default() -> Self {
999 Self {
1000 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1001 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1002 }
1003 }
1004}
1005
1006impl EnforcePolicy {
1007 pub fn from_settings(settings: &crate::config::Settings) -> Self {
1009 Self {
1010 allow_rewrite: settings.allow_manifest_rewrite,
1011 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
1012 }
1013 }
1014}
1015
1016pub fn enforce_two_tier(
1032 lockfile: &Path,
1033 program: &str,
1034 verify_args: &[&str],
1035 write_args: &[&str],
1036 cwd: &Path,
1037 policy: EnforcePolicy,
1038) -> Result<()> {
1039 if policy.allow_rewrite {
1040 return lock_sync_or_verify_with_timeout(
1041 lockfile,
1042 program,
1043 write_args,
1044 cwd,
1045 policy.timeout,
1046 );
1047 }
1048 lock_verify_or_generate(
1049 lockfile,
1050 program,
1051 verify_args,
1052 write_args,
1053 cwd,
1054 policy.timeout,
1055 )
1056}
1057
1058pub fn lock_verify_or_generate(
1068 lockfile: &Path,
1069 program: &str,
1070 verify_args: &[&str],
1071 generate_args: &[&str],
1072 cwd: &Path,
1073 timeout: std::time::Duration,
1074) -> Result<()> {
1075 let lockfile_exists = lockfile.exists();
1076
1077 if !binary_available(program) {
1078 if lockfile_exists {
1079 refuse_if_manifest_newer(lockfile, program, cwd)?;
1080 return Ok(());
1081 }
1082 anyhow::bail!(
1083 "`{program}` is not available and no lockfile was found at `{}`. \
1084 Cannot safely delete dependencies — install {program} first, \
1085 or commit a lockfile.",
1086 lockfile.display()
1087 );
1088 }
1089
1090 if lockfile_exists {
1091 run_command_with_timeout(program, verify_args, cwd, timeout)
1092 } else {
1093 run_command_with_timeout(program, generate_args, cwd, timeout)
1094 }
1095}
1096
1097pub fn lock_sync_or_verify(
1099 lockfile: &Path,
1100 program: &str,
1101 sync_args: &[&str],
1102 cwd: &Path,
1103) -> Result<()> {
1104 lock_sync_or_verify_with_timeout(
1105 lockfile,
1106 program,
1107 sync_args,
1108 cwd,
1109 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1110 )
1111}
1112
1113const PYVENV_CFG: &str = "pyvenv.cfg";
1119
1120pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1127 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1128 for line in cfg.lines() {
1129 let Some((key, value)) = line.split_once('=') else {
1130 continue;
1131 };
1132 if matches!(key.trim(), "version" | "version_info") {
1133 let mut parts = value.trim().split('.');
1134 let major: u64 = parts.next()?.parse().ok()?;
1135 let minor: u64 = parts.next()?.parse().ok()?;
1136 return Some(format!("{major}.{minor}"));
1137 }
1138 }
1139 None
1140}
1141
1142pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1146 let mut parts = tag.split('.');
1147 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1148 return false;
1149 };
1150 !major.is_empty()
1151 && !minor.is_empty()
1152 && major.len() <= 2
1153 && minor.len() <= 3
1154 && major.bytes().all(|b| b.is_ascii_digit())
1155 && minor.bytes().all(|b| b.is_ascii_digit())
1156}
1157
1158pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1166 if !is_valid_runtime_tag(tag) {
1167 return None;
1168 }
1169 #[cfg(windows)]
1170 {
1171 Some(("py".to_string(), vec![format!("-{tag}")]))
1172 }
1173 #[cfg(not(windows))]
1174 {
1175 Some((format!("python{tag}"), Vec::new()))
1176 }
1177}
1178
1179pub(crate) fn python_executable(tag: &str) -> Option<String> {
1187 let (program, prefix) = python_launcher(tag)?;
1188 let out = crate::spawn::command(resolve_program(&program))
1189 .args(&prefix)
1190 .args(["-c", "import sys; print(sys.executable)"])
1191 .stdin(std::process::Stdio::null())
1192 .stderr(std::process::Stdio::null())
1193 .output()
1194 .ok()?;
1195 if !out.status.success() {
1196 return None;
1197 }
1198 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1199 (!path.is_empty()).then_some(path)
1200}
1201
1202pub(crate) fn python_runtime_available(tag: &str) -> bool {
1207 let Some((program, prefix)) = python_launcher(tag) else {
1208 return false;
1209 };
1210 crate::spawn::command(resolve_program(&program))
1211 .args(&prefix)
1212 .arg("--version")
1213 .stdin(std::process::Stdio::null())
1214 .stdout(std::process::Stdio::null())
1215 .stderr(std::process::Stdio::null())
1216 .status()
1217 .is_ok_and(|s| s.success())
1218}
1219
1220const NO_RESTORE_BINARY: [&str; 5] = ["venv", "gradle", "maven", "mix_build", "swift"];
1221
1222const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1224
1225pub fn adapter_binary(adapter: &str) -> &str {
1227 ADAPTER_BINARIES
1228 .iter()
1229 .find(|(name, _)| *name == adapter)
1230 .map_or(adapter, |(_, binary)| *binary)
1231}
1232
1233const INSTALL_HINTS: [(&str, &str); 16] = [
1238 ("npm", "ships with Node.js — https://nodejs.org"),
1239 (
1240 "pnpm",
1241 "`npm install -g pnpm` — https://pnpm.io/installation",
1242 ),
1243 (
1244 "yarn",
1245 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1246 ),
1247 ("bun", "https://bun.sh/docs/installation"),
1248 (
1249 "uv",
1250 "https://docs.astral.sh/uv/getting-started/installation/",
1251 ),
1252 ("poetry", "https://python-poetry.org/docs/#installation"),
1253 (
1254 "pdm",
1255 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1256 ),
1257 (
1258 "pipenv",
1259 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1260 ),
1261 ("cargo", "ships with Rust — https://rustup.rs"),
1262 ("go", "https://go.dev/dl/"),
1263 ("composer", "https://getcomposer.org/download/"),
1264 ("bundler", "`gem install bundler` — https://bundler.io"),
1265 (
1266 "cocoapods",
1267 "`gem install cocoapods` — https://cocoapods.org",
1268 ),
1269 (
1270 "mix",
1271 "ships with Elixir — https://elixir-lang.org/install.html",
1272 ),
1273 (
1274 "terraform",
1275 "https://developer.hashicorp.com/terraform/install",
1276 ),
1277 (
1278 "dart",
1279 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1280 ),
1281];
1282
1283pub fn install_hint(adapter: &str) -> Option<&'static str> {
1285 INSTALL_HINTS
1286 .iter()
1287 .find(|(name, _)| *name == adapter)
1288 .map(|(_, hint)| *hint)
1289}
1290
1291#[derive(Debug, Clone)]
1293pub struct BinaryCheckStatus {
1294 pub name: String,
1295 pub available: bool,
1296 pub version: Option<String>,
1297}
1298
1299pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1301 let mut unique: Vec<String> = adapter_names
1302 .iter()
1303 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1306 .cloned()
1307 .collect();
1308 unique.sort();
1309 unique.dedup();
1310
1311 unique
1312 .into_iter()
1313 .map(|name| {
1314 let binary = adapter_binary(&name);
1315 let output = crate::spawn::command(resolve_program(binary))
1316 .args(version_probe_args(binary))
1317 .stdin(std::process::Stdio::null())
1318 .output();
1319 match output {
1320 Ok(out) if out.status.success() => {
1321 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1322 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1323 BinaryCheckStatus {
1324 name,
1325 available: true,
1326 version: if first_line.is_empty() {
1327 None
1328 } else {
1329 Some(first_line)
1330 },
1331 }
1332 }
1333 _ => BinaryCheckStatus {
1334 name,
1335 available: false,
1336 version: None,
1337 },
1338 }
1339 })
1340 .collect()
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use std::fs;
1347 use tempfile::TempDir;
1348
1349 #[test]
1350 fn every_adapter_is_grouped_exactly_once() {
1351 let registered = all_adapter_names();
1354 let grouped: Vec<&str> = ADAPTER_GROUPS
1355 .iter()
1356 .flat_map(|(_, names)| names.iter().copied())
1357 .collect();
1358
1359 for name in ®istered {
1360 assert_eq!(
1361 grouped.iter().filter(|g| *g == name).count(),
1362 1,
1363 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1364 );
1365 }
1366 for name in &grouped {
1367 assert!(
1368 registered.contains(name),
1369 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1370 );
1371 }
1372 assert_eq!(registered.len(), grouped.len());
1373 }
1374
1375 #[test]
1376 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1377 let mut opt_in = opt_in_adapter_names();
1382 opt_in.sort_unstable();
1383 assert_eq!(
1384 opt_in,
1385 vec!["cargo", "dart", "gradle", "maven", "mix_build", "swift"]
1386 );
1387 }
1388
1389 #[test]
1390 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1391 assert_eq!(version_probe_args("go"), &["version"]);
1396 assert_eq!(version_probe_args("npm"), &["--version"]);
1397 }
1398
1399 #[test]
1400 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1401 for adapter in get_all_adapters() {
1404 let name = adapter.name();
1405 if NO_RESTORE_BINARY.contains(&name) {
1406 continue;
1407 }
1408 assert!(
1409 install_hint(name).is_some(),
1410 "adapter `{name}` has no install hint"
1411 );
1412 }
1413 }
1414
1415 #[test]
1416 fn test_bloat_dir_display() {
1417 let bd = BloatDir {
1418 name: "node_modules".to_string(),
1419 path: PathBuf::from("/test/node_modules"),
1420 size_bytes: 1024,
1421 shared_bytes: 0,
1422 };
1423 assert!(bd.to_string().contains("node_modules"));
1424 }
1425
1426 #[test]
1427 fn test_hardlink_size_counts_a_plain_file_in_full() {
1428 let tmp = TempDir::new().unwrap();
1429 let tree = tmp.path().join("tree");
1430 fs::create_dir(&tree).unwrap();
1431 fs::write(tree.join("copied.txt"), "12345").unwrap();
1432 let size = dir_size_with_hardlinks(&tree);
1433 assert_eq!(size.freed_bytes, 5);
1434 assert_eq!(size.shared_bytes, 0);
1435 }
1436
1437 #[test]
1438 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1439 let tmp = TempDir::new().unwrap();
1442 let store = tmp.path().join("store");
1443 let tree = tmp.path().join("tree");
1444 fs::create_dir(&store).unwrap();
1445 fs::create_dir(&tree).unwrap();
1446 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1447 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1448 let size = dir_size_with_hardlinks(&tree);
1449 assert_eq!(size.freed_bytes, 0);
1450 assert_eq!(size.shared_bytes, 10);
1451 }
1452
1453 #[test]
1454 fn test_hardlink_size_counts_an_internal_pair_once() {
1455 let tmp = TempDir::new().unwrap();
1458 let tree = tmp.path().join("tree");
1459 fs::create_dir(&tree).unwrap();
1460 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1461 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1462 let size = dir_size_with_hardlinks(&tree);
1463 assert_eq!(size.freed_bytes, 7);
1464 assert_eq!(size.shared_bytes, 0);
1465 }
1466
1467 #[test]
1468 fn test_dir_size_empty() {
1469 let tmp = TempDir::new().unwrap();
1470 assert_eq!(dir_size(tmp.path()), 0);
1471 }
1472
1473 #[test]
1474 fn test_dir_size_with_files() {
1475 let tmp = TempDir::new().unwrap();
1476 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1477 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1478 assert_eq!(dir_size(tmp.path()), 11); }
1480
1481 #[test]
1482 fn test_dir_size_nonexistent() {
1483 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1484 }
1485
1486 #[test]
1487 fn test_get_all_adapters_not_empty() {
1488 let adapters = get_all_adapters();
1489 assert!(adapters.len() >= 6);
1490 }
1491
1492 #[test]
1493 fn test_detect_adapters_npm() {
1494 let tmp = TempDir::new().unwrap();
1495 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1496 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1497 let adapters = detect_adapters(tmp.path());
1498 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1499 assert!(names.contains(&"npm"));
1500 }
1501
1502 #[test]
1503 fn test_detect_adapters_empty_dir() {
1504 let tmp = TempDir::new().unwrap();
1505 let adapters = detect_adapters(tmp.path());
1506 assert!(adapters.is_empty());
1507 }
1508
1509 fn detected_names(dir: &Path) -> Vec<&'static str> {
1515 let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1516 .iter()
1517 .map(|a| a.name())
1518 .collect();
1519 names.sort_unstable();
1520 names
1521 }
1522
1523 #[test]
1524 fn test_detect_adapters_multiple_ecosystems_coexist() {
1525 let tmp = TempDir::new().unwrap();
1527 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1528 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1529 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1530 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1531 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1532
1533 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1538 }
1539
1540 #[test]
1541 fn test_detect_adapters_opt_in_appears_once_enabled() {
1542 let tmp = TempDir::new().unwrap();
1545 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1546
1547 let on = [String::from("cargo")];
1548 let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1549 .iter()
1550 .map(|a| a.name())
1551 .collect();
1552 names.sort_unstable();
1553 assert_eq!(names, vec!["cargo"]);
1554 }
1555
1556 #[test]
1557 fn test_detect_adapters_disabled_adapter_is_invisible() {
1558 let tmp = TempDir::new().unwrap();
1560 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1561
1562 let off = [String::from("go")];
1563 assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1564 }
1565
1566 #[test]
1567 fn test_js_conflict_resolved_by_package_manager_field() {
1568 let tmp = TempDir::new().unwrap();
1569 fs::write(
1570 tmp.path().join("package.json"),
1571 r#"{"packageManager":"yarn@4.1.0"}"#,
1572 )
1573 .unwrap();
1574 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1575 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1576 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1577
1578 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1579 }
1580
1581 #[test]
1582 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1583 let tmp = TempDir::new().unwrap();
1586 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1587 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1588 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1589 std::thread::sleep(std::time::Duration::from_millis(20));
1590 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1591
1592 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1593 }
1594
1595 #[test]
1596 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1597 let tmp = TempDir::new().unwrap();
1599 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1600 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1601 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1602 let nm = tmp.path().join("node_modules");
1603 fs::create_dir_all(&nm).unwrap();
1604 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1605 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1606
1607 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1608 }
1609
1610 #[test]
1611 fn test_declared_package_manager_outranks_what_is_installed() {
1612 let tmp = TempDir::new().unwrap();
1614 fs::write(
1615 tmp.path().join("package.json"),
1616 r#"{"packageManager":"pnpm@9.1.0"}"#,
1617 )
1618 .unwrap();
1619 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1620 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1621 let nm = tmp.path().join("node_modules");
1622 fs::create_dir_all(&nm).unwrap();
1623 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1624
1625 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1626 }
1627
1628 #[test]
1629 fn test_uv_takes_precedence_over_plain_venv() {
1630 let tmp = TempDir::new().unwrap();
1633 fs::write(
1634 tmp.path().join("pyproject.toml"),
1635 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1636 )
1637 .unwrap();
1638 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1639 let venv = tmp.path().join(".venv");
1640 fs::create_dir_all(&venv).unwrap();
1641 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1642
1643 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1644 }
1645
1646 #[test]
1647 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1648 let tmp = TempDir::new().unwrap();
1649 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1650 let venv = tmp.path().join("venv");
1651 fs::create_dir_all(&venv).unwrap();
1652 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1653
1654 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1655 }
1656
1657 #[test]
1658 fn test_js_conflict_falls_back_to_newest_lockfile() {
1659 let tmp = TempDir::new().unwrap();
1660 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1661 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1662 std::thread::sleep(std::time::Duration::from_millis(20));
1665 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1666
1667 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1668 }
1669
1670 #[test]
1671 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1672 let tmp = TempDir::new().unwrap();
1675 fs::write(
1676 tmp.path().join("package.json"),
1677 r#"{"packageManager":"deno@2.0.0"}"#,
1678 )
1679 .unwrap();
1680 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1681 std::thread::sleep(std::time::Duration::from_millis(20));
1682 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1683
1684 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1685 }
1686
1687 #[test]
1688 fn test_js_conflict_does_not_disturb_a_single_manager() {
1689 let tmp = TempDir::new().unwrap();
1690 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1691 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1692 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1693 }
1694
1695 #[test]
1696 fn test_js_adapters_declare_their_lockfiles() {
1697 for adapter in get_all_adapters() {
1698 if JS_MANAGERS.contains(&adapter.name()) {
1699 assert!(
1700 !adapter.lockfiles().is_empty(),
1701 "{} shares node_modules and must declare its lockfiles for \
1702 conflict resolution",
1703 adapter.name()
1704 );
1705 }
1706 }
1707 }
1708
1709 #[test]
1710 fn test_adapter_names_unique() {
1711 let adapters = get_all_adapters();
1712 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1713 let mut unique = names.clone();
1714 unique.sort();
1715 unique.dedup();
1716 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1717 }
1718
1719 #[test]
1720 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1721 assert!(is_valid_runtime_tag("3.12"));
1724 assert!(is_valid_runtime_tag("3.9"));
1725 for bad in [
1726 "",
1727 "3",
1728 "3.12.1",
1729 "3.x",
1730 "3.12; rm -rf /",
1731 "-3.12",
1732 "../python",
1733 "3.1234",
1734 "300.1",
1735 ] {
1736 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1737 }
1738 }
1739
1740 #[test]
1741 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1742 let tmp = tempfile::tempdir().unwrap();
1743 let venv = tmp.path().join(".venv");
1744 std::fs::create_dir_all(&venv).unwrap();
1745 std::fs::write(
1746 venv.join("pyvenv.cfg"),
1747 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1748 )
1749 .unwrap();
1750 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1751 }
1752
1753 #[test]
1754 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1755 let tmp = tempfile::tempdir().unwrap();
1756 assert_eq!(venv_runtime_tag(tmp.path()), None);
1757 }
1758}