//! Actions for Ambient CI.
//!
//! Actual action implementations are in the [`action_impl`](crate::action_impl) module.
//! Actions are divided into [pre-plan](PrePlanAction), [plan](UnsafeAction), and [post-plan](PostPlanAction) actions.
//! These are turned into [runnable actions](RunnableAction).

#![allow(clippy::result_large_err)]

use std::{
    collections::HashMap,
    ffi::OsString,
    os::unix::ffi::OsStringExt,
    path::{Path, PathBuf},
};

use clingwrap::runner::CommandError;
use serde::{Deserialize, Serialize};

use crate::{action_impl::*, envs::EnvVars, plan::RunnablePlan, runlog::RunLog};

/// A context for running an action.
///
/// The context holds state between actions getting executiong,
/// as well as configuration for a specific CI run. The context
/// is created at the start of a CI run and can be modified by
/// actions.
#[derive(Debug)]
pub struct Context<'a> {
    envs: HashMap<OsString, OsString>,
    source_dir: PathBuf,
    deps_dir: PathBuf,
    artifacts_dir: PathBuf,

    // These environment variables will be carried over to the runnable plan
    // that is given to the VM.
    plan_envs: EnvVars,

    #[allow(dead_code)]
    runlog: &'a mut RunLog,
}

impl<'a> Context<'a> {
    /// Create a new `Context`.
    pub fn new(runlog: &'a mut RunLog) -> Self {
        Self {
            envs: HashMap::new(),
            source_dir: PathBuf::default(),
            deps_dir: PathBuf::default(),
            artifacts_dir: PathBuf::default(),
            plan_envs: EnvVars::default(),
            runlog,
        }
    }

    /// Return a mutable reference to the current run log.
    pub fn runlog(&mut self) -> &mut RunLog {
        self.runlog
    }

    /// Set environment variables from a [`RunnablePlan`].
    pub fn set_envs_from_plan(&mut self, plan: &RunnablePlan) -> Result<(), ActionError> {
        self.source_dir = plan
            .source_dir()
            .ok_or(ActionError::Missing("source_dir"))?
            .into();

        self.deps_dir = plan
            .deps_dir()
            .ok_or(ActionError::Missing("deps_dir"))?
            .into();

        self.artifacts_dir = plan
            .artifacts_dir()
            .ok_or(ActionError::Missing("artifacts_dir"))?
            .into();

        if let Some(path) = plan.cache_dir() {
            self.set_env("CARGO_TARGET_DIR", &format!("{path}/cargo-target"));
        }

        if let Some(path) = plan.deps_dir() {
            self.set_env("CARGO_HOME", path);
        }

        let path = std::env::var("PATH").unwrap_or("/bin".into());
        self.set_env("PATH", &format!("/root/.cargo/bin:{path}"));

        for (k, v) in plan.envs().iter() {
            let k = OsString::from(k);
            let v = OsString::from_vec(v.to_vec());
            self.set_env(k, v);
        }

        Ok(())
    }

    /// Return environment variables meant to be carried over from pre-plan to plan.
    pub fn plan_envs(&self) -> &EnvVars {
        &self.plan_envs
    }

    /// Return all environment variables set in the context.
    /// Note that this only incoludes the once explicitly set.
    /// It does not include ones inherited when Ambient (`ambient-execute-plan`)
    /// starts.
    pub fn env(&self) -> Vec<(OsString, OsString)> {
        self.envs
            .iter()
            .map(|(k, v)| (k.into(), v.into()))
            .collect()
    }

    /// Set environment variable for execution of future actions.
    pub fn set_env<S: Into<OsString>>(&mut self, name: S, value: S) {
        self.envs.insert(name.into(), value.into());
    }

    /// Set environment variable that will be added to the runnable plan
    /// for the VM. This only happens in pre-plan. This method is a NO-OP
    /// for any other phase.
    pub fn set_plan_env<S: Into<String>, T: Into<OsString>>(&mut self, name: S, value: T) {
        let name = name.into();
        let value = value.into();
        self.plan_envs.set(name, value.as_encoded_bytes());
    }

    /// Return source directory for this context.
    pub fn source_dir(&self) -> &Path {
        &self.source_dir
    }

    /// Return dependencies directory for this context.
    pub fn deps_dir(&self) -> &Path {
        &self.deps_dir
    }

