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}