workflow 0.3.0

Execute complex workflows using simple definition files.
Documentation
use std::collections::HashMap;
use std::error::Error;
use std::fs::File;
use std::path::Path;
use std::process::Command;

use serde::{Deserialize, Serialize};
use walkdir::{DirEntry, WalkDir};

/// `Workflow` represent a executable workflow
///
/// A workflow is mainly defined in two parts:
///
/// - The metadata: this contains the workflow id, name, author, description
/// - The steps: these are the instructions that will be executed
#[derive(Debug, Serialize, Deserialize)]
pub struct Workflow {
    /// The workflow id. This is mandatory and must be unique
    /// withing a executor context.
    pub id: String,
    /// The optional workflow name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// The optional workflow author.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
    /// The optional workflow description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// The steps to execute.
    pub steps: Vec<Step>,
}

/// Step represent a single, instruction in the `Workflow`
///
/// Like a workflow, a step is mainly defined in two parts:
///
/// - The metadata: step name
/// - The instructions: the command to run, the parameters, etc...
///
/// There's two kind of steps currently existing:
///
/// ## The executable step
///
/// A step is executable if it can be executed directly (i.e if a command is defined on the step)
///
/// ```rust
/// use workflow::Step;
/// use std::collections::HashMap;
/// let step = Step {
///     name: Some("Test step".to_string()),
///     uses: None,
///     exec: Some("echo {name}".to_string()),
///     with: Some(HashMap::default()),
/// };
///
/// assert!(step.executable());
/// ```
///
/// ## The referral step
///
/// A step is referral if it cannot be executed directly, but rather call another
/// workflow to perform the job.
///
/// ```rust
/// use workflow::Step;
/// use std::collections::HashMap;
/// let step = Step {
///     name: Some("Test step".to_string()),
///     uses: Some("std-download".to_string()), // The workflow to run
///     exec: None,
///     with: Some(HashMap::default()),
/// };
///
/// assert!(!step.executable());
/// ```
///
/// The referral system allows to build complex workflow while not re-inventing existing
/// but rathers use already defined workflow.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Step {
    /// The optional step name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// The id of the workflow to be executed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uses: Option<String>,
    /// The command to execute. This will step as executable.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exec: Option<String>,
    /// The optional step args.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub with: Option<HashMap<String, String>>,
}

/// The `WorkflowExecutor` is use to define the executing context of a workflow
/// it is composed of a local cache of workflows that will be provided at runtime for the
/// running workflows to resolve.
#[derive(Default)]
pub struct WorkflowExecutor {
    /// The local cache of workflows that will be used to resolve workflow by referral step.
    workflows_cache: HashMap<String, Workflow>,
}

impl Workflow {
    /// `from_file` allows to read a workflow from his yaml file definition
    /// this will either return the loaded workflow, or an error if something goes wrong.
    ///
    /// ```rust,no_run
    /// use workflow::Workflow;
    /// use std::path::Path;
    /// let workflow = Workflow::from_file(Path::new("my-workflow.yml")).expect("unable to load workflow");
    /// ```
    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn Error>> {
        let file = File::open(&path)?;
        serde_yaml::from_reader(file).map_err(|e| {
            format!("Invalid workflow file `{}`: {}", path.as_ref().display(), e).into()
        })
    }

    /// `execute` will execute the workflow using given cache of workflow (to resolve referral step)
    /// and linked config.
    ///
    /// ```rust,no_run
    /// use workflow::Workflow;
    /// use std::path::Path;
    /// use std::collections::HashMap;
    /// let workflow = Workflow::from_file(Path::new("my-workflow.yml")).expect("unable to load workflow");
    /// workflow.execute(&HashMap::new(), &HashMap::new()).expect("unable to execute workflow");
    /// ```
    pub fn execute(
        &self,
        workflows_cache: &HashMap<String, Workflow>,
        config: &HashMap<String, String>,
    ) -> Result<(), Box<dyn Error>> {
        log::debug!("There is {} steps defined.", self.steps.len());

        for step in &self.steps {
            log::debug!(
                "Executing step `{}`",
                step.name.as_ref().unwrap_or(&"".to_string())
            );

            // if step is directly executable, execute it.
            if step.executable() {
                step.execute(config)?;
                continue;
            }

            let workflow_id = step.uses.as_ref().unwrap();
            let workflow = workflows_cache.get(workflow_id);
            if workflow.is_none() {
                return Err(format!("Cannot find workflow {}", workflow_id).into());
            }

            let workflow = workflow.unwrap();
            workflow.execute(&workflows_cache, &step.with.clone().unwrap_or_default())?;
        }

        Ok(())
    }
}

