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