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  /// The GPG key ID or fingerprint to use for secret encryption/decryption via the system gpg binary.
77  /// When set, mk delegates all vault crypto operations to `gpg` instead of the built-in PGP engine,
78  /// enabling hardware keys (e.g. YubiKey) and passphrase-protected keys.
79  #[serde(default)]
80  pub gpg_key_id: Option<String>,
81
82  /// This allows mk to use npm scripts as tasks
83  #[serde(default)]
84  pub use_npm: Option<UseNpm>,
85
86  /// This allows mk to use cargo commands as tasks
87  #[serde(default)]
88  pub use_cargo: Option<UseCargo>,
89
90  /// Default container runtime to use for container commands
91  #[serde(default)]
92  pub container_runtime: Option<ContainerRuntime>,
93
94  /// Includes additional files to be merged into the current file
95  #[serde(default)]
96  pub include: Option<Vec<Include>>,
97
98  /// Extend another root task file
99  #[serde(default)]
100  pub extends: Option<String>,
101
102  /// Absolute path to the config file used to load this root
103  #[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  // Deserialize the YAML file into a serde_yaml::Value to be able to merge
211  // anchors and aliases
212  let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
213  value.apply_merge()?;
214
215  // Deserialize the serde_yaml::Value into a TaskRoot
216  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  // Deserialize the TOML file into a TaskRoot
231  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  // Deserialize the JSON file into a TaskRoot
245  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  // Deserialize the Lua value into a TaskRoot
260  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  // Create a new Lua instance
315  let lua = Lua::new();
316
317  // Load the Lua file and evaluate it
318  let value = lua.load(contents).eval()?;
319
320  // Deserialize the Lua value into a TaskRoot
321  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    // Parent has a gpg_key_id; child does not → parent value propagates.
579    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    // Child has its own gpg_key_id → it overrides the parent.
598    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}