task_template.rs

  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}