1use anyhow::Context;
2use hashbrown::HashMap;
3use mlua::{
4 Lua,
5 LuaSerdeExt,
6};
7use serde::Deserialize;
8
9use std::fs::File;
10use std::io::{
11 BufReader,
12 Read as _,
13};
14use std::path::{
15 Path,
16 PathBuf,
17};
18
19use super::{
20 ContainerRuntime,
21 Include,
22 Task,
23 UseCargo,
24 UseNpm,
25};
26use crate::file::ToUtf8 as _;
27use crate::utils::{
28 deserialize_environment,
29 resolve_path,
30};
31
32const MK_COMMANDS: [&str; 10] = [
33 "run",
34 "list",
35 "completion",
36 "secrets",
37 "help",
38 "init",
39 "update",
40 "validate",
41 "plan",
42 "clean-cache",
43];
44
45#[derive(Debug, Default, Deserialize)]
48pub struct TaskRoot {
49 pub tasks: HashMap<String, Task>,
51
52 #[serde(default, deserialize_with = "deserialize_environment")]
54 pub environment: HashMap<String, String>,
55
56 #[serde(default)]
58 pub env_file: Vec<String>,
59
60 #[serde(default)]
62 pub secrets_path: Vec<String>,
63
64 #[serde(default)]
66 pub vault_location: Option<String>,
67
68 #[serde(default)]
70 pub keys_location: Option<String>,
71
72 #[serde(default)]
74 pub key_name: Option<String>,
75
76 #[serde(default)]
78 pub use_npm: Option<UseNpm>,
79
80 #[serde(default)]
82 pub use_cargo: Option<UseCargo>,
83
84 #[serde(default)]
86 pub container_runtime: Option<ContainerRuntime>,
87
88 #[serde(default)]
90 pub include: Option<Vec<Include>>,
91
92 #[serde(default)]
94 pub extends: Option<String>,
95
96 #[serde(skip)]
98 pub source_path: Option<PathBuf>,
99}
100
101impl TaskRoot {
102 pub fn from_file(file: &str) -> anyhow::Result<Self> {
103 Self::from_file_with_stack(file, &mut Vec::new())
104 }
105
106 fn from_file_with_stack(file: &str, stack: &mut Vec<PathBuf>) -> anyhow::Result<Self> {
107 let file_path = normalize_task_file_path(file)?;
108
109 if let Some(index) = stack.iter().position(|path| path == &file_path) {
110 let mut cycle = stack[index..]
111 .iter()
112 .map(|path| path.to_string_lossy().into_owned())
113 .collect::<Vec<_>>();
114 cycle.push(file_path.to_string_lossy().into_owned());
115 anyhow::bail!("Circular extends detected: {}", cycle.join(" -> "));
116 }
117
118 stack.push(file_path.clone());
119 let result = load_task_root(&file_path, stack);
120 stack.pop();
121 result
122 }
123
124 pub fn from_hashmap(tasks: HashMap<String, Task>) -> Self {
125 Self {
126 tasks,
127 environment: HashMap::new(),
128 env_file: Vec::new(),
129 secrets_path: Vec::new(),
130 vault_location: None,
131 keys_location: None,
132 key_name: None,
133 use_npm: None,
134 use_cargo: None,
135 container_runtime: None,
136 include: None,
137 extends: None,
138 source_path: None,
139 }
140 }
141
142 pub fn config_base_dir(&self) -> PathBuf {
143 self
144 .source_path
145 .as_ref()
146 .and_then(|path| path.parent().map(Path::to_path_buf))
147 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
148 }
149
150 pub fn cache_base_dir(&self) -> PathBuf {
151 self.config_base_dir()
152 }
153
154 pub fn resolve_from_config(&self, value: &str) -> PathBuf {
155 resolve_path(&self.config_base_dir(), value)
156 }
157}
158
159fn normalize_task_file_path(file: &str) -> anyhow::Result<PathBuf> {
160 let file_path = Path::new(file);
161 if file_path.is_absolute() {
162 Ok(file_path.to_path_buf())
163 } else {
164 Ok(std::env::current_dir()?.join(file_path))
165 }
166}
167
168fn load_task_root(file_path: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
169 let file_extension = file_path
170 .extension()
171 .and_then(|ext| ext.to_str())
172 .context("Failed to get file extension")?;
173
174 let mut root = match file_extension {
175 "yaml" | "yml" => load_yaml_file(file_path, stack),
176 "lua" => load_lua_file(file_path, stack),
177 "json" => load_json_file(file_path, stack),
178 "toml" => load_toml_file(file_path, stack),
179 "json5" => anyhow::bail!("JSON5 files are not supported yet"),
180 "makefile" | "mk" => anyhow::bail!("Makefiles are not supported yet"),
181 _ => anyhow::bail!("Unsupported file extension - {}", file_extension),
182 }?;
183
184 if root.include.is_some() {
185 anyhow::bail!("`include` is no longer supported. Use `extends` instead.");
186 }
187
188 root.source_path = Some(file_path.to_path_buf());
189 process_task_sources(&mut root)?;
190
191 Ok(root)
192}
193
194fn load_yaml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
195 let file_handle = File::open(file).with_context(|| {
196 format!(
197 "Failed to open file - {}",
198 file.to_utf8().unwrap_or("<non-utf8-path>")
199 )
200 })?;
201 let reader = BufReader::new(file_handle);
202
203 let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
206 value.apply_merge()?;
207
208 let root: TaskRoot = serde_yaml::from_value(value)?;
210 apply_extends(file, stack, root)
211}
212
213fn load_toml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
214 let mut file_handle = File::open(file).with_context(|| {
215 format!(
216 "Failed to open file - {}",
217 file.to_utf8().unwrap_or("<non-utf8-path>")
218 )
219 })?;
220 let mut contents = String::new();
221 file_handle.read_to_string(&mut contents)?;
222
223 let root: TaskRoot = toml::from_str(&contents)?;
225 apply_extends(file, stack, root)
226}
227
228fn load_json_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
229 let file_handle = File::open(file).with_context(|| {
230 format!(
231 "Failed to open file - {}",
232 file.to_utf8().unwrap_or("<non-utf8-path>")
233 )
234 })?;
235 let reader = BufReader::new(file_handle);
236
237 let root: TaskRoot = serde_json::from_reader(reader)?;
239 apply_extends(file, stack, root)
240}
241
242fn load_lua_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
243 let mut file_handle = File::open(file).with_context(|| {
244 format!(
245 "Failed to open file - {}",
246 file.to_utf8().unwrap_or("<non-utf8-path>")
247 )
248 })?;
249 let mut contents = String::new();
250 file_handle.read_to_string(&mut contents)?;
251
252 let root: TaskRoot = get_lua_table(&contents)?;
254 apply_extends(file, stack, root)
255}
256
257fn process_task_sources(root: &mut TaskRoot) -> anyhow::Result<()> {
258 root.tasks = rename_tasks(
259 std::mem::take(&mut root.tasks),
260 "task",
261 &MK_COMMANDS,
262 &HashMap::new(),
263 );
264
265 if let Some(npm) = &root.use_npm {
266 let npm_tasks = npm.capture_in_dir(&root.config_base_dir())?;
267 let renamed_npm_tasks = rename_tasks(npm_tasks, "npm", &MK_COMMANDS, &root.tasks);
268 root.tasks.extend(renamed_npm_tasks);
269 }
270
271 if let Some(cargo) = &root.use_cargo {
272 let cargo_tasks = cargo.capture_in_dir(&root.config_base_dir())?;
273 let renamed_cargo_tasks = rename_tasks(cargo_tasks, "cargo", &MK_COMMANDS, &root.tasks);
274 root.tasks.extend(renamed_cargo_tasks);
275 }
276
277 Ok(())
278}
279
280fn apply_extends(file: &Path, stack: &mut Vec<PathBuf>, mut root: TaskRoot) -> anyhow::Result<TaskRoot> {
281 let Some(parent) = root.extends.clone() else {
282 return Ok(root);
283 };
284
285 let parent_path = file.parent().unwrap_or_else(|| Path::new(".")).join(parent);
286 let mut base = TaskRoot::from_file_with_stack(parent_path.to_string_lossy().as_ref(), stack)?;
287
288 base.tasks.extend(root.tasks.drain());
289 base.environment.extend(root.environment.drain());
290 base.env_file.extend(root.env_file);
291 base.secrets_path.extend(root.secrets_path);
292 base.vault_location = root.vault_location.or(base.vault_location);
293 base.keys_location = root.keys_location.or(base.keys_location);
294 base.key_name = root.key_name.or(base.key_name);
295 base.use_npm = root.use_npm.or(base.use_npm);
296 base.use_cargo = root.use_cargo.or(base.use_cargo);
297 base.container_runtime = root.container_runtime.or(base.container_runtime);
298 base.include = root.include.or(base.include);
299 base.extends = None;
300 base.source_path = root.source_path.or(base.source_path);
301
302 Ok(base)
303}
304
305fn get_lua_table(contents: &str) -> anyhow::Result<TaskRoot> {
306 let lua = Lua::new();
308
309 let value = lua.load(contents).eval()?;
311
312 let root = lua.from_value(value)?;
314
315 Ok(root)
316}
317
318fn rename_tasks(
319 tasks: HashMap<String, Task>,
320 prefix: &str,
321 mk_commands: &[&str],
322 existing_tasks: &HashMap<String, Task>,
323) -> HashMap<String, Task> {
324 let mut new_tasks = HashMap::new();
325 for (task_name, task) in tasks.into_iter() {
326 let new_task_name =
327 if mk_commands.contains(&task_name.as_str()) || existing_tasks.contains_key(&task_name) {
328 format!("{}_{}", prefix, task_name)
329 } else {
330 task_name
331 };
332
333 new_tasks.insert(new_task_name, task);
334 }
335 new_tasks
336}
337
338#[cfg(test)]
339mod test {
340 use super::*;
341 use crate::schema::{
342 CommandRunner,
343 TaskDependency,
344 };
345
346 #[test]
347 fn test_task_root_1() -> anyhow::Result<()> {
348 let yaml = "
349 tasks:
350 task1:
351 commands:
352 - command: echo \"Hello, World 1!\"
353 ignore_errors: false
354 verbose: false
355 depends_on:
356 - name: task2
357 description: 'This is a task'
358 labels: {}
359 environment:
360 FOO: bar
361 env_file:
362 - test.env
363 task2:
364 commands:
365 - command: echo \"Hello, World 2!\"
366 ignore_errors: false
367 verbose: false
368 depends_on:
369 - name: task1
370 description: 'This is a task'
371 labels: {}
372 environment: {}
373 task3:
374 commands:
375 - command: echo \"Hello, World 3!\"
376 ignore_errors: false
377 verbose: false
378 ";
379
380 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
381
382 assert_eq!(task_root.tasks.len(), 3);
383
384 if let Task::Task(task) = &task_root.tasks["task1"] {
385 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
386 assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
387 assert_eq!(local_run.work_dir, None);
388 assert_eq!(local_run.ignore_errors, Some(false));
389 assert_eq!(local_run.verbose, Some(false));
390 } else {
391 panic!("Expected CommandRunner::LocalRun");
392 }
393
394 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
395 assert_eq!(args.name, "task2");
396 } else {
397 panic!("Expected TaskDependency::TaskDependency");
398 }
399 assert_eq!(task.labels.len(), 0);
400 assert_eq!(task.description, "This is a task");
401 assert_eq!(task.environment.len(), 1);
402 assert_eq!(task.env_file.len(), 1);
403 } else {
404 panic!("Expected Task::Task");
405 }
406
407 if let Task::Task(task) = &task_root.tasks["task2"] {
408 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
409 assert_eq!(local_run.command, "echo \"Hello, World 2!\"");
410 assert_eq!(local_run.work_dir, None);
411 assert_eq!(local_run.ignore_errors, Some(false));
412 assert_eq!(local_run.verbose, Some(false));
413 } else {
414 panic!("Expected CommandRunner::LocalRun");
415 }
416
417 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
418 assert_eq!(args.name, "task1");
419 } else {
420 panic!("Expected TaskDependency::TaskDependency");
421 }
422 assert_eq!(task.labels.len(), 0);
423 assert_eq!(task.description, "This is a task");
424 assert_eq!(task.environment.len(), 0);
425 assert_eq!(task.env_file.len(), 0);
426 } else {
427 panic!("Expected Task::Task");
428 }
429
430 if let Task::Task(task) = &task_root.tasks["task3"] {
431 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
432 assert_eq!(local_run.command, "echo \"Hello, World 3!\"");
433 assert_eq!(local_run.work_dir, None);
434 assert_eq!(local_run.ignore_errors, Some(false));
435 assert_eq!(local_run.verbose, Some(false));
436 } else {
437 panic!("Expected CommandRunner::LocalRun");
438 }
439
440 assert_eq!(task.depends_on.len(), 0);
441 assert_eq!(task.labels.len(), 0);
442 assert_eq!(task.description.len(), 0);
443 assert_eq!(task.environment.len(), 0);
444 assert_eq!(task.env_file.len(), 0);
445 } else {
446 panic!("Expected Task::Task");
447 }
448
449 Ok(())
450 }
451
452 #[test]
453 fn test_task_root_2() -> anyhow::Result<()> {
454 let yaml = "
455 tasks:
456 task1:
457 commands:
458 - command: echo \"Hello, World 1!\"
459 task2:
460 commands:
461 - echo \"Hello, World 2!\"
462 task3: echo \"Hello, World 3!\"
463 ";
464
465 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
466
467 assert_eq!(task_root.tasks.len(), 3);
468
469 if let Task::Task(task) = &task_root.tasks["task1"] {
470 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
471 assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
472 assert_eq!(local_run.work_dir, None);
473 assert_eq!(local_run.ignore_errors, None);
474 assert_eq!(local_run.verbose, None);
475 } else {
476 panic!("Expected CommandRunner::LocalRun");
477 }
478
479 assert_eq!(task.labels.len(), 0);
480 assert_eq!(task.description, "");
481 assert_eq!(task.environment.len(), 0);
482 assert_eq!(task.env_file.len(), 0);
483 } else {
484 panic!("Expected Task::Task");
485 }
486
487 if let Task::Task(task) = &task_root.tasks["task2"] {
488 if let CommandRunner::CommandRun(command) = &task.commands[0] {
489 assert_eq!(command, "echo \"Hello, World 2!\"");
490 } else {
491 panic!("Expected CommandRunner::CommandRun");
492 }
493
494 assert_eq!(task.labels.len(), 0);
495 assert_eq!(task.description, "");
496 assert_eq!(task.environment.len(), 0);
497 assert_eq!(task.env_file.len(), 0);
498 } else {
499 panic!("Expected Task::Task");
500 }
501
502 if let Task::String(command) = &task_root.tasks["task3"] {
503 assert_eq!(command, "echo \"Hello, World 3!\"");
504 } else {
505 panic!("Expected Task::String");
506 }
507
508 Ok(())
509 }
510
511 #[test]
512 fn test_task_root_secrets_config() -> anyhow::Result<()> {
513 let yaml = "
514 vault_location: ./.mk/vault
515 keys_location: ./.mk/keys
516 key_name: team
517 secrets_path:
518 - app/common
519 tasks:
520 demo:
521 commands:
522 - command: echo ready
523 ";
524
525 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
526
527 assert_eq!(task_root.secrets_path, vec!["app/common"]);
528 assert_eq!(task_root.vault_location.as_deref(), Some("./.mk/vault"));
529 assert_eq!(task_root.keys_location.as_deref(), Some("./.mk/keys"));
530 assert_eq!(task_root.key_name.as_deref(), Some("team"));
531
532 Ok(())
533 }
534
535 #[test]
536 fn test_task_root_3() -> anyhow::Result<()> {
537 let yaml = "
538 tasks:
539 task1: echo \"Hello, World 1!\"
540 task2: echo \"Hello, World 2!\"
541 task3: echo \"Hello, World 3!\"
542 ";
543
544 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
545
546 assert_eq!(task_root.tasks.len(), 3);
547
548 if let Task::String(command) = &task_root.tasks["task1"] {
549 assert_eq!(command, "echo \"Hello, World 1!\"");
550 } else {
551 panic!("Expected Task::String");
552 }
553
554 if let Task::String(command) = &task_root.tasks["task2"] {
555 assert_eq!(command, "echo \"Hello, World 2!\"");
556 } else {
557 panic!("Expected Task::String");
558 }
559
560 if let Task::String(command) = &task_root.tasks["task3"] {
561 assert_eq!(command, "echo \"Hello, World 3!\"");
562 } else {
563 panic!("Expected Task::String");
564 }
565
566 Ok(())
567 }
568
569 #[test]
570 fn test_task_root_4() -> anyhow::Result<()> {
571 let lua = "
572 {
573 tasks = {
574 task1 = 'echo \"Hello, World 1!\"',
575 task2 = 'echo \"Hello, World 2!\"',
576 task3 = 'echo \"Hello, World 3!\"',
577 }
578 }
579 ";
580
581 let task_root = get_lua_table(lua)?;
582
583 assert_eq!(task_root.tasks.len(), 3);
584
585 if let Task::String(command) = &task_root.tasks["task1"] {
586 assert_eq!(command, "echo \"Hello, World 1!\"");
587 } else {
588 panic!("Expected Task::String");
589 }
590
591 if let Task::String(command) = &task_root.tasks["task2"] {
592 assert_eq!(command, "echo \"Hello, World 2!\"");
593 } else {
594 panic!("Expected Task::String");
595 }
596
597 if let Task::String(command) = &task_root.tasks["task3"] {
598 assert_eq!(command, "echo \"Hello, World 3!\"");
599 } else {
600 panic!("Expected Task::String");
601 }
602
603 Ok(())
604 }
605
606 #[test]
607 fn test_task_root_5_from_file_loads_use_cargo() -> anyhow::Result<()> {
608 use assert_fs::TempDir;
609 use std::fs;
610
611 let temp_dir = TempDir::new()?;
612 let config_path = temp_dir.path().join("tasks.yaml");
613 fs::write(
614 &config_path,
615 "
616 tasks:
617 build:
618 commands:
619 - command: echo build
620 use_cargo:
621 work_dir: crates/app
622 ",
623 )?;
624
625 let task_root = TaskRoot::from_file(config_path.to_str().unwrap())?;
626
627 assert!(task_root.tasks.contains_key("test"));
628
629 if let Task::Task(task) = &task_root.tasks["test"] {
630 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
631 assert_eq!(local_run.command, "cargo test");
632 assert_eq!(
633 local_run.work_dir,
634 Some(temp_dir.path().join("crates/app").to_string_lossy().into_owned())
635 );
636 } else {
637 panic!("Expected CommandRunner::LocalRun");
638 }
639 } else {
640 panic!("Expected Task::Task");
641 }
642
643 Ok(())
644 }
645
646 #[test]
647 fn test_task_root_6_from_file_rejects_include() -> anyhow::Result<()> {
648 use assert_fs::TempDir;
649 use std::fs;
650
651 let temp_dir = TempDir::new()?;
652 let config_path = temp_dir.path().join("tasks.yaml");
653 fs::write(
654 &config_path,
655 "
656 include:
657 - shared.yaml
658 tasks:
659 hello:
660 commands:
661 - command: echo hello
662 ",
663 )?;
664
665 let error = TaskRoot::from_file(config_path.to_str().unwrap()).unwrap_err();
666 assert!(error
667 .to_string()
668 .contains("`include` is no longer supported. Use `extends` instead."));
669 Ok(())
670 }
671
672 #[test]
673 fn test_task_root_7_from_file_rejects_extends_cycle() -> anyhow::Result<()> {
674 use assert_fs::TempDir;
675 use std::fs;
676
677 let temp_dir = TempDir::new()?;
678 let a_path = temp_dir.path().join("a.yaml");
679 let b_path = temp_dir.path().join("b.yaml");
680
681 fs::write(
682 &a_path,
683 "
684 extends: b.yaml
685 tasks:
686 a:
687 commands:
688 - command: echo a
689 ",
690 )?;
691 fs::write(
692 &b_path,
693 "
694 extends: a.yaml
695 tasks:
696 b:
697 commands:
698 - command: echo b
699 ",
700 )?;
701
702 let error = TaskRoot::from_file(a_path.to_str().unwrap()).unwrap_err();
703 assert!(error.to_string().contains("Circular extends detected:"));
704 Ok(())
705 }
706}