    /// Return artifacts directory for this context.
    pub fn artifacts_dir(&self) -> &Path {
        &self.artifacts_dir
    }
}

/// A pair of URL and basename, for an item in a `http_get` action.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct Pair {
    /// URL where the file is.
    pub url: String,

    /// File in the depenendencies directory where to store the
    /// downloaded file.
    pub filename: PathBuf,
}

impl Pair {
    /// Return the URL.
    pub fn url(&self) -> &str {
        &self.url
    }

    /// Return the filename.
    pub fn filename(&self) -> &Path {
        &self.filename
    }
}

/// An action that is ready to be executed. These can be executed in any kind of
/// plan, including a runnable plan.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum RunnableAction {
    /// A dummy action that does nothing. Useful for trouble-shooting.
    Dummy(Dummy),

    /// Print current working directory. Useful for trouble-shooting.
    Pwd(Pwd),

    /// Configure git.
    GitConfig(GitConfig),

    /// Run `cargo fetch` in a safe and secure manner.
    CargoFetch(CargoFetch),

    /// Do a DNS lookup.
    Dns(Dns),

    /// Log network diagnostics.
    NetworkDiagnostics(NetworkdDiagnostics),

    /// Wait for VM to have network access.
    WaitOnline(WaitOnline),

    /// Download files via HTTP.
    HttpGet(HttpGet),

    /// Download Debian packages and their dependencies.
    DebGet(DebGet),

    /// Install downloaded Debian packages.
    DebInstall(DebInstall),

    /// Download `npm` packages.
    NpmGet(NpmGet),

    /// Upload files via `rsync`.
    Rsync(Rsync),

    /// Upload built Debian packages.
    Dput(Dput),

    /// Create a directory.
    Mkdir(Mkdir),

    /// Create a tar archive from a directory.
    TarCreate(TarCreate),

    /// Extract a tar archive into a directory.
    TarExtract(TarExtract),

    /// Execute a shell script snippet with `bash`.
    Shell(Shell),

    /// Run `cargo fmt --check`.
    CargoFmt(CargoFmt),

    /// Run `cargo clippy`.
    CargoClippy(CargoClippy),

    /// Run `cargo deny`.
    CargoDeny(CargoDeny),

    /// Run `cargo doc`.
    CargoDoc(CargoDoc),

    /// Run `cargo build`.
    CargoBuild(CargoBuild),

    /// Run `cargo test`.
    CargoTest(CargoTest),

    /// Run `cargo install`.
    CargoInstall(CargoInstall),

    /// Build a Debian `deb` package.
    Deb(Deb),

    /// Execute a custom action.
    Custom(Custom),

    /// Set environment variables.
    Setenv(Setenv),

    /// Download a Rust toolchain into the `$DEPENDENCIES/rustup` directory.
    Rustup(Rustup),

    /// Create a file with specific content.
    CreateFile(CreateFile),

    /// Download Python packages using `pip download`.
    PipDownload(PipDownload),

    /// Download Go module dependencies using `go mod download`.
    GoModDownload(GoModDownload),
}

