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