Skip to main content

mk_lib/schema/
task_root.rs

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