impl RunnableAction {
    /// Name of the action.
    pub fn name(&self) -> &'static str {
        match self {
            Self::Dummy(_) => "dummy",
            Self::Pwd(_) => "pwd",
            Self::Dns(_) => "dns",
            Self::NetworkDiagnostics(_) => "network_diagnostics",
            Self::WaitOnline(_) => "wait_online",
            Self::GitConfig(_) => "git_config",
            Self::CargoFetch(_) => "cargo_fetch",
            Self::CargoFmt(_) => "cargo_fmt",
            Self::CargoClippy(_) => "cargo_clippy",
            Self::CargoDoc(_) => "cargo_doc",
            Self::CargoDeny(_) => "cargo_deny",
            Self::CargoBuild(_) => "cargo_build",
            Self::CargoTest(_) => "cargo_test",
            Self::CargoInstall(_) => "cargo_install",
            Self::HttpGet(_) => "http_get",
            Self::DebGet(_) => "deb_get",
            Self::DebInstall(_) => "deb_install",
            Self::NpmGet(_) => "npm_get",
            Self::Rsync(_) => "rsync",
            Self::Dput(_) => "dput",
            Self::Mkdir(_) => "mkdir",
            Self::TarCreate(_) => "tar_create",
            Self::TarExtract(_) => "tar_extract",
            Self::Shell(_) => "shell",
            Self::Custom(_) => "custom",
            Self::Deb(_) => "deb",
            Self::Setenv(_) => "setenv",
            Self::Rustup(_) => "rustup",
            Self::CreateFile(_) => "create_file",
            Self::PipDownload(_) => "pip_download",
            Self::GoModDownload(_) => "go_mod_download",
        }
    }

    /// Summarize action: name and the most salient detals.
    pub fn summary(&self) -> String {
        let mut summary = self.name().to_string();
        match self {
            Self::Mkdir(mkdir) => summary.push_str(&format!(": {}", mkdir.pathname().display())),
            Self::Shell(shell) => summary.push_str(&format!(": {}", shell.shell())),
            Self::Custom(custom) => summary.push_str(&format!(": {}", custom.name)),
            _ => (),
        }
        summary
    }

    /// Execute a runnable action.
    pub fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        match self {
            Self::Dummy(x) => x.execute(context),
            Self::Pwd(x) => x.execute(context),
            Self::Dns(x) => x.execute(context),
            Self::NetworkDiagnostics(x) => x.execute(context),
            Self::WaitOnline(x) => x.execute(context),
            Self::GitConfig(x) => x.execute(context),
            Self::CargoFetch(x) => x.execute(context),
            Self::HttpGet(x) => x.execute(context),
            Self::DebGet(x) => x.execute(context),
            Self::DebInstall(x) => x.execute(context),
            Self::NpmGet(x) => x.execute(context),
            Self::Rsync(x) => x.execute(context),
            Self::Dput(x) => x.execute(context),
            Self::Mkdir(x) => x.execute(context),
            Self::TarCreate(x) => x.execute(context),
            Self::TarExtract(x) => x.execute(context),
            Self::Shell(x) => x.execute(context),
            Self::CargoFmt(x) => x.execute(context),
            Self::CargoClippy(x) => x.execute(context),
            Self::CargoDeny(x) => x.execute(context),
            Self::CargoDoc(x) => x.execute(context),
            Self::CargoBuild(x) => x.execute(context),
            Self::CargoTest(x) => x.execute(context),
            Self::CargoInstall(x) => x.execute(context),
            Self::Deb(x) => x.execute(context),
            Self::Custom(x) => x.execute(context),
            Self::Setenv(x) => x.execute(context),
            Self::Rustup(x) => x.execute(context),
            Self::CreateFile(x) => x.execute(context),
            Self::PipDownload(x) => x.execute(context),
            Self::GoModDownload(x) => x.execute(context),
        }?;

        Ok(())
    }
}

impl From<&PrePlanAction> for RunnableAction {
    fn from(action: &PrePlanAction) -> Self {
        match action {
            PrePlanAction::Dummy => Self::Dummy(Dummy),
            PrePlanAction::Pwd => Self::Pwd(Pwd),
            PrePlanAction::Dns { domain } => Self::Dns(Dns::new(domain)),
            PrePlanAction::CargoFetch => Self::CargoFetch(CargoFetch),
            PrePlanAction::HttpGet { items } => {
                let items: Vec<Pair> = items.to_vec();
                Self::HttpGet(HttpGet::new(items))
            }
            PrePlanAction::DebGet {
                packages,
                suite,
                cert,
            } => Self::DebGet(DebGet::new(suite, cert, packages)),
            PrePlanAction::NpmGet => Self::NpmGet(NpmGet::default()),
            PrePlanAction::Rustup {
                channel,
                target,
                components,
                profile,
            } => Self::Rustup(Rustup::new(channel, target, components, profile)),
            PrePlanAction::PipDownload {
                requirements,
                allow_sdist,
            } => Self::PipDownload(PipDownload::new(requirements.clone(), *allow_sdist)),
            PrePlanAction::GoModDownload => Self::GoModDownload(GoModDownload),
        }
    }
}

impl From<(&PostPlanAction, Option<String>, Option<&str>)> for RunnableAction {
    fn from(args: (&PostPlanAction, Option<String>, Option<&str>)) -> Self {
        let (action, rsync_target, dput_target) = args;
        match action {
            PostPlanAction::Dummy => Self::Dummy(Dummy),
            PostPlanAction::Pwd => Self::Pwd(Pwd),
            PostPlanAction::Dns { domain } => Self::Dns(Dns::new(domain)),
            PostPlanAction::Rsync => {
                Self::Rsync(Rsync::new(".", rsync_target.map(|s| s.to_string())))
            }
            PostPlanAction::Rsync2 => {
                Self::Rsync(Rsync::new("rsync", rsync_target.map(|s| s.to_string())))
            }
            PostPlanAction::Dput => Self::Dput(Dput::new(".", dput_target.map(|s| s.into()))),
            PostPlanAction::Dput2 => Self::Dput(Dput::new("debian", dput_target.map(|s| s.into()))),
        }
    }
}

