Skip to main content

o_/
pm.rs

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}