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)]
80 pub gpg_key_id: Option<String>,
81
82 #[serde(default)]
84 pub use_npm: Option<UseNpm>,
85
86 #[serde(default)]
88 pub use_cargo: Option<UseCargo>,
89
90 #[serde(default)]
92 pub container_runtime: Option<ContainerRuntime>,
93
94 #[serde(default)]
96 pub include: Option<Vec<Include>>,
97
98 #[serde(default)]
100 pub extends: Option<String>,
101
102 #[serde(skip)]
104 pub source_path: Option<PathBuf>,
105}
106
107impl TaskRoot {
108 pub fn from_file(file: &str) -> anyhow::Result<Self> {
109 Self::from_file_with_stack(file, &mut Vec::new())
110 }
111
112 fn from_file_with_stack(file: &str, stack: &mut Vec<PathBuf>) -> anyhow::Result<Self> {
113 let file_path = normalize_task_file_path(file)?;
114
115 if let Some(index) = stack.iter().position(|path| path == &file_path) {
116 let mut cycle = stack[index..]
117 .iter()
118 .map(|path| path.to_string_lossy().into_owned())
119 .collect::<Vec<_>>();
120 cycle.push(file_path.to_string_lossy().into_owned());
121 anyhow::bail!("Circular extends detected: {}", cycle.join(" -> "));
122 }
123
124 stack.push(file_path.clone());
125 let result = load_task_root(&file_path, stack);
126 stack.pop();
127 result
128 }
129
130 pub fn from_hashmap(tasks: HashMap<String, Task>) -> Self {
131 Self {
132 tasks,
133 environment: HashMap::new(),
134 env_file: Vec::new(),
135 secrets_path: Vec::new(),
136 vault_location: None,
137 keys_location: None,
138 key_name: None,
139 gpg_key_id: None,
140 use_npm: None,
141 use_cargo: None,
142 container_runtime: None,
143 include: None,
144 extends: None,
145 source_path: None,
146 }
147 }
148
149 pub fn config_base_dir(&self) -> PathBuf {
150 self
151 .source_path
152 .as_ref()
153 .and_then(|path| path.parent().map(Path::to_path_buf))
154 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
155 }
156
157 pub fn cache_base_dir(&self) -> PathBuf {
158 self.config_base_dir()
159 }
160
161 pub fn resolve_from_config(&self, value: &str) -> PathBuf {
162 resolve_path(&self.config_base_dir(), value)
163 }
164}
165
166fn normalize_task_file_path(file: &str) -> anyhow::Result<PathBuf> {
167 let file_path = Path::new(file);
168 if file_path.is_absolute() {
169 Ok(file_path.to_path_buf())
170 } else {
171 Ok(std::env::current_dir()?.join(file_path))
172 }
173}
174
175fn load_task_root(file_path: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
176 let file_extension = file_path
177 .extension()
178 .and_then(|ext| ext.to_str())
179 .context("Failed to get file extension")?;
180
181 let mut root = match file_extension {
182 "yaml" | "yml" => load_yaml_file(file_path, stack),
183 "lua" => load_lua_file(file_path, stack),
184 "json" => load_json_file(file_path, stack),
185 "toml" => load_toml_file(file_path, stack),
186 "json5" => anyhow::bail!("JSON5 files are not supported yet"),
187 "makefile" | "mk" => anyhow::bail!("Makefiles are not supported yet"),
188 _ => anyhow::bail!("Unsupported file extension - {}", file_extension),
189 }?;
190
191 if root.include.is_some() {
192 anyhow::bail!("`include` is no longer supported. Use `extends` instead.");
193 }
194
195 root.source_path = Some(file_path.to_path_buf());
196 process_task_sources(&mut root)?;
197
198 Ok(root)
199}
200
201fn load_yaml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
202 let file_handle = File::open(file).with_context(|| {
203 format!(
204 "Failed to open file - {}",
205 file.to_utf8().unwrap_or("<non-utf8-path>")
206 )
207 })?;
208 let reader = BufReader::new(file_handle);
209
210 let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
213 value.apply_merge()?;
214
215 let root: TaskRoot = serde_yaml::from_value(value)?;
217 apply_extends(file, stack, root)
218}
219
220fn load_toml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
221 let mut file_handle = File::open(file).with_context(|| {
222 format!(
223 "Failed to open file - {}",
224 file.to_utf8().unwrap_or("<non-utf8-path>")
225 )
226 })?;
227 let mut contents = String::new();
228 file_handle.read_to_string(&mut contents)?;
229
230 let root: TaskRoot = toml::from_str(&contents)?;
232 apply_extends(file, stack, root)
233}
234
235fn load_json_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
236 let file_handle = File::open(file).with_context(|| {
237 format!(
238 "Failed to open file - {}",
239 file.to_utf8().unwrap_or("<non-utf8-path>")
240 )
241 })?;
242 let reader = BufReader::new(file_handle);
243
244 let root: TaskRoot = serde_json::from_reader(reader)?;
246 apply_extends(file, stack, root)
247}
248
249fn load_lua_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
250 let mut file_handle = File::open(file).with_context(|| {
251 format!(
252 "Failed to open file - {}",
253 file.to_utf8().unwrap_or("<non-utf8-path>")
254 )
255 })?;
256 let mut contents = String::new();
257 file_handle.read_to_string(&mut contents)?;
258
259 let root: TaskRoot = get_lua_table(&contents)?;
261 apply_extends(file, stack, root)
262}
263
264fn process_task_sources(root: &mut TaskRoot) -> anyhow::Result<()> {
265 root.tasks = rename_tasks(
266 std::mem::take(&mut root.tasks),
267 "task",
268 &MK_COMMANDS,
269 &HashMap::new(),
270 );
271
272 if let Some(npm) = &root.use_npm {
273 let npm_tasks = npm.capture_in_dir(&root.config_base_dir())?;
274 let renamed_npm_tasks = rename_tasks(npm_tasks, "npm", &MK_COMMANDS, &root.tasks);
275 root.tasks.extend(renamed_npm_tasks);
276 }
277
278 if let Some(cargo) = &root.use_cargo {
279 let cargo_tasks = cargo.capture_in_dir(&root.config_base_dir())?;
280 let renamed_cargo_tasks = rename_tasks(cargo_tasks, "cargo", &MK_COMMANDS, &root.tasks);
281 root.tasks.extend(renamed_cargo_tasks);
282 }
283
284 Ok(())
285}
286
287fn apply_extends(file: &Path, stack: &mut Vec<PathBuf>, mut root: TaskRoot) -> anyhow::Result<TaskRoot> {
288 let Some(parent) = root.extends.clone() else {
289 return Ok(root);
290 };
291
292 let parent_path = file.parent().unwrap_or_else(|| Path::new(".")).join(parent);
293 let mut base = TaskRoot::from_file_with_stack(parent_path.to_string_lossy().as_ref(), stack)?;
294
295 base.tasks.extend(root.tasks.drain());
296 base.environment.extend(root.environment.drain());
297 base.env_file.extend(root.env_file);
298 base.secrets_path.extend(root.secrets_path);
299 base.vault_location = root.vault_location.or(base.vault_location);
300 base.keys_location = root.keys_location.or(base.keys_location);
301 base.key_name = root.key_name.or(base.key_name);
302 base.gpg_key_id = root.gpg_key_id.or(base.gpg_key_id);
303 base.use_npm = root.use_npm.or(base.use_npm);
304 base.use_cargo = root.use_cargo.or(base.use_cargo);
305 base.container_runtime = root.container_runtime.or(base.container_runtime);
306 base.include = root.include.or(base.include);
307 base.extends = None;
308 base.source_path = root.source_path.or(base.source_path);
309
310 Ok(base)
311}
312
313fn get_lua_table(contents: &str) -> anyhow::Result<TaskRoot> {
314 let lua = Lua::new();
316
317 let value = lua.load(contents).eval()?;
319
320 let root = lua.from_value(value)?;
322
323 Ok(root)
324}
325
326fn rename_tasks(
327 tasks: HashMap<String, Task>,
328 prefix: &str,
329 mk_commands: &[&str],
330 existing_tasks: &HashMap<String, Task>,
331) -> HashMap<String, Task> {
332 let mut new_tasks = HashMap::new();
333 for (task_name, task) in tasks.into_iter() {
334 let new_task_name =
335 if mk_commands.contains(&task_name.as_str()) || existing_tasks.contains_key(&task_name) {
336 format!("{}_{}", prefix, task_name)
337 } else {
338 task_name
339 };
340
341 new_tasks.insert(new_task_name, task);
342 }
343 new_tasks
344}
345
346#[cfg(test)]
347mod test {
348 use super::*;
349 use crate::schema::{
350 CommandRunner,
351 TaskDependency,
352 };
353 use assert_fs::TempDir;
354
355 #[test]
356 fn test_task_root_1() -> anyhow::Result<()> {
357 let yaml = "
358 tasks:
359 task1:
360 commands:
361 - command: echo \"Hello, World 1!\"
362 ignore_errors: false
363 verbose: false
364 depends_on:
365 - name: task2
366 description: 'This is a task'
367 labels: {}
368 environment:
369 FOO: bar
370 env_file:
371 - test.env
372 task2:
373 commands:
374 - command: echo \"Hello, World 2!\"
375 ignore_errors: false
376 verbose: false
377 depends_on:
378 - name: task1
379 description: 'This is a task'
380 labels: {}
381 environment: {}
382 task3:
383 commands:
384 - command: echo \"Hello, World 3!\"
385 ignore_errors: false
386 verbose: false
387 ";
388
389 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
390
391 assert_eq!(task_root.tasks.len(), 3);
392
393 if let Task::Task(task) = &task_root.tasks["task1"] {
394 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
395 assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
396 assert_eq!(local_run.work_dir, None);
397 assert_eq!(local_run.ignore_errors, Some(false));
398 assert_eq!(local_run.verbose, Some(false));
399 } else {
400 panic!("Expected CommandRunner::LocalRun");
401 }
402
403 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
404 assert_eq!(args.name, "task2");
405 } else {
406 panic!("Expected TaskDependency::TaskDependency");
407 }
408 assert_eq!(task.labels.len(), 0);
409 assert_eq!(task.description, "This is a task");
410 assert_eq!(task.environment.len(), 1);
411 assert_eq!(task.env_file.len(), 1);
412 } else {
413 panic!("Expected Task::Task");
414 }
415
416 if let Task::Task(task) = &task_root.tasks["task2"] {
417 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
418 assert_eq!(local_run.command, "echo \"Hello, World 2!\"");
419 assert_eq!(local_run.work_dir, None);
420 assert_eq!(local_run.ignore_errors, Some(false));
421 assert_eq!(local_run.verbose, Some(false));
422 } else {
423 panic!("Expected CommandRunner::LocalRun");
424 }
425
426 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
427 assert_eq!(args.name, "task1");
428 } else {
429 panic!("Expected TaskDependency::TaskDependency");
430 }
431 assert_eq!(task.labels.len(), 0);
432 assert_eq!(task.description, "This is a task");
433 assert_eq!(task.environment.len(), 0);
434 assert_eq!(task.env_file.len(), 0);
435 } else {
436 panic!("Expected Task::Task");
437 }
438
439 if let Task::Task(task) = &task_root.tasks["task3"] {
440 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
441 assert_eq!(local_run.command, "echo \"Hello, World 3!\"");
442 assert_eq!(local_run.work_dir, None);
443 assert_eq!(local_run.ignore_errors, Some(false));
444 assert_eq!(local_run.verbose, Some(false));
445 } else {
446 panic!("Expected CommandRunner::LocalRun");
447 }
448
449 assert_eq!(task.depends_on.len(), 0);
450 assert_eq!(task.labels.len(), 0);
451 assert_eq!(task.description.len(), 0);
452 assert_eq!(task.environment.len(), 0);
453 assert_eq!(task.env_file.len(), 0);
454 } else {
455 panic!("Expected Task::Task");
456 }
457
458 Ok(())
459 }
460
461 #[test]
462 fn test_task_root_2() -> anyhow::Result<()> {
463 let yaml = "
464 tasks:
465 task1:
466 commands:
467 - command: echo \"Hello, World 1!\"
468 task2:
469 commands:
470 - echo \"Hello, World 2!\"
471 task3: echo \"Hello, World 3!\"
472 ";
473
474 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
475
476 assert_eq!(task_root.tasks.len(), 3);
477
478 if let Task::Task(task) = &task_root.tasks["task1"] {
479 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
480 assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
481 assert_eq!(local_run.work_dir, None);
482 assert_eq!(local_run.ignore_errors, None);
483 assert_eq!(local_run.verbose, None);
484 } else {
485 panic!("Expected CommandRunner::LocalRun");
486 }
487
488 assert_eq!(task.labels.len(), 0);
489 assert_eq!(task.description, "");
490 assert_eq!(task.environment.len(), 0);
491 assert_eq!(task.env_file.len(), 0);
492 } else {
493 panic!("Expected Task::Task");
494 }
495
496 if let Task::Task(task) = &task_root.tasks["task2"] {
497 if let CommandRunner::CommandRun(command) = &task.commands[0] {
498 assert_eq!(command, "echo \"Hello, World 2!\"");
499 } else {
500 panic!("Expected CommandRunner::CommandRun");
501 }
502
503 assert_eq!(task.labels.len(), 0);
504 assert_eq!(task.description, "");
505 assert_eq!(task.environment.len(), 0);
506 assert_eq!(task.env_file.len(), 0);
507 } else {
508 panic!("Expected Task::Task");
509 }
510
511 if let Task::String(command) = &task_root.tasks["task3"] {
512 assert_eq!(command, "echo \"Hello, World 3!\"");
513 } else {
514 panic!("Expected Task::String");
515 }
516
517 Ok(())
518 }
519
520 #[test]
521 fn test_task_root_secrets_config() -> anyhow::Result<()> {
522 let yaml = "
523 vault_location: ./.mk/vault
524 keys_location: ./.mk/keys
525 key_name: team
526 secrets_path:
527 - app/common
528 tasks:
529 demo:
530 commands:
531 - command: echo ready
532 ";
533
534 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
535
536 assert_eq!(task_root.secrets_path, vec!["app/common"]);
537 assert_eq!(task_root.vault_location.as_deref(), Some("./.mk/vault"));
538 assert_eq!(task_root.keys_location.as_deref(), Some("./.mk/keys"));
539 assert_eq!(task_root.key_name.as_deref(), Some("team"));
540 assert_eq!(task_root.gpg_key_id, None);
541
542 Ok(())
543 }
544
545 #[test]
546 fn test_task_root_gpg_key_id_deserialized() -> anyhow::Result<()> {
547 let yaml = "
548 gpg_key_id: 0xABCD1234EFGH5678
549 tasks:
550 demo:
551 commands:
552 - command: echo ready
553 ";
554
555 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
556 assert_eq!(task_root.gpg_key_id.as_deref(), Some("0xABCD1234EFGH5678"));
557
558 Ok(())
559 }
560
561 #[test]
562 fn test_task_root_gpg_key_id_absent_defaults_to_none() -> anyhow::Result<()> {
563 let yaml = "
564 tasks:
565 demo:
566 commands:
567 - command: echo ready
568 ";
569
570 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
571 assert_eq!(task_root.gpg_key_id, None);
572
573 Ok(())
574 }
575
576 #[test]
577 fn test_task_root_apply_extends_gpg_key_id() -> anyhow::Result<()> {
578 let dir = TempDir::new().unwrap();
580 let parent_path = dir.path().join("parent.yaml");
581 let child_path = dir.path().join("child.yaml");
582
583 std::fs::write(&parent_path, "gpg_key_id: PARENT_KEY\ntasks:\n dummy: echo ok\n")?;
584 std::fs::write(
585 &child_path,
586 "extends: parent.yaml\ntasks:\n child_task: echo child\n",
587 )?;
588
589 let root = TaskRoot::from_file(child_path.to_str().unwrap())?;
590 assert_eq!(root.gpg_key_id.as_deref(), Some("PARENT_KEY"));
591
592 Ok(())
593 }
594
595 #[test]
596 fn test_task_root_apply_extends_child_gpg_key_id_overrides() -> anyhow::Result<()> {
597 let dir = TempDir::new().unwrap();
599 let parent_path = dir.path().join("parent.yaml");
600 let child_path = dir.path().join("child.yaml");
601
602 std::fs::write(&parent_path, "gpg_key_id: PARENT_KEY\ntasks:\n dummy: echo ok\n")?;
603 std::fs::write(
604 &child_path,
605 "extends: parent.yaml\ngpg_key_id: CHILD_KEY\ntasks:\n child_task: echo child\n",
606 )?;
607
608 let root = TaskRoot::from_file(child_path.to_str().unwrap())?;
609 assert_eq!(root.gpg_key_id.as_deref(), Some("CHILD_KEY"));
610
611 Ok(())
612 }
613
614 #[test]
615 fn test_task_root_3() -> anyhow::Result<()> {
616 let yaml = "
617 tasks:
618 task1: echo \"Hello, World 1!\"
619 task2: echo \"Hello, World 2!\"
620 task3: echo \"Hello, World 3!\"
621 ";
622
623 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
624
625 assert_eq!(task_root.tasks.len(), 3);
626
627 if let Task::String(command) = &task_root.tasks["task1"] {
628 assert_eq!(command, "echo \"Hello, World 1!\"");
629 } else {
630 panic!("Expected Task::String");
631 }
632
633 if let Task::String(command) = &task_root.tasks["task2"] {
634 assert_eq!(command, "echo \"Hello, World 2!\"");
635 } else {
636 panic!("Expected Task::String");
637 }
638
639 if let Task::String(command) = &task_root.tasks["task3"] {
640 assert_eq!(command, "echo \"Hello, World 3!\"");
641 } else {
642 panic!("Expected Task::String");
643 }
644
645 Ok(())
646 }
647
648 #[test]
649 fn test_task_root_4() -> anyhow::Result<()> {
650 let lua = "
651 {
652 tasks = {
653 task1 = 'echo \"Hello, World 1!\"',
654 task2 = 'echo \"Hello, World 2!\"',
655 task3 = 'echo \"Hello, World 3!\"',
656 }
657 }
658 ";
659
660 let task_root = get_lua_table(lua)?;
661
662 assert_eq!(task_root.tasks.len(), 3);
663
664 if let Task::String(command) = &task_root.tasks["task1"] {
665 assert_eq!(command, "echo \"Hello, World 1!\"");
666 } else {
667 panic!("Expected Task::String");
668 }
669
670 if let Task::String(command) = &task_root.tasks["task2"] {
671 assert_eq!(command, "echo \"Hello, World 2!\"");
672 } else {
673 panic!("Expected Task::String");
674 }
675
676 if let Task::String(command) = &task_root.tasks["task3"] {
677 assert_eq!(command, "echo \"Hello, World 3!\"");
678 } else {
679 panic!("Expected Task::String");
680 }
681
682 Ok(())
683 }
684
685 #[test]
686 fn test_task_root_5_from_file_loads_use_cargo() -> anyhow::Result<()> {
687 use assert_fs::TempDir;
688 use std::fs;
689
690 let temp_dir = TempDir::new()?;
691 let config_path = temp_dir.path().join("tasks.yaml");
692 fs::write(
693 &config_path,
694 "
695 tasks:
696 build:
697 commands:
698 - command: echo build
699 use_cargo:
700 work_dir: crates/app
701 ",
702 )?;
703
704 let task_root = TaskRoot::from_file(config_path.to_str().unwrap())?;
705
706 assert!(task_root.tasks.contains_key("test"));
707
708 if let Task::Task(task) = &task_root.tasks["test"] {
709 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
710 assert_eq!(local_run.command, "cargo test");
711 assert_eq!(
712 local_run.work_dir,
713 Some(temp_dir.path().join("crates/app").to_string_lossy().into_owned())
714 );
715 } else {
716 panic!("Expected CommandRunner::LocalRun");
717 }
718 } else {
719 panic!("Expected Task::Task");
720 }
721
722 Ok(())
723 }
724
725 #[test]
726 fn test_task_root_6_from_file_rejects_include() -> anyhow::Result<()> {
727 use assert_fs::TempDir;
728 use std::fs;
729
730 let temp_dir = TempDir::new()?;
731 let config_path = temp_dir.path().join("tasks.yaml");
732 fs::write(
733 &config_path,
734 "
735 include:
736 - shared.yaml
737 tasks:
738 hello:
739 commands:
740 - command: echo hello
741 ",
742 )?;
743
744 let error = TaskRoot::from_file(config_path.to_str().unwrap()).unwrap_err();
745 assert!(error
746 .to_string()
747 .contains("`include` is no longer supported. Use `extends` instead."));
748 Ok(())
749 }
750
751 #[test]
752 fn test_task_root_7_from_file_rejects_extends_cycle() -> anyhow::Result<()> {
753 use assert_fs::TempDir;
754 use std::fs;
755
756 let temp_dir = TempDir::new()?;
757 let a_path = temp_dir.path().join("a.yaml");
758 let b_path = temp_dir.path().join("b.yaml");
759
760 fs::write(
761 &a_path,
762 "
763 extends: b.yaml
764 tasks:
765 a:
766 commands:
767 - command: echo a
768 ",
769 )?;
770 fs::write(
771 &b_path,
772 "
773 extends: a.yaml
774 tasks:
775 b:
776 commands:
777 - command: echo b
778 ",
779 )?;
780
781 let error = TaskRoot::from_file(a_path.to_str().unwrap()).unwrap_err();
782 assert!(error.to_string().contains("Circular extends detected:"));
783 Ok(())
784 }
785}