1
use anyhow::Context;
2
use hashbrown::HashMap;
3
use mlua::{
4
  Lua,
5
  LuaSerdeExt,
6
};
7
use schemars::JsonSchema;
8
use serde::Deserialize;
9

            
10
use std::fs::File;
11
use std::io::{
12
  BufReader,
13
  Read as _,
14
};
15
use std::path::{
16
  Path,
17
  PathBuf,
18
};
19

            
20
use super::{
21
  ContainerRuntime,
22
  Include,
23
  Task,
24
  UseCargo,
25
  UseNpm,
26
};
27
use crate::file::ToUtf8 as _;
28
use crate::secrets::{
29
  merge_optional_secret_settings,
30
  SecretSettings,
31
};
32
use crate::utils::{
33
  deserialize_environment,
34
  expand_home_path,
35
  resolve_path,
36
};
37

            
38
const 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)]
55
pub 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

            
131
impl TaskRoot {
132
100
  pub fn from_file(file: impl AsRef<Path>) -> anyhow::Result<Self> {
133
100
    Self::from_file_with_stack(file.as_ref(), &mut Vec::new())
134
100
  }
135

            
136
372
  fn from_file_with_stack(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<Self> {
137
372
    let file_path = normalize_task_file_path(file)?;
138

            
139
372
    if let Some(index) = stack.iter().position(|path| path == &file_path) {
140
2
      let mut cycle = stack[index..]
141
2
        .iter()
142
4
        .map(|path| path.to_string_lossy().into_owned())
143
2
        .collect::<Vec<_>>();
144
2
      cycle.push(file_path.to_string_lossy().into_owned());
145
2
      anyhow::bail!("Circular extends detected: {}", cycle.join(" -> "));
146
370
    }
147

            
148
370
    stack.push(file_path.clone());
149
370
    let result = load_task_root(&file_path, stack);
150
370
    stack.pop();
151
370
    result
152
372
  }
153

            
154
2
  pub fn from_hashmap(tasks: HashMap<String, Task>) -> Self {
155
2
    Self {
156
2
      tasks,
157
2
      environment: HashMap::new(),
158
2
      env_file: Vec::new(),
159
2
      secrets_path: Vec::new(),
160
2
      secrets: None,
161
2
      vault_location: None,
162
2
      keys_location: None,
163
2
      key_name: None,
164
2
      gpg_key_id: None,
165
2
      use_npm: None,
166
2
      use_cargo: None,
167
2
      container_runtime: None,
168
2
      include: None,
169
2
      extends: None,
170
2
      source_path: None,
171
2
      raw_legacy_secret_settings: None,
172
2
      raw_secrets: None,
173
2
    }
174
2
  }
175

            
176
310
  pub fn config_base_dir(&self) -> PathBuf {
177
310
    self
178
310
      .source_path
179
310
      .as_ref()
180
310
      .and_then(|path| path.parent().map(Path::to_path_buf))
181
310
      .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
182
310
  }
183

            
184
80
  pub fn cache_base_dir(&self) -> PathBuf {
185
80
    self.config_base_dir()
186
80
  }
187

            
188
8
  pub fn resolve_from_config(&self, value: &str) -> PathBuf {
189
8
    resolve_path(&self.config_base_dir(), value)
190
8
  }
191

            
192
370
  pub fn normalized_secret_settings(&self) -> Option<SecretSettings> {
193
370
    merge_optional_secret_settings(
194
370
      Some(SecretSettings::from_legacy(
195
370
        self.vault_location.clone(),
196
370
        self.keys_location.clone(),
197
370
        self.key_name.clone(),
198
370
        self.gpg_key_id.clone(),
199
370
        self.secrets_path.clone(),
200
370
      )),
201
370
      self.secrets.clone(),
202
    )
203
370
    .filter(|settings| !settings.is_empty())
204
370
  }
205

            
206
172
  pub(crate) fn validation_legacy_secret_settings(&self) -> SecretSettings {
207
172
    self.raw_legacy_secret_settings.clone().unwrap_or_else(|| {
208
44
      SecretSettings::from_legacy(
209
44
        self.vault_location.clone(),
210
44
        self.keys_location.clone(),
211
44
        self.key_name.clone(),
212
44
        self.gpg_key_id.clone(),
213
44
        self.secrets_path.clone(),
214
      )
215
44
    })
216
172
  }
217

            
218
356
  fn normalize_secret_settings(&mut self) {
219
356
    if self.raw_legacy_secret_settings.is_none() {
220
356
      self.raw_legacy_secret_settings = Some(SecretSettings::from_legacy(
221
356
        self.vault_location.clone(),
222
356
        self.keys_location.clone(),
223
356
        self.key_name.clone(),
224
356
        self.gpg_key_id.clone(),
225
356
        self.secrets_path.clone(),
226
356
      ));
227
356
    }
228
356
    if self.raw_secrets.is_none() {
229
356
      self.raw_secrets = self.secrets.clone();
230
356
    }
231

            
232
356
    self.secrets = self.normalized_secret_settings();
233
356
    if let Some(secrets) = &self.secrets {
234
8
      self.vault_location = secrets.vault_location.clone();
235
8
      self.keys_location = secrets.keys_location.clone();
236
8
      self.key_name = secrets.key_name.clone();
237
8
      self.gpg_key_id = secrets.gpg_key_id.clone();
238
8
      self.secrets_path = secrets.secrets_path.clone().unwrap_or_default();
239
352
    }
240

            
241
1832
    for task in self.tasks.values_mut() {
242
1832
      if let Task::Task(task) = task {
243
1762
        task.normalize_secret_settings();
244
1774
      }
245
    }
246
356
  }
247
}
248

            
249
372
fn normalize_task_file_path(file: &Path) -> anyhow::Result<PathBuf> {
250
372
  let file_path = file
251
372
    .to_str()
252
372
    .and_then(expand_home_path)
253
372
    .unwrap_or_else(|| file.to_path_buf());
254
372
  if file_path.is_absolute() {
255
324
    Ok(file_path)
256
  } else {
257
48
    Ok(std::env::current_dir()?.join(file_path))
258
  }
259
372
}
260

            
261
370
fn load_task_root(file_path: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
262
370
  let file_extension = file_path
263
370
    .extension()
264
370
    .and_then(|ext| ext.to_str())
265
370
    .context("Failed to get file extension")?;
266

            
267
370
  let mut root = match file_extension {
268
370
    "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
4
  }?;
279

            
280
366
  if root.include.is_some() {
281
10
    anyhow::bail!("`include` is no longer supported. Use `extends` instead.");
282
356
  }
283

            
284
356
  root.normalize_secret_settings();
285
356
  root.source_path = Some(file_path.to_path_buf());
286
356
  process_task_sources(&mut root)?;
287

            
288
356
  Ok(root)
289
370
}
290

            
291
370
fn load_yaml_file(file: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<TaskRoot> {
292
370
  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
370
  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
370
  let mut value: serde_yaml::Value = serde_yaml::from_reader(reader)?;
303
370
  value.apply_merge()?;
304

            
305
  // Deserialize the serde_yaml::Value into a TaskRoot
306
370
  let root: TaskRoot = serde_yaml::from_value(value)?;
307
370
  apply_extends(file, stack, root)
308
370
}
309

            
310
fn 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

            
325
fn 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

            
339
fn 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

            
354
356
fn process_task_sources(root: &mut TaskRoot) -> anyhow::Result<()> {
355
356
  root.tasks = rename_tasks(
356
356
    std::mem::take(&mut root.tasks),
357
356
    "task",
358
356
    &MK_COMMANDS,
359
356
    &HashMap::new(),
360
  );
361

            
362
356
  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
356
  }
367

            
368
356
  if let Some(cargo) = &root.use_cargo {
369
2
    let cargo_tasks = cargo.capture_in_dir(&root.config_base_dir())?;
370
2
    let renamed_cargo_tasks = rename_tasks(cargo_tasks, "cargo", &MK_COMMANDS, &root.tasks);
371
2
    root.tasks.extend(renamed_cargo_tasks);
372
354
  }
373

            
374
356
  Ok(())
375
356
}
376

            
377
370
fn apply_extends(file: &Path, stack: &mut Vec<PathBuf>, mut root: TaskRoot) -> anyhow::Result<TaskRoot> {
378
370
  let Some(parent) = root.extends.clone() else {
379
362
    return Ok(root);
380
  };
381

            
382
8
  let parent_path = file.parent().unwrap_or_else(|| Path::new(".")).join(parent);
383
8
  let mut base = TaskRoot::from_file_with_stack(&parent_path, stack)?;
384
4
  let base_secrets = base.normalized_secret_settings();
385
4
  let root_secrets = root.normalized_secret_settings();
386
4
  let merged_secrets =
387
4
    merge_optional_secret_settings(base_secrets.clone(), root_secrets.clone()).map(|mut secrets| {
388
4
      let mut merged_paths = base_secrets
389
4
        .as_ref()
390
4
        .and_then(|settings| settings.secrets_path.clone())
391
4
        .unwrap_or_default();
392
4
      if let Some(child_paths) = root_secrets.and_then(|settings| settings.secrets_path) {
393
        merged_paths.extend(child_paths);
394
4
      }
395
4
      if !merged_paths.is_empty() {
396
        secrets.secrets_path = Some(merged_paths);
397
4
      }
398
4
      secrets
399
4
    });
400

            
401
4
  base.tasks.extend(root.tasks.drain());
402
4
  base.environment.extend(root.environment.drain());
403
4
  base.env_file.extend(root.env_file);
404
4
  base.secrets = merged_secrets;
405
4
  if let Some(secrets) = &base.secrets {
406
4
    base.vault_location = secrets.vault_location.clone();
407
4
    base.keys_location = secrets.keys_location.clone();
408
4
    base.key_name = secrets.key_name.clone();
409
4
    base.gpg_key_id = secrets.gpg_key_id.clone();
410
4
    base.secrets_path = secrets.secrets_path.clone().unwrap_or_default();
411
4
  }
412
4
  base.use_npm = root.use_npm.or(base.use_npm);
413
4
  base.use_cargo = root.use_cargo.or(base.use_cargo);
414
4
  base.container_runtime = root.container_runtime.or(base.container_runtime);
415
4
  base.include = root.include.or(base.include);
416
4
  base.extends = None;
417
4
  base.source_path = root.source_path.or(base.source_path);
418
4
  base.raw_legacy_secret_settings = None;
419
4
  base.raw_secrets = None;
420

            
421
4
  Ok(base)
422
370
}
423

            
424
2
fn get_lua_table(contents: &str) -> anyhow::Result<TaskRoot> {
425
  // Create a new Lua instance
426
2
  let lua = Lua::new();
427

            
428
  // Load the Lua file and evaluate it
429
2
  let value = lua.load(contents).eval()?;
430

            
431
  // Deserialize the Lua value into a TaskRoot
432
2
  let root = lua.from_value(value)?;
433

            
434
2
  Ok(root)
435
2
}
436

            
437
358
fn rename_tasks(
438
358
  tasks: HashMap<String, Task>,
439
358
  prefix: &str,
440
358
  mk_commands: &[&str],
441
358
  existing_tasks: &HashMap<String, Task>,
442
358
) -> HashMap<String, Task> {
443
358
  let mut new_tasks = HashMap::new();
444
1874
  for (task_name, task) in tasks.into_iter() {
445
1874
    let new_task_name =
446
1874
      if mk_commands.contains(&task_name.as_str()) || existing_tasks.contains_key(&task_name) {
447
64
        format!("{}_{}", prefix, task_name)
448
      } else {
449
1810
        task_name
450
      };
451

            
452
1874
    new_tasks.insert(new_task_name, task);
453
  }
454
358
  new_tasks
455
358
}
456

            
457
#[cfg(test)]
458
mod test {
459
  use super::*;
460
  use crate::schema::{
461
    CommandRunner,
462
    TaskDependency,
463
  };
464
  use assert_fs::TempDir;
465

            
466
  #[test]
467
2
  fn test_task_root_1() -> anyhow::Result<()> {
468
2
    let yaml = "
469
2
      tasks:
470
2
        task1:
471
2
          commands:
472
2
            - command: echo \"Hello, World 1!\"
473
2
              ignore_errors: false
474
2
              verbose: false
475
2
          depends_on:
476
2
            - name: task2
477
2
          description: 'This is a task'
478
2
          labels: {}
479
2
          environment:
480
2
            FOO: bar
481
2
          env_file:
482
2
            - test.env
483
2
        task2:
484
2
          commands:
485
2
            - command: echo \"Hello, World 2!\"
486
2
              ignore_errors: false
487
2
              verbose: false
488
2
          depends_on:
489
2
            - name: task1
490
2
          description: 'This is a task'
491
2
          labels: {}
492
2
          environment: {}
493
2
        task3:
494
2
          commands:
495
2
            - command: echo \"Hello, World 3!\"
496
2
              ignore_errors: false
497
2
              verbose: false
498
2
    ";
499

            
500
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
501

            
502
2
    assert_eq!(task_root.tasks.len(), 3);
503

            
504
2
    if let Task::Task(task) = &task_root.tasks["task1"] {
505
2
      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
506
2
        assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
507
2
        assert_eq!(local_run.work_dir, None);
508
2
        assert_eq!(local_run.ignore_errors, Some(false));
509
2
        assert_eq!(local_run.verbose, Some(false));
510
      } else {
511
        panic!("Expected CommandRunner::LocalRun");
512
      }
513

            
514
2
      if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
515
2
        assert_eq!(args.name, "task2");
516
      } else {
517
        panic!("Expected TaskDependency::TaskDependency");
518
      }
519
2
      assert_eq!(task.labels.len(), 0);
520
2
      assert_eq!(task.description, "This is a task");
521
2
      assert_eq!(task.environment.len(), 1);
522
2
      assert_eq!(task.env_file.len(), 1);
523
    } else {
524
      panic!("Expected Task::Task");
525
    }
526

            
527
2
    if let Task::Task(task) = &task_root.tasks["task2"] {
528
2
      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
529
2
        assert_eq!(local_run.command, "echo \"Hello, World 2!\"");
530
2
        assert_eq!(local_run.work_dir, None);
531
2
        assert_eq!(local_run.ignore_errors, Some(false));
532
2
        assert_eq!(local_run.verbose, Some(false));
533
      } else {
534
        panic!("Expected CommandRunner::LocalRun");
535
      }
536

            
537
2
      if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
538
2
        assert_eq!(args.name, "task1");
539
      } else {
540
        panic!("Expected TaskDependency::TaskDependency");
541
      }
