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