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, SpawnInTerminal, TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX,
 12};
 13
 14/// A template definition of a Zed task to run.
 15/// May use the [`VariableName`] to get the corresponding substitutions into its fields.
 16///
 17/// Template itself is not ready to spawn a task, it needs to be resolved with a [`TaskContext`] first, that
 18/// contains all relevant Zed state in task variables.
 19/// A single template may produce different tasks (or none) for different contexts.
 20#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 21#[serde(rename_all = "snake_case")]
 22pub struct TaskTemplate {
 23    /// Human readable name of the task to display in the UI.
 24    pub label: String,
 25    /// Executable command to spawn.
 26    pub command: String,
 27    /// Arguments to the command.
 28    #[serde(default)]
 29    pub args: Vec<String>,
 30    /// Env overrides for the command, will be appended to the terminal's environment from the settings.
 31    #[serde(default)]
 32    pub env: HashMap<String, String>,
 33    /// Current working directory to spawn the command into, defaults to current project root.
 34    #[serde(default)]
 35    pub cwd: Option<String>,
 36    /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
 37    #[serde(default)]
 38    pub use_new_terminal: bool,
 39    /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
 40    #[serde(default)]
 41    pub allow_concurrent_runs: bool,
 42    /// What to do with the terminal pane and tab, after the command was started:
 43    /// * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default)
 44    /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
 45    #[serde(default)]
 46    pub reveal: RevealStrategy,
 47
 48    /// Represents the tags which this template attaches to. Adding this removes this task from other UI.
 49    #[serde(default)]
 50    pub tags: Vec<String>,
 51}
 52
 53/// What to do with the terminal pane and tab, after the command was started.
 54#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 55#[serde(rename_all = "snake_case")]
 56pub enum RevealStrategy {
 57    /// Always show the terminal pane, add and focus the corresponding task's tab in it.
 58    #[default]
 59    Always,
 60    /// Do not change terminal pane focus, but still add/reuse the task's tab there.
 61    Never,
 62}
 63
 64/// A group of Tasks defined in a JSON file.
 65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 66pub struct TaskTemplates(pub Vec<TaskTemplate>);
 67
 68impl TaskTemplates {
 69    /// Generates JSON schema of Tasks JSON template format.
 70    pub fn generate_json_schema() -> serde_json_lenient::Value {
 71        let schema = SchemaSettings::draft07()
 72            .with(|settings| settings.option_add_null_type = false)
 73            .into_generator()
 74            .into_root_schema_for::<Self>();
 75
 76        serde_json_lenient::to_value(schema).unwrap()
 77    }
 78}
 79
 80impl TaskTemplate {
 81    /// Replaces all `VariableName` task variables in the task template string fields.
 82    /// If any replacement fails or the new string substitutions still have [`ZED_VARIABLE_NAME_PREFIX`],
 83    /// `None` is returned.
 84    ///
 85    /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
 86    /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
 87    pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
 88        if self.label.trim().is_empty() || self.command.trim().is_empty() {
 89            return None;
 90        }
 91
 92        let mut variable_names = HashMap::default();
 93        let mut substituted_variables = HashSet::default();
 94        let task_variables = cx
 95            .task_variables
 96            .0
 97            .iter()
 98            .map(|(key, value)| {
 99                let key_string = key.to_string();
100                if !variable_names.contains_key(&key_string) {
101                    variable_names.insert(key_string.clone(), key.clone());
102                }
103                (key_string, value.as_str())
104            })
105            .collect::<HashMap<_, _>>();
106        let truncated_variables = truncate_variables(&task_variables);
107        let cwd = match self.cwd.as_deref() {
108            Some(cwd) => {
109                let substitured_cwd = substitute_all_template_variables_in_str(
110                    cwd,
111                    &task_variables,
112                    &variable_names,
113                    &mut substituted_variables,
114                )?;
115                Some(substitured_cwd)
116            }
117            None => None,
118        }
119        .map(PathBuf::from)
120        .or(cx.cwd.clone());
121        let human_readable_label = substitute_all_template_variables_in_str(
122            &self.label,
123            &truncated_variables,
124            &variable_names,
125            &mut substituted_variables,
126        )?
127        .lines()
128        .fold(String::new(), |mut string, line| {
129            if string.is_empty() {
130                string.push_str(line);
131            } else {
132                string.push_str("\\n");
133                string.push_str(line);
134            }
135            string
136        });
137        let full_label = substitute_all_template_variables_in_str(
138            &self.label,
139            &task_variables,
140            &variable_names,
141            &mut substituted_variables,
142        )?;
143        let command = substitute_all_template_variables_in_str(
144            &self.command,
145            &task_variables,
146            &variable_names,
147            &mut substituted_variables,
148        )?;
149        let args_with_substitutions = substitute_all_template_variables_in_vec(
150            &self.args,
151            &task_variables,
152            &variable_names,
153            &mut substituted_variables,
154        )?;
155
156        let task_hash = to_hex_hash(&self)
157            .context("hashing task template")
158            .log_err()?;
159        let variables_hash = to_hex_hash(&task_variables)
160            .context("hashing task variables")
161            .log_err()?;
162        let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
163        let mut env = substitute_all_template_variables_in_map(
164            &self.env,
165            &task_variables,
166            &variable_names,
167            &mut substituted_variables,
168        )?;
169        env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
170        Some(ResolvedTask {
171            id: id.clone(),
172            substituted_variables,
173            original_task: self.clone(),
174            resolved_label: full_label.clone(),
175            resolved: Some(SpawnInTerminal {
176                id,
177                cwd,
178                full_label,
179                label: human_readable_label,
180                command_label: args_with_substitutions.iter().fold(
181                    command.clone(),
182                    |mut command_label, arg| {
183                        command_label.push(' ');
184                        command_label.push_str(arg);
185                        command_label
186                    },
187                ),
188                command,
189                args: self.args.clone(),
190                env,
191                use_new_terminal: self.use_new_terminal,
192                allow_concurrent_runs: self.allow_concurrent_runs,
193                reveal: self.reveal,
194            }),
195        })
196    }
197}
198
199const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
200
201fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
202    task_variables
203        .iter()
204        .map(|(key, value)| {
205            (
206                key.clone(),
207                truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
208            )
209        })
210        .collect()
211}
212
213fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
214    let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
215    let mut hasher = Sha256::new();
216    hasher.update(json.as_bytes());
217    Ok(hex::encode(hasher.finalize()))
218}
219
220fn substitute_all_template_variables_in_str<A: AsRef<str>>(
221    template_str: &str,
222    task_variables: &HashMap<String, A>,
223    variable_names: &HashMap<String, VariableName>,
224    substituted_variables: &mut HashSet<VariableName>,
225) -> Option<String> {
226    let substituted_string = shellexpand::env_with_context(template_str, |var| {
227        // 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.
228        let colon_position = var.find(':').unwrap_or(var.len());
229        let (variable_name, default) = var.split_at(colon_position);
230        if let Some(name) = task_variables.get(variable_name) {
231            if let Some(substituted_variable) = variable_names.get(variable_name) {
232                substituted_variables.insert(substituted_variable.clone());
233            }
234
235            let mut name = name.as_ref().to_owned();
236            // Got a task variable hit
237            if !default.is_empty() {
238                name.push_str(default);
239            }
240            return Ok(Some(name));
241        } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
242            bail!("Unknown variable name: {variable_name}");
243        }
244        // This is an unknown variable.
245        // 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.
246        // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
247        if !default.is_empty() {
248            return Ok(Some(format!("${{{var}}}")));
249        }
250        // Else we can just return None and that variable will be left as is.
251        Ok(None)
252    })
253    .ok()?;
254    Some(substituted_string.into_owned())
255}
256
257fn substitute_all_template_variables_in_vec(
258    template_strs: &[String],
259    task_variables: &HashMap<String, &str>,
260    variable_names: &HashMap<String, VariableName>,
261    substituted_variables: &mut HashSet<VariableName>,
262) -> Option<Vec<String>> {
263    let mut expanded = Vec::with_capacity(template_strs.len());
264    for variable in template_strs {
265        let new_value = substitute_all_template_variables_in_str(
266            variable,
267            task_variables,
268            variable_names,
269            substituted_variables,
270        )?;
271        expanded.push(new_value);
272    }
273    Some(expanded)
274}
275
276fn substitute_all_template_variables_in_map(
277    keys_and_values: &HashMap<String, String>,
278    task_variables: &HashMap<String, &str>,
279    variable_names: &HashMap<String, VariableName>,
280    substituted_variables: &mut HashSet<VariableName>,
281) -> Option<HashMap<String, String>> {
282    let mut new_map: HashMap<String, String> = Default::default();
283    for (key, value) in keys_and_values {
284        let new_value = substitute_all_template_variables_in_str(
285            &value,
286            task_variables,
287            variable_names,
288            substituted_variables,
289        )?;
290        let new_key = substitute_all_template_variables_in_str(
291            &key,
292            task_variables,
293            variable_names,
294            substituted_variables,
295        )?;
296        new_map.insert(new_key, new_value);
297    }
298    Some(new_map)
299}
300
301#[cfg(test)]
302mod tests {
303    use std::{borrow::Cow, path::Path};
304
305    use crate::{TaskVariables, VariableName};
306
307    use super::*;
308
309    const TEST_ID_BASE: &str = "test_base";
310
311    #[test]
312    fn test_resolving_templates_with_blank_command_and_label() {
313        let task_with_all_properties = TaskTemplate {
314            label: "test_label".to_string(),
315            command: "test_command".to_string(),
316            args: vec!["test_arg".to_string()],
317            env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
318            ..TaskTemplate::default()
319        };
320
321        for task_with_blank_property in &[
322            TaskTemplate {
323                label: "".to_string(),
324                ..task_with_all_properties.clone()
325            },
326            TaskTemplate {
327                command: "".to_string(),
328                ..task_with_all_properties.clone()
329            },
330            TaskTemplate {
331                label: "".to_string(),
332                command: "".to_string(),
333                ..task_with_all_properties.clone()
334            },
335        ] {
336            assert_eq!(
337                task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
338                None,
339                "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
340            );
341        }
342    }
343
344    #[test]
345    fn test_template_cwd_resolution() {
346        let task_without_cwd = TaskTemplate {
347            cwd: None,
348            label: "test task".to_string(),
349            command: "echo 4".to_string(),
350            ..TaskTemplate::default()
351        };
352
353        let resolved_task = |task_template: &TaskTemplate, task_cx| {
354            let resolved_task = task_template
355                .resolve_task(TEST_ID_BASE, task_cx)
356                .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
357            assert_substituted_variables(&resolved_task, Vec::new());
358            resolved_task
359                .resolved
360                .clone()
361                .unwrap_or_else(|| {
362                    panic!("failed to get resolve data for resolved task. Template: {task_without_cwd:?} Resolved: {resolved_task:?}")
363                })
364        };
365
366        let cx = TaskContext {
367            cwd: None,
368            task_variables: TaskVariables::default(),
369        };
370        assert_eq!(
371            resolved_task(&task_without_cwd, &cx).cwd,
372            None,
373            "When neither task nor task context have cwd, it should be None"
374        );
375
376        let context_cwd = Path::new("a").join("b").join("c");
377        let cx = TaskContext {
378            cwd: Some(context_cwd.clone()),
379            task_variables: TaskVariables::default(),
380        };
381        assert_eq!(
382            resolved_task(&task_without_cwd, &cx).cwd.as_deref(),
383            Some(context_cwd.as_path()),
384            "TaskContext's cwd should be taken on resolve if task's cwd is None"
385        );
386
387        let task_cwd = Path::new("d").join("e").join("f");
388        let mut task_with_cwd = task_without_cwd.clone();
389        task_with_cwd.cwd = Some(task_cwd.display().to_string());
390        let task_with_cwd = task_with_cwd;
391
392        let cx = TaskContext {
393            cwd: None,
394            task_variables: TaskVariables::default(),
395        };
396        assert_eq!(
397            resolved_task(&task_with_cwd, &cx).cwd.as_deref(),
398            Some(task_cwd.as_path()),
399            "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
400        );
401
402        let cx = TaskContext {
403            cwd: Some(context_cwd.clone()),
404            task_variables: TaskVariables::default(),
405        };
406        assert_eq!(
407            resolved_task(&task_with_cwd, &cx).cwd.as_deref(),
408            Some(task_cwd.as_path()),
409            "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
410        );
411    }
412
413    #[test]
414    fn test_template_variables_resolution() {
415        let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
416        let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
417        let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
418        let all_variables = [
419            (VariableName::Row, "1234".to_string()),
420            (VariableName::Column, "5678".to_string()),
421            (VariableName::File, "test_file".to_string()),
422            (VariableName::SelectedText, "test_selected_text".to_string()),
423            (VariableName::Symbol, long_value.clone()),
424            (VariableName::WorktreeRoot, "/test_root/".to_string()),
425            (
426                custom_variable_1.clone(),
427                "test_custom_variable_1".to_string(),
428            ),
429            (
430                custom_variable_2.clone(),
431                "test_custom_variable_2".to_string(),
432            ),
433        ];
434
435        let task_with_all_variables = TaskTemplate {
436            label: format!(
437                "test label for {} and {}",
438                VariableName::Row.template_value(),
439                VariableName::Symbol.template_value(),
440            ),
441            command: format!(
442                "echo {} {}",
443                VariableName::File.template_value(),
444                VariableName::Symbol.template_value(),
445            ),
446            args: vec![
447                format!("arg1 {}", VariableName::SelectedText.template_value()),
448                format!("arg2 {}", VariableName::Column.template_value()),
449                format!("arg3 {}", VariableName::Symbol.template_value()),
450            ],
451            env: HashMap::from_iter([
452                ("test_env_key".to_string(), "test_env_var".to_string()),
453                (
454                    "env_key_1".to_string(),
455                    VariableName::WorktreeRoot.template_value(),
456                ),
457                (
458                    "env_key_2".to_string(),
459                    format!(
460                        "env_var_2_{}_{}",
461                        custom_variable_1.template_value(),
462                        custom_variable_2.template_value()
463                    ),
464                ),
465                (
466                    "env_key_3".to_string(),
467                    format!("env_var_3_{}", VariableName::Symbol.template_value()),
468                ),
469            ]),
470            ..TaskTemplate::default()
471        };
472
473        let mut first_resolved_id = None;
474        for i in 0..15 {
475            let resolved_task = task_with_all_variables.resolve_task(
476                TEST_ID_BASE,
477                &TaskContext {
478                    cwd: None,
479                    task_variables: TaskVariables::from_iter(all_variables.clone()),
480                },
481            ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
482
483            match &first_resolved_id {
484                None => first_resolved_id = Some(resolved_task.id.clone()),
485                Some(first_id) => assert_eq!(
486                    &resolved_task.id, first_id,
487                    "Step {i}, for the same task template and context, there should be the same resolved task id"
488                ),
489            }
490
491            assert_eq!(
492                resolved_task.original_task, task_with_all_variables,
493                "Resolved task should store its template without changes"
494            );
495            assert_eq!(
496                resolved_task.resolved_label,
497                format!("test label for 1234 and {long_value}"),
498                "Resolved task label should be substituted with variables and those should not be shortened"
499            );
500            assert_substituted_variables(
501                &resolved_task,
502                all_variables.iter().map(|(name, _)| name.clone()).collect(),
503            );
504
505            let spawn_in_terminal = resolved_task
506                .resolved
507                .as_ref()
508                .expect("should have resolved a spawn in terminal task");
509            assert_eq!(
510                spawn_in_terminal.label,
511                format!(
512                    "test label for 1234 and …{}",
513                    &long_value[..=MAX_DISPLAY_VARIABLE_LENGTH]
514                ),
515                "Human-readable label should have long substitutions trimmed"
516            );
517            assert_eq!(
518                spawn_in_terminal.command,
519                format!("echo test_file {long_value}"),
520                "Command should be substituted with variables and those should not be shortened"
521            );
522            assert_eq!(
523                spawn_in_terminal.args,
524                &[
525                    "arg1 $ZED_SELECTED_TEXT",
526                    "arg2 $ZED_COLUMN",
527                    "arg3 $ZED_SYMBOL",
528                ],
529                "Args should not be substituted with variables"
530            );
531            assert_eq!(
532                spawn_in_terminal.command_label,
533                format!("{} arg1 test_selected_text arg2 5678 arg3 {long_value}", spawn_in_terminal.command),
534                "Command label args should be substituted with variables and those should not be shortened"
535            );
536
537            assert_eq!(
538                spawn_in_terminal
539                    .env
540                    .get("test_env_key")
541                    .map(|s| s.as_str()),
542                Some("test_env_var")
543            );
544            assert_eq!(
545                spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
546                Some("/test_root/")
547            );
548            assert_eq!(
549                spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
550                Some("env_var_2_test_custom_variable_1_test_custom_variable_2")
551            );
552            assert_eq!(
553                spawn_in_terminal.env.get("env_key_3"),
554                Some(&format!("env_var_3_{long_value}")),
555                "Env vars should be substituted with variables and those should not be shortened"
556            );
557        }
558
559        for i in 0..all_variables.len() {
560            let mut not_all_variables = all_variables.to_vec();
561            let removed_variable = not_all_variables.remove(i);
562            let resolved_task_attempt = task_with_all_variables.resolve_task(
563                TEST_ID_BASE,
564                &TaskContext {
565                    cwd: None,
566                    task_variables: TaskVariables::from_iter(not_all_variables),
567                },
568            );
569            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})");
570        }
571    }
572
573    #[test]
574    fn test_can_resolve_free_variables() {
575        let task = TaskTemplate {
576            label: "My task".into(),
577            command: "echo".into(),
578            args: vec!["$PATH".into()],
579            ..Default::default()
580        };
581        let resolved_task = task
582            .resolve_task(TEST_ID_BASE, &TaskContext::default())
583            .unwrap();
584        assert_substituted_variables(&resolved_task, Vec::new());
585        let resolved = resolved_task.resolved.unwrap();
586        assert_eq!(resolved.label, task.label);
587        assert_eq!(resolved.command, task.command);
588        assert_eq!(resolved.args, task.args);
589    }
590
591    #[test]
592    fn test_errors_on_missing_zed_variable() {
593        let task = TaskTemplate {
594            label: "My task".into(),
595            command: "echo".into(),
596            args: vec!["$ZED_VARIABLE".into()],
597            ..Default::default()
598        };
599        assert!(task
600            .resolve_task(TEST_ID_BASE, &TaskContext::default())
601            .is_none());
602    }
603
604    #[test]
605    fn test_symbol_dependent_tasks() {
606        let task_with_all_properties = TaskTemplate {
607            label: "test_label".to_string(),
608            command: "test_command".to_string(),
609            args: vec!["test_arg".to_string()],
610            env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
611            ..TaskTemplate::default()
612        };
613        let cx = TaskContext {
614            cwd: None,
615            task_variables: TaskVariables::from_iter(Some((
616                VariableName::Symbol,
617                "test_symbol".to_string(),
618            ))),
619        };
620
621        for (i, symbol_dependent_task) in [
622            TaskTemplate {
623                label: format!("test_label_{}", VariableName::Symbol.template_value()),
624                ..task_with_all_properties.clone()
625            },
626            TaskTemplate {
627                command: format!("test_command_{}", VariableName::Symbol.template_value()),
628                ..task_with_all_properties.clone()
629            },
630            TaskTemplate {
631                args: vec![format!(
632                    "test_arg_{}",
633                    VariableName::Symbol.template_value()
634                )],
635                ..task_with_all_properties.clone()
636            },
637            TaskTemplate {
638                env: HashMap::from_iter([(
639                    "test_env_key".to_string(),
640                    format!("test_env_var_{}", VariableName::Symbol.template_value()),
641                )]),
642                ..task_with_all_properties.clone()
643            },
644        ]
645        .into_iter()
646        .enumerate()
647        {
648            let resolved = symbol_dependent_task
649                .resolve_task(TEST_ID_BASE, &cx)
650                .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
651            assert_eq!(
652                resolved.substituted_variables,
653                HashSet::from_iter(Some(VariableName::Symbol)),
654                "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
655            )
656        }
657    }
658
659    #[track_caller]
660    fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
661        let mut resolved_variables = resolved_task
662            .substituted_variables
663            .iter()
664            .cloned()
665            .collect::<Vec<_>>();
666        resolved_variables.sort_by_key(|var| var.to_string());
667        expected.sort_by_key(|var| var.to_string());
668        assert_eq!(resolved_variables, expected)
669    }
670}