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};
#[derive(Debug, Serialize, Deserialize)]
pub struct Workflow {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub steps: Vec<Step>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Step {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uses: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub with: Option<HashMap<String, String>>,
}
#[derive(Default)]
pub struct WorkflowExecutor {
workflows_cache: HashMap<String, Workflow>,
}
impl 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()
})
}
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.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 {
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())
}
}
pub fn executable(&self) -> bool {
self.exec.is_some()
}
}
impl WorkflowExecutor {
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 })
}
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())
}
}
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('{') {
return Err("missing variables".into());
}
Ok(line)
}
fn is_workflow_file(entry: &DirEntry) -> bool {
entry
.file_name()
.to_str()
.map(|s| s.ends_with(".yml"))
.unwrap_or(false)
}
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);
}
}