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#[derive(Debug, Default, Deserialize, JsonSchema)]
50pub struct TaskRoot {
51 #[schemars(with = "std::collections::HashMap<String, Task>")]
53 pub tasks: HashMap<String, Task>,
54
55 #[schemars(with = "std::collections::HashMap<String, String>")]
57 #[serde(default, deserialize_with = "deserialize_environment")]
58 pub environment: HashMap<String, String>,
59
60 #[serde(default)]
62 pub env_file: Vec<String>,
63
64 #[serde(default)]
66 pub secrets_path: Vec<String>,
67
68 #[serde(default)]
70 pub vault_location: Option<String>,
71
72 #[serde(default)]
74 pub keys_location: Option<String>,
75
76 #[serde(default)]
78 pub key_name: Option<String>,
79
80 #[serde(default)]
84 pub gpg_key_id: Option<String>,
85
86 #[serde(default)]
88 pub use_npm: Option<UseNpm>,
89
90 #[serde(default)]
92 pub use_cargo: Option<UseCargo>,
93
94 #[serde(default)]
96 pub container_runtime: Option<ContainerRuntime>,
97
98 #[serde(default)]
100 pub include: Option<Vec<Include>>,
101
102 #[serde(default)]
104 pub extends: Option<String>,
105
106 #[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 let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
221 value.apply_merge()?;
222
223 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 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 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 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 let lua = Lua::new();
324
325 let value = lua.load(contents).eval()?;
327
328 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 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 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}