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 npm;
30pub mod pdm;
31pub mod pipenv;
32pub mod pnpm;
33pub mod poetry;
34pub mod swift;
35pub mod terraform;
36pub mod uv;
37pub mod venv;
38pub mod yarn;
39
40use std::collections::HashMap;
41use std::fmt;
42use std::path::{Path, PathBuf};
43use std::sync::{Mutex, OnceLock};
44
45use anyhow::{Context as _, Result};
46use walkdir::WalkDir;
47
48#[derive(Debug, Clone)]
50pub struct BloatDir {
51 pub name: String,
53 pub path: PathBuf,
55 pub size_bytes: u64,
57 pub shared_bytes: u64,
61}
62
63impl fmt::Display for BloatDir {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 write!(f, "{} ({})", self.name, self.path.display())
66 }
67}
68
69#[derive(Debug, Clone)]
72pub struct DriftReport {
73 pub directory: String,
75 pub unrecorded: Vec<String>,
77 pub record_command: &'static str,
79}
80
81pub trait PackageManager: Send + Sync {
89 fn name(&self) -> &'static str;
91
92 fn detect(&self, project_path: &Path) -> bool;
96
97 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
101
102 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
109
110 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
117
118 fn restore_named(
129 &self,
130 project_path: &Path,
131 dir_name: &str,
132 runtime: Option<&str>,
133 timeout: std::time::Duration,
134 ) -> Result<()> {
135 let _ = (dir_name, runtime);
136 self.restore(project_path, timeout)
137 }
138
139 fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
149 let _ = (project_path, dir_name);
150 None
151 }
152
153 fn lockfiles(&self) -> &'static [&'static str] {
163 &[]
164 }
165
166 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
174 let _ = project_path;
175 Vec::new()
176 }
177
178 fn opt_in(&self) -> bool {
190 false
191 }
192}
193
194const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
196
197const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
205 ("pnpm", &[".pnpm", ".modules.yaml"]),
206 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
207 ("npm", &[".package-lock.json"]),
208];
209
210pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
214 vec![
215 Box::new(npm::Npm),
216 Box::new(pnpm::Pnpm),
217 Box::new(yarn::Yarn),
218 Box::new(bun::Bun),
219 Box::new(uv::Uv),
220 Box::new(poetry::Poetry),
221 Box::new(pdm::Pdm),
222 Box::new(pipenv::Pipenv),
223 Box::new(venv::Venv),
224 Box::new(cargo_adapter::Cargo),
225 Box::new(go::Go),
226 Box::new(composer::Composer),
227 Box::new(bundler::Bundler),
228 Box::new(cocoapods::CocoaPods),
229 Box::new(mix::Mix),
230 Box::new(gradle::Gradle),
231 Box::new(maven::Maven),
232 Box::new(swift::Swift),
233 Box::new(terraform::Terraform),
234 Box::new(dart::Dart),
235 ]
236}
237
238fn opt_in_enabled() -> &'static [String] {
246 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
247 ENABLED.get_or_init(|| {
248 crate::config::Registry::load()
249 .map(|r| {
250 let mut names = Vec::new();
251 if r.settings.enable_cargo {
252 names.push("cargo".to_string());
253 }
254 if r.settings.enable_gradle {
255 names.push("gradle".to_string());
256 }
257 if r.settings.enable_maven {
258 names.push("maven".to_string());
259 }
260 if r.settings.enable_swift {
261 names.push("swift".to_string());
262 }
263 if r.settings.enable_dart {
264 names.push("dart".to_string());
265 }
266 names
267 })
268 .unwrap_or_default()
269 })
270}
271
272fn user_disabled() -> &'static [String] {
279 static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
280 DISABLED.get_or_init(|| {
281 crate::config::Registry::load()
282 .map(|r| {
283 r.settings
284 .disabled_adapters
285 .iter()
286 .map(|n| n.trim().to_ascii_lowercase())
287 .filter(|n| !n.is_empty())
288 .collect()
289 })
290 .unwrap_or_default()
291 })
292}
293
294pub fn is_adapter_name(name: &str) -> bool {
296 get_all_adapters().iter().any(|a| a.name() == name)
297}
298
299pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
312 ("JavaScript", &["npm", "pnpm", "yarn", "bun"]),
313 ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
314 ("Rust", &["cargo"]),
315 ("Go", &["go"]),
316 ("JVM", &["gradle", "maven"]),
317 ("PHP", &["composer"]),
318 ("Ruby", &["bundler"]),
319 ("Swift & Objective-C", &["swift", "cocoapods"]),
320 ("Elixir", &["mix"]),
321 ("Infrastructure", &["terraform"]),
322 ("Dart & Flutter", &["dart"]),
323];
324
325pub fn adapter_group(name: &str) -> &'static str {
331 ADAPTER_GROUPS
332 .iter()
333 .find(|(_, names)| names.contains(&name))
334 .map(|(group, _)| *group)
335 .unwrap_or("Other")
336}
337
338pub fn all_adapter_names() -> Vec<&'static str> {
340 get_all_adapters().iter().map(|a| a.name()).collect()
341}
342
343pub fn opt_in_adapter_names() -> Vec<&'static str> {
348 get_all_adapters()
349 .iter()
350 .filter(|a| a.opt_in())
351 .map(|a| a.name())
352 .collect()
353}
354
355pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
362 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
363 .into_iter()
364 .filter(|adapter| !adapter.opt_in() || opt_in_enabled().iter().any(|n| n == adapter.name()))
365 .filter(|adapter| !user_disabled().iter().any(|n| n == adapter.name()))
366 .filter(|adapter| adapter.detect(project_path))
367 .collect();
368 resolve_conflicts(project_path, &mut detected);
369 detected
370}
371
372fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
374 resolve_js_conflict(project_path, detected);
375 resolve_python_conflict(project_path, detected);
376}
377
378fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
391 if detected
392 .iter()
393 .filter(|a| JS_MANAGERS.contains(&a.name()))
394 .count()
395 < 2
396 {
397 return;
398 }
399
400 let winner = declared_package_manager(project_path)
401 .filter(|name| detected.iter().any(|a| a.name() == name))
402 .or_else(|| installed_manager(project_path, detected))
403 .or_else(|| newest_lockfile_owner(project_path, detected));
404
405 let Some(winner) = winner else { return };
406 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
407}
408
409const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
422 ("uv", "uv.lock"),
423 ("poetry", "poetry.lock"),
424 ("pdm", "pdm.lock"),
425 ("pipenv", "Pipfile.lock"),
426];
427
428fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
429 let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
430 .iter()
431 .copied()
432 .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
433 .collect();
434 let Some(&(first, _)) = claimants.first() else {
435 return;
436 };
437 detected.retain(|a| a.name() != "venv");
438 if claimants.len() < 2 {
439 return;
440 }
441 let winner = claimants
442 .iter()
443 .find(|(_, lockfile)| project_path.join(lockfile).exists())
444 .map_or(first, |(name, _)| *name);
445 detected.retain(|a| {
446 a.name() == winner
447 || !PYTHON_ENV_MANAGERS
448 .iter()
449 .any(|(name, _)| *name == a.name())
450 });
451}
452
453fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
455 let node_modules = project_path.join("node_modules");
456 if !node_modules.is_dir() {
457 return None;
458 }
459
460 JS_INSTALL_MARKERS
461 .iter()
462 .find(|(name, markers)| {
463 detected.iter().any(|a| a.name() == *name)
464 && markers.iter().any(|m| node_modules.join(m).exists())
465 })
466 .map(|(name, _)| (*name).to_string())
467}
468
469fn declared_package_manager(project_path: &Path) -> Option<String> {
471 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
472 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
473 let declared = json.get("packageManager")?.as_str()?;
474 let name = declared.split('@').next().unwrap_or_default();
475 JS_MANAGERS
476 .iter()
477 .find(|m| **m == name)
478 .map(|m| (*m).to_string())
479}
480
481fn newest_lockfile_owner(
483 project_path: &Path,
484 detected: &[Box<dyn PackageManager>],
485) -> Option<String> {
486 detected
487 .iter()
488 .filter(|a| JS_MANAGERS.contains(&a.name()))
489 .filter_map(|a| {
490 let newest = a
491 .lockfiles()
492 .iter()
493 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
494 .max()?;
495 Some((newest, a.name().to_string()))
496 })
497 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
500 match best {
501 Some(b) if b.0 >= cur.0 => Some(b),
502 _ => Some(cur),
503 }
504 })
505 .map(|(_, name)| name)
506}
507
508pub fn dir_size(path: &Path) -> u64 {
510 if !path.exists() {
511 return 0;
512 }
513 WalkDir::new(path)
514 .follow_links(false)
515 .into_iter()
516 .flatten()
517 .filter_map(|entry| entry.metadata().ok())
518 .filter(|meta| meta.is_file())
519 .map(|meta| meta.len())
520 .sum()
521}
522
523#[derive(Debug, Clone, Copy, Default)]
525pub struct DirSizeBreakdown {
526 pub freed_bytes: u64,
528 pub shared_bytes: u64,
531}
532
533pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
544 let mut out = DirSizeBreakdown::default();
545 if !path.exists() {
546 return out;
547 }
548 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
550 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
551 let Ok(meta) = entry.metadata() else { continue };
552 if !meta.is_file() {
553 continue;
554 }
555 match file_link_identity(entry.path(), &meta) {
556 Some((dev, ino, nlink)) if nlink > 1 => {
557 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
558 }
559 _ => out.freed_bytes += meta.len(),
560 }
561 }
562 for (bytes, nlink, seen) in linked.into_values() {
563 if seen >= nlink {
564 out.freed_bytes += bytes;
565 } else {
566 out.shared_bytes += bytes;
567 }
568 }
569 out
570}
571
572#[cfg(unix)]
574fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
575 use std::os::unix::fs::MetadataExt as _;
576 Some((meta.dev(), meta.ino(), meta.nlink()))
577}
578
579#[cfg(windows)]
583fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
584 use std::os::windows::fs::OpenOptionsExt as _;
585 use std::os::windows::io::AsRawHandle as _;
586 use windows_sys::Win32::Storage::FileSystem::{
587 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
588 };
589
590 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
593 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
594 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
597 return None;
598 }
599 Some((
600 u64::from(info.dwVolumeSerialNumber),
601 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
602 u64::from(info.nNumberOfLinks),
603 ))
604}
605
606#[cfg(not(any(unix, windows)))]
607fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
608 None
609}
610
611pub fn resolve_program(program: &str) -> String {
621 #[cfg(windows)]
622 {
623 if Path::new(program).components().count() > 1 {
624 return program.to_string();
625 }
626 let Some(path_var) = std::env::var_os("PATH") else {
627 return program.to_string();
628 };
629 for dir in std::env::split_paths(&path_var) {
630 for ext in ["exe", "cmd", "bat"] {
631 let candidate = dir.join(format!("{program}.{ext}"));
632 if candidate.is_file() {
633 return candidate.to_string_lossy().into_owned();
634 }
635 }
636 }
637 }
638 program.to_string()
639}
640
641pub fn binary_available(program: &str) -> bool {
649 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
650 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
651
652 let mut guard = match cache.lock() {
655 Ok(g) => g,
656 Err(_) => return probe_binary(program),
659 };
660 if let Some(known) = guard.get(program) {
661 return *known;
662 }
663 let available = probe_binary(program);
664 guard.insert(program.to_string(), available);
665 available
666}
667
668const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
676
677fn version_probe_args(program: &str) -> &'static [&'static str] {
679 VERSION_PROBE_ARGS
680 .iter()
681 .find(|(name, _)| *name == program)
682 .map_or(&["--version"], |(_, args)| *args)
683}
684
685fn probe_binary(program: &str) -> bool {
687 crate::spawn::command(resolve_program(program))
688 .args(version_probe_args(program))
689 .stdin(std::process::Stdio::null())
690 .output()
691 .map(|o| o.status.success())
692 .unwrap_or(false)
693}
694
695struct CommandOutput {
697 status: std::process::ExitStatus,
698 stdout: String,
699 stderr: String,
700}
701
702fn spawn_capture(
709 program: &str,
710 args: &[&str],
711 cwd: &Path,
712 timeout: std::time::Duration,
713) -> Result<CommandOutput> {
714 use std::io::Read;
715 use std::process::Stdio;
716 use std::thread;
717 use std::time::Instant;
718
719 let resolved = resolve_program(program);
720 let mut child = crate::spawn::command(&resolved)
721 .args(args)
722 .current_dir(cwd)
723 .stdin(Stdio::null())
724 .stdout(Stdio::piped())
725 .stderr(Stdio::piped())
726 .spawn()
727 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
728
729 let mut stdout_pipe = child.stdout.take();
733 let mut stderr_pipe = child.stderr.take();
734 let stdout_reader = thread::spawn(move || {
735 let mut buf = Vec::new();
736 if let Some(pipe) = stdout_pipe.as_mut() {
737 let _ = pipe.read_to_end(&mut buf);
738 }
739 buf
740 });
741 let stderr_reader = thread::spawn(move || {
742 let mut buf = Vec::new();
743 if let Some(pipe) = stderr_pipe.as_mut() {
744 let _ = pipe.read_to_end(&mut buf);
745 }
746 buf
747 });
748
749 let start = Instant::now();
750 let status = loop {
751 match child.try_wait()? {
752 Some(status) => break status,
753 None => {
754 if start.elapsed() >= timeout {
755 let _ = child.kill();
756 let _ = child.wait();
757 anyhow::bail!(
758 "Command timed out after {}s: {} {}\n\
759 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
760 timeout.as_secs(),
761 program,
762 args.join(" ")
763 );
764 }
765 thread::sleep(std::time::Duration::from_millis(100));
766 }
767 }
768 };
769
770 let stderr = stderr_reader
771 .join()
772 .map(|b| String::from_utf8_lossy(&b).into_owned())
773 .unwrap_or_default();
774 let stdout = stdout_reader
775 .join()
776 .map(|b| String::from_utf8_lossy(&b).into_owned())
777 .unwrap_or_default();
778
779 Ok(CommandOutput {
780 status,
781 stdout,
782 stderr,
783 })
784}
785
786pub fn run_command_with_timeout(
788 program: &str,
789 args: &[&str],
790 cwd: &Path,
791 timeout: std::time::Duration,
792) -> Result<()> {
793 let out = spawn_capture(program, args, cwd, timeout)?;
794 if out.status.success() {
795 Ok(())
796 } else {
797 anyhow::bail!(
798 "{} {} failed (exit code {:?}):\n{}",
799 program,
800 args.join(" "),
801 out.status.code(),
802 crate::output::condense_tool_output(
803 &out.stderr,
804 crate::constants::TOOL_OUTPUT_MAX_LINES
805 )
806 )
807 }
808}
809
810pub fn capture_command_with_timeout(
816 program: &str,
817 args: &[&str],
818 cwd: &Path,
819 timeout: std::time::Duration,
820) -> Result<String> {
821 let out = spawn_capture(program, args, cwd, timeout)?;
822 if out.status.success() {
823 Ok(out.stdout)
824 } else {
825 anyhow::bail!(
826 "{} {} failed (exit code {:?}):\n{}",
827 program,
828 args.join(" "),
829 out.status.code(),
830 crate::output::condense_tool_output(
831 &out.stderr,
832 crate::constants::TOOL_OUTPUT_MAX_LINES
833 )
834 )
835 }
836}
837
838pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
840 crate::spawn::command(resolve_program(program))
841 .args(args)
842 .current_dir(cwd)
843 .stdin(std::process::Stdio::null())
844 .output()
845 .map(|o| o.status.success())
846 .unwrap_or(false)
847}
848
849const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
857
858fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
865 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
866 Some("Cargo.lock") => "Cargo.toml",
867 Some("package-lock.json")
868 | Some("yarn.lock")
869 | Some("pnpm-lock.yaml")
870 | Some("bun.lockb")
871 | Some("bun.lock") => "package.json",
872 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
873 Some("go.sum") => "go.mod",
874 Some("composer.lock") => "composer.json",
875 Some("Gemfile.lock") => "Gemfile",
876 Some("Pipfile.lock") => "Pipfile",
877 _ => return Ok(()),
878 };
879 let manifest = cwd.join(manifest_name);
880 let (Ok(manifest_meta), Ok(lock_meta)) =
881 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
882 else {
883 return Ok(());
884 };
885 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
886 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
887 {
888 anyhow::bail!(
889 "`{program}` is not available, and `{manifest_name}` has been edited more \
890 recently than `{}` — the lockfile may no longer record the current \
891 dependencies, and without `{program}` that cannot be verified. Install \
892 {program} and run its lockfile sync, then prune again.",
893 lockfile.display()
894 );
895 }
896 Ok(())
897}
898
899pub fn refuse_if_manifest_stale(
910 manifest: &Path,
911 lockfile: &Path,
912 sync_command: &str,
913) -> Result<()> {
914 let (Ok(manifest_meta), Ok(lock_meta)) =
915 (std::fs::metadata(manifest), std::fs::metadata(lockfile))
916 else {
917 return Ok(());
918 };
919 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
920 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
921 {
922 anyhow::bail!(
923 "`{}` has been edited more recently than `{}` — the lockfile may no longer \
924 record the current dependencies. Run `{sync_command}` and prune again.",
925 manifest.display(),
926 lockfile.display()
927 );
928 }
929 Ok(())
930}
931
932pub fn lock_sync_or_verify_with_timeout(
934 lockfile: &Path,
935 program: &str,
936 sync_args: &[&str],
937 cwd: &Path,
938 timeout: std::time::Duration,
939) -> Result<()> {
940 let lockfile_exists = lockfile.exists();
941
942 if !binary_available(program) {
943 if lockfile_exists {
944 refuse_if_manifest_newer(lockfile, program, cwd)?;
945 return Ok(());
946 } else {
947 anyhow::bail!(
948 "`{program}` is not available and no lockfile was found at `{}`. \
949 Cannot safely delete dependencies — install {program} first, \
950 or commit a lockfile.",
951 lockfile.display()
952 );
953 }
954 }
955
956 run_command_with_timeout(program, sync_args, cwd, timeout)
958}
959
960#[derive(Debug, Clone, Copy)]
966pub struct EnforcePolicy {
967 pub allow_rewrite: bool,
973 pub timeout: std::time::Duration,
975}
976
977impl Default for EnforcePolicy {
978 fn default() -> Self {
979 Self {
980 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
981 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
982 }
983 }
984}
985
986impl EnforcePolicy {
987 pub fn from_settings(settings: &crate::config::Settings) -> Self {
989 Self {
990 allow_rewrite: settings.allow_manifest_rewrite,
991 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
992 }
993 }
994}
995
996pub fn enforce_two_tier(
1012 lockfile: &Path,
1013 program: &str,
1014 verify_args: &[&str],
1015 write_args: &[&str],
1016 cwd: &Path,
1017 policy: EnforcePolicy,
1018) -> Result<()> {
1019 if policy.allow_rewrite {
1020 return lock_sync_or_verify_with_timeout(
1021 lockfile,
1022 program,
1023 write_args,
1024 cwd,
1025 policy.timeout,
1026 );
1027 }
1028 lock_verify_or_generate(
1029 lockfile,
1030 program,
1031 verify_args,
1032 write_args,
1033 cwd,
1034 policy.timeout,
1035 )
1036}
1037
1038pub fn lock_verify_or_generate(
1048 lockfile: &Path,
1049 program: &str,
1050 verify_args: &[&str],
1051 generate_args: &[&str],
1052 cwd: &Path,
1053 timeout: std::time::Duration,
1054) -> Result<()> {
1055 let lockfile_exists = lockfile.exists();
1056
1057 if !binary_available(program) {
1058 if lockfile_exists {
1059 refuse_if_manifest_newer(lockfile, program, cwd)?;
1060 return Ok(());
1061 }
1062 anyhow::bail!(
1063 "`{program}` is not available and no lockfile was found at `{}`. \
1064 Cannot safely delete dependencies — install {program} first, \
1065 or commit a lockfile.",
1066 lockfile.display()
1067 );
1068 }
1069
1070 if lockfile_exists {
1071 run_command_with_timeout(program, verify_args, cwd, timeout)
1072 } else {
1073 run_command_with_timeout(program, generate_args, cwd, timeout)
1074 }
1075}
1076
1077pub fn lock_sync_or_verify(
1079 lockfile: &Path,
1080 program: &str,
1081 sync_args: &[&str],
1082 cwd: &Path,
1083) -> Result<()> {
1084 lock_sync_or_verify_with_timeout(
1085 lockfile,
1086 program,
1087 sync_args,
1088 cwd,
1089 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1090 )
1091}
1092
1093const PYVENV_CFG: &str = "pyvenv.cfg";
1099
1100pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1107 let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1108 for line in cfg.lines() {
1109 let Some((key, value)) = line.split_once('=') else {
1110 continue;
1111 };
1112 if matches!(key.trim(), "version" | "version_info") {
1113 let mut parts = value.trim().split('.');
1114 let major: u64 = parts.next()?.parse().ok()?;
1115 let minor: u64 = parts.next()?.parse().ok()?;
1116 return Some(format!("{major}.{minor}"));
1117 }
1118 }
1119 None
1120}
1121
1122pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1126 let mut parts = tag.split('.');
1127 let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1128 return false;
1129 };
1130 !major.is_empty()
1131 && !minor.is_empty()
1132 && major.len() <= 2
1133 && minor.len() <= 3
1134 && major.bytes().all(|b| b.is_ascii_digit())
1135 && minor.bytes().all(|b| b.is_ascii_digit())
1136}
1137
1138pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1146 if !is_valid_runtime_tag(tag) {
1147 return None;
1148 }
1149 #[cfg(windows)]
1150 {
1151 Some(("py".to_string(), vec![format!("-{tag}")]))
1152 }
1153 #[cfg(not(windows))]
1154 {
1155 Some((format!("python{tag}"), Vec::new()))
1156 }
1157}
1158
1159pub(crate) fn python_executable(tag: &str) -> Option<String> {
1167 let (program, prefix) = python_launcher(tag)?;
1168 let out = crate::spawn::command(resolve_program(&program))
1169 .args(&prefix)
1170 .args(["-c", "import sys; print(sys.executable)"])
1171 .stdin(std::process::Stdio::null())
1172 .stderr(std::process::Stdio::null())
1173 .output()
1174 .ok()?;
1175 if !out.status.success() {
1176 return None;
1177 }
1178 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1179 (!path.is_empty()).then_some(path)
1180}
1181
1182pub(crate) fn python_runtime_available(tag: &str) -> bool {
1187 let Some((program, prefix)) = python_launcher(tag) else {
1188 return false;
1189 };
1190 crate::spawn::command(resolve_program(&program))
1191 .args(&prefix)
1192 .arg("--version")
1193 .stdin(std::process::Stdio::null())
1194 .stdout(std::process::Stdio::null())
1195 .stderr(std::process::Stdio::null())
1196 .status()
1197 .is_ok_and(|s| s.success())
1198}
1199
1200const NO_RESTORE_BINARY: [&str; 4] = ["venv", "gradle", "maven", "swift"];
1201
1202const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1204
1205pub fn adapter_binary(adapter: &str) -> &str {
1207 ADAPTER_BINARIES
1208 .iter()
1209 .find(|(name, _)| *name == adapter)
1210 .map_or(adapter, |(_, binary)| *binary)
1211}
1212
1213const INSTALL_HINTS: [(&str, &str); 16] = [
1218 ("npm", "ships with Node.js — https://nodejs.org"),
1219 (
1220 "pnpm",
1221 "`npm install -g pnpm` — https://pnpm.io/installation",
1222 ),
1223 (
1224 "yarn",
1225 "`corepack enable` — https://yarnpkg.com/getting-started/install",
1226 ),
1227 ("bun", "https://bun.sh/docs/installation"),
1228 (
1229 "uv",
1230 "https://docs.astral.sh/uv/getting-started/installation/",
1231 ),
1232 ("poetry", "https://python-poetry.org/docs/#installation"),
1233 (
1234 "pdm",
1235 "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1236 ),
1237 (
1238 "pipenv",
1239 "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1240 ),
1241 ("cargo", "ships with Rust — https://rustup.rs"),
1242 ("go", "https://go.dev/dl/"),
1243 ("composer", "https://getcomposer.org/download/"),
1244 ("bundler", "`gem install bundler` — https://bundler.io"),
1245 (
1246 "cocoapods",
1247 "`gem install cocoapods` — https://cocoapods.org",
1248 ),
1249 (
1250 "mix",
1251 "ships with Elixir — https://elixir-lang.org/install.html",
1252 ),
1253 (
1254 "terraform",
1255 "https://developer.hashicorp.com/terraform/install",
1256 ),
1257 (
1258 "dart",
1259 "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1260 ),
1261];
1262
1263pub fn install_hint(adapter: &str) -> Option<&'static str> {
1265 INSTALL_HINTS
1266 .iter()
1267 .find(|(name, _)| *name == adapter)
1268 .map(|(_, hint)| *hint)
1269}
1270
1271#[derive(Debug, Clone)]
1273pub struct BinaryCheckStatus {
1274 pub name: String,
1275 pub available: bool,
1276 pub version: Option<String>,
1277}
1278
1279pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1281 let mut unique: Vec<String> = adapter_names
1282 .iter()
1283 .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1286 .cloned()
1287 .collect();
1288 unique.sort();
1289 unique.dedup();
1290
1291 unique
1292 .into_iter()
1293 .map(|name| {
1294 let binary = adapter_binary(&name);
1295 let output = crate::spawn::command(resolve_program(binary))
1296 .args(version_probe_args(binary))
1297 .stdin(std::process::Stdio::null())
1298 .output();
1299 match output {
1300 Ok(out) if out.status.success() => {
1301 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1302 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1303 BinaryCheckStatus {
1304 name,
1305 available: true,
1306 version: if first_line.is_empty() {
1307 None
1308 } else {
1309 Some(first_line)
1310 },
1311 }
1312 }
1313 _ => BinaryCheckStatus {
1314 name,
1315 available: false,
1316 version: None,
1317 },
1318 }
1319 })
1320 .collect()
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use super::*;
1326 use std::fs;
1327 use tempfile::TempDir;
1328
1329 #[test]
1330 fn every_adapter_is_grouped_exactly_once() {
1331 let registered = all_adapter_names();
1334 let grouped: Vec<&str> = ADAPTER_GROUPS
1335 .iter()
1336 .flat_map(|(_, names)| names.iter().copied())
1337 .collect();
1338
1339 for name in ®istered {
1340 assert_eq!(
1341 grouped.iter().filter(|g| *g == name).count(),
1342 1,
1343 "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1344 );
1345 }
1346 for name in &grouped {
1347 assert!(
1348 registered.contains(name),
1349 "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1350 );
1351 }
1352 assert_eq!(registered.len(), grouped.len());
1353 }
1354
1355 #[test]
1356 fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1357 let mut opt_in = opt_in_adapter_names();
1362 opt_in.sort_unstable();
1363 assert_eq!(opt_in, vec!["cargo", "dart", "gradle", "maven", "swift"]);
1364 }
1365
1366 #[test]
1367 fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1368 assert_eq!(version_probe_args("go"), &["version"]);
1373 assert_eq!(version_probe_args("npm"), &["--version"]);
1374 }
1375
1376 #[test]
1377 fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1378 for adapter in get_all_adapters() {
1381 let name = adapter.name();
1382 if NO_RESTORE_BINARY.contains(&name) {
1383 continue;
1384 }
1385 assert!(
1386 install_hint(name).is_some(),
1387 "adapter `{name}` has no install hint"
1388 );
1389 }
1390 }
1391
1392 #[test]
1393 fn test_bloat_dir_display() {
1394 let bd = BloatDir {
1395 name: "node_modules".to_string(),
1396 path: PathBuf::from("/test/node_modules"),
1397 size_bytes: 1024,
1398 shared_bytes: 0,
1399 };
1400 assert!(bd.to_string().contains("node_modules"));
1401 }
1402
1403 #[test]
1404 fn test_hardlink_size_counts_a_plain_file_in_full() {
1405 let tmp = TempDir::new().unwrap();
1406 let tree = tmp.path().join("tree");
1407 fs::create_dir(&tree).unwrap();
1408 fs::write(tree.join("copied.txt"), "12345").unwrap();
1409 let size = dir_size_with_hardlinks(&tree);
1410 assert_eq!(size.freed_bytes, 5);
1411 assert_eq!(size.shared_bytes, 0);
1412 }
1413
1414 #[test]
1415 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1416 let tmp = TempDir::new().unwrap();
1419 let store = tmp.path().join("store");
1420 let tree = tmp.path().join("tree");
1421 fs::create_dir(&store).unwrap();
1422 fs::create_dir(&tree).unwrap();
1423 fs::write(store.join("pkg.js"), "0123456789").unwrap();
1424 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1425 let size = dir_size_with_hardlinks(&tree);
1426 assert_eq!(size.freed_bytes, 0);
1427 assert_eq!(size.shared_bytes, 10);
1428 }
1429
1430 #[test]
1431 fn test_hardlink_size_counts_an_internal_pair_once() {
1432 let tmp = TempDir::new().unwrap();
1435 let tree = tmp.path().join("tree");
1436 fs::create_dir(&tree).unwrap();
1437 fs::write(tree.join("a.js"), "abcdefg").unwrap();
1438 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1439 let size = dir_size_with_hardlinks(&tree);
1440 assert_eq!(size.freed_bytes, 7);
1441 assert_eq!(size.shared_bytes, 0);
1442 }
1443
1444 #[test]
1445 fn test_dir_size_empty() {
1446 let tmp = TempDir::new().unwrap();
1447 assert_eq!(dir_size(tmp.path()), 0);
1448 }
1449
1450 #[test]
1451 fn test_dir_size_with_files() {
1452 let tmp = TempDir::new().unwrap();
1453 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1454 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1455 assert_eq!(dir_size(tmp.path()), 11); }
1457
1458 #[test]
1459 fn test_dir_size_nonexistent() {
1460 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1461 }
1462
1463 #[test]
1464 fn test_get_all_adapters_not_empty() {
1465 let adapters = get_all_adapters();
1466 assert!(adapters.len() >= 6);
1467 }
1468
1469 #[test]
1470 fn test_detect_adapters_npm() {
1471 let tmp = TempDir::new().unwrap();
1472 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1473 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1474 let adapters = detect_adapters(tmp.path());
1475 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1476 assert!(names.contains(&"npm"));
1477 }
1478
1479 #[test]
1480 fn test_detect_adapters_empty_dir() {
1481 let tmp = TempDir::new().unwrap();
1482 let adapters = detect_adapters(tmp.path());
1483 assert!(adapters.is_empty());
1484 }
1485
1486 fn detected_names(dir: &Path) -> Vec<&'static str> {
1488 let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
1489 names.sort_unstable();
1490 names
1491 }
1492
1493 #[test]
1494 fn test_detect_adapters_multiple_ecosystems_coexist() {
1495 let tmp = TempDir::new().unwrap();
1497 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1498 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1499 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1500 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1501 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1502
1503 assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1508 }
1509
1510 #[test]
1511 fn test_js_conflict_resolved_by_package_manager_field() {
1512 let tmp = TempDir::new().unwrap();
1513 fs::write(
1514 tmp.path().join("package.json"),
1515 r#"{"packageManager":"yarn@4.1.0"}"#,
1516 )
1517 .unwrap();
1518 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1519 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1520 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1521
1522 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1523 }
1524
1525 #[test]
1526 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1527 let tmp = TempDir::new().unwrap();
1530 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1531 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1532 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1533 std::thread::sleep(std::time::Duration::from_millis(20));
1534 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1535
1536 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1537 }
1538
1539 #[test]
1540 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1541 let tmp = TempDir::new().unwrap();
1543 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1544 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1545 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1546 let nm = tmp.path().join("node_modules");
1547 fs::create_dir_all(&nm).unwrap();
1548 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1549 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1550
1551 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1552 }
1553
1554 #[test]
1555 fn test_declared_package_manager_outranks_what_is_installed() {
1556 let tmp = TempDir::new().unwrap();
1558 fs::write(
1559 tmp.path().join("package.json"),
1560 r#"{"packageManager":"pnpm@9.1.0"}"#,
1561 )
1562 .unwrap();
1563 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1564 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1565 let nm = tmp.path().join("node_modules");
1566 fs::create_dir_all(&nm).unwrap();
1567 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1568
1569 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1570 }
1571
1572 #[test]
1573 fn test_uv_takes_precedence_over_plain_venv() {
1574 let tmp = TempDir::new().unwrap();
1577 fs::write(
1578 tmp.path().join("pyproject.toml"),
1579 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1580 )
1581 .unwrap();
1582 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1583 let venv = tmp.path().join(".venv");
1584 fs::create_dir_all(&venv).unwrap();
1585 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1586
1587 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1588 }
1589
1590 #[test]
1591 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1592 let tmp = TempDir::new().unwrap();
1593 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1594 let venv = tmp.path().join("venv");
1595 fs::create_dir_all(&venv).unwrap();
1596 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1597
1598 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1599 }
1600
1601 #[test]
1602 fn test_js_conflict_falls_back_to_newest_lockfile() {
1603 let tmp = TempDir::new().unwrap();
1604 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1605 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1606 std::thread::sleep(std::time::Duration::from_millis(20));
1609 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1610
1611 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1612 }
1613
1614 #[test]
1615 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1616 let tmp = TempDir::new().unwrap();
1619 fs::write(
1620 tmp.path().join("package.json"),
1621 r#"{"packageManager":"deno@2.0.0"}"#,
1622 )
1623 .unwrap();
1624 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1625 std::thread::sleep(std::time::Duration::from_millis(20));
1626 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1627
1628 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1629 }
1630
1631 #[test]
1632 fn test_js_conflict_does_not_disturb_a_single_manager() {
1633 let tmp = TempDir::new().unwrap();
1634 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1635 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1636 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1637 }
1638
1639 #[test]
1640 fn test_js_adapters_declare_their_lockfiles() {
1641 for adapter in get_all_adapters() {
1642 if JS_MANAGERS.contains(&adapter.name()) {
1643 assert!(
1644 !adapter.lockfiles().is_empty(),
1645 "{} shares node_modules and must declare its lockfiles for \
1646 conflict resolution",
1647 adapter.name()
1648 );
1649 }
1650 }
1651 }
1652
1653 #[test]
1654 fn test_adapter_names_unique() {
1655 let adapters = get_all_adapters();
1656 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1657 let mut unique = names.clone();
1658 unique.sort();
1659 unique.dedup();
1660 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1661 }
1662
1663 #[test]
1664 fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1665 assert!(is_valid_runtime_tag("3.12"));
1668 assert!(is_valid_runtime_tag("3.9"));
1669 for bad in [
1670 "",
1671 "3",
1672 "3.12.1",
1673 "3.x",
1674 "3.12; rm -rf /",
1675 "-3.12",
1676 "../python",
1677 "3.1234",
1678 "300.1",
1679 ] {
1680 assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1681 }
1682 }
1683
1684 #[test]
1685 fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1686 let tmp = tempfile::tempdir().unwrap();
1687 let venv = tmp.path().join(".venv");
1688 std::fs::create_dir_all(&venv).unwrap();
1689 std::fs::write(
1690 venv.join("pyvenv.cfg"),
1691 "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1692 )
1693 .unwrap();
1694 assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1695 }
1696
1697 #[test]
1698 fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1699 let tmp = tempfile::tempdir().unwrap();
1700 assert_eq!(venv_runtime_tag(tmp.path()), None);
1701 }
1702}