task_inventory.rs

  1//! Project-wide storage of the tasks available, capable of updating itself from the sources set.
  2
  3use std::{
  4    cmp::{self, Reverse},
  5    path::{Path, PathBuf},
  6    sync::Arc,
  7};
  8
  9use collections::{btree_map, BTreeMap, VecDeque};
 10use gpui::{AppContext, Context, Model, ModelContext};
 11use itertools::Itertools;
 12use language::Language;
 13use task::{
 14    static_source::StaticSource, ResolvedTask, TaskContext, TaskId, TaskTemplate, VariableName,
 15};
 16use util::{post_inc, NumericPrefixWithSuffix};
 17use worktree::WorktreeId;
 18
 19/// Inventory tracks available tasks for a given project.
 20pub struct Inventory {
 21    sources: Vec<SourceInInventory>,
 22    last_scheduled_tasks: VecDeque<(TaskSourceKind, ResolvedTask)>,
 23}
 24
 25struct SourceInInventory {
 26    source: StaticSource,
 27    kind: TaskSourceKind,
 28}
 29
 30/// Kind of a source the tasks are fetched from, used to display more source information in the UI.
 31#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
 32pub enum TaskSourceKind {
 33    /// bash-like commands spawned by users, not associated with any path
 34    UserInput,
 35    /// Tasks from the worktree's .zed/task.json
 36    Worktree {
 37        id: WorktreeId,
 38        abs_path: PathBuf,
 39        id_base: &'static str,
 40    },
 41    /// ~/.config/zed/task.json - like global files with task definitions, applicable to any path
 42    AbsPath {
 43        id_base: &'static str,
 44        abs_path: PathBuf,
 45    },
 46    /// Languages-specific tasks coming from extensions.
 47    Language { name: Arc<str> },
 48}
 49
 50impl TaskSourceKind {
 51    pub fn abs_path(&self) -> Option<&Path> {
 52        match self {
 53            Self::AbsPath { abs_path, .. } | Self::Worktree { abs_path, .. } => Some(abs_path),
 54            Self::UserInput | Self::Language { .. } => None,
 55        }
 56    }
 57
 58    pub fn worktree(&self) -> Option<WorktreeId> {
 59        match self {
 60            Self::Worktree { id, .. } => Some(*id),
 61            _ => None,
 62        }
 63    }
 64
 65    pub fn to_id_base(&self) -> String {
 66        match self {
 67            TaskSourceKind::UserInput => "oneshot".to_string(),
 68            TaskSourceKind::AbsPath { id_base, abs_path } => {
 69                format!("{id_base}_{}", abs_path.display())
 70            }
 71            TaskSourceKind::Worktree {
 72                id,
 73                id_base,
 74                abs_path,
 75            } => {
 76                format!("{id_base}_{id}_{}", abs_path.display())
 77            }
 78            TaskSourceKind::Language { name } => format!("language_{name}"),
 79        }
 80    }
 81}
 82
 83impl Inventory {
 84    pub fn new(cx: &mut AppContext) -> Model<Self> {
 85        cx.new_model(|_| Self {
 86            sources: Vec::new(),
 87            last_scheduled_tasks: VecDeque::new(),
 88        })
 89    }
 90
 91    /// If the task with the same path was not added yet,
 92    /// registers a new tasks source to fetch for available tasks later.
 93    /// Unless a source is removed, ignores future additions for the same path.
 94    pub fn add_source(
 95        &mut self,
 96        kind: TaskSourceKind,
 97        source: StaticSource,
 98        cx: &mut ModelContext<Self>,
 99    ) {
100        let abs_path = kind.abs_path();
101        if abs_path.is_some() {
102            if let Some(a) = self.sources.iter().find(|s| s.kind.abs_path() == abs_path) {
103                log::debug!("Source for path {abs_path:?} already exists, not adding. Old kind: {OLD_KIND:?}, new kind: {kind:?}", OLD_KIND = a.kind);
104                return;
105            }
106        }
107
108        let source = SourceInInventory { source, kind };
109        self.sources.push(source);
110        cx.notify();
111    }
112
113    /// If present, removes the local static source entry that has the given path,
114    /// making corresponding task definitions unavailable in the fetch results.
115    ///
116    /// Now, entry for this path can be re-added again.
117    pub fn remove_local_static_source(&mut self, abs_path: &Path) {
118        self.sources.retain(|s| s.kind.abs_path() != Some(abs_path));
119    }
120
121    /// If present, removes the worktree source entry that has the given worktree id,
122    /// making corresponding task definitions unavailable in the fetch results.
123    ///
124    /// Now, entry for this path can be re-added again.
125    pub fn remove_worktree_sources(&mut self, worktree: WorktreeId) {
126        self.sources.retain(|s| s.kind.worktree() != Some(worktree));
127    }
128
129    /// Pulls its task sources relevant to the worktree and the language given,
130    /// returns all task templates with their source kinds, in no specific order.
131    pub fn list_tasks(
132        &self,
133        language: Option<Arc<Language>>,
134        worktree: Option<WorktreeId>,
135    ) -> Vec<(TaskSourceKind, TaskTemplate)> {
136        let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
137            name: language.name(),
138        });
139        let language_tasks = language
140            .and_then(|language| language.context_provider()?.associated_tasks())
141            .into_iter()
142            .flat_map(|tasks| tasks.0.into_iter())
143            .flat_map(|task| Some((task_source_kind.as_ref()?, task)));
144
145        self.sources
146            .iter()
147            .filter(|source| {
148                let source_worktree = source.kind.worktree();
149                worktree.is_none() || source_worktree.is_none() || source_worktree == worktree
150            })
151            .flat_map(|source| {
152                source
153                    .source
154                    .tasks_to_schedule()
155                    .0
156                    .into_iter()
157                    .map(|task| (&source.kind, task))
158            })
159            .chain(language_tasks)
160            .map(|(task_source_kind, task)| (task_source_kind.clone(), task))
161            .collect()
162    }
163
164    /// Pulls its task sources relevant to the worktree and the language given and resolves them with the [`TaskContext`] given.
165    /// Joins the new resolutions with the resolved tasks that were used (spawned) before,
166    /// orders them so that the most recently used come first, all equally used ones are ordered so that the most specific tasks come first.
167    /// Deduplicates the tasks by their labels and splits the ordered list into two: used tasks and the rest, newly resolved tasks.
168    pub fn used_and_current_resolved_tasks(
169        &self,
170        language: Option<Arc<Language>>,
171        worktree: Option<WorktreeId>,
172        task_context: &TaskContext,
173    ) -> (
174        Vec<(TaskSourceKind, ResolvedTask)>,
175        Vec<(TaskSourceKind, ResolvedTask)>,
176    ) {
177        let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
178            name: language.name(),
179        });
180        let language_tasks = language
181            .and_then(|language| language.context_provider()?.associated_tasks())
182            .into_iter()
183            .flat_map(|tasks| tasks.0.into_iter())
184            .flat_map(|task| Some((task_source_kind.as_ref()?, task)));
185
186        let mut lru_score = 0_u32;
187        let mut task_usage = self
188            .last_scheduled_tasks
189            .iter()
190            .rev()
191            .filter(|(task_kind, _)| {
192                if matches!(task_kind, TaskSourceKind::Language { .. }) {
193                    Some(task_kind) == task_source_kind.as_ref()
194                } else {
195                    true
196                }
197            })
198            .fold(
199                BTreeMap::default(),
200                |mut tasks, (task_source_kind, resolved_task)| {
201                    tasks.entry(&resolved_task.id).or_insert_with(|| {
202                        (task_source_kind, resolved_task, post_inc(&mut lru_score))
203                    });
204                    tasks
205                },
206            );
207        let not_used_score = post_inc(&mut lru_score);
208        let currently_resolved_tasks = self
209            .sources
210            .iter()
211            .filter(|source| {
212                let source_worktree = source.kind.worktree();
213                worktree.is_none() || source_worktree.is_none() || source_worktree == worktree
214            })
215            .flat_map(|source| {
216                source
217                    .source
218                    .tasks_to_schedule()
219                    .0
220                    .into_iter()
221                    .map(|task| (&source.kind, task))
222            })
223            .chain(language_tasks)
224            .filter_map(|(kind, task)| {
225                let id_base = kind.to_id_base();
226                Some((kind, task.resolve_task(&id_base, task_context)?))
227            })
228            .map(|(kind, task)| {
229                let lru_score = task_usage
230                    .remove(&task.id)
231                    .map(|(_, _, lru_score)| lru_score)
232                    .unwrap_or(not_used_score);
233                (kind.clone(), task, lru_score)
234            })
235            .collect::<Vec<_>>();
236        let previously_spawned_tasks = task_usage
237            .into_iter()
238            .map(|(_, (kind, task, lru_score))| (kind.clone(), task.clone(), lru_score));
239
240        let mut tasks_by_label = BTreeMap::default();
241        tasks_by_label = previously_spawned_tasks.into_iter().fold(
242            tasks_by_label,
243            |mut tasks_by_label, (source, task, lru_score)| {
244                match tasks_by_label.entry((source, task.resolved_label.clone())) {
245                    btree_map::Entry::Occupied(mut o) => {
246                        let (_, previous_lru_score) = o.get();
247                        if previous_lru_score >= &lru_score {
248                            o.insert((task, lru_score));
249                        }
250                    }
251                    btree_map::Entry::Vacant(v) => {
252                        v.insert((task, lru_score));
253                    }
254                }
255                tasks_by_label
256            },
257        );
258        tasks_by_label = currently_resolved_tasks.iter().fold(
259            tasks_by_label,
260            |mut tasks_by_label, (source, task, lru_score)| {
261                match tasks_by_label.entry((source.clone(), task.resolved_label.clone())) {
262                    btree_map::Entry::Occupied(mut o) => {
263                        let (previous_task, _) = o.get();
264                        let new_template = task.original_task();
265                        if new_template != previous_task.original_task() {
266                            o.insert((task.clone(), *lru_score));
267                        }
268                    }
269                    btree_map::Entry::Vacant(v) => {
270                        v.insert((task.clone(), *lru_score));
271                    }
272                }
273                tasks_by_label
274            },
275        );
276
277        let resolved = tasks_by_label
278            .into_iter()
279            .map(|((kind, _), (task, lru_score))| (kind, task, lru_score))
280            .sorted_by(task_lru_comparator)
281            .filter_map(|(kind, task, lru_score)| {
282                if lru_score < not_used_score {
283                    Some((kind, task))
284                } else {
285                    None
286                }
287            })
288            .collect();
289
290        (
291            resolved,
292            currently_resolved_tasks
293                .into_iter()
294                .sorted_unstable_by(task_lru_comparator)
295                .map(|(kind, task, _)| (kind, task))
296                .collect(),
297        )
298    }
299
300    /// Returns the last scheduled task, if any of the sources contains one with the matching id.
301    pub fn last_scheduled_task(&self) -> Option<(TaskSourceKind, ResolvedTask)> {
302        self.last_scheduled_tasks.back().cloned()
303    }
304
305    /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.
306    pub fn task_scheduled(
307        &mut self,
308        task_source_kind: TaskSourceKind,
309        resolved_task: ResolvedTask,
310    ) {
311        self.last_scheduled_tasks
312            .push_back((task_source_kind, resolved_task));
313        if self.last_scheduled_tasks.len() > 5_000 {
314            self.last_scheduled_tasks.pop_front();
315        }
316    }
317
318    /// Deletes a resolved task from history, using its id.
319    /// A similar may still resurface in `used_and_current_resolved_tasks` when its [`TaskTemplate`] is resolved again.
320    pub fn delete_previously_used(&mut self, id: &TaskId) {
321        self.last_scheduled_tasks.retain(|(_, task)| &task.id != id);
322    }
323}
324
325fn task_lru_comparator(
326    (kind_a, task_a, lru_score_a): &(TaskSourceKind, ResolvedTask, u32),
327    (kind_b, task_b, lru_score_b): &(TaskSourceKind, ResolvedTask, u32),
328) -> cmp::Ordering {
329    lru_score_a
330        // First, display recently used templates above all.
331        .cmp(&lru_score_b)
332        // Then, ensure more specific sources are displayed first.
333        .then(task_source_kind_preference(kind_a).cmp(&task_source_kind_preference(kind_b)))
334        // After that, display first more specific tasks, using more template variables.
335        // Bonus points for tasks with symbol variables.
336        .then(task_variables_preference(task_a).cmp(&task_variables_preference(task_b)))
337        // Finally, sort by the resolved label, but a bit more specifically, to avoid mixing letters and digits.
338        .then({
339            NumericPrefixWithSuffix::from_numeric_prefixed_str(&task_a.resolved_label)
340                .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
341                    &task_b.resolved_label,
342                ))
343                .then(task_a.resolved_label.cmp(&task_b.resolved_label))
344                .then(kind_a.cmp(kind_b))
345        })
346}
347
348fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 {
349    match kind {
350        TaskSourceKind::Language { .. } => 1,
351        TaskSourceKind::UserInput => 2,
352        TaskSourceKind::Worktree { .. } => 3,
353        TaskSourceKind::AbsPath { .. } => 4,
354    }
355}
356
357fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
358    let task_variables = task.substituted_variables();
359    Reverse(if task_variables.contains(&VariableName::Symbol) {
360        task_variables.len() + 1
361    } else {
362        task_variables.len()
363    })
364}
365
366#[cfg(test)]
367mod test_inventory {
368    use gpui::{AppContext, Model, TestAppContext};
369    use itertools::Itertools;
370    use task::{
371        static_source::{StaticSource, TrackedFile},
372        TaskContext, TaskTemplate, TaskTemplates,
373    };
374    use worktree::WorktreeId;
375
376    use crate::Inventory;
377
378    use super::{task_source_kind_preference, TaskSourceKind};
379
380    #[derive(Debug, Clone, PartialEq, Eq)]
381    pub struct TestTask {
382        name: String,
383    }
384
385    pub(super) fn static_test_source(
386        task_names: impl IntoIterator<Item = String>,
387        cx: &mut AppContext,
388    ) -> StaticSource {
389        let tasks = TaskTemplates(
390            task_names
391                .into_iter()
392                .map(|name| TaskTemplate {
393                    label: name,
394                    command: "test command".to_owned(),
395                    ..TaskTemplate::default()
396                })
397                .collect(),
398        );
399        let (tx, rx) = futures::channel::mpsc::unbounded();
400        let file = TrackedFile::new(rx, cx);
401        tx.unbounded_send(serde_json::to_string(&tasks).unwrap())
402            .unwrap();
403        StaticSource::new(file)
404    }
405
406    pub(super) fn task_template_names(
407        inventory: &Model<Inventory>,
408        worktree: Option<WorktreeId>,
409        cx: &mut TestAppContext,
410    ) -> Vec<String> {
411        inventory.update(cx, |inventory, _| {
412            inventory
413                .list_tasks(None, worktree)
414                .into_iter()
415                .map(|(_, task)| task.label)
416                .sorted()
417                .collect()
418        })
419    }
420
421    pub(super) fn resolved_task_names(
422        inventory: &Model<Inventory>,
423        worktree: Option<WorktreeId>,
424        cx: &mut TestAppContext,
425    ) -> Vec<String> {
426        inventory.update(cx, |inventory, _| {
427            let (used, current) =
428                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
429            used.into_iter()
430                .chain(current)
431                .map(|(_, task)| task.original_task().label.clone())
432                .collect()
433        })
434    }
435
436    pub(super) fn register_task_used(
437        inventory: &Model<Inventory>,
438        task_name: &str,
439        cx: &mut TestAppContext,
440    ) {
441        inventory.update(cx, |inventory, _| {
442            let (task_source_kind, task) = inventory
443                .list_tasks(None, None)
444                .into_iter()
445                .find(|(_, task)| task.label == task_name)
446                .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
447            let id_base = task_source_kind.to_id_base();
448            inventory.task_scheduled(
449                task_source_kind.clone(),
450                task.resolve_task(&id_base, &TaskContext::default())
451                    .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
452            );
453        });
454    }
455
456    pub(super) fn list_tasks(
457        inventory: &Model<Inventory>,
458        worktree: Option<WorktreeId>,
459        cx: &mut TestAppContext,
460    ) -> Vec<(TaskSourceKind, String)> {
461        inventory.update(cx, |inventory, _| {
462            let (used, current) =
463                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
464            let mut all = used;
465            all.extend(current);
466            all.into_iter()
467                .map(|(source_kind, task)| (source_kind, task.resolved_label))
468                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
469                .collect()
470        })
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use gpui::TestAppContext;
477
478    use super::test_inventory::*;
479    use super::*;
480
481    #[gpui::test]
482    fn test_task_list_sorting(cx: &mut TestAppContext) {
483        let inventory = cx.update(Inventory::new);
484        let initial_tasks = resolved_task_names(&inventory, None, cx);
485        assert!(
486            initial_tasks.is_empty(),
487            "No tasks expected for empty inventory, but got {initial_tasks:?}"
488        );
489        let initial_tasks = task_template_names(&inventory, None, cx);
490        assert!(
491            initial_tasks.is_empty(),
492            "No tasks expected for empty inventory, but got {initial_tasks:?}"
493        );
494
495        inventory.update(cx, |inventory, cx| {
496            inventory.add_source(
497                TaskSourceKind::UserInput,
498                static_test_source(vec!["3_task".to_string()], cx),
499                cx,
500            );
501        });
502        inventory.update(cx, |inventory, cx| {
503            inventory.add_source(
504                TaskSourceKind::UserInput,
505                static_test_source(
506                    vec![
507                        "1_task".to_string(),
508                        "2_task".to_string(),
509                        "1_a_task".to_string(),
510                    ],
511                    cx,
512                ),
513                cx,
514            );
515        });
516        cx.run_until_parked();
517        let expected_initial_state = [
518            "1_a_task".to_string(),
519            "1_task".to_string(),
520            "2_task".to_string(),
521            "3_task".to_string(),
522        ];
523        assert_eq!(
524            task_template_names(&inventory, None, cx),
525            &expected_initial_state,
526        );
527        assert_eq!(
528            resolved_task_names(&inventory, None, cx),
529            &expected_initial_state,
530            "Tasks with equal amount of usages should be sorted alphanumerically"
531        );
532
533        register_task_used(&inventory, "2_task", cx);
534        assert_eq!(
535            task_template_names(&inventory, None, cx),
536            &expected_initial_state,
537        );
538        assert_eq!(
539            resolved_task_names(&inventory, None, cx),
540            vec![
541                "2_task".to_string(),
542                "2_task".to_string(),
543                "1_a_task".to_string(),
544                "1_task".to_string(),
545                "3_task".to_string()
546            ],
547        );
548
549        register_task_used(&inventory, "1_task", cx);
550        register_task_used(&inventory, "1_task", cx);
551        register_task_used(&inventory, "1_task", cx);
552        register_task_used(&inventory, "3_task", cx);
553        assert_eq!(
554            task_template_names(&inventory, None, cx),
555            &expected_initial_state,
556        );
557        assert_eq!(
558            resolved_task_names(&inventory, None, cx),
559            vec![
560                "3_task".to_string(),
561                "1_task".to_string(),
562                "2_task".to_string(),
563                "3_task".to_string(),
564                "1_task".to_string(),
565                "2_task".to_string(),
566                "1_a_task".to_string(),
567            ],
568        );
569
570        inventory.update(cx, |inventory, cx| {
571            inventory.add_source(
572                TaskSourceKind::UserInput,
573                static_test_source(vec!["10_hello".to_string(), "11_hello".to_string()], cx),
574                cx,
575            );
576        });
577        cx.run_until_parked();
578        let expected_updated_state = [
579            "10_hello".to_string(),
580            "11_hello".to_string(),
581            "1_a_task".to_string(),
582            "1_task".to_string(),
583            "2_task".to_string(),
584            "3_task".to_string(),
585        ];
586        assert_eq!(
587            task_template_names(&inventory, None, cx),
588            &expected_updated_state,
589        );
590        assert_eq!(
591            resolved_task_names(&inventory, None, cx),
592            vec![
593                "3_task".to_string(),
594                "1_task".to_string(),
595                "2_task".to_string(),
596                "3_task".to_string(),
597                "1_task".to_string(),
598                "2_task".to_string(),
599                "1_a_task".to_string(),
600                "10_hello".to_string(),
601                "11_hello".to_string(),
602            ],
603        );
604
605        register_task_used(&inventory, "11_hello", cx);
606        assert_eq!(
607            task_template_names(&inventory, None, cx),
608            &expected_updated_state,
609        );
610        assert_eq!(
611            resolved_task_names(&inventory, None, cx),
612            vec![
613                "11_hello".to_string(),
614                "3_task".to_string(),
615                "1_task".to_string(),
616                "2_task".to_string(),
617                "11_hello".to_string(),
618                "3_task".to_string(),
619                "1_task".to_string(),
620                "2_task".to_string(),
621                "1_a_task".to_string(),
622                "10_hello".to_string(),
623            ],
624        );
625    }
626
627    #[gpui::test]
628    fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
629        let inventory_with_statics = cx.update(Inventory::new);
630        let common_name = "common_task_name";
631        let path_1 = Path::new("path_1");
632        let path_2 = Path::new("path_2");
633        let worktree_1 = WorktreeId::from_usize(1);
634        let worktree_path_1 = Path::new("worktree_path_1");
635        let worktree_2 = WorktreeId::from_usize(2);
636        let worktree_path_2 = Path::new("worktree_path_2");
637
638        inventory_with_statics.update(cx, |inventory, cx| {
639            inventory.add_source(
640                TaskSourceKind::UserInput,
641                static_test_source(vec!["user_input".to_string(), common_name.to_string()], cx),
642                cx,
643            );
644            inventory.add_source(
645                TaskSourceKind::AbsPath {
646                    id_base: "test source",
647                    abs_path: path_1.to_path_buf(),
648                },
649                static_test_source(
650                    vec!["static_source_1".to_string(), common_name.to_string()],
651                    cx,
652                ),
653                cx,
654            );
655            inventory.add_source(
656                TaskSourceKind::AbsPath {
657                    id_base: "test source",
658                    abs_path: path_2.to_path_buf(),
659                },
660                static_test_source(
661                    vec!["static_source_2".to_string(), common_name.to_string()],
662                    cx,
663                ),
664                cx,
665            );
666            inventory.add_source(
667                TaskSourceKind::Worktree {
668                    id: worktree_1,
669                    abs_path: worktree_path_1.to_path_buf(),
670                    id_base: "test_source",
671                },
672                static_test_source(vec!["worktree_1".to_string(), common_name.to_string()], cx),
673                cx,
674            );
675            inventory.add_source(
676                TaskSourceKind::Worktree {
677                    id: worktree_2,
678                    abs_path: worktree_path_2.to_path_buf(),
679                    id_base: "test_source",
680                },
681                static_test_source(vec!["worktree_2".to_string(), common_name.to_string()], cx),
682                cx,
683            );
684        });
685        cx.run_until_parked();
686        let worktree_independent_tasks = vec![
687            (
688                TaskSourceKind::AbsPath {
689                    id_base: "test source",
690                    abs_path: path_1.to_path_buf(),
691                },
692                "static_source_1".to_string(),
693            ),
694            (
695                TaskSourceKind::AbsPath {
696                    id_base: "test source",
697                    abs_path: path_1.to_path_buf(),
698                },
699                common_name.to_string(),
700            ),
701            (
702                TaskSourceKind::AbsPath {
703                    id_base: "test source",
704                    abs_path: path_2.to_path_buf(),
705                },
706                common_name.to_string(),
707            ),
708            (
709                TaskSourceKind::AbsPath {
710                    id_base: "test source",
711                    abs_path: path_2.to_path_buf(),
712                },
713                "static_source_2".to_string(),
714            ),
715            (TaskSourceKind::UserInput, common_name.to_string()),
716            (TaskSourceKind::UserInput, "user_input".to_string()),
717        ];
718        let worktree_1_tasks = [
719            (
720                TaskSourceKind::Worktree {
721                    id: worktree_1,
722                    abs_path: worktree_path_1.to_path_buf(),
723                    id_base: "test_source",
724                },
725                common_name.to_string(),
726            ),
727            (
728                TaskSourceKind::Worktree {
729                    id: worktree_1,
730                    abs_path: worktree_path_1.to_path_buf(),
731                    id_base: "test_source",
732                },
733                "worktree_1".to_string(),
734            ),
735        ];
736        let worktree_2_tasks = [
737            (
738                TaskSourceKind::Worktree {
739                    id: worktree_2,
740                    abs_path: worktree_path_2.to_path_buf(),
741                    id_base: "test_source",
742                },
743                common_name.to_string(),
744            ),
745            (
746                TaskSourceKind::Worktree {
747                    id: worktree_2,
748                    abs_path: worktree_path_2.to_path_buf(),
749                    id_base: "test_source",
750                },
751                "worktree_2".to_string(),
752            ),
753        ];
754
755        let all_tasks = worktree_1_tasks
756            .iter()
757            .chain(worktree_2_tasks.iter())
758            // worktree-less tasks come later in the list
759            .chain(worktree_independent_tasks.iter())
760            .cloned()
761            .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
762            .collect::<Vec<_>>();
763
764        assert_eq!(list_tasks(&inventory_with_statics, None, cx), all_tasks);
765        assert_eq!(
766            list_tasks(&inventory_with_statics, Some(worktree_1), cx),
767            worktree_1_tasks
768                .iter()
769                .chain(worktree_independent_tasks.iter())
770                .cloned()
771                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
772                .collect::<Vec<_>>(),
773        );
774        assert_eq!(
775            list_tasks(&inventory_with_statics, Some(worktree_2), cx),
776            worktree_2_tasks
777                .iter()
778                .chain(worktree_independent_tasks.iter())
779                .cloned()
780                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
781                .collect::<Vec<_>>(),
782        );
783    }
784}