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::Path;
15
16use super::{
17  Include,
18  Task,
19  UseCargo,
20  UseNpm,
21};
22use crate::utils::deserialize_environment;
23
24const MK_COMMANDS: [&str; 5] = ["run", "list", "completion", "secrets", "help"];
25
26macro_rules! process_tasks {
27  ($root:expr, $mk_commands:expr) => {
28    // Rename tasks that have the same name as mk commands
29    $root.tasks = rename_tasks($root.tasks, "task", &$mk_commands, &HashMap::new());
30
31    if let Some(npm) = &$root.use_npm {
32      let npm_tasks = npm.capture()?;
33
34      // Rename tasks that have the same name as mk commands and existing tasks
35      let renamed_npm_tasks = rename_tasks(npm_tasks, "npm", &$mk_commands, &$root.tasks);
36
37      $root.tasks.extend(renamed_npm_tasks);
38    }
39  };
40}
41
42/// This struct represents the root of the task schema. It contains all the tasks
43/// that can be executed.
44#[derive(Debug, Default, Deserialize)]
45pub struct TaskRoot {
46  /// The tasks that can be executed
47  pub tasks: HashMap<String, Task>,
48
49  /// The environment variables to set before running any task
50  #[serde(default, deserialize_with = "deserialize_environment")]
51  pub environment: HashMap<String, String>,
52
53  /// The environment files to load before running any task
54  #[serde(default)]
55  pub env_file: Vec<String>,
56
57  /// This allows mk to use npm scripts as tasks
58  #[serde(default)]
59  pub use_npm: Option<UseNpm>,
60
61  /// This allows mk to use cargo commands as tasks
62  #[serde(default)]
63  pub use_cargo: Option<UseCargo>,
64
65  /// Includes additional files to be merged into the current file
66  #[serde(default)]
67  pub include: Option<Vec<Include>>,
68}
69
70impl TaskRoot {
71  pub fn from_file(file: &str) -> anyhow::Result<Self> {
72    let file_path = Path::new(file);
73    let file_extension = file_path
74      .extension()
75      .and_then(|ext| ext.to_str())
76      .context("Failed to get file extension")?;
77
78    match file_extension {
79      "yaml" | "yml" => load_yaml_file(file),
80      "lua" => load_lua_file(file),
81      "json" => load_json_file(file),
82      "toml" => load_toml_file(file),
83      "json5" => anyhow::bail!("JSON5 files are not supported yet"),
84      "makefile" | "mk" => anyhow::bail!("Makefiles are not supported yet"),
85      _ => anyhow::bail!("Unsupported file extension - {}", file_extension),
86    }
87  }
88
89  pub fn from_hashmap(tasks: HashMap<String, Task>) -> Self {
90    Self {
91      tasks,
92      environment: HashMap::new(),
93      env_file: Vec::new(),
94      use_npm: None,
95      use_cargo: None,
96      include: None,
97    }
98  }
99}
100
101fn load_yaml_file(file: &str) -> anyhow::Result<TaskRoot> {
102  let file = File::open(file).with_context(|| format!("Failed to open file - {}", file))?;
103  let reader = BufReader::new(file);
104
105  // Deserialize the YAML file into a serde_yaml::Value to be able to merge
106  // anchors and aliases
107  let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
108  value.apply_merge()?;
109
110  // Deserialize the serde_yaml::Value into a TaskRoot
111  let mut root: TaskRoot = serde_yaml::from_value(value)?;
112
113  process_tasks!(root, MK_COMMANDS);
114
115  Ok(root)
116}
117
118fn load_toml_file(file: &str) -> anyhow::Result<TaskRoot> {
119  let mut file = File::open(file).with_context(|| format!("Failed to open file - {}", file))?;
120  let mut contents = String::new();
121  file.read_to_string(&mut contents)?;
122
123  // Deserialize the TOML file into a TaskRoot
124  let mut root: TaskRoot = toml::from_str(&contents)?;
125
126  process_tasks!(root, MK_COMMANDS);
127
128  Ok(root)
129}
130
131fn load_json_file(file: &str) -> anyhow::Result<TaskRoot> {
132  let file = File::open(file).with_context(|| format!("Failed to open file - {}", file))?;
133  let reader = BufReader::new(file);
134
135  // Deserialize the JSON file into a TaskRoot
136  let mut root: TaskRoot = serde_json::from_reader(reader)?;
137
138  process_tasks!(root, MK_COMMANDS);
139
140  Ok(root)
141}
142
143fn load_lua_file(file: &str) -> anyhow::Result<TaskRoot> {
144  let mut file = File::open(file).with_context(|| format!("Failed to open file - {}", file))?;
145  let mut contents = String::new();
146  file.read_to_string(&mut contents)?;
147
148  // Deserialize the Lua value into a TaskRoot
149  let mut root: TaskRoot = get_lua_table(&contents)?;
150
151  process_tasks!(root, MK_COMMANDS);
152
153  Ok(root)
154}
155
156fn get_lua_table(contents: &str) -> anyhow::Result<TaskRoot> {
157  // Create a new Lua instance
158  let lua = Lua::new();
159
160  // Load the Lua file and evaluate it
161  let value = lua.load(contents).eval()?;
162
163  // Deserialize the Lua value into a TaskRoot
164  let root = lua.from_value(value)?;
165
166  Ok(root)
167}
168
169fn rename_tasks(
170  tasks: HashMap<String, Task>,
171  prefix: &str,
172  mk_commands: &[&str],
173  existing_tasks: &HashMap<String, Task>,
174) -> HashMap<String, Task> {
175  let mut new_tasks = HashMap::new();
176  for (task_name, task) in tasks.into_iter() {
177    let new_task_name =
178      if mk_commands.contains(&task_name.as_str()) || existing_tasks.contains_key(&task_name) {
179        format!("{}_{}", prefix, task_name)
180      } else {
181        task_name
182      };
183
184    new_tasks.insert(new_task_name, task);
185  }
186  new_tasks
187}
188
189#[cfg(test)]
190mod test {
191  use super::*;
192  use crate::schema::{
193    CommandRunner,
194    TaskDependency,
195  };
196
197  #[test]
198  fn test_task_root_1() -> anyhow::Result<()> {
199    let yaml = "
200      tasks:
201        task1:
202          commands:
203            - command: echo \"Hello, World 1!\"
204              ignore_errors: false
205              verbose: false
206          depends_on:
207            - name: task2
208          description: 'This is a task'
209          labels: {}
210          environment:
211            FOO: bar
212          env_file:
213            - test.env
214        task2:
215          commands:
216            - command: echo \"Hello, World 2!\"
217              ignore_errors: false
218              verbose: false
219          depends_on:
220            - name: task1
221          description: 'This is a task'
222          labels: {}
223          environment: {}
224        task3:
225          commands:
226            - command: echo \"Hello, World 3!\"
227              ignore_errors: false
228              verbose: false
229    ";
230
231    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
232
233    assert_eq!(task_root.tasks.len(), 3);
234
235    if let Task::Task(task) = &task_root.tasks["task1"] {
236      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
237        assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
238        assert_eq!(local_run.work_dir, None);
239        assert_eq!(local_run.ignore_errors, Some(false));
240        assert_eq!(local_run.verbose, Some(false));
241      } else {
242        panic!("Expected CommandRunner::LocalRun");
243      }
244
245      if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
246        assert_eq!(args.name, "task2");
247      } else {
248        panic!("Expected TaskDependency::TaskDependency");
249      }
250      assert_eq!(task.labels.len(), 0);
251      assert_eq!(task.description, "This is a task");
252      assert_eq!(task.environment.len(), 1);
253      assert_eq!(task.env_file.len(), 1);
254    } else {
255      panic!("Expected Task::Task");
256    }
257
258    if let Task::Task(task) = &task_root.tasks["task2"] {
259      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
260        assert_eq!(local_run.command, "echo \"Hello, World 2!\"");
261        assert_eq!(local_run.work_dir, None);
262        assert_eq!(local_run.ignore_errors, Some(false));
263        assert_eq!(local_run.verbose, Some(false));
264      } else {
265        panic!("Expected CommandRunner::LocalRun");
266      }
267
268      if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
269        assert_eq!(args.name, "task1");
270      } else {
271        panic!("Expected TaskDependency::TaskDependency");
272      }
273      assert_eq!(task.labels.len(), 0);
274      assert_eq!(task.description, "This is a task");
275      assert_eq!(task.environment.len(), 0);
276      assert_eq!(task.env_file.len(), 0);
277    } else {
278      panic!("Expected Task::Task");
279    }
280
281    if let Task::Task(task) = &task_root.tasks["task3"] {
282      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
283        assert_eq!(local_run.command, "echo \"Hello, World 3!\"");
284        assert_eq!(local_run.work_dir, None);
285        assert_eq!(local_run.ignore_errors, Some(false));
286        assert_eq!(local_run.verbose, Some(false));
287      } else {
288        panic!("Expected CommandRunner::LocalRun");
289      }
290
291      assert_eq!(task.depends_on.len(), 0);
292      assert_eq!(task.labels.len(), 0);
293      assert_eq!(task.description.len(), 0);
294      assert_eq!(task.environment.len(), 0);
295      assert_eq!(task.env_file.len(), 0);
296    } else {
297      panic!("Expected Task::Task");
298    }
299
300    Ok(())
301  }
302
303  #[test]
304  fn test_task_root_2() -> anyhow::Result<()> {
305    let yaml = "
306      tasks:
307        task1:
308          commands:
309            - command: echo \"Hello, World 1!\"
310        task2:
311          commands:
312            - echo \"Hello, World 2!\"
313        task3: echo \"Hello, World 3!\"
314    ";
315
316    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
317
318    assert_eq!(task_root.tasks.len(), 3);
319
320    if let Task::Task(task) = &task_root.tasks["task1"] {
321      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
322        assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
323        assert_eq!(local_run.work_dir, None);
324        assert_eq!(local_run.ignore_errors, None);
325        assert_eq!(local_run.verbose, None);
326      } else {
327        panic!("Expected CommandRunner::LocalRun");
328      }
329
330      assert_eq!(task.labels.len(), 0);
331      assert_eq!(task.description, "");
332      assert_eq!(task.environment.len(), 0);
333      assert_eq!(task.env_file.len(), 0);
334    } else {
335      panic!("Expected Task::Task");
336    }
337
338    if let Task::Task(task) = &task_root.tasks["task2"] {
339      if let CommandRunner::CommandRun(command) = &task.commands[0] {
340        assert_eq!(command, "echo \"Hello, World 2!\"");
341      } else {
342        panic!("Expected CommandRunner::CommandRun");
343      }
344
345      assert_eq!(task.labels.len(), 0);
346      assert_eq!(task.description, "");
347      assert_eq!(task.environment.len(), 0);
348      assert_eq!(task.env_file.len(), 0);
349    } else {
350      panic!("Expected Task::Task");
351    }
352
353    if let Task::String(command) = &task_root.tasks["task3"] {
354      assert_eq!(command, "echo \"Hello, World 3!\"");
355    } else {
356      panic!("Expected Task::String");
357    }
358
359    Ok(())
360  }
361
362  #[test]
363  fn test_task_root_3() -> anyhow::Result<()> {
364    let yaml = "
365      tasks:
366        task1: echo \"Hello, World 1!\"
367        task2: echo \"Hello, World 2!\"
368        task3: echo \"Hello, World 3!\"
369    ";
370
371    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
372
373    assert_eq!(task_root.tasks.len(), 3);
374
375    if let Task::String(command) = &task_root.tasks["task1"] {
376      assert_eq!(command, "echo \"Hello, World 1!\"");
377    } else {
378      panic!("Expected Task::String");
379    }
380
381    if let Task::String(command) = &task_root.tasks["task2"] {
382      assert_eq!(command, "echo \"Hello, World 2!\"");
383    } else {
384      panic!("Expected Task::String");
385    }
386
387    if let Task::String(command) = &task_root.tasks["task3"] {
388      assert_eq!(command, "echo \"Hello, World 3!\"");
389    } else {
390      panic!("Expected Task::String");
391    }
392
393    Ok(())
394  }
395
396  #[test]
397  fn test_task_root_4() -> anyhow::Result<()> {
398    let lua = "
399      {
400        tasks = {
401          task1 = 'echo \"Hello, World 1!\"',
402          task2 = 'echo \"Hello, World 2!\"',
403          task3 = 'echo \"Hello, World 3!\"',
404        }
405      }
406    ";
407
408    let task_root = get_lua_table(lua)?;
409
410    assert_eq!(task_root.tasks.len(), 3);
411
412    if let Task::String(command) = &task_root.tasks["task1"] {
413      assert_eq!(command, "echo \"Hello, World 1!\"");
414    } else {
415      panic!("Expected Task::String");
416    }
417
418    if let Task::String(command) = &task_root.tasks["task2"] {
419      assert_eq!(command, "echo \"Hello, World 2!\"");
420    } else {
421      panic!("Expected Task::String");
422    }
423
424    if let Task::String(command) = &task_root.tasks["task3"] {
425      assert_eq!(command, "echo \"Hello, World 3!\"");
426    } else {
427      panic!("Expected Task::String");
428    }
429
430    Ok(())
431  }
432}