impl From<&UnsafeAction> for RunnableAction {
    fn from(action: &UnsafeAction) -> Self {
        match action {
            UnsafeAction::Pwd => Self::Pwd(Pwd),
            UnsafeAction::Dns { domain } => Self::Dns(Dns::new(domain)),
            UnsafeAction::Mkdir { pathname } => Self::Mkdir(Mkdir::new(pathname.to_path_buf())),
            UnsafeAction::TarCreate { archive, directory } => {
                Self::TarCreate(TarCreate::new(archive.clone(), directory.clone()))
            }
            UnsafeAction::TarExtract { archive, directory } => {
                Self::TarExtract(TarExtract::new(archive.clone(), directory.clone()))
            }
            UnsafeAction::GitConfig => Self::GitConfig(GitConfig),
            UnsafeAction::Shell { shell } => Self::Shell(Shell::new(shell.clone())),
            UnsafeAction::DebInstall => Self::DebInstall(DebInstall::default()),
            UnsafeAction::CargoFmt => Self::CargoFmt(CargoFmt),
            UnsafeAction::CargoClippy => Self::CargoClippy(CargoClippy),
            UnsafeAction::CargoDeny => Self::CargoDeny(CargoDeny),
            UnsafeAction::CargoDoc => Self::CargoDoc(CargoDoc),
            UnsafeAction::CargoBuild => Self::CargoBuild(CargoBuild),
            UnsafeAction::CargoTest => Self::CargoTest(CargoTest),
            UnsafeAction::CargoInstall => Self::CargoInstall(CargoInstall),
            UnsafeAction::Deb => Self::Deb(Deb::new(".")),
            UnsafeAction::Deb2 => Self::Deb(Deb::new("debian")),
            UnsafeAction::Custom(x) => Self::Custom(x.clone()),
            UnsafeAction::Setenv(setenv) => Self::Setenv(setenv.clone()),
            UnsafeAction::CreateFile(x) => Self::CreateFile(x.clone()),
        }
    }
}

/// Actions that can be used in a pre-plan.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum PrePlanAction {
    /// Do nothing. This is only really useful for testing pre-plans.
    Dummy,

    /// Write out the current working directory. This is only really
    /// useful for troubleshooting.
    Pwd,

    /// Do a DNS lookup.
    Dns {
        /// Domain name to look up.
        domain: String,
    },

    /// Fetch Rust language crate dependencies using `cargo fetch`.
    CargoFetch,

    /// Download files from the given URLs, if they've changed or are
    /// missing.
    HttpGet {
        /// List of URL/filename pairs to retrieve.
        items: Vec<Pair>,
    },

    /// Download Debian packages and their dependencies.
    DebGet {
        /// List of packages to download. Dependencies are found automatically.
        packages: Vec<String>,

        /// Which suite (release) to use. Defaults to "stable".
        suite: Option<String>,

        /// Certificate (public key) for archive signing key. Defaults to the Debian trixie one.
        cert: Option<String>,
    },

    /// Download `npm` packages listed in a package lock file.
    NpmGet,

    /// Install Rust toolchains and components with `rustup`.
    Rustup {
        /// Channel, defaults to `stable`.
        channel: Option<String>,

        /// Target platform for toolchain to install.
        target: Option<String>,

        /// Components to install with the toolchain.
        components: Option<Vec<String>>,

        /// Profile: `minimal`, `default`, or `complete` are currently
        /// supported by `rustup`. Defaults to `minimal`.
        profile: Option<String>,
    },

    /// Download Python packages using `pip download`.
    PipDownload {
        /// Requirements file name, relative to source root. Default: "requirements.txt".
        #[serde(default)]
        requirements: Option<String>,

        /// Allow source distributions (not just wheels). Default: false.
        #[serde(default)]
        allow_sdist: Option<bool>,
    },

    /// Download Go module dependencies using `go mod download`.
    GoModDownload,
}

