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::{hash_map, 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 currently_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 previously_spawned_tasks = task_usage
261            .into_iter()
262            .map(|(_, (kind, task, lru_score))| (kind.clone(), task.clone(), lru_score));
263
264        let mut tasks_by_label = HashMap::default();
265        tasks_by_label = previously_spawned_tasks.into_iter().fold(
266            tasks_by_label,
267            |mut tasks_by_label, (source, task, lru_score)| {
268                match tasks_by_label.entry((source, task.resolved_label.clone())) {
269                    hash_map::Entry::Occupied(mut o) => {
270                        let (_, previous_lru_score) = o.get();
271                        if previous_lru_score >= &lru_score {
272                            o.insert((task, lru_score));
273                        }
274                    }
275                    hash_map::Entry::Vacant(v) => {
276                        v.insert((task, lru_score));
277                    }
278                }
279                tasks_by_label
280            },
281        );
282        tasks_by_label = currently_resolved_tasks.into_iter().fold(
283            tasks_by_label,
284            |mut tasks_by_label, (source, task, lru_score)| {
285                match tasks_by_label.entry((source, task.resolved_label.clone())) {
286                    hash_map::Entry::Occupied(mut o) => {
287                        let (previous_task, _) = o.get();
288                        let new_template = task.original_task();
289                        if new_template.ignore_previously_resolved
290                            || new_template != previous_task.original_task()
291                        {
292                            o.insert((task, lru_score));
293                        }
294                    }
295                    hash_map::Entry::Vacant(v) => {
296                        v.insert((task, lru_score));
297                    }
298                }
299                tasks_by_label
300            },
301        );
302
303        tasks_by_label
304            .into_iter()
305            .map(|((kind, _), (task, lru_score))| (kind, task, lru_score))
306            .sorted_unstable_by(task_lru_comparator)
307            .partition_map(|(kind, task, lru_score)| {
308                if lru_score < not_used_score {
309                    Either::Left((kind, task))
310                } else {
311                    Either::Right((kind, task))
312                }
313            })
314    }
315
316    /// Returns the last scheduled task, if any of the sources contains one with the matching id.
317    pub fn last_scheduled_task(&self) -> Option<(TaskSourceKind, ResolvedTask)> {
318        self.last_scheduled_tasks.back().cloned()
319    }
320
321    /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.
322    pub fn task_scheduled(
323        &mut self,
324        task_source_kind: TaskSourceKind,
325        resolved_task: ResolvedTask,
326    ) {
327        self.last_scheduled_tasks
328            .push_back((task_source_kind, resolved_task));
329        if self.last_scheduled_tasks.len() > 5_000 {
330            self.last_scheduled_tasks.pop_front();
331        }
332    }
333
334    /// Deletes a resolved task from history, using its id.
335    /// A similar may still resurface in `used_and_current_resolved_tasks` when its [`TaskTemplate`] is resolved again.
336    pub fn delete_previously_used(&mut self, id: &TaskId) {
337        self.last_scheduled_tasks.retain(|(_, task)| &task.id != id);
338    }
339}
340
341fn task_lru_comparator(
342    (kind_a, task_a, lru_score_a): &(TaskSourceKind, ResolvedTask, u32),
343    (kind_b, task_b, lru_score_b): &(TaskSourceKind, ResolvedTask, u32),
344) -> cmp::Ordering {
345    lru_score_a
346        // First, display recently used templates above all.
347        .cmp(&lru_score_b)
348        // Then, ensure more specific sources are displayed first.
349        .then(task_source_kind_preference(kind_a).cmp(&task_source_kind_preference(kind_b)))
350        // After that, display first more specific tasks, using more template variables.
351        // Bonus points for tasks with symbol variables.
352        .then(task_variables_preference(task_a).cmp(&task_variables_preference(task_b)))
353        // Finally, sort by the resolved label, but a bit more specifically, to avoid mixing letters and digits.
354        .then({
355            NumericPrefixWithSuffix::from_numeric_prefixed_str(&task_a.resolved_label)
356                .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
357                    &task_b.resolved_label,
358                ))
359                .then(task_a.resolved_label.cmp(&task_b.resolved_label))
360        })
361}
362
363fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 {
364    match kind {
365        TaskSourceKind::Language { .. } => 1,
366        TaskSourceKind::UserInput => 2,
367        TaskSourceKind::Worktree { .. } => 3,
368        TaskSourceKind::AbsPath { .. } => 4,
369    }
370}
371
372fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
373    let task_variables = task.substituted_variables();
374    Reverse(if task_variables.contains(&VariableName::Symbol) {
375        task_variables.len() + 1
376    } else {
377        task_variables.len()
378    })
379}
380
381#[cfg(test)]
382mod test_inventory {
383    use gpui::{AppContext, Context as _, Model, ModelContext, TestAppContext};
384    use itertools::Itertools;
385    use task::{TaskContext, TaskId, TaskSource, TaskTemplate, TaskTemplates};
386    use worktree::WorktreeId;
387
388    use crate::Inventory;
389
390    use super::{task_source_kind_preference, TaskSourceKind};
391
392    #[derive(Debug, Clone, PartialEq, Eq)]
393    pub struct TestTask {
394        id: task::TaskId,
395        name: String,
396    }
397
398    pub struct StaticTestSource {
399        pub tasks: Vec<TestTask>,
400    }
401
402    impl StaticTestSource {
403        pub(super) fn new(
404            task_names: impl IntoIterator<Item = String>,
405            cx: &mut AppContext,
406        ) -> Model<Box<dyn TaskSource>> {
407            cx.new_model(|_| {
408                Box::new(Self {
409                    tasks: task_names
410                        .into_iter()
411                        .enumerate()
412                        .map(|(i, name)| TestTask {
413                            id: TaskId(format!("task_{i}_{name}")),
414                            name,
415                        })
416                        .collect(),
417                }) as Box<dyn TaskSource>
418            })
419        }
420    }
421
422    impl TaskSource for StaticTestSource {
423        fn tasks_to_schedule(
424            &mut self,
425            _cx: &mut ModelContext<Box<dyn TaskSource>>,
426        ) -> TaskTemplates {
427            TaskTemplates(
428                self.tasks
429                    .clone()
430                    .into_iter()
431                    .map(|task| TaskTemplate {
432                        label: task.name,
433                        command: "test command".to_string(),
434                        ..TaskTemplate::default()
435                    })
436                    .collect(),
437            )
438        }
439
440        fn as_any(&mut self) -> &mut dyn std::any::Any {
441            self
442        }
443    }
444
445    pub(super) fn task_template_names(
446        inventory: &Model<Inventory>,
447        worktree: Option<WorktreeId>,
448        cx: &mut TestAppContext,
449    ) -> Vec<String> {
450        inventory.update(cx, |inventory, cx| {
451            inventory
452                .list_tasks(None, worktree, cx)
453                .into_iter()
454                .map(|(_, task)| task.label)
455                .sorted()
456                .collect()
457        })
458    }
459
460    pub(super) fn resolved_task_names(
461        inventory: &Model<Inventory>,
462        worktree: Option<WorktreeId>,
463        cx: &mut TestAppContext,
464    ) -> Vec<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            used.into_iter()
473                .chain(current)
474                .map(|(_, task)| task.original_task().label.clone())
475                .collect()
476        })
477    }
478
479    pub(super) fn register_task_used(
480        inventory: &Model<Inventory>,
481        task_name: &str,
482        cx: &mut TestAppContext,
483    ) {
484        inventory.update(cx, |inventory, cx| {
485            let (task_source_kind, task) = inventory
486                .list_tasks(None, None, cx)
487                .into_iter()
488                .find(|(_, task)| task.label == task_name)
489                .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
490            let id_base = task_source_kind.to_id_base();
491            inventory.task_scheduled(
492                task_source_kind.clone(),
493                task.resolve_task(&id_base, &TaskContext::default())
494                    .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
495            );
496        });
497    }
498
499    pub(super) fn list_tasks(
500        inventory: &Model<Inventory>,
501        worktree: Option<WorktreeId>,
502        cx: &mut TestAppContext,
503    ) -> Vec<(TaskSourceKind, String)> {
504        inventory.update(cx, |inventory, cx| {
505            let (used, current) = inventory.used_and_current_resolved_tasks(
506                None,
507                worktree,
508                &TaskContext::default(),
509                cx,
510            );
511            let mut all = used;
512            all.extend(current);
513            all.into_iter()
514                .map(|(source_kind, task)| (source_kind, task.resolved_label))
515                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
516                .collect()
517        })
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use gpui::TestAppContext;
524
525    use super::test_inventory::*;
526    use super::*;
527
528    #[gpui::test]
529    fn test_task_list_sorting(cx: &mut TestAppContext) {
530        let inventory = cx.update(Inventory::new);
531        let initial_tasks = resolved_task_names(&inventory, None, cx);
532        assert!(
533            initial_tasks.is_empty(),
534            "No tasks expected for empty inventory, but got {initial_tasks:?}"
535        );
536        let initial_tasks = task_template_names(&inventory, None, cx);
537        assert!(
538            initial_tasks.is_empty(),
539            "No tasks expected for empty inventory, but got {initial_tasks:?}"
540        );
541
542        inventory.update(cx, |inventory, cx| {
543            inventory.add_source(
544                TaskSourceKind::UserInput,
545                |cx| StaticTestSource::new(vec!["3_task".to_string()], cx),
546                cx,
547            );
548        });
549        inventory.update(cx, |inventory, cx| {
550            inventory.add_source(
551                TaskSourceKind::UserInput,
552                |cx| {
553                    StaticTestSource::new(
554                        vec![
555                            "1_task".to_string(),
556                            "2_task".to_string(),
557                            "1_a_task".to_string(),
558                        ],
559                        cx,
560                    )
561                },
562                cx,
563            );
564        });
565
566        let expected_initial_state = [
567            "1_a_task".to_string(),
568            "1_task".to_string(),
569            "2_task".to_string(),
570            "3_task".to_string(),
571        ];
572        assert_eq!(
573            task_template_names(&inventory, None, cx),
574            &expected_initial_state,
575        );
576        assert_eq!(
577            resolved_task_names(&inventory, None, cx),
578            &expected_initial_state,
579            "Tasks with equal amount of usages should be sorted alphanumerically"
580        );
581
582        register_task_used(&inventory, "2_task", cx);
583        assert_eq!(
584            task_template_names(&inventory, None, cx),
585            &expected_initial_state,
586        );
587        assert_eq!(
588            resolved_task_names(&inventory, None, cx),
589            vec![
590                "2_task".to_string(),
591                "1_a_task".to_string(),
592                "1_task".to_string(),
593                "3_task".to_string()
594            ],
595        );
596
597        register_task_used(&inventory, "1_task", cx);
598        register_task_used(&inventory, "1_task", cx);
599        register_task_used(&inventory, "1_task", cx);
600        register_task_used(&inventory, "3_task", cx);
601        assert_eq!(
602            task_template_names(&inventory, None, cx),
603            &expected_initial_state,
604        );
605        assert_eq!(
606            resolved_task_names(&inventory, None, cx),
607            vec![
608                "3_task".to_string(),
609                "1_task".to_string(),
610                "2_task".to_string(),
611                "1_a_task".to_string(),
612            ],
613        );
614
615        inventory.update(cx, |inventory, cx| {
616            inventory.add_source(
617                TaskSourceKind::UserInput,
618                |cx| {
619                    StaticTestSource::new(vec!["10_hello".to_string(), "11_hello".to_string()], cx)
620                },
621                cx,
622            );
623        });
624        let expected_updated_state = [
625            "10_hello".to_string(),
626            "11_hello".to_string(),
627            "1_a_task".to_string(),
628            "1_task".to_string(),
629            "2_task".to_string(),
630            "3_task".to_string(),
631        ];
632        assert_eq!(
633            task_template_names(&inventory, None, cx),
634            &expected_updated_state,
635        );
636        assert_eq!(
637            resolved_task_names(&inventory, None, cx),
638            vec![
639                "3_task".to_string(),
640                "1_task".to_string(),
641                "2_task".to_string(),
642                "1_a_task".to_string(),
643                "10_hello".to_string(),
644                "11_hello".to_string(),
645            ],
646        );
647
648        register_task_used(&inventory, "11_hello", cx);
649        assert_eq!(
650            task_template_names(&inventory, None, cx),
651            &expected_updated_state,
652        );
653        assert_eq!(
654            resolved_task_names(&inventory, None, cx),
655            vec![
656                "11_hello".to_string(),
657                "3_task".to_string(),
658                "1_task".to_string(),
659                "2_task".to_string(),
660                "1_a_task".to_string(),
661                "10_hello".to_string(),
662            ],
663        );
664    }
665
666    #[gpui::test]
667    fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
668        let inventory_with_statics = cx.update(Inventory::new);
669        let common_name = "common_task_name";
670        let path_1 = Path::new("path_1");
671        let path_2 = Path::new("path_2");
672        let worktree_1 = WorktreeId::from_usize(1);
673        let worktree_path_1 = Path::new("worktree_path_1");
674        let worktree_2 = WorktreeId::from_usize(2);
675        let worktree_path_2 = Path::new("worktree_path_2");
676        inventory_with_statics.update(cx, |inventory, cx| {
677            inventory.add_source(
678                TaskSourceKind::UserInput,
679                |cx| {
680                    StaticTestSource::new(
681                        vec!["user_input".to_string(), common_name.to_string()],
682                        cx,
683                    )
684                },
685                cx,
686            );
687            inventory.add_source(
688                TaskSourceKind::AbsPath {
689                    id_base: "test source",
690                    abs_path: path_1.to_path_buf(),
691                },
692                |cx| {
693                    StaticTestSource::new(
694                        vec!["static_source_1".to_string(), common_name.to_string()],
695                        cx,
696                    )
697                },
698                cx,
699            );
700            inventory.add_source(
701                TaskSourceKind::AbsPath {
702                    id_base: "test source",
703                    abs_path: path_2.to_path_buf(),
704                },
705                |cx| {
706                    StaticTestSource::new(
707                        vec!["static_source_2".to_string(), common_name.to_string()],
708                        cx,
709                    )
710                },
711                cx,
712            );
713            inventory.add_source(
714                TaskSourceKind::Worktree {
715                    id: worktree_1,
716                    abs_path: worktree_path_1.to_path_buf(),
717                    id_base: "test_source",
718                },
719                |cx| {
720                    StaticTestSource::new(
721                        vec!["worktree_1".to_string(), common_name.to_string()],
722                        cx,
723                    )
724                },
725                cx,
726            );
727            inventory.add_source(
728                TaskSourceKind::Worktree {
729                    id: worktree_2,
730                    abs_path: worktree_path_2.to_path_buf(),
731                    id_base: "test_source",
732                },
733                |cx| {
734                    StaticTestSource::new(
735                        vec!["worktree_2".to_string(), common_name.to_string()],
736                        cx,
737                    )
738                },
739                cx,
740            );
741        });
742
743        let worktree_independent_tasks = vec![
744            (
745                TaskSourceKind::AbsPath {
746                    id_base: "test source",
747                    abs_path: path_2.to_path_buf(),
748                },
749                common_name.to_string(),
750            ),
751            (
752                TaskSourceKind::AbsPath {
753                    id_base: "test source",
754                    abs_path: path_1.to_path_buf(),
755                },
756                "static_source_1".to_string(),
757            ),
758            (
759                TaskSourceKind::AbsPath {
760                    id_base: "test source",
761                    abs_path: path_1.to_path_buf(),
762                },
763                common_name.to_string(),
764            ),
765            (
766                TaskSourceKind::AbsPath {
767                    id_base: "test source",
768                    abs_path: path_2.to_path_buf(),
769                },
770                "static_source_2".to_string(),
771            ),
772            (TaskSourceKind::UserInput, common_name.to_string()),
773            (TaskSourceKind::UserInput, "user_input".to_string()),
774        ];
775        let worktree_1_tasks = [
776            (
777                TaskSourceKind::Worktree {
778                    id: worktree_1,
779                    abs_path: worktree_path_1.to_path_buf(),
780                    id_base: "test_source",
781                },
782                common_name.to_string(),
783            ),
784            (
785                TaskSourceKind::Worktree {
786                    id: worktree_1,
787                    abs_path: worktree_path_1.to_path_buf(),
788                    id_base: "test_source",
789                },
790                "worktree_1".to_string(),
791            ),
792        ];
793        let worktree_2_tasks = [
794            (
795                TaskSourceKind::Worktree {
796                    id: worktree_2,
797                    abs_path: worktree_path_2.to_path_buf(),
798                    id_base: "test_source",
799                },
800                common_name.to_string(),
801            ),
802            (
803                TaskSourceKind::Worktree {
804                    id: worktree_2,
805                    abs_path: worktree_path_2.to_path_buf(),
806                    id_base: "test_source",
807                },
808                "worktree_2".to_string(),
809            ),
810        ];
811
812        let all_tasks = worktree_1_tasks
813            .iter()
814            .chain(worktree_2_tasks.iter())
815            // worktree-less tasks come later in the list
816            .chain(worktree_independent_tasks.iter())
817            .cloned()
818            .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
819            .collect::<Vec<_>>();
820
821        assert_eq!(list_tasks(&inventory_with_statics, None, cx), all_tasks);
822        assert_eq!(
823            list_tasks(&inventory_with_statics, Some(worktree_1), cx),
824            worktree_1_tasks
825                .iter()
826                .chain(worktree_independent_tasks.iter())
827                .cloned()
828                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
829                .collect::<Vec<_>>(),
830        );
831        assert_eq!(
832            list_tasks(&inventory_with_statics, Some(worktree_2), cx),
833            worktree_2_tasks
834                .iter()
835                .chain(worktree_independent_tasks.iter())
836                .cloned()
837                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
838                .collect::<Vec<_>>(),
839        );
840    }
841}