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        let mut env = substitute_all_template_variables_in_map(
188            &self.env,
189            &task_variables,
190            &variable_names,
191            &mut substituted_variables,
192        )?;
193        env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
194        Some(ResolvedTask {
195            id: id.clone(),
196            substituted_variables,
197            original_task: self.clone(),
198            resolved_label: full_label.clone(),
199            resolved: Some(SpawnInTerminal {
200                id,
201                cwd,
202                full_label,
203                label: human_readable_label,
204                command_label: args_with_substitutions.iter().fold(
205                    command.clone(),
206                    |mut command_label, arg| {
207                        command_label.push(' ');
208                        command_label.push_str(arg);
209                        command_label
210                    },
211                ),
212                command,
213                args: self.args.clone(),
214                env,
215                use_new_terminal: self.use_new_terminal,
216                allow_concurrent_runs: self.allow_concurrent_runs,
217                reveal: self.reveal,
218                hide: self.hide,
219                shell: self.shell.clone(),
220            }),
221        })
222    }
223}
224
225const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
226
227fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
228    task_variables
229        .iter()
230        .map(|(key, value)| {
231            (
232                key.clone(),
233                truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
234            )
235        })
236        .collect()
237}
238
239fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
240    let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
241    let mut hasher = Sha256::new();
242    hasher.update(json.as_bytes());
243    Ok(hex::encode(hasher.finalize()))
244}
245
246fn substitute_all_template_variables_in_str<A: AsRef<str>>(
247    template_str: &str,
248    task_variables: &HashMap<String, A>,
249    variable_names: &HashMap<String, VariableName>,
250    substituted_variables: &mut HashSet<VariableName>,
251) -> Option<String> {
252    let substituted_string = shellexpand::env_with_context(template_str, |var| {
253        // 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.
254        let colon_position = var.find(':').unwrap_or(var.len());
255        let (variable_name, default) = var.split_at(colon_position);
256        if let Some(name) = task_variables.get(variable_name) {
257            if let Some(substituted_variable) = variable_names.get(variable_name) {
258                substituted_variables.insert(substituted_variable.clone());
259            }
260
261            let mut name = name.as_ref().to_owned();
262            // Got a task variable hit
263            if !default.is_empty() {
264                name.push_str(default);
265            }
266            return Ok(Some(name));
267        } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
268            bail!("Unknown variable name: {variable_name}");
269        }
270        // This is an unknown variable.
271        // 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.
272        // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
273        if !default.is_empty() {
274            return Ok(Some(format!("${{{var}}}")));
275        }
276        // Else we can just return None and that variable will be left as is.
277        Ok(None)
278    })
279    .ok()?;
280    Some(substituted_string.into_owned())
281}
282
283fn substitute_all_template_variables_in_vec(
284    template_strs: &[String],
285    task_variables: &HashMap<String, &str>,
286    variable_names: &HashMap<String, VariableName>,
287    substituted_variables: &mut HashSet<VariableName>,
288) -> Option<Vec<String>> {
289    let mut expanded = Vec::with_capacity(template_strs.len());
290    for variable in template_strs {
291        let new_value = substitute_all_template_variables_in_str(
292            variable,
293            task_variables,
294            variable_names,
295            substituted_variables,
296        )?;
297        expanded.push(new_value);
298    }
299    Some(expanded)
300}
301
302fn substitute_all_template_variables_in_map(
303    keys_and_values: &HashMap<String, String>,
304    task_variables: &HashMap<String, &str>,
305    variable_names: &HashMap<String, VariableName>,
306    substituted_variables: &mut HashSet<VariableName>,
307) -> Option<HashMap<String, String>> {
308    let mut new_map: HashMap<String, String> = Default::default();
309    for (key, value) in keys_and_values {
310        let new_value = substitute_all_template_variables_in_str(
311            &value,
312            task_variables,
313            variable_names,
314            substituted_variables,
315        )?;
316        let new_key = substitute_all_template_variables_in_str(
317            &key,
318            task_variables,
319            variable_names,
320            substituted_variables,
321        )?;
322        new_map.insert(new_key, new_value);
323    }
324    Some(new_map)
325}
326
327#[cfg(test)]
328mod tests {
329    use std::{borrow::Cow, path::Path};
330
331    use crate::{TaskVariables, VariableName};
332
333    use super::*;
334
335    const TEST_ID_BASE: &str = "test_base";
336
337    #[test]
338    fn test_resolving_templates_with_blank_command_and_label() {
339        let task_with_all_properties = TaskTemplate {
340            label: "test_label".to_string(),
341            command: "test_command".to_string(),
342            args: vec!["test_arg".to_string()],
343            env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
344            ..TaskTemplate::default()
345        };
346
347        for task_with_blank_property in &[
348            TaskTemplate {
349                label: "".to_string(),
350                ..task_with_all_properties.clone()
351            },
352            TaskTemplate {
353                command: "".to_string(),
354                ..task_with_all_properties.clone()
355            },
356            TaskTemplate {
357                label: "".to_string(),
358                command: "".to_string(),
359                ..task_with_all_properties.clone()
360            },
361        ] {
362            assert_eq!(
363                task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
364                None,
365                "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
366            );
367        }
368    }
369
370    #[test]
371    fn test_template_cwd_resolution() {
372        let task_without_cwd = TaskTemplate {
373            cwd: None,
374            label: "test task".to_string(),
375            command: "echo 4".to_string(),
376            ..TaskTemplate::default()
377        };
378
379        let resolved_task = |task_template: &TaskTemplate, task_cx| {
380            let resolved_task = task_template
381                .resolve_task(TEST_ID_BASE, task_cx)
382                .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
383            assert_substituted_variables(&resolved_task, Vec::new());
384            resolved_task
385                .resolved
386                .clone()
387                .unwrap_or_else(|| {
388                    panic!("failed to get resolve data for resolved task. Template: {task_without_cwd:?} Resolved: {resolved_task:?}")
389                })
390        };
391
392        let cx = TaskContext {
393            cwd: None,
394            task_variables: TaskVariables::default(),
395        };
396        assert_eq!(
397            resolved_task(&task_without_cwd, &cx).cwd,
398            None,
399            "When neither task nor task context have cwd, it should be None"
400        );
401
402        let context_cwd = Path::new("a").join("b").join("c");
403        let cx = TaskContext {
404            cwd: Some(context_cwd.clone()),
405            task_variables: TaskVariables::default(),
406        };
407        assert_eq!(
408            resolved_task(&task_without_cwd, &cx)
409                .cwd
410                .as_ref()
411                .and_then(|cwd| cwd.local_path()),
412            Some(context_cwd.as_path()),
413            "TaskContext's cwd should be taken on resolve if task's cwd is None"
414        );
415
416        let task_cwd = Path::new("d").join("e").join("f");
417        let mut task_with_cwd = task_without_cwd.clone();
418        task_with_cwd.cwd = Some(task_cwd.display().to_string());
419        let task_with_cwd = task_with_cwd;
420
421        let cx = TaskContext {
422            cwd: None,
423            task_variables: TaskVariables::default(),
424        };
425        assert_eq!(
426            resolved_task(&task_with_cwd, &cx)
427                .cwd
428                .as_ref()
429                .and_then(|cwd| cwd.local_path()),
430            Some(task_cwd.as_path()),
431            "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
432        );
433
434        let cx = TaskContext {
435            cwd: Some(context_cwd.clone()),
436            task_variables: TaskVariables::default(),
437        };
438        assert_eq!(
439            resolved_task(&task_with_cwd, &cx)
440                .cwd
441                .as_ref()
442                .and_then(|cwd| cwd.local_path()),
443            Some(task_cwd.as_path()),
444            "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
445        );
446    }
447
448    #[test]
449    fn test_template_variables_resolution() {
450        let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
451        let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
452        let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
453        let all_variables = [
454            (VariableName::Row, "1234".to_string()),
455            (VariableName::Column, "5678".to_string()),
456            (VariableName::File, "test_file".to_string()),
457            (VariableName::SelectedText, "test_selected_text".to_string()),
458            (VariableName::Symbol, long_value.clone()),
459            (VariableName::WorktreeRoot, "/test_root/".to_string()),
460            (
461                custom_variable_1.clone(),
462                "test_custom_variable_1".to_string(),
463            ),
464            (
465                custom_variable_2.clone(),
466                "test_custom_variable_2".to_string(),
467            ),
468        ];
469
470        let task_with_all_variables = TaskTemplate {
471            label: format!(
472                "test label for {} and {}",
473                VariableName::Row.template_value(),
474                VariableName::Symbol.template_value(),
475            ),
476            command: format!(
477                "echo {} {}",
478                VariableName::File.template_value(),
479                VariableName::Symbol.template_value(),
480            ),
481            args: vec![
482                format!("arg1 {}", VariableName::SelectedText.template_value()),
483                format!("arg2 {}", VariableName::Column.template_value()),
484                format!("arg3 {}", VariableName::Symbol.template_value()),
485            ],
486            env: HashMap::from_iter([
487                ("test_env_key".to_string(), "test_env_var".to_string()),
488                (
489                    "env_key_1".to_string(),
490                    VariableName::WorktreeRoot.template_value(),
491                ),
492                (
493                    "env_key_2".to_string(),
494                    format!(
495                        "env_var_2 {} {}",
496                        custom_variable_1.template_value(),
497                        custom_variable_2.template_value()
498                    ),
499                ),
500                (
501                    "env_key_3".to_string(),
502                    format!("env_var_3 {}", VariableName::Symbol.template_value()),
503                ),
504            ]),
505            ..TaskTemplate::default()
506        };
507
508        let mut first_resolved_id = None;
509        for i in 0..15 {
510            let resolved_task = task_with_all_variables.resolve_task(
511                TEST_ID_BASE,
512                &TaskContext {
513                    cwd: None,
514                    task_variables: TaskVariables::from_iter(all_variables.clone()),
515                },
516            ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
517
518            match &first_resolved_id {
519                None => first_resolved_id = Some(resolved_task.id.clone()),
520                Some(first_id) => assert_eq!(
521                    &resolved_task.id, first_id,
522                    "Step {i}, for the same task template and context, there should be the same resolved task id"
523                ),
524            }
525
526            assert_eq!(
527                resolved_task.original_task, task_with_all_variables,
528                "Resolved task should store its template without changes"
529            );
530            assert_eq!(
531                resolved_task.resolved_label,
532                format!("test label for 1234 and {long_value}"),
533                "Resolved task label should be substituted with variables and those should not be shortened"
534            );
535            assert_substituted_variables(
536                &resolved_task,
537                all_variables.iter().map(|(name, _)| name.clone()).collect(),
538            );
539
540            let spawn_in_terminal = resolved_task
541                .resolved
542                .as_ref()
543                .expect("should have resolved a spawn in terminal task");
544            assert_eq!(
545                spawn_in_terminal.label,
546                format!(
547                    "test label for 1234 and …{}",
548                    &long_value[..=MAX_DISPLAY_VARIABLE_LENGTH]
549                ),
550                "Human-readable label should have long substitutions trimmed"
551            );
552            assert_eq!(
553                spawn_in_terminal.command,
554                format!("echo test_file {long_value}"),
555                "Command should be substituted with variables and those should not be shortened"
556            );
557            assert_eq!(
558                spawn_in_terminal.args,
559                &[
560                    "arg1 $ZED_SELECTED_TEXT",
561                    "arg2 $ZED_COLUMN",
562                    "arg3 $ZED_SYMBOL",
563                ],
564                "Args should not be substituted with variables"
565            );
566            assert_eq!(
567                spawn_in_terminal.command_label,
568                format!("{} arg1 test_selected_text arg2 5678 arg3 {long_value}", spawn_in_terminal.command),
569                "Command label args should be substituted with variables and those should not be shortened"
570            );
571
572            assert_eq!(
573                spawn_in_terminal
574                    .env
575                    .get("test_env_key")
576                    .map(|s| s.as_str()),
577                Some("test_env_var")
578            );
579            assert_eq!(
580                spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
581                Some("/test_root/")
582            );
583            assert_eq!(
584                spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
585                Some("env_var_2 test_custom_variable_1 test_custom_variable_2")
586            );
587            assert_eq!(
588                spawn_in_terminal.env.get("env_key_3"),
589                Some(&format!("env_var_3 {long_value}")),
590                "Env vars should be substituted with variables and those should not be shortened"
591            );
592        }
593
594        for i in 0..all_variables.len() {
595            let mut not_all_variables = all_variables.to_vec();
596            let removed_variable = not_all_variables.remove(i);
597            let resolved_task_attempt = task_with_all_variables.resolve_task(
598                TEST_ID_BASE,
599                &TaskContext {
600                    cwd: None,
601                    task_variables: TaskVariables::from_iter(not_all_variables),
602                },
603            );
604            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})");
605        }
606    }
607
608    #[test]
609    fn test_can_resolve_free_variables() {
610        let task = TaskTemplate {
611            label: "My task".into(),
612            command: "echo".into(),
613            args: vec!["$PATH".into()],
614            ..Default::default()
615        };
616        let resolved_task = task
617            .resolve_task(TEST_ID_BASE, &TaskContext::default())
618            .unwrap();
619        assert_substituted_variables(&resolved_task, Vec::new());
620        let resolved = resolved_task.resolved.unwrap();
621        assert_eq!(resolved.label, task.label);
622        assert_eq!(resolved.command, task.command);
623        assert_eq!(resolved.args, task.args);
624    }
625
626    #[test]
627    fn test_errors_on_missing_zed_variable() {
628        let task = TaskTemplate {
629            label: "My task".into(),
630            command: "echo".into(),
631            args: vec!["$ZED_VARIABLE".into()],
632            ..Default::default()
633        };
634        assert!(task
635            .resolve_task(TEST_ID_BASE, &TaskContext::default())
636            .is_none());
637    }
638
639    #[test]
640    fn test_symbol_dependent_tasks() {
641        let task_with_all_properties = TaskTemplate {
642            label: "test_label".to_string(),
643            command: "test_command".to_string(),
644            args: vec!["test_arg".to_string()],
645            env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
646            ..TaskTemplate::default()
647        };
648        let cx = TaskContext {
649            cwd: None,
650            task_variables: TaskVariables::from_iter(Some((
651                VariableName::Symbol,
652                "test_symbol".to_string(),
653            ))),
654        };
655
656        for (i, symbol_dependent_task) in [
657            TaskTemplate {
658                label: format!("test_label_{}", VariableName::Symbol.template_value()),
659                ..task_with_all_properties.clone()
660            },
661            TaskTemplate {
662                command: format!("test_command_{}", VariableName::Symbol.template_value()),
663                ..task_with_all_properties.clone()
664            },
665            TaskTemplate {
666                args: vec![format!(
667                    "test_arg_{}",
668                    VariableName::Symbol.template_value()
669                )],
670                ..task_with_all_properties.clone()
671            },
672            TaskTemplate {
673                env: HashMap::from_iter([(
674                    "test_env_key".to_string(),
675                    format!("test_env_var_{}", VariableName::Symbol.template_value()),
676                )]),
677                ..task_with_all_properties.clone()
678            },
679        ]
680        .into_iter()
681        .enumerate()
682        {
683            let resolved = symbol_dependent_task
684                .resolve_task(TEST_ID_BASE, &cx)
685                .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
686            assert_eq!(
687                resolved.substituted_variables,
688                HashSet::from_iter(Some(VariableName::Symbol)),
689                "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
690            )
691        }
692    }
693
694    #[track_caller]
695    fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
696        let mut resolved_variables = resolved_task
697            .substituted_variables
698            .iter()
699            .cloned()
700            .collect::<Vec<_>>();
701        resolved_variables.sort_by_key(|var| var.to_string());
702        expected.sort_by_key(|var| var.to_string());
703        assert_eq!(resolved_variables, expected)
704    }
705
706    #[test]
707    fn substitute_funky_labels() {
708        let faulty_go_test = TaskTemplate {
709            label: format!(
710                "go test {}/{}",
711                VariableName::Symbol.template_value(),
712                VariableName::Symbol.template_value(),
713            ),
714            command: "go".into(),
715            args: vec![format!(
716                "^{}$/^{}$",
717                VariableName::Symbol.template_value(),
718                VariableName::Symbol.template_value()
719            )],
720            ..TaskTemplate::default()
721        };
722        let mut context = TaskContext::default();
723        context
724            .task_variables
725            .insert(VariableName::Symbol, "my-symbol".to_string());
726        assert!(faulty_go_test.resolve_task("base", &context).is_some());
727    }
728}