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::ShellBuilder;
12use crate::{
13 AttachRequest, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TaskContext, TaskId,
14 VariableName, ZED_VARIABLE_NAME_PREFIX, serde_helpers::non_empty_string_vec,
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(length(min = 1))]
66 pub tags: Vec<String>,
67 /// Which shell to use when spawning the task.
68 #[serde(default)]
69 pub shell: Option<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::Value {
120 let schema = schemars::generate::SchemaSettings::draft2019_09()
121 .with_transform(DefaultDenyUnknownFields)
122 .into_generator()
123 .root_schema_for::<Self>();
124
125 serde_json::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(
137 &self,
138 id_base: &str,
139 remote_shell: &dyn Fn() -> Option<String>,
140 cx: &TaskContext,
141 ) -> 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 let (shell, command, args, command_label) = match &self.shell {
251 // shell is specified, command + args may be a shell script so we force wrap it in another a shell execution
252 Some(shell) => {
253 let shell = if let Shell::System = shell
254 && let Some(remote_shell) = remote_shell()
255 {
256 Shell::Program(remote_shell)
257 } else {
258 shell.clone()
259 };
260
261 let builder = ShellBuilder::new(&shell, cx.is_windows);
262 let command_label = builder.command_label(&command);
263 let (command, args) = builder.build(Some(command), &args_with_substitutions);
264
265 (shell, command, args, command_label)
266 }
267 // no shell specified but command contains whitespace, might be a shell script so spawn a shell for backwards compat
268 None if command.contains(char::is_whitespace) => {
269 let shell = match remote_shell() {
270 Some(remote_shell) => Shell::Program(remote_shell),
271 None => Shell::System,
272 };
273
274 let builder = ShellBuilder::new(&shell, cx.is_windows);
275 let command_label = builder.command_label(&command);
276 let (command, args) = builder.build(Some(command), &args_with_substitutions);
277
278 (shell, command, args, command_label)
279 }
280 // no shell specified, interpret as direct process spawn
281 None => {
282 let command_label = args_with_substitutions.iter().fold(
283 command.clone(),
284 |mut command_label, arg| {
285 command_label.push(' ');
286 command_label.push_str(arg);
287 command_label
288 },
289 );
290 (
291 Shell::System,
292 command,
293 args_with_substitutions,
294 command_label,
295 )
296 }
297 };
298
299 Some(ResolvedTask {
300 id: id.clone(),
301 substituted_variables,
302 original_task: self.clone(),
303 resolved_label: full_label.clone(),
304 resolved: SpawnInTerminal {
305 id,
306 cwd,
307 full_label,
308 label: human_readable_label,
309 command_label,
310 command: Some(command),
311 args: args,
312 env,
313 use_new_terminal: self.use_new_terminal,
314 allow_concurrent_runs: self.allow_concurrent_runs,
315 reveal: self.reveal,
316 reveal_target: self.reveal_target,
317 hide: self.hide,
318 shell,
319 show_summary: self.show_summary,
320 show_command: self.show_command,
321 show_rerun: true,
322 },
323 })
324 }
325}
326
327const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
328
329fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
330 task_variables
331 .iter()
332 .map(|(key, value)| {
333 (
334 key.clone(),
335 truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
336 )
337 })
338 .collect()
339}
340
341fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
342 let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
343 let mut hasher = Sha256::new();
344 hasher.update(json.as_bytes());
345 Ok(hex::encode(hasher.finalize()))
346}
347
348pub fn substitute_variables_in_str(template_str: &str, context: &TaskContext) -> Option<String> {
349 let mut variable_names = HashMap::default();
350 let mut substituted_variables = HashSet::default();
351 let task_variables = context
352 .task_variables
353 .0
354 .iter()
355 .map(|(key, value)| {
356 let key_string = key.to_string();
357 if !variable_names.contains_key(&key_string) {
358 variable_names.insert(key_string.clone(), key.clone());
359 }
360 (key_string, value.as_str())
361 })
362 .collect::<HashMap<_, _>>();
363 substitute_all_template_variables_in_str(
364 template_str,
365 &task_variables,
366 &variable_names,
367 &mut substituted_variables,
368 )
369}
370fn substitute_all_template_variables_in_str<A: AsRef<str>>(
371 template_str: &str,
372 task_variables: &HashMap<String, A>,
373 variable_names: &HashMap<String, VariableName>,
374 substituted_variables: &mut HashSet<VariableName>,
375) -> Option<String> {
376 let substituted_string = shellexpand::env_with_context(template_str, |var| {
377 // 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.
378 let colon_position = var.find(':').unwrap_or(var.len());
379 let (variable_name, default) = var.split_at(colon_position);
380 if let Some(name) = task_variables.get(variable_name) {
381 if let Some(substituted_variable) = variable_names.get(variable_name) {
382 substituted_variables.insert(substituted_variable.clone());
383 }
384 // Got a task variable hit - use the variable value, ignore default
385 return Ok(Some(name.as_ref().to_owned()));
386 } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
387 // Unknown ZED variable - use default if available
388 if !default.is_empty() {
389 // Strip the colon and return the default value
390 return Ok(Some(default[1..].to_owned()));
391 } else {
392 bail!("Unknown variable name: {variable_name}");
393 }
394 }
395 // This is an unknown variable.
396 // 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.
397 // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
398 if !default.is_empty() {
399 return Ok(Some(format!("${{{var}}}")));
400 }
401 // Else we can just return None and that variable will be left as is.
402 Ok(None)
403 })
404 .ok()?;
405 Some(substituted_string.into_owned())
406}
407
408fn substitute_all_template_variables_in_vec(
409 template_strs: &[String],
410 task_variables: &HashMap<String, &str>,
411 variable_names: &HashMap<String, VariableName>,
412 substituted_variables: &mut HashSet<VariableName>,
413) -> Option<Vec<String>> {
414 let mut expanded = Vec::with_capacity(template_strs.len());
415 for variable in template_strs {
416 let new_value = substitute_all_template_variables_in_str(
417 variable,
418 task_variables,
419 variable_names,
420 substituted_variables,
421 )?;
422 expanded.push(new_value);
423 }
424 Some(expanded)
425}
426
427pub fn substitute_variables_in_map(
428 keys_and_values: &HashMap<String, String>,
429 context: &TaskContext,
430) -> Option<HashMap<String, String>> {
431 let mut variable_names = HashMap::default();
432 let mut substituted_variables = HashSet::default();
433 let task_variables = context
434 .task_variables
435 .0
436 .iter()
437 .map(|(key, value)| {
438 let key_string = key.to_string();
439 if !variable_names.contains_key(&key_string) {
440 variable_names.insert(key_string.clone(), key.clone());
441 }
442 (key_string, value.as_str())
443 })
444 .collect::<HashMap<_, _>>();
445 substitute_all_template_variables_in_map(
446 keys_and_values,
447 &task_variables,
448 &variable_names,
449 &mut substituted_variables,
450 )
451}
452fn substitute_all_template_variables_in_map(
453 keys_and_values: &HashMap<String, String>,
454 task_variables: &HashMap<String, &str>,
455 variable_names: &HashMap<String, VariableName>,
456 substituted_variables: &mut HashSet<VariableName>,
457) -> Option<HashMap<String, String>> {
458 let mut new_map: HashMap<String, String> = Default::default();
459 for (key, value) in keys_and_values {
460 let new_value = substitute_all_template_variables_in_str(
461 value,
462 task_variables,
463 variable_names,
464 substituted_variables,
465 )?;
466 let new_key = substitute_all_template_variables_in_str(
467 key,
468 task_variables,
469 variable_names,
470 substituted_variables,
471 )?;
472 new_map.insert(new_key, new_value);
473 }
474 Some(new_map)
475}
476
477#[cfg(test)]
478mod tests {
479 use std::{borrow::Cow, path::Path};
480
481 use crate::{TaskVariables, VariableName};
482
483 use super::*;
484
485 const TEST_ID_BASE: &str = "test_base";
486
487 #[test]
488 fn test_resolving_templates_with_blank_command_and_label() {
489 let task_with_all_properties = TaskTemplate {
490 label: "test_label".to_string(),
491 command: "test_command".to_string(),
492 args: vec!["test_arg".to_string()],
493 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
494 ..TaskTemplate::default()
495 };
496
497 for task_with_blank_property in &[
498 TaskTemplate {
499 label: "".to_string(),
500 ..task_with_all_properties.clone()
501 },
502 TaskTemplate {
503 command: "".to_string(),
504 ..task_with_all_properties.clone()
505 },
506 TaskTemplate {
507 label: "".to_string(),
508 command: "".to_string(),
509 ..task_with_all_properties
510 },
511 ] {
512 assert_eq!(
513 task_with_blank_property.resolve_task(
514 TEST_ID_BASE,
515 &|| None,
516 &TaskContext::default()
517 ),
518 None,
519 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
520 );
521 }
522 }
523
524 #[test]
525 fn test_template_cwd_resolution() {
526 let task_without_cwd = TaskTemplate {
527 cwd: None,
528 label: "test task".to_string(),
529 command: "echo 4".to_string(),
530 ..TaskTemplate::default()
531 };
532
533 let resolved_task = |task_template: &TaskTemplate, task_cx| {
534 let resolved_task = task_template
535 .resolve_task(TEST_ID_BASE, &|| None, task_cx)
536 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
537 assert_substituted_variables(&resolved_task, Vec::new());
538 resolved_task.resolved
539 };
540
541 let cx = TaskContext {
542 cwd: None,
543 task_variables: TaskVariables::default(),
544 project_env: HashMap::default(),
545 is_windows: cfg!(windows),
546 };
547 assert_eq!(
548 resolved_task(&task_without_cwd, &cx).cwd,
549 None,
550 "When neither task nor task context have cwd, it should be None"
551 );
552
553 let context_cwd = Path::new("a").join("b").join("c");
554 let cx = TaskContext {
555 cwd: Some(context_cwd.clone()),
556 task_variables: TaskVariables::default(),
557 project_env: HashMap::default(),
558 is_windows: cfg!(windows),
559 };
560 assert_eq!(
561 resolved_task(&task_without_cwd, &cx).cwd,
562 Some(context_cwd.clone()),
563 "TaskContext's cwd should be taken on resolve if task's cwd is None"
564 );
565
566 let task_cwd = Path::new("d").join("e").join("f");
567 let mut task_with_cwd = task_without_cwd.clone();
568 task_with_cwd.cwd = Some(task_cwd.display().to_string());
569 let task_with_cwd = task_with_cwd;
570
571 let cx = TaskContext {
572 cwd: None,
573 task_variables: TaskVariables::default(),
574 project_env: HashMap::default(),
575 is_windows: cfg!(windows),
576 };
577 assert_eq!(
578 resolved_task(&task_with_cwd, &cx).cwd,
579 Some(task_cwd.clone()),
580 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
581 );
582
583 let cx = TaskContext {
584 cwd: Some(context_cwd),
585 task_variables: TaskVariables::default(),
586 project_env: HashMap::default(),
587 is_windows: cfg!(windows),
588 };
589 assert_eq!(
590 resolved_task(&task_with_cwd, &cx).cwd,
591 Some(task_cwd),
592 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
593 );
594 }
595
596 #[test]
597 fn test_template_variables_resolution() {
598 let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
599 let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
600 let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
601 let all_variables = [
602 (VariableName::Row, "1234".to_string()),
603 (VariableName::Column, "5678".to_string()),
604 (VariableName::File, "test_file".to_string()),
605 (VariableName::SelectedText, "test_selected_text".to_string()),
606 (VariableName::Symbol, long_value.clone()),
607 (VariableName::WorktreeRoot, "/test_root/".to_string()),
608 (
609 custom_variable_1.clone(),
610 "test_custom_variable_1".to_string(),
611 ),
612 (
613 custom_variable_2.clone(),
614 "test_custom_variable_2".to_string(),
615 ),
616 ];
617
618 let task_with_all_variables = TaskTemplate {
619 label: format!(
620 "test label for {} and {}",
621 VariableName::Row.template_value(),
622 VariableName::Symbol.template_value(),
623 ),
624 command: format!(
625 "echo {} {}",
626 VariableName::File.template_value(),
627 VariableName::Symbol.template_value(),
628 ),
629 args: vec![
630 format!("arg1 {}", VariableName::SelectedText.template_value()),
631 format!("arg2 {}", VariableName::Column.template_value()),
632 format!("arg3 {}", VariableName::Symbol.template_value()),
633 ],
634 env: HashMap::from_iter([
635 ("test_env_key".to_string(), "test_env_var".to_string()),
636 (
637 "env_key_1".to_string(),
638 VariableName::WorktreeRoot.template_value(),
639 ),
640 (
641 "env_key_2".to_string(),
642 format!(
643 "env_var_2 {} {}",
644 custom_variable_1.template_value(),
645 custom_variable_2.template_value()
646 ),
647 ),
648 (
649 "env_key_3".to_string(),
650 format!("env_var_3 {}", VariableName::Symbol.template_value()),
651 ),
652 ]),
653 ..TaskTemplate::default()
654 };
655
656 let mut first_resolved_id = None;
657 for i in 0..15 {
658 let resolved_task = task_with_all_variables.resolve_task(
659 TEST_ID_BASE,
660 &|| None, &TaskContext {
661 cwd: None,
662 task_variables: TaskVariables::from_iter(all_variables.clone()),
663 project_env: HashMap::default(),
664 is_windows: cfg!(windows),
665 },
666 ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
667
668 match &first_resolved_id {
669 None => first_resolved_id = Some(resolved_task.id.clone()),
670 Some(first_id) => assert_eq!(
671 &resolved_task.id, first_id,
672 "Step {i}, for the same task template and context, there should be the same resolved task id"
673 ),
674 }
675
676 assert_eq!(
677 resolved_task.original_task, task_with_all_variables,
678 "Resolved task should store its template without changes"
679 );
680 assert_eq!(
681 resolved_task.resolved_label,
682 format!("test label for 1234 and {long_value}"),
683 "Resolved task label should be substituted with variables and those should not be shortened"
684 );
685 assert_substituted_variables(
686 &resolved_task,
687 all_variables.iter().map(|(name, _)| name.clone()).collect(),
688 );
689
690 let spawn_in_terminal = &resolved_task.resolved;
691 assert_eq!(
692 spawn_in_terminal.label,
693 format!(
694 "test label for 1234 and …{}",
695 &long_value[long_value.len() - MAX_DISPLAY_VARIABLE_LENGTH..]
696 ),
697 "Human-readable label should have long substitutions trimmed"
698 );
699 assert_eq!(
700 spawn_in_terminal.command.clone().unwrap(),
701 format!("echo test_file {long_value}"),
702 "Command should be substituted with variables and those should not be shortened"
703 );
704 assert_eq!(
705 spawn_in_terminal.args,
706 &[
707 "arg1 test_selected_text",
708 "arg2 5678",
709 "arg3 010101010101010101010101010101010101010101010101010101010101",
710 ],
711 "Args should be substituted with variables"
712 );
713 assert_eq!(
714 spawn_in_terminal.command_label,
715 format!(
716 "{} arg1 test_selected_text arg2 5678 arg3 {long_value}",
717 spawn_in_terminal.command.clone().unwrap()
718 ),
719 "Command label args should be substituted with variables and those should not be shortened"
720 );
721
722 assert_eq!(
723 spawn_in_terminal
724 .env
725 .get("test_env_key")
726 .map(|s| s.as_str()),
727 Some("test_env_var")
728 );
729 assert_eq!(
730 spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
731 Some("/test_root/")
732 );
733 assert_eq!(
734 spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
735 Some("env_var_2 test_custom_variable_1 test_custom_variable_2")
736 );
737 assert_eq!(
738 spawn_in_terminal.env.get("env_key_3"),
739 Some(&format!("env_var_3 {long_value}")),
740 "Env vars should be substituted with variables and those should not be shortened"
741 );
742 }
743
744 for i in 0..all_variables.len() {
745 let mut not_all_variables = all_variables.to_vec();
746 let removed_variable = not_all_variables.remove(i);
747 let resolved_task_attempt = task_with_all_variables.resolve_task(
748 TEST_ID_BASE,
749 &|| None,
750 &TaskContext {
751 cwd: None,
752 task_variables: TaskVariables::from_iter(not_all_variables),
753 project_env: HashMap::default(),
754 is_windows: cfg!(windows),
755 },
756 );
757 assert_eq!(
758 resolved_task_attempt, None,
759 "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})"
760 );
761 }
762 }
763
764 #[test]
765 fn test_can_resolve_free_variables() {
766 let task = TaskTemplate {
767 label: "My task".into(),
768 command: "echo".into(),
769 args: vec!["$PATH".into()],
770 ..TaskTemplate::default()
771 };
772 let resolved_task = task
773 .resolve_task(TEST_ID_BASE, &|| None, &TaskContext::default())
774 .unwrap();
775 assert_substituted_variables(&resolved_task, Vec::new());
776 let resolved = resolved_task.resolved;
777 assert_eq!(resolved.label, task.label);
778 assert_eq!(resolved.command, Some(task.command));
779 assert_eq!(resolved.args, task.args);
780 }
781
782 #[test]
783 fn test_errors_on_missing_zed_variable() {
784 let task = TaskTemplate {
785 label: "My task".into(),
786 command: "echo".into(),
787 args: vec!["$ZED_VARIABLE".into()],
788 ..TaskTemplate::default()
789 };
790 assert!(
791 task.resolve_task(TEST_ID_BASE, &|| None, &TaskContext::default())
792 .is_none()
793 );
794 }
795
796 #[test]
797 fn test_symbol_dependent_tasks() {
798 let task_with_all_properties = TaskTemplate {
799 label: "test_label".to_string(),
800 command: "test_command".to_string(),
801 args: vec!["test_arg".to_string()],
802 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
803 ..TaskTemplate::default()
804 };
805 let cx = TaskContext {
806 cwd: None,
807 task_variables: TaskVariables::from_iter(Some((
808 VariableName::Symbol,
809 "test_symbol".to_string(),
810 ))),
811 project_env: HashMap::default(),
812 is_windows: cfg!(windows),
813 };
814
815 for (i, symbol_dependent_task) in [
816 TaskTemplate {
817 label: format!("test_label_{}", VariableName::Symbol.template_value()),
818 ..task_with_all_properties.clone()
819 },
820 TaskTemplate {
821 command: format!("test_command_{}", VariableName::Symbol.template_value()),
822 ..task_with_all_properties.clone()
823 },
824 TaskTemplate {
825 args: vec![format!(
826 "test_arg_{}",
827 VariableName::Symbol.template_value()
828 )],
829 ..task_with_all_properties.clone()
830 },
831 TaskTemplate {
832 env: HashMap::from_iter([(
833 "test_env_key".to_string(),
834 format!("test_env_var_{}", VariableName::Symbol.template_value()),
835 )]),
836 ..task_with_all_properties
837 },
838 ]
839 .into_iter()
840 .enumerate()
841 {
842 let resolved = symbol_dependent_task
843 .resolve_task(TEST_ID_BASE, &|| None, &cx)
844 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
845 assert_eq!(
846 resolved.substituted_variables,
847 HashSet::from_iter(Some(VariableName::Symbol)),
848 "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
849 )
850 }
851 }
852
853 #[track_caller]
854 fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
855 let mut resolved_variables = resolved_task
856 .substituted_variables
857 .iter()
858 .cloned()
859 .collect::<Vec<_>>();
860 resolved_variables.sort_by_key(|var| var.to_string());
861 expected.sort_by_key(|var| var.to_string());
862 assert_eq!(resolved_variables, expected)
863 }
864
865 #[test]
866 fn substitute_funky_labels() {
867 let faulty_go_test = TaskTemplate {
868 label: format!(
869 "go test {}/{}",
870 VariableName::Symbol.template_value(),
871 VariableName::Symbol.template_value(),
872 ),
873 command: "go".into(),
874 args: vec![format!(
875 "^{}$/^{}$",
876 VariableName::Symbol.template_value(),
877 VariableName::Symbol.template_value()
878 )],
879 ..TaskTemplate::default()
880 };
881 let mut context = TaskContext::default();
882 context
883 .task_variables
884 .insert(VariableName::Symbol, "my-symbol".to_string());
885 assert!(
886 faulty_go_test
887 .resolve_task("base", &|| None, &context)
888 .is_some()
889 );
890 }
891
892 #[test]
893 fn test_project_env() {
894 let all_variables = [
895 (VariableName::Row, "1234".to_string()),
896 (VariableName::Column, "5678".to_string()),
897 (VariableName::File, "test_file".to_string()),
898 (VariableName::Symbol, "my symbol".to_string()),
899 ];
900
901 let template = TaskTemplate {
902 label: "my task".to_string(),
903 command: format!(
904 "echo {} {}",
905 VariableName::File.template_value(),
906 VariableName::Symbol.template_value(),
907 ),
908 args: vec![],
909 env: HashMap::from_iter([
910 (
911 "TASK_ENV_VAR1".to_string(),
912 "TASK_ENV_VAR1_VALUE".to_string(),
913 ),
914 (
915 "TASK_ENV_VAR2".to_string(),
916 format!(
917 "env_var_2 {} {}",
918 VariableName::Row.template_value(),
919 VariableName::Column.template_value()
920 ),
921 ),
922 (
923 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
924 "overwritten".to_string(),
925 ),
926 ]),
927 ..TaskTemplate::default()
928 };
929
930 let project_env = HashMap::from_iter([
931 (
932 "PROJECT_ENV_VAR1".to_string(),
933 "PROJECT_ENV_VAR1_VALUE".to_string(),
934 ),
935 (
936 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
937 "PROJECT_ENV_WILL_BE_OVERWRITTEN_VALUE".to_string(),
938 ),
939 ]);
940
941 let context = TaskContext {
942 cwd: None,
943 task_variables: TaskVariables::from_iter(all_variables),
944 project_env,
945 is_windows: cfg!(windows),
946 };
947
948 let resolved = template
949 .resolve_task(TEST_ID_BASE, &|| None, &context)
950 .unwrap()
951 .resolved;
952
953 assert_eq!(resolved.env["TASK_ENV_VAR1"], "TASK_ENV_VAR1_VALUE");
954 assert_eq!(resolved.env["TASK_ENV_VAR2"], "env_var_2 1234 5678");
955 assert_eq!(resolved.env["PROJECT_ENV_VAR1"], "PROJECT_ENV_VAR1_VALUE");
956 assert_eq!(
957 resolved.env["PROJECT_ENV_WILL_BE_OVERWRITTEN"],
958 "overwritten"
959 );
960 }
961
962 #[test]
963 fn test_variable_default_values() {
964 let task_with_defaults = TaskTemplate {
965 label: "test with defaults".to_string(),
966 command: format!(
967 "echo ${{{}}}",
968 VariableName::File.to_string() + ":fallback.txt"
969 ),
970 args: vec![
971 "${ZED_MISSING_VAR:default_value}".to_string(),
972 format!("${{{}}}", VariableName::Row.to_string() + ":42"),
973 ],
974 ..TaskTemplate::default()
975 };
976
977 // Test 1: When ZED_FILE exists, should use actual value and ignore default
978 let context_with_file = TaskContext {
979 cwd: None,
980 task_variables: TaskVariables::from_iter(vec![
981 (VariableName::File, "actual_file.rs".to_string()),
982 (VariableName::Row, "123".to_string()),
983 ]),
984 project_env: HashMap::default(),
985 is_windows: cfg!(windows),
986 };
987
988 let resolved = task_with_defaults
989 .resolve_task(TEST_ID_BASE, &|| None, &context_with_file)
990 .expect("Should resolve task with existing variables");
991
992 assert_eq!(
993 resolved.resolved.command.unwrap(),
994 "echo actual_file.rs",
995 "Should use actual ZED_FILE value, not default"
996 );
997 assert_eq!(
998 resolved.resolved.args,
999 vec!["default_value", "123"],
1000 "Should use default for missing var, actual value for existing var"
1001 );
1002
1003 // Test 2: When ZED_FILE doesn't exist, should use default value
1004 let context_without_file = TaskContext {
1005 cwd: None,
1006 task_variables: TaskVariables::from_iter(vec![(VariableName::Row, "456".to_string())]),
1007 project_env: HashMap::default(),
1008 is_windows: cfg!(windows),
1009 };
1010
1011 let resolved = task_with_defaults
1012 .resolve_task(TEST_ID_BASE, &|| None, &context_without_file)
1013 .expect("Should resolve task using default values");
1014
1015 assert_eq!(
1016 resolved.resolved.command.unwrap(),
1017 "echo fallback.txt",
1018 "Should use default value when ZED_FILE is missing"
1019 );
1020 assert_eq!(
1021 resolved.resolved.args,
1022 vec!["default_value", "456"],
1023 "Should use defaults for missing vars"
1024 );
1025
1026 // Test 3: Missing ZED variable without default should fail
1027 let task_no_default = TaskTemplate {
1028 label: "test no default".to_string(),
1029 command: "${ZED_MISSING_NO_DEFAULT}".to_string(),
1030 ..TaskTemplate::default()
1031 };
1032
1033 assert!(
1034 task_no_default
1035 .resolve_task(TEST_ID_BASE, &|| None, &TaskContext::default())
1036 .is_none(),
1037 "Should fail when ZED variable has no default and doesn't exist"
1038 );
1039 }
1040}