Skip to main content

mk_lib/schema/
task_root.rs

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/// This struct represents the root of the task schema. It contains all the tasks
46/// that can be executed.
47#[derive(Debug, Default, Deserialize)]
48pub struct TaskRoot {
49  /// The tasks that can be executed
50  pub tasks: HashMap<String, Task>,
51
52  /// The environment variables to set before running any task
53  #[serde(default, deserialize_with = "deserialize_environment")]
54  pub environment: HashMap<String, String>,
55
56  /// The environment files to load before running any task
57  #[serde(default)]
58  pub env_file: Vec<String>,
59
60  /// Secret paths to load as dotenv-style environment entries before running any task
61  #[serde(default)]
62  pub secrets_path: Vec<String>,
63
64  /// The path to the secret vault
65  #[serde(default)]
66  pub vault_location: Option<String>,
67
68  /// The path to the private keys used for secret decryption
69  #[serde(default)]
70  pub keys_location: Option<String>,
71
72  /// The key name to use for secret decryption
73  #[serde(default)]
74  pub key_name: Option<String>,
75
76  /// This allows mk to use npm scripts as tasks
77  #[serde(default)]
78  pub use_npm: Option<UseNpm>,
79
80  /// This allows mk to use cargo commands as tasks
81  #[serde(default)]
82  pub use_cargo: Option<UseCargo>,
83
84  /// Default container runtime to use for container commands
85  #[serde(default)]
86  pub container_runtime: Option<ContainerRuntime>,
87
88  /// Includes additional files to be merged into the current file
89  #[serde(default)]
90  pub include: Option<Vec<Include>>,
91
92  /// Extend another root task file
93  #[serde(default)]
94  pub extends: Option<String>,
95
96  /// Absolute path to the config file used to load this root
97  #[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  // Deserialize the YAML file into a serde_yaml::Value to be able to merge
204  // anchors and aliases
205  let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
206  value.apply_merge()?;
207
208  // Deserialize the serde_yaml::Value into a TaskRoot
209  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  // Deserialize the TOML file into a TaskRoot
224  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  // Deserialize the JSON file into a TaskRoot
238  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  // Deserialize the Lua value into a TaskRoot
253  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  // Create a new Lua instance
307  let lua = Lua::new();
308
309  // Load the Lua file and evaluate it
310  let value = lua.load(contents).eval()?;
311
312  // Deserialize the Lua value into a TaskRoot
313  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}