Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:26:00.952Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

task_template.rs

1155 lines · 43.5 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.