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