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}