1pub mod bun;
20pub mod cargo_adapter;
21pub mod go;
22pub mod npm;
23pub mod pnpm;
24pub mod uv;
25pub mod venv;
26pub mod yarn;
27
28use std::collections::HashMap;
29use std::fmt;
30use std::path::{Path, PathBuf};
31use std::sync::{Mutex, OnceLock};
32
33use anyhow::{Context as _, Result};
34use walkdir::WalkDir;
35
36#[derive(Debug, Clone)]
38pub struct BloatDir {
39 pub name: String,
41 pub path: PathBuf,
43 pub size_bytes: u64,
45 pub shared_bytes: u64,
49}
50
51impl fmt::Display for BloatDir {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "{} ({})", self.name, self.path.display())
54 }
55}
56
57#[derive(Debug, Clone)]
60pub struct DriftReport {
61 pub directory: String,
63 pub unrecorded: Vec<String>,
65 pub record_command: &'static str,
67}
68
69pub trait PackageManager: Send + Sync {
77 fn name(&self) -> &'static str;
79
80 fn detect(&self, project_path: &Path) -> bool;
84
85 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
89
90 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
97
98 fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
105
106 fn restore_named(
113 &self,
114 project_path: &Path,
115 dir_name: &str,
116 timeout: std::time::Duration,
117 ) -> Result<()> {
118 let _ = dir_name;
119 self.restore(project_path, timeout)
120 }
121
122 fn lockfiles(&self) -> &'static [&'static str] {
132 &[]
133 }
134
135 fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
143 let _ = project_path;
144 Vec::new()
145 }
146}
147
148const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
150
151const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
159 ("pnpm", &[".pnpm", ".modules.yaml"]),
160 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
161 ("npm", &[".package-lock.json"]),
162];
163
164pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
168 vec![
169 Box::new(npm::Npm),
170 Box::new(pnpm::Pnpm),
171 Box::new(yarn::Yarn),
172 Box::new(bun::Bun),
173 Box::new(uv::Uv),
174 Box::new(venv::Venv),
175 Box::new(cargo_adapter::Cargo),
176 Box::new(go::Go),
177 ]
178}
179
180pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
187 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
188 .into_iter()
189 .filter(|adapter| adapter.detect(project_path))
190 .collect();
191 resolve_conflicts(project_path, &mut detected);
192 detected
193}
194
195fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
197 resolve_js_conflict(project_path, detected);
198 resolve_python_conflict(detected);
199}
200
201fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
214 if detected
215 .iter()
216 .filter(|a| JS_MANAGERS.contains(&a.name()))
217 .count()
218 < 2
219 {
220 return;
221 }
222
223 let winner = declared_package_manager(project_path)
224 .filter(|name| detected.iter().any(|a| a.name() == name))
225 .or_else(|| installed_manager(project_path, detected))
226 .or_else(|| newest_lockfile_owner(project_path, detected));
227
228 let Some(winner) = winner else { return };
229 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
230}
231
232fn resolve_python_conflict(detected: &mut Vec<Box<dyn PackageManager>>) {
239 if detected.iter().any(|a| a.name() == "uv") {
240 detected.retain(|a| a.name() != "venv");
241 }
242}
243
244fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
246 let node_modules = project_path.join("node_modules");
247 if !node_modules.is_dir() {
248 return None;
249 }
250
251 JS_INSTALL_MARKERS
252 .iter()
253 .find(|(name, markers)| {
254 detected.iter().any(|a| a.name() == *name)
255 && markers.iter().any(|m| node_modules.join(m).exists())
256 })
257 .map(|(name, _)| (*name).to_string())
258}
259
260fn declared_package_manager(project_path: &Path) -> Option<String> {
262 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
263 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
264 let declared = json.get("packageManager")?.as_str()?;
265 let name = declared.split('@').next().unwrap_or_default();
266 JS_MANAGERS
267 .iter()
268 .find(|m| **m == name)
269 .map(|m| (*m).to_string())
270}
271
272fn newest_lockfile_owner(
274 project_path: &Path,
275 detected: &[Box<dyn PackageManager>],
276) -> Option<String> {
277 detected
278 .iter()
279 .filter(|a| JS_MANAGERS.contains(&a.name()))
280 .filter_map(|a| {
281 let newest = a
282 .lockfiles()
283 .iter()
284 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
285 .max()?;
286 Some((newest, a.name().to_string()))
287 })
288 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
291 match best {
292 Some(b) if b.0 >= cur.0 => Some(b),
293 _ => Some(cur),
294 }
295 })
296 .map(|(_, name)| name)
297}
298
299pub fn dir_size(path: &Path) -> u64 {
301 if !path.exists() {
302 return 0;
303 }
304 WalkDir::new(path)
305 .follow_links(false)
306 .into_iter()
307 .flatten()
308 .filter_map(|entry| entry.metadata().ok())
309 .filter(|meta| meta.is_file())
310 .map(|meta| meta.len())
311 .sum()
312}
313
314#[derive(Debug, Clone, Copy, Default)]
316pub struct DirSizeBreakdown {
317 pub freed_bytes: u64,
319 pub shared_bytes: u64,
322}
323
324pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
335 let mut out = DirSizeBreakdown::default();
336 if !path.exists() {
337 return out;
338 }
339 let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
341 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
342 let Ok(meta) = entry.metadata() else { continue };
343 if !meta.is_file() {
344 continue;
345 }
346 match file_link_identity(entry.path(), &meta) {
347 Some((dev, ino, nlink)) if nlink > 1 => {
348 linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
349 }
350 _ => out.freed_bytes += meta.len(),
351 }
352 }
353 for (bytes, nlink, seen) in linked.into_values() {
354 if seen >= nlink {
355 out.freed_bytes += bytes;
356 } else {
357 out.shared_bytes += bytes;
358 }
359 }
360 out
361}
362
363#[cfg(unix)]
365fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
366 use std::os::unix::fs::MetadataExt as _;
367 Some((meta.dev(), meta.ino(), meta.nlink()))
368}
369
370#[cfg(windows)]
374fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
375 use std::os::windows::fs::OpenOptionsExt as _;
376 use std::os::windows::io::AsRawHandle as _;
377 use windows_sys::Win32::Storage::FileSystem::{
378 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
379 };
380
381 let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
384 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
385 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
388 return None;
389 }
390 Some((
391 u64::from(info.dwVolumeSerialNumber),
392 (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
393 u64::from(info.nNumberOfLinks),
394 ))
395}
396
397#[cfg(not(any(unix, windows)))]
398fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
399 None
400}
401
402pub fn resolve_program(program: &str) -> String {
412 #[cfg(windows)]
413 {
414 if Path::new(program).components().count() > 1 {
415 return program.to_string();
416 }
417 let Some(path_var) = std::env::var_os("PATH") else {
418 return program.to_string();
419 };
420 for dir in std::env::split_paths(&path_var) {
421 for ext in ["exe", "cmd", "bat"] {
422 let candidate = dir.join(format!("{program}.{ext}"));
423 if candidate.is_file() {
424 return candidate.to_string_lossy().into_owned();
425 }
426 }
427 }
428 }
429 program.to_string()
430}
431
432pub fn binary_available(program: &str) -> bool {
440 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
441 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
442
443 let mut guard = match cache.lock() {
446 Ok(g) => g,
447 Err(_) => return probe_binary(program),
450 };
451 if let Some(known) = guard.get(program) {
452 return *known;
453 }
454 let available = probe_binary(program);
455 guard.insert(program.to_string(), available);
456 available
457}
458
459fn probe_binary(program: &str) -> bool {
461 std::process::Command::new(resolve_program(program))
462 .arg("--version")
463 .stdin(std::process::Stdio::null())
464 .output()
465 .map(|o| o.status.success())
466 .unwrap_or(false)
467}
468
469struct CommandOutput {
471 status: std::process::ExitStatus,
472 stdout: String,
473 stderr: String,
474}
475
476fn spawn_capture(
483 program: &str,
484 args: &[&str],
485 cwd: &Path,
486 timeout: std::time::Duration,
487) -> Result<CommandOutput> {
488 use std::io::Read;
489 use std::process::{Command, Stdio};
490 use std::thread;
491 use std::time::Instant;
492
493 let resolved = resolve_program(program);
494 let mut child = Command::new(&resolved)
495 .args(args)
496 .current_dir(cwd)
497 .stdin(Stdio::null())
498 .stdout(Stdio::piped())
499 .stderr(Stdio::piped())
500 .spawn()
501 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
502
503 let mut stdout_pipe = child.stdout.take();
507 let mut stderr_pipe = child.stderr.take();
508 let stdout_reader = thread::spawn(move || {
509 let mut buf = Vec::new();
510 if let Some(pipe) = stdout_pipe.as_mut() {
511 let _ = pipe.read_to_end(&mut buf);
512 }
513 buf
514 });
515 let stderr_reader = thread::spawn(move || {
516 let mut buf = Vec::new();
517 if let Some(pipe) = stderr_pipe.as_mut() {
518 let _ = pipe.read_to_end(&mut buf);
519 }
520 buf
521 });
522
523 let start = Instant::now();
524 let status = loop {
525 match child.try_wait()? {
526 Some(status) => break status,
527 None => {
528 if start.elapsed() >= timeout {
529 let _ = child.kill();
530 let _ = child.wait();
531 anyhow::bail!(
532 "Command timed out after {}s: {} {}\n\
533 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
534 timeout.as_secs(),
535 program,
536 args.join(" ")
537 );
538 }
539 thread::sleep(std::time::Duration::from_millis(100));
540 }
541 }
542 };
543
544 let stderr = stderr_reader
545 .join()
546 .map(|b| String::from_utf8_lossy(&b).into_owned())
547 .unwrap_or_default();
548 let stdout = stdout_reader
549 .join()
550 .map(|b| String::from_utf8_lossy(&b).into_owned())
551 .unwrap_or_default();
552
553 Ok(CommandOutput {
554 status,
555 stdout,
556 stderr,
557 })
558}
559
560pub fn run_command_with_timeout(
562 program: &str,
563 args: &[&str],
564 cwd: &Path,
565 timeout: std::time::Duration,
566) -> Result<()> {
567 let out = spawn_capture(program, args, cwd, timeout)?;
568 if out.status.success() {
569 Ok(())
570 } else {
571 anyhow::bail!(
572 "{} {} failed (exit code {:?}):\n{}",
573 program,
574 args.join(" "),
575 out.status.code(),
576 out.stderr.trim()
577 )
578 }
579}
580
581pub fn capture_command_with_timeout(
587 program: &str,
588 args: &[&str],
589 cwd: &Path,
590 timeout: std::time::Duration,
591) -> Result<String> {
592 let out = spawn_capture(program, args, cwd, timeout)?;
593 if out.status.success() {
594 Ok(out.stdout)
595 } else {
596 anyhow::bail!(
597 "{} {} failed (exit code {:?}):\n{}",
598 program,
599 args.join(" "),
600 out.status.code(),
601 out.stderr.trim()
602 )
603 }
604}
605
606pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
608 std::process::Command::new(resolve_program(program))
609 .args(args)
610 .current_dir(cwd)
611 .stdin(std::process::Stdio::null())
612 .output()
613 .map(|o| o.status.success())
614 .unwrap_or(false)
615}
616
617const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
625
626fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
633 let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
634 Some("Cargo.lock") => "Cargo.toml",
635 Some("package-lock.json")
636 | Some("yarn.lock")
637 | Some("pnpm-lock.yaml")
638 | Some("bun.lockb")
639 | Some("bun.lock") => "package.json",
640 Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
641 Some("go.sum") => "go.mod",
642 Some("composer.lock") => "composer.json",
643 _ => return Ok(()),
644 };
645 let manifest = cwd.join(manifest_name);
646 let (Ok(manifest_meta), Ok(lock_meta)) =
647 (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
648 else {
649 return Ok(());
650 };
651 if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified()) {
652 if manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE {
653 anyhow::bail!(
654 "`{program}` is not available, and `{manifest_name}` has been edited more \
655 recently than `{}` — the lockfile may no longer record the current \
656 dependencies, and without `{program}` that cannot be verified. Install \
657 {program} and run its lockfile sync, then prune again.",
658 lockfile.display()
659 );
660 }
661 }
662 Ok(())
663}
664
665pub fn lock_sync_or_verify_with_timeout(
667 lockfile: &Path,
668 program: &str,
669 sync_args: &[&str],
670 cwd: &Path,
671 timeout: std::time::Duration,
672) -> Result<()> {
673 let lockfile_exists = lockfile.exists();
674
675 if !binary_available(program) {
676 if lockfile_exists {
677 refuse_if_manifest_newer(lockfile, program, cwd)?;
678 return Ok(());
679 } else {
680 anyhow::bail!(
681 "`{program}` is not available and no lockfile was found at `{}`. \
682 Cannot safely delete dependencies — install {program} first, \
683 or commit a lockfile.",
684 lockfile.display()
685 );
686 }
687 }
688
689 run_command_with_timeout(program, sync_args, cwd, timeout)
691}
692
693#[derive(Debug, Clone, Copy)]
699pub struct EnforcePolicy {
700 pub allow_rewrite: bool,
706 pub timeout: std::time::Duration,
708}
709
710impl Default for EnforcePolicy {
711 fn default() -> Self {
712 Self {
713 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
714 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
715 }
716 }
717}
718
719impl EnforcePolicy {
720 pub fn from_settings(settings: &crate::config::Settings) -> Self {
722 Self {
723 allow_rewrite: settings.allow_manifest_rewrite,
724 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
725 }
726 }
727}
728
729pub fn enforce_two_tier(
745 lockfile: &Path,
746 program: &str,
747 verify_args: &[&str],
748 write_args: &[&str],
749 cwd: &Path,
750 policy: EnforcePolicy,
751) -> Result<()> {
752 if policy.allow_rewrite {
753 return lock_sync_or_verify_with_timeout(
754 lockfile,
755 program,
756 write_args,
757 cwd,
758 policy.timeout,
759 );
760 }
761 lock_verify_or_generate(
762 lockfile,
763 program,
764 verify_args,
765 write_args,
766 cwd,
767 policy.timeout,
768 )
769}
770
771pub fn lock_verify_or_generate(
781 lockfile: &Path,
782 program: &str,
783 verify_args: &[&str],
784 generate_args: &[&str],
785 cwd: &Path,
786 timeout: std::time::Duration,
787) -> Result<()> {
788 let lockfile_exists = lockfile.exists();
789
790 if !binary_available(program) {
791 if lockfile_exists {
792 refuse_if_manifest_newer(lockfile, program, cwd)?;
793 return Ok(());
794 }
795 anyhow::bail!(
796 "`{program}` is not available and no lockfile was found at `{}`. \
797 Cannot safely delete dependencies — install {program} first, \
798 or commit a lockfile.",
799 lockfile.display()
800 );
801 }
802
803 if lockfile_exists {
804 run_command_with_timeout(program, verify_args, cwd, timeout)
805 } else {
806 run_command_with_timeout(program, generate_args, cwd, timeout)
807 }
808}
809
810pub fn lock_sync_or_verify(
812 lockfile: &Path,
813 program: &str,
814 sync_args: &[&str],
815 cwd: &Path,
816) -> Result<()> {
817 lock_sync_or_verify_with_timeout(
818 lockfile,
819 program,
820 sync_args,
821 cwd,
822 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
823 )
824}
825
826#[derive(Debug, Clone)]
828pub struct BinaryCheckStatus {
829 pub name: String,
830 pub available: bool,
831 pub version: Option<String>,
832}
833
834pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
836 let mut unique: Vec<String> = adapter_names
837 .iter()
838 .filter(|&n| n != "-" && n != "venv")
839 .cloned()
840 .collect();
841 unique.sort();
842 unique.dedup();
843
844 unique
845 .into_iter()
846 .map(|name| {
847 let output = std::process::Command::new(resolve_program(&name))
848 .arg("--version")
849 .stdin(std::process::Stdio::null())
850 .output();
851 match output {
852 Ok(out) if out.status.success() => {
853 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
854 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
855 BinaryCheckStatus {
856 name,
857 available: true,
858 version: if first_line.is_empty() {
859 None
860 } else {
861 Some(first_line)
862 },
863 }
864 }
865 _ => BinaryCheckStatus {
866 name,
867 available: false,
868 version: None,
869 },
870 }
871 })
872 .collect()
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use std::fs;
879 use tempfile::TempDir;
880
881 #[test]
882 fn test_bloat_dir_display() {
883 let bd = BloatDir {
884 name: "node_modules".to_string(),
885 path: PathBuf::from("/test/node_modules"),
886 size_bytes: 1024,
887 shared_bytes: 0,
888 };
889 assert!(bd.to_string().contains("node_modules"));
890 }
891
892 #[test]
893 fn test_hardlink_size_counts_a_plain_file_in_full() {
894 let tmp = TempDir::new().unwrap();
895 let tree = tmp.path().join("tree");
896 fs::create_dir(&tree).unwrap();
897 fs::write(tree.join("copied.txt"), "12345").unwrap();
898 let size = dir_size_with_hardlinks(&tree);
899 assert_eq!(size.freed_bytes, 5);
900 assert_eq!(size.shared_bytes, 0);
901 }
902
903 #[test]
904 fn test_hardlink_size_excludes_a_file_the_store_keeps() {
905 let tmp = TempDir::new().unwrap();
908 let store = tmp.path().join("store");
909 let tree = tmp.path().join("tree");
910 fs::create_dir(&store).unwrap();
911 fs::create_dir(&tree).unwrap();
912 fs::write(store.join("pkg.js"), "0123456789").unwrap();
913 fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
914 let size = dir_size_with_hardlinks(&tree);
915 assert_eq!(size.freed_bytes, 0);
916 assert_eq!(size.shared_bytes, 10);
917 }
918
919 #[test]
920 fn test_hardlink_size_counts_an_internal_pair_once() {
921 let tmp = TempDir::new().unwrap();
924 let tree = tmp.path().join("tree");
925 fs::create_dir(&tree).unwrap();
926 fs::write(tree.join("a.js"), "abcdefg").unwrap();
927 fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
928 let size = dir_size_with_hardlinks(&tree);
929 assert_eq!(size.freed_bytes, 7);
930 assert_eq!(size.shared_bytes, 0);
931 }
932
933 #[test]
934 fn test_dir_size_empty() {
935 let tmp = TempDir::new().unwrap();
936 assert_eq!(dir_size(tmp.path()), 0);
937 }
938
939 #[test]
940 fn test_dir_size_with_files() {
941 let tmp = TempDir::new().unwrap();
942 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
943 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
944 assert_eq!(dir_size(tmp.path()), 11); }
946
947 #[test]
948 fn test_dir_size_nonexistent() {
949 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
950 }
951
952 #[test]
953 fn test_get_all_adapters_not_empty() {
954 let adapters = get_all_adapters();
955 assert!(adapters.len() >= 6);
956 }
957
958 #[test]
959 fn test_detect_adapters_npm() {
960 let tmp = TempDir::new().unwrap();
961 fs::write(tmp.path().join("package.json"), "{}").unwrap();
962 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
963 let adapters = detect_adapters(tmp.path());
964 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
965 assert!(names.contains(&"npm"));
966 }
967
968 #[test]
969 fn test_detect_adapters_empty_dir() {
970 let tmp = TempDir::new().unwrap();
971 let adapters = detect_adapters(tmp.path());
972 assert!(adapters.is_empty());
973 }
974
975 fn detected_names(dir: &Path) -> Vec<&'static str> {
977 let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
978 names.sort_unstable();
979 names
980 }
981
982 #[test]
983 fn test_detect_adapters_multiple_ecosystems_coexist() {
984 let tmp = TempDir::new().unwrap();
986 fs::write(tmp.path().join("package.json"), "{}").unwrap();
987 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
988 fs::write(tmp.path().join("uv.lock"), "").unwrap();
989 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
990 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
991
992 assert_eq!(detected_names(tmp.path()), vec!["cargo", "go", "npm", "uv"]);
993 }
994
995 #[test]
996 fn test_js_conflict_resolved_by_package_manager_field() {
997 let tmp = TempDir::new().unwrap();
998 fs::write(
999 tmp.path().join("package.json"),
1000 r#"{"packageManager":"yarn@4.1.0"}"#,
1001 )
1002 .unwrap();
1003 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1004 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1005 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1006
1007 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1008 }
1009
1010 #[test]
1011 fn test_js_conflict_resolved_by_what_installed_node_modules() {
1012 let tmp = TempDir::new().unwrap();
1015 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1016 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1017 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1018 std::thread::sleep(std::time::Duration::from_millis(20));
1019 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1020
1021 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1022 }
1023
1024 #[test]
1025 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1026 let tmp = TempDir::new().unwrap();
1028 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1029 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1030 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1031 let nm = tmp.path().join("node_modules");
1032 fs::create_dir_all(&nm).unwrap();
1033 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1034 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1035
1036 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1037 }
1038
1039 #[test]
1040 fn test_declared_package_manager_outranks_what_is_installed() {
1041 let tmp = TempDir::new().unwrap();
1043 fs::write(
1044 tmp.path().join("package.json"),
1045 r#"{"packageManager":"pnpm@9.1.0"}"#,
1046 )
1047 .unwrap();
1048 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1049 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1050 let nm = tmp.path().join("node_modules");
1051 fs::create_dir_all(&nm).unwrap();
1052 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1053
1054 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1055 }
1056
1057 #[test]
1058 fn test_uv_takes_precedence_over_plain_venv() {
1059 let tmp = TempDir::new().unwrap();
1062 fs::write(
1063 tmp.path().join("pyproject.toml"),
1064 "[project]\nname = \"x\"\n\n[tool.uv]\n",
1065 )
1066 .unwrap();
1067 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1068 let venv = tmp.path().join(".venv");
1069 fs::create_dir_all(&venv).unwrap();
1070 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1071
1072 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1073 }
1074
1075 #[test]
1076 fn test_plain_venv_handles_projects_uv_does_not_claim() {
1077 let tmp = TempDir::new().unwrap();
1078 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1079 let venv = tmp.path().join("venv");
1080 fs::create_dir_all(&venv).unwrap();
1081 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1082
1083 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1084 }
1085
1086 #[test]
1087 fn test_js_conflict_falls_back_to_newest_lockfile() {
1088 let tmp = TempDir::new().unwrap();
1089 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1090 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1091 std::thread::sleep(std::time::Duration::from_millis(20));
1094 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1095
1096 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1097 }
1098
1099 #[test]
1100 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1101 let tmp = TempDir::new().unwrap();
1104 fs::write(
1105 tmp.path().join("package.json"),
1106 r#"{"packageManager":"deno@2.0.0"}"#,
1107 )
1108 .unwrap();
1109 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1110 std::thread::sleep(std::time::Duration::from_millis(20));
1111 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1112
1113 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1114 }
1115
1116 #[test]
1117 fn test_js_conflict_does_not_disturb_a_single_manager() {
1118 let tmp = TempDir::new().unwrap();
1119 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1120 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1121 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1122 }
1123
1124 #[test]
1125 fn test_js_adapters_declare_their_lockfiles() {
1126 for adapter in get_all_adapters() {
1127 if JS_MANAGERS.contains(&adapter.name()) {
1128 assert!(
1129 !adapter.lockfiles().is_empty(),
1130 "{} shares node_modules and must declare its lockfiles for \
1131 conflict resolution",
1132 adapter.name()
1133 );
1134 }
1135 }
1136 }
1137
1138 #[test]
1139 fn test_adapter_names_unique() {
1140 let adapters = get_all_adapters();
1141 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1142 let mut unique = names.clone();
1143 unique.sort();
1144 unique.dedup();
1145 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1146 }
1147}