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