mk 0.7.13

Yet another simple task runner 🦀
Documentation
mod command;
mod include;
mod plan;
mod precondition;
mod shell;
mod task;
mod task_context;
mod task_dependency;
mod task_root;
mod use_cargo;
mod use_npm;
mod validation;

use std::collections::HashSet;
use std::fmt;
use std::process::Stdio;
use std::sync::{
  Arc,
  Mutex,
};

use once_cell::sync::Lazy;
use regex::Regex;

pub type ActiveTasks = Arc<Mutex<HashSet<String>>>;
pub type CompletedTasks = Arc<Mutex<HashSet<String>>>;

#[derive(Debug)]
pub struct ExecutionInterrupted;

impl fmt::Display for ExecutionInterrupted {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    write!(f, "Execution interrupted")
  }
}

impl std::error::Error for ExecutionInterrupted {}

pub use command::*;
pub use include::*;
pub use plan::*;
pub use precondition::*;
pub use shell::*;
pub use task::*;
pub use task_context::*;
pub use task_dependency::*;
pub use task_root::*;
pub use use_cargo::*;
pub use use_npm::*;
pub use validation::*;

use crate::secrets::load_secret_value;

static TEMPLATE_COMMAND_RE: Lazy<Regex> =
  Lazy::new(|| Regex::new(r"^\$\{\{.+\}\}$").expect("valid template regex"));
static TEMPLATE_EXPR_RE: Lazy<Regex> =
  Lazy::new(|| Regex::new(r"\$\{\{\s*(.+?)\s*\}\}").expect("valid template expression regex"));

pub fn is_shell_command(value: &str) -> anyhow::Result<bool> {
  let re = Regex::new(r"^\$\(.+\)$")?;
  Ok(re.is_match(value))
}

pub fn is_template_command(value: &str) -> anyhow::Result<bool> {
  Ok(TEMPLATE_COMMAND_RE.is_match(value))
}

pub fn resolve_template_command_value(value: &str, context: &TaskContext) -> anyhow::Result<String> {
  let value = value.trim_start_matches("${{").trim_end_matches("}}").trim();
  resolve_template_expression(value, context)
}

pub fn resolve_template_expression(value: &str, context: &TaskContext) -> anyhow::Result<String> {
  if value.starts_with("env.") {
    let value = value.trim_start_matches("env.");
    let value = context
      .env_vars
      .get(value)
      .ok_or_else(|| anyhow::anyhow!("Environment variable '{}' is not defined", value))?;
    Ok(value.to_string())
  } else if value.starts_with("secrets.") {
    let path = value.trim_start_matches("secrets.");
    load_secret_value(
      path,
      context
        .secret_config
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("Secret config missing from task context"))?,
    )
  } else if value.starts_with("outputs.") {
    let name = value.trim_start_matches("outputs.");
    context.get_task_output(name)?.ok_or_else(|| {
      anyhow::anyhow!(
        "Task output '{}' is not available. Ensure the task that produces it runs before this one.",
        name
      )
    })
  } else {
    Ok(value.to_string())
  }
}

pub fn interpolate_template_string(value: &str, context: &TaskContext) -> anyhow::Result<String> {
  let mut result = String::with_capacity(value.len());
  let mut last_end = 0usize;
  for captures in TEMPLATE_EXPR_RE.captures_iter(value) {
    let Some(full_match) = captures.get(0) else {
      continue;
    };
    let Some(expr) = captures.get(1) else {
      continue;
    };
    result.push_str(&value[last_end..full_match.start()]);
    result.push_str(&resolve_template_expression(expr.as_str().trim(), context)?);
    last_end = full_match.end();
  }
  result.push_str(&value[last_end..]);
  Ok(result)
}

pub fn extract_output_references(value: &str) -> Vec<String> {
  TEMPLATE_EXPR_RE
    .captures_iter(value)
    .filter_map(|captures| captures.get(1))
    .map(|expr| expr.as_str().trim())
    .filter_map(|expr| expr.strip_prefix("outputs."))
    .map(str::to_string)
    .collect()
}

pub fn contains_output_reference(value: &str) -> bool {
  !extract_output_references(value).is_empty()
}

pub fn get_output_handler(verbose: bool) -> Stdio {
  if verbose {
    Stdio::piped()
  } else {
    Stdio::null()
  }
}

#[cfg(test)]
mod test {
  use std::sync::Arc;

  use super::*;

  #[test]
  fn test_interpolate_template_string_resolves_outputs() -> anyhow::Result<()> {
    let root = Arc::new(TaskRoot::default());
    let context = TaskContext::empty_with_root(root);
    context.insert_task_output("version", "v1.2.3")?;
    assert_eq!(
      interpolate_template_string("tag=${{ outputs.version }}", &context)?,
      "tag=v1.2.3"
    );
    Ok(())
  }

  #[test]
  fn test_extract_output_references_finds_all_output_templates() {
    assert_eq!(
      extract_output_references("${{ outputs.first }}-${{ outputs.second }}"),
      vec!["first".to_string(), "second".to_string()]
    );
  }
}