impl Step {
    /// `execute` will execute the step using given config
    /// this method *SHOULD* only be called on executable step otherwise this will fails.
    fn execute(&self, config: &HashMap<String, String>) -> Result<(), Box<dyn Error>> {
        if self.executable() {
            let cmd = self.exec.as_ref().unwrap();
            let line = extrapolate(&cmd, &self.with.as_ref().unwrap_or(config))?;
            let args: Vec<&str> = line.split(' ').collect();
            execute(args[0], &args[1..])
        } else {
            Err(format!(
                "Step `{}` is not executable.",
                self.name.as_ref().unwrap_or(&"".to_string())
            )
            .into())
        }
    }

    /// `executable` determines if the step is an executable one
    pub fn executable(&self) -> bool {
        self.exec.is_some()
    }
}

impl WorkflowExecutor {
    /// `with_cache` create a new `WorkflowExecutor` and load a local cache of workflows
    /// by searching for workflow file definition in given directory.
    ///
    /// ```rust,no_run
    /// use workflow::WorkflowExecutor;
    /// use std::path::Path;
    /// let executor = WorkflowExecutor::with_cache(Path::new("workflows")).expect("unable to load cache");
    /// ```
    pub fn with_cache<P: AsRef<Path>>(cache_dir: P) -> Result<Self, Box<dyn Error>> {
        let mut workflows_cache = HashMap::new();

        for entry in WalkDir::new(&cache_dir).into_iter() {
            let entry = entry.unwrap();
            if !is_workflow_file(&entry) {
                continue;
            }

            let workflow = Workflow::from_file(entry.path())?;

            log::trace!(
                "loading workflow {} from {}",
                workflow.id,
                entry.path().display()
            );
            workflows_cache.insert(workflow.id.to_string(), workflow);
        }
        log::debug!("Cache of {} workflows loaded.", workflows_cache.len());

        Ok(WorkflowExecutor { workflows_cache })
    }

    /// `execute` will execute given `Workflow` using the executor, passing
    /// the context to the workflow.
    ///
    /// this method is the main entry point.
    ///
    /// ```rust,no_run
    /// use workflow::{WorkflowExecutor, Workflow};
    /// use std::path::Path;
    /// let executor = WorkflowExecutor::with_cache(Path::new("workflows")).expect("unable to load cache");
    ///
    /// let workflow = Workflow::from_file(Path::new("workflow.yml")).expect("unable to load workflow");
    ///
    /// executor.execute(&workflow).expect("unable to execute workflow");
    /// ```
    pub fn execute(&self, workflow: &Workflow) -> Result<(), Box<dyn Error>> {
        log::info!(
            "Executing workflow `{}`{}",
            workflow.name.as_ref().unwrap_or(&"".to_string()),
            workflow
                .author
                .as_ref()
                .map(|v| format!(" (by {}).", v))
                .unwrap_or_default()
        );
        workflow.execute(&self.workflows_cache, &HashMap::new())
    }
}

/// `extrapolate will extrapolate any variable such as {name}
/// in given command line, and will replace them by given value in `args` HashMap.
/// this method fails if after resolve arguments are still present the the command line.
fn extrapolate(command: &str, args: &HashMap<String, String>) -> Result<String, Box<dyn Error>> {
    let mut line = command.to_string();

    for (key, value) in args {
        line = line.replace(&format!("{{{}}}", key), value);
    }

    if line.contains('{') {
        // todo better with regex
        return Err("missing variables".into());
    }

    Ok(line)
}

/// `is_workflow_file` determinate if given `DirEntry` is a workflow file.
fn is_workflow_file(entry: &DirEntry) -> bool {
    entry
        .file_name()
        .to_str()
        .map(|s| s.ends_with(".yml"))
        .unwrap_or(false)
}

/// `execute` will execute given command using given arguments
/// blocking until the command terminates.
fn execute(command: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
    log::trace!("Executing: `{} {}`", command, args.join(" "));
    Command::new(command)
        .args(args)
        .spawn()
        .map(|mut c| c.wait())
        .map(|_| ())
        .map_err(|e| e.into())
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use crate::{extrapolate, Step};

    #[test]
    fn test_extrapolate() {
        let command = "wget {url} > {path}";
        let mut args = HashMap::new();
        args.insert(
            "url".to_string(),
            "ftp://ftp.vim.org/pub/vim/unix/vim-8.1.tar.bz2".to_string(),
        );

        let line = extrapolate(command, &args);
        assert!(line.is_err());

        args.insert("path".to_string(), "/tmp".to_string());
        let line = extrapolate(command, &args);
        assert!(line.is_ok());

        let line = line.unwrap();
        assert_eq!(
            line,
            "wget ftp://ftp.vim.org/pub/vim/unix/vim-8.1.tar.bz2 > /tmp"
        );
    }

    #[test]
    fn test_step_executable() {
        let mut step = Step::default();
        assert_eq!(step.executable(), false);

        step.exec = Some("sh".to_string());
        assert_eq!(step.executable(), true);
    }
}