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