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, RevealTarget, 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 task's pane, and focus the corresponding tab in it (default)
46 // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it
47 // * `never` — do not alter focus, but still add/reuse the task's tab in its pane
48 #[serde(default)]
49 pub reveal: RevealStrategy,
50 /// Where to place the task's terminal item after starting the task.
51 /// * `dock` — in the terminal dock, "regular" terminal items' place (default).
52 /// * `center` — in the central pane group, "main" editor area.
53 #[serde(default)]
54 pub reveal_target: RevealTarget,
55 /// What to do with the terminal pane and tab, after the command had finished:
56 /// * `never` — do nothing when the command finishes (default)
57 /// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
58 /// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`.
59 #[serde(default)]
60 pub hide: HideStrategy,
61 /// Represents the tags which this template attaches to. Adding this removes this task from other UI.
62 #[serde(default)]
63 pub tags: Vec<String>,
64 /// Which shell to use when spawning the task.
65 #[serde(default)]
66 pub shell: Shell,
67 /// Whether to show the task line in the task output.
68 #[serde(default = "default_true")]
69 pub show_summary: bool,
70 /// Whether to show the command line in the task output.
71 #[serde(default = "default_true")]
72 pub show_command: bool,
73}
74
75/// What to do with the terminal pane and tab, after the command was started.
76#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77#[serde(rename_all = "snake_case")]
78pub enum RevealStrategy {
79 /// Always show the task's pane, and focus the corresponding tab in it.
80 #[default]
81 Always,
82 /// Always show the task's pane, add the task's tab in it, but don't focus it.
83 NoFocus,
84 /// Do not alter focus, but still add/reuse the task's tab in its pane.
85 Never,
86}
87
88/// What to do with the terminal pane and tab, after the command has finished.
89#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90#[serde(rename_all = "snake_case")]
91pub enum HideStrategy {
92 /// Do nothing when the command finishes.
93 #[default]
94 Never,
95 /// Always hide the terminal tab, hide the pane also if it was the last tab in it.
96 Always,
97 /// Hide the terminal tab on task success only, otherwise behaves similar to `Always`.
98 OnSuccess,
99}
100
101/// A group of Tasks defined in a JSON file.
102#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct TaskTemplates(pub Vec<TaskTemplate>);
104
105impl TaskTemplates {
106 /// Generates JSON schema of Tasks JSON template format.
107 pub fn generate_json_schema() -> serde_json_lenient::Value {
108 let schema = SchemaSettings::draft07()
109 .with(|settings| settings.option_add_null_type = false)
110 .into_generator()
111 .into_root_schema_for::<Self>();
112
113 serde_json_lenient::to_value(schema).unwrap()
114 }
115}
116
117impl TaskTemplate {
118 /// Replaces all `VariableName` task variables in the task template string fields.
119 /// If any replacement fails or the new string substitutions still have [`ZED_VARIABLE_NAME_PREFIX`],
120 /// `None` is returned.
121 ///
122 /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
123 /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
124 pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
125 if self.label.trim().is_empty() || self.command.trim().is_empty() {
126 return None;
127 }
128
129 let mut variable_names = HashMap::default();
130 let mut substituted_variables = HashSet::default();
131 let task_variables = cx
132 .task_variables
133 .0
134 .iter()
135 .map(|(key, value)| {
136 let key_string = key.to_string();
137 if !variable_names.contains_key(&key_string) {
138 variable_names.insert(key_string.clone(), key.clone());
139 }
140 (key_string, value.as_str())
141 })
142 .collect::<HashMap<_, _>>();
143 let truncated_variables = truncate_variables(&task_variables);
144 let cwd = match self.cwd.as_deref() {
145 Some(cwd) => {
146 let substituted_cwd = substitute_all_template_variables_in_str(
147 cwd,
148 &task_variables,
149 &variable_names,
150 &mut substituted_variables,
151 )?;
152 Some(PathBuf::from(substituted_cwd))
153 }
154 None => None,
155 }
156 .or(cx.cwd.clone());
157 let human_readable_label = substitute_all_template_variables_in_str(
158 &self.label,
159 &truncated_variables,
160 &variable_names,
161 &mut substituted_variables,
162 )?
163 .lines()
164 .fold(String::new(), |mut string, line| {
165 if string.is_empty() {
166 string.push_str(line);
167 } else {
168 string.push_str("\\n");
169 string.push_str(line);
170 }
171 string
172 });
173 let full_label = substitute_all_template_variables_in_str(
174 &self.label,
175 &task_variables,
176 &variable_names,
177 &mut substituted_variables,
178 )?;
179 let command = substitute_all_template_variables_in_str(
180 &self.command,
181 &task_variables,
182 &variable_names,
183 &mut substituted_variables,
184 )?;
185 let args_with_substitutions = substitute_all_template_variables_in_vec(
186 &self.args,
187 &task_variables,
188 &variable_names,
189 &mut substituted_variables,
190 )?;
191
192 let task_hash = to_hex_hash(self)
193 .context("hashing task template")
194 .log_err()?;
195 let variables_hash = to_hex_hash(&task_variables)
196 .context("hashing task variables")
197 .log_err()?;
198 let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
199
200 let env = {
201 // Start with the project environment as the base.
202 let mut env = cx.project_env.clone();
203
204 // Extend that environment with what's defined in the TaskTemplate
205 env.extend(self.env.clone());
206
207 // Then we replace all task variables that could be set in environment variables
208 let mut env = substitute_all_template_variables_in_map(
209 &env,
210 &task_variables,
211 &variable_names,
212 &mut substituted_variables,
213 )?;
214
215 // Last step: set the task variables as environment variables too
216 env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
217 env
218 };
219
220 Some(ResolvedTask {
221 id: id.clone(),
222 substituted_variables,
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 reveal_target: self.reveal_target,
245 hide: self.hide,
246 shell: self.shell.clone(),
247 show_summary: self.show_summary,
248 show_command: self.show_command,
249 }),
250 })
251 }
252}
253
254const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
255
256fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
257 task_variables
258 .iter()
259 .map(|(key, value)| {
260 (
261 key.clone(),
262 truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
263 )
264 })
265 .collect()
266}
267
268fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
269 let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
270 let mut hasher = Sha256::new();
271 hasher.update(json.as_bytes());
272 Ok(hex::encode(hasher.finalize()))
273}
274
275fn substitute_all_template_variables_in_str<A: AsRef<str>>(
276 template_str: &str,
277 task_variables: &HashMap<String, A>,
278 variable_names: &HashMap<String, VariableName>,
279 substituted_variables: &mut HashSet<VariableName>,
280) -> Option<String> {
281 let substituted_string = shellexpand::env_with_context(template_str, |var| {
282 // 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.
283 let colon_position = var.find(':').unwrap_or(var.len());
284 let (variable_name, default) = var.split_at(colon_position);
285 if let Some(name) = task_variables.get(variable_name) {
286 if let Some(substituted_variable) = variable_names.get(variable_name) {
287 substituted_variables.insert(substituted_variable.clone());
288 }
289
290 let mut name = name.as_ref().to_owned();
291 // Got a task variable hit
292 if !default.is_empty() {
293 name.push_str(default);
294 }
295 return Ok(Some(name));
296 } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
297 bail!("Unknown variable name: {variable_name}");
298 }
299 // This is an unknown variable.
300 // 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.
301 // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
302 if !default.is_empty() {
303 return Ok(Some(format!("${{{var}}}")));
304 }
305 // Else we can just return None and that variable will be left as is.
306 Ok(None)
307 })
308 .ok()?;
309 Some(substituted_string.into_owned())
310}
311
312fn substitute_all_template_variables_in_vec(
313 template_strs: &[String],
314 task_variables: &HashMap<String, &str>,
315 variable_names: &HashMap<String, VariableName>,
316 substituted_variables: &mut HashSet<VariableName>,
317) -> Option<Vec<String>> {
318 let mut expanded = Vec::with_capacity(template_strs.len());
319 for variable in template_strs {
320 let new_value = substitute_all_template_variables_in_str(
321 variable,
322 task_variables,
323 variable_names,
324 substituted_variables,
325 )?;
326 expanded.push(new_value);
327 }
328 Some(expanded)
329}
330
331fn substitute_all_template_variables_in_map(
332 keys_and_values: &HashMap<String, String>,
333 task_variables: &HashMap<String, &str>,
334 variable_names: &HashMap<String, VariableName>,
335 substituted_variables: &mut HashSet<VariableName>,
336) -> Option<HashMap<String, String>> {
337 let mut new_map: HashMap<String, String> = Default::default();
338 for (key, value) in keys_and_values {
339 let new_value = substitute_all_template_variables_in_str(
340 value,
341 task_variables,
342 variable_names,
343 substituted_variables,
344 )?;
345 let new_key = substitute_all_template_variables_in_str(
346 key,
347 task_variables,
348 variable_names,
349 substituted_variables,
350 )?;
351 new_map.insert(new_key, new_value);
352 }
353 Some(new_map)
354}
355
356#[cfg(test)]
357mod tests {
358 use std::{borrow::Cow, path::Path};
359
360 use crate::{TaskVariables, VariableName};
361
362 use super::*;
363
364 const TEST_ID_BASE: &str = "test_base";
365
366 #[test]
367 fn test_resolving_templates_with_blank_command_and_label() {
368 let task_with_all_properties = TaskTemplate {
369 label: "test_label".to_string(),
370 command: "test_command".to_string(),
371 args: vec!["test_arg".to_string()],
372 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
373 ..TaskTemplate::default()
374 };
375
376 for task_with_blank_property in &[
377 TaskTemplate {
378 label: "".to_string(),
379 ..task_with_all_properties.clone()
380 },
381 TaskTemplate {
382 command: "".to_string(),
383 ..task_with_all_properties.clone()
384 },
385 TaskTemplate {
386 label: "".to_string(),
387 command: "".to_string(),
388 ..task_with_all_properties.clone()
389 },
390 ] {
391 assert_eq!(
392 task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
393 None,
394 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
395 );
396 }
397 }
398
399 #[test]
400 fn test_template_cwd_resolution() {
401 let task_without_cwd = TaskTemplate {
402 cwd: None,
403 label: "test task".to_string(),
404 command: "echo 4".to_string(),
405 ..TaskTemplate::default()
406 };
407
408 let resolved_task = |task_template: &TaskTemplate, task_cx| {
409 let resolved_task = task_template
410 .resolve_task(TEST_ID_BASE, task_cx)
411 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
412 assert_substituted_variables(&resolved_task, Vec::new());
413 resolved_task
414 .resolved
415 .clone()
416 .unwrap_or_else(|| {
417 panic!("failed to get resolve data for resolved task. Template: {task_without_cwd:?} Resolved: {resolved_task:?}")
418 })
419 };
420
421 let cx = TaskContext {
422 cwd: None,
423 task_variables: TaskVariables::default(),
424 project_env: HashMap::default(),
425 };
426 assert_eq!(
427 resolved_task(&task_without_cwd, &cx).cwd,
428 None,
429 "When neither task nor task context have cwd, it should be None"
430 );
431
432 let context_cwd = Path::new("a").join("b").join("c");
433 let cx = TaskContext {
434 cwd: Some(context_cwd.clone()),
435 task_variables: TaskVariables::default(),
436 project_env: HashMap::default(),
437 };
438 assert_eq!(
439 resolved_task(&task_without_cwd, &cx).cwd,
440 Some(context_cwd.clone()),
441 "TaskContext's cwd should be taken on resolve if task's cwd is None"
442 );
443
444 let task_cwd = Path::new("d").join("e").join("f");
445 let mut task_with_cwd = task_without_cwd.clone();
446 task_with_cwd.cwd = Some(task_cwd.display().to_string());
447 let task_with_cwd = task_with_cwd;
448
449 let cx = TaskContext {
450 cwd: None,
451 task_variables: TaskVariables::default(),
452 project_env: HashMap::default(),
453 };
454 assert_eq!(
455 resolved_task(&task_with_cwd, &cx).cwd,
456 Some(task_cwd.clone()),
457 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
458 );
459
460 let cx = TaskContext {
461 cwd: Some(context_cwd.clone()),
462 task_variables: TaskVariables::default(),
463 project_env: HashMap::default(),
464 };
465 assert_eq!(
466 resolved_task(&task_with_cwd, &cx).cwd,
467 Some(task_cwd),
468 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
469 );
470 }
471
472 #[test]
473 fn test_template_variables_resolution() {
474 let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
475 let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
476 let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
477 let all_variables = [
478 (VariableName::Row, "1234".to_string()),
479 (VariableName::Column, "5678".to_string()),
480 (VariableName::File, "test_file".to_string()),
481 (VariableName::SelectedText, "test_selected_text".to_string()),
482 (VariableName::Symbol, long_value.clone()),
483 (VariableName::WorktreeRoot, "/test_root/".to_string()),
484 (
485 custom_variable_1.clone(),
486 "test_custom_variable_1".to_string(),
487 ),
488 (
489 custom_variable_2.clone(),
490 "test_custom_variable_2".to_string(),
491 ),
492 ];
493
494 let task_with_all_variables = TaskTemplate {
495 label: format!(
496 "test label for {} and {}",
497 VariableName::Row.template_value(),
498 VariableName::Symbol.template_value(),
499 ),
500 command: format!(
501 "echo {} {}",
502 VariableName::File.template_value(),
503 VariableName::Symbol.template_value(),
504 ),
505 args: vec![
506 format!("arg1 {}", VariableName::SelectedText.template_value()),
507 format!("arg2 {}", VariableName::Column.template_value()),
508 format!("arg3 {}", VariableName::Symbol.template_value()),
509 ],
510 env: HashMap::from_iter([
511 ("test_env_key".to_string(), "test_env_var".to_string()),
512 (
513 "env_key_1".to_string(),
514 VariableName::WorktreeRoot.template_value(),
515 ),
516 (
517 "env_key_2".to_string(),
518 format!(
519 "env_var_2 {} {}",
520 custom_variable_1.template_value(),
521 custom_variable_2.template_value()
522 ),
523 ),
524 (
525 "env_key_3".to_string(),
526 format!("env_var_3 {}", VariableName::Symbol.template_value()),
527 ),
528 ]),
529 ..TaskTemplate::default()
530 };
531
532 let mut first_resolved_id = None;
533 for i in 0..15 {
534 let resolved_task = task_with_all_variables.resolve_task(
535 TEST_ID_BASE,
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[long_value.len() - 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 &TaskContext {
625 cwd: None,
626 task_variables: TaskVariables::from_iter(not_all_variables),
627 project_env: HashMap::default(),
628 },
629 );
630 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})");
631 }
632 }
633
634 #[test]
635 fn test_can_resolve_free_variables() {
636 let task = TaskTemplate {
637 label: "My task".into(),
638 command: "echo".into(),
639 args: vec!["$PATH".into()],
640 ..TaskTemplate::default()
641 };
642 let resolved_task = task
643 .resolve_task(TEST_ID_BASE, &TaskContext::default())
644 .unwrap();
645 assert_substituted_variables(&resolved_task, Vec::new());
646 let resolved = resolved_task.resolved.unwrap();
647 assert_eq!(resolved.label, task.label);
648 assert_eq!(resolved.command, task.command);
649 assert_eq!(resolved.args, task.args);
650 }
651
652 #[test]
653 fn test_errors_on_missing_zed_variable() {
654 let task = TaskTemplate {
655 label: "My task".into(),
656 command: "echo".into(),
657 args: vec!["$ZED_VARIABLE".into()],
658 ..TaskTemplate::default()
659 };
660 assert!(task
661 .resolve_task(TEST_ID_BASE, &TaskContext::default())
662 .is_none());
663 }
664
665 #[test]
666 fn test_symbol_dependent_tasks() {
667 let task_with_all_properties = TaskTemplate {
668 label: "test_label".to_string(),
669 command: "test_command".to_string(),
670 args: vec!["test_arg".to_string()],
671 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
672 ..TaskTemplate::default()
673 };
674 let cx = TaskContext {
675 cwd: None,
676 task_variables: TaskVariables::from_iter(Some((
677 VariableName::Symbol,
678 "test_symbol".to_string(),
679 ))),
680 project_env: HashMap::default(),
681 };
682
683 for (i, symbol_dependent_task) in [
684 TaskTemplate {
685 label: format!("test_label_{}", VariableName::Symbol.template_value()),
686 ..task_with_all_properties.clone()
687 },
688 TaskTemplate {
689 command: format!("test_command_{}", VariableName::Symbol.template_value()),
690 ..task_with_all_properties.clone()
691 },
692 TaskTemplate {
693 args: vec![format!(
694 "test_arg_{}",
695 VariableName::Symbol.template_value()
696 )],
697 ..task_with_all_properties.clone()
698 },
699 TaskTemplate {
700 env: HashMap::from_iter([(
701 "test_env_key".to_string(),
702 format!("test_env_var_{}", VariableName::Symbol.template_value()),
703 )]),
704 ..task_with_all_properties.clone()
705 },
706 ]
707 .into_iter()
708 .enumerate()
709 {
710 let resolved = symbol_dependent_task
711 .resolve_task(TEST_ID_BASE, &cx)
712 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
713 assert_eq!(
714 resolved.substituted_variables,
715 HashSet::from_iter(Some(VariableName::Symbol)),
716 "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
717 )
718 }
719 }
720
721 #[track_caller]
722 fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
723 let mut resolved_variables = resolved_task
724 .substituted_variables
725 .iter()
726 .cloned()
727 .collect::<Vec<_>>();
728 resolved_variables.sort_by_key(|var| var.to_string());
729 expected.sort_by_key(|var| var.to_string());
730 assert_eq!(resolved_variables, expected)
731 }
732
733 #[test]
734 fn substitute_funky_labels() {
735 let faulty_go_test = TaskTemplate {
736 label: format!(
737 "go test {}/{}",
738 VariableName::Symbol.template_value(),
739 VariableName::Symbol.template_value(),
740 ),
741 command: "go".into(),
742 args: vec![format!(
743 "^{}$/^{}$",
744 VariableName::Symbol.template_value(),
745 VariableName::Symbol.template_value()
746 )],
747 ..TaskTemplate::default()
748 };
749 let mut context = TaskContext::default();
750 context
751 .task_variables
752 .insert(VariableName::Symbol, "my-symbol".to_string());
753 assert!(faulty_go_test.resolve_task("base", &context).is_some());
754 }
755
756 #[test]
757 fn test_project_env() {
758 let all_variables = [
759 (VariableName::Row, "1234".to_string()),
760 (VariableName::Column, "5678".to_string()),
761 (VariableName::File, "test_file".to_string()),
762 (VariableName::Symbol, "my symbol".to_string()),
763 ];
764
765 let template = TaskTemplate {
766 label: "my task".to_string(),
767 command: format!(
768 "echo {} {}",
769 VariableName::File.template_value(),
770 VariableName::Symbol.template_value(),
771 ),
772 args: vec![],
773 env: HashMap::from_iter([
774 (
775 "TASK_ENV_VAR1".to_string(),
776 "TASK_ENV_VAR1_VALUE".to_string(),
777 ),
778 (
779 "TASK_ENV_VAR2".to_string(),
780 format!(
781 "env_var_2 {} {}",
782 VariableName::Row.template_value(),
783 VariableName::Column.template_value()
784 ),
785 ),
786 (
787 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
788 "overwritten".to_string(),
789 ),
790 ]),
791 ..TaskTemplate::default()
792 };
793
794 let project_env = HashMap::from_iter([
795 (
796 "PROJECT_ENV_VAR1".to_string(),
797 "PROJECT_ENV_VAR1_VALUE".to_string(),
798 ),
799 (
800 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
801 "PROJECT_ENV_WILL_BE_OVERWRITTEN_VALUE".to_string(),
802 ),
803 ]);
804
805 let context = TaskContext {
806 cwd: None,
807 task_variables: TaskVariables::from_iter(all_variables.clone()),
808 project_env,
809 };
810
811 let resolved = template
812 .resolve_task(TEST_ID_BASE, &context)
813 .unwrap()
814 .resolved
815 .unwrap();
816
817 assert_eq!(resolved.env["TASK_ENV_VAR1"], "TASK_ENV_VAR1_VALUE");
818 assert_eq!(resolved.env["TASK_ENV_VAR2"], "env_var_2 1234 5678");
819 assert_eq!(resolved.env["PROJECT_ENV_VAR1"], "PROJECT_ENV_VAR1_VALUE");
820 assert_eq!(
821 resolved.env["PROJECT_ENV_WILL_BE_OVERWRITTEN"],
822 "overwritten"
823 );
824 }
825}