mk 0.7.12

Yet another simple task runner 🦀
Documentation
use std::collections::HashSet;

use serde::Serialize;

use crate::defaults::default_shell;

use super::{
  CommandRunner,
  Shell,
  Task,
  TaskArgs,
  TaskRoot,
};

#[derive(Debug, Serialize)]
pub struct TaskPlan {
  pub root_task: String,
  pub steps: Vec<PlannedTask>,
}

#[derive(Debug, Serialize)]
pub struct PlannedTask {
  pub name: String,
  pub description: Option<String>,
  pub commands: Vec<PlannedCommand>,
  pub dependencies: Vec<String>,
  pub base_dir: String,
  pub execution_mode: PlannedExecutionMode,
  pub max_parallel: Option<usize>,
  pub skipped_reason: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PlannedExecutionMode {
  Sequential,
  Parallel,
}

#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlannedCommand {
  CommandRun {
    command: String,
    shell: String,
  },
  LocalRun {
    command: String,
    shell: Option<String>,
    work_dir: Option<String>,
    interactive: bool,
    retrigger: bool,
  },
  ContainerRun {
    runtime: String,
    image: String,
    command: Vec<String>,
    mounted_paths: Vec<String>,
  },
  ContainerBuild {
    runtime: String,
    image_name: String,
    context: String,
    containerfile: Option<String>,
    tags: Vec<String>,
    build_args: Vec<String>,
    labels: Vec<String>,
  },
  TaskRun {
    task: String,
  },
}

impl PlannedCommand {
  pub fn summary(&self) -> String {
    match self {
      PlannedCommand::CommandRun { command, .. } => format!("command: {}", command),
      PlannedCommand::LocalRun { command, .. } => format!("local: {}", command),
      PlannedCommand::ContainerRun { image, command, .. } => {
        format!("container_run: {} -> {}", image, command.join(" "))
      },
      PlannedCommand::ContainerBuild {
        image_name, context, ..
      } => format!("container_build: {} ({})", image_name, context),
      PlannedCommand::TaskRun { task } => format!("task: {}", task),
    }
  }
}

impl TaskRoot {
  pub fn plan_task(&self, task_name: &str) -> anyhow::Result<TaskPlan> {
    let mut planner = Planner::default();
    planner.visit_task(self, task_name)?;
    Ok(TaskPlan {
      root_task: task_name.to_string(),
      steps: planner.steps,
    })
  }
}

#[derive(Default)]
struct Planner {
  steps: Vec<PlannedTask>,
  visiting: HashSet<String>,
  visited: HashSet<String>,
}

impl Planner {
  fn visit_task(&mut self, root: &TaskRoot, task_name: &str) -> anyhow::Result<()> {
    if self.visited.contains(task_name) {
      return Ok(());
    }

    if !self.visiting.insert(task_name.to_string()) {
      anyhow::bail!("Circular dependency detected - {}", task_name);
    }

    let task = root.tasks.get(task_name).ok_or_else(|| {
      anyhow::anyhow!(
        "Task '{}' not found. Run 'mk list' to see available tasks.",
        task_name
      )
    })?;

    let planned_task = match task {
      Task::String(command) => PlannedTask {
        name: task_name.to_string(),
        description: None,
        commands: vec![PlannedCommand::CommandRun {
          command: command.clone(),
          shell: default_shell().cmd(),
        }],
        dependencies: Vec::new(),
        base_dir: root.config_base_dir().to_string_lossy().into_owned(),
        execution_mode: PlannedExecutionMode::Sequential,
        max_parallel: None,
        skipped_reason: None,
      },
      Task::Task(task) => {
        for dependency in &task.depends_on {
          self.visit_task(root, dependency.resolve_name())?;
        }

        PlannedTask {
          name: task_name.to_string(),
          description: if task.description.is_empty() {
            None
          } else {
            Some(task.description.clone())
          },
          commands: task
            .commands
            .iter()
            .map(|command| PlannedCommand::from_task_command(root, task, command))
            .collect(),
          dependencies: task
            .depends_on
            .iter()
            .map(|dependency| dependency.resolve_name().to_string())
            .collect(),
          base_dir: task.task_base_dir_from_root(root).to_string_lossy().into_owned(),
          execution_mode: if task.is_parallel() {
            PlannedExecutionMode::Parallel
          } else {
            PlannedExecutionMode::Sequential
          },
          max_parallel: if task.is_parallel() {
            Some(task.max_parallel())
          } else {
            None
          },
          skipped_reason: None,
        }
      },
    };

    self.visiting.remove(task_name);
    self.visited.insert(task_name.to_string());
    self.steps.push(planned_task);
    Ok(())
  }
}

impl From<&CommandRunner> for PlannedCommand {
  fn from(value: &CommandRunner) -> Self {
    Self::from_task_command(&TaskRoot::default(), &TaskArgs::default(), value)
  }
}

