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