542
2
      assert_eq!(task.labels.len(), 0);
543
2
      assert_eq!(task.description, "This is a task");
544
2
      assert_eq!(task.environment.len(), 0);
545
2
      assert_eq!(task.env_file.len(), 0);
546
    } else {
547
      panic!("Expected Task::Task");
548
    }
549

            
550
2
    if let Task::Task(task) = &task_root.tasks["task3"] {
551
2
      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
552
2
        assert_eq!(local_run.command, "echo \"Hello, World 3!\"");
553
2
        assert_eq!(local_run.work_dir, None);
554
2
        assert_eq!(local_run.ignore_errors, Some(false));
555
2
        assert_eq!(local_run.verbose, Some(false));
556
      } else {
557
        panic!("Expected CommandRunner::LocalRun");
558
      }
559

            
560
2
      assert_eq!(task.depends_on.len(), 0);
561
2
      assert_eq!(task.labels.len(), 0);
562
2
      assert_eq!(task.description.len(), 0);
563
2
      assert_eq!(task.environment.len(), 0);
564
2
      assert_eq!(task.env_file.len(), 0);
565
    } else {
566
      panic!("Expected Task::Task");
567
    }
568

            
569
2
    Ok(())
570
2
  }
571

            
572
  #[test]
573
2
  fn test_task_root_2() -> anyhow::Result<()> {
574
2
    let yaml = "
575
2
      tasks:
576
2
        task1:
577
2
          commands:
578
2
            - command: echo \"Hello, World 1!\"
579
2
        task2:
580
2
          commands:
581
2
            - echo \"Hello, World 2!\"
582
2
        task3: echo \"Hello, World 3!\"
583
2
    ";
584

            
585
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
586

            
587
2
    assert_eq!(task_root.tasks.len(), 3);
588

            
589
2
    if let Task::Task(task) = &task_root.tasks["task1"] {
590
2
      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
591
2
        assert_eq!(local_run.command, "echo \"Hello, World 1!\"");
592
2
        assert_eq!(local_run.work_dir, None);
593
2
        assert_eq!(local_run.ignore_errors, None);
594
2
        assert_eq!(local_run.verbose, None);
595
      } else {
596
        panic!("Expected CommandRunner::LocalRun");
597
      }
598

            
599
2
      assert_eq!(task.labels.len(), 0);
600
2
      assert_eq!(task.description, "");
601
2
      assert_eq!(task.environment.len(), 0);
602
2
      assert_eq!(task.env_file.len(), 0);
603
    } else {
604
      panic!("Expected Task::Task");
605
    }
