Skip to repository content1155 lines · 43.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:36:46.944Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
task_template.rs
1use anyhow::{Context as _, bail};
2use collections::{HashMap, HashSet};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::path::PathBuf;
7use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields};
8use util::serde::default_true;
9use util::{ResultExt, truncate_and_remove_front};
10
11use crate::{
12 AttachRequest, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TaskContext, TaskId,
13 VariableName, ZED_VARIABLE_NAME_PREFIX, serde_helpers::non_empty_string_vec,
14};
15
16/// A template definition of a Omega 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 Omega state in task variables.
21/// A single template may produce different tasks (or none) for different contexts.
22#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub struct TaskTemplate {
25 /// Human readable name of the task to display in the UI.
26 pub label: String,
27 /// Executable command to spawn.
28 pub command: String,
29 /// Arguments to the command.
30 #[serde(default)]
31 pub args: Vec<String>,
32 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
33 #[serde(default)]
34 pub env: HashMap<String, String>,
35 /// Current working directory to spawn the command into, defaults to current project root.
36 #[serde(default)]
37 pub cwd: Option<String>,
38 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
39 #[serde(default)]
40 pub use_new_terminal: bool,
41 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
42 #[serde(default)]
43 pub allow_concurrent_runs: bool,
44 /// What to do with the terminal pane and tab, after the command was started:
45 /// * `always` — always show the task's pane, and focus the corresponding tab in it (default)
46 // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it
47 // * `never` — do not alter focus, but still add/reuse the task's tab in its pane
48 #[serde(default)]
49 pub reveal: RevealStrategy,
50 /// Where to place the task's terminal item after starting the task.
51 /// * `dock` — in the terminal dock, "regular" terminal items' place (default).
52 /// * `center` — in the central pane group, "main" editor area.
53 #[serde(default)]
54 pub reveal_target: RevealTarget,
55 /// What to do with the terminal pane and tab, after the command had finished:
56 /// * `never` — do nothing when the command finishes (default)
57 /// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
58 /// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`.
59 #[serde(default)]
60 pub hide: HideStrategy,
61 /// Represents the tags which this template attaches to.
62 /// Adding this removes this task from other UI and gives you ability to run it by tag.
63 #[serde(default, deserialize_with = "non_empty_string_vec")]
64 #[schemars(length(min = 1))]
65 pub tags: Vec<String>,
66 /// Which shell to use when spawning the task.
67 #[serde(default)]
68 pub shell: Shell,
69 /// Whether to show the task line in the task output.
70 #[serde(default = "default_true")]
71 pub show_summary: bool,
72 /// Whether to show the command line in the task output.
73 #[serde(default = "default_true")]
74 pub show_command: bool,
75 /// Which edited buffers to save before running the task.
76 #[serde(default)]
77 pub save: SaveStrategy,
78 /// Hooks that this task runs when emitted.
79 #[serde(default)]
80 pub hooks: HashSet<TaskHook>,
81}
82
83#[derive(Deserialize, Eq, PartialEq, Clone, Debug)]
84/// Use to represent debug request type
85pub enum DebugArgsRequest {
86 /// launch (program, cwd) are stored in TaskTemplate as (command, cwd)
87 Launch,
88 /// Attach
89 Attach(AttachRequest),
90}
91
92/// What to do with the terminal pane and tab, after the command was started.
93#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Serialize, Deserialize, JsonSchema)]
94#[serde(rename_all = "snake_case")]
95pub enum TaskHook {
96 #[serde(alias = "create_git_worktree")]
97 CreateWorktree,
98}
99
100/// What to do with the terminal pane and tab, after the command was started.
101#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
102#[serde(rename_all = "snake_case")]
103pub enum RevealStrategy {
104 /// Always show the task's pane, and focus the corresponding tab in it.
105 #[default]
106 Always,
107 /// Always show the task's pane, add the task's tab in it, but don't focus it.
108 NoFocus,
109 /// Do not alter focus, but still add/reuse the task's tab in its pane.
110 Never,
111}
112
113/// What to do with the terminal pane and tab, after the command has finished.
114#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(rename_all = "snake_case")]
116pub enum HideStrategy {
117 /// Do nothing when the command finishes.
118 #[default]
119 Never,
120 /// Always hide the terminal tab, hide the pane also if it was the last tab in it.
121 Always,
122 /// Hide the terminal tab on task success only, otherwise behaves similar to `Always`.
123 OnSuccess,
124}
125
126/// Which edited buffers to save before running a task.
127#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[serde(rename_all = "snake_case")]
129pub enum SaveStrategy {
130 /// Save all edited buffers.
131 All,
132 /// Save the current buffer.
133 Current,
134 #[default]
135 /// Don't save any buffers.
136 None,
137}
138
139/// A group of Tasks defined in a JSON file.
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
141pub struct TaskTemplates(pub Vec<TaskTemplate>);
142
143impl TaskTemplates {
144 pub const FILE_NAME: &str = "tasks.json";
145 /// Generates JSON schema of Tasks JSON template format.
146 pub fn generate_json_schema() -> serde_json::Value {
147 let schema = schemars::generate::SchemaSettings::draft2019_09()
148 .with_transform(DefaultDenyUnknownFields)
149 .with_transform(AllowTrailingCommas)
150 .into_generator()
151 .root_schema_for::<Self>();
152
153 serde_json::to_value(schema).unwrap()
154 }
155}
156
157impl TaskTemplate {
158 /// Replaces all `VariableName` task variables in the task template string fields.
159 ///
160 /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
161 /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
162 pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
163 if self.label.trim().is_empty() || self.command.trim().is_empty() {
164 return None;
165 }
166
167 let mut variable_names = HashMap::default();
168 let mut substituted_variables = HashSet::default();
169 let task_variables = cx
170 .task_variables
171 .0
172 .iter()
173 .map(|(key, value)| {
174 let key_string = key.to_string();
175 if !variable_names.contains_key(&key_string) {
176 variable_names.insert(key_string.clone(), key.clone());
177 }
178 (key_string, value.as_str())
179 })
180 .collect::<HashMap<_, _>>();
181 let truncated_variables = truncate_variables(&task_variables);
182 let cwd = match self.cwd.as_deref() {
183 Some(cwd) => {
184 let substituted_cwd = substitute_all_template_variables_in_str(
185 cwd,
186 &task_variables,
187 &variable_names,
188 &mut substituted_variables,
189 )?;
190 Some(PathBuf::from(substituted_cwd))
191 }
192 None => None,
193 }
194 .or(cx.cwd.clone());
195 let full_label = substitute_all_template_variables_in_str(
196 &self.label,
197 &task_variables,
198 &variable_names,
199 &mut substituted_variables,
200 )?;
201
202 // Arbitrarily picked threshold below which we don't truncate any variables.
203 const TRUNCATION_THRESHOLD: usize = 64;
204
205 let human_readable_label = if full_label.len() > TRUNCATION_THRESHOLD {
206 substitute_all_template_variables_in_str(
207 &self.label,
208 &truncated_variables,
209 &variable_names,
210 &mut substituted_variables,
211 )?
212 } else {
213 #[allow(
214 clippy::redundant_clone,
215 reason = "We want to clone the full_label to avoid borrowing it in the fold closure"
216 )]
217 full_label.clone()
218 }
219 .lines()
220 .fold(String::new(), |mut string, line| {
221 if string.is_empty() {
222 string.push_str(line);
223 } else {
224 string.push_str("\\n");
225 string.push_str(line);
226 }
227 string
228 });
229
230 let command = substitute_all_template_variables_in_str(
231 &self.command,
232 &task_variables,
233 &variable_names,
234 &mut substituted_variables,
235 )?;
236 let args_with_substitutions = substitute_all_template_variables_in_vec(
237 &self.args,
238 &task_variables,
239 &variable_names,
240 &mut substituted_variables,
241 )?;
242
243 let task_hash = to_hex_hash(self)
244 .context("hashing task template")
245 .log_err()?;
246 let variables_hash = to_hex_hash(&task_variables)
247 .context("hashing task variables")
248 .log_err()?;
249 let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
250
251 let env = {
252 // Start with the project environment as the base.
253 let mut env = cx.project_env.clone();
254
255 // Extend that environment with what's defined in the TaskTemplate
256 env.extend(self.env.clone());
257
258 // Then we replace all task variables that could be set in environment variables
259 let mut env = substitute_all_template_variables_in_map(
260 &env,
261 &task_variables,
262 &variable_names,
263 &mut substituted_variables,
264 )?;
265
266 // Last step: set the task variables as environment variables too
267 env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
268 env
269 };
270
271 Some(ResolvedTask {
272 id: id.clone(),
273 substituted_variables,
274 original_task: self.clone(),
275 resolved_label: full_label.clone(),
276 resolved: SpawnInTerminal {
277 id,
278 cwd,
279 full_label,
280 label: human_readable_label,
281 command_label: args_with_substitutions.iter().fold(
282 command.clone(),
283 |mut command_label, arg| {
284 command_label.push(' ');
285 command_label.push_str(arg);
286 command_label
287 },
288 ),
289 command: Some(command),
290 args: args_with_substitutions,
291 env,
292 use_new_terminal: self.use_new_terminal,
293 allow_concurrent_runs: self.allow_concurrent_runs,
294 reveal: self.reveal,
295 reveal_target: self.reveal_target,
296 hide: self.hide,
297 shell: self.shell.clone(),
298 show_summary: self.show_summary,
299 show_command: self.show_command,
300 show_rerun: true,
301 save: self.save,
302 },
303 })
304 }
305
306 /// Validates that all `$ZED_*` variables used in this template are known
307 /// variable names, returning a vector with all of the unique unknown
308 /// variables.
309 ///
310 /// Note that `$ZED_CUSTOM_*` variables are never considered to be invalid
311 /// since those are provided dynamically by extensions.
312 pub fn unknown_variables(&self) -> Vec<String> {
313 let mut variables = HashSet::default();
314
315 Self::collect_unknown_variables(&self.label, &mut variables);
316 Self::collect_unknown_variables(&self.command, &mut variables);
317
318 self.args
319 .iter()
320 .for_each(|arg| Self::collect_unknown_variables(arg, &mut variables));
321
322 self.env
323 .values()
324 .for_each(|value| Self::collect_unknown_variables(value, &mut variables));
325
326 if let Some(cwd) = &self.cwd {
327 Self::collect_unknown_variables(cwd, &mut variables);
328 }
329
330 variables.into_iter().collect()
331 }
332
333 fn collect_unknown_variables(template: &str, unknown: &mut HashSet<String>) {
334 shellexpand::env_with_context_no_errors(template, |variable| {
335 // It's possible that the variable has a default defined, which is
336 // separated by a `:`, for example, `${ZED_FILE:default_value} so we
337 // ensure that we're only looking at the variable name itself.
338 let colon_position = variable.find(':').unwrap_or(variable.len());
339 let variable_name = &variable[..colon_position];
340
341 if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX)
342 && let without_prefix = &variable_name[ZED_VARIABLE_NAME_PREFIX.len()..]
343 && !without_prefix.starts_with("CUSTOM_")
344 && variable_name.parse::<VariableName>().is_err()
345 {
346 unknown.insert(variable_name.to_string());
347 }
348
349 None::<&str>
350 });
351 }
352}
353
354const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
355
356fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
357 task_variables
358 .iter()
359 .map(|(key, value)| {
360 (
361 key.clone(),
362 truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
363 )
364 })
365 .collect()
366}
367
368fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
369 let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
370 let mut hasher = Sha256::new();
371 hasher.update(json.as_bytes());
372 Ok(hex::encode(hasher.finalize()))
373}
374
375pub fn substitute_variables_in_str(template_str: &str, context: &TaskContext) -> Option<String> {
376 let mut variable_names = HashMap::default();
377 let mut substituted_variables = HashSet::default();
378 let task_variables = context
379 .task_variables
380 .0
381 .iter()
382 .map(|(key, value)| {
383 let key_string = key.to_string();
384 if !variable_names.contains_key(&key_string) {
385 variable_names.insert(key_string.clone(), key.clone());
386 }
387 (key_string, value.as_str())
388 })
389 .collect::<HashMap<_, _>>();
390 substitute_all_template_variables_in_str(
391 template_str,
392 &task_variables,
393 &variable_names,
394 &mut substituted_variables,
395 )
396}
397fn substitute_all_template_variables_in_str<A: AsRef<str>>(
398 template_str: &str,
399 task_variables: &HashMap<String, A>,
400 variable_names: &HashMap<String, VariableName>,
401 substituted_variables: &mut HashSet<VariableName>,
402) -> Option<String> {
403 let substituted_string = shellexpand::env_with_context(template_str, |var| {
404 // Colons denote a default value in case the variable is not set. We
405 // want to preserve that default, as otherwise shellexpand will
406 // substitute it for us.
407 let colon_position = var.find(':').unwrap_or(var.len());
408 let (variable_name, default) = var.split_at(colon_position);
409 if let Some(name) = task_variables.get(variable_name) {
410 if let Some(substituted_variable) = variable_names.get(variable_name) {
411 substituted_variables.insert(substituted_variable.clone());
412 }
413 // Got a task variable hit - use the variable value, ignore default
414 return Ok(Some(name.as_ref().to_owned()));
415 } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
416 // Unknown ZED variable - use default if available
417 if !default.is_empty() {
418 // Strip the colon and return the default value
419 return Ok(Some(default[1..].to_owned()));
420 } else {
421 bail!("Unknown variable name: {variable_name}");
422 }
423 }
424 // This is an unknown variable.
425 // We should not error out, as they may come from user environment (e.g.
426 // $PATH). That means that the variable substitution might not be
427 // perfect. If there's a default, we need to return the string verbatim
428 // as otherwise shellexpand will apply that default for us.
429 if !default.is_empty() {
430 return Ok(Some(format!("${{{var}}}")));
431 }
432
433 // Else we can just return None and that variable will be left as is.
434 Ok(None)
435 })
436 .ok()?;
437
438 Some(substituted_string.into_owned())
439}
440
441fn substitute_all_template_variables_in_vec(
442 template_strs: &[String],
443 task_variables: &HashMap<String, &str>,
444 variable_names: &HashMap<String, VariableName>,
445 substituted_variables: &mut HashSet<VariableName>,
446) -> Option<Vec<String>> {
447 let mut expanded = Vec::with_capacity(template_strs.len());
448 for variable in template_strs {
449 let new_value = substitute_all_template_variables_in_str(
450 variable,
451 task_variables,
452 variable_names,
453 substituted_variables,
454 )?;
455 expanded.push(new_value);
456 }
457
458 Some(expanded)
459}
460
461pub fn substitute_variables_in_map(
462 keys_and_values: &HashMap<String, String>,
463 context: &TaskContext,
464) -> Option<HashMap<String, String>> {
465 let mut variable_names = HashMap::default();
466 let mut substituted_variables = HashSet::default();
467 let task_variables = context
468 .task_variables
469 .0
470 .iter()
471 .map(|(key, value)| {
472 let key_string = key.to_string();
473 if !variable_names.contains_key(&key_string) {
474 variable_names.insert(key_string.clone(), key.clone());
475 }
476 (key_string, value.as_str())
477 })
478 .collect::<HashMap<_, _>>();
479 substitute_all_template_variables_in_map(
480 keys_and_values,
481 &task_variables,
482 &variable_names,
483 &mut substituted_variables,
484 )
485}
486fn substitute_all_template_variables_in_map(
487 keys_and_values: &HashMap<String, String>,
488 task_variables: &HashMap<String, &str>,
489 variable_names: &HashMap<String, VariableName>,
490 substituted_variables: &mut HashSet<VariableName>,
491) -> Option<HashMap<String, String>> {
492 let mut new_map: HashMap<String, String> = Default::default();
493 for (key, value) in keys_and_values {
494 let new_value = substitute_all_template_variables_in_str(
495 value,
496 task_variables,
497 variable_names,
498 substituted_variables,
499 )?;
500 let new_key = substitute_all_template_variables_in_str(
501 key,
502 task_variables,
503 variable_names,
504 substituted_variables,
505 )?;
506 new_map.insert(new_key, new_value);
507 }
508
509 Some(new_map)
510}
511
512#[cfg(test)]
513mod tests {
514 use std::{
515 borrow::Cow,
516 path::{Path, PathBuf},
517 };
518
519 use crate::{TaskVariables, VariableName};
520
521 use super::*;
522
523 const TEST_ID_BASE: &str = "test_base";
524
525 #[test]
526 fn test_resolving_templates_with_blank_command_and_label() {
527 let task_with_all_properties = TaskTemplate {
528 label: "test_label".to_string(),
529 command: "test_command".to_string(),
530 args: vec!["test_arg".to_string()],
531 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
532 ..TaskTemplate::default()
533 };
534
535 for task_with_blank_property in &[
536 TaskTemplate {
537 label: "".to_string(),
538 ..task_with_all_properties.clone()
539 },
540 TaskTemplate {
541 command: "".to_string(),
542 ..task_with_all_properties.clone()
543 },
544 TaskTemplate {
545 label: "".to_string(),
546 command: "".to_string(),
547 ..task_with_all_properties
548 },
549 ] {
550 assert_eq!(
551 task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
552 None,
553 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
554 );
555 }
556 }
557
558 #[test]
559 fn test_template_cwd_resolution() {
560 let task_without_cwd = TaskTemplate {
561 cwd: None,
562 label: "test task".to_string(),
563 command: "echo 4".to_string(),
564 ..TaskTemplate::default()
565 };
566
567 let resolved_task = |task_template: &TaskTemplate, task_cx| {
568 let resolved_task = task_template
569 .resolve_task(TEST_ID_BASE, task_cx)
570 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
571 assert_substituted_variables(&resolved_task, Vec::new());
572 resolved_task.resolved
573 };
574
575 let cx = TaskContext {
576 cwd: None,
577 task_variables: TaskVariables::default(),
578 project_env: HashMap::default(),
579 };
580 assert_eq!(
581 resolved_task(&task_without_cwd, &cx).cwd,
582 None,
583 "When neither task nor task context have cwd, it should be None"
584 );
585
586 let context_cwd = Path::new("a").join("b").join("c");
587 let cx = TaskContext {
588 cwd: Some(context_cwd.clone()),
589 task_variables: TaskVariables::default(),
590 project_env: HashMap::default(),
591 };
592 assert_eq!(
593 resolved_task(&task_without_cwd, &cx).cwd,
594 Some(context_cwd.clone()),
595 "TaskContext's cwd should be taken on resolve if task's cwd is None"
596 );
597
598 let task_cwd = Path::new("d").join("e").join("f");
599 let mut task_with_cwd = task_without_cwd.clone();
600 task_with_cwd.cwd = Some(task_cwd.display().to_string());
601 let task_with_cwd = task_with_cwd;
602
603 let cx = TaskContext {
604 cwd: None,
605 task_variables: TaskVariables::default(),
606 project_env: HashMap::default(),
607 };
608 assert_eq!(
609 resolved_task(&task_with_cwd, &cx).cwd,
610 Some(task_cwd.clone()),
611 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
612 );
613
614 let cx = TaskContext {
615 cwd: Some(context_cwd),
616 task_variables: TaskVariables::default(),
617 project_env: HashMap::default(),
618 };
619 assert_eq!(
620 resolved_task(&task_with_cwd, &cx).cwd,
621 Some(task_cwd),
622 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
623 );
624 }
625
626 #[test]
627 fn test_template_variables_resolution() {
628 let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
629 let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
630 let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
631 let all_variables = [
632 (VariableName::Row, "1234".to_string()),
633 (VariableName::Column, "5678".to_string()),
634 (VariableName::File, "test_file".to_string()),
635 (VariableName::SelectedText, "test_selected_text".to_string()),
636 (VariableName::Symbol, long_value.clone()),
637 (VariableName::WorktreeRoot, "/test_root/".to_string()),
638 (
639 custom_variable_1.clone(),
640 "test_custom_variable_1".to_string(),
641 ),
642 (
643 custom_variable_2.clone(),
644 "test_custom_variable_2".to_string(),
645 ),
646 ];
647
648 let task_with_all_variables = TaskTemplate {
649 label: format!(
650 "test label for {} and {}",
651 VariableName::Row.template_value(),
652 VariableName::Symbol.template_value(),
653 ),
654 command: format!(
655 "echo {} {}",
656 VariableName::File.template_value(),
657 VariableName::Symbol.template_value(),
658 ),
659 args: vec![
660 format!("arg1 {}", VariableName::SelectedText.template_value()),
661 format!("arg2 {}", VariableName::Column.template_value()),
662 format!("arg3 {}", VariableName::Symbol.template_value()),
663 ],
664 env: HashMap::from_iter([
665 ("test_env_key".to_string(), "test_env_var".to_string()),
666 (
667 "env_key_1".to_string(),
668 VariableName::WorktreeRoot.template_value(),
669 ),
670 (
671 "env_key_2".to_string(),
672 format!(
673 "env_var_2 {} {}",
674 custom_variable_1.template_value(),
675 custom_variable_2.template_value()
676 ),
677 ),
678 (
679 "env_key_3".to_string(),
680 format!("env_var_3 {}", VariableName::Symbol.template_value()),
681 ),
682 ]),
683 ..TaskTemplate::default()
684 };
685
686 let mut first_resolved_id = None;
687 for i in 0..15 {
688 let resolved_task = task_with_all_variables.resolve_task(
689 TEST_ID_BASE,
690 &TaskContext {
691 cwd: None,
692 task_variables: TaskVariables::from_iter(all_variables.clone()),
693 project_env: HashMap::default(),
694 },
695 ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
696
697 match &first_resolved_id {
698 None => first_resolved_id = Some(resolved_task.id.clone()),
699 Some(first_id) => assert_eq!(
700 &resolved_task.id, first_id,
701 "Step {i}, for the same task template and context, there should be the same resolved task id"
702 ),
703 }
704
705 assert_eq!(
706 resolved_task.original_task, task_with_all_variables,
707 "Resolved task should store its template without changes"
708 );
709 assert_eq!(
710 resolved_task.resolved_label,
711 format!("test label for 1234 and {long_value}"),
712 "Resolved task label should be substituted with variables and those should not be shortened"
713 );
714 assert_substituted_variables(
715 &resolved_task,
716 all_variables.iter().map(|(name, _)| name.clone()).collect(),
717 );
718
719 let spawn_in_terminal = &resolved_task.resolved;
720 assert_eq!(
721 spawn_in_terminal.label,
722 format!(
723 "test label for 1234 and …{}",
724 &long_value[long_value.len() - MAX_DISPLAY_VARIABLE_LENGTH..]
725 ),
726 "Human-readable label should have long substitutions trimmed"
727 );
728 assert_eq!(
729 spawn_in_terminal.command.clone().unwrap(),
730 format!("echo test_file {long_value}"),
731 "Command should be substituted with variables and those should not be shortened"
732 );
733 assert_eq!(
734 spawn_in_terminal.args,
735 &[
736 "arg1 test_selected_text",
737 "arg2 5678",
738 "arg3 010101010101010101010101010101010101010101010101010101010101",
739 ],
740 "Args should be substituted with variables"
741 );
742 assert_eq!(
743 spawn_in_terminal.command_label,
744 format!(
745 "{} arg1 test_selected_text arg2 5678 arg3 {long_value}",
746 spawn_in_terminal.command.clone().unwrap()
747 ),
748 "Command label args should be substituted with variables and those should not be shortened"
749 );
750
751 assert_eq!(
752 spawn_in_terminal
753 .env
754 .get("test_env_key")
755 .map(|s| s.as_str()),
756 Some("test_env_var")
757 );
758 assert_eq!(
759 spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
760 Some("/test_root/")
761 );
762 assert_eq!(
763 spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
764 Some("env_var_2 test_custom_variable_1 test_custom_variable_2")
765 );
766 assert_eq!(
767 spawn_in_terminal.env.get("env_key_3"),
768 Some(&format!("env_var_3 {long_value}")),
769 "Env vars should be substituted with variables and those should not be shortened"
770 );
771 }
772
773 for i in 0..all_variables.len() {
774 let mut not_all_variables = all_variables.to_vec();
775 let removed_variable = not_all_variables.remove(i);
776 let resolved_task_attempt = task_with_all_variables.resolve_task(
777 TEST_ID_BASE,
778 &TaskContext {
779 cwd: None,
780 task_variables: TaskVariables::from_iter(not_all_variables),
781 project_env: HashMap::default(),
782 },
783 );
784 assert!(
785 matches!(resolved_task_attempt, None),
786 "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})"
787 );
788 }
789 }
790
791 #[test]
792 fn test_can_resolve_free_variables() {
793 let task = TaskTemplate {
794 label: "My task".into(),
795 command: "echo".into(),
796 args: vec!["$PATH".into()],
797 ..TaskTemplate::default()
798 };
799 let resolved_task = task
800 .resolve_task(TEST_ID_BASE, &TaskContext::default())
801 .unwrap();
802 assert_substituted_variables(&resolved_task, Vec::new());
803 let resolved = resolved_task.resolved;
804 assert_eq!(resolved.label, task.label);
805 assert_eq!(resolved.command, Some(task.command));
806 assert_eq!(resolved.args, task.args);
807 }
808
809 #[test]
810 fn test_errors_on_missing_zed_variable() {
811 let task = TaskTemplate {
812 label: "My task".into(),
813 command: "echo".into(),
814 args: vec!["$ZED_VARIABLE".into()],
815 ..TaskTemplate::default()
816 };
817 assert!(
818 task.resolve_task(TEST_ID_BASE, &TaskContext::default())
819 .is_none()
820 );
821 }
822
823 #[test]
824 fn test_symbol_dependent_tasks() {
825 let task_with_all_properties = TaskTemplate {
826 label: "test_label".to_string(),
827 command: "test_command".to_string(),
828 args: vec!["test_arg".to_string()],
829 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
830 ..TaskTemplate::default()
831 };
832 let cx = TaskContext {
833 cwd: None,
834 task_variables: TaskVariables::from_iter(Some((
835 VariableName::Symbol,
836 "test_symbol".to_string(),
837 ))),
838 project_env: HashMap::default(),
839 };
840
841 for (i, symbol_dependent_task) in [
842 TaskTemplate {
843 label: format!("test_label_{}", VariableName::Symbol.template_value()),
844 ..task_with_all_properties.clone()
845 },
846 TaskTemplate {
847 command: format!("test_command_{}", VariableName::Symbol.template_value()),
848 ..task_with_all_properties.clone()
849 },
850 TaskTemplate {
851 args: vec![format!(
852 "test_arg_{}",
853 VariableName::Symbol.template_value()
854 )],
855 ..task_with_all_properties.clone()
856 },
857 TaskTemplate {
858 env: HashMap::from_iter([(
859 "test_env_key".to_string(),
860 format!("test_env_var_{}", VariableName::Symbol.template_value()),
861 )]),
862 ..task_with_all_properties
863 },
864 ]
865 .into_iter()
866 .enumerate()
867 {
868 let resolved = symbol_dependent_task
869 .resolve_task(TEST_ID_BASE, &cx)
870 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
871 assert_eq!(
872 resolved.substituted_variables,
873 HashSet::from_iter(Some(VariableName::Symbol)),
874 "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
875 )
876 }
877 }
878
879 #[track_caller]
880 fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
881 let mut resolved_variables = resolved_task
882 .substituted_variables
883 .iter()
884 .cloned()
885 .collect::<Vec<_>>();
886 resolved_variables.sort_by_key(|var| var.to_string());
887 expected.sort_by_key(|var| var.to_string());
888 assert_eq!(resolved_variables, expected)
889 }
890
891 #[test]
892 fn substitute_funky_labels() {
893 let faulty_go_test = TaskTemplate {
894 label: format!(
895 "go test {}/{}",
896 VariableName::Symbol.template_value(),
897 VariableName::Symbol.template_value(),
898 ),
899 command: "go".into(),
900 args: vec![format!(
901 "^{}$/^{}$",
902 VariableName::Symbol.template_value(),
903 VariableName::Symbol.template_value()
904 )],
905 ..TaskTemplate::default()
906 };
907 let mut context = TaskContext::default();
908 context
909 .task_variables
910 .insert(VariableName::Symbol, "my-symbol".to_string());
911 assert!(faulty_go_test.resolve_task("base", &context).is_some());
912 }
913
914 #[test]
915 fn test_project_env() {
916 let all_variables = [
917 (VariableName::Row, "1234".to_string()),
918 (VariableName::Column, "5678".to_string()),
919 (VariableName::File, "test_file".to_string()),
920 (VariableName::Symbol, "my symbol".to_string()),
921 ];
922
923 let template = TaskTemplate {
924 label: "my task".to_string(),
925 command: format!(
926 "echo {} {}",
927 VariableName::File.template_value(),
928 VariableName::Symbol.template_value(),
929 ),
930 args: vec![],
931 env: HashMap::from_iter([
932 (
933 "TASK_ENV_VAR1".to_string(),
934 "TASK_ENV_VAR1_VALUE".to_string(),
935 ),
936 (
937 "TASK_ENV_VAR2".to_string(),
938 format!(
939 "env_var_2 {} {}",
940 VariableName::Row.template_value(),
941 VariableName::Column.template_value()
942 ),
943 ),
944 (
945 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
946 "overwritten".to_string(),
947 ),
948 ]),
949 ..TaskTemplate::default()
950 };
951
952 let project_env = HashMap::from_iter([
953 (
954 "PROJECT_ENV_VAR1".to_string(),
955 "PROJECT_ENV_VAR1_VALUE".to_string(),
956 ),
957 (
958 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
959 "PROJECT_ENV_WILL_BE_OVERWRITTEN_VALUE".to_string(),
960 ),
961 ]);
962
963 let context = TaskContext {
964 cwd: None,
965 task_variables: TaskVariables::from_iter(all_variables),
966 project_env,
967 };
968
969 let resolved = template
970 .resolve_task(TEST_ID_BASE, &context)
971 .unwrap()
972 .resolved;
973
974 assert_eq!(resolved.env["TASK_ENV_VAR1"], "TASK_ENV_VAR1_VALUE");
975 assert_eq!(resolved.env["TASK_ENV_VAR2"], "env_var_2 1234 5678");
976 assert_eq!(resolved.env["PROJECT_ENV_VAR1"], "PROJECT_ENV_VAR1_VALUE");
977 assert_eq!(
978 resolved.env["PROJECT_ENV_WILL_BE_OVERWRITTEN"],
979 "overwritten"
980 );
981 }
982
983 #[test]
984 fn test_variable_default_values() {
985 let task_with_defaults = TaskTemplate {
986 label: "test with defaults".to_string(),
987 command: format!(
988 "echo ${{{}}}",
989 VariableName::File.to_string() + ":fallback.txt"
990 ),
991 args: vec![
992 "${ZED_MISSING_VAR:default_value}".to_string(),
993 format!("${{{}}}", VariableName::Row.to_string() + ":42"),
994 ],
995 ..TaskTemplate::default()
996 };
997
998 // Test 1: When ZED_FILE exists, should use actual value and ignore default
999 let context_with_file = TaskContext {
1000 cwd: None,
1001 task_variables: TaskVariables::from_iter(vec![
1002 (VariableName::File, "actual_file.rs".to_string()),
1003 (VariableName::Row, "123".to_string()),
1004 ]),
1005 project_env: HashMap::default(),
1006 };
1007
1008 let resolved = task_with_defaults
1009 .resolve_task(TEST_ID_BASE, &context_with_file)
1010 .expect("Should resolve task with existing variables");
1011
1012 assert_eq!(
1013 resolved.resolved.command.unwrap(),
1014 "echo actual_file.rs",
1015 "Should use actual ZED_FILE value, not default"
1016 );
1017 assert_eq!(
1018 resolved.resolved.args,
1019 vec!["default_value", "123"],
1020 "Should use default for missing var, actual value for existing var"
1021 );
1022
1023 // Test 2: When ZED_FILE doesn't exist, should use default value
1024 let context_without_file = TaskContext {
1025 cwd: None,
1026 task_variables: TaskVariables::from_iter(vec![(VariableName::Row, "456".to_string())]),
1027 project_env: HashMap::default(),
1028 };
1029
1030 let resolved = task_with_defaults
1031 .resolve_task(TEST_ID_BASE, &context_without_file)
1032 .expect("Should resolve task using default values");
1033
1034 assert_eq!(
1035 resolved.resolved.command.unwrap(),
1036 "echo fallback.txt",
1037 "Should use default value when ZED_FILE is missing"
1038 );
1039 assert_eq!(
1040 resolved.resolved.args,
1041 vec!["default_value", "456"],
1042 "Should use defaults for missing vars"
1043 );
1044
1045 // Test 3: Missing ZED variable without default should fail
1046 let task_no_default = TaskTemplate {
1047 label: "test no default".to_string(),
1048 command: "${ZED_MISSING_NO_DEFAULT}".to_string(),
1049 ..TaskTemplate::default()
1050 };
1051
1052 assert!(
1053 task_no_default
1054 .resolve_task(TEST_ID_BASE, &TaskContext::default())
1055 .is_none(),
1056 "Should fail when ZED variable has no default and doesn't exist"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_unknown_variables() {
1062 // Variable names starting with `ZED_` that are not valid should be
1063 // reported.
1064 let label = "test unknown variables".to_string();
1065 let command = "$ZED_UNKNOWN".to_string();
1066 let task = TaskTemplate {
1067 label,
1068 command,
1069 ..TaskTemplate::default()
1070 };
1071
1072 assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
1073
1074 // Variable names starting with `ZED_CUSTOM_` should never be reported,
1075 // as those are dynamically provided by extensions.
1076 let label = "test custom variables".to_string();
1077 let command = "$ZED_CUSTOM_UNKNOWN".to_string();
1078 let task = TaskTemplate {
1079 label,
1080 command,
1081 ..TaskTemplate::default()
1082 };
1083
1084 assert!(task.unknown_variables().is_empty());
1085
1086 // Unknown variable names with defaults should still be reported,
1087 // otherwise the default would always be silently used.
1088 let label = "test custom variables".to_string();
1089 let command = "${ZED_UNKNOWN:default_value}".to_string();
1090 let task = TaskTemplate {
1091 label,
1092 command,
1093 ..TaskTemplate::default()
1094 };
1095
1096 assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
1097
1098 // Valid variable names are not reported.
1099 let label = "test custom variables".to_string();
1100 let command = "$ZED_FILE".to_string();
1101 let task = TaskTemplate {
1102 label,
1103 command,
1104 ..TaskTemplate::default()
1105 };
1106 assert!(task.unknown_variables().is_empty());
1107 }
1108
1109 #[test]
1110 fn test_git_variables_resolution() {
1111 let task = TaskTemplate {
1112 label: "Show $ZED_GIT_SHA_SHORT in $ZED_GIT_REPOSITORY_NAME".to_string(),
1113 command: "git".to_string(),
1114 args: vec!["show".to_string(), "$ZED_GIT_SHA".to_string()],
1115 cwd: Some("$ZED_GIT_REPOSITORY_PATH".to_string()),
1116 env: HashMap::from_iter([("COMMIT".to_string(), "$ZED_GIT_SHA".to_string())]),
1117 ..TaskTemplate::default()
1118 };
1119 let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string();
1120 let sha_short = "0123456".to_string();
1121 let repo_name = "zed".to_string();
1122 let repo_path = format!("/Users/example/{repo_name}");
1123
1124 let context = TaskContext {
1125 task_variables: TaskVariables::from_iter([
1126 (VariableName::GitSha, sha.clone()),
1127 (VariableName::GitShaShort, sha_short.clone()),
1128 (VariableName::GitRepositoryName, repo_name.clone()),
1129 (VariableName::GitRepositoryPath, repo_path.clone()),
1130 ]),
1131 ..TaskContext::default()
1132 };
1133
1134 let task = task.resolve_task(TEST_ID_BASE, &context).unwrap();
1135 assert_eq!(
1136 task.resolved_label,
1137 format!("Show {sha_short} in {repo_name}")
1138 );
1139 assert_eq!(task.resolved.command, Some("git".to_string()));
1140 assert_eq!(task.resolved.args, vec!["show".to_string(), sha.clone()]);
1141 assert_eq!(task.resolved.cwd, Some(PathBuf::from(repo_path)));
1142 assert_eq!(task.resolved.env.get("COMMIT"), Some(&sha));
1143
1144 assert_substituted_variables(
1145 &task,
1146 vec![
1147 VariableName::GitSha,
1148 VariableName::GitShaShort,
1149 VariableName::GitRepositoryName,
1150 VariableName::GitRepositoryPath,
1151 ],
1152 );
1153 }
1154}
1155