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}