606

            
607
2
    if let Task::Task(task) = &task_root.tasks["task2"] {
608
2
      if let CommandRunner::CommandRun(command) = &task.commands[0] {
609
2
        assert_eq!(command, "echo \"Hello, World 2!\"");
610
      } else {
611
        panic!("Expected CommandRunner::CommandRun");
612
      }
613

            
614
2
      assert_eq!(task.labels.len(), 0);
615
2
      assert_eq!(task.description, "");
616
2
      assert_eq!(task.environment.len(), 0);
617
2
      assert_eq!(task.env_file.len(), 0);
618
    } else {
619
      panic!("Expected Task::Task");
620
    }
621

            
622
2
    if let Task::String(command) = &task_root.tasks["task3"] {
623
2
      assert_eq!(command, "echo \"Hello, World 3!\"");
624
    } else {
625
      panic!("Expected Task::String");
626
    }
627

            
628
2
    Ok(())
629
2
  }
630

            
631
  #[test]
632
2
  fn test_task_root_secrets_config() -> anyhow::Result<()> {
633
2
    let yaml = "
634
2
      vault_location: ./.mk/vault
635
2
      keys_location: ./.mk/keys
636
2
      key_name: team
637
2
      secrets_path:
638
2
        - app/common
639
2
      tasks:
640
2
        demo:
641
2
          commands:
642
2
            - command: echo ready
643
2
    ";
644

            
645
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
646

            
647
2
    let secrets = task_root.normalized_secret_settings().unwrap();
648
2
    assert_eq!(secrets.key_name.as_deref(), Some("team"));
649
2
    assert_eq!(task_root.secrets_path, vec!["app/common"]);
650
2
    assert_eq!(task_root.vault_location.as_deref(), Some("./.mk/vault"));
651
2
    assert_eq!(task_root.keys_location.as_deref(), Some("./.mk/keys"));
652
2
    assert_eq!(task_root.key_name.as_deref(), Some("team"));
653
2
    assert_eq!(task_root.gpg_key_id, None);
654

            
655
2
    Ok(())
656
2
  }
