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