1
use std::collections::HashSet;
2

            
3
use serde::Serialize;
4

            
5
use crate::defaults::default_shell;
6

            
7
use super::{
8
  CommandRunner,
9
  Shell,
10
  Task,
11
  TaskArgs,
12
  TaskRoot,
13
};
14

            
15
#[derive(Debug, Serialize)]
16
pub struct TaskPlan {
17
  pub root_task: String,
18
  pub steps: Vec<PlannedTask>,
19
}
20

            
21
#[derive(Debug, Serialize)]
22
pub 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")]
35
pub enum PlannedExecutionMode {
36
  Sequential,
37
  Parallel,
38
}
39

            
40
#[derive(Debug, Serialize)]
41
#[serde(tag = "type", rename_all = "snake_case")]
42
pub 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

            
74
impl PlannedCommand {
75
40
  pub fn summary(&self) -> String {
76
40
    match self {
77
      PlannedCommand::CommandRun { command, .. } => format!("command: {}", command),
78
40
      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
40
  }
88
}
89

            
90
impl TaskRoot {
91
68
  pub fn plan_task(&self, task_name: &str) -> anyhow::Result<TaskPlan> {
92
68
    let mut planner = Planner::default();
93
68
    planner.visit_task(self, task_name)?;
94
68
    Ok(TaskPlan {
95
68
      root_task: task_name.to_string(),
96
68
      steps: planner.steps,
97
68
    })
98
68
  }
99
}
100

            
101
#[derive(Default)]
102
struct Planner {
103
  steps: Vec<PlannedTask>,
104
  visiting: HashSet<String>,
105
  visited: HashSet<String>,
106
}
107

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

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

            
118
84
    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
84
    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
84
      Task::Task(task) => {
140
84
        for dependency in &task.depends_on {
141
16
          self.visit_task(root, dependency.resolve_name())?;
142
        }
143

            
144
        PlannedTask {
145
84
          name: task_name.to_string(),
146
84
          description: if task.description.is_empty() {
147
44
            None
148
          } else {
149
40
            Some(task.description.clone())
150
          },
151
84
          commands: task
152
84
            .commands
153
84
            .iter()
154
84
            .map(|command| PlannedCommand::from_task_command(root, task, command))
155
84
            .collect(),
156
84
          dependencies: task
157
84
            .depends_on
158
84
            .iter()
159
84
            .map(|dependency| dependency.resolve_name().to_string())
160
84
            .collect(),
161
84
          base_dir: task.task_base_dir_from_root(root).to_string_lossy().into_owned(),
162
84
          execution_mode: if task.is_parallel() {
163
            PlannedExecutionMode::Parallel
164
          } else {
165
84
            PlannedExecutionMode::Sequential
166
          },
167
84
          max_parallel: if task.is_parallel() {
168
            Some(task.max_parallel())
169
          } else {
170
84
            None
171
          },
172
84
          skipped_reason: None,
173
        }
174
      },
175
    };
176

            
177
84
    self.visiting.remove(task_name);
178
84
    self.visited.insert(task_name.to_string());
179
84
    self.steps.push(planned_task);
180
84
    Ok(())
181
84
  }
182
}
183

            
184
impl From<&CommandRunner> for PlannedCommand {
185
  fn from(value: &CommandRunner) -> Self {
186
    Self::from_task_command(&TaskRoot::default(), &TaskArgs::default(), value)
187
  }
188
}
189

            
190
impl PlannedCommand {
191
84
  fn from_task_command(root: &TaskRoot, task: &TaskArgs, value: &CommandRunner) -> Self {
192
84
    match value {
193
      CommandRunner::CommandRun(command) => PlannedCommand::CommandRun {
194
        command: command.clone(),
195
        shell: effective_shell(task, None).cmd(),
196
      },
197
76
      CommandRunner::LocalRun(local_run) => PlannedCommand::LocalRun {
198
76
        command: local_run.command.clone(),
199
76
        shell: Some(effective_shell(task, local_run.shell.as_ref()).cmd()),
200
76
        work_dir: local_run
201
76
          .work_dir
202
76
          .as_ref()
203
76
          .map(|work_dir| root.resolve_from_config(work_dir).to_string_lossy().into_owned()),
204
76
        interactive: local_run.interactive_enabled(),
205
76
        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
8
      CommandRunner::ContainerBuild(container_build) => PlannedCommand::ContainerBuild {
223
8
        runtime: container_build
224
8
          .container_build
225
8
          .runtime
226
8
          .as_ref()
227
8
          .or(root.container_runtime.as_ref())
228
8
          .map(|runtime| runtime.name().to_string())
229
8
          .unwrap_or_else(|| "auto".to_string()),
230
8
        image_name: container_build.container_build.image_name.clone(),
231
8
        context: root
232
8
          .resolve_from_config(&container_build.container_build.context)
233
8
          .to_string_lossy()
234
8
          .into_owned(),
235
8
        containerfile: container_build
236
8
          .container_build
237
8
          .containerfile
238
8
          .as_ref()
239
8
          .map(|containerfile| {
240
            root
241
              .resolve_from_config(containerfile)
242
              .to_string_lossy()
243
              .into_owned()
244
          }),
245
8
        tags: container_build
246
8
          .container_build
247
8
          .tags
248
8
          .clone()
249
8
          .unwrap_or_else(|| vec!["latest".to_string()]),
250
8
        build_args: container_build
251
8
          .container_build
252
8
          .build_args
253
8
          .clone()
254
8
          .unwrap_or_default(),
255
8
        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
84
  }
262
}
263

            
264
76
fn effective_shell(task: &TaskArgs, command_shell: Option<&Shell>) -> Shell {
265
76
  command_shell
266
76
    .cloned()
267
76
    .or_else(|| task.shell.clone())
268
76
    .unwrap_or_else(default_shell)
269
76
}
270

            
271
fn 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

            
297
fn 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)]
310
mod tests {
311
  use super::*;
312

            
313
  #[test]
314
2
  fn test_plan_task_resolves_task_shell() -> anyhow::Result<()> {
315
2
    let yaml = "
316
2
      tasks:
317
2
        build:
318
2
          shell: bash
319
2
          commands:
320
2
            - command: echo build
321
2
    ";
322

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

            
327
2
    match command {
328
2
      PlannedCommand::LocalRun { shell, .. } => {
329
2
        assert_eq!(shell.as_deref(), Some("bash"));
330
      },
331
      _ => panic!("Expected PlannedCommand::LocalRun"),
332
    }
333

            
334
2
    Ok(())
335
2
  }
336

            
337
  #[test]
338
2
  fn test_plan_task_includes_retrigger() -> anyhow::Result<()> {
339
2
    let yaml = "
340
2
      tasks:
341
2
        dev:
342
2
          commands:
343
2
            - command: go run .
344
2
              retrigger: true
345
2
    ";
346

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

            
351
2
    match command {
352
2
      PlannedCommand::LocalRun { retrigger, .. } => {
353
2
        assert!(*retrigger);
354
      },
355
      _ => panic!("Expected PlannedCommand::LocalRun"),
356
    }
357

            
358
2
    Ok(())
359
2
  }
360
}