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::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/// This struct represents the root of the task schema. It contains all the tasks
53/// that can be executed.
54#[derive(Debug, Default, Deserialize, JsonSchema)]
55pub struct TaskRoot {
56  /// The tasks that can be executed
57  #[schemars(with = "std::collections::HashMap<String, Task>")]
58  pub tasks: HashMap<String, Task>,
59
60  /// The environment variables to set before running any task
61  #[schemars(with = "std::collections::HashMap<String, String>")]
62  #[serde(default, deserialize_with = "deserialize_environment")]
63  pub environment: HashMap<String, String>,
64
65  /// The environment files to load before running any task
66  #[serde(default)]
67  pub env_file: Vec<String>,
68
69  /// Secret paths to load as dotenv-style environment entries before running any task
70  #[serde(default)]
71  pub secrets_path: Vec<String>,
72
73  /// Canonical secret settings block.
74  #[serde(default)]
75  pub secrets: Option<SecretSettings>,
76
77  /// The path to the secret vault
78  #[serde(default)]
79  pub vault_location: Option<String>,
80
81  /// The path to the private keys used for secret decryption
82  #[serde(default)]
83  pub keys_location: Option<String>,
84
85  /// The key name to use for secret decryption
86  #[serde(default)]
87  pub key_name: Option<String>,
88
89  /// The GPG key ID or fingerprint to use for secret encryption/decryption via the system gpg binary.
90  /// When set, mk delegates all vault crypto operations to `gpg` instead of the built-in PGP engine,
91  /// enabling hardware keys (e.g. YubiKey) and passphrase-protected keys.
92  #[serde(default)]
93  pub gpg_key_id: Option<String>,
94
95  /// This allows mk to use npm scripts as tasks
96  #[serde(default)]
97  pub use_npm: Option<UseNpm>,
98
99  /// This allows mk to use cargo commands as tasks
100  #[serde(default)]
101  pub use_cargo: Option<UseCargo>,
102
103  /// Default container runtime to use for container commands
104  #[serde(default)]
105  pub container_runtime: Option<ContainerRuntime>,
106
107  /// Includes additional files to be merged into the current file
108  #[serde(default)]
109  pub include: Option<Vec<Include>>,
110
111  /// Extend another root task file
112  #[serde(default)]
113  pub extends: Option<String>,
114
115  /// Absolute path to the config file used to load this root
116  #[schemars(skip)]
117  #[serde(skip)]
118  pub source_path: Option<PathBuf>,
119
120  /// Original legacy secret scalar fields before normalization.
121  #[schemars(skip)]
122  #[serde(skip)]
123  pub(crate) raw_legacy_secret_settings: Option<SecretSettings>,
124
125  /// Original `secrets` block before normalization.
126  #[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  // Deserialize the YAML file into a serde_yaml::Value to be able to merge
301  // anchors and aliases
302  let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
303  value.apply_merge()?;
304
305  // Deserialize the serde_yaml::Value into a TaskRoot
306  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  // Deserialize the TOML file into a TaskRoot
321  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  // Deserialize the JSON file into a TaskRoot
335  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  // Deserialize the Lua value into a TaskRoot
350  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  // Create a new Lua instance
426  let lua = Lua::new();
427
428  // Load the Lua file and evaluate it
429  let value = lua.load(contents).eval()?;
430
431  // Deserialize the Lua value into a TaskRoot
432  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    // Parent has a gpg_key_id; child does not → parent value propagates.
722    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    // Child has its own gpg_key_id → it overrides the parent.
741    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    // APFS surfaces invalid byte sequences as OS errors instead of allowing
957    // stable round-tripping of arbitrary non-UTF-8 path bytes.
958  }
959}