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}
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}