1pub mod bun;
20pub mod cargo_adapter;
21pub mod go;
22pub mod gradle;
23pub mod maven;
24pub mod npm;
25pub mod pnpm;
26pub mod poetry;
27pub mod uv;
28pub mod venv;
29pub mod yarn;
30
31use std::collections::HashMap;
32use std::fmt;
33use std::path::{Path, PathBuf};
34use std::sync::{Mutex, OnceLock};
35
36use anyhow::{Context as _, Result};
37use walkdir::WalkDir;
38
39#[derive(Debug, Clone)]
41pub struct BloatDir {
42 pub name: String,
44 pub path: PathBuf,
46 pub size_bytes: u64,
48 pub shared_bytes: u64,
52}
53
54impl fmt::Display for BloatDir {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(f, "{} ({})", self.name, self.path.display())
57 }
58}
59
60#[derive(Debug, Clone)]
63pub struct DriftReport {
64 pub directory: String,
66 pub unrecorded: Vec<String>,
68 pub record_command: &'static str,
70}
71
72pub trait PackageManager: Send + Sync {
80 fn name(&self) -> &'static str;
82
83 fn detect(&self, project_path: &Path) -> bool;
87
88 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
92
93 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
100
101 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
108
109 fn restore_named(
116 &self,
117 project_path: &Path,
118 dir_name: &str,
119 timeout: std::time::Duration,
120 ) -> Result<()> {
121 let _ = dir_name;
122 self.restore(project_path, timeout)
123 }
124
125 fn lockfiles(&self) -> &'static [&'static str] {
135 &[]
136 }
137
138 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
146 let _ = project_path;
147 Vec::new()
148 }
149
150 fn opt_in(&self) -> bool {
157 false
158 }
159}
160
161const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
163
164const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
172 ("pnpm", &[".pnpm", ".modules.yaml"]),
173 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
174 ("npm", &[".package-lock.json"]),
175];
176
177pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
181 vec![
182 Box::new(npm::Npm),
183 Box::new(pnpm::Pnpm),
184 Box::new(yarn::Yarn),
185 Box::new(bun::Bun),
186 Box::new(uv::Uv),
187 Box::new(poetry::Poetry),
188 Box::new(venv::Venv),
189 Box::new(cargo_adapter::Cargo),
190 Box::new(go::Go),
191 Box::new(gradle::Gradle),
192 Box::new(maven::Maven),
193 ]
194}
195
196fn opt_in_enabled() -> &'static [String] {
204 static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
205 ENABLED.get_or_init(|| {
206 crate::config::Registry::load()
207 .map(|r| {
208 let mut names = Vec::new();
209 if r.settings.enable_gradle {
210 names.push("gradle".to_string());
211 }
212 if r.settings.enable_maven {
213 names.push("maven".to_string());
214 }
215 names
216 })
217 .unwrap_or_default()
218 })
219}
220
221pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
228 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
229 .into_iter()
230 .filter(|adapter| !adapter.opt_in() || opt_in_enabled().iter().any(|n| n == adapter.name()))
231 .filter(|adapter| adapter.detect(project_path))
232 .collect();
233 resolve_conflicts(project_path, &mut detected);
234 detected
235}
236
237fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
239 resolve_js_conflict(project_path, detected);
240 resolve_python_conflict(project_path, detected);
241}
242
243fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
256 if detected
257 .iter()
258 .filter(|a| JS_MANAGERS.contains(&a.name()))
259 .count()
260 < 2
261 {
262 return;
263 }
264
265 let winner = declared_package_manager(project_path)
266 .filter(|name| detected.iter().any(|a| a.name() == name))
267 .or_else(|| installed_manager(project_path, detected))
268 .or_else(|| newest_lockfile_owner(project_path, detected));
269
270 let Some(winner) = winner else { return };
271 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
272}
273
274fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
281 if detected.iter().any(|a| a.name() == "uv") {
282 detected.retain(|a| a.name() != "venv");
283 }
284 let uv_detected = detected.iter().any(|a| a.name() == "uv");
288 let poetry_detected = detected.iter().any(|a| a.name() == "poetry");
289 if uv_detected && poetry_detected {
290 let loser = if !project_path.join("uv.lock").exists()
291 && project_path.join("poetry.lock").exists()
292 {
293 "uv"
294 } else {
295 "poetry"
296 };
297 detected.retain(|a| a.name() != loser);
298 }
299}
300
301fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
303 let node_modules = project_path.join("node_modules");
304 if !node_modules.is_dir() {
305 return None;
306 }
307
308 JS_INSTALL_MARKERS
309 .iter()
310 .find(|(name, markers)| {
311 detected.iter().any(|a| a.name() == *name)
312 && markers.iter().any(|m| node_modules.join(m).exists())
313 })
314 .map(|(name, _)| (*name).to_string())
315}
316
317fn declared_package_manager(project_path: &Path) -> Option<String> {
319 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
320 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
321 let declared = json.get("packageManager")?.as_str()?;
322 let name = declared.split('@').next().unwrap_or_default();
323 JS_MANAGERS
324 .iter()
325 .find(|m| **m == name)
326 .map(|m| (*m).to_string())
327}
328
329fn newest_lockfile_owner(
331 project_path: &Path,
332 detected: &[Box<dyn PackageManager>],
333) -> Option<String> {
334 detected
335 .iter()
336 .filter(|a| JS_MANAGERS.contains(&a.name()))
337 .filter_map(|a| {
338 let newest = a
339 .lockfiles()
340 .iter()
341 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
342 .max()?;
343 Some((newest, a.name().to_string()))
344 })
345 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
348 match best {
349 Some(b) if b.0 >= cur.0 => Some(b),
350 _ => Some(cur),
351 }
352 })
353 .map(|(_, name)| name)
354}
355
356pub fn dir_size(path: &Path) -> u64 {
358 if !path.exists() {
359 return 0;
360 }
361 WalkDir::new(path)
362 .follow_links(false)
363 .into_iter()
364 .flatten()
365 .filter_map(|entry| entry.metadata().ok())
366 .filter(|meta| meta.is_file())
367 .map(|meta| meta.len())
368 .sum()
369}
370
371#[derive(Debug, Clone, Copy, Default)]
373pub struct DirSizeBreakdown {
374 pub freed_bytes: u64,
376 pub shared_bytes: u64,
379}
380
381pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
392 let mut out = DirSizeBreakdown::default();
393 if !path.exists() {
394 return out;
395 }
396 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
398 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
399 let Ok(meta) = entry.metadata() else { continue };
400 if !meta.is_file() {
401 continue;
402 }
403 match file_link_identity(entry.path(), &meta) {
404 Some((dev, ino, nlink)) if nlink > 1 => {
405 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
406 }
407 _ => out.freed_bytes += meta.len(),
408 }
409 }
410 for (bytes, nlink, seen) in linked.into_values() {
411 if seen >= nlink {
412 out.freed_bytes += bytes;
413 } else {
414 out.shared_bytes += bytes;
415 }
416 }
417 out
418}
419
420#[cfg(unix)]
422fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
423 use std::os::unix::fs::MetadataExt as _;
424 Some((meta.dev(), meta.ino(), meta.nlink()))
425}
426
427#[cfg(windows)]
431fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
432 use std::os::windows::fs::OpenOptionsExt as _;
433 use std::os::windows::io::AsRawHandle as _;
434 use windows_sys::Win32::Storage::FileSystem::{
435 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
436 };
437
438 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
441 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
442 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
445 return None;
446 }
447 Some((
448 u64::from(info.dwVolumeSerialNumber),
449 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
450 u64::from(info.nNumberOfLinks),
451 ))
452}
453
454#[cfg(not(any(unix, windows)))]
455fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
456 None
457}
458
459pub fn resolve_program(program: &str) -> String {
469 #[cfg(windows)]
470 {
471 if Path::new(program).components().count() > 1 {
472 return program.to_string();
473 }
474 let Some(path_var) = std::env::var_os("PATH") else {
475 return program.to_string();
476 };
477 for dir in std::env::split_paths(&path_var) {
478 for ext in ["exe", "cmd", "bat"] {
479 let candidate = dir.join(format!("{program}.{ext}"));
480 if candidate.is_file() {
481 return candidate.to_string_lossy().into_owned();
482 }
483 }
484 }
485 }
486 program.to_string()
487}
488
489pub fn binary_available(program: &str) -> bool {
497 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
498 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
499
500 let mut guard = match cache.lock() {
503 Ok(g) => g,
504 Err(_) => return probe_binary(program),
507 };
508 if let Some(known) = guard.get(program) {
509 return *known;
510 }
511 let available = probe_binary(program);
512 guard.insert(program.to_string(), available);
513 available
514}
515
516fn probe_binary(program: &str) -> bool {
518 crate::spawn::command(resolve_program(program))
519 .arg("--version")
520 .stdin(std::process::Stdio::null())
521 .output()
522 .map(|o| o.status.success())
523 .unwrap_or(false)
524}
525
526struct CommandOutput {
528 status: std::process::ExitStatus,
529 stdout: String,
530 stderr: String,
531}
532
533fn spawn_capture(
540 program: &str,
541 args: &[&str],
542 cwd: &Path,
543 timeout: std::time::Duration,
544) -> Result<CommandOutput> {
545 use std::io::Read;
546 use std::process::Stdio;
547 use std::thread;
548 use std::time::Instant;
549
550 let resolved = resolve_program(program);
551 let mut child = crate::spawn::command(&resolved)
552 .args(args)
553 .current_dir(cwd)
554 .stdin(Stdio::null())
555 .stdout(Stdio::piped())
556 .stderr(Stdio::piped())
557 .spawn()
558 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
559
560 let mut stdout_pipe = child.stdout.take();
564 let mut stderr_pipe = child.stderr.take();
565 let stdout_reader = thread::spawn(move || {
566 let mut buf = Vec::new();
567 if let Some(pipe) = stdout_pipe.as_mut() {
568 let _ = pipe.read_to_end(&mut buf);
569 }
570 buf
571 });
572 let stderr_reader = thread::spawn(move || {
573 let mut buf = Vec::new();
574 if let Some(pipe) = stderr_pipe.as_mut() {
575 let _ = pipe.read_to_end(&mut buf);
576 }
577 buf
578 });
579
580 let start = Instant::now();
581 let status = loop {
582 match child.try_wait()? {
583 Some(status) => break status,
584 None => {
585 if start.elapsed() >= timeout {
586 let _ = child.kill();
587 let _ = child.wait();
588 anyhow::bail!(
589 "Command timed out after {}s: {} {}\n\
590 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
591 timeout.as_secs(),
592 program,
593 args.join(" ")
594 );
595 }
596 thread::sleep(std::time::Duration::from_millis(100));
597 }
598 }
599 };
600
601 let stderr = stderr_reader
602 .join()
603 .map(|b| String::from_utf8_lossy(&b).into_owned())
604 .unwrap_or_default();
605 let stdout = stdout_reader
606 .join()
607 .map(|b| String::from_utf8_lossy(&b).into_owned())
608 .unwrap_or_default();
609
610 Ok(CommandOutput {
611 status,
612 stdout,
613 stderr,
614 })
615}
616
617pub fn run_command_with_timeout(
619 program: &str,
620 args: &[&str],
621 cwd: &Path,
622 timeout: std::time::Duration,
623) -> Result<()> {
624 let out = spawn_capture(program, args, cwd, timeout)?;
625 if out.status.success() {
626 Ok(())
627 } else {
628 anyhow::bail!(
629 "{} {} failed (exit code {:?}):\n{}",
630 program,
631 args.join(" "),
632 out.status.code(),
633 out.stderr.trim()
634 )
635 }
636}
637
638pub fn capture_command_with_timeout(
644 program: &str,
645 args: &[&str],
646 cwd: &Path,
647 timeout: std::time::Duration,
648) -> Result<String> {
649 let out = spawn_capture(program, args, cwd, timeout)?;
650 if out.status.success() {
651 Ok(out.stdout)
652 } else {
653 anyhow::bail!(
654 "{} {} failed (exit code {:?}):\n{}",
655 program,
656 args.join(" "),
657 out.status.code(),
658 out.stderr.trim()
659 )
660 }
661}
662
663pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
665 crate::spawn::command(resolve_program(program))
666 .args(args)
667 .current_dir(cwd)
668 .stdin(std::process::Stdio::null())
669 .output()
670 .map(|o| o.status.success())
671 .unwrap_or(false)
672}
673
674const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
682
683fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
690 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
691 Some("Cargo.lock") => "Cargo.toml",
692 Some("package-lock.json")
693 | Some("yarn.lock")
694 | Some("pnpm-lock.yaml")
695 | Some("bun.lockb")
696 | Some("bun.lock") => "package.json",
697 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
698 Some("go.sum") => "go.mod",
699 Some("composer.lock") => "composer.json",
700 _ => return Ok(()),
701 };
702 let manifest = cwd.join(manifest_name);
703 let (Ok(manifest_meta), Ok(lock_meta)) =
704 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
705 else {
706 return Ok(());
707 };
708 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
709 && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
710 {
711 anyhow::bail!(
712 "`{program}` is not available, and `{manifest_name}` has been edited more \
713 recently than `{}` — the lockfile may no longer record the current \
714 dependencies, and without `{program}` that cannot be verified. Install \
715 {program} and run its lockfile sync, then prune again.",
716 lockfile.display()
717 );
718 }
719 Ok(())
720}
721
722pub fn lock_sync_or_verify_with_timeout(
724 lockfile: &Path,
725 program: &str,
726 sync_args: &[&str],
727 cwd: &Path,
728 timeout: std::time::Duration,
729) -> Result<()> {
730 let lockfile_exists = lockfile.exists();
731
732 if !binary_available(program) {
733 if lockfile_exists {
734 refuse_if_manifest_newer(lockfile, program, cwd)?;
735 return Ok(());
736 } else {
737 anyhow::bail!(
738 "`{program}` is not available and no lockfile was found at `{}`. \
739 Cannot safely delete dependencies — install {program} first, \
740 or commit a lockfile.",
741 lockfile.display()
742 );
743 }
744 }
745
746 run_command_with_timeout(program, sync_args, cwd, timeout)
748}
749
750#[derive(Debug, Clone, Copy)]
756pub struct EnforcePolicy {
757 pub allow_rewrite: bool,
763 pub timeout: std::time::Duration,
765}
766
767impl Default for EnforcePolicy {
768 fn default() -> Self {
769 Self {
770 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
771 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
772 }
773 }
774}
775
776impl EnforcePolicy {
777 pub fn from_settings(settings: &crate::config::Settings) -> Self {
779 Self {
780 allow_rewrite: settings.allow_manifest_rewrite,
781 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
782 }
783 }
784}
785
786pub fn enforce_two_tier(
802 lockfile: &Path,
803 program: &str,
804 verify_args: &[&str],
805 write_args: &[&str],
806 cwd: &Path,
807 policy: EnforcePolicy,
808) -> Result<()> {
809 if policy.allow_rewrite {
810 return lock_sync_or_verify_with_timeout(
811 lockfile,
812 program,
813 write_args,
814 cwd,
815 policy.timeout,
816 );
817 }
818 lock_verify_or_generate(
819 lockfile,
820 program,
821 verify_args,
822 write_args,
823 cwd,
824 policy.timeout,
825 )
826}
827
828pub fn lock_verify_or_generate(
838 lockfile: &Path,
839 program: &str,
840 verify_args: &[&str],
841 generate_args: &[&str],
842 cwd: &Path,
843 timeout: std::time::Duration,
844) -> Result<()> {
845 let lockfile_exists = lockfile.exists();
846
847 if !binary_available(program) {
848 if lockfile_exists {
849 refuse_if_manifest_newer(lockfile, program, cwd)?;
850 return Ok(());
851 }
852 anyhow::bail!(
853 "`{program}` is not available and no lockfile was found at `{}`. \
854 Cannot safely delete dependencies — install {program} first, \
855 or commit a lockfile.",
856 lockfile.display()
857 );
858 }
859
860 if lockfile_exists {
861 run_command_with_timeout(program, verify_args, cwd, timeout)
862 } else {
863 run_command_with_timeout(program, generate_args, cwd, timeout)
864 }
865}
866
867pub fn lock_sync_or_verify(
869 lockfile: &Path,
870 program: &str,
871 sync_args: &[&str],
872 cwd: &Path,
873) -> Result<()> {
874 lock_sync_or_verify_with_timeout(
875 lockfile,
876 program,
877 sync_args,
878 cwd,
879 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
880 )
881}
882
883#[derive(Debug, Clone)]
885pub struct BinaryCheckStatus {
886 pub name: String,
887 pub available: bool,
888 pub version: Option<String>,
889}
890
891pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
893 let mut unique: Vec<String> = adapter_names
894 .iter()
895 .filter(|&n| n != "-" && n != "venv" && n != "gradle" && n != "maven")
898 .cloned()
899 .collect();
900 unique.sort();
901 unique.dedup();
902
903 unique
904 .into_iter()
905 .map(|name| {
906 let output = crate::spawn::command(resolve_program(&name))
907 .arg("--version")
908 .stdin(std::process::Stdio::null())
909 .output();
910 match output {
911 Ok(out) if out.status.success() => {
912 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
913 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
914 BinaryCheckStatus {
915 name,
916 available: true,
917 version: if first_line.is_empty() {
918 None
919 } else {
920 Some(first_line)
921 },
922 }
923 }
924 _ => BinaryCheckStatus {
925 name,
926 available: false,
927 version: None,
928 },
929 }
930 })
931 .collect()
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use std::fs;
938 use tempfile::TempDir;
939
940 #[test]
941 fn test_bloat_dir_display() {
942 let bd = BloatDir {
943 name: "node_modules".to_string(),
944 path: PathBuf::from("/test/node_modules"),
945 size_bytes: 1024,
946 shared_bytes: 0,
947 };
948 assert!(bd.to_string().contains("node_modules"));
949 }
950
951 #[test]
952 fn test_hardlink_size_counts_a_plain_file_in_full() {
953 let tmp = TempDir::new().unwrap();
954 let tree = tmp.path().join("tree");
955 fs::create_dir(&tree).unwrap();
956 fs::write(tree.join("copied.txt"), "12345").unwrap();
957 let size = dir_size_with_hardlinks(&tree);
958 assert_eq!(size.freed_bytes, 5);
959 assert_eq!(size.shared_bytes, 0);
960 }
961
962 #[test]
963 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
964 let tmp = TempDir::new().unwrap();
967 let store = tmp.path().join("store");
968 let tree = tmp.path().join("tree");
969 fs::create_dir(&store).unwrap();
970 fs::create_dir(&tree).unwrap();
971 fs::write(store.join("pkg.js"), "0123456789").unwrap();
972 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
973 let size = dir_size_with_hardlinks(&tree);
974 assert_eq!(size.freed_bytes, 0);
975 assert_eq!(size.shared_bytes, 10);
976 }
977
978 #[test]
979 fn test_hardlink_size_counts_an_internal_pair_once() {
980 let tmp = TempDir::new().unwrap();
983 let tree = tmp.path().join("tree");
984 fs::create_dir(&tree).unwrap();
985 fs::write(tree.join("a.js"), "abcdefg").unwrap();
986 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
987 let size = dir_size_with_hardlinks(&tree);
988 assert_eq!(size.freed_bytes, 7);
989 assert_eq!(size.shared_bytes, 0);
990 }
991
992 #[test]
993 fn test_dir_size_empty() {
994 let tmp = TempDir::new().unwrap();
995 assert_eq!(dir_size(tmp.path()), 0);
996 }
997
998 #[test]
999 fn test_dir_size_with_files() {
1000 let tmp = TempDir::new().unwrap();
1001 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1002 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1003 assert_eq!(dir_size(tmp.path()), 11); }
1005
1006 #[test]
1007 fn test_dir_size_nonexistent() {
1008 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1009 }
1010
1011 #[test]
1012 fn test_get_all_adapters_not_empty() {
1013 let adapters = get_all_adapters();
1014 assert!(adapters.len() >= 6);
1015 }
1016
1017 #[test]
1018 fn test_detect_adapters_npm() {
1019 let tmp = TempDir::new().unwrap();
1020 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1021 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1022 let adapters = detect_adapters(tmp.path());
1023 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1024 assert!(names.contains(&"npm"));
1025 }
1026
1027 #[test]
1028 fn test_detect_adapters_empty_dir() {
1029 let tmp = TempDir::new().unwrap();
1030 let adapters = detect_adapters(tmp.path());
1031 assert!(adapters.is_empty());
1032 }
1033
1034 fn detected_names(dir: &Path) -> Vec<&'static str> {
1036 let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
1037 names.sort_unstable();
1038 names
1039 }
1040
1041 #[test]
1042 fn test_detect_adapters_multiple_ecosystems_coexist() {
1043 let tmp = TempDir::new().unwrap();
1045 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1046 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1047 fs::write(tmp.path().join("uv.lock"), "").unwrap();
1048 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1049 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1050
1051 assert_eq!(detected_names(tmp.path()), vec!["cargo", "go", "npm", "uv"]);
1052 }
1053
1054 #[test]
1055 fn test_js_conflict_resolved_by_package_manager_field() {
1056 let tmp = TempDir::new().unwrap();
1057 fs::write(
1058 tmp.path().join("package.json"),
1059 r#"{"packageManager":"yarn@4.1.0"}"#,
1060 )
1061 .unwrap();
1062 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1063 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1064 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1065
1066 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1067 }
1068
1069 #[test]
1070 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1071 let tmp = TempDir::new().unwrap();
1074 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1075 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1076 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1077 std::thread::sleep(std::time::Duration::from_millis(20));
1078 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1079
1080 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1081 }
1082
1083 #[test]
1084 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1085 let tmp = TempDir::new().unwrap();
1087 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1088 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1089 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1090 let nm = tmp.path().join("node_modules");
1091 fs::create_dir_all(&nm).unwrap();
1092 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1093 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1094
1095 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1096 }
1097
1098 #[test]
1099 fn test_declared_package_manager_outranks_what_is_installed() {
1100 let tmp = TempDir::new().unwrap();
1102 fs::write(
1103 tmp.path().join("package.json"),
1104 r#"{"packageManager":"pnpm@9.1.0"}"#,
1105 )
1106 .unwrap();
1107 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1108 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1109 let nm = tmp.path().join("node_modules");
1110 fs::create_dir_all(&nm).unwrap();
1111 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1112
1113 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1114 }
1115
1116 #[test]
1117 fn test_uv_takes_precedence_over_plain_venv() {
1118 let tmp = TempDir::new().unwrap();
1121 fs::write(
1122 tmp.path().join("pyproject.toml"),
1123 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1124 )
1125 .unwrap();
1126 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1127 let venv = tmp.path().join(".venv");
1128 fs::create_dir_all(&venv).unwrap();
1129 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1130
1131 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1132 }
1133
1134 #[test]
1135 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1136 let tmp = TempDir::new().unwrap();
1137 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1138 let venv = tmp.path().join("venv");
1139 fs::create_dir_all(&venv).unwrap();
1140 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1141
1142 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1143 }
1144
1145 #[test]
1146 fn test_js_conflict_falls_back_to_newest_lockfile() {
1147 let tmp = TempDir::new().unwrap();
1148 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1149 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1150 std::thread::sleep(std::time::Duration::from_millis(20));
1153 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1154
1155 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1156 }
1157
1158 #[test]
1159 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1160 let tmp = TempDir::new().unwrap();
1163 fs::write(
1164 tmp.path().join("package.json"),
1165 r#"{"packageManager":"deno@2.0.0"}"#,
1166 )
1167 .unwrap();
1168 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1169 std::thread::sleep(std::time::Duration::from_millis(20));
1170 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1171
1172 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1173 }
1174
1175 #[test]
1176 fn test_js_conflict_does_not_disturb_a_single_manager() {
1177 let tmp = TempDir::new().unwrap();
1178 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1179 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1180 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1181 }
1182
1183 #[test]
1184 fn test_js_adapters_declare_their_lockfiles() {
1185 for adapter in get_all_adapters() {
1186 if JS_MANAGERS.contains(&adapter.name()) {
1187 assert!(
1188 !adapter.lockfiles().is_empty(),
1189 "{} shares node_modules and must declare its lockfiles for \
1190 conflict resolution",
1191 adapter.name()
1192 );
1193 }
1194 }
1195 }
1196
1197 #[test]
1198 fn test_adapter_names_unique() {
1199 let adapters = get_all_adapters();
1200 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1201 let mut unique = names.clone();
1202 unique.sort();
1203 unique.dedup();
1204 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1205 }
1206}