1use std::path::PathBuf;
2use util::serde::default_true;
3
4use anyhow::{bail, Context};
5use collections::{HashMap, HashSet};
6use schemars::{gen::SchemaSettings, JsonSchema};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use util::{truncate_and_remove_front, ResultExt};
10
11use crate::{
12 ResolvedTask, Shell, SpawnInTerminal, TaskContext, TaskId, VariableName,
13 ZED_VARIABLE_NAME_PREFIX,
14};
15
16/// A template definition of a Zed task to run.
17/// May use the [`VariableName`] to get the corresponding substitutions into its fields.
18///
19/// Template itself is not ready to spawn a task, it needs to be resolved with a [`TaskContext`] first, that
20/// contains all relevant Zed state in task variables.
21/// A single template may produce different tasks (or none) for different contexts.
22#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub struct TaskTemplate {
25 /// Human readable name of the task to display in the UI.
26 pub label: String,
27 /// Executable command to spawn.
28 pub command: String,
29 /// Arguments to the command.
30 #[serde(default)]
31 pub args: Vec<String>,
32 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
33 #[serde(default)]
34 pub env: HashMap<String, String>,
35 /// Current working directory to spawn the command into, defaults to current project root.
36 #[serde(default)]
37 pub cwd: Option<String>,
38 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
39 #[serde(default)]
40 pub use_new_terminal: bool,
41 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
42 #[serde(default)]
43 pub allow_concurrent_runs: bool,
44 /// What to do with the terminal pane and tab, after the command was started:
45 /// * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default)
46 /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
47 #[serde(default)]
48 pub reveal: RevealStrategy,
49 /// What to do with the terminal pane and tab, after the command had finished:
50 /// * `never` — do nothing when the command finishes (default)
51 /// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
52 /// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`.
53 #[serde(default)]
54 pub hide: HideStrategy,
55 /// Represents the tags which this template attaches to. Adding this removes this task from other UI.
56 #[serde(default)]
57 pub tags: Vec<String>,
58 /// Which shell to use when spawning the task.
59 #[serde(default)]
60 pub shell: Shell,
61 /// Whether to show the task line in the task output.
62 #[serde(default = "default_true")]
63 pub show_summary: bool,
64 /// Whether to show the command line in the task output.
65 #[serde(default = "default_true")]
66 pub show_command: bool,
67}
68
69/// What to do with the terminal pane and tab, after the command was started.
70#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "snake_case")]
72pub enum RevealStrategy {
73 /// Always show the terminal pane, add and focus the corresponding task's tab in it.
74 #[default]
75 Always,
76 /// Always show the terminal pane, add the task's tab in it, but don't focus it.
77 NoFocus,
78 /// Do not change terminal pane focus, but still add/reuse the task's tab there.
79 Never,
80}
81
82/// What to do with the terminal pane and tab, after the command has finished.
83#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(rename_all = "snake_case")]
85pub enum HideStrategy {
86 /// Do nothing when the command finishes.
87 #[default]
88 Never,
89 /// Always hide the terminal tab, hide the pane also if it was the last tab in it.
90 Always,
91 /// Hide the terminal tab on task success only, otherwise behaves similar to `Always`.
92 OnSuccess,
93}
94
95/// A group of Tasks defined in a JSON file.
96#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97pub struct TaskTemplates(pub Vec<TaskTemplate>);
98
99impl TaskTemplates {
100 /// Generates JSON schema of Tasks JSON template format.
101 pub fn generate_json_schema() -> serde_json_lenient::Value {
102 let schema = SchemaSettings::draft07()
103 .with(|settings| settings.option_add_null_type = false)
104 .into_generator()
105 .into_root_schema_for::<Self>();
106
107 serde_json_lenient::to_value(schema).unwrap()
108 }
109}
110
111impl TaskTemplate {
112 /// Replaces all `VariableName` task variables in the task template string fields.
113 /// If any replacement fails or the new string substitutions still have [`ZED_VARIABLE_NAME_PREFIX`],
114 /// `None` is returned.
115 ///
116 /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
117 /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
118 pub fn resolve_task(
119 &self,
120 id_base: &str,
121 target: zed_actions::TaskSpawnTarget,
122 cx: &TaskContext,
123 ) -> Option<ResolvedTask> {
124 if self.label.trim().is_empty() || self.command.trim().is_empty() {
125 return None;
126 }
127
128 let mut variable_names = HashMap::default();
129 let mut substituted_variables = HashSet::default();
130 let task_variables = cx
131 .task_variables
132 .0
133 .iter()
134 .map(|(key, value)| {
135 let key_string = key.to_string();
136 if !variable_names.contains_key(&key_string) {
137 variable_names.insert(key_string.clone(), key.clone());
138 }
139 (key_string, value.as_str())
140 })
141 .collect::<HashMap<_, _>>();
142 let truncated_variables = truncate_variables(&task_variables);
143 let cwd = match self.cwd.as_deref() {
144 Some(cwd) => {
145 let substitured_cwd = substitute_all_template_variables_in_str(
146 cwd,
147 &task_variables,
148 &variable_names,
149 &mut substituted_variables,
150 )?;
151 Some(PathBuf::from(substitured_cwd))
152 }
153 None => None,
154 }
155 .or(cx.cwd.clone());
156 let human_readable_label = substitute_all_template_variables_in_str(
157 &self.label,
158 &truncated_variables,
159 &variable_names,
160 &mut substituted_variables,
161 )?
162 .lines()
163 .fold(String::new(), |mut string, line| {
164 if string.is_empty() {
165 string.push_str(line);
166 } else {
167 string.push_str("\\n");
168 string.push_str(line);
169 }
170 string
171 });
172 let full_label = substitute_all_template_variables_in_str(
173 &self.label,
174 &task_variables,
175 &variable_names,
176 &mut substituted_variables,
177 )?;
178 let command = substitute_all_template_variables_in_str(
179 &self.command,
180 &task_variables,
181 &variable_names,
182 &mut substituted_variables,
183 )?;
184 let args_with_substitutions = substitute_all_template_variables_in_vec(
185 &self.args,
186 &task_variables,
187 &variable_names,
188 &mut substituted_variables,
189 )?;
190
191 let task_hash = to_hex_hash(self)
192 .context("hashing task template")
193 .log_err()?;
194 let variables_hash = to_hex_hash(&task_variables)
195 .context("hashing task variables")
196 .log_err()?;
197 let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
198
199 let env = {
200 // Start with the project environment as the base.
201 let mut env = cx.project_env.clone();
202
203 // Extend that environment with what's defined in the TaskTemplate
204 env.extend(self.env.clone());
205
206 // Then we replace all task variables that could be set in environment variables
207 let mut env = substitute_all_template_variables_in_map(
208 &env,
209 &task_variables,
210 &variable_names,
211 &mut substituted_variables,
212 )?;
213
214 // Last step: set the task variables as environment variables too
215 env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
216 env
217 };
218
219 Some(ResolvedTask {
220 id: id.clone(),
221 substituted_variables,
222 target,
223 original_task: self.clone(),
224 resolved_label: full_label.clone(),
225 resolved: Some(SpawnInTerminal {
226 id,
227 cwd,
228 full_label,
229 label: human_readable_label,
230 command_label: args_with_substitutions.iter().fold(
231 command.clone(),
232 |mut command_label, arg| {
233 command_label.push(' ');
234 command_label.push_str(arg);
235 command_label
236 },
237 ),
238 command,
239 args: self.args.clone(),
240 env,
241 use_new_terminal: self.use_new_terminal,
242 allow_concurrent_runs: self.allow_concurrent_runs,
243 reveal: self.reveal,
244 hide: self.hide,
245 shell: self.shell.clone(),
246 show_summary: self.show_summary,
247 show_command: self.show_command,
248 }),
249 })
250 }
251}
252
253const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
254
255fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
256 task_variables
257 .iter()
258 .map(|(key, value)| {
259 (
260 key.clone(),
261 truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
262 )
263 })
264 .collect()
265}
266
267fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
268 let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
269 let mut hasher = Sha256::new();
270 hasher.update(json.as_bytes());
271 Ok(hex::encode(hasher.finalize()))
272}
273
274fn substitute_all_template_variables_in_str<A: AsRef<str>>(
275 template_str: &str,
276 task_variables: &HashMap<String, A>,
277 variable_names: &HashMap<String, VariableName>,
278 substituted_variables: &mut HashSet<VariableName>,
279) -> Option<String> {
280 let substituted_string = shellexpand::env_with_context(template_str, |var| {
281 // Colons denote a default value in case the variable is not set. We want to preserve that default, as otherwise shellexpand will substitute it for us.
282 let colon_position = var.find(':').unwrap_or(var.len());
283 let (variable_name, default) = var.split_at(colon_position);
284 if let Some(name) = task_variables.get(variable_name) {
285 if let Some(substituted_variable) = variable_names.get(variable_name) {
286 substituted_variables.insert(substituted_variable.clone());
287 }
288
289 let mut name = name.as_ref().to_owned();
290 // Got a task variable hit
291 if !default.is_empty() {
292 name.push_str(default);
293 }
294 return Ok(Some(name));
295 } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
296 bail!("Unknown variable name: {variable_name}");
297 }
298 // This is an unknown variable.
299 // We should not error out, as they may come from user environment (e.g. $PATH). That means that the variable substitution might not be perfect.
300 // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
301 if !default.is_empty() {
302 return Ok(Some(format!("${{{var}}}")));
303 }
304 // Else we can just return None and that variable will be left as is.
305 Ok(None)
306 })
307 .ok()?;
308 Some(substituted_string.into_owned())
309}
310
311fn substitute_all_template_variables_in_vec(
312 template_strs: &[String],
313 task_variables: &HashMap<String, &str>,
314 variable_names: &HashMap<String, VariableName>,
315 substituted_variables: &mut HashSet<VariableName>,
316) -> Option<Vec<String>> {
317 let mut expanded = Vec::with_capacity(template_strs.len());
318 for variable in template_strs {
319 let new_value = substitute_all_template_variables_in_str(
320 variable,
321 task_variables,
322 variable_names,
323 substituted_variables,
324 )?;
325 expanded.push(new_value);
326 }
327 Some(expanded)
328}
329
330fn substitute_all_template_variables_in_map(
331 keys_and_values: &HashMap<String, String>,
332 task_variables: &HashMap<String, &str>,
333 variable_names: &HashMap<String, VariableName>,
334 substituted_variables: &mut HashSet<VariableName>,
335) -> Option<HashMap<String, String>> {
336 let mut new_map: HashMap<String, String> = Default::default();
337 for (key, value) in keys_and_values {
338 let new_value = substitute_all_template_variables_in_str(
339 value,
340 task_variables,
341 variable_names,
342 substituted_variables,
343 )?;
344 let new_key = substitute_all_template_variables_in_str(
345 key,
346 task_variables,
347 variable_names,
348 substituted_variables,
349 )?;
350 new_map.insert(new_key, new_value);
351 }
352 Some(new_map)
353}
354
355#[cfg(test)]
356mod tests {
357 use std::{borrow::Cow, path::Path};
358
359 use crate::{TaskVariables, VariableName};
360
361 use super::*;
362
363 const TEST_ID_BASE: &str = "test_base";
364
365 #[test]
366 fn test_resolving_templates_with_blank_command_and_label() {
367 let task_with_all_properties = TaskTemplate {
368 label: "test_label".to_string(),
369 command: "test_command".to_string(),
370 args: vec!["test_arg".to_string()],
371 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
372 ..TaskTemplate::default()
373 };
374
375 for task_with_blank_property in &[
376 TaskTemplate {
377 label: "".to_string(),
378 ..task_with_all_properties.clone()
379 },
380 TaskTemplate {
381 command: "".to_string(),
382 ..task_with_all_properties.clone()
383 },
384 TaskTemplate {
385 label: "".to_string(),
386 command: "".to_string(),
387 ..task_with_all_properties.clone()
388 },
389 ] {
390 assert_eq!(
391 task_with_blank_property.resolve_task(TEST_ID_BASE, Default::default(), &TaskContext::default()),
392 None,
393 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
394 );
395 }
396 }
397
398 #[test]
399 fn test_template_cwd_resolution() {
400 let task_without_cwd = TaskTemplate {
401 cwd: None,
402 label: "test task".to_string(),
403 command: "echo 4".to_string(),
404 ..TaskTemplate::default()
405 };
406
407 let resolved_task = |task_template: &TaskTemplate, task_cx| {
408 let resolved_task = task_template
409 .resolve_task(TEST_ID_BASE, Default::default(), task_cx)
410 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
411 assert_substituted_variables(&resolved_task, Vec::new());
412 resolved_task
413 .resolved
414 .clone()
415 .unwrap_or_else(|| {
416 panic!("failed to get resolve data for resolved task. Template: {task_without_cwd:?} Resolved: {resolved_task:?}")
417 })
418 };
419
420 let cx = TaskContext {
421 cwd: None,
422 task_variables: TaskVariables::default(),
423 project_env: HashMap::default(),
424 };
425 assert_eq!(
426 resolved_task(&task_without_cwd, &cx).cwd,
427 None,
428 "When neither task nor task context have cwd, it should be None"
429 );
430
431 let context_cwd = Path::new("a").join("b").join("c");
432 let cx = TaskContext {
433 cwd: Some(context_cwd.clone()),
434 task_variables: TaskVariables::default(),
435 project_env: HashMap::default(),
436 };
437 assert_eq!(
438 resolved_task(&task_without_cwd, &cx).cwd,
439 Some(context_cwd.clone()),
440 "TaskContext's cwd should be taken on resolve if task's cwd is None"
441 );
442
443 let task_cwd = Path::new("d").join("e").join("f");
444 let mut task_with_cwd = task_without_cwd.clone();
445 task_with_cwd.cwd = Some(task_cwd.display().to_string());
446 let task_with_cwd = task_with_cwd;
447
448 let cx = TaskContext {
449 cwd: None,
450 task_variables: TaskVariables::default(),
451 project_env: HashMap::default(),
452 };
453 assert_eq!(
454 resolved_task(&task_with_cwd, &cx).cwd,
455 Some(task_cwd.clone()),
456 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
457 );
458
459 let cx = TaskContext {
460 cwd: Some(context_cwd.clone()),
461 task_variables: TaskVariables::default(),
462 project_env: HashMap::default(),
463 };
464 assert_eq!(
465 resolved_task(&task_with_cwd, &cx).cwd,
466 Some(task_cwd),
467 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
468 );
469 }
470
471 #[test]
472 fn test_template_variables_resolution() {
473 let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
474 let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
475 let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
476 let all_variables = [
477 (VariableName::Row, "1234".to_string()),
478 (VariableName::Column, "5678".to_string()),
479 (VariableName::File, "test_file".to_string()),
480 (VariableName::SelectedText, "test_selected_text".to_string()),
481 (VariableName::Symbol, long_value.clone()),
482 (VariableName::WorktreeRoot, "/test_root/".to_string()),
483 (
484 custom_variable_1.clone(),
485 "test_custom_variable_1".to_string(),
486 ),
487 (
488 custom_variable_2.clone(),
489 "test_custom_variable_2".to_string(),
490 ),
491 ];
492
493 let task_with_all_variables = TaskTemplate {
494 label: format!(
495 "test label for {} and {}",
496 VariableName::Row.template_value(),
497 VariableName::Symbol.template_value(),
498 ),
499 command: format!(
500 "echo {} {}",
501 VariableName::File.template_value(),
502 VariableName::Symbol.template_value(),
503 ),
504 args: vec![
505 format!("arg1 {}", VariableName::SelectedText.template_value()),
506 format!("arg2 {}", VariableName::Column.template_value()),
507 format!("arg3 {}", VariableName::Symbol.template_value()),
508 ],
509 env: HashMap::from_iter([
510 ("test_env_key".to_string(), "test_env_var".to_string()),
511 (
512 "env_key_1".to_string(),
513 VariableName::WorktreeRoot.template_value(),
514 ),
515 (
516 "env_key_2".to_string(),
517 format!(
518 "env_var_2 {} {}",
519 custom_variable_1.template_value(),
520 custom_variable_2.template_value()
521 ),
522 ),
523 (
524 "env_key_3".to_string(),
525 format!("env_var_3 {}", VariableName::Symbol.template_value()),
526 ),
527 ]),
528 ..TaskTemplate::default()
529 };
530
531 let mut first_resolved_id = None;
532 for i in 0..15 {
533 let resolved_task = task_with_all_variables.resolve_task(
534 TEST_ID_BASE,
535 Default::default(),
536 &TaskContext {
537 cwd: None,
538 task_variables: TaskVariables::from_iter(all_variables.clone()),
539 project_env: HashMap::default(),
540 },
541 ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
542
543 match &first_resolved_id {
544 None => first_resolved_id = Some(resolved_task.id.clone()),
545 Some(first_id) => assert_eq!(
546 &resolved_task.id, first_id,
547 "Step {i}, for the same task template and context, there should be the same resolved task id"
548 ),
549 }
550
551 assert_eq!(
552 resolved_task.original_task, task_with_all_variables,
553 "Resolved task should store its template without changes"
554 );
555 assert_eq!(
556 resolved_task.resolved_label,
557 format!("test label for 1234 and {long_value}"),
558 "Resolved task label should be substituted with variables and those should not be shortened"
559 );
560 assert_substituted_variables(
561 &resolved_task,
562 all_variables.iter().map(|(name, _)| name.clone()).collect(),
563 );
564
565 let spawn_in_terminal = resolved_task
566 .resolved
567 .as_ref()
568 .expect("should have resolved a spawn in terminal task");
569 assert_eq!(
570 spawn_in_terminal.label,
571 format!(
572 "test label for 1234 and …{}",
573 &long_value[..=MAX_DISPLAY_VARIABLE_LENGTH]
574 ),
575 "Human-readable label should have long substitutions trimmed"
576 );
577 assert_eq!(
578 spawn_in_terminal.command,
579 format!("echo test_file {long_value}"),
580 "Command should be substituted with variables and those should not be shortened"
581 );
582 assert_eq!(
583 spawn_in_terminal.args,
584 &[
585 "arg1 $ZED_SELECTED_TEXT",
586 "arg2 $ZED_COLUMN",
587 "arg3 $ZED_SYMBOL",
588 ],
589 "Args should not be substituted with variables"
590 );
591 assert_eq!(
592 spawn_in_terminal.command_label,
593 format!("{} arg1 test_selected_text arg2 5678 arg3 {long_value}", spawn_in_terminal.command),
594 "Command label args should be substituted with variables and those should not be shortened"
595 );
596
597 assert_eq!(
598 spawn_in_terminal
599 .env
600 .get("test_env_key")
601 .map(|s| s.as_str()),
602 Some("test_env_var")
603 );
604 assert_eq!(
605 spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
606 Some("/test_root/")
607 );
608 assert_eq!(
609 spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
610 Some("env_var_2 test_custom_variable_1 test_custom_variable_2")
611 );
612 assert_eq!(
613 spawn_in_terminal.env.get("env_key_3"),
614 Some(&format!("env_var_3 {long_value}")),
615 "Env vars should be substituted with variables and those should not be shortened"
616 );
617 }
618
619 for i in 0..all_variables.len() {
620 let mut not_all_variables = all_variables.to_vec();
621 let removed_variable = not_all_variables.remove(i);
622 let resolved_task_attempt = task_with_all_variables.resolve_task(
623 TEST_ID_BASE,
624 Default::default(),
625 &TaskContext {
626 cwd: None,
627 task_variables: TaskVariables::from_iter(not_all_variables),
628 project_env: HashMap::default(),
629 },
630 );
631 assert_eq!(resolved_task_attempt, None, "If any of the Zed task variables is not substituted, the task should not be resolved, but got some resolution without the variable {removed_variable:?} (index {i})");
632 }
633 }
634
635 #[test]
636 fn test_can_resolve_free_variables() {
637 let task = TaskTemplate {
638 label: "My task".into(),
639 command: "echo".into(),
640 args: vec!["$PATH".into()],
641 ..Default::default()
642 };
643 let resolved_task = task
644 .resolve_task(TEST_ID_BASE, Default::default(), &TaskContext::default())
645 .unwrap();
646 assert_substituted_variables(&resolved_task, Vec::new());
647 let resolved = resolved_task.resolved.unwrap();
648 assert_eq!(resolved.label, task.label);
649 assert_eq!(resolved.command, task.command);
650 assert_eq!(resolved.args, task.args);
651 }
652
653 #[test]
654 fn test_errors_on_missing_zed_variable() {
655 let task = TaskTemplate {
656 label: "My task".into(),
657 command: "echo".into(),
658 args: vec!["$ZED_VARIABLE".into()],
659 ..Default::default()
660 };
661 assert!(task
662 .resolve_task(TEST_ID_BASE, Default::default(), &TaskContext::default())
663 .is_none());
664 }
665
666 #[test]
667 fn test_symbol_dependent_tasks() {
668 let task_with_all_properties = TaskTemplate {
669 label: "test_label".to_string(),
670 command: "test_command".to_string(),
671 args: vec!["test_arg".to_string()],
672 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
673 ..TaskTemplate::default()
674 };
675 let cx = TaskContext {
676 cwd: None,
677 task_variables: TaskVariables::from_iter(Some((
678 VariableName::Symbol,
679 "test_symbol".to_string(),
680 ))),
681 project_env: HashMap::default(),
682 };
683
684 for (i, symbol_dependent_task) in [
685 TaskTemplate {
686 label: format!("test_label_{}", VariableName::Symbol.template_value()),
687 ..task_with_all_properties.clone()
688 },
689 TaskTemplate {
690 command: format!("test_command_{}", VariableName::Symbol.template_value()),
691 ..task_with_all_properties.clone()
692 },
693 TaskTemplate {
694 args: vec![format!(
695 "test_arg_{}",
696 VariableName::Symbol.template_value()
697 )],
698 ..task_with_all_properties.clone()
699 },
700 TaskTemplate {
701 env: HashMap::from_iter([(
702 "test_env_key".to_string(),
703 format!("test_env_var_{}", VariableName::Symbol.template_value()),
704 )]),
705 ..task_with_all_properties.clone()
706 },
707 ]
708 .into_iter()
709 .enumerate()
710 {
711 let resolved = symbol_dependent_task
712 .resolve_task(TEST_ID_BASE, Default::default(), &cx)
713 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
714 assert_eq!(
715 resolved.substituted_variables,
716 HashSet::from_iter(Some(VariableName::Symbol)),
717 "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
718 )
719 }
720 }
721
722 #[track_caller]
723 fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
724 let mut resolved_variables = resolved_task
725 .substituted_variables
726 .iter()
727 .cloned()
728 .collect::<Vec<_>>();
729 resolved_variables.sort_by_key(|var| var.to_string());
730 expected.sort_by_key(|var| var.to_string());
731 assert_eq!(resolved_variables, expected)
732 }
733
734 #[test]
735 fn substitute_funky_labels() {
736 let faulty_go_test = TaskTemplate {
737 label: format!(
738 "go test {}/{}",
739 VariableName::Symbol.template_value(),
740 VariableName::Symbol.template_value(),
741 ),
742 command: "go".into(),
743 args: vec![format!(
744 "^{}$/^{}$",
745 VariableName::Symbol.template_value(),
746 VariableName::Symbol.template_value()
747 )],
748 ..TaskTemplate::default()
749 };
750 let mut context = TaskContext::default();
751 context
752 .task_variables
753 .insert(VariableName::Symbol, "my-symbol".to_string());
754 assert!(faulty_go_test
755 .resolve_task("base", Default::default(), &context)
756 .is_some());
757 }
758
759 #[test]
760 fn test_project_env() {
761 let all_variables = [
762 (VariableName::Row, "1234".to_string()),
763 (VariableName::Column, "5678".to_string()),
764 (VariableName::File, "test_file".to_string()),
765 (VariableName::Symbol, "my symbol".to_string()),
766 ];
767
768 let template = TaskTemplate {
769 label: "my task".to_string(),
770 command: format!(
771 "echo {} {}",
772 VariableName::File.template_value(),
773 VariableName::Symbol.template_value(),
774 ),
775 args: vec![],
776 env: HashMap::from_iter([
777 (
778 "TASK_ENV_VAR1".to_string(),
779 "TASK_ENV_VAR1_VALUE".to_string(),
780 ),
781 (
782 "TASK_ENV_VAR2".to_string(),
783 format!(
784 "env_var_2 {} {}",
785 VariableName::Row.template_value(),
786 VariableName::Column.template_value()
787 ),
788 ),
789 (
790 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
791 "overwritten".to_string(),
792 ),
793 ]),
794 ..TaskTemplate::default()
795 };
796
797 let project_env = HashMap::from_iter([
798 (
799 "PROJECT_ENV_VAR1".to_string(),
800 "PROJECT_ENV_VAR1_VALUE".to_string(),
801 ),
802 (
803 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
804 "PROJECT_ENV_WILL_BE_OVERWRITTEN_VALUE".to_string(),
805 ),
806 ]);
807
808 let context = TaskContext {
809 cwd: None,
810 task_variables: TaskVariables::from_iter(all_variables.clone()),
811 project_env,
812 };
813
814 let resolved = template
815 .resolve_task(TEST_ID_BASE, Default::default(), &context)
816 .unwrap()
817 .resolved
818 .unwrap();
819
820 assert_eq!(resolved.env["TASK_ENV_VAR1"], "TASK_ENV_VAR1_VALUE");
821 assert_eq!(resolved.env["TASK_ENV_VAR2"], "env_var_2 1234 5678");
822 assert_eq!(resolved.env["PROJECT_ENV_VAR1"], "PROJECT_ENV_VAR1_VALUE");
823 assert_eq!(
824 resolved.env["PROJECT_ENV_WILL_BE_OVERWRITTEN"],
825 "overwritten"
826 );
827 }
828}