task_inventory.rs

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