Skip to main content

mk_lib/schema/
plan.rs

1use std::collections::HashSet;
2
3use serde::Serialize;
4
5use crate::defaults::default_shell;
6
7use super::{
8  CommandRunner,
9  Shell,
10  Task,
11  TaskArgs,
12  TaskRoot,
13};
14
15#[derive(Debug, Serialize)]
16pub struct TaskPlan {
17  pub root_task: String,
18  pub steps: Vec<PlannedTask>,
19}
20
21#[derive(Debug, Serialize)]
22pub struct PlannedTask {
23  pub name: String,
24  pub description: Option<String>,
25  pub commands: Vec<PlannedCommand>,
26  pub dependencies: Vec<String>,
27  pub base_dir: String,
28  pub execution_mode: PlannedExecutionMode,
29  pub max_parallel: Option<usize>,
30  pub skipped_reason: Option<String>,
31}
32
33#[derive(Debug, Serialize)]
34#[serde(rename_all = "snake_case")]
35pub enum PlannedExecutionMode {
36  Sequential,
37  Parallel,
38}
39
40#[derive(Debug, Serialize)]
41#[serde(tag = "type", rename_all = "snake_case")]
42pub enum PlannedCommand {
43  CommandRun {
44    command: String,
45    shell: String,
46  },
47  LocalRun {
48    command: String,
49    shell: Option<String>,
50    work_dir: Option<String>,
51    interactive: bool,
52    retrigger: bool,
53  },
54  ContainerRun {
55    runtime: String,
56    image: String,
57    command: Vec<String>,
58    mounted_paths: Vec<String>,
59  },
60  ContainerBuild {
61    runtime: String,
62    image_name: String,
63    context: String,
64    containerfile: Option<String>,
65    tags: Vec<String>,
66    build_args: Vec<String>,
67    labels: Vec<String>,
68  },
69  TaskRun {
70    task: String,
71  },
72}
73
74impl PlannedCommand {
75  pub fn summary(&self) -> String {
76    match self {
77      PlannedCommand::CommandRun { command, .. } => format!("command: {}", command),
78      PlannedCommand::LocalRun { command, .. } => format!("local: {}", command),
79      PlannedCommand::ContainerRun { image, command, .. } => {
80        format!("container_run: {} -> {}", image, command.join(" "))
81      },
82      PlannedCommand::ContainerBuild {
83        image_name, context, ..
84      } => format!("container_build: {} ({})", image_name, context),
85      PlannedCommand::TaskRun { task } => format!("task: {}", task),
86    }
87  }
88}
89
90impl TaskRoot {
91  pub fn plan_task(&self, task_name: &str) -> anyhow::Result<TaskPlan> {
92    let mut planner = Planner::default();
93    planner.visit_task(self, task_name)?;
94    Ok(TaskPlan {
95      root_task: task_name.to_string(),
96      steps: planner.steps,
97    })
98  }
99}
100
101#[derive(Default)]
102struct Planner {
103  steps: Vec<PlannedTask>,
104  visiting: HashSet<String>,
105  visited: HashSet<String>,
106}
107
108impl Planner {
109  fn visit_task(&mut self, root: &TaskRoot, task_name: &str) -> anyhow::Result<()> {
110    if self.visited.contains(task_name) {
111      return Ok(());
112    }
113
114    if !self.visiting.insert(task_name.to_string()) {
115      anyhow::bail!("Circular dependency detected - {}", task_name);
116    }
117
118    let task = root.tasks.get(task_name).ok_or_else(|| {
119      anyhow::anyhow!(
120        "Task '{}' not found. Run 'mk list' to see available tasks.",
121        task_name
122      )
123    })?;
124
125    let planned_task = match task {
126      Task::String(command) => PlannedTask {
127        name: task_name.to_string(),
128        description: None,
129        commands: vec![PlannedCommand::CommandRun {
130          command: command.clone(),
131          shell: default_shell().cmd(),
132        }],
133        dependencies: Vec::new(),
134        base_dir: root.config_base_dir().to_string_lossy().into_owned(),
135        execution_mode: PlannedExecutionMode::Sequential,
136        max_parallel: None,
137        skipped_reason: None,
138      },
139      Task::Task(task) => {
140        for dependency in &task.depends_on {
141          self.visit_task(root, dependency.resolve_name())?;
142        }
143
144        PlannedTask {
145          name: task_name.to_string(),
146          description: if task.description.is_empty() {
147            None
148          } else {
149            Some(task.description.clone())
150          },
151          commands: task
152            .commands
153            .iter()
154            .map(|command| PlannedCommand::from_task_command(root, task, command))
155            .collect(),
156          dependencies: task
157            .depends_on
158            .iter()
159            .map(|dependency| dependency.resolve_name().to_string())
160            .collect(),
161          base_dir: task.task_base_dir_from_root(root).to_string_lossy().into_owned(),
162          execution_mode: if task.is_parallel() {
163            PlannedExecutionMode::Parallel
164          } else {
165            PlannedExecutionMode::Sequential
166          },
167          max_parallel: if task.is_parallel() {
168            Some(task.max_parallel())
169          } else {
170            None
171          },
172          skipped_reason: None,
173        }
174      },
175    };
176
177    self.visiting.remove(task_name);
178    self.visited.insert(task_name.to_string());
179    self.steps.push(planned_task);
180    Ok(())
181  }
182}
183
184impl From<&CommandRunner> for PlannedCommand {
185  fn from(value: &CommandRunner) -> Self {
186    Self::from_task_command(&TaskRoot::default(), &TaskArgs::default(), value)
187  }
188}
189
190impl PlannedCommand {
191  fn from_task_command(root: &TaskRoot, task: &TaskArgs, value: &CommandRunner) -> Self {
192    match value {
193      CommandRunner::CommandRun(command) => PlannedCommand::CommandRun {
194        command: command.clone(),
195        shell: effective_shell(task, None).cmd(),
196      },
197      CommandRunner::LocalRun(local_run) => PlannedCommand::LocalRun {
198        command: local_run.command.clone(),
199        shell: Some(effective_shell(task, local_run.shell.as_ref()).cmd()),
200        work_dir: local_run
201          .work_dir
202          .as_ref()
203          .map(|work_dir| root.resolve_from_config(work_dir).to_string_lossy().into_owned()),
204        interactive: local_run.interactive_enabled(),
205        retrigger: local_run.retrigger_enabled(),
206      },
207      CommandRunner::ContainerRun(container_run) => PlannedCommand::ContainerRun {
208        runtime: container_run
209          .runtime
210          .as_ref()
211          .or(root.container_runtime.as_ref())
212          .map(|runtime| runtime.name().to_string())
213          .unwrap_or_else(|| "auto".to_string()),
214        image: container_run.image.clone(),
215        command: container_run.container_command.clone(),
216        mounted_paths: container_run
217          .mounted_paths
218          .iter()
219          .map(|mounted_path| resolve_plan_mount_spec(root, mounted_path))
220          .collect(),
221      },
222      CommandRunner::ContainerBuild(container_build) => PlannedCommand::ContainerBuild {
223        runtime: container_build
224          .container_build
225          .runtime
226          .as_ref()
227          .or(root.container_runtime.as_ref())
228          .map(|runtime| runtime.name().to_string())
229          .unwrap_or_else(|| "auto".to_string()),
230        image_name: container_build.container_build.image_name.clone(),
231        context: root
232          .resolve_from_config(&container_build.container_build.context)
233          .to_string_lossy()
234          .into_owned(),
235        containerfile: container_build
236          .container_build
237          .containerfile
238          .as_ref()
239          .map(|containerfile| {
240            root
241              .resolve_from_config(containerfile)
242              .to_string_lossy()
243              .into_owned()
244          }),
245        tags: container_build
246          .container_build
247          .tags
248          .clone()
249          .unwrap_or_else(|| vec!["latest".to_string()]),
250        build_args: container_build
251          .container_build
252          .build_args
253          .clone()
254          .unwrap_or_default(),
255        labels: container_build.container_build.labels.clone().unwrap_or_default(),
256      },
257      CommandRunner::TaskRun(task_run) => PlannedCommand::TaskRun {
258        task: task_run.task.clone(),
259      },
260    }
261  }
262}
263
264fn effective_shell(task: &TaskArgs, command_shell: Option<&Shell>) -> Shell {
265  command_shell
266    .cloned()
267    .or_else(|| task.shell.clone())
268    .unwrap_or_else(default_shell)
269}
270
271fn resolve_plan_mount_spec(root: &TaskRoot, mounted_path: &str) -> String {
272  let mut parts = mounted_path.splitn(3, ':');
273  let host = parts.next().unwrap_or_default();
274  let second = parts.next();
275  let third = parts.next();
276
277  if let Some(container_path) = second {
278    if !should_resolve_bind_host(host, container_path) {
279      return mounted_path.to_string();
280    }
281
282    let resolved_host = root.resolve_from_config(host);
283    match third {
284      Some(options) => format!(
285        "{}:{}:{}",
286        resolved_host.to_string_lossy(),
287        container_path,
288        options
289      ),
290      None => format!("{}:{}", resolved_host.to_string_lossy(), container_path),
291    }
292  } else {
293    mounted_path.to_string()
294  }
295}
296
297fn should_resolve_bind_host(host: &str, container_path: &str) -> bool {
298  if host.is_empty() || container_path.is_empty() {
299    return false;
300  }
301
302  host.starts_with('.')
303    || host.starts_with('/')
304    || host.contains('/')
305    || host == "~"
306    || host.starts_with("~/")
307}
308
309#[cfg(test)]
310mod tests {
311  use super::*;
312
313  #[test]
314  fn test_plan_task_resolves_task_shell() -> anyhow::Result<()> {
315    let yaml = "
316      tasks:
317        build:
318          shell: bash
319          commands:
320            - command: echo build
321    ";
322
323    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
324    let plan = task_root.plan_task("build")?;
325    let command = &plan.steps[0].commands[0];
326
327    match command {
328      PlannedCommand::LocalRun { shell, .. } => {
329        assert_eq!(shell.as_deref(), Some("bash"));
330      },
331      _ => panic!("Expected PlannedCommand::LocalRun"),
332    }
333
334    Ok(())
335  }
336
337  #[test]
338  fn test_plan_task_includes_retrigger() -> anyhow::Result<()> {
339    let yaml = "
340      tasks:
341        dev:
342          commands:
343            - command: go run .
344              retrigger: true
345    ";
346
347    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
348    let plan = task_root.plan_task("dev")?;
349    let command = &plan.steps[0].commands[0];
350
351    match command {
352      PlannedCommand::LocalRun { retrigger, .. } => {
353        assert!(*retrigger);
354      },
355      _ => panic!("Expected PlannedCommand::LocalRun"),
356    }
357
358    Ok(())
359  }
360}