657

            
658
  #[test]
659
2
  fn test_task_root_gpg_key_id_deserialized() -> anyhow::Result<()> {
660
2
    let yaml = "
661
2
      gpg_key_id: 0xABCD1234EFGH5678
662
2
      tasks:
663
2
        demo:
664
2
          commands:
665
2
            - command: echo ready
666
2
    ";
667

            
668
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
669
2
    assert_eq!(task_root.gpg_key_id.as_deref(), Some("0xABCD1234EFGH5678"));
670
2
    assert_eq!(
671
2
      task_root.normalized_secret_settings().unwrap().backend.as_ref(),
672
      Some(&crate::secrets::SecretBackend::Gpg)
673
    );
674

            
675
2
    Ok(())
676
2
  }
677

            
678
  #[test]
679
2
  fn test_task_root_secrets_block_deserialized() -> anyhow::Result<()> {
680
2
    let yaml = "
681
2
      secrets:
682
2
        backend: gpg
683
2
        vault_location: ./.mk/vault
684
2
        keys_location: ./.mk/keys
685
2
        key_name: team
686
2
        gpg_key_id: TEAMKEY
687
2
        secrets_path:
688
2
          - app/common
689
2
      tasks:
690
2
        demo:
691
2
          commands:
692
2
            - command: echo ready
693
2
    ";
694

            
695
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
696
2
    let secrets = task_root.normalized_secret_settings().unwrap();
697
2
    assert_eq!(secrets.backend, Some(crate::secrets::SecretBackend::Gpg));
698
2
    assert_eq!(secrets.gpg_key_id.as_deref(), Some("TEAMKEY"));
699
2
    assert_eq!(secrets.vault_location.as_deref(), Some("./.mk/vault"));
700
2
    assert_eq!(secrets.secrets_path, Some(vec!["app/common".to_string()]));
701
2
    Ok(())
702
2
  }