impl PrePlanAction {
    /// Names of all pre-plan actions.
    pub fn names() -> &'static [&'static str] {
        &["dummy", "pwd", "cargo_fetch"]
    }
}

/// Actions that can be used in a post-plan.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum PostPlanAction {
    /// Do nothing. This is only really useful for testing post-plans.
    Dummy,

    /// Write out the current working directory. This is only really
    /// useful for troubleshooting.
    Pwd,

    /// Do a DNS lookup.
    Dns {
        /// Domain name to look up.
        domain: String,
    },

    /// Upload, with `rsync`, the artifacts directory to the server
    /// configured for Ambient ([`Config::rsync_target`](crate::config::StoredConfig#structfield.rsync_target)).
    Rsync,

    /// Upload, with `rsync`, the `rsync` subdirectory of the artifacts
    /// directory to the server configured for Ambient
    /// ([`Config::rsync_target`](crate::config::StoredConfig#structfield.rsync_target)).
    /// The difference with the `Rsync` variant is that only the `rsync`
    /// subdirectory is uploaded.
    Rsync2,

    /// Upload, with `dput`, the Debian (`deb`) packages in the artifacts
    /// directory to the server configured for Ambient
    /// ([`Config::dput_target`](crate::config::StoredConfig#structfield.dput_target)).
    Dput,

    /// Upload, with `dput`, the Debian (`deb`) packages in the `debian`
    /// subdirectory of the artifacts directory to the server configured
    /// for Ambient
    /// ([`Config::dput_target`](crate::config::StoredConfig#structfield.dput_target)).
    /// The difference with the `Dput` variant is that only the `debian`
    /// subdirectory is searched for packages.
    Dput2,
}

impl PostPlanAction {
    /// Return names of all post-plan actions.
    pub fn names() -> &'static [&'static str] {
        &["dummy", "pwd", "cargo_fetch", "rsync", "dput"]
    }
}

/// Untrusted actions that are run in a virtual machine, where they can't
/// do any damage.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum UnsafeAction {
    /// Write out the current working directory. This is only really
    /// useful for troubleshooting.
    Pwd,

    /// Do a DNS lookup.
    Dns {
        /// Domain name to look up.
        domain: String,
    },

    /// Create a directory if it doesn't exist.
    Mkdir {
        /// Path to directory.
        pathname: PathBuf,
    },

    /// Create a tar archive from a directory.
    /// This is meant to be used in a [`RunnablePlan`] to  new cache or
    /// artifacts.
    TarCreate {
        /// Location of the tar archive to be created.
        archive: PathBuf,

        /// Directory to package in a tar archive.
        directory: PathBuf,
    },

    /// Extract a tar archive from a directory.
    /// This is meant to be used in a [`RunnablePlan`] to extract
    /// source code, dependencies, etc.
    TarExtract {
        /// Location of the tar archive to be extracted.
        archive: PathBuf,

        /// Directory where the archive is to be unpacked.
        directory: PathBuf,
    },

    /// Execute a shell script snippet. It is run with `bash` with
    /// `set -xeuo pipefail` to catch problems as early as possible.
    Shell {
        /// Shell snippet.
        shell: String,
    },

    /// Configure git.
    GitConfig,

    /// Install `deb` packages download by the `deb_get` pre-plan action.
    /// Really, all packages in `/ci/deps/debian`.
    DebInstall,

    /// Check that Rust source code is formatted in the idiomatic way,
    /// by running `cargo fmt --check`.
    CargoFmt,

    /// Check that Rust source code is correct and idiomatic, by
    /// running `cargo clippy`. No warnings are allowed.
    CargoClippy,

    /// Check that Rust source code lacks dependencies, licenses, and
    /// other things that are unwanted, by running `cargo deny`.
    CargoDeny,

    /// Format documentation from Rust source code, by running `cargo doc`.
    CargoDoc,

    /// Build a Rust project by running `cargo build`.
    CargoBuild,

    /// Run the test suite of a Rust project, by running `cargo test`.
    CargoTest,

    /// Build and install a Rust project by running `cargo install`.
    /// The installed files go into the artifacts directory.
    CargoInstall,

    /// Build a Debian `deb` package. The built files go into the
    /// artifacts directory.
    Deb,

    /// Build a Debian `deb` package. The built files go into the
    /// `debian` subdirectory of the artifacts directory.
    Deb2,

    /// Run a custom Ambient action from the `.ambient` directory in
    /// the root of the source tree.
    Custom(Custom),

    /// Set enviornment variables for later actions.
    Setenv(Setenv),

    /// Create a file with specific content.
    CreateFile(CreateFile),
}

