1use crate::lock::{LockCollector, write_lockfile};
2use crate::report::Report;
3use flate2::read::GzDecoder;
4use home::home_dir;
5use nodejs_semver::{Range, Version};
6use reqwest::blocking::Client;
7use serde::Deserialize;
8use serde_json::Value;
9use ssri::Integrity;
10use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use tar::Archive;
15use tempfile::tempdir;
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct Manifest {
19 pub name: String,
20 pub version: String,
21 #[serde(default)]
22 pub dependencies: Option<HashMap<String, String>>,
23 #[serde(default, rename = "devDependencies")]
24 pub dev_dependencies: Option<HashMap<String, String>>,
25 #[serde(default, rename = "optionalDependencies")]
26 pub optional_dependencies: Option<HashMap<String, String>>,
27 #[serde(default, rename = "peerDependencies")]
28 pub peer_dependencies: Option<HashMap<String, String>>,
29 #[serde(default)]
30 pub scripts: Option<HashMap<String, String>>,
31}
32
33pub fn read_manifest(path: &str) -> io::Result<Manifest> {
34 let manifest_path = find_manifest_path(Path::new(path))?;
35 read_manifest_from_path(&manifest_path)
36}
37
38fn read_manifest_from_path(path: &Path) -> io::Result<Manifest> {
39 let source = fs::read_to_string(path)?;
40 serde_json::from_str(&source).map_err(|source| {
41 io::Error::new(
42 io::ErrorKind::InvalidData,
43 format!("failed to parse {}: {source}", path.display()),
44 )
45 })
46}
47
48fn find_manifest_path(start: &Path) -> io::Result<PathBuf> {
49 let mut current = if start.is_dir() {
50 start.to_path_buf()
51 } else {
52 start
53 .parent()
54 .map(Path::to_path_buf)
55 .unwrap_or_else(|| PathBuf::from("."))
56 };
57
58 loop {
59 let candidate = current.join("package.json");
60 if candidate.is_file() {
61 return Ok(candidate);
62 }
63
64 if !current.pop() {
65 return Err(io::Error::new(
66 io::ErrorKind::NotFound,
67 format!("failed to find package.json from {}", start.display()),
68 ));
69 }
70 }
71}
72
73#[derive(Debug, Deserialize)]
74struct Packument {
75 versions: HashMap<String, RegistryVersion>,
76}
77
78#[derive(Debug, Deserialize)]
79struct RegistryVersion {
80 #[serde(default)]
81 dependencies: HashMap<String, String>,
82 #[serde(default)]
83 optional_dependencies: HashMap<String, String>,
84 #[serde(default)]
85 peer_dependencies: HashMap<String, String>,
86 dist: RegistryDist,
87}
88
89#[derive(Debug, Deserialize)]
90struct RegistryDist {
91 tarball: String,
92 integrity: Option<String>,
93}
94
95#[derive(Debug)]
96struct ResolvedPackage {
97 name: String,
98 version: String,
99 dependencies: HashMap<String, String>,
100 optional_dependencies: HashMap<String, String>,
101 peer_dependencies: HashMap<String, String>,
102 tarball_url: String,
103 integrity: Option<String>,
104}
105
106#[derive(Debug, Clone, Copy)]
107enum DependencyKind {
108 Prod,
109 Dev,
110 Optional,
111 Peer,
112}
113
114#[derive(Debug, Default)]
115struct InstallSummary {
116 prod_installed: usize,
117 dev_installed: usize,
118 optional_installed: usize,
119 peer_installed: usize,
120 warnings: Vec<String>,
121}
122
123impl InstallSummary {
124 fn record_install(&mut self, kind: DependencyKind) {
125 match kind {
126 DependencyKind::Prod => self.prod_installed += 1,
127 DependencyKind::Dev => self.dev_installed += 1,
128 DependencyKind::Optional => self.optional_installed += 1,
129 DependencyKind::Peer => self.peer_installed += 1,
130 }
131 }
132
133 fn warn(&mut self, warning: impl Into<String>) {
134 self.warnings.push(warning.into());
135 }
136}
137
138#[derive(Debug)]
139pub enum PmError {
140 HomeDirUnavailable,
141 MissingGlobalPackageSpec,
142 InvalidPackageSpec {
143 spec: String,
144 },
145 PackageNotInstalled {
146 name: String,
147 path: PathBuf,
148 },
149 FindManifest {
150 start: PathBuf,
151 source: io::Error,
152 },
153 ReadManifest {
154 path: PathBuf,
155 source: io::Error,
156 },
157 ParseManifest {
158 path: PathBuf,
159 source: serde_json::Error,
160 },
161 ProjectRootMissing {
162 path: PathBuf,
163 },
164 CreateDir {
165 path: PathBuf,
166 source: io::Error,
167 },
168 FetchMetadata {
169 package: String,
170 source: reqwest::Error,
171 },
172 MetadataStatus {
173 package: String,
174 source: reqwest::Error,
175 },
176 ReadMetadataBody {
177 package: String,
178 source: reqwest::Error,
179 },
180 ParseMetadata {
181 package: String,
182 source: serde_json::Error,
183 },
184 InvalidRange {
185 package: String,
186 range: String,
187 source: String,
188 },
189 VersionNotFound {
190 package: String,
191 range: String,
192 },
193 MissingResolvedVersion {
194 package: String,
195 version: String,
196 },
197 DownloadTarball {
198 package: String,
199 source: reqwest::Error,
200 },
201 TarballStatus {
202 package: String,
203 source: reqwest::Error,
204 },
205 ReadTarballBody {
206 package: String,
207 source: reqwest::Error,
208 },
209 InvalidIntegrity {
210 package: String,
211 version: String,
212 source: String,
213 },
214 IntegrityMismatch {
215 package: String,
216 version: String,
217 source: String,
218 },
219 ExtractTarball {
220 package: String,
221 source: io::Error,
222 },
223 MissingPackageDir {
224 package: String,
225 path: PathBuf,
226 },
227 RemoveExistingInstall {
228 path: PathBuf,
229 source: io::Error,
230 },
231 CopyInstall {
232 from: PathBuf,
233 to: PathBuf,
234 source: io::Error,
235 },
236 ReadInstalledManifest {
237 path: PathBuf,
238 source: io::Error,
239 },
240 MissingInstalledName {
241 path: PathBuf,
242 },
243 InvalidBinField {
244 path: PathBuf,
245 },
246 InvalidBinEntry {
247 path: PathBuf,
248 entry: String,
249 },
250 AmbiguousBinEntry {
251 package: String,
252 path: PathBuf,
253 available: Vec<String>,
254 },
255 MissingBinTarget {
256 package_dir: PathBuf,
257 target: PathBuf,
258 },
259 CreateTempDir {
260 source: io::Error,
261 },
262 CurrentDir {
263 source: io::Error,
264 },
265 WriteGeneratedManifest {
266 path: PathBuf,
267 source: io::Error,
268 },
269 WriteProcessOutput {
270 source: io::Error,
271 },
272 MissingPackageBinary {
273 package: String,
274 command: String,
275 path: PathBuf,
276 },
277 SpawnPackageBinary {
278 package: String,
279 command: PathBuf,
280 source: io::Error,
281 },
282 PackageBinaryFailed {
283 package: String,
284 command: PathBuf,
285 status: String,
286 stderr: Option<String>,
287 },
288 CreateBinLink {
289 command: String,
290 path: PathBuf,
291 source: io::Error,
292 },
293 RemoveBinLink {
294 command: String,
295 path: PathBuf,
296 source: io::Error,
297 },
298 RemoveInstalledPackage {
299 path: PathBuf,
300 source: io::Error,
301 },
302 ReadLockfile {
303 path: PathBuf,
304 source: io::Error,
305 },
306 ParseLockfile {
307 path: PathBuf,
308 source: serde_json::Error,
309 },
310 WriteLockfile {
311 path: PathBuf,
312 source: io::Error,
313 },
314 InvalidTempPath {
315 path: PathBuf,
316 },
317}
318
319impl PmError {
320 pub fn report(&self) -> Report {
321 match self {
322 Self::HomeDirUnavailable => Report::new("could not resolve home directory")
323 .detail("`$HOME` is unavailable in the current environment"),
324 Self::MissingGlobalPackageSpec => Report::new("global install requires a package name")
325 .detail("example: `o- install --global cowsay`"),
326 Self::InvalidPackageSpec { spec } => Report::new("failed to parse package spec")
327 .detail(format!("spec: {spec}"))
328 .detail("expected `name`, `name@version`, `@scope/name`, or `@scope/name@version`"),
329 Self::PackageNotInstalled { name, path } => {
330 Report::new(format!("package `{name}` is not installed"))
331 .detail(format!("path: {}", path.display()))
332 }
333 Self::FindManifest { start, source } => Report::new("failed to find package.json")
334 .detail(format!("start: {}", start.display()))
335 .detail(format!("cause: {source}")),
336 Self::ReadManifest { path, source } => Report::new("failed to read package.json")
337 .detail(format!("path: {}", path.display()))
338 .detail(format!("cause: {source}")),
339 Self::ParseManifest { path, source } => Report::new("failed to parse package.json")
340 .detail(format!("path: {}", path.display()))
341 .detail(format!("cause: {source}")),
342 Self::ProjectRootMissing { path } => Report::new("failed to resolve project root")
343 .detail(format!("path: {}", path.display())),
344 Self::CreateDir { path, source } => Report::new("failed to create directory")
345 .detail(format!("path: {}", path.display()))
346 .detail(format!("cause: {source}")),
347 Self::FetchMetadata { package, source } => {
348 Report::new(format!("failed to fetch package metadata for `{package}`"))
349 .detail(format!("cause: {source}"))
350 }
351 Self::MetadataStatus { package, source } => {
352 Report::new(format!("registry returned an error for `{package}`"))
353 .detail(format!("cause: {source}"))
354 }
355 Self::ReadMetadataBody { package, source } => Report::new(format!(
356 "failed to read package metadata body for `{package}`"
357 ))
358 .detail(format!("cause: {source}")),
359 Self::ParseMetadata { package, source } => {
360 Report::new(format!("failed to decode package metadata for `{package}`"))
361 .detail(format!("cause: {source}"))
362 }
363 Self::InvalidRange {
364 package,
365 range,
366 source,
367 } => Report::new(format!("invalid semver range for `{package}`"))
368 .detail(format!("range: {range}"))
369 .detail(format!("cause: {source}")),
370 Self::VersionNotFound { package, range } => Report::new(format!(
371 "no version of `{package}` satisfies the requested range"
372 ))
373 .detail(format!("range: {range}")),
374 Self::MissingResolvedVersion { package, version } => {
375 Report::new(format!("registry metadata is incomplete for `{package}`"))
376 .detail(format!("version: {version}"))
377 }
378 Self::DownloadTarball { package, source } => {
379 Report::new(format!("failed to download tarball for `{package}`"))
380 .detail(format!("cause: {source}"))
381 }
382 Self::TarballStatus { package, source } => {
383 Report::new(format!("tarball request failed for `{package}`"))
384 .detail(format!("cause: {source}"))
385 }
386 Self::ReadTarballBody { package, source } => {
387 Report::new(format!("failed to read tarball body for `{package}`"))
388 .detail(format!("cause: {source}"))
389 }
390 Self::InvalidIntegrity {
391 package,
392 version,
393 source,
394 } => Report::new(format!("registry integrity is invalid for `{package}`"))
395 .detail(format!("version: {version}"))
396 .detail(format!("cause: {source}")),
397 Self::IntegrityMismatch {
398 package,
399 version,
400 source,
401 } => Report::new(format!("integrity check failed for `{package}`"))
402 .detail(format!("version: {version}"))
403 .detail(format!("cause: {source}")),
404 Self::ExtractTarball { package, source } => {
405 Report::new(format!("failed to extract tarball for `{package}`"))
406 .detail(format!("cause: {source}"))
407 }
408 Self::MissingPackageDir { package, path } => {
409 Report::new(format!("downloaded tarball for `{package}` is malformed"))
410 .detail(format!("missing: {}", path.display()))
411 }
412 Self::RemoveExistingInstall { path, source } => {
413 Report::new("failed to remove existing package installation")
414 .detail(format!("path: {}", path.display()))
415 .detail(format!("cause: {source}"))
416 }
417 Self::CopyInstall { from, to, source } => Report::new("failed to copy package files")
418 .detail(format!("from: {}", from.display()))
419 .detail(format!("to: {}", to.display()))
420 .detail(format!("cause: {source}")),
421 Self::ReadInstalledManifest { path, source } => {
422 Report::new("failed to read installed package manifest")
423 .detail(format!("path: {}", path.display()))
424 .detail(format!("cause: {source}"))
425 }
426 Self::MissingInstalledName { path } => {
427 Report::new("installed package manifest is missing `name`")
428 .detail(format!("path: {}", path.display()))
429 }
430 Self::InvalidBinField { path } => {
431 Report::new("installed package has an invalid `bin` field")
432 .detail(format!("path: {}", path.display()))
433 }
434 Self::InvalidBinEntry { path, entry } => {
435 Report::new("installed package has an invalid `bin` entry")
436 .detail(format!("path: {}", path.display()))
437 .detail(format!("entry: {entry}"))
438 }
439 Self::AmbiguousBinEntry {
440 package,
441 path,
442 available,
443 } => Report::new(format!("package `{package}` exposes multiple bin commands"))
444 .detail(format!("path: {}", path.display()))
445 .detail(format!("available: {}", available.join(", "))),
446 Self::MissingBinTarget {
447 package_dir,
448 target,
449 } => Report::new("installed package bin target does not exist")
450 .detail(format!("package: {}", package_dir.display()))
451 .detail(format!("target: {}", target.display())),
452 Self::CreateTempDir { source } => Report::new("failed to create temporary directory")
453 .detail(format!("cause: {source}")),
454 Self::CurrentDir { source } => Report::new("failed to resolve current directory")
455 .detail(format!("cause: {source}")),
456 Self::WriteGeneratedManifest { path, source } => {
457 Report::new("failed to write generated package.json")
458 .detail(format!("path: {}", path.display()))
459 .detail(format!("cause: {source}"))
460 }
461 Self::WriteProcessOutput { source } => {
462 Report::new("failed to write package output").detail(format!("cause: {source}"))
463 }
464 Self::MissingPackageBinary {
465 package,
466 command,
467 path,
468 } => Report::new(format!(
469 "package `{package}` does not expose a runnable binary"
470 ))
471 .detail(format!("command: {command}"))
472 .detail(format!("expected shim: {}", path.display())),
473 Self::SpawnPackageBinary {
474 package,
475 command,
476 source,
477 } => Report::new(format!("failed to execute package binary for `{package}`"))
478 .detail(format!("command: {}", command.display()))
479 .detail(format!("cause: {source}")),
480 Self::PackageBinaryFailed {
481 package,
482 command,
483 status,
484 stderr,
485 } => {
486 let report = Report::new(format!("package binary `{package}` failed"))
487 .detail(format!("command: {}", command.display()))
488 .detail(format!("status: {status}"));
489 if let Some(stderr) = stderr {
490 report.detail(format!("stderr: {stderr}"))
491 } else {
492 report
493 }
494 }
495 Self::CreateBinLink {
496 command,
497 path,
498 source,
499 } => Report::new(format!("failed to create bin link `{command}`"))
500 .detail(format!("path: {}", path.display()))
501 .detail(format!("cause: {source}")),
502 Self::RemoveBinLink {
503 command,
504 path,
505 source,
506 } => Report::new(format!("failed to remove bin link `{command}`"))
507 .detail(format!("path: {}", path.display()))
508 .detail(format!("cause: {source}")),
509 Self::RemoveInstalledPackage { path, source } => {
510 Report::new("failed to remove installed package")
511 .detail(format!("path: {}", path.display()))
512 .detail(format!("cause: {source}"))
513 }
514 Self::ReadLockfile { path, source } => Report::new("failed to read package-lock.json")
515 .detail(format!("path: {}", path.display()))
516 .detail(format!("cause: {source}")),
517 Self::ParseLockfile { path, source } => {
518 Report::new("failed to parse package-lock.json")
519 .detail(format!("path: {}", path.display()))
520 .detail(format!("cause: {source}"))
521 }
522 Self::WriteLockfile { path, source } => {
523 Report::new("failed to write package-lock.json")
524 .detail(format!("path: {}", path.display()))
525 .detail(format!("cause: {source}"))
526 }
527 Self::InvalidTempPath { path } => Report::new("failed to run x")
528 .detail("note: tempdir failed".to_string())
529 .detail(format!("path: {}", path.display())),
530 }
531 }
532
533 fn warning_summary(&self) -> String {
534 self.report().summary().to_string()
535 }
536}
537
538pub fn install() -> Result<Report, PmError> {
539 install_from(".")
540}
541
542pub fn global_install(package_spec: Option<&str>) -> Result<Report, PmError> {
543 let package_spec = package_spec.ok_or(PmError::MissingGlobalPackageSpec)?;
544 if package_spec.trim().is_empty() {
545 return Err(PmError::MissingGlobalPackageSpec);
546 }
547 let (package_name, package_range) = parse_package_spec(package_spec);
548 let global_root = global_packages_root()?;
549 let node_modules = global_node_modules_dir(&global_root);
550 fs::create_dir_all(&node_modules).map_err(|source| PmError::CreateDir {
551 path: node_modules.clone(),
552 source,
553 })?;
554
555 let mut installed = HashSet::new();
556 let mut lock = LockCollector::new();
557 let mut root_dependencies = HashMap::new();
558 root_dependencies.insert(package_name.clone(), package_range.clone());
559 let empty_dependencies = HashMap::new();
560 lock.insert_root_fields(
561 "o--global",
562 "0.0.0",
563 &root_dependencies,
564 &empty_dependencies,
565 &empty_dependencies,
566 &empty_dependencies,
567 );
568
569 let mut summary = InstallSummary::default();
570 let client = Client::new();
571 install_dependency(
572 &client,
573 &global_root,
574 &package_name,
575 &package_range,
576 &node_modules,
577 &mut installed,
578 &mut lock,
579 &mut summary,
580 DependencyKind::Prod,
581 )?;
582
583 let installed_manifest_path = install_dir(&node_modules, &package_name).join("package.json");
584 let installed_manifest = read_manifest_from_path(&installed_manifest_path)
585 .map_err(|source| map_manifest_error(&installed_manifest_path, source))?;
586 let lockfile = lock.into_lockfile_fields("o--global", "0.0.0");
587 let lockfile_path =
588 write_lockfile(&global_root, &lockfile).map_err(|source| PmError::WriteLockfile {
589 path: global_root.join("package-lock.json"),
590 source,
591 })?;
592
593 Ok(Report::new(format!(
594 "installed global package `{}`",
595 installed_manifest.name
596 ))
597 .detail(format!("requested: {package_spec}"))
598 .detail(format!("resolved version: {}", installed_manifest.version))
599 .detail(format!("root: {}", global_root.display()))
600 .detail(format!(
601 "bin dir: {}",
602 global_bin_dir(&node_modules).display()
603 ))
604 .detail(format!("lockfile: {}", lockfile_path.display()))
605 .detail(format!("dependencies: {}", summary.prod_installed))
606 .detail(format!(
607 "optionalDependencies: {}",
608 summary.optional_installed
609 ))
610 .detail(format!("peerDependencies: {}", summary.peer_installed))
611 .detail(format!("peer warnings: {}", summary.warnings.len()))
612 .detail(if summary.warnings.is_empty() {
613 "peer/optional warnings: none".to_string()
614 } else {
615 format!("peer/optional warnings: {}", summary.warnings.join(" | "))
616 }))
617}
618
619pub fn install_from(path: &str) -> Result<Report, PmError> {
620 let manifest_path =
621 find_manifest_path(Path::new(path)).map_err(|source| PmError::FindManifest {
622 start: PathBuf::from(path),
623 source,
624 })?;
625 let project_root = manifest_path
626 .parent()
627 .ok_or_else(|| PmError::ProjectRootMissing {
628 path: manifest_path.clone(),
629 })?;
630 let manifest = read_manifest_from_path(&manifest_path)
631 .map_err(|source| map_manifest_error(&manifest_path, source))?;
632 let node_modules = project_root.join("node_modules");
633 fs::create_dir_all(&node_modules).map_err(|source| PmError::CreateDir {
634 path: node_modules.clone(),
635 source,
636 })?;
637
638 let mut installed = HashSet::new();
639 let mut lock = LockCollector::new();
640 lock.insert_root_fields(
641 &manifest.name,
642 &manifest.version,
643 &manifest.dependencies.clone().unwrap_or_default(),
644 &manifest.dev_dependencies.clone().unwrap_or_default(),
645 &manifest.optional_dependencies.clone().unwrap_or_default(),
646 &manifest.peer_dependencies.clone().unwrap_or_default(),
647 );
648 let mut summary = InstallSummary::default();
649 let client = Client::new();
650
651 let root_dependencies = manifest.dependencies.clone().unwrap_or_default();
652 let root_dev_dependencies = manifest.dev_dependencies.clone().unwrap_or_default();
653 let root_optional_dependencies = manifest.optional_dependencies.clone().unwrap_or_default();
654 let root_peer_dependencies = manifest.peer_dependencies.clone().unwrap_or_default();
655
656 install_dependency_set(
657 &client,
658 project_root,
659 &root_dependencies,
660 &node_modules,
661 &mut installed,
662 &mut lock,
663 &mut summary,
664 DependencyKind::Prod,
665 )?;
666 install_dependency_set(
667 &client,
668 project_root,
669 &root_dev_dependencies,
670 &node_modules,
671 &mut installed,
672 &mut lock,
673 &mut summary,
674 DependencyKind::Dev,
675 )?;
676 install_optional_dependency_set(
677 &client,
678 project_root,
679 &root_optional_dependencies,
680 &node_modules,
681 &mut installed,
682 &mut lock,
683 &mut summary,
684 "root package",
685 );
686 reconcile_peer_dependencies(
687 "root package",
688 &root_peer_dependencies,
689 &client,
690 project_root,
691 &node_modules,
692 &mut installed,
693 &mut lock,
694 &mut summary,
695 );
696
697 let lockfile = lock.into_lockfile_fields(&manifest.name, &manifest.version);
698 let lockfile_path =
699 write_lockfile(project_root, &lockfile).map_err(|source| PmError::WriteLockfile {
700 path: project_root.join("package-lock.json"),
701 source,
702 })?;
703
704 Ok(Report::new("installed project dependencies")
705 .detail(format!("root: {}", project_root.display()))
706 .detail(format!("dependencies: {}", summary.prod_installed))
707 .detail(format!("devDependencies: {}", summary.dev_installed))
708 .detail(format!(
709 "optionalDependencies: {}",
710 summary.optional_installed
711 ))
712 .detail(format!("peerDependencies: {}", summary.peer_installed))
713 .detail(format!("peer warnings: {}", summary.warnings.len()))
714 .detail(format!("lockfile: {}", lockfile_path.display()))
715 .detail(format!(
716 "declared dependencies: {}",
717 root_dependencies.len()
718 ))
719 .detail(format!(
720 "declared devDependencies: {}",
721 root_dev_dependencies.len()
722 ))
723 .detail(format!(
724 "declared optionalDependencies: {}",
725 root_optional_dependencies.len()
726 ))
727 .detail(format!(
728 "declared peerDependencies: {}",
729 root_peer_dependencies.len()
730 ))
731 .detail(if summary.warnings.is_empty() {
732 "peer/optional warnings: none".to_string()
733 } else {
734 format!("peer/optional warnings: {}", summary.warnings.join(" | "))
735 }))
736}
737
738fn install_dependency_set(
739 client: &Client,
740 project_root: &Path,
741 dependencies: &HashMap<String, String>,
742 node_modules_dir: &Path,
743 installed: &mut HashSet<String>,
744 lock: &mut LockCollector,
745 summary: &mut InstallSummary,
746 kind: DependencyKind,
747) -> Result<(), PmError> {
748 for (name, range) in dependencies {
749 install_dependency(
750 client,
751 project_root,
752 name,
753 range,
754 node_modules_dir,
755 installed,
756 lock,
757 summary,
758 kind,
759 )?;
760 }
761
762 Ok(())
763}
764
765fn install_optional_dependency_set(
766 client: &Client,
767 project_root: &Path,
768 dependencies: &HashMap<String, String>,
769 node_modules_dir: &Path,
770 installed: &mut HashSet<String>,
771 lock: &mut LockCollector,
772 summary: &mut InstallSummary,
773 owner: &str,
774) {
775 for (name, range) in dependencies {
776 if let Err(error) = install_dependency(
777 client,
778 project_root,
779 name,
780 range,
781 node_modules_dir,
782 installed,
783 lock,
784 summary,
785 DependencyKind::Optional,
786 ) {
787 summary.warn(format!(
788 "optional dependency `{name}` for `{owner}` was skipped: {}",
789 error.warning_summary()
790 ));
791 }
792 }
793}
794
795fn install_dependency(
796 client: &Client,
797 project_root: &Path,
798 name: &str,
799 range: &str,
800 node_modules_dir: &Path,
801 installed: &mut HashSet<String>,
802 lock: &mut LockCollector,
803 summary: &mut InstallSummary,
804 kind: DependencyKind,
805) -> Result<(), PmError> {
806 let resolved = resolve_package(client, name, range)?;
807 let install_key = format!(
808 "{}@{}::{}",
809 resolved.name,
810 resolved.version,
811 node_modules_dir.display()
812 );
813
814 if !installed.insert(install_key) {
815 return Ok(());
816 }
817
818 let target_dir = install_dir(node_modules_dir, &resolved.name);
819 if is_matching_install(&target_dir, &resolved.version)? {
820 lock.insert_package(
821 project_root,
822 &target_dir,
823 &resolved.name,
824 &resolved.version,
825 &resolved.tarball_url,
826 resolved.integrity.as_deref(),
827 &resolved.dependencies,
828 &resolved.optional_dependencies,
829 &resolved.peer_dependencies,
830 )
831 .map_err(|source| PmError::WriteLockfile {
832 path: project_root.join("package-lock.json"),
833 source,
834 })?;
835 install_dependency_set(
836 client,
837 project_root,
838 &resolved.dependencies,
839 &target_dir.join("node_modules"),
840 installed,
841 lock,
842 summary,
843 DependencyKind::Prod,
844 )?;
845 install_optional_dependency_set(
846 client,
847 project_root,
848 &resolved.optional_dependencies,
849 &target_dir.join("node_modules"),
850 installed,
851 lock,
852 summary,
853 &resolved.name,
854 );
855 reconcile_peer_dependencies(
856 &resolved.name,
857 &resolved.peer_dependencies,
858 client,
859 project_root,
860 node_modules_dir,
861 installed,
862 lock,
863 summary,
864 );
865 return Ok(());
866 }
867
868 if let Some(parent) = target_dir.parent() {
869 fs::create_dir_all(parent).map_err(|source| PmError::CreateDir {
870 path: parent.to_path_buf(),
871 source,
872 })?;
873 }
874
875 let package_root = download_and_extract_package(client, &resolved)?;
876
877 if target_dir.exists() {
878 fs::remove_dir_all(&target_dir).map_err(|source| PmError::RemoveExistingInstall {
879 path: target_dir.clone(),
880 source,
881 })?;
882 }
883 copy_dir_all(&package_root, &target_dir).map_err(|source| PmError::CopyInstall {
884 from: package_root.clone(),
885 to: target_dir.clone(),
886 source,
887 })?;
888 create_bin_links(node_modules_dir, &target_dir)?;
889 summary.record_install(kind);
890 lock.insert_package(
891 project_root,
892 &target_dir,
893 &resolved.name,
894 &resolved.version,
895 &resolved.tarball_url,
896 resolved.integrity.as_deref(),
897 &resolved.dependencies,
898 &resolved.optional_dependencies,
899 &resolved.peer_dependencies,
900 )
901 .map_err(|source| PmError::WriteLockfile {
902 path: project_root.join("package-lock.json"),
903 source,
904 })?;
905
906 let nested_node_modules = target_dir.join("node_modules");
907 fs::create_dir_all(&nested_node_modules).map_err(|source| PmError::CreateDir {
908 path: nested_node_modules.clone(),
909 source,
910 })?;
911 install_dependency_set(
912 client,
913 project_root,
914 &resolved.dependencies,
915 &nested_node_modules,
916 installed,
917 lock,
918 summary,
919 DependencyKind::Prod,
920 )?;
921 install_optional_dependency_set(
922 client,
923 project_root,
924 &resolved.optional_dependencies,
925 &nested_node_modules,
926 installed,
927 lock,
928 summary,
929 &resolved.name,
930 );
931 reconcile_peer_dependencies(
932 &resolved.name,
933 &resolved.peer_dependencies,
934 client,
935 project_root,
936 node_modules_dir,
937 installed,
938 lock,
939 summary,
940 );
941
942 Ok(())
943}
944
945fn resolve_package(client: &Client, name: &str, range: &str) -> Result<ResolvedPackage, PmError> {
946 let url = resolve_npm_url(name);
947 let response = client
948 .get(url)
949 .send()
950 .map_err(|source| PmError::FetchMetadata {
951 package: name.to_string(),
952 source,
953 })?;
954
955 let response = response
956 .error_for_status()
957 .map_err(|source| PmError::MetadataStatus {
958 package: name.to_string(),
959 source,
960 })?;
961
962 let body = response
963 .text()
964 .map_err(|source| PmError::ReadMetadataBody {
965 package: name.to_string(),
966 source,
967 })?;
968
969 let packument: Packument =
970 serde_json::from_str(&body).map_err(|source| PmError::ParseMetadata {
971 package: name.to_string(),
972 source,
973 })?;
974
975 let range: Range = range
976 .parse()
977 .map_err(|source: nodejs_semver::SemverError| PmError::InvalidRange {
978 package: name.to_string(),
979 range: range.to_string(),
980 source: source.to_string(),
981 })?;
982
983 let version = packument
984 .versions
985 .keys()
986 .filter_map(|raw_version| {
987 Version::parse(raw_version)
988 .ok()
989 .map(|parsed| (raw_version, parsed))
990 })
991 .filter(|(_, parsed)| parsed.satisfies(&range))
992 .map(|(_, parsed)| parsed)
993 .max()
994 .ok_or_else(|| PmError::VersionNotFound {
995 package: name.to_string(),
996 range: range.to_string(),
997 })?;
998
999 let version_string = version.to_string();
1000 let metadata =
1001 packument
1002 .versions
1003 .get(&version_string)
1004 .ok_or_else(|| PmError::MissingResolvedVersion {
1005 package: name.to_string(),
1006 version: version_string.clone(),
1007 })?;
1008
1009 Ok(ResolvedPackage {
1010 name: name.to_string(),
1011 version: version_string,
1012 dependencies: metadata.dependencies.clone(),
1013 optional_dependencies: metadata.optional_dependencies.clone(),
1014 peer_dependencies: metadata.peer_dependencies.clone(),
1015 tarball_url: metadata.dist.tarball.clone(),
1016 integrity: metadata.dist.integrity.clone(),
1017 })
1018}
1019
1020fn download_and_extract_package(
1021 client: &Client,
1022 package: &ResolvedPackage,
1023) -> Result<PathBuf, PmError> {
1024 let response =
1025 client
1026 .get(&package.tarball_url)
1027 .send()
1028 .map_err(|source| PmError::DownloadTarball {
1029 package: package.name.clone(),
1030 source,
1031 })?;
1032 let response = response
1033 .error_for_status()
1034 .map_err(|source| PmError::TarballStatus {
1035 package: package.name.clone(),
1036 source,
1037 })?;
1038
1039 let bytes = response
1040 .bytes()
1041 .map_err(|source| PmError::ReadTarballBody {
1042 package: package.name.clone(),
1043 source,
1044 })?;
1045 verify_integrity(package, bytes.as_ref())?;
1046
1047 let temp = tempdir().map_err(|source| PmError::ExtractTarball {
1048 package: package.name.clone(),
1049 source,
1050 })?;
1051 let temp_path = temp.keep();
1052
1053 let tar = GzDecoder::new(bytes.as_ref());
1054 let mut archive = Archive::new(tar);
1055 archive
1056 .unpack(&temp_path)
1057 .map_err(|source| PmError::ExtractTarball {
1058 package: package.name.clone(),
1059 source,
1060 })?;
1061
1062 locate_extracted_package_root(&temp_path).ok_or_else(|| PmError::MissingPackageDir {
1063 package: package.name.clone(),
1064 path: temp_path.join("package"),
1065 })
1066}
1067
1068fn locate_extracted_package_root(temp_path: &Path) -> Option<PathBuf> {
1069 let package_json = temp_path.join("package.json");
1070 if package_json.is_file() {
1071 return Some(temp_path.to_path_buf());
1072 }
1073
1074 let package_dir = temp_path.join("package");
1075 if package_json.is_file() || package_dir.join("package.json").is_file() {
1076 return Some(package_dir);
1077 }
1078
1079 let entries = fs::read_dir(temp_path).ok()?;
1080 let mut candidate_dir: Option<PathBuf> = None;
1081
1082 for entry in entries.flatten() {
1083 let path = entry.path();
1084 if path.is_file() {
1085 continue;
1086 }
1087
1088 if path.join("package.json").is_file() {
1089 if candidate_dir.is_some() {
1090 return None;
1091 }
1092 candidate_dir = Some(path);
1093 }
1094 }
1095
1096 candidate_dir
1097}
1098
1099fn verify_integrity(package: &ResolvedPackage, bytes: &[u8]) -> Result<(), PmError> {
1100 let Some(integrity) = &package.integrity else {
1101 return Ok(());
1102 };
1103
1104 let parsed: Integrity =
1105 integrity
1106 .parse()
1107 .map_err(|source: ssri::Error| PmError::InvalidIntegrity {
1108 package: package.name.clone(),
1109 version: package.version.clone(),
1110 source: source.to_string(),
1111 })?;
1112
1113 parsed
1114 .check(bytes)
1115 .map_err(|source: ssri::Error| PmError::IntegrityMismatch {
1116 package: package.name.clone(),
1117 version: package.version.clone(),
1118 source: source.to_string(),
1119 })?;
1120
1121 Ok(())
1122}
1123
1124fn install_dir(node_modules: &Path, package_name: &str) -> PathBuf {
1125 if let Some((scope, name)) = package_name.split_once('/') {
1126 node_modules.join(scope).join(name)
1127 } else {
1128 node_modules.join(package_name)
1129 }
1130}
1131
1132fn is_matching_install(path: &Path, version: &str) -> Result<bool, PmError> {
1133 let manifest_path = path.join("package.json");
1134 if !manifest_path.is_file() {
1135 return Ok(false);
1136 }
1137
1138 let manifest = read_manifest_from_path(&manifest_path)
1139 .map_err(|source| map_manifest_error(&manifest_path, source))?;
1140 Ok(manifest.version == version)
1141}
1142
1143fn copy_dir_all(src: &Path, dst: &Path) -> io::Result<()> {
1144 fs::create_dir_all(dst)?;
1145
1146 for entry in fs::read_dir(src)? {
1147 let entry = entry?;
1148 let file_type = entry.file_type()?;
1149 let from = entry.path();
1150 let to = dst.join(entry.file_name());
1151
1152 if file_type.is_dir() {
1153 copy_dir_all(&from, &to)?;
1154 } else {
1155 fs::copy(&from, &to)?;
1156 }
1157 }
1158
1159 Ok(())
1160}
1161
1162fn create_bin_links(node_modules_dir: &Path, package_dir: &Path) -> Result<(), PmError> {
1163 let bin_entries = read_bin_entries(package_dir)?;
1164 if bin_entries.is_empty() {
1165 return Ok(());
1166 }
1167
1168 let bin_dir = node_modules_dir.join(".bin");
1169 fs::create_dir_all(&bin_dir).map_err(|source| PmError::CreateDir {
1170 path: bin_dir.clone(),
1171 source,
1172 })?;
1173
1174 for (command_name, relative_target) in bin_entries {
1175 let target = package_dir.join(normalize_package_relative_path(&relative_target));
1176 if !target.is_file() {
1177 return Err(PmError::MissingBinTarget {
1178 package_dir: package_dir.to_path_buf(),
1179 target,
1180 });
1181 }
1182
1183 create_bin_link(&bin_dir, &command_name, &target)?;
1184 }
1185
1186 Ok(())
1187}
1188
1189fn read_bin_entries(package_dir: &Path) -> Result<Vec<(String, String)>, PmError> {
1190 let package_json_path = package_dir.join("package.json");
1191 let source = fs::read_to_string(&package_json_path).map_err(|source| {
1192 PmError::ReadInstalledManifest {
1193 path: package_json_path.clone(),
1194 source,
1195 }
1196 })?;
1197 let value: Value = serde_json::from_str(&source).map_err(|source| PmError::ParseManifest {
1198 path: package_json_path.clone(),
1199 source,
1200 })?;
1201
1202 let package_name = value
1203 .get("name")
1204 .and_then(Value::as_str)
1205 .map(default_bin_name)
1206 .ok_or_else(|| PmError::MissingInstalledName {
1207 path: package_json_path.clone(),
1208 })?;
1209
1210 let Some(bin_value) = value.get("bin") else {
1211 return Ok(Vec::new());
1212 };
1213
1214 match bin_value {
1215 Value::String(path) => Ok(vec![(package_name, path.clone())]),
1216 Value::Object(entries) => {
1217 let mut bins = Vec::with_capacity(entries.len());
1218 for (command_name, target) in entries {
1219 let target = target.as_str().ok_or_else(|| PmError::InvalidBinEntry {
1220 path: package_json_path.clone(),
1221 entry: command_name.clone(),
1222 })?;
1223 bins.push((command_name.clone(), target.to_string()));
1224 }
1225 Ok(bins)
1226 }
1227 Value::Null => Ok(Vec::new()),
1228 _ => Err(PmError::InvalidBinField {
1229 path: package_json_path,
1230 }),
1231 }
1232}
1233
1234fn default_bin_name(package_name: &str) -> String {
1235 package_name
1236 .rsplit_once('/')
1237 .map(|(_, name)| name.to_string())
1238 .unwrap_or_else(|| package_name.to_string())
1239}
1240
1241fn normalize_package_relative_path(path: &str) -> PathBuf {
1242 let trimmed = path.strip_prefix("./").unwrap_or(path);
1243 PathBuf::from(trimmed)
1244}
1245
1246fn reconcile_peer_dependencies(
1247 owner: &str,
1248 peer_dependencies: &HashMap<String, String>,
1249 client: &Client,
1250 project_root: &Path,
1251 node_modules_dir: &Path,
1252 installed: &mut HashSet<String>,
1253 lock: &mut LockCollector,
1254 summary: &mut InstallSummary,
1255) {
1256 for (name, range) in peer_dependencies {
1257 if peer_dependency_warning(name, range, node_modules_dir).is_some() {
1258 if let Err(error) = install_dependency(
1259 client,
1260 project_root,
1261 name,
1262 range,
1263 node_modules_dir,
1264 installed,
1265 lock,
1266 summary,
1267 DependencyKind::Peer,
1268 ) {
1269 summary.warn(format!(
1270 "peer dependency `{name}` for `{owner}` could not be installed: {}",
1271 error.warning_summary()
1272 ));
1273 }
1274 }
1275 }
1276
1277 validate_peer_dependencies(owner, peer_dependencies, node_modules_dir, summary);
1278}
1279
1280fn validate_peer_dependencies(
1281 owner: &str,
1282 peer_dependencies: &HashMap<String, String>,
1283 node_modules_dir: &Path,
1284 summary: &mut InstallSummary,
1285) {
1286 for (name, range) in peer_dependencies {
1287 if let Some(warning) = peer_dependency_warning(name, range, node_modules_dir) {
1288 summary.warn(format!("peer dependency for `{owner}`: {warning}"));
1289 }
1290 }
1291}
1292
1293fn peer_dependency_warning(name: &str, range: &str, node_modules_dir: &Path) -> Option<String> {
1294 let package_dir = install_dir(node_modules_dir, name);
1295 let manifest_path = package_dir.join("package.json");
1296 if !manifest_path.is_file() {
1297 return Some(format!("missing `{name}` required by range `{range}`"));
1298 }
1299
1300 let manifest = match read_manifest_from_path(&manifest_path) {
1301 Ok(manifest) => manifest,
1302 Err(error) => {
1303 return Some(format!(
1304 "failed to read installed `{name}` manifest: {error}"
1305 ));
1306 }
1307 };
1308 let installed_version = match Version::parse(&manifest.version) {
1309 Ok(version) => version,
1310 Err(error) => {
1311 return Some(format!(
1312 "`{name}` is installed with invalid version `{}`: {error}",
1313 manifest.version
1314 ));
1315 }
1316 };
1317 let expected_range: Range = match range.parse() {
1318 Ok(parsed) => parsed,
1319 Err(error) => {
1320 return Some(format!(
1321 "`{name}` requires invalid peer range `{range}`: {error}"
1322 ));
1323 }
1324 };
1325 if installed_version.satisfies(&expected_range) {
1326 None
1327 } else {
1328 Some(format!(
1329 "`{name}` is installed as `{}` but `{range}` is required",
1330 manifest.version
1331 ))
1332 }
1333}
1334
1335#[cfg(unix)]
1336fn create_bin_link(bin_dir: &Path, command_name: &str, target: &Path) -> Result<(), PmError> {
1337 use std::os::unix::fs::symlink;
1338
1339 let link_path = bin_dir.join(command_name);
1340 remove_existing_link_path(&link_path).map_err(|source| PmError::CreateBinLink {
1341 command: command_name.to_string(),
1342 path: link_path.clone(),
1343 source,
1344 })?;
1345 symlink(target, &link_path).map_err(|source| PmError::CreateBinLink {
1346 command: command_name.to_string(),
1347 path: link_path,
1348 source,
1349 })
1350}
1351
1352#[cfg(windows)]
1353fn create_bin_link(bin_dir: &Path, command_name: &str, target: &Path) -> Result<(), PmError> {
1354 let link_path = bin_dir.join(format!("{command_name}.cmd"));
1355 remove_existing_link_path(&link_path).map_err(|source| PmError::CreateBinLink {
1356 command: command_name.to_string(),
1357 path: link_path.clone(),
1358 source,
1359 })?;
1360 let script = format!("@ECHO off\r\nnode \"{}\" %*\r\n", target.display());
1361 fs::write(&link_path, script).map_err(|source| PmError::CreateBinLink {
1362 command: command_name.to_string(),
1363 path: link_path,
1364 source,
1365 })
1366}
1367
1368fn remove_existing_link_path(path: &Path) -> io::Result<()> {
1369 if !path.exists() {
1370 return Ok(());
1371 }
1372
1373 let metadata = fs::symlink_metadata(path)?;
1374 if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
1375 fs::remove_dir_all(path)
1376 } else {
1377 fs::remove_file(path)
1378 }
1379}
1380
1381fn resolve_npm_url(package: &str) -> String {
1382 let encoded = package.replace('@', "%40").replace('/', "%2F");
1383 format!("https://registry.npmjs.org/{encoded}")
1384}
1385
1386fn global_packages_root() -> Result<PathBuf, PmError> {
1387 let mut path = home_dir().ok_or(PmError::HomeDirUnavailable)?;
1388 path.push(".config");
1389 path.push("o-");
1390 path.push("packages");
1391 Ok(path)
1392}
1393
1394fn global_node_modules_dir(global_root: &Path) -> PathBuf {
1395 global_root.join("node_modules")
1396}
1397
1398fn global_bin_dir(node_modules_dir: &Path) -> PathBuf {
1399 node_modules_dir.join(".bin")
1400}
1401
1402fn parse_package_spec(spec: &str) -> (String, String) {
1403 let trimmed = spec.trim();
1404 if trimmed.is_empty() {
1405 return (String::new(), "*".to_string());
1406 }
1407
1408 if let Some((name, range)) = split_package_spec(trimmed) {
1409 return (name.to_string(), normalize_package_range(range).to_string());
1410 }
1411
1412 (trimmed.to_string(), "*".to_string())
1413}
1414
1415fn split_package_spec(spec: &str) -> Option<(&str, &str)> {
1416 if spec.starts_with('@') {
1417 let slash = spec.find('/')?;
1418 let tail = &spec[slash + 1..];
1419 let at = tail.rfind('@')?;
1420 let split_index = slash + 1 + at;
1421 let name = &spec[..split_index];
1422 let range = &spec[split_index + 1..];
1423 if range.is_empty() {
1424 None
1425 } else {
1426 Some((name, range))
1427 }
1428 } else if let Some((name, range)) = spec.rsplit_once('@') {
1429 if name.is_empty() || range.is_empty() {
1430 None
1431 } else {
1432 Some((name, range))
1433 }
1434 } else {
1435 None
1436 }
1437}
1438
1439fn normalize_package_range(range: &str) -> &str {
1440 if range == "latest" { "*" } else { range }
1441}
1442
1443pub fn remove_shim(node_modules_dir: &Path, command: &str) -> Result<bool, PmError> {
1444 let shim_path = shim_path_for_command(node_modules_dir, command);
1445 if !shim_path.exists() {
1446 return Ok(false);
1447 }
1448
1449 remove_existing_link_path(&shim_path).map_err(|source| PmError::RemoveBinLink {
1450 command: command.to_string(),
1451 path: shim_path,
1452 source,
1453 })?;
1454 Ok(true)
1455}
1456
1457pub fn uninstall(name: &str) -> Result<Report, PmError> {
1458 let global_root = global_packages_root()?;
1459 let node_modules_dir = global_node_modules_dir(&global_root);
1460 let package_dir = install_dir(&node_modules_dir, name);
1461 if !package_dir.is_dir() {
1462 return Err(PmError::PackageNotInstalled {
1463 name: name.to_string(),
1464 path: package_dir,
1465 });
1466 }
1467
1468 let manifest_path = package_dir.join("package.json");
1469 let manifest = read_manifest_from_path(&manifest_path)
1470 .map_err(|source| map_manifest_error(&manifest_path, source))?;
1471 let bin_entries = read_bin_entries(&package_dir)?;
1472
1473 fs::remove_dir_all(&package_dir).map_err(|source| PmError::RemoveInstalledPackage {
1474 path: package_dir.clone(),
1475 source,
1476 })?;
1477 remove_empty_scope_dir(&package_dir)?;
1478
1479 let mut removed_shims = Vec::new();
1480 for (command, _) in bin_entries {
1481 if remove_shim(&node_modules_dir, &command)? {
1482 removed_shims.push(command);
1483 }
1484 }
1485
1486 let lockfile_path = remove_global_lockfile_entry(&global_root, &package_dir, &manifest.name)?;
1487
1488 Ok(
1489 Report::new(format!("uninstalled package `{}`", manifest.name))
1490 .detail(format!("version: {}", manifest.version))
1491 .detail(format!("package: {}", package_dir.display()))
1492 .detail(format!(
1493 "bin dir: {}",
1494 global_bin_dir(&node_modules_dir).display()
1495 ))
1496 .detail(match lockfile_path {
1497 Some(path) => format!("lockfile: {}", path.display()),
1498 None => "lockfile: none".to_string(),
1499 })
1500 .detail(if removed_shims.is_empty() {
1501 "removed shims: none".to_string()
1502 } else {
1503 format!("removed shims: {}", removed_shims.join(", "))
1504 }),
1505 )
1506}
1507
1508#[cfg(unix)]
1509fn shim_path_for_command(node_modules_dir: &Path, command: &str) -> PathBuf {
1510 global_bin_dir(node_modules_dir).join(command)
1511}
1512
1513#[cfg(windows)]
1514fn shim_path_for_command(node_modules_dir: &Path, command: &str) -> PathBuf {
1515 global_bin_dir(node_modules_dir).join(format!("{command}.cmd"))
1516}
1517
1518fn remove_empty_scope_dir(package_dir: &Path) -> Result<(), PmError> {
1519 let Some(parent) = package_dir.parent() else {
1520 return Ok(());
1521 };
1522
1523 let Some(scope_name) = parent.file_name().and_then(|name| name.to_str()) else {
1524 return Ok(());
1525 };
1526
1527 if !scope_name.starts_with('@') {
1528 return Ok(());
1529 }
1530
1531 if parent
1532 .read_dir()
1533 .map_err(|source| PmError::RemoveInstalledPackage {
1534 path: parent.to_path_buf(),
1535 source,
1536 })?
1537 .next()
1538 .is_none()
1539 {
1540 fs::remove_dir(parent).map_err(|source| PmError::RemoveInstalledPackage {
1541 path: parent.to_path_buf(),
1542 source,
1543 })?;
1544 }
1545
1546 Ok(())
1547}
1548
1549fn remove_global_lockfile_entry(
1550 global_root: &Path,
1551 package_dir: &Path,
1552 package_name: &str,
1553) -> Result<Option<PathBuf>, PmError> {
1554 let lockfile_path = global_root.join("package-lock.json");
1555 if !lockfile_path.is_file() {
1556 return Ok(None);
1557 }
1558
1559 let source = fs::read_to_string(&lockfile_path).map_err(|source| PmError::ReadLockfile {
1560 path: lockfile_path.clone(),
1561 source,
1562 })?;
1563 let mut lockfile: crate::lock::LockFile =
1564 serde_json::from_str(&source).map_err(|source| PmError::ParseLockfile {
1565 path: lockfile_path.clone(),
1566 source,
1567 })?;
1568
1569 let key = package_dir
1570 .strip_prefix(global_root)
1571 .map(|path| path.to_string_lossy().replace('\\', "/"))
1572 .unwrap_or_else(|_| package_dir.to_string_lossy().replace('\\', "/"));
1573 lockfile.packages.remove(&key);
1574
1575 if let Some(root) = lockfile.packages.get_mut("") {
1576 remove_dependency_entry(&mut root.dependencies, package_name);
1577 remove_dependency_entry(&mut root.dev_dependencies, package_name);
1578 remove_dependency_entry(&mut root.optional_dependencies, package_name);
1579 remove_dependency_entry(&mut root.peer_dependencies, package_name);
1580 }
1581
1582 let rewritten =
1583 write_lockfile(global_root, &lockfile).map_err(|source| PmError::WriteLockfile {
1584 path: lockfile_path,
1585 source,
1586 })?;
1587 Ok(Some(rewritten))
1588}
1589
1590fn remove_dependency_entry(
1591 dependencies: &mut Option<std::collections::BTreeMap<String, String>>,
1592 name: &str,
1593) {
1594 let should_clear = if let Some(entries) = dependencies.as_mut() {
1595 entries.remove(name);
1596 entries.is_empty()
1597 } else {
1598 false
1599 };
1600
1601 if should_clear {
1602 *dependencies = None;
1603 }
1604}
1605
1606fn map_manifest_error(path: &Path, source: io::Error) -> PmError {
1607 match source.kind() {
1608 io::ErrorKind::InvalidData => {
1609 let parse_source =
1610 serde_json::Error::io(io::Error::new(source.kind(), source.to_string()));
1611 PmError::ParseManifest {
1612 path: path.to_path_buf(),
1613 source: parse_source,
1614 }
1615 }
1616 _ => PmError::ReadManifest {
1617 path: path.to_path_buf(),
1618 source,
1619 },
1620 }
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625 use super::locate_extracted_package_root;
1626 use std::fs;
1627 use tempfile::tempdir;
1628
1629 #[test]
1630 fn uses_temp_root_when_package_json_is_at_root() {
1631 let temp = tempdir().unwrap();
1632 fs::write(temp.path().join("package.json"), "{}").unwrap();
1633
1634 let root = locate_extracted_package_root(temp.path()).unwrap();
1635 assert_eq!(root, temp.path());
1636 }
1637
1638 #[test]
1639 fn uses_package_directory_when_present() {
1640 let temp = tempdir().unwrap();
1641 let package_dir = temp.path().join("package");
1642 fs::create_dir(&package_dir).unwrap();
1643 fs::write(package_dir.join("package.json"), "{}").unwrap();
1644
1645 let root = locate_extracted_package_root(temp.path()).unwrap();
1646 assert_eq!(root, package_dir);
1647 }
1648
1649 #[test]
1650 fn uses_single_top_level_directory_as_fallback() {
1651 let temp = tempdir().unwrap();
1652 let package_dir = temp.path().join("nested");
1653 fs::create_dir(&package_dir).unwrap();
1654 fs::write(package_dir.join("package.json"), "{}").unwrap();
1655
1656 let root = locate_extracted_package_root(temp.path()).unwrap();
1657 assert_eq!(root, package_dir);
1658 }
1659
1660 #[test]
1661 fn rejects_ambiguous_top_level_layouts() {
1662 let temp = tempdir().unwrap();
1663 let left = temp.path().join("left");
1664 let right = temp.path().join("right");
1665 fs::create_dir(&left).unwrap();
1666 fs::create_dir(&right).unwrap();
1667 fs::write(left.join("package.json"), "{}").unwrap();
1668 fs::write(right.join("package.json"), "{}").unwrap();
1669
1670 assert!(locate_extracted_package_root(temp.path()).is_none());
1671 }
1672}