703

            
704
  #[test]
705
2
  fn test_task_root_gpg_key_id_absent_defaults_to_none() -> anyhow::Result<()> {
706
2
    let yaml = "
707
2
      tasks:
708
2
        demo:
709
2
          commands:
710
2
            - command: echo ready
711
2
    ";
712

            
713
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
714
2
    assert_eq!(task_root.gpg_key_id, None);
715

            
716
2
    Ok(())
717
2
  }
718

            
719
  #[test]
720
2
  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
2
    let dir = TempDir::new().unwrap();
723
2
    let parent_path = dir.path().join("parent.yaml");
724
2
    let child_path = dir.path().join("child.yaml");
725

            
726
2
    std::fs::write(&parent_path, "gpg_key_id: PARENT_KEY\ntasks:\n  dummy: echo ok\n")?;
727
2
    std::fs::write(
728
2
      &child_path,
729
      "extends: parent.yaml\ntasks:\n  child_task: echo child\n",
730
    )?;
731

            
732
2
    let root = TaskRoot::from_file(&child_path)?;
733
2
    assert_eq!(root.gpg_key_id.as_deref(), Some("PARENT_KEY"));
734

            
735
2
    Ok(())
736
2
  }
737

            
738
  #[test]
739
2
  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
2
    let dir = TempDir::new().unwrap();
