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}
46
47impl fmt::Display for BloatDir {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 write!(f, "{} ({})", self.name, self.path.display())
50 }
51}
52
53pub trait PackageManager: Send + Sync {
61 fn name(&self) -> &'static str;
63
64 fn detect(&self, project_path: &Path) -> bool;
68
69 fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
73
74 fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
81
82 fn restore(&self, project_path: &Path) -> Result<()>;
84
85 fn lockfiles(&self) -> &'static [&'static str] {
95 &[]
96 }
97}
98
99const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
101
102const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
110 ("pnpm", &[".pnpm", ".modules.yaml"]),
111 ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
112 ("npm", &[".package-lock.json"]),
113];
114
115pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
119 vec![
120 Box::new(npm::Npm),
121 Box::new(pnpm::Pnpm),
122 Box::new(yarn::Yarn),
123 Box::new(bun::Bun),
124 Box::new(uv::Uv),
125 Box::new(venv::Venv),
126 Box::new(cargo_adapter::Cargo),
127 Box::new(go::Go),
128 ]
129}
130
131pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
138 let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
139 .into_iter()
140 .filter(|adapter| adapter.detect(project_path))
141 .collect();
142 resolve_conflicts(project_path, &mut detected);
143 detected
144}
145
146fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
148 resolve_js_conflict(project_path, detected);
149 resolve_python_conflict(detected);
150}
151
152fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
165 if detected
166 .iter()
167 .filter(|a| JS_MANAGERS.contains(&a.name()))
168 .count()
169 < 2
170 {
171 return;
172 }
173
174 let winner = declared_package_manager(project_path)
175 .filter(|name| detected.iter().any(|a| a.name() == name))
176 .or_else(|| installed_manager(project_path, detected))
177 .or_else(|| newest_lockfile_owner(project_path, detected));
178
179 let Some(winner) = winner else { return };
180 detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
181}
182
183fn resolve_python_conflict(detected: &mut Vec<Box<dyn PackageManager>>) {
190 if detected.iter().any(|a| a.name() == "uv") {
191 detected.retain(|a| a.name() != "venv");
192 }
193}
194
195fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
197 let node_modules = project_path.join("node_modules");
198 if !node_modules.is_dir() {
199 return None;
200 }
201
202 JS_INSTALL_MARKERS
203 .iter()
204 .find(|(name, markers)| {
205 detected.iter().any(|a| a.name() == *name)
206 && markers.iter().any(|m| node_modules.join(m).exists())
207 })
208 .map(|(name, _)| (*name).to_string())
209}
210
211fn declared_package_manager(project_path: &Path) -> Option<String> {
213 let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
214 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
215 let declared = json.get("packageManager")?.as_str()?;
216 let name = declared.split('@').next().unwrap_or_default();
217 JS_MANAGERS
218 .iter()
219 .find(|m| **m == name)
220 .map(|m| (*m).to_string())
221}
222
223fn newest_lockfile_owner(
225 project_path: &Path,
226 detected: &[Box<dyn PackageManager>],
227) -> Option<String> {
228 detected
229 .iter()
230 .filter(|a| JS_MANAGERS.contains(&a.name()))
231 .filter_map(|a| {
232 let newest = a
233 .lockfiles()
234 .iter()
235 .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
236 .max()?;
237 Some((newest, a.name().to_string()))
238 })
239 .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
242 match best {
243 Some(b) if b.0 >= cur.0 => Some(b),
244 _ => Some(cur),
245 }
246 })
247 .map(|(_, name)| name)
248}
249
250pub fn dir_size(path: &Path) -> u64 {
252 if !path.exists() {
253 return 0;
254 }
255 WalkDir::new(path)
256 .follow_links(false)
257 .into_iter()
258 .flatten()
259 .filter_map(|entry| entry.metadata().ok())
260 .filter(|meta| meta.is_file())
261 .map(|meta| meta.len())
262 .sum()
263}
264
265pub fn resolve_program(program: &str) -> String {
275 #[cfg(windows)]
276 {
277 if Path::new(program).components().count() > 1 {
278 return program.to_string();
279 }
280 let Some(path_var) = std::env::var_os("PATH") else {
281 return program.to_string();
282 };
283 for dir in std::env::split_paths(&path_var) {
284 for ext in ["exe", "cmd", "bat"] {
285 let candidate = dir.join(format!("{program}.{ext}"));
286 if candidate.is_file() {
287 return candidate.to_string_lossy().into_owned();
288 }
289 }
290 }
291 }
292 program.to_string()
293}
294
295pub fn binary_available(program: &str) -> bool {
303 static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
304 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
305
306 let mut guard = match cache.lock() {
309 Ok(g) => g,
310 Err(_) => return probe_binary(program),
313 };
314 if let Some(known) = guard.get(program) {
315 return *known;
316 }
317 let available = probe_binary(program);
318 guard.insert(program.to_string(), available);
319 available
320}
321
322fn probe_binary(program: &str) -> bool {
324 std::process::Command::new(resolve_program(program))
325 .arg("--version")
326 .stdin(std::process::Stdio::null())
327 .output()
328 .map(|o| o.status.success())
329 .unwrap_or(false)
330}
331
332struct CommandOutput {
334 status: std::process::ExitStatus,
335 stdout: String,
336 stderr: String,
337}
338
339fn spawn_capture(
346 program: &str,
347 args: &[&str],
348 cwd: &Path,
349 timeout: std::time::Duration,
350) -> Result<CommandOutput> {
351 use std::io::Read;
352 use std::process::{Command, Stdio};
353 use std::thread;
354 use std::time::Instant;
355
356 let resolved = resolve_program(program);
357 let mut child = Command::new(&resolved)
358 .args(args)
359 .current_dir(cwd)
360 .stdin(Stdio::null())
361 .stdout(Stdio::piped())
362 .stderr(Stdio::piped())
363 .spawn()
364 .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
365
366 let mut stdout_pipe = child.stdout.take();
370 let mut stderr_pipe = child.stderr.take();
371 let stdout_reader = thread::spawn(move || {
372 let mut buf = Vec::new();
373 if let Some(pipe) = stdout_pipe.as_mut() {
374 let _ = pipe.read_to_end(&mut buf);
375 }
376 buf
377 });
378 let stderr_reader = thread::spawn(move || {
379 let mut buf = Vec::new();
380 if let Some(pipe) = stderr_pipe.as_mut() {
381 let _ = pipe.read_to_end(&mut buf);
382 }
383 buf
384 });
385
386 let start = Instant::now();
387 let status = loop {
388 match child.try_wait()? {
389 Some(status) => break status,
390 None => {
391 if start.elapsed() >= timeout {
392 let _ = child.kill();
393 let _ = child.wait();
394 anyhow::bail!(
395 "Command timed out after {}s: {} {}\n\
396 To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
397 timeout.as_secs(),
398 program,
399 args.join(" ")
400 );
401 }
402 thread::sleep(std::time::Duration::from_millis(100));
403 }
404 }
405 };
406
407 let stderr = stderr_reader
408 .join()
409 .map(|b| String::from_utf8_lossy(&b).into_owned())
410 .unwrap_or_default();
411 let stdout = stdout_reader
412 .join()
413 .map(|b| String::from_utf8_lossy(&b).into_owned())
414 .unwrap_or_default();
415
416 Ok(CommandOutput {
417 status,
418 stdout,
419 stderr,
420 })
421}
422
423pub fn run_command_with_timeout(
425 program: &str,
426 args: &[&str],
427 cwd: &Path,
428 timeout: std::time::Duration,
429) -> Result<()> {
430 let out = spawn_capture(program, args, cwd, timeout)?;
431 if out.status.success() {
432 Ok(())
433 } else {
434 anyhow::bail!(
435 "{} {} failed (exit code {:?}):\n{}",
436 program,
437 args.join(" "),
438 out.status.code(),
439 out.stderr.trim()
440 )
441 }
442}
443
444pub fn capture_command_with_timeout(
450 program: &str,
451 args: &[&str],
452 cwd: &Path,
453 timeout: std::time::Duration,
454) -> Result<String> {
455 let out = spawn_capture(program, args, cwd, timeout)?;
456 if out.status.success() {
457 Ok(out.stdout)
458 } else {
459 anyhow::bail!(
460 "{} {} failed (exit code {:?}):\n{}",
461 program,
462 args.join(" "),
463 out.status.code(),
464 out.stderr.trim()
465 )
466 }
467}
468
469pub fn run_command(program: &str, args: &[&str], cwd: &Path) -> Result<()> {
471 run_command_with_timeout(
472 program,
473 args,
474 cwd,
475 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
476 )
477}
478
479pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
481 std::process::Command::new(resolve_program(program))
482 .args(args)
483 .current_dir(cwd)
484 .stdin(std::process::Stdio::null())
485 .output()
486 .map(|o| o.status.success())
487 .unwrap_or(false)
488}
489
490pub fn lock_sync_or_verify_with_timeout(
492 lockfile: &Path,
493 program: &str,
494 sync_args: &[&str],
495 cwd: &Path,
496 timeout: std::time::Duration,
497) -> Result<()> {
498 let lockfile_exists = lockfile.exists();
499
500 if !binary_available(program) {
501 if lockfile_exists {
502 return Ok(());
503 } else {
504 anyhow::bail!(
505 "`{program}` is not available and no lockfile was found at `{}`. \
506 Cannot safely delete dependencies — install {program} first, \
507 or commit a lockfile.",
508 lockfile.display()
509 );
510 }
511 }
512
513 run_command_with_timeout(program, sync_args, cwd, timeout)
515}
516
517#[derive(Debug, Clone, Copy)]
523pub struct EnforcePolicy {
524 pub allow_rewrite: bool,
530 pub timeout: std::time::Duration,
532}
533
534impl Default for EnforcePolicy {
535 fn default() -> Self {
536 Self {
537 allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
538 timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
539 }
540 }
541}
542
543impl EnforcePolicy {
544 pub fn from_settings(settings: &crate::config::Settings) -> Self {
546 Self {
547 allow_rewrite: settings.allow_manifest_rewrite,
548 timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
549 }
550 }
551}
552
553pub fn enforce_two_tier(
569 lockfile: &Path,
570 program: &str,
571 verify_args: &[&str],
572 write_args: &[&str],
573 cwd: &Path,
574 policy: EnforcePolicy,
575) -> Result<()> {
576 if policy.allow_rewrite {
577 return lock_sync_or_verify_with_timeout(
578 lockfile,
579 program,
580 write_args,
581 cwd,
582 policy.timeout,
583 );
584 }
585 lock_verify_or_generate(
586 lockfile,
587 program,
588 verify_args,
589 write_args,
590 cwd,
591 policy.timeout,
592 )
593}
594
595pub fn lock_verify_or_generate(
605 lockfile: &Path,
606 program: &str,
607 verify_args: &[&str],
608 generate_args: &[&str],
609 cwd: &Path,
610 timeout: std::time::Duration,
611) -> Result<()> {
612 let lockfile_exists = lockfile.exists();
613
614 if !binary_available(program) {
615 if lockfile_exists {
616 return Ok(());
617 }
618 anyhow::bail!(
619 "`{program}` is not available and no lockfile was found at `{}`. \
620 Cannot safely delete dependencies — install {program} first, \
621 or commit a lockfile.",
622 lockfile.display()
623 );
624 }
625
626 if lockfile_exists {
627 run_command_with_timeout(program, verify_args, cwd, timeout)
628 } else {
629 run_command_with_timeout(program, generate_args, cwd, timeout)
630 }
631}
632
633pub fn lock_sync_or_verify(
635 lockfile: &Path,
636 program: &str,
637 sync_args: &[&str],
638 cwd: &Path,
639) -> Result<()> {
640 lock_sync_or_verify_with_timeout(
641 lockfile,
642 program,
643 sync_args,
644 cwd,
645 std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
646 )
647}
648
649#[derive(Debug, Clone)]
651pub struct BinaryCheckStatus {
652 pub name: String,
653 pub available: bool,
654 pub version: Option<String>,
655}
656
657pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
659 let mut unique: Vec<String> = adapter_names
660 .iter()
661 .filter(|&n| n != "-" && n != "venv")
662 .cloned()
663 .collect();
664 unique.sort();
665 unique.dedup();
666
667 unique
668 .into_iter()
669 .map(|name| {
670 let output = std::process::Command::new(resolve_program(&name))
671 .arg("--version")
672 .stdin(std::process::Stdio::null())
673 .output();
674 match output {
675 Ok(out) if out.status.success() => {
676 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
677 let first_line = ver.lines().next().unwrap_or(&ver).to_string();
678 BinaryCheckStatus {
679 name,
680 available: true,
681 version: if first_line.is_empty() {
682 None
683 } else {
684 Some(first_line)
685 },
686 }
687 }
688 _ => BinaryCheckStatus {
689 name,
690 available: false,
691 version: None,
692 },
693 }
694 })
695 .collect()
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use std::fs;
702 use tempfile::TempDir;
703
704 #[test]
705 fn test_bloat_dir_display() {
706 let bd = BloatDir {
707 name: "node_modules".to_string(),
708 path: PathBuf::from("/test/node_modules"),
709 size_bytes: 1024,
710 };
711 assert!(bd.to_string().contains("node_modules"));
712 }
713
714 #[test]
715 fn test_dir_size_empty() {
716 let tmp = TempDir::new().unwrap();
717 assert_eq!(dir_size(tmp.path()), 0);
718 }
719
720 #[test]
721 fn test_dir_size_with_files() {
722 let tmp = TempDir::new().unwrap();
723 fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
724 fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
725 assert_eq!(dir_size(tmp.path()), 11); }
727
728 #[test]
729 fn test_dir_size_nonexistent() {
730 assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
731 }
732
733 #[test]
734 fn test_get_all_adapters_not_empty() {
735 let adapters = get_all_adapters();
736 assert!(adapters.len() >= 6);
737 }
738
739 #[test]
740 fn test_detect_adapters_npm() {
741 let tmp = TempDir::new().unwrap();
742 fs::write(tmp.path().join("package.json"), "{}").unwrap();
743 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
744 let adapters = detect_adapters(tmp.path());
745 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
746 assert!(names.contains(&"npm"));
747 }
748
749 #[test]
750 fn test_detect_adapters_empty_dir() {
751 let tmp = TempDir::new().unwrap();
752 let adapters = detect_adapters(tmp.path());
753 assert!(adapters.is_empty());
754 }
755
756 fn detected_names(dir: &Path) -> Vec<&'static str> {
758 let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
759 names.sort_unstable();
760 names
761 }
762
763 #[test]
764 fn test_detect_adapters_multiple_ecosystems_coexist() {
765 let tmp = TempDir::new().unwrap();
767 fs::write(tmp.path().join("package.json"), "{}").unwrap();
768 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
769 fs::write(tmp.path().join("uv.lock"), "").unwrap();
770 fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
771 fs::write(tmp.path().join("go.mod"), "module x").unwrap();
772
773 assert_eq!(detected_names(tmp.path()), vec!["cargo", "go", "npm", "uv"]);
774 }
775
776 #[test]
777 fn test_js_conflict_resolved_by_package_manager_field() {
778 let tmp = TempDir::new().unwrap();
779 fs::write(
780 tmp.path().join("package.json"),
781 r#"{"packageManager":"yarn@4.1.0"}"#,
782 )
783 .unwrap();
784 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
785 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
786 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
787
788 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
789 }
790
791 #[test]
792 fn test_js_conflict_resolved_by_what_installed_node_modules() {
793 let tmp = TempDir::new().unwrap();
796 fs::write(tmp.path().join("package.json"), "{}").unwrap();
797 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
798 fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
799 std::thread::sleep(std::time::Duration::from_millis(20));
800 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
801
802 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
803 }
804
805 #[test]
806 fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
807 let tmp = TempDir::new().unwrap();
809 fs::write(tmp.path().join("package.json"), "{}").unwrap();
810 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
811 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
812 let nm = tmp.path().join("node_modules");
813 fs::create_dir_all(&nm).unwrap();
814 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
815 fs::write(nm.join(".yarn-state.yml"), "").unwrap();
816
817 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
818 }
819
820 #[test]
821 fn test_declared_package_manager_outranks_what_is_installed() {
822 let tmp = TempDir::new().unwrap();
824 fs::write(
825 tmp.path().join("package.json"),
826 r#"{"packageManager":"pnpm@9.1.0"}"#,
827 )
828 .unwrap();
829 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
830 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
831 let nm = tmp.path().join("node_modules");
832 fs::create_dir_all(&nm).unwrap();
833 fs::write(nm.join(".package-lock.json"), "{}").unwrap();
834
835 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
836 }
837
838 #[test]
839 fn test_uv_takes_precedence_over_plain_venv() {
840 let tmp = TempDir::new().unwrap();
843 fs::write(
844 tmp.path().join("pyproject.toml"),
845 "[project]\nname = \"x\"\n\n[tool.uv]\n",
846 )
847 .unwrap();
848 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
849 let venv = tmp.path().join(".venv");
850 fs::create_dir_all(&venv).unwrap();
851 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
852
853 assert_eq!(detected_names(tmp.path()), vec!["uv"]);
854 }
855
856 #[test]
857 fn test_plain_venv_handles_projects_uv_does_not_claim() {
858 let tmp = TempDir::new().unwrap();
859 fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
860 let venv = tmp.path().join("venv");
861 fs::create_dir_all(&venv).unwrap();
862 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
863
864 assert_eq!(detected_names(tmp.path()), vec!["venv"]);
865 }
866
867 #[test]
868 fn test_js_conflict_falls_back_to_newest_lockfile() {
869 let tmp = TempDir::new().unwrap();
870 fs::write(tmp.path().join("package.json"), "{}").unwrap();
871 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
872 std::thread::sleep(std::time::Duration::from_millis(20));
875 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
876
877 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
878 }
879
880 #[test]
881 fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
882 let tmp = TempDir::new().unwrap();
885 fs::write(
886 tmp.path().join("package.json"),
887 r#"{"packageManager":"deno@2.0.0"}"#,
888 )
889 .unwrap();
890 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
891 std::thread::sleep(std::time::Duration::from_millis(20));
892 fs::write(tmp.path().join("yarn.lock"), "").unwrap();
893
894 assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
895 }
896
897 #[test]
898 fn test_js_conflict_does_not_disturb_a_single_manager() {
899 let tmp = TempDir::new().unwrap();
900 fs::write(tmp.path().join("package.json"), "{}").unwrap();
901 fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
902 assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
903 }
904
905 #[test]
906 fn test_js_adapters_declare_their_lockfiles() {
907 for adapter in get_all_adapters() {
908 if JS_MANAGERS.contains(&adapter.name()) {
909 assert!(
910 !adapter.lockfiles().is_empty(),
911 "{} shares node_modules and must declare its lockfiles for \
912 conflict resolution",
913 adapter.name()
914 );
915 }
916 }
917 }
918
919 #[test]
920 fn test_adapter_names_unique() {
921 let adapters = get_all_adapters();
922 let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
923 let mut unique = names.clone();
924 unique.sort();
925 unique.dedup();
926 assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
927 }
928}