ambient-ci 0.14.0

A continuous integration engine
Documentation
use std::{
    collections::HashMap,
    error::Error,
    path::{Path, PathBuf},
};

use serde::{Deserialize, Serialize};
use url::Url;

use crate::{
    action::{ActionError, Context},
    action_impl::ActionImpl,
    runlog::RunLogSource,
    util::{http_get_to_file, mkdir, UtilError},
};

// From <https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json?v=true>.
const LOCK_FILES: &[&str] = &["npm-shrinkwrap.json", "package-lock.json"];

const SUBDIR: &str = "npm";

/// Download npm packages based on lock file.
///
/// Use `npm-shrinkwrap.json` is available, or `package-lock.json` otherwise.
/// If neither exist, error out.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NpmGet {}

impl NpmGet {
    fn load_lock_file(&self, srcdir: &Path) -> Result<PackagesLock, NpmError> {
        for filename in LOCK_FILES.iter() {
            let filename = srcdir.join(filename);
            if filename.exists() {
                let data = std::fs::read(&filename)
                    .map_err(|err| NpmError::ReadLockFile(filename.to_path_buf(), err))?;
                let lockfile: PackagesLock = serde_json::from_slice(&data)
                    .map_err(|err| NpmError::ParseLockFile(filename.to_path_buf(), err))?;
                return Ok(lockfile);
            }
        }

        Err(NpmError::NoLockFile(LOCK_FILES))
    }

    fn download(&self, npm_dir: PathBuf, lockfile: PackagesLock) -> Result<(), NpmError> {
        mkdir(&npm_dir).map_err(|err| NpmError::Mkdir2(npm_dir.to_path_buf(), err))?;

        for (name, p) in lockfile.packages.iter().filter(|(n, _)| !n.is_empty()) {
            let filename = npm_dir.join(name);
            let dir = filename
                .parent()
                .ok_or(NpmError::NoParent(filename.to_path_buf()))?;
            if !dir.exists() {
                mkdir(dir).map_err(|err| NpmError::Mkdir2(dir.to_path_buf(), err))?;
            }

            let url = Url::parse(&p.resolved)
                .map_err(|err| NpmError::UrlParse(p.resolved.clone(), err))?;
            let filename = npm_dir.join(format!("{}.tgz", name));
            http_get_to_file(url.as_str(), &filename)
                .map_err(|err| NpmError::HttpGet(url, filename.clone(), err))?;
        }

        Ok(())
    }
}

impl ActionImpl for NpmGet {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        match self.load_lock_file(context.source_dir()) {
            Ok(lockfile) => {
                if let Err(err) = self.download(context.deps_dir().join(SUBDIR), lockfile) {
                    context.runlog().npm_get_failed(RunLogSource::PrePlan, &err);
                    Err(err)?
                }
            }
            Err(err) => {
                context.runlog().npm_get_failed(RunLogSource::PrePlan, &err);
                Err(err)?
            }
        }

        context.runlog().npm_get_succeeded(RunLogSource::PrePlan);
        Ok(())
    }
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PackagesLock {
    packages: HashMap<String, Package>,
}

#[derive(Debug, Default, Deserialize)]
#[allow(dead_code)]
#[serde(default)]
struct Package {
    resolved: String,
}

/// Errors from the NpmGet action.
#[derive(Debug, thiserror::Error)]
pub enum NpmError {
    /// No lock file.
    #[error("failed to find lock file, tried {0:?}")]
    NoLockFile(&'static [&'static str]),

    /// Can't read package lock file.
    #[error("failed to read package lock file {0}")]
    ReadLockFile(PathBuf, #[source] std::io::Error),

    /// Can't parse package lock file.
    #[error("failed to parse package lock file {0}")]
    ParseLockFile(PathBuf, #[source] serde_json::Error),

    /// Failed to create the artifacts directory for `npm` packages.
    #[error("could not create artifacts directory {0}")]
    Mkdir2(PathBuf, #[source] crate::util::UtilError),

    /// Can't determine parent of file.
    #[error("failed to determine directory of path {0}")]
    NoParent(PathBuf),

    /// Parse URL.
    #[error("failed to parse URL {0:?}")]
    UrlParse(String, #[source] url::ParseError),

    /// Download file
    #[error("failed to download {0} to {1}")]
    HttpGet(Url, PathBuf, #[source] UtilError),
}

impl From<NpmError> for ActionError {
    fn from(value: NpmError) -> Self {
        Self::Npm(value)
    }
}

impl From<NpmError> for String {
    fn from(err: NpmError) -> Self {
        let mut msg = format!("ERROR: {err}");
        let mut source = err.source();
        while let Some(src) = source {
            msg.push_str(&format!("caused by: {src}"));
            source = src.source();
        }

        msg
    }
}