ambient-ci 0.14.0

A continuous integration engine
Documentation
//! Look for common problems in configuration and CI plan.

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

use clingwrap::runner::{CommandError, CommandRunner};

use crate::{
    action::{PostPlanAction, PrePlanAction, UnsafeAction},
    action_impl::bash_snippet,
    config::Config,
    project::{Project, Projects},
};

/// Look for common problems.
#[derive(Debug)]
pub struct Linter<'a> {
    config: &'a Config,
    projects: &'a Projects,
    lints: Vec<Box<dyn Lint>>,
}

impl<'a> Linter<'a> {
    /// Create a new linter using a given configuration and project list.
    pub fn new(config: &'a Config, projects: &'a Projects) -> Self {
        Self {
            config,
            projects,
            lints: vec![
                Box::new(RsyncTarget),
                Box::new(HttpGet),
                Box::new(ShellCheck),
                Box::new(DebDputVersions),
                Box::new(OldWorkspacePath),
            ],
        }
    }

    /// Lint a configuration and projects list.
    pub fn lint(&self) -> Result<(), LinterError> {
        for (name, project) in self.projects.iter() {
            for lint in self.lints.iter() {
                lint.check(self.config, name, project)?;
            }
        }
        Ok(())
    }
}

trait Lint: std::fmt::Debug {
    #[allow(dead_code)]
    fn name(&self) -> &'static str;
    fn check(
        &self,
        config: &Config,
        project_name: &str,
        project: &Project,
    ) -> Result<(), LinterError>;
}

#[derive(Debug)]
struct RsyncTarget;

impl Lint for RsyncTarget {
    fn name(&self) -> &'static str {
        "rsync_target"
    }

    fn check(
        &self,
        config: &Config,
        project_name: &str,
        project: &Project,
    ) -> Result<(), LinterError> {
        let uses_rsync = project
            .post_plan()
            .iter()
            .any(|a| matches!(a, PostPlanAction::Rsync | PostPlanAction::Rsync2));
        let configs_rsync = config.rsync_target_for_project(project_name).is_some();
        if uses_rsync && !configs_rsync {
            return Err(LinterError::rsync_target_missing(project_name));
        }
        Ok(())
    }
}

#[derive(Debug)]
struct HttpGet;

impl Lint for HttpGet {
    fn name(&self) -> &'static str {
        "http_get"
    }

    fn check(
        &self,
        _config: &Config,
        project_name: &str,
        project: &Project,
    ) -> Result<(), LinterError> {
        for action in project.pre_plan() {
            let mut filenames = vec![];
            if let PrePlanAction::HttpGet { items } = action {
                for item in items {
                    if filenames.contains(&item.filename) {
                        return Err(LinterError::http_get_duplicate_filename(
                            project_name,
                            &item.filename,
                        ));
                    }
                    filenames.push(item.filename.clone());
                }
            }
        }

        Ok(())
    }
}

#[derive(Debug)]
struct ShellCheck;

impl Lint for ShellCheck {
    fn name(&self) -> &'static str {
        "shellcheck"
    }

    fn check(
        &self,
        _config: &Config,
        _project_name: &str,
        project: &Project,
    ) -> Result<(), LinterError> {
        for action in project.plan() {
            if let UnsafeAction::Shell { shell } = action {
                let mut cmd = Command::new("shellcheck");
                cmd.args(["-s", "bash", "-"]);
                let mut runner = CommandRunner::new(cmd);
                runner.feed_stdin(bash_snippet(shell).as_bytes());
                runner.capture_stdout();
                runner.capture_stderr();
                match runner.execute() {
                    Ok(_) => (),
                    Err(CommandError::CommandFailed { output, .. }) => {
                        return Err(LinterError::shellcheck(output.stdout))
                    }
                    Err(err) => return Err(LinterError::ShellcheckError(err)),
                }
            }
        }

        Ok(())
    }
}

#[derive(Debug)]
struct DebDputVersions;

impl Lint for DebDputVersions {
    fn name(&self) -> &'static str {
        "deb_dput_versions"
    }

    fn check(
        &self,
        _config: &Config,
        _project_name: &str,
        project: &Project,
    ) -> Result<(), LinterError> {
        let has_deb = project
            .plan()
            .iter()
            .any(|action| matches!(action, UnsafeAction::Deb));
        let has_deb2 = project
            .plan()
            .iter()
            .any(|action| matches!(action, UnsafeAction::Deb2));
        let has_dput = project
            .post_plan()
            .iter()
            .any(|action| matches!(action, PostPlanAction::Dput));
        let has_dput2 = project
            .post_plan()
            .iter()
            .any(|action| matches!(action, PostPlanAction::Dput2));
        if (has_deb && has_dput2) || (has_deb2 && has_dput) {
            Err(LinterError::IncompatibleDebianActions)
        } else {
            Ok(())
        }
    }
}

#[derive(Debug)]
struct OldWorkspacePath;

impl Lint for OldWorkspacePath {
    fn name(&self) -> &'static str {
        "/workspace_used"
    }

    fn check(
        &self,
        _config: &Config,
        _project_name: &str,
        project: &Project,
    ) -> Result<(), LinterError> {
        let old_path_used = project.plan().iter().any(|action| {
            if let UnsafeAction::Shell { shell } = action {
                shell.contains("/workspace")
            } else {
                false
            }
        });
        if old_path_used {
            Err(LinterError::OldWorkspacePath)
        } else {
            Ok(())
        }
    }
}

/// Problems found by a linter.
#[derive(Debug, thiserror::Error)]
pub enum LinterError {
    /// `rsync` or `rsync2` action used in plan, but no `rsync` target configured.
    #[error(
        "rsync or rsync2 action used in project {project}, but no `rsync` target in configuration"
    )]
    RsyncTargetMissing {
        /// Name of project.
        project: String,
    },

    /// `http_get` action uses the same filename for items.
    #[error("in project {project_name} http_get pre-plan action uses the same filename for more than one item: {filename}")]
    HttpGetDuplicateFilename {
        /// Project name.
        project_name: String,

        /// Duplicate filename.
        filename: PathBuf,
    },

    /// `shellcheck` command complains.
    #[error("shellcheck found problems: {0}")]
    Shellcheck(String),

    /// Can't execute `shellcheck`.
    #[error("failed to run shellcheck")]
    ShellcheckError(#[source] CommandError),

    /// Has conflicting `deb`/`deb2` vs `dput`/`dput2` actions.
    #[error("has incompible deb/deb2 and dput/dput2 pairs, use only one version")]
    IncompatibleDebianActions,

    /// Old workspace path used.
    #[error("old path /workspace used, use /ci instead")]
    OldWorkspacePath,
}

impl LinterError {
    fn rsync_target_missing(project_name: &str) -> Self {
        Self::RsyncTargetMissing {
            project: project_name.into(),
        }
    }

    fn http_get_duplicate_filename(project_name: &str, filename: &Path) -> Self {
        Self::HttpGetDuplicateFilename {
            project_name: project_name.into(),
            filename: filename.into(),
        }
    }

    fn shellcheck(output: Vec<u8>) -> Self {
        let output = String::from_utf8_lossy(&output).to_string();
        Self::Shellcheck(output)
    }
}