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