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