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