task_template.rs

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