task_template.rs

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