impl UnsafeAction {
    /// Names of all plan actions.
    pub fn names() -> &'static [&'static str] {
        &[
            "mkdir",
            "tar_create",
            "tar_extract",
            "shell",
            "cargo_fmt",
            "cargo_clippy",
            "cargo_build",
            "cargo_test",
            "cargo_install",
            "deb",
            "custom",
            "create_file",
        ]
    }

    /// Construct a `Mkdir` action.
    pub fn mkdir<P: AsRef<Path>>(pathname: P) -> Self {
        Self::Mkdir {
            pathname: pathname.as_ref().into(),
        }
    }

    /// Construct a `TarCreate` action.
    pub fn tar_create<P: AsRef<Path>>(archive: P, directory: P) -> Self {
        Self::TarCreate {
            archive: archive.as_ref().into(),
            directory: directory.as_ref().into(),
        }
    }

    /// Construct a `TarExtract` action.
    pub fn tar_extract<P: AsRef<Path>>(archive: P, directory: P) -> Self {
        Self::TarExtract {
            archive: archive.as_ref().into(),
            directory: directory.as_ref().into(),
        }
    }

    /// Construct a `Shell` action.
    pub fn shell(shell: &str) -> Self {
        Self::Shell {
            shell: shell.into(),
        }
    }

    /// Create a `CreateFile` action.
    pub fn create_file<P: Into<PathBuf>, S: Into<String>>(
        filename: P,
        content: S,
        overwrite: bool,
    ) -> Self {
        Self::CreateFile(CreateFile::new(filename.into(), content.into(), overwrite))
    }
}

