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::{Either, 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)| !task.original_task().ignore_previously_resolved)
192            .filter(|(task_kind, _)| {
193                if matches!(task_kind, TaskSourceKind::Language { .. }) {
194                    Some(task_kind) == task_source_kind.as_ref()
195                } else {
196                    true
197                }
198            })
199            .fold(
200                BTreeMap::default(),
201                |mut tasks, (task_source_kind, resolved_task)| {
202                    tasks.entry(&resolved_task.id).or_insert_with(|| {
203                        (task_source_kind, resolved_task, post_inc(&mut lru_score))
204                    });
205                    tasks
206                },
207            );
208        let not_used_score = post_inc(&mut lru_score);
209        let currently_resolved_tasks = self
210            .sources
211            .iter()
212            .filter(|source| {
213                let source_worktree = source.kind.worktree();
214                worktree.is_none() || source_worktree.is_none() || source_worktree == worktree
215            })
216            .flat_map(|source| {
217                source
218                    .source
219                    .tasks_to_schedule()
220                    .0
221                    .into_iter()
222                    .map(|task| (&source.kind, task))
223            })
224            .chain(language_tasks)
225            .filter_map(|(kind, task)| {
226                let id_base = kind.to_id_base();
227                Some((kind, task.resolve_task(&id_base, task_context)?))
228            })
229            .map(|(kind, task)| {
230                let lru_score = task_usage
231                    .remove(&task.id)
232                    .map(|(_, _, lru_score)| lru_score)
233                    .unwrap_or(not_used_score);
234                (kind.clone(), task, lru_score)
235            })
236            .collect::<Vec<_>>();
237        let previously_spawned_tasks = task_usage
238            .into_iter()
239            .map(|(_, (kind, task, lru_score))| (kind.clone(), task.clone(), lru_score));
240
241        let mut tasks_by_label = BTreeMap::default();
242        tasks_by_label = previously_spawned_tasks.into_iter().fold(
243            tasks_by_label,
244            |mut tasks_by_label, (source, task, lru_score)| {
245                match tasks_by_label.entry((source, task.resolved_label.clone())) {
246                    btree_map::Entry::Occupied(mut o) => {
247                        let (_, previous_lru_score) = o.get();
248                        if previous_lru_score >= &lru_score {
249                            o.insert((task, lru_score));
250                        }
251                    }
252                    btree_map::Entry::Vacant(v) => {
253                        v.insert((task, lru_score));
254                    }
255                }
256                tasks_by_label
257            },
258        );
259        tasks_by_label = currently_resolved_tasks.into_iter().fold(
260            tasks_by_label,
261            |mut tasks_by_label, (source, task, lru_score)| {
262                match tasks_by_label.entry((source, task.resolved_label.clone())) {
263                    btree_map::Entry::Occupied(mut o) => {
264                        let (previous_task, _) = o.get();
265                        let new_template = task.original_task();
266                        if new_template.ignore_previously_resolved
267                            || new_template != previous_task.original_task()
268                        {
269                            o.insert((task, lru_score));
270                        }
271                    }
272                    btree_map::Entry::Vacant(v) => {
273                        v.insert((task, lru_score));
274                    }
275                }
276                tasks_by_label
277            },
278        );
279
280        tasks_by_label
281            .into_iter()
282            .map(|((kind, _), (task, lru_score))| (kind, task, lru_score))
283            .sorted_unstable_by(task_lru_comparator)
284            .partition_map(|(kind, task, lru_score)| {
285                if lru_score < not_used_score {
286                    Either::Left((kind, task))
287                } else {
288                    Either::Right((kind, task))
289                }
290            })
291    }
292
293    /// Returns the last scheduled task, if any of the sources contains one with the matching id.
294    pub fn last_scheduled_task(&self) -> Option<(TaskSourceKind, ResolvedTask)> {
295        self.last_scheduled_tasks.back().cloned()
296    }
297
298    /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.
299    pub fn task_scheduled(
300        &mut self,
301        task_source_kind: TaskSourceKind,
302        resolved_task: ResolvedTask,
303    ) {
304        self.last_scheduled_tasks
305            .push_back((task_source_kind, resolved_task));
306        if self.last_scheduled_tasks.len() > 5_000 {
307            self.last_scheduled_tasks.pop_front();
308        }
309    }
310
311    /// Deletes a resolved task from history, using its id.
312    /// A similar may still resurface in `used_and_current_resolved_tasks` when its [`TaskTemplate`] is resolved again.
313    pub fn delete_previously_used(&mut self, id: &TaskId) {
314        self.last_scheduled_tasks.retain(|(_, task)| &task.id != id);
315    }
316}
317
318fn task_lru_comparator(
319    (kind_a, task_a, lru_score_a): &(TaskSourceKind, ResolvedTask, u32),
320    (kind_b, task_b, lru_score_b): &(TaskSourceKind, ResolvedTask, u32),
321) -> cmp::Ordering {
322    lru_score_a
323        // First, display recently used templates above all.
324        .cmp(&lru_score_b)
325        // Then, ensure more specific sources are displayed first.
326        .then(task_source_kind_preference(kind_a).cmp(&task_source_kind_preference(kind_b)))
327        // After that, display first more specific tasks, using more template variables.
328        // Bonus points for tasks with symbol variables.
329        .then(task_variables_preference(task_a).cmp(&task_variables_preference(task_b)))
330        // Finally, sort by the resolved label, but a bit more specifically, to avoid mixing letters and digits.
331        .then({
332            NumericPrefixWithSuffix::from_numeric_prefixed_str(&task_a.resolved_label)
333                .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
334                    &task_b.resolved_label,
335                ))
336                .then(task_a.resolved_label.cmp(&task_b.resolved_label))
337        })
338}
339
340fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 {
341    match kind {
342        TaskSourceKind::Language { .. } => 1,
343        TaskSourceKind::UserInput => 2,
344        TaskSourceKind::Worktree { .. } => 3,
345        TaskSourceKind::AbsPath { .. } => 4,
346    }
347}
348
349fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
350    let task_variables = task.substituted_variables();
351    Reverse(if task_variables.contains(&VariableName::Symbol) {
352        task_variables.len() + 1
353    } else {
354        task_variables.len()
355    })
356}
357
358#[cfg(test)]
359mod test_inventory {
360    use gpui::{AppContext, Model, TestAppContext};
361    use itertools::Itertools;
362    use task::{
363        static_source::{StaticSource, TrackedFile},
364        TaskContext, TaskTemplate, TaskTemplates,
365    };
366    use worktree::WorktreeId;
367
368    use crate::Inventory;
369
370    use super::{task_source_kind_preference, TaskSourceKind};
371
372    #[derive(Debug, Clone, PartialEq, Eq)]
373    pub struct TestTask {
374        name: String,
375    }
376
377    pub(super) fn static_test_source(
378        task_names: impl IntoIterator<Item = String>,
379        cx: &mut AppContext,
380    ) -> StaticSource {
381        let tasks = TaskTemplates(
382            task_names
383                .into_iter()
384                .map(|name| TaskTemplate {
385                    label: name,
386                    command: "test command".to_owned(),
387                    ..TaskTemplate::default()
388                })
389                .collect(),
390        );
391        let (tx, rx) = futures::channel::mpsc::unbounded();
392        let file = TrackedFile::new(rx, cx);
393        tx.unbounded_send(serde_json::to_string(&tasks).unwrap())
394            .unwrap();
395        StaticSource::new(file)
396    }
397
398    pub(super) fn task_template_names(
399        inventory: &Model<Inventory>,
400        worktree: Option<WorktreeId>,
401        cx: &mut TestAppContext,
402    ) -> Vec<String> {
403        inventory.update(cx, |inventory, _| {
404            inventory
405                .list_tasks(None, worktree)
406                .into_iter()
407                .map(|(_, task)| task.label)
408                .sorted()
409                .collect()
410        })
411    }
412
413    pub(super) fn resolved_task_names(
414        inventory: &Model<Inventory>,
415        worktree: Option<WorktreeId>,
416        cx: &mut TestAppContext,
417    ) -> Vec<String> {
418        inventory.update(cx, |inventory, _| {
419            let (used, current) =
420                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
421            used.into_iter()
422                .chain(current)
423                .map(|(_, task)| task.original_task().label.clone())
424                .collect()
425        })
426    }
427
428    pub(super) fn register_task_used(
429        inventory: &Model<Inventory>,
430        task_name: &str,
431        cx: &mut TestAppContext,
432    ) {
433        inventory.update(cx, |inventory, _| {
434            let (task_source_kind, task) = inventory
435                .list_tasks(None, None)
436                .into_iter()
437                .find(|(_, task)| task.label == task_name)
438                .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
439            let id_base = task_source_kind.to_id_base();
440            inventory.task_scheduled(
441                task_source_kind.clone(),
442                task.resolve_task(&id_base, &TaskContext::default())
443                    .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
444            );
445        });
446    }
447
448    pub(super) fn list_tasks(
449        inventory: &Model<Inventory>,
450        worktree: Option<WorktreeId>,
451        cx: &mut TestAppContext,
452    ) -> Vec<(TaskSourceKind, String)> {
453        inventory.update(cx, |inventory, _| {
454            let (used, current) =
455                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
456            let mut all = used;
457            all.extend(current);
458            all.into_iter()
459                .map(|(source_kind, task)| (source_kind, task.resolved_label))
460                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
461                .collect()
462        })
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use gpui::TestAppContext;
469
470    use super::test_inventory::*;
471    use super::*;
472
473    #[gpui::test]
474    fn test_task_list_sorting(cx: &mut TestAppContext) {
475        let inventory = cx.update(Inventory::new);
476        let initial_tasks = resolved_task_names(&inventory, None, cx);
477        assert!(
478            initial_tasks.is_empty(),
479            "No tasks expected for empty inventory, but got {initial_tasks:?}"
480        );
481        let initial_tasks = task_template_names(&inventory, None, cx);
482        assert!(
483            initial_tasks.is_empty(),
484            "No tasks expected for empty inventory, but got {initial_tasks:?}"
485        );
486
487        inventory.update(cx, |inventory, cx| {
488            inventory.add_source(
489                TaskSourceKind::UserInput,
490                static_test_source(vec!["3_task".to_string()], cx),
491                cx,
492            );
493        });
494        inventory.update(cx, |inventory, cx| {
495            inventory.add_source(
496                TaskSourceKind::UserInput,
497                static_test_source(
498                    vec![
499                        "1_task".to_string(),
500                        "2_task".to_string(),
501                        "1_a_task".to_string(),
502                    ],
503                    cx,
504                ),
505                cx,
506            );
507        });
508        cx.run_until_parked();
509        let expected_initial_state = [
510            "1_a_task".to_string(),
511            "1_task".to_string(),
512            "2_task".to_string(),
513            "3_task".to_string(),
514        ];
515        assert_eq!(
516            task_template_names(&inventory, None, cx),
517            &expected_initial_state,
518        );
519        assert_eq!(
520            resolved_task_names(&inventory, None, cx),
521            &expected_initial_state,
522            "Tasks with equal amount of usages should be sorted alphanumerically"
523        );
524
525        register_task_used(&inventory, "2_task", cx);
526        assert_eq!(
527            task_template_names(&inventory, None, cx),
528            &expected_initial_state,
529        );
530        assert_eq!(
531            resolved_task_names(&inventory, None, cx),
532            vec![
533                "2_task".to_string(),
534                "1_a_task".to_string(),
535                "1_task".to_string(),
536                "3_task".to_string()
537            ],
538        );
539
540        register_task_used(&inventory, "1_task", cx);
541        register_task_used(&inventory, "1_task", cx);
542        register_task_used(&inventory, "1_task", cx);
543        register_task_used(&inventory, "3_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                "3_task".to_string(),
552                "1_task".to_string(),
553                "2_task".to_string(),
554                "1_a_task".to_string(),
555            ],
556        );
557
558        inventory.update(cx, |inventory, cx| {
559            inventory.add_source(
560                TaskSourceKind::UserInput,
561                static_test_source(vec!["10_hello".to_string(), "11_hello".to_string()], cx),
562                cx,
563            );
564        });
565        cx.run_until_parked();
566        let expected_updated_state = [
567            "10_hello".to_string(),
568            "11_hello".to_string(),
569            "1_a_task".to_string(),
570            "1_task".to_string(),
571            "2_task".to_string(),
572            "3_task".to_string(),
573        ];
574        assert_eq!(
575            task_template_names(&inventory, None, cx),
576            &expected_updated_state,
577        );
578        assert_eq!(
579            resolved_task_names(&inventory, None, cx),
580            vec![
581                "3_task".to_string(),
582                "1_task".to_string(),
583                "2_task".to_string(),
584                "1_a_task".to_string(),
585                "10_hello".to_string(),
586                "11_hello".to_string(),
587            ],
588        );
589
590        register_task_used(&inventory, "11_hello", cx);
591        assert_eq!(
592            task_template_names(&inventory, None, cx),
593            &expected_updated_state,
594        );
595        assert_eq!(
596            resolved_task_names(&inventory, None, cx),
597            vec![
598                "11_hello".to_string(),
599                "3_task".to_string(),
600                "1_task".to_string(),
601                "2_task".to_string(),
602                "1_a_task".to_string(),
603                "10_hello".to_string(),
604            ],
605        );
606    }
607
608    #[gpui::test]
609    fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
610        let inventory_with_statics = cx.update(Inventory::new);
611        let common_name = "common_task_name";
612        let path_1 = Path::new("path_1");
613        let path_2 = Path::new("path_2");
614        let worktree_1 = WorktreeId::from_usize(1);
615        let worktree_path_1 = Path::new("worktree_path_1");
616        let worktree_2 = WorktreeId::from_usize(2);
617        let worktree_path_2 = Path::new("worktree_path_2");
618
619        inventory_with_statics.update(cx, |inventory, cx| {
620            inventory.add_source(
621                TaskSourceKind::UserInput,
622                static_test_source(vec!["user_input".to_string(), common_name.to_string()], cx),
623                cx,
624            );
625            inventory.add_source(
626                TaskSourceKind::AbsPath {
627                    id_base: "test source",
628                    abs_path: path_1.to_path_buf(),
629                },
630                static_test_source(
631                    vec!["static_source_1".to_string(), common_name.to_string()],
632                    cx,
633                ),
634                cx,
635            );
636            inventory.add_source(
637                TaskSourceKind::AbsPath {
638                    id_base: "test source",
639                    abs_path: path_2.to_path_buf(),
640                },
641                static_test_source(
642                    vec!["static_source_2".to_string(), common_name.to_string()],
643                    cx,
644                ),
645                cx,
646            );
647            inventory.add_source(
648                TaskSourceKind::Worktree {
649                    id: worktree_1,
650                    abs_path: worktree_path_1.to_path_buf(),
651                    id_base: "test_source",
652                },
653                static_test_source(vec!["worktree_1".to_string(), common_name.to_string()], cx),
654                cx,
655            );
656            inventory.add_source(
657                TaskSourceKind::Worktree {
658                    id: worktree_2,
659                    abs_path: worktree_path_2.to_path_buf(),
660                    id_base: "test_source",
661                },
662                static_test_source(vec!["worktree_2".to_string(), common_name.to_string()], cx),
663                cx,
664            );
665        });
666        cx.run_until_parked();
667        let worktree_independent_tasks = vec![
668            (
669                TaskSourceKind::AbsPath {
670                    id_base: "test source",
671                    abs_path: path_1.to_path_buf(),
672                },
673                "static_source_1".to_string(),
674            ),
675            (
676                TaskSourceKind::AbsPath {
677                    id_base: "test source",
678                    abs_path: path_1.to_path_buf(),
679                },
680                common_name.to_string(),
681            ),
682            (
683                TaskSourceKind::AbsPath {
684                    id_base: "test source",
685                    abs_path: path_2.to_path_buf(),
686                },
687                common_name.to_string(),
688            ),
689            (
690                TaskSourceKind::AbsPath {
691                    id_base: "test source",
692                    abs_path: path_2.to_path_buf(),
693                },
694                "static_source_2".to_string(),
695            ),
696            (TaskSourceKind::UserInput, common_name.to_string()),
697            (TaskSourceKind::UserInput, "user_input".to_string()),
698        ];
699        let worktree_1_tasks = [
700            (
701                TaskSourceKind::Worktree {
702                    id: worktree_1,
703                    abs_path: worktree_path_1.to_path_buf(),
704                    id_base: "test_source",
705                },
706                common_name.to_string(),
707            ),
708            (
709                TaskSourceKind::Worktree {
710                    id: worktree_1,
711                    abs_path: worktree_path_1.to_path_buf(),
712                    id_base: "test_source",
713                },
714                "worktree_1".to_string(),
715            ),
716        ];
717        let worktree_2_tasks = [
718            (
719                TaskSourceKind::Worktree {
720                    id: worktree_2,
721                    abs_path: worktree_path_2.to_path_buf(),
722                    id_base: "test_source",
723                },
724                common_name.to_string(),
725            ),
726            (
727                TaskSourceKind::Worktree {
728                    id: worktree_2,
729                    abs_path: worktree_path_2.to_path_buf(),
730                    id_base: "test_source",
731                },
732                "worktree_2".to_string(),
733            ),
734        ];
735
736        let all_tasks = worktree_1_tasks
737            .iter()
738            .chain(worktree_2_tasks.iter())
739            // worktree-less tasks come later in the list
740            .chain(worktree_independent_tasks.iter())
741            .cloned()
742            .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
743            .collect::<Vec<_>>();
744
745        assert_eq!(list_tasks(&inventory_with_statics, None, cx), all_tasks);
746        assert_eq!(
747            list_tasks(&inventory_with_statics, Some(worktree_1), cx),
748            worktree_1_tasks
749                .iter()
750                .chain(worktree_independent_tasks.iter())
751                .cloned()
752                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
753                .collect::<Vec<_>>(),
754        );
755        assert_eq!(
756            list_tasks(&inventory_with_statics, Some(worktree_2), cx),
757            worktree_2_tasks
758                .iter()
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    }
765}