impl PlannedCommand {
  fn from_task_command(root: &TaskRoot, task: &TaskArgs, value: &CommandRunner) -> Self {
    match value {
      CommandRunner::CommandRun(command) => PlannedCommand::CommandRun {
        command: command.clone(),
        shell: effective_shell(task, None).cmd(),
      },
      CommandRunner::LocalRun(local_run) => PlannedCommand::LocalRun {
        command: local_run.command.clone(),
        shell: Some(effective_shell(task, local_run.shell.as_ref()).cmd()),
        work_dir: local_run
          .work_dir
          .as_ref()
          .map(|work_dir| root.resolve_from_config(work_dir).to_string_lossy().into_owned()),
        interactive: local_run.interactive_enabled(),
        retrigger: local_run.retrigger_enabled(),
      },
      CommandRunner::ContainerRun(container_run) => PlannedCommand::ContainerRun {
        runtime: container_run
          .runtime
          .as_ref()
          .or(root.container_runtime.as_ref())
          .map(|runtime| runtime.name().to_string())
          .unwrap_or_else(|| "auto".to_string()),
        image: container_run.image.clone(),
        command: container_run.container_command.clone(),
        mounted_paths: container_run
          .mounted_paths
          .iter()
          .map(|mounted_path| resolve_plan_mount_spec(root, mounted_path))
          .collect(),
      },
      CommandRunner::ContainerBuild(container_build) => PlannedCommand::ContainerBuild {
        runtime: container_build
          .container_build
          .runtime
          .as_ref()
          .or(root.container_runtime.as_ref())
          .map(|runtime| runtime.name().to_string())
          .unwrap_or_else(|| "auto".to_string()),
        image_name: container_build.container_build.image_name.clone(),
        context: root
          .resolve_from_config(&container_build.container_build.context)
          .to_string_lossy()
          .into_owned(),
        containerfile: container_build
          .container_build
          .containerfile
          .as_ref()
          .map(|containerfile| {
            root
              .resolve_from_config(containerfile)
              .to_string_lossy()
              .into_owned()
          }),
        tags: container_build
          .container_build
          .tags
          .clone()
          .unwrap_or_else(|| vec!["latest".to_string()]),
        build_args: container_build
          .container_build
          .build_args
          .clone()
          .unwrap_or_default(),
        labels: container_build.container_build.labels.clone().unwrap_or_default(),
      },
      CommandRunner::TaskRun(task_run) => PlannedCommand::TaskRun {
        task: task_run.task.clone(),
      },
    }
  }
}

fn effective_shell(task: &TaskArgs, command_shell: Option<&Shell>) -> Shell {
  command_shell
    .cloned()
    .or_else(|| task.shell.clone())
    .unwrap_or_else(default_shell)
}

fn resolve_plan_mount_spec(root: &TaskRoot, mounted_path: &str) -> String {
  let mut parts = mounted_path.splitn(3, ':');
  let host = parts.next().unwrap_or_default();
  let second = parts.next();
  let third = parts.next();

  if let Some(container_path) = second {
    if !should_resolve_bind_host(host, container_path) {
      return mounted_path.to_string();
    }

    let resolved_host = root.resolve_from_config(host);
    match third {
      Some(options) => format!(
        "{}:{}:{}",
        resolved_host.to_string_lossy(),
        container_path,
        options
      ),
      None => format!("{}:{}", resolved_host.to_string_lossy(), container_path),
    }
  } else {
    mounted_path.to_string()
  }
}

fn should_resolve_bind_host(host: &str, container_path: &str) -> bool {
  if host.is_empty() || container_path.is_empty() {
    return false;
  }

  host.starts_with('.')
    || host.starts_with('/')
    || host.contains('/')
    || host == "~"
    || host.starts_with("~/")
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn test_plan_task_resolves_task_shell() -> anyhow::Result<()> {
    let yaml = "
      tasks:
        build:
          shell: bash
          commands:
            - command: echo build
    ";

    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
    let plan = task_root.plan_task("build")?;
    let command = &plan.steps[0].commands[0];

    match command {
      PlannedCommand::LocalRun { shell, .. } => {
        assert_eq!(shell.as_deref(), Some("bash"));
      },
      _ => panic!("Expected PlannedCommand::LocalRun"),
    }

    Ok(())
  }

  #[test]
  fn test_plan_task_includes_retrigger() -> anyhow::Result<()> {
    let yaml = "
      tasks:
        dev:
          commands:
            - command: go run .
              retrigger: true
    ";

    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
    let plan = task_root.plan_task("dev")?;
    let command = &plan.steps[0].commands[0];

    match command {
      PlannedCommand::LocalRun { retrigger, .. } => {
        assert!(*retrigger);
      },
      _ => panic!("Expected PlannedCommand::LocalRun"),
    }

    Ok(())
  }
}