/// Errors returned from handling actions.
#[derive(Debug, thiserror::Error)]
pub enum ActionError {
    /// DNS action failed.
    #[error(transparent)]
    Dns(#[from] DnsError),

    /// Problem in a Cargo action.
    #[error("cargo action failed")]
    Cargo(#[source] crate::action_impl::CargoError),

    /// Problem in a HttpGet action.
    #[error("http_get action failed")]
    HttpGet(#[source] crate::action_impl::HttpGetError),

    /// Problem in a Dput action.
    #[error("dput action failed")]
    Dput(#[source] crate::action_impl::DputError),

    /// Problem in an Rsync action.
    #[error("rsync action failed")]
    Rsync(#[source] crate::action_impl::RsyncError),

    /// Problem in a tar action.
    #[error("tar action failed")]
    Tar(#[source] crate::action_impl::TarError),

    /// Problem in a custom action.
    #[error("custom action failed")]
    Custom(#[source] crate::action_impl::CustomError),

    /// Problem in a pwd action.
    #[error("pwd action failed")]
    Pwd(#[source] crate::action_impl::PwdError),

    /// Problem in `git_config` action.
    #[error("git action failed")]
    GitConfig(#[source] crate::action_impl::GitError),

    /// Problem with `rustup` action.
    #[error("rustup action failed")]
    Rustup(#[source] crate::action_impl::RustupError),

    /// Problem in an mkdir action.
    #[error("failed to create directory")]
    Mkdir(#[source] crate::action_impl::MkdirError),

    /// Problem in a deb action.
    #[error("deb action failed")]
    Deb(#[source] crate::action_impl::DebError),

    /// Probelm with `npm` action.
    #[error("npm action failed")]
    Npm(#[source] crate::action_impl::NpmError),

    /// Problem with `pip_download` action.
    #[error("pip_download action failed")]
    Pip(#[source] crate::action_impl::PipError),

    /// Problem with `go_mod_download` action.
    #[error("go_mod_download action failed")]
    GoMod(#[source] crate::action_impl::GoModError),

    /// Can't execute a command.
    #[error("failed to execute {0}")]
    Execute(String, #[source] CommandError),

    /// Internal error: can't spawn a program.
    #[error("failed to invoke command: empty argv")]
    SpawnNoArgv0,

    /// Can't create file.
    #[error("create_file action failed")]
    CreateFile(#[source] crate::action_impl::CreateFileError),

    /// Programming error: [`RunnablePlan`] is missing a field.
    #[error("runnable plan does not have field {0} set")]
    Missing(&'static str),
}

#[cfg(test)]
mod test {
    use super::*;
    use tempfile::tempdir;

    fn plan() -> RunnablePlan {
        let mut plan = RunnablePlan::default();
        plan.set_cache_dir("/tmp");
        plan.set_deps_dir("/tmp");
        plan.set_source_dir("/tmp");
        plan.set_artifacts_dir("/tmp");
        plan
    }

    #[test]
    fn mkdir_action() -> Result<(), Box<dyn std::error::Error>> {
        let tmp = tempdir()?;
        let path = tmp.path().join("testdir");
        let action = RunnableAction::from(&UnsafeAction::mkdir(&path));
        let plan = plan();
        let mut runlog = RunLog::default();
        let mut context = Context::new(&mut runlog);
        context.set_envs_from_plan(&plan)?;
        assert!(!path.exists());
        assert!(action.execute(&mut context).is_ok());
        assert!(path.exists());
        Ok(())
    }

    // Deserialize a PrePlanAction from YAML and verify it parses correctly.
    fn parse_pre_plan_yaml(yaml: &str) -> PrePlanAction {
        serde_norway::from_str(yaml).expect("failed to parse PrePlanAction YAML")
    }

    // Convert a PrePlanAction to a RunnableAction and check the name.
    fn pre_plan_to_runnable_name(action: &PrePlanAction) -> &'static str {
        RunnableAction::from(action).name()
    }

    #[test]
    fn parse_pip_download_defaults() {
        let action = parse_pre_plan_yaml("action: pip_download\n");
        assert!(matches!(
            action,
            PrePlanAction::PipDownload {
                requirements: None,
                allow_sdist: None,
            }
        ));
        assert_eq!(pre_plan_to_runnable_name(&action), "pip_download");
    }

    #[test]
    fn parse_pip_download_with_options() {
        let yaml = r#"
action: pip_download
requirements: requirements-dev.txt
allow_sdist: true
"#;
        let action = parse_pre_plan_yaml(yaml);
        assert!(matches!(
            action,
            PrePlanAction::PipDownload {
                requirements: Some(ref r),
                allow_sdist: Some(true),
            } if r == "requirements-dev.txt"
        ));
        assert_eq!(pre_plan_to_runnable_name(&action), "pip_download");
    }

    #[test]
    fn parse_go_mod_download() {
        let action = parse_pre_plan_yaml("action: go_mod_download\n");
        assert!(matches!(action, PrePlanAction::GoModDownload));
        assert_eq!(pre_plan_to_runnable_name(&action), "go_mod_download");
    }

    #[test]
    fn pip_download_runnable_round_trip() {
        let action = PipDownload::new(Some("req.txt".into()), Some(true));
        let runnable = RunnableAction::PipDownload(action);
        let json = serde_json::to_string(&runnable).expect("serialize");
        let back: RunnableAction = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(runnable, back);
    }

    #[test]
    fn go_mod_download_runnable_round_trip() {
        let runnable = RunnableAction::GoModDownload(GoModDownload);
        let json = serde_json::to_string(&runnable).expect("serialize");
        let back: RunnableAction = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(runnable, back);
    }

    #[test]
    fn pip_download_in_pre_plan_list() {
        let yaml = r#"
- action: cargo_fetch
- action: pip_download
- action: pip_download
  requirements: dev.txt
"#;
        let actions: Vec<PrePlanAction> =
            serde_norway::from_str(yaml).expect("parse pre_plan list");
        assert_eq!(actions.len(), 3);
        assert!(matches!(actions[0], PrePlanAction::CargoFetch));
        assert!(matches!(
            actions[1],
            PrePlanAction::PipDownload {
                requirements: None,
                ..
            }
        ));
        assert!(matches!(
            actions[2],
            PrePlanAction::PipDownload {
                requirements: Some(ref r),
                ..
            } if r == "dev.txt"
        ));
    }

    #[test]
    fn go_mod_download_in_pre_plan_list() {
        let yaml = r#"
- action: cargo_fetch
- action: go_mod_download
"#;
        let actions: Vec<PrePlanAction> =
            serde_norway::from_str(yaml).expect("parse pre_plan list");
        assert_eq!(actions.len(), 2);
        assert!(matches!(actions[1], PrePlanAction::GoModDownload));
    }
}
