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