task_template.rs

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