742
2
    let parent_path = dir.path().join("parent.yaml");
743
2
    let child_path = dir.path().join("child.yaml");
744

            
745
2
    std::fs::write(&parent_path, "gpg_key_id: PARENT_KEY\ntasks:\n  dummy: echo ok\n")?;
746
2
    std::fs::write(
747
2
      &child_path,
748
      "extends: parent.yaml\ngpg_key_id: CHILD_KEY\ntasks:\n  child_task: echo child\n",
749
    )?;
750

            
751
2
    let root = TaskRoot::from_file(&child_path)?;
752
2
    assert_eq!(root.gpg_key_id.as_deref(), Some("CHILD_KEY"));
753

            
754
2
    Ok(())
755
2
  }
756

            
757
  #[test]
758
2
  fn test_task_root_3() -> anyhow::Result<()> {
759
2
    let yaml = "
760
2
      tasks:
761
2
        task1: echo \"Hello, World 1!\"
762
2
        task2: echo \"Hello, World 2!\"
763
2
        task3: echo \"Hello, World 3!\"
764
2
    ";
765

            
766
2
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
767

            
768
2
    assert_eq!(task_root.tasks.len(), 3);
769

            
770
2
    if let Task::String(command) = &task_root.tasks["task1"] {
771
2
      assert_eq!(command, "echo \"Hello, World 1!\"");
772
    } else {
773
      panic!("Expected Task::String");
774
    }
775

            
776
2
    if let Task::String(command) = &task_root.tasks["task2"] {
777
2
      assert_eq!(command, "echo \"Hello, World 2!\"");
778
    } else {
779
      panic!("Expected Task::String");
780
    }
781

            
782
2
    if let Task::String(command) = &task_root.tasks["task3"] {
783
2
      assert_eq!(command, "echo \"Hello, World 3!\"");
784
    } else {
785
      panic!("Expected Task::String");
786
    }
787

            
788
2
    Ok(())
789
2
  }
790

            
791
  #[test]
792
2
  fn test_task_root_4() -> anyhow::Result<()> {
793
2
    let lua = "
794
2
      {
795
2
        tasks = {
796
2
          task1 = 'echo \"Hello, World 1!\"',
797
2
          task2 = 'echo \"Hello, World 2!\"',
798
2
          task3 = 'echo \"Hello, World 3!\"',
799
2
        }
800
2
      }
801
2
    ";
802

            
803
2
    let task_root = get_lua_table(lua)?;
804

            
805
2
    assert_eq!(task_root.tasks.len(), 3);
806

            
807
2
    if let Task::String(command) = &task_root.tasks["task1"] {
808
2
      assert_eq!(command, "echo \"Hello, World 1!\"");
809
    } else {
810
      panic!("Expected Task::String");
811
    }
812

            
813
2
    if let Task::String(command) = &task_root.tasks["task2"] {
814
2
      assert_eq!(command, "echo \"Hello, World 2!\"");
815
    } else {
816
      panic!("Expected Task::String");
817
    }
818

            
819
2
    if let Task::String(command) = &task_root.tasks["task3"] {
820
2
      assert_eq!(command, "echo \"Hello, World 3!\"");
821
    } else {
822
      panic!("Expected Task::String");
823
    }
824

            
825
2
    Ok(())
826
2
  }
