ambient-ci 0.14.0

A continuous integration engine
Documentation
//! Action that use Rust `cargo` too.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tempfile::tempdir;
use walkdir::WalkDir;

use crate::{
    action::{ActionError, Context},
    action_impl::{rust_toolchain_versions, spawn, spawn_in, ActionImpl},
    util::{copy_file, mkdir, UtilError},
};

/// Download Rust crate dependencies using `cargo fetch`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoFetch;

impl ActionImpl for CargoFetch {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        let tmp = tempdir().map_err(CargoError::TempDir)?;
        let dest = tmp.path();
        copy_partial_tree(context.source_dir(), dest, |path| {
            if let Some(name) = path.file_name().map(|s| s.as_encoded_bytes()) {
                name == b"Cargo.toml"
                    || name == b"Cargo.lock"
                    || (name.ends_with(b".rs") && name != b"build.rs")
            } else {
                false
            }
        })?;

        let lockfile = dest.join("Cargo.lock");
        let deny1 = dest.join("deny.toml");
        let deny2 = dest.join(".cargo/deny.toml");
        let deny = deny1.exists() || deny2.exists();
        if lockfile.exists() {
            spawn_in(context, &["cargo", "fetch", "--locked"], dest.to_path_buf())?;
            if deny {
                spawn_in(
                    context,
                    &["cargo", "deny", "--locked", "fetch"],
                    dest.to_path_buf(),
                )?;
            }
        } else {
            spawn_in(context, &["cargo", "fetch"], dest.to_path_buf())?;
            if deny {
                spawn_in(
                    context,
                    &["cargo", "deny", "--locked", "fetch"],
                    dest.to_path_buf(),
                )?;
            }
        }

        Ok(())
    }
}

/// Check that Rust code is formatted in the canonical way, using `cargo fmt --check`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoFmt;

impl ActionImpl for CargoFmt {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        spawn(context, &["cargo", "fmt", "--check"])?;
        Ok(())
    }
}

/// Check that Rust code is correct and idiomatic using `cargo clippy`.
///
/// Warnings are treated as errors.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoClippy;

impl ActionImpl for CargoClippy {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        spawn(
            context,
            &[
                "cargo",
                "clippy",
                "--offline",
                "--locked",
                "--workspace",
                "--all-targets",
                "--no-deps",
                "--",
                "--deny",
                "warnings",
            ],
        )?;
        Ok(())
    }
}

/// Check Rust code for denied stuff, using `cargo deny`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoDeny;

impl ActionImpl for CargoDeny {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        spawn(
            context,
            &[
                "cargo",
                "deny",
                "--offline",
                "--locked",
                "--workspace",
                "check",
            ],
        )?;
        Ok(())
    }
}

/// Render Rust documentation comments, using `cargo doc`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoDoc;

impl ActionImpl for CargoDoc {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        spawn(
            context,
            &[
                "env",
                "RUSTDOCFLAGS=-D warnings",
                "cargo",
                "doc",
                "--workspace",
            ],
        )?;
        Ok(())
    }
}

/// Build a Rust project.
///
/// Run `cargo build` in a way that all parts of the project are built.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoBuild;

impl ActionImpl for CargoBuild {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        spawn(
            context,
            &[
                "cargo",
                "build",
                "--offline",
                "--locked",
                "--workspace",
                "--all-targets",
            ],
        )?;
        Ok(())
    }
}

/// Run automated test suite for a Rust project.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoTest;

impl ActionImpl for CargoTest {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        spawn(
            context,
            &["cargo", "test", "--offline", "--locked", "--workspace"],
        )?;
        Ok(())
    }
}

/// Install a Rust project into the artifacts directory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoInstall;

impl ActionImpl for CargoInstall {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        rust_toolchain_versions(context)?;
        let artifacts = context.artifacts_dir().to_string_lossy().to_string();
        spawn(
            context,
            &[
                "cargo",
                "install",
                "--offline",
                "--locked",
                "--bins",
                "--path=.",
                "--root",
                &artifacts,
            ],
        )?;
        Ok(())
    }
}

fn copy_partial_tree<P, PP, F>(src: P, dest: PP, wanted: F) -> Result<(), CargoError>
where
    P: AsRef<Path>,
    PP: AsRef<Path>,
    F: Fn(&Path) -> bool,
{
    let src = src.as_ref();
    let dest = dest.as_ref();

    mkdir(dest)?;
    for e in WalkDir::new(src) {
        let path = e
            .map_err(|err| CargoError::CopyTreeWalkDir(src.into(), err))?
            .path()
            .to_path_buf();
        if wanted(&path) {
            let dest = dest.join(path.strip_prefix(src).unwrap_or(&path));
            if let Some(parent) = dest.parent() {
                if !parent.exists() {
                    mkdir(parent)?;
                }
            }
            copy_file(&path, &dest)?;
        }
    }
    Ok(())
}

/// Errors from Cargo actions.
#[derive(Debug, thiserror::Error)]
pub enum CargoError {
    /// Forwarded from `util` module.
    #[error(transparent)]
    Util(#[from] UtilError),

    /// Can't list files.
    #[error("failed to list contents of upload directory")]
    WalkDir(#[source] walkdir::Error),

    /// Can't find a .changes file.
    #[error("no *.changes file built for deb project")]
    NoChanges,

    /// Found more than one .changes file.
    #[error("more than one *.changes file built for deb project")]
    ManyChanges,

    /// Can't copy directory tree.
    #[error("failed to list files in directory {0} when copying files")]
    CopyTreeWalkDir(PathBuf, #[source] walkdir::Error),

    /// Couldn't create a temporary directory.
    #[error("failed to create a temporary directory")]
    TempDir(#[source] std::io::Error),
}

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