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::secrets::{
29 merge_optional_secret_settings,
30 SecretSettings,
31};
32use crate::utils::{
33 deserialize_environment,
34 expand_home_path,
35 resolve_path,
36};
37
38const MK_COMMANDS: [&str; 11] = [
39 "run",
40 "list",
41 "completion",
42 "secrets",
43 "help",
44 "init",
45 "update",
46 "validate",
47 "plan",
48 "clean-cache",
49 "schema",
50];
51
52#[derive(Debug, Default, Deserialize, JsonSchema)]
55pub struct TaskRoot {
56 #[schemars(with = "std::collections::HashMap<String, Task>")]
58 pub tasks: HashMap<String, Task>,
59
60 #[schemars(with = "std::collections::HashMap<String, String>")]
62 #[serde(default, deserialize_with = "deserialize_environment")]
63 pub environment: HashMap<String, String>,
64
65 #[serde(default)]
67 pub env_file: Vec<String>,
68
69 #[serde(default)]
71 pub secrets_path: Vec<String>,
72
73 #[serde(default)]
75 pub secrets: Option<SecretSettings>,
76
77 #[serde(default)]
79 pub vault_location: Option<String>,
80
81 #[serde(default)]
83 pub keys_location: Option<String>,
84
85 #[serde(default)]
87 pub key_name: Option<String>,
88
89 #[serde(default)]
93 pub gpg_key_id: Option<String>,
94
95 #[serde(default)]
97 pub use_npm: Option<UseNpm>,
98
99 #[serde(default)]
101 pub use_cargo: Option<UseCargo>,
102
103 #[serde(default)]
105 pub container_runtime: Option<ContainerRuntime>,
106
107 #[serde(default)]
109 pub include: Option<Vec<Include>>,
110
111 #[serde(default)]
113 pub extends: Option<String>,
114
115 #[schemars(skip)]
117 #[serde(skip)]
118 pub source_path: Option<PathBuf>,
119
120 #[schemars(skip)]
122 #[serde(skip)]
123 pub(crate) raw_legacy_secret_settings: Option<SecretSettings>,
124
125 #[schemars(skip)]
127 #[serde(skip)]
128 pub(crate) raw_secrets: Option<SecretSettings>,
129}
130
131impl TaskRoot {
132 pub fn from_file(file: impl AsRef<Path>) -> anyhow::Result<Self> {
133 Self::from_file_with_stack(file.as_ref(), &mut Vec::new())
134 }
135
136 fn from_file_with_stack(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<Self> {
137 let file_path = normalize_task_file_path(file)?;
138
139 if let Some(index) = stack.iter().position(|path| path == &file_path) {
140 let mut cycle = stack[index..]
141 .iter()
142 .map(|path| path.to_string_lossy().into_owned())
143 .collect::<Vec<_>>();
144 cycle.push(file_path.to_string_lossy().into_owned());
145 anyhow::bail!("Circular extends detected: {}", cycle.join(" -> "));
146 }
147
148 stack.push(file_path.clone());
149 let result = load_task_root(&file_path, stack);
150 stack.pop();
151 result
152 }
153
154 pub fn from_hashmap(tasks: HashMap<String, Task>) -> Self {
155 Self {
156 tasks,
157 environment: HashMap::new(),
158 env_file: Vec::new(),
159 secrets_path: Vec::new(),
160 secrets: None,
161 vault_location: None,
162 keys_location: None,
163 key_name: None,
164 gpg_key_id: None,
165 use_npm: None,
166 use_cargo: None,
167 container_runtime: None,
168 include: None,
169 extends: None,
170 source_path: None,
171 raw_legacy_secret_settings: None,
172 raw_secrets: None,
173 }
174 }
175
176 pub fn config_base_dir(&self) -> PathBuf {
177 self
178 .source_path
179 .as_ref()
180 .and_then(|path| path.parent().map(Path::to_path_buf))
181 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
182 }
183
184 pub fn cache_base_dir(&self) -> PathBuf {
185 self.config_base_dir()
186 }
187
188 pub fn resolve_from_config(&self, value: &str) -> PathBuf {
189 resolve_path(&self.config_base_dir(), value)
190 }
191
192 pub fn normalized_secret_settings(&self) -> Option<SecretSettings> {
193 merge_optional_secret_settings(
194 Some(SecretSettings::from_legacy(
195 self.vault_location.clone(),
196 self.keys_location.clone(),
197 self.key_name.clone(),
198 self.gpg_key_id.clone(),
199 self.secrets_path.clone(),
200 )),
201 self.secrets.clone(),
202 )
203 .filter(|settings| !settings.is_empty())
204 }
205
206 pub(crate) fn validation_legacy_secret_settings(&self) -> SecretSettings {
207 self.raw_legacy_secret_settings.clone().unwrap_or_else(|| {
208 SecretSettings::from_legacy(
209 self.vault_location.clone(),
210 self.keys_location.clone(),
211 self.key_name.clone(),
212 self.gpg_key_id.clone(),
213 self.secrets_path.clone(),
214 )
215 })
216 }
217
218 fn normalize_secret_settings(&mut self) {
219 if self.raw_legacy_secret_settings.is_none() {
220 self.raw_legacy_secret_settings = Some(SecretSettings::from_legacy(
221 self.vault_location.clone(),
222 self.keys_location.clone(),
223 self.key_name.clone(),
224 self.gpg_key_id.clone(),
225 self.secrets_path.clone(),
226 ));
227 }
228 if self.raw_secrets.is_none() {
229 self.raw_secrets = self.secrets.clone();
230 }
231
232 self.secrets = self.normalized_secret_settings();
233 if let Some(secrets) = &self.secrets {
234 self.vault_location = secrets.vault_location.clone();
235 self.keys_location = secrets.keys_location.clone();
236 self.key_name = secrets.key_name.clone();
237 self.gpg_key_id = secrets.gpg_key_id.clone();
238 self.secrets_path = secrets.secrets_path.clone().unwrap_or_default();
239 }
240
241 for task in self.tasks.values_mut() {
242 if let Task::Task(task) = task {
243 task.normalize_secret_settings();
244 }
245 }
246 }
247}
248
249fn normalize_task_file_path(file: &Path) -> anyhow::Result<PathBuf> {
250 let file_path = file
251 .to_str()
252 .and_then(expand_home_path)
253 .unwrap_or_else(|| file.to_path_buf());
254 if file_path.is_absolute() {
255 Ok(file_path)
256 } else {
257 Ok(std::env::current_dir()?.join(file_path))
258 }
259}
260
261fn load_task_root(file_path: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
262 let file_extension = file_path
263 .extension()
264 .and_then(|ext| ext.to_str())
265 .context("Failed to get file extension")?;
266
267 let mut root = match file_extension {
268 "yaml" | "yml" => load_yaml_file(file_path, stack),
269 "lua" => load_lua_file(file_path, stack),
270 "json" => load_json_file(file_path, stack),
271 "toml" => load_toml_file(file_path, stack),
272 "json5" => anyhow::bail!("JSON5 files are not supported yet. Use YAML, TOML, JSON, or Lua instead."),
273 "makefile" | "mk" => anyhow::bail!("Makefiles are not supported. Use a tasks.yaml file instead."),
274 _ => anyhow::bail!(
275 "Unsupported config file extension '{}'. Supported formats: yaml, yml, toml, json, lua.",
276 file_extension
277 ),
278 }?;
279
280 if root.include.is_some() {
281 anyhow::bail!("`include` is no longer supported. Use `extends` instead.");
282 }
283
284 root.normalize_secret_settings();
285 root.source_path = Some(file_path.to_path_buf());
286 process_task_sources(&mut root)?;
287
288 Ok(root)
289}
290
291fn load_yaml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
292 let file_handle = File::open(file).with_context(|| {
293 format!(
294 "Failed to open file - {}",
295 file.to_utf8().unwrap_or("<non-utf8-path>")
296 )
297 })?;
298 let reader = BufReader::new(file_handle);
299
300 let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
303 value.apply_merge()?;
304
305 let root: TaskRoot = serde_yaml::from_value(value)?;
307 apply_extends(file, stack, root)
308}
309
310fn load_toml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
311 let mut file_handle = File::open(file).with_context(|| {
312 format!(
313 "Failed to open file - {}",
314 file.to_utf8().unwrap_or("<non-utf8-path>")
315 )
316 })?;
317 let mut contents = String::new();
318 file_handle.read_to_string(&mut contents)?;
319
320 let root: TaskRoot = toml::from_str(&contents)?;
322 apply_extends(file, stack, root)
323}
324
325fn load_json_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
326 let file_handle = File::open(file).with_context(|| {
327 format!(
328 "Failed to open file - {}",
329 file.to_utf8().unwrap_or("<non-utf8-path>")
330 )
331 })?;
332 let reader = BufReader::new(file_handle);
333
334 let root: TaskRoot = serde_json::from_reader(reader)?;
336 apply_extends(file, stack, root)
337}
338
339fn load_lua_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
340 let mut file_handle = File::open(file).with_context(|| {
341 format!(
342 "Failed to open file - {}",
343 file.to_utf8().unwrap_or("<non-utf8-path>")
344 )
345 })?;
346 let mut contents = String::new();
347 file_handle.read_to_string(&mut contents)?;
348
349 let root: TaskRoot = get_lua_table(&contents)?;
351 apply_extends(file, stack, root)
352}
353
354fn process_task_sources(root: &mut TaskRoot) -> anyhow::Result<()> {
355 root.tasks = rename_tasks(
356 std::mem::take(&mut root.tasks),
357 "task",
358 &MK_COMMANDS,
359 &HashMap::new(),
360 );
361
362 if let Some(npm) = &root.use_npm {
363 let npm_tasks = npm.capture_in_dir(&root.config_base_dir())?;
364 let renamed_npm_tasks = rename_tasks(npm_tasks, "npm", &MK_COMMANDS, &root.tasks);
365 root.tasks.extend(renamed_npm_tasks);
366 }
367
368 if let Some(cargo) = &root.use_cargo {
369 let cargo_tasks = cargo.capture_in_dir(&root.config_base_dir())?;
370 let renamed_cargo_tasks = rename_tasks(cargo_tasks, "cargo", &MK_COMMANDS, &root.tasks);
371 root.tasks.extend(renamed_cargo_tasks);
372 }
373
374 Ok(())
375}
376
377fn apply_extends(file: &Path, stack: &mut Vec<PathBuf>, mut root: TaskRoot) -> anyhow::Result<TaskRoot> {
378 let Some(parent) = root.extends.clone() else {
379 return Ok(root);
380 };
381
382 let parent_path = file.parent().unwrap_or_else(|| Path::new(".")).join(parent);
383 let mut base = TaskRoot::from_file_with_stack(&parent_path, stack)?;
384 let base_secrets = base.normalized_secret_settings();
385 let root_secrets = root.normalized_secret_settings();
386 let merged_secrets =
387 merge_optional_secret_settings(base_secrets.clone(), root_secrets.clone()).map(|mut secrets| {
388 let mut merged_paths = base_secrets
389 .as_ref()
390 .and_then(|settings| settings.secrets_path.clone())
391 .unwrap_or_default();
392 if let Some(child_paths) = root_secrets.and_then(|settings| settings.secrets_path) {
393 merged_paths.extend(child_paths);
394 }
395 if !merged_paths.is_empty() {
396 secrets.secrets_path = Some(merged_paths);
397 }
398 secrets
399 });
400
401 base.tasks.extend(root.tasks.drain());
402 base.environment.extend(root.environment.drain());
403 base.env_file.extend(root.env_file);
404 base.secrets = merged_secrets;
405 if let Some(secrets) = &base.secrets {
406 base.vault_location = secrets.vault_location.clone();
407 base.keys_location = secrets.keys_location.clone();
408 base.key_name = secrets.key_name.clone();
409 base.gpg_key_id = secrets.gpg_key_id.clone();
410 base.secrets_path = secrets.secrets_path.clone().unwrap_or_default();
411 }
412 base.use_npm = root.use_npm.or(base.use_npm);
413 base.use_cargo = root.use_cargo.or(base.use_cargo);
414 base.container_runtime = root.container_runtime.or(base.container_runtime);
415 base.include = root.include.or(base.include);
416 base.extends = None;
417 base.source_path = root.source_path.or(base.source_path);
418 base.raw_legacy_secret_settings = None;
419 base.raw_secrets = None;
420
421 Ok(base)
422}
423
424fn get_lua_table(contents: &str) -> anyhow::Result<TaskRoot> {
425 let lua = Lua::new();
427
428 let value = lua.load(contents).eval()?;
430
431 let root = lua.from_value(value)?;
433
434 Ok(root)
435}
436
437fn rename_tasks(
438 tasks: HashMap<String, Task>,
439 prefix: &str,
440 mk_commands: &[&str],
441 existing_tasks: &HashMap<String, Task>,
442) -> HashMap<String, Task> {
443 let mut new_tasks = HashMap::new();
444 for (task_name, task) in tasks.into_iter() {
445 let new_task_name =
446 if mk_commands.contains(&task_name.as_str()) || existing_tasks.contains_key(&task_name) {
447 format!("{}_{}", prefix, task_name)
448 } else {
449 task_name
450 };
451
452 new_tasks.insert(new_task_name, task);
453 }
454 new_tasks
455}
456
457#[cfg(test)]
458mod test {
459 use super::*;
460 use crate::schema::{
461 CommandRunner,
462 TaskDependency,
463 };
464 use assert_fs::TempDir;
465
466 #[test]
467 fn test_task_root_1() -> anyhow::Result<()> {
468 let yaml = "
469 tasks:
470 task1:
471 commands:
472 - command: echo \"Hello, World 1!\"
473 ignore_errors: false
474 verbose: false
475 depends_on:
476 - name: task2
477 description: 'This is a task'
478 labels: {}
479 environment:
480 FOO: bar
481 env_file:
482 - test.env
483 task2:
484 commands:
485 - command: echo \"Hello, World 2!\"
486 ignore_errors: false
487 verbose: false
488 depends_on:
489 - name: task1
490 description: 'This is a task'
491 labels: {}
492 environment: {}
493 task3:
494 commands:
495 - command: echo \"Hello, World 3!\"
496 ignore_errors: false
497 verbose: false
498 ";
499
500 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
501
502 assert_eq!(task_root.tasks.len(), 3);
503
504 if let Task::Task(task) = &task_root.tasks["task1"] {
505 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
506 assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
507 assert_eq!(local_run.work_dir, None);
508 assert_eq!(local_run.ignore_errors, Some(false));
509 assert_eq!(local_run.verbose, Some(false));
510 } else {
511 panic!("Expected CommandRunner::LocalRun");
512 }
513
514 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
515 assert_eq!(args.name, "task2");
516 } else {
517 panic!("Expected TaskDependency::TaskDependency");
518 }
519 assert_eq!(task.labels.len(), 0);
520 assert_eq!(task.description, "This is a task");
521 assert_eq!(task.environment.len(), 1);
522 assert_eq!(task.env_file.len(), 1);
523 } else {
524 panic!("Expected Task::Task");
525 }
526
527 if let Task::Task(task) = &task_root.tasks["task2"] {
528 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
529 assert_eq!(local_run.command, "echo \"Hello, World 2!\"");
530 assert_eq!(local_run.work_dir, None);
531 assert_eq!(local_run.ignore_errors, Some(false));
532 assert_eq!(local_run.verbose, Some(false));
533 } else {
534 panic!("Expected CommandRunner::LocalRun");
535 }
536
537 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
538 assert_eq!(args.name, "task1");
539 } else {
540 panic!("Expected TaskDependency::TaskDependency");
541 }
542 assert_eq!(task.labels.len(), 0);
543 assert_eq!(task.description, "This is a task");
544 assert_eq!(task.environment.len(), 0);
545 assert_eq!(task.env_file.len(), 0);
546 } else {
547 panic!("Expected Task::Task");
548 }
549
550 if let Task::Task(task) = &task_root.tasks["task3"] {
551 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
552 assert_eq!(local_run.command, "echo \"Hello, World 3!\"");
553 assert_eq!(local_run.work_dir, None);
554 assert_eq!(local_run.ignore_errors, Some(false));
555 assert_eq!(local_run.verbose, Some(false));
556 } else {
557 panic!("Expected CommandRunner::LocalRun");
558 }
559
560 assert_eq!(task.depends_on.len(), 0);
561 assert_eq!(task.labels.len(), 0);
562 assert_eq!(task.description.len(), 0);
563 assert_eq!(task.environment.len(), 0);
564 assert_eq!(task.env_file.len(), 0);
565 } else {
566 panic!("Expected Task::Task");
567 }
568
569 Ok(())
570 }
571
572 #[test]
573 fn test_task_root_2() -> anyhow::Result<()> {
574 let yaml = "
575 tasks:
576 task1:
577 commands:
578 - command: echo \"Hello, World 1!\"
579 task2:
580 commands:
581 - echo \"Hello, World 2!\"
582 task3: echo \"Hello, World 3!\"
583 ";
584
585 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
586
587 assert_eq!(task_root.tasks.len(), 3);
588
589 if let Task::Task(task) = &task_root.tasks["task1"] {
590 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
591 assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
592 assert_eq!(local_run.work_dir, None);
593 assert_eq!(local_run.ignore_errors, None);
594 assert_eq!(local_run.verbose, None);
595 } else {
596 panic!("Expected CommandRunner::LocalRun");
597 }
598
599 assert_eq!(task.labels.len(), 0);
600 assert_eq!(task.description, "");
601 assert_eq!(task.environment.len(), 0);
602 assert_eq!(task.env_file.len(), 0);
603 } else {
604 panic!("Expected Task::Task");
605 }
606
607 if let Task::Task(task) = &task_root.tasks["task2"] {
608 if let CommandRunner::CommandRun(command) = &task.commands[0] {
609 assert_eq!(command, "echo \"Hello, World 2!\"");
610 } else {
611 panic!("Expected CommandRunner::CommandRun");
612 }
613
614 assert_eq!(task.labels.len(), 0);
615 assert_eq!(task.description, "");
616 assert_eq!(task.environment.len(), 0);
617 assert_eq!(task.env_file.len(), 0);
618 } else {
619 panic!("Expected Task::Task");
620 }
621
622 if let Task::String(command) = &task_root.tasks["task3"] {
623 assert_eq!(command, "echo \"Hello, World 3!\"");
624 } else {
625 panic!("Expected Task::String");
626 }
627
628 Ok(())
629 }
630
631 #[test]
632 fn test_task_root_secrets_config() -> anyhow::Result<()> {
633 let yaml = "
634 vault_location: ./.mk/vault
635 keys_location: ./.mk/keys
636 key_name: team
637 secrets_path:
638 - app/common
639 tasks:
640 demo:
641 commands:
642 - command: echo ready
643 ";
644
645 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
646
647 let secrets = task_root.normalized_secret_settings().unwrap();
648 assert_eq!(secrets.key_name.as_deref(), Some("team"));
649 assert_eq!(task_root.secrets_path, vec!["app/common"]);
650 assert_eq!(task_root.vault_location.as_deref(), Some("./.mk/vault"));
651 assert_eq!(task_root.keys_location.as_deref(), Some("./.mk/keys"));
652 assert_eq!(task_root.key_name.as_deref(), Some("team"));
653 assert_eq!(task_root.gpg_key_id, None);
654
655 Ok(())
656 }
657
658 #[test]
659 fn test_task_root_gpg_key_id_deserialized() -> anyhow::Result<()> {
660 let yaml = "
661 gpg_key_id: 0xABCD1234EFGH5678
662 tasks:
663 demo:
664 commands:
665 - command: echo ready
666 ";
667
668 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
669 assert_eq!(task_root.gpg_key_id.as_deref(), Some("0xABCD1234EFGH5678"));
670 assert_eq!(
671 task_root.normalized_secret_settings().unwrap().backend.as_ref(),
672 Some(&crate::secrets::SecretBackend::Gpg)
673 );
674
675 Ok(())
676 }
677
678 #[test]
679 fn test_task_root_secrets_block_deserialized() -> anyhow::Result<()> {
680 let yaml = "
681 secrets:
682 backend: gpg
683 vault_location: ./.mk/vault
684 keys_location: ./.mk/keys
685 key_name: team
686 gpg_key_id: TEAMKEY
687 secrets_path:
688 - app/common
689 tasks:
690 demo:
691 commands:
692 - command: echo ready
693 ";
694
695 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
696 let secrets = task_root.normalized_secret_settings().unwrap();
697 assert_eq!(secrets.backend, Some(crate::secrets::SecretBackend::Gpg));
698 assert_eq!(secrets.gpg_key_id.as_deref(), Some("TEAMKEY"));
699 assert_eq!(secrets.vault_location.as_deref(), Some("./.mk/vault"));
700 assert_eq!(secrets.secrets_path, Some(vec!["app/common".to_string()]));
701 Ok(())
702 }
703
704 #[test]
705 fn test_task_root_gpg_key_id_absent_defaults_to_none() -> anyhow::Result<()> {
706 let yaml = "
707 tasks:
708 demo:
709 commands:
710 - command: echo ready
711 ";
712
713 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
714 assert_eq!(task_root.gpg_key_id, None);
715
716 Ok(())
717 }
718
719 #[test]
720 fn test_task_root_apply_extends_gpg_key_id() -> anyhow::Result<()> {
721 let dir = TempDir::new().unwrap();
723 let parent_path = dir.path().join("parent.yaml");
724 let child_path = dir.path().join("child.yaml");
725
726 std::fs::write(&parent_path, "gpg_key_id: PARENT_KEY\ntasks:\n dummy: echo ok\n")?;
727 std::fs::write(
728 &child_path,
729 "extends: parent.yaml\ntasks:\n child_task: echo child\n",
730 )?;
731
732 let root = TaskRoot::from_file(&child_path)?;
733 assert_eq!(root.gpg_key_id.as_deref(), Some("PARENT_KEY"));
734
735 Ok(())
736 }
737
738 #[test]
739 fn test_task_root_apply_extends_child_gpg_key_id_overrides() -> anyhow::Result<()> {
740 let dir = TempDir::new().unwrap();
742 let parent_path = dir.path().join("parent.yaml");
743 let child_path = dir.path().join("child.yaml");
744
745 std::fs::write(&parent_path, "gpg_key_id: PARENT_KEY\ntasks:\n dummy: echo ok\n")?;
746 std::fs::write(
747 &child_path,
748 "extends: parent.yaml\ngpg_key_id: CHILD_KEY\ntasks:\n child_task: echo child\n",
749 )?;
750
751 let root = TaskRoot::from_file(&child_path)?;
752 assert_eq!(root.gpg_key_id.as_deref(), Some("CHILD_KEY"));
753
754 Ok(())
755 }
756
757 #[test]
758 fn test_task_root_3() -> anyhow::Result<()> {
759 let yaml = "
760 tasks:
761 task1: echo \"Hello, World 1!\"
762 task2: echo \"Hello, World 2!\"
763 task3: echo \"Hello, World 3!\"
764 ";
765
766 let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
767
768 assert_eq!(task_root.tasks.len(), 3);
769
770 if let Task::String(command) = &task_root.tasks["task1"] {
771 assert_eq!(command, "echo \"Hello, World 1!\"");
772 } else {
773 panic!("Expected Task::String");
774 }
775
776 if let Task::String(command) = &task_root.tasks["task2"] {
777 assert_eq!(command, "echo \"Hello, World 2!\"");
778 } else {
779 panic!("Expected Task::String");
780 }
781
782 if let Task::String(command) = &task_root.tasks["task3"] {
783 assert_eq!(command, "echo \"Hello, World 3!\"");
784 } else {
785 panic!("Expected Task::String");
786 }
787
788 Ok(())
789 }
790
791 #[test]
792 fn test_task_root_4() -> anyhow::Result<()> {
793 let lua = "
794 {
795 tasks = {
796 task1 = 'echo \"Hello, World 1!\"',
797 task2 = 'echo \"Hello, World 2!\"',
798 task3 = 'echo \"Hello, World 3!\"',
799 }
800 }
801 ";
802
803 let task_root = get_lua_table(lua)?;
804
805 assert_eq!(task_root.tasks.len(), 3);
806
807 if let Task::String(command) = &task_root.tasks["task1"] {
808 assert_eq!(command, "echo \"Hello, World 1!\"");
809 } else {
810 panic!("Expected Task::String");
811 }
812
813 if let Task::String(command) = &task_root.tasks["task2"] {
814 assert_eq!(command, "echo \"Hello, World 2!\"");
815 } else {
816 panic!("Expected Task::String");
817 }
818
819 if let Task::String(command) = &task_root.tasks["task3"] {
820 assert_eq!(command, "echo \"Hello, World 3!\"");
821 } else {
822 panic!("Expected Task::String");
823 }
824
825 Ok(())
826 }
827
828 #[test]
829 fn test_task_root_5_from_file_loads_use_cargo() -> anyhow::Result<()> {
830 use assert_fs::TempDir;
831 use std::fs;
832
833 let temp_dir = TempDir::new()?;
834 let config_path = temp_dir.path().join("tasks.yaml");
835 fs::write(
836 &config_path,
837 "
838 tasks:
839 build:
840 commands:
841 - command: echo build
842 use_cargo:
843 work_dir: crates/app
844 ",
845 )?;
846
847 let task_root = TaskRoot::from_file(&config_path)?;
848
849 assert!(task_root.tasks.contains_key("test"));
850
851 if let Task::Task(task) = &task_root.tasks["test"] {
852 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
853 assert_eq!(local_run.command, "cargo test");
854 assert_eq!(
855 local_run.work_dir,
856 Some(
857 temp_dir
858 .path()
859 .join("crates")
860 .join("app")
861 .to_string_lossy()
862 .into_owned()
863 )
864 );
865 } else {
866 panic!("Expected CommandRunner::LocalRun");
867 }
868 } else {
869 panic!("Expected Task::Task");
870 }
871
872 Ok(())
873 }
874
875 #[test]
876 fn test_task_root_6_from_file_rejects_include() -> anyhow::Result<()> {
877 use assert_fs::TempDir;
878 use std::fs;
879
880 let temp_dir = TempDir::new()?;
881 let config_path = temp_dir.path().join("tasks.yaml");
882 fs::write(
883 &config_path,
884 "
885 include:
886 - shared.yaml
887 tasks:
888 hello:
889 commands:
890 - command: echo hello
891 ",
892 )?;
893
894 let error = TaskRoot::from_file(&config_path).unwrap_err();
895 assert!(error
896 .to_string()
897 .contains("`include` is no longer supported. Use `extends` instead."));
898 Ok(())
899 }
900
901 #[test]
902 fn test_task_root_7_from_file_rejects_extends_cycle() -> anyhow::Result<()> {
903 use assert_fs::TempDir;
904 use std::fs;
905
906 let temp_dir = TempDir::new()?;
907 let a_path = temp_dir.path().join("a.yaml");
908 let b_path = temp_dir.path().join("b.yaml");
909
910 fs::write(
911 &a_path,
912 "
913 extends: b.yaml
914 tasks:
915 a:
916 commands:
917 - command: echo a
918 ",
919 )?;
920 fs::write(
921 &b_path,
922 "
923 extends: a.yaml
924 tasks:
925 b:
926 commands:
927 - command: echo b
928 ",
929 )?;
930
931 let error = TaskRoot::from_file(&a_path).unwrap_err();
932 assert!(error.to_string().contains("Circular extends detected:"));
933 Ok(())
934 }
935
936 #[cfg(all(unix, not(target_os = "macos")))]
937 #[test]
938 fn test_task_root_from_non_utf8_path() -> anyhow::Result<()> {
939 use std::ffi::OsString;
940 use std::os::unix::ffi::OsStringExt as _;
941
942 let temp_dir = TempDir::new()?;
943 let file_name = OsString::from_vec(vec![0x66, 0x6f, 0x80, 0x6f, 0x2e, 0x79, 0x61, 0x6d, 0x6c]);
944 let config_path = temp_dir.path().join(file_name);
945 std::fs::write(&config_path, "tasks:\n hello: echo ok\n")?;
946
947 let root = TaskRoot::from_file(&config_path)?;
948 assert!(root.tasks.contains_key("hello"));
949
950 Ok(())
951 }
952
953 #[cfg(target_os = "macos")]
954 #[test]
955 fn test_task_root_from_non_utf8_path() {
956 }
959}