Skip to main content

ambient_ci/action_impl/
npm.rs

1use std::{
2    collections::HashMap,
3    error::Error,
4    path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use crate::{
11    action::{ActionError, Context},
12    action_impl::ActionImpl,
13    runlog::RunLogSource,
14    util::{http_get_to_file, mkdir, UtilError},
15};
16
17// From <https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json?v=true>.
18const LOCK_FILES: &[&str] = &["npm-shrinkwrap.json", "package-lock.json"];
19
20const SUBDIR: &str = "npm";
21
22/// Download npm packages based on lock file.
23///
24/// Use `npm-shrinkwrap.json` is available, or `package-lock.json` otherwise.
25/// If neither exist, error out.
26#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct NpmGet {}
28
29impl NpmGet {
30    fn load_lock_file(&self, srcdir: &Path) -> Result<PackagesLock, NpmError> {
31        for filename in LOCK_FILES.iter() {
32            let filename = srcdir.join(filename);
33            if filename.exists() {
34                let data = std::fs::read(&filename)
35                    .map_err(|err| NpmError::ReadLockFile(filename.to_path_buf(), err))?;
36                let lockfile: PackagesLock = serde_json::from_slice(&data)
37                    .map_err(|err| NpmError::ParseLockFile(filename.to_path_buf(), err))?;
38                return Ok(lockfile);
39            }
40        }
41
42        Err(NpmError::NoLockFile(LOCK_FILES))
43    }
44
45    fn download(&self, npm_dir: PathBuf, lockfile: PackagesLock) -> Result<(), NpmError> {
46        mkdir(&npm_dir).map_err(|err| NpmError::Mkdir2(npm_dir.to_path_buf(), err))?;
47
48        for (name, p) in lockfile.packages.iter().filter(|(n, _)| !n.is_empty()) {
49            let filename = npm_dir.join(name);
50            let dir = filename
51                .parent()
52                .ok_or(NpmError::NoParent(filename.to_path_buf()))?;
53            if !dir.exists() {
54                mkdir(dir).map_err(|err| NpmError::Mkdir2(dir.to_path_buf(), err))?;
55            }
56
57            let url = Url::parse(&p.resolved)
58                .map_err(|err| NpmError::UrlParse(p.resolved.clone(), err))?;
59            let filename = npm_dir.join(format!("{}.tgz", name));
60            http_get_to_file(url.as_str(), &filename)
61                .map_err(|err| NpmError::HttpGet(url, filename.clone(), err))?;
62        }
63
64        Ok(())
65    }
66}
67
68impl ActionImpl for NpmGet {
69    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
70        match self.load_lock_file(context.source_dir()) {
71            Ok(lockfile) => {
72                if let Err(err) = self.download(context.deps_dir().join(SUBDIR), lockfile) {
73                    context.runlog().npm_get_failed(RunLogSource::PrePlan, &err);
74                    Err(err)?
75                }
76            }
77            Err(err) => {
78                context.runlog().npm_get_failed(RunLogSource::PrePlan, &err);
79                Err(err)?
80            }
81        }
82
83        context.runlog().npm_get_succeeded(RunLogSource::PrePlan);
84        Ok(())
85    }
86}
87
88#[derive(Debug, Deserialize)]
89#[allow(dead_code)]
90struct PackagesLock {
91    packages: HashMap<String, Package>,
92}
93
94#[derive(Debug, Default, Deserialize)]
95#[allow(dead_code)]
96#[serde(default)]
97struct Package {
98    resolved: String,
99}
100
101/// Errors from the NpmGet action.
102#[derive(Debug, thiserror::Error)]
103pub enum NpmError {
104    /// No lock file.
105    #[error("failed to find lock file, tried {0:?}")]
106    NoLockFile(&'static [&'static str]),
107
108    /// Can't read package lock file.
109    #[error("failed to read package lock file {0}")]
110    ReadLockFile(PathBuf, #[source] std::io::Error),
111
112    /// Can't parse package lock file.
113    #[error("failed to parse package lock file {0}")]
114    ParseLockFile(PathBuf, #[source] serde_json::Error),
115
116    /// Failed to create the artifacts directory for `npm` packages.
117    #[error("could not create artifacts directory {0}")]
118    Mkdir2(PathBuf, #[source] crate::util::UtilError),
119
120    /// Can't determine parent of file.
121    #[error("failed to determine directory of path {0}")]
122    NoParent(PathBuf),
123
124    /// Parse URL.
125    #[error("failed to parse URL {0:?}")]
126    UrlParse(String, #[source] url::ParseError),
127
128    /// Download file
129    #[error("failed to download {0} to {1}")]
130    HttpGet(Url, PathBuf, #[source] UtilError),
131}
132
133impl From<NpmError> for ActionError {
134    fn from(value: NpmError) -> Self {
135        Self::Npm(value)
136    }
137}
138
139impl From<NpmError> for String {
140    fn from(err: NpmError) -> Self {
141        let mut msg = format!("ERROR: {err}");
142        let mut source = err.source();
143        while let Some(src) = source {
144            msg.push_str(&format!("caused by: {src}"));
145            source = src.source();
146        }
147
148        msg
149    }
150}