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