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
119      .tasks
120      .get(task_name)
121      .ok_or_else(|| anyhow::anyhow!("Task not found - {}", task_name))?;
122
123    let planned_task = match task {
124      Task::String(command) => PlannedTask {
125        name: task_name.to_string(),
126        description: None,
127        commands: vec![PlannedCommand::CommandRun {
128          command: command.clone(),
129          shell: default_shell().cmd(),
130        }],
131        dependencies: Vec::new(),
132        base_dir: root.config_base_dir().to_string_lossy().into_owned(),
133        execution_mode: PlannedExecutionMode::Sequential,
134        max_parallel: None,
135        skipped_reason: None,
136      },
137      Task::Task(task) => {
138        for dependency in &task.depends_on {
139          self.visit_task(root, dependency.resolve_name())?;
140        }
141
142        PlannedTask {
143          name: task_name.to_string(),
144          description: if task.description.is_empty() {
145            None
146          } else {
147            Some(task.description.clone())
148          },
149          commands: task
150            .commands
151            .iter()
152            .map(|command| PlannedCommand::from_task_command(root, task, command))
153            .collect(),
154          dependencies: task
155            .depends_on
156            .iter()
157            .map(|dependency| dependency.resolve_name().to_string())
158            .collect(),
159          base_dir: task.task_base_dir_from_root(root).to_string_lossy().into_owned(),
160          execution_mode: if task.is_parallel() {
161            PlannedExecutionMode::Parallel
162          } else {
163            PlannedExecutionMode::Sequential
164          },
165          max_parallel: if task.is_parallel() {
166            Some(task.max_parallel())
167          } else {
168            None
169          },
170          skipped_reason: None,
171        }
172      },
173    };
174
175    self.visiting.remove(task_name);
176    self.visited.insert(task_name.to_string());
177    self.steps.push(planned_task);
178    Ok(())
179  }
180}
181
182impl From<&CommandRunner> for PlannedCommand {
183  fn from(value: &CommandRunner) -> Self {
184    Self::from_task_command(&TaskRoot::default(), &TaskArgs::default(), value)
185  }
186}
187
188impl PlannedCommand {
189  fn from_task_command(root: &TaskRoot, task: &TaskArgs, value: &CommandRunner) -> Self {
190    match value {
191      CommandRunner::CommandRun(command) => PlannedCommand::CommandRun {
192        command: command.clone(),
193        shell: effective_shell(task, None).cmd(),
194      },
195      CommandRunner::LocalRun(local_run) => PlannedCommand::LocalRun {
196        command: local_run.command.clone(),
197        shell: Some(effective_shell(task, local_run.shell.as_ref()).cmd()),
198        work_dir: local_run
199          .work_dir
200          .as_ref()
201          .map(|work_dir| root.resolve_from_config(work_dir).to_string_lossy().into_owned()),
202        interactive: local_run.interactive_enabled(),
203        retrigger: local_run.retrigger_enabled(),
204      },
205      CommandRunner::ContainerRun(container_run) => PlannedCommand::ContainerRun {
206        runtime: container_run
207          .runtime
208          .as_ref()
209          .or(root.container_runtime.as_ref())
210          .map(|runtime| runtime.name().to_string())
211          .unwrap_or_else(|| "auto".to_string()),
212        image: container_run.image.clone(),
213        command: container_run.container_command.clone(),
214        mounted_paths: container_run
215          .mounted_paths
216          .iter()
217          .map(|mounted_path| resolve_plan_mount_spec(root, mounted_path))
218          .collect(),
219      },
220      CommandRunner::ContainerBuild(container_build) => PlannedCommand::ContainerBuild {
221        runtime: container_build
222          .container_build
223          .runtime
224          .as_ref()
225          .or(root.container_runtime.as_ref())
226          .map(|runtime| runtime.name().to_string())
227          .unwrap_or_else(|| "auto".to_string()),
228        image_name: container_build.container_build.image_name.clone(),
229        context: root
230          .resolve_from_config(&container_build.container_build.context)
231          .to_string_lossy()
232          .into_owned(),
233        containerfile: container_build
234          .container_build
235          .containerfile
236          .as_ref()
237          .map(|containerfile| {
238            root
239              .resolve_from_config(containerfile)
240              .to_string_lossy()
241              .into_owned()
242          }),
243        tags: container_build
244          .container_build
245          .tags
246          .clone()
247          .unwrap_or_else(|| vec!["latest".to_string()]),
248        build_args: container_build
249          .container_build
250          .build_args
251          .clone()
252          .unwrap_or_default(),
253        labels: container_build.container_build.labels.clone().unwrap_or_default(),
254      },
255      CommandRunner::TaskRun(task_run) => PlannedCommand::TaskRun {
256        task: task_run.task.clone(),
257      },
258    }
259  }
260}
261
262fn effective_shell(task: &TaskArgs, command_shell: Option<&Shell>) -> Shell {
263  command_shell
264    .cloned()
265    .or_else(|| task.shell.clone())
266    .unwrap_or_else(default_shell)
267}
268
269fn resolve_plan_mount_spec(root: &TaskRoot, mounted_path: &str) -> String {
270  let mut parts = mounted_path.splitn(3, ':');
271  let host = parts.next().unwrap_or_default();
272  let second = parts.next();
273  let third = parts.next();
274
275  if let Some(container_path) = second {
276    if !should_resolve_bind_host(host, container_path) {
277      return mounted_path.to_string();
278    }
279
280    let resolved_host = root.resolve_from_config(host);
281    match third {
282      Some(options) => format!(
283        "{}:{}:{}",
284        resolved_host.to_string_lossy(),
285        container_path,
286        options
287      ),
288      None => format!("{}:{}", resolved_host.to_string_lossy(), container_path),
289    }
290  } else {
291    mounted_path.to_string()
292  }
293}
294
295fn should_resolve_bind_host(host: &str, container_path: &str) -> bool {
296  if host.is_empty() || container_path.is_empty() {
297    return false;
298  }
299
300  host.starts_with('.')
301    || host.starts_with('/')
302    || host.contains('/')
303    || host == "~"
304    || host.starts_with("~/")
305}
306
307#[cfg(test)]
308mod tests {
309  use super::*;
310
311  #[test]
312  fn test_plan_task_resolves_task_shell() -> anyhow::Result<()> {
313    let yaml = "
314      tasks:
315        build:
316          shell: bash
317          commands:
318            - command: echo build
319    ";
320
321    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
322    let plan = task_root.plan_task("build")?;
323    let command = &plan.steps[0].commands[0];
324
325    match command {
326      PlannedCommand::LocalRun { shell, .. } => {
327        assert_eq!(shell.as_deref(), Some("bash"));
328      },
329      _ => panic!("Expected PlannedCommand::LocalRun"),
330    }
331
332    Ok(())
333  }
334
335  #[test]
336  fn test_plan_task_includes_retrigger() -> anyhow::Result<()> {
337    let yaml = "
338      tasks:
339        dev:
340          commands:
341            - command: go run .
342              retrigger: true
343    ";
344
345    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
346    let plan = task_root.plan_task("dev")?;
347    let command = &plan.steps[0].commands[0];
348
349    match command {
350      PlannedCommand::LocalRun { retrigger, .. } => {
351        assert!(*retrigger);
352      },
353      _ => panic!("Expected PlannedCommand::LocalRun"),
354    }
355
356    Ok(())
357  }
358}