827

            
828
  #[test]
829
2
  fn test_task_root_5_from_file_loads_use_cargo() -> anyhow::Result<()> {
830
    use assert_fs::TempDir;
831
    use std::fs;
832

            
833
2
    let temp_dir = TempDir::new()?;
834
2
    let config_path = temp_dir.path().join("tasks.yaml");
835
2
    fs::write(
836
2
      &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
2
    let task_root = TaskRoot::from_file(&config_path)?;
848

            
849
2
    assert!(task_root.tasks.contains_key("test"));
850

            
851
2
    if let Task::Task(task) = &task_root.tasks["test"] {
852
2
      if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
853
2
        assert_eq!(local_run.command, "cargo test");
854
2
        assert_eq!(
855
          local_run.work_dir,
856
2
          Some(
857
2
            temp_dir
858
2
              .path()
859
2
              .join("crates")
860
2
              .join("app")
861
2
              .to_string_lossy()
862
2
              .into_owned()
863
2
          )
864
        );
865
      } else {
866
        panic!("Expected CommandRunner::LocalRun");
867
      }
868
    } else {
869
      panic!("Expected Task::Task");
870
    }
871

            
872
2
    Ok(())
873
2
  }
874

            
875
  #[test]
876
2
  fn test_task_root_6_from_file_rejects_include() -> anyhow::Result<()> {
877
    use assert_fs::TempDir;
878
    use std::fs;
879

            
880
2
    let temp_dir = TempDir::new()?;
881
2
    let config_path = temp_dir.path().join("tasks.yaml");
882
2
    fs::write(
883
2
      &config_path,
884
      "
885
      include:
886
        - shared.yaml
887
      tasks:
888
        hello:
889
          commands:
890
            - command: echo hello
891
      ",
892
    )?;
893

            
894
2
    let error = TaskRoot::from_file(&config_path).unwrap_err();
895
2
    assert!(error
896
2
      .to_string()
897
2
      .contains("`include` is no longer supported. Use `extends` instead."));
898
2
    Ok(())
899
2
  }
900

            
901
  #[test]
902
2
  fn test_task_root_7_from_file_rejects_extends_cycle() -> anyhow::Result<()> {
903
    use assert_fs::TempDir;
904
    use std::fs;
905

            
906
2
    let temp_dir = TempDir::new()?;
907
2
    let a_path = temp_dir.path().join("a.yaml");
908
2
    let b_path = temp_dir.path().join("b.yaml");
909

            
910
2
    fs::write(
911
2
      &a_path,
912
      "
913
        extends: b.yaml
914
        tasks:
915
          a:
916
            commands:
917
              - command: echo a
918
        ",
919
    )?;
920
2
    fs::write(
921
2
      &b_path,
922
      "
923
        extends: a.yaml
924
        tasks:
925
          b:
926
            commands:
927
              - command: echo b
928
        ",
929
    )?;
930

            
931
2
    let error = TaskRoot::from_file(&a_path).unwrap_err();
932
2
    assert!(error.to_string().contains("Circular extends detected:"));
933
2
    Ok(())
934
2
  }
935

            
936
  #[cfg(all(unix, not(target_os = "macos")))]
937
  #[test]
938
2
  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
2
    let temp_dir = TempDir::new()?;
943
2
    let file_name = OsString::from_vec(vec![0x66, 0x6f, 0x80, 0x6f, 0x2e, 0x79, 0x61, 0x6d, 0x6c]);
944
2
    let config_path = temp_dir.path().join(file_name);
945
2
    std::fs::write(&config_path, "tasks:\n  hello: echo ok\n")?;
946

            
947
2
    let root = TaskRoot::from_file(&config_path)?;
948
2
    assert!(root.tasks.contains_key("hello"));
949

            
950
2
    Ok(())
951
2
  }
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
}