Skip to main content

onetaskgraph_core/engine/
mod.rs

1//! The query engine: fan-out, capability compensation, and the plan it reports.
2//!
3//! Given the sources a configuration resolved to and a query, the engine addresses one,
4//! several or every one of them **at once**, and decides per source and per predicate
5//! whether to push the predicate down or to apply it itself. A predicate a source
6//! declares [`Native`](Support::Native) is passed in the query and never re-applied. A
7//! predicate it declares [`Unsupported`](Support::Unsupported) is **removed** from what
8//! that source sees — the source would ignore it anyway, and leaving it in would invite
9//! a source to half-apply it — and the engine narrows the wider result set in memory.
10//!
11//! What makes that worth the machinery is that the two are not the same plan. A source
12//! with real server-side search keeps it, and a folder of Markdown beside it is
13//! compensated for, and the caller can see which of the two it got: every response
14//! carries a [`QueryPlan`], `--explain` renders it, and `--json` publishes it.
15//!
16//! Nothing here writes anything down. See [`fetch`] for the walk that makes that true.
17
18mod fetch;
19mod join;
20mod local;
21mod resume;
22
23use std::collections::BTreeMap;
24use std::num::NonZeroU32;
25use std::sync::atomic::{AtomicU32, Ordering};
26
27use onetaskgraph_plugin_api::{
28    Capabilities, Cursor, DependencyEdge, Direction, Label, LabelFilter, NativeId, Page,
29    PageRequest, Project, ProjectFilter, ProjectQuery, SecretResolver, SourceError, SourceName,
30    StatusCategory, Task, TaskQuery, TextFields, TextQuery,
31};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use crate::GlobalId;
36use crate::config::Config;
37use crate::plan::{PageToken, Predicate, QueryPlan, QueryResponse, SourceFailure, SourcePlan};
38use crate::resolve::{ResolvedSource, UnavailableSource, resolve_available};
39
40use fetch::{Fetched, Stream, fits, merge, walk};
41use join::join_all;
42use local::{LocalProjects, LocalTasks};
43pub(crate) use resume::{Owed, Resumption, StreamState};
44use resume::{Resume, StreamKind};
45
46pub use local::ProjectSelector;
47
48/// One item, under the qualified id the engine addresses it by.
49///
50/// A plugin only ever deals in its own [`NativeId`]; qualifying one is the engine's job,
51/// so this type is the engine's and a plugin never constructs one.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53pub struct Qualified<T> {
54    /// `<source>:<native>`, the form a user types back at the command line.
55    pub id: GlobalId,
56    /// The item as its source reported it, unchanged.
57    pub item: T,
58}
59
60/// One dependency edge with both ends qualified.
61///
62/// Both ends belong to the same source: a cross-source edge would need state relating an
63/// id in one system to an id in another, and the engine holds none.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
65pub struct QualifiedEdge {
66    /// The item the edge starts at.
67    pub from: GlobalId,
68    /// The item the edge points at.
69    pub to: GlobalId,
70    /// What the edge means.
71    pub kind: onetaskgraph_plugin_api::DependencyKind,
72}
73
74/// One hit of a search that may cross entities.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
76#[serde(tag = "kind", rename_all = "kebab-case")]
77pub enum SearchHit {
78    /// A task matched.
79    Task(Qualified<Task>),
80    /// A project matched.
81    Project(Qualified<Project>),
82}
83
84/// Which entities a search covers.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "kebab-case")]
87pub enum SearchKind {
88    /// Tasks only.
89    Tasks,
90    /// Projects only.
91    Projects,
92    /// Both, interleaved.
93    #[default]
94    Both,
95}
96
97/// What one configured source is, as `sources list` reports it.
98#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
99pub struct SourceListing {
100    /// The name the configuration gave it.
101    pub source: SourceName,
102    /// The plugin kind behind it.
103    ///
104    /// A `String` because the vocabulary is open, not because it was not thought about:
105    /// this is the kind a source reports, and a subprocess-hosted plugin reports one
106    /// arriving over the wire from a binary this workspace never compiled. No
107    /// compile-time enumeration can hold that, and a newtype over the same string would
108    /// only move where an unrelated value is accepted.
109    // llmlint: ignore[invalid_states_unrepresentable] the reason above, and the one
110    // recorded for the same field of `SourcePlan` in plan.rs: `kind` is an open
111    // vocabulary a subprocess plugin extends at run time, and `SourcePlan.kind: String`
112    // is approved contract text this field is rendered beside.
113    pub kind: String,
114    /// Whether it built, and what it can do if it did.
115    #[serde(flatten)]
116    pub state: SourceState,
117}
118
119/// Whether a configured source is answering.
120#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
121#[serde(tag = "state", rename_all = "kebab-case")]
122pub enum SourceState {
123    /// The source built, and declares this.
124    Available {
125        /// What it applies itself.
126        capabilities: Capabilities,
127    },
128    /// The source could not be built at all.
129    Unavailable {
130        /// Why not.
131        error: SourceError,
132    },
133}
134
135/// Which page of a result set the caller wants.
136#[derive(Debug, Clone, PartialEq)]
137pub struct Paging {
138    /// The most items to return.
139    pub limit: NonZeroU32,
140    /// Where to resume, or `None` to start at the beginning.
141    pub token: Option<PageToken>,
142}
143
144/// The filters every list verb shares.
145#[derive(Debug, Clone, Default, PartialEq)]
146pub struct Filters {
147    /// Free-text search, when the caller asked for one.
148    pub text: Option<TextQuery>,
149    /// Label membership, by name.
150    pub labels: LabelFilter,
151    /// Status categories to keep. Empty means unfiltered.
152    pub statuses: Vec<StatusCategory>,
153}
154
155/// A request for a page of tasks.
156#[derive(Debug, Clone)]
157pub struct TaskRequest {
158    /// Which sources to address. Empty means the configuration's own selection.
159    pub sources: Vec<SourceName>,
160    /// What to keep.
161    pub filters: Filters,
162    /// Which project the tasks belong to.
163    pub project: ProjectSelector,
164    /// Which page.
165    pub paging: Paging,
166}
167
168/// A request for a page of projects.
169#[derive(Debug, Clone)]
170pub struct ProjectRequest {
171    /// Which sources to address. Empty means the configuration's own selection.
172    pub sources: Vec<SourceName>,
173    /// What to keep.
174    pub filters: Filters,
175    /// Which page.
176    pub paging: Paging,
177}
178
179/// A request for a page of labels.
180#[derive(Debug, Clone)]
181pub struct LabelRequest {
182    /// Which sources to address. Empty means the configuration's own selection.
183    pub sources: Vec<SourceName>,
184    /// Which page.
185    pub paging: Paging,
186}
187
188/// A request for a page of search hits.
189#[derive(Debug, Clone)]
190pub struct SearchRequest {
191    /// Which sources to address. Empty means the configuration's own selection.
192    pub sources: Vec<SourceName>,
193    /// What to look for, and where.
194    pub text: TextQuery,
195    /// Which entities to cover.
196    pub kind: SearchKind,
197    /// Which page.
198    pub paging: Paging,
199}
200
201/// A request for a page of one item's dependency edges.
202#[derive(Debug, Clone)]
203pub struct DependencyRequest {
204    /// The qualified item to walk from.
205    pub id: GlobalId,
206    /// Which way to walk.
207    pub direction: Direction,
208    /// Which page.
209    pub paging: Paging,
210}
211
212/// What the engine refuses before it asks any source.
213///
214/// Distinct from a [`SourceFailure`], which is one source failing while the others
215/// answer: everything here means the request itself cannot be run at all.
216#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
217pub enum EngineError {
218    /// A `--source` named something no configuration configures.
219    #[error(
220        "no source named {name:?} is configured\n\
221         next: name one of the configured sources ({configured}), or add {name:?} under \
222         `sources` — `onetaskgraph sources list` shows what this configuration has."
223    )]
224    UnknownSource {
225        /// The name that was asked for.
226        name: String,
227        /// The names that exist, for the message.
228        configured: String,
229    },
230
231    /// A `--page` token that decodes but does not belong to this query.
232    #[error(
233        "{message}\n\
234         next: page with a token exactly as the previous page reported it, and against \
235         the same configuration — or drop `--page` to start the walk again."
236    )]
237    Token {
238        /// What the token claims that this configuration cannot honour.
239        message: String,
240    },
241
242    /// Nothing at all is configured, so there is nothing to ask.
243    #[error(
244        "no sources are configured\n\
245         next: add one under `sources` in onetaskgraph.yaml — `onetaskgraph schema` \
246         prints what each plugin accepts."
247    )]
248    NoSources,
249}
250
251/// One configured source, in exactly one of the two states a configured source has.
252///
253/// A sum rather than two lists side by side: with a `ready` vector and an `unavailable`
254/// one, a source appearing in both is a shape the type permits and every reader has to
255/// decide about — and they would not all decide the same way, since one of them fans a
256/// query out and another names failures.
257pub enum ConfiguredSource {
258    /// It built, and answers queries.
259    Ready(ResolvedSource),
260    /// It did not, and every response says so instead.
261    Unavailable(UnavailableSource),
262}
263
264impl ConfiguredSource {
265    /// The name the configuration gave it, whichever state it is in.
266    #[must_use]
267    pub fn name(&self) -> &SourceName {
268        match self {
269            Self::Ready(source) => source.name(),
270            Self::Unavailable(source) => source.name(),
271        }
272    }
273}
274
275/// The sources a configuration resolved to, and the queries they answer.
276pub struct Engine {
277    /// Every configured source, in configured-name order, each in one state.
278    sources: Vec<ConfiguredSource>,
279    /// Which sources answer when a request names none.
280    selection: Vec<SourceName>,
281}
282
283impl Engine {
284    /// Build every source a configuration names.
285    ///
286    /// A source whose plugin refuses to build — a credential that is not there, a
287    /// plugin whose implementation has not landed — is **not** fatal: it becomes an
288    /// entry in every response's `errors`, exactly as a source that fails mid-query
289    /// does, and the other sources still answer. A user with three sources and one
290    /// expired token gets the other two rather than nothing.
291    #[must_use]
292    pub fn build(config: &Config, secrets: &dyn SecretResolver) -> Self {
293        let (ready, unavailable) = resolve_available(config, secrets);
294        Self::new(
295            ready
296                .into_iter()
297                .map(ConfiguredSource::Ready)
298                .chain(unavailable.into_iter().map(ConfiguredSource::Unavailable))
299                .collect(),
300            config.selected_sources(),
301        )
302    }
303
304    /// Drive sources built elsewhere — the engine's own tests, and any caller holding a
305    /// source it did not resolve from a configuration document.
306    #[must_use]
307    pub fn new(sources: Vec<ConfiguredSource>, selection: Vec<SourceName>) -> Self {
308        Self { sources, selection }
309    }
310
311    /// Every source that built, in configured-name order.
312    fn ready(&self) -> impl Iterator<Item = &ResolvedSource> {
313        self.sources.iter().filter_map(|source| match source {
314            ConfiguredSource::Ready(ready) => Some(ready),
315            ConfiguredSource::Unavailable(_) => None,
316        })
317    }
318
319    /// Every source that did not, in the same order.
320    fn unavailable(&self) -> impl Iterator<Item = &UnavailableSource> {
321        self.sources.iter().filter_map(|source| match source {
322            ConfiguredSource::Unavailable(unavailable) => Some(unavailable),
323            ConfiguredSource::Ready(_) => None,
324        })
325    }
326
327    /// Every configured source, whether or not it built, in name order.
328    #[must_use]
329    pub fn listing(&self) -> Vec<SourceListing> {
330        let mut listings: Vec<SourceListing> = self
331            .ready()
332            .map(|source| SourceListing {
333                source: source.name().clone(),
334                kind: source.kind().to_owned(),
335                state: SourceState::Available {
336                    capabilities: source.source().capabilities(),
337                },
338            })
339            .chain(self.unavailable().map(|source| SourceListing {
340                source: source.name().clone(),
341                kind: source.kind().to_owned(),
342                state: SourceState::Unavailable {
343                    error: source.error().clone(),
344                },
345            }))
346            .collect();
347        listings.sort_by(|left, right| left.source.cmp(&right.source));
348        listings
349    }
350
351    /// Whether this configuration has a source called `name`, built or not.
352    ///
353    /// A caller reading a `--project` argument needs this: `urn:project:1` is a qualified
354    /// id only if `urn` is a source here, and a native id full of colons otherwise. That
355    /// rule cannot be applied without knowing what is configured.
356    #[must_use]
357    pub fn has(&self, name: &SourceName) -> bool {
358        self.sources.iter().any(|source| source.name() == name)
359    }
360
361    /// One page of tasks.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`EngineError`] when the request names a source nothing configures, or
366    /// carries a page token this engine did not issue. One source failing is not an
367    /// error: it lands in the response's `errors`.
368    pub async fn tasks(
369        &self,
370        request: &TaskRequest,
371    ) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
372        let mut names = self.resolve_selection(&request.sources)?;
373        // A qualified project id names one project of one source, so no other source can
374        // hold a task in it. Narrowing here means the plan reports the source that was
375        // actually asked rather than a row of empty entries for sources that could not
376        // have answered.
377        if let ProjectSelector::Qualified(id) = &request.project {
378            self.known(&id.source)?;
379            names.retain(|name| name == &id.source);
380        }
381        let query = shape("task-list", &names, &(&request.filters, &request.project));
382        let states = resumption(
383            self,
384            request.paging.token.as_ref(),
385            &[StreamKind::Items],
386            &query,
387        )?;
388        let budget = request.paging.limit.get();
389
390        let mut answer = Answer::new();
391        let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
392
393        let shapes: Vec<TaskShape> = ready
394            .iter()
395            .map(|source| {
396                shape_tasks(
397                    &source.source().capabilities(),
398                    &request.filters,
399                    &project_filter(&request.project),
400                )
401            })
402            .collect();
403        let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
404        let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
405
406        let walks = ready
407            .iter()
408            .enumerate()
409            .map(|(index, source)| {
410                fetch_tasks(
411                    source,
412                    &shapes[index],
413                    &starts[index],
414                    budget,
415                    &counters[index],
416                )
417            })
418            .collect();
419
420        let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
421        answer.finish(
422            streams,
423            budget,
424            owed(&states),
425            &query,
426            |name, task: Task| Qualified {
427                id: GlobalId::new(name.clone(), task.id.clone()),
428                item: task,
429            },
430        )
431    }
432
433    /// One page of projects.
434    ///
435    /// # Errors
436    ///
437    /// As [`tasks`](Self::tasks).
438    pub async fn projects(
439        &self,
440        request: &ProjectRequest,
441    ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
442        let names = self.resolve_selection(&request.sources)?;
443        let query = shape("project-list", &names, &request.filters);
444        let states = resumption(
445            self,
446            request.paging.token.as_ref(),
447            &[StreamKind::Items],
448            &query,
449        )?;
450        let budget = request.paging.limit.get();
451
452        let mut answer = Answer::new();
453        // A source declaring `projects: unsupported` has no project table at all, so
454        // there is nothing to compensate for and nothing to ask: the predicate is
455        // reported unavailable and that source contributes no rows. This is the one
456        // outcome the engine cannot narrow its way out of, which is what `unavailable`
457        // in the plan is for.
458        let mut with_projects = Vec::new();
459        for source in answer.split(self, &names) {
460            if source.source().capabilities().projects.is_native() {
461                with_projects.push(source);
462            } else {
463                answer.unreachable_predicate(source, Predicate::Project);
464            }
465        }
466        let (ready, starts) = walking(with_projects, &states, StreamKind::Items);
467
468        let shapes: Vec<ProjectShape> = ready
469            .iter()
470            .map(|source| shape_projects(&source.source().capabilities(), &request.filters))
471            .collect();
472        let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
473        let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
474
475        let walks = ready
476            .iter()
477            .enumerate()
478            .map(|(index, source)| {
479                fetch_projects(
480                    source,
481                    &shapes[index],
482                    &starts[index],
483                    budget,
484                    &counters[index],
485                )
486            })
487            .collect();
488
489        let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
490        answer.finish(
491            streams,
492            budget,
493            owed(&states),
494            &query,
495            |name, project: Project| Qualified {
496                id: GlobalId::new(name.clone(), project.id.clone()),
497                item: project,
498            },
499        )
500    }
501
502    /// One page of labels.
503    ///
504    /// # Errors
505    ///
506    /// As [`tasks`](Self::tasks).
507    pub async fn labels(
508        &self,
509        request: &LabelRequest,
510    ) -> Result<QueryResponse<Qualified<Label>>, EngineError> {
511        let names = self.resolve_selection(&request.sources)?;
512        let query = shape("label-list", &names, &());
513        let states = resumption(
514            self,
515            request.paging.token.as_ref(),
516            &[StreamKind::Items],
517            &query,
518        )?;
519        let budget = request.paging.limit.get();
520
521        let mut answer = Answer::new();
522        let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
523        let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
524        let outcomes: Vec<Outcomes> = ready.iter().map(|_| Outcomes::default()).collect();
525
526        let walks = ready
527            .iter()
528            .enumerate()
529            .map(|(index, source)| fetch_labels(source, &starts[index], budget, &counters[index]))
530            .collect();
531
532        let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
533        answer.finish(
534            streams,
535            budget,
536            owed(&states),
537            &query,
538            |name, label: Label| Qualified {
539                id: GlobalId::new(name.clone(), label.id.clone()),
540                item: label,
541            },
542        )
543    }
544
545    /// One page of search hits, over tasks, projects, or both.
546    ///
547    /// # Errors
548    ///
549    /// As [`tasks`](Self::tasks).
550    pub async fn search(
551        &self,
552        request: &SearchRequest,
553    ) -> Result<QueryResponse<SearchHit>, EngineError> {
554        let names = self.resolve_selection(&request.sources)?;
555        // The streams this search reads, which is what a token resuming it may name. A
556        // `--kind both` walk that has exhausted one half carries only the other, so this
557        // is what a token may name rather than what it must.
558        let reads: &[StreamKind] = match request.kind {
559            SearchKind::Tasks => &[StreamKind::Tasks],
560            SearchKind::Projects => &[StreamKind::Projects],
561            SearchKind::Both => &[StreamKind::Tasks, StreamKind::Projects],
562        };
563        // The scope is deliberately not in the fingerprint: which streams a search covers
564        // is exactly what `reads` checks below, name by name and with a message that says
565        // which half a token names. Folding it in here would refuse the same mistake one
566        // layer earlier and less clearly, and leave that check unreachable.
567        let query = shape("search", &names, &request.text);
568        let states = resumption(self, request.paging.token.as_ref(), reads, &query)?;
569        let budget = request.paging.limit.get();
570        let filters = Filters {
571            text: Some(request.text.clone()),
572            ..Filters::default()
573        };
574
575        let mut answer = Answer::new();
576
577        // One stream per (source, entity), because a search over both entities reads two
578        // result sets from each source and each has its own place to resume.
579        let mut ready = Vec::new();
580        let mut kinds = Vec::new();
581        let mut starts = Vec::new();
582        for source in answer.split(self, &names) {
583            let mut streams = Vec::new();
584            if matches!(request.kind, SearchKind::Tasks | SearchKind::Both) {
585                streams.push(StreamKind::Tasks);
586            }
587            if matches!(request.kind, SearchKind::Projects | SearchKind::Both) {
588                if source.source().capabilities().projects.is_native() {
589                    streams.push(StreamKind::Projects);
590                } else {
591                    answer.unreachable_predicate(source, Predicate::Project);
592                }
593            }
594            for stream in streams {
595                if let Some(resume) = resume_at(&states, source.name(), stream) {
596                    ready.push(source);
597                    kinds.push(stream);
598                    starts.push(resume);
599                }
600            }
601        }
602
603        let shapes: Vec<HitShape> = ready
604            .iter()
605            .zip(kinds.iter())
606            .map(|(source, kind)| shape_hits(&source.source().capabilities(), &filters, *kind))
607            .collect();
608        let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
609        let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
610
611        let walks = ready
612            .iter()
613            .enumerate()
614            .map(|(index, source)| {
615                fetch_hits(
616                    source,
617                    &shapes[index],
618                    &starts[index],
619                    budget,
620                    &counters[index],
621                )
622            })
623            .collect();
624
625        let streams =
626            answer.collect_streams(&ready, &kinds, join_all(walks).await, &counters, outcomes);
627        answer.finish(
628            streams,
629            budget,
630            owed(&states),
631            &query,
632            |name, found: Found| match found {
633                Found::Task(task) => SearchHit::Task(Qualified {
634                    id: GlobalId::new(name.clone(), task.id.clone()),
635                    item: task,
636                }),
637                Found::Project(project) => SearchHit::Project(Qualified {
638                    id: GlobalId::new(name.clone(), project.id.clone()),
639                    item: project,
640                }),
641            },
642        )
643    }
644
645    /// One task by its qualified id, or an empty page when there is no such task.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`EngineError::UnknownSource`] when the id names a source nothing
650    /// configures.
651    pub async fn task(&self, id: &GlobalId) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
652        let name = self.known(&id.source)?;
653        let mut answer = Answer::new();
654        let selected = answer.split(self, std::slice::from_ref(&name));
655        let Some(source) = selected.first() else {
656            return answer.nothing();
657        };
658        let found = source.source().get_task(&id.native).await;
659        let qualified = GlobalId::new(source.name().clone(), id.native.clone());
660        answer.one(source, found, |task| Qualified {
661            id: qualified,
662            item: task,
663        })
664    }
665
666    /// One project by its qualified id, or an empty page when there is no such project.
667    ///
668    /// # Errors
669    ///
670    /// As [`task`](Self::task).
671    pub async fn project(
672        &self,
673        id: &GlobalId,
674    ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
675        let name = self.known(&id.source)?;
676        let mut answer = Answer::new();
677        let selected = answer.split(self, std::slice::from_ref(&name));
678        let Some(source) = selected.first() else {
679            return answer.nothing();
680        };
681        let found = source.source().get_project(&id.native).await;
682        let qualified = GlobalId::new(source.name().clone(), id.native.clone());
683        answer.one(source, found, |project| Qualified {
684            id: qualified,
685            item: project,
686        })
687    }
688
689    /// One page of a task's dependency edges.
690    ///
691    /// # Errors
692    ///
693    /// As [`task`](Self::task), plus [`EngineError::Token`] for a page token this engine
694    /// did not issue.
695    pub async fn task_dependencies(
696        &self,
697        request: &DependencyRequest,
698    ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
699        self.dependencies(request, Entity::Task).await
700    }
701
702    /// One page of a project's dependency edges.
703    ///
704    /// # Errors
705    ///
706    /// As [`task_dependencies`](Self::task_dependencies).
707    pub async fn project_dependencies(
708        &self,
709        request: &DependencyRequest,
710    ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
711        self.dependencies(request, Entity::Project).await
712    }
713
714    /// Both dependency verbs, which differ only in which of a source's two edge sets
715    /// they read and which of its two declarations governs the reverse direction.
716    async fn dependencies(
717        &self,
718        request: &DependencyRequest,
719        entity: Entity,
720    ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
721        let name = self.known(&request.id.source)?;
722        let query = shape(
723            "dependencies",
724            std::slice::from_ref(&name),
725            &(entity, &request.id.native, request.direction),
726        );
727        let states = resumption(
728            self,
729            request.paging.token.as_ref(),
730            &[StreamKind::Items],
731            &query,
732        )?;
733        let budget = request.paging.limit.get();
734
735        let mut answer = Answer::new();
736        let (ready, starts) = walking(
737            answer.split(self, std::slice::from_ref(&name)),
738            &states,
739            StreamKind::Items,
740        );
741        let Some(source) = ready.first() else {
742            return answer.nothing();
743        };
744
745        let capabilities = source.source().capabilities();
746        let support = match entity {
747            Entity::Task => capabilities.task_dependencies,
748            Entity::Project => capabilities.project_dependencies,
749        };
750        // `DependencySupport` has no unsupported variant on purpose: a dependency read is
751        // answered natively or emulated by the scan below, never abandoned and never
752        // silently empty.
753        let emulating = request.direction == Direction::DependedOnBy && !support.answers_reverse();
754        let mut outcomes = Outcomes::default();
755        if request.direction == Direction::DependedOnBy {
756            if emulating {
757                outcomes.record(Predicate::ReverseDependencies, Outcome::Emulated);
758            } else {
759                outcomes.record(Predicate::ReverseDependencies, Outcome::PushedDown);
760            }
761        }
762
763        let counters = vec![AtomicU32::new(0)];
764        let walked = fetch_edges(
765            source,
766            &request.id.native,
767            request.direction,
768            entity,
769            emulating,
770            &starts[0],
771            budget,
772            &counters[0],
773        )
774        .await;
775
776        let streams = answer.collect(&ready, vec![walked], &counters, vec![outcomes]);
777        answer.finish(
778            streams,
779            budget,
780            owed(&states),
781            &query,
782            |name, edge: DependencyEdge| QualifiedEdge {
783                from: GlobalId::new(name.clone(), edge.from),
784                to: GlobalId::new(name.clone(), edge.to),
785                kind: edge.kind,
786            },
787        )
788    }
789
790    /// The names a request addresses: the ones it gave, or the configuration's own.
791    fn resolve_selection(&self, asked: &[SourceName]) -> Result<Vec<SourceName>, EngineError> {
792        if asked.is_empty() {
793            if self.selection.is_empty() {
794                return Err(EngineError::NoSources);
795            }
796            return Ok(self.selection.clone());
797        }
798        asked.iter().map(|name| self.known(name)).collect()
799    }
800
801    /// `name` when this configuration has a source called that.
802    fn known(&self, name: &SourceName) -> Result<SourceName, EngineError> {
803        if self.has(name) {
804            return Ok(name.clone());
805        }
806        if self.sources.is_empty() {
807            return Err(EngineError::NoSources);
808        }
809        Err(EngineError::UnknownSource {
810            name: name.to_string(),
811            configured: self
812                .listing()
813                .iter()
814                .map(|listing| listing.source.to_string())
815                .collect::<Vec<_>>()
816                .join(", "),
817        })
818    }
819}
820
821/// Which of a source's two dependency graphs a request walks.
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823enum Entity {
824    /// Task dependencies.
825    Task,
826    /// Project dependencies.
827    Project,
828}
829
830/// A search hit before it is qualified.
831enum Found {
832    /// A task matched.
833    Task(Task),
834    /// A project matched.
835    Project(Project),
836}
837
838/// What happened to one predicate against one source.
839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
840enum Outcome {
841    /// Applied by the source itself.
842    PushedDown,
843    /// Applied by the engine over a wider result set.
844    AppliedLocally,
845    /// Answered by a bounded scan of the source.
846    Emulated,
847    /// Neither side could answer it, so this source contributed nothing for it.
848    Unavailable,
849}
850
851/// What happened to each predicate against one source.
852///
853/// Keyed by predicate, one outcome each, because those are the only states there are: a
854/// predicate the source applied was not also applied here, and one nobody could answer
855/// was not also pushed down. The four lists [`SourcePlan`] carries are this map fanned
856/// out at the boundary — held *as* four lists a predicate could sit in all four at once,
857/// and four contradictory claims about one predicate is the one thing the part of the
858/// answer whose whole job is to say which of them is true must not be able to say.
859///
860/// A `BTreeMap` rather than a `HashMap` so the lists come out in one order and two runs
861/// of a query render the same plan.
862#[derive(Debug, Clone, Default, PartialEq)]
863struct Outcomes(BTreeMap<Predicate, Outcome>);
864
865impl Outcomes {
866    /// Record what happened to one predicate, replacing whatever was recorded before.
867    ///
868    /// Replacing rather than refusing: shaping a query decides each predicate once, and a
869    /// second decision about the same one is the later one — there is no case here where
870    /// both were meant to stand.
871    fn record(&mut self, predicate: Predicate, outcome: Outcome) {
872        self.0.insert(predicate, outcome);
873    }
874
875    /// Record the same outcome for several predicates, as a text search does for the two
876    /// fields it covers.
877    fn record_all(&mut self, predicates: impl IntoIterator<Item = Predicate>, outcome: Outcome) {
878        for predicate in predicates {
879            self.record(predicate, outcome);
880        }
881    }
882
883    /// The predicates this outcome befell, in the map's own stable order.
884    fn with(&self, outcome: Outcome) -> Vec<Predicate> {
885        self.0
886            .iter()
887            .filter(|(_, recorded)| **recorded == outcome)
888            .map(|(predicate, _)| *predicate)
889            .collect()
890    }
891}
892
893/// The query one source sees, and the predicates left to the engine.
894struct TaskShape {
895    /// What the source is asked.
896    pushed: TaskQuery,
897    /// What the engine narrows afterwards.
898    local: LocalTasks,
899    /// What to report.
900    outcomes: Outcomes,
901}
902
903/// As [`TaskShape`], for projects.
904struct ProjectShape {
905    /// What the source is asked.
906    pushed: ProjectQuery,
907    /// What the engine narrows afterwards.
908    local: LocalProjects,
909    /// What to report.
910    outcomes: Outcomes,
911}
912
913/// As [`TaskShape`], for one entity's half of a search.
914struct HitShape {
915    /// Which entity this stream reads.
916    stream: StreamKind,
917    /// What a task stream asks.
918    tasks: TaskQuery,
919    /// What a project stream asks.
920    projects: ProjectQuery,
921    /// What the engine narrows afterwards, for tasks.
922    local_tasks: LocalTasks,
923    /// What the engine narrows afterwards, for projects.
924    local_projects: LocalProjects,
925    /// What to report.
926    outcomes: Outcomes,
927}
928
929/// The plan and the failures a response carries, accumulated as the verb runs.
930///
931/// One type rather than three parallel vectors threaded through every verb, because the
932/// rule they enforce together is one rule: a source that fails contributes an error and
933/// still leaves every other source's results standing.
934struct Answer {
935    /// One entry per source the engine addressed, merged by source at the end.
936    plans: Vec<SourcePlan>,
937    /// Every source that could not answer.
938    errors: Vec<SourceFailure>,
939}
940
941impl Answer {
942    fn new() -> Self {
943        Self {
944            plans: Vec::new(),
945            errors: Vec::new(),
946        }
947    }
948
949    /// The selected sources that built, recording the ones that did not as failures.
950    ///
951    /// A source that never built is reported and skipped rather than fatal: that is the
952    /// same rule as a source failing mid-query, applied one step earlier.
953    fn split<'a>(&mut self, engine: &'a Engine, names: &[SourceName]) -> Vec<&'a ResolvedSource> {
954        let mut selected = Vec::new();
955        for name in names {
956            match engine.sources.iter().find(|source| source.name() == name) {
957                Some(ConfiguredSource::Ready(source)) => selected.push(source),
958                Some(ConfiguredSource::Unavailable(source)) => {
959                    self.errors.push(source.failure());
960                }
961                None => {}
962            }
963        }
964        selected
965    }
966
967    /// Record that a source could answer nothing for `predicate`.
968    fn unreachable_predicate(&mut self, source: &ResolvedSource, predicate: Predicate) {
969        let mut outcomes = Outcomes::default();
970        outcomes.record(predicate, Outcome::Unavailable);
971        self.plans.push(plan_for(source, outcomes, 0));
972    }
973
974    /// Turn each source's walk into a stream, keeping a failed source's failure.
975    fn collect<T>(
976        &mut self,
977        ready: &[&ResolvedSource],
978        walked: Vec<Result<Fetched<T>, SourceError>>,
979        counters: &[AtomicU32],
980        outcomes: Vec<Outcomes>,
981    ) -> Vec<Stream<T>> {
982        let kinds = vec![StreamKind::Items; ready.len()];
983        self.collect_streams(ready, &kinds, walked, counters, outcomes)
984    }
985
986    /// As [`collect`](Self::collect), where a source may contribute more than one stream.
987    fn collect_streams<T>(
988        &mut self,
989        ready: &[&ResolvedSource],
990        kinds: &[StreamKind],
991        walked: Vec<Result<Fetched<T>, SourceError>>,
992        counters: &[AtomicU32],
993        outcomes: Vec<Outcomes>,
994    ) -> Vec<Stream<T>> {
995        let mut streams = Vec::new();
996        for (index, result) in walked.into_iter().enumerate() {
997            let source = ready[index];
998            let pages = counters[index].load(Ordering::Relaxed);
999            self.plans
1000                .push(plan_for(source, outcomes[index].clone(), pages));
1001            match result {
1002                Ok(fetched) => streams.push(Stream {
1003                    source: source.name().clone(),
1004                    kind: kinds[index],
1005                    fetched,
1006                }),
1007                // A stream that failed leaves the token, so a walk always terminates: a
1008                // source failing on every page would otherwise page forever.
1009                Err(error) => self.errors.push(SourceFailure {
1010                    source: source.name().clone(),
1011                    error,
1012                }),
1013            }
1014        }
1015        streams
1016    }
1017
1018    /// The response for a verb that reads exactly one item from exactly one source.
1019    fn one<T, U>(
1020        mut self,
1021        source: &ResolvedSource,
1022        found: Result<Option<T>, SourceError>,
1023        qualify: impl FnOnce(T) -> U,
1024    ) -> Result<QueryResponse<U>, EngineError> {
1025        self.plans.push(plan_for(source, Outcomes::default(), 1));
1026        let items = match found {
1027            Ok(Some(item)) => vec![qualify(item)],
1028            Ok(None) => Vec::new(),
1029            Err(error) => {
1030                self.errors.push(SourceFailure {
1031                    source: source.name().clone(),
1032                    error,
1033                });
1034                Vec::new()
1035            }
1036        };
1037        Ok(QueryResponse {
1038            items,
1039            next: None,
1040            plan: QueryPlan {
1041                per_source: merge_plans(self.plans),
1042            },
1043            errors: self.errors,
1044        })
1045    }
1046
1047    /// The response for a verb with nothing left to ask.
1048    fn nothing<U>(self) -> Result<QueryResponse<U>, EngineError> {
1049        Ok(QueryResponse {
1050            items: Vec::new(),
1051            next: None,
1052            plan: QueryPlan {
1053                per_source: merge_plans(self.plans),
1054            },
1055            errors: self.errors,
1056        })
1057    }
1058
1059    /// Merge the streams into the caller's page and mint the token that resumes it.
1060    ///
1061    /// `first` is the stream the token being resumed says is owed the next row, so the
1062    /// round-robin picks up where the previous page stopped rather than restarting.
1063    fn finish<T, U>(
1064        self,
1065        streams: Vec<Stream<T>>,
1066        budget: u32,
1067        first: Option<&Owed>,
1068        query: &str,
1069        qualify: impl Fn(&SourceName, T) -> U,
1070    ) -> Result<QueryResponse<U>, EngineError> {
1071        let (rows, states, owed) = merge(streams, budget, first);
1072        let next = (!states.is_empty()).then(|| PageToken::encode(query, owed, &states));
1073        Ok(QueryResponse {
1074            items: rows
1075                .into_iter()
1076                .map(|(name, item)| qualify(&name, item))
1077                .collect(),
1078            next,
1079            plan: QueryPlan {
1080                per_source: merge_plans(self.plans),
1081            },
1082            errors: self.errors,
1083        })
1084    }
1085}
1086
1087/// One source's plan entry: the outcomes fanned out into the four lists the contract's
1088/// [`SourcePlan`] carries, each in one order so two runs read the same.
1089fn plan_for(source: &ResolvedSource, outcomes: Outcomes, pages: u32) -> SourcePlan {
1090    SourcePlan {
1091        source: source.name().clone(),
1092        kind: source.kind().to_owned(),
1093        pushed_down: outcomes.with(Outcome::PushedDown),
1094        applied_locally: outcomes.with(Outcome::AppliedLocally),
1095        emulated: outcomes.with(Outcome::Emulated),
1096        unavailable: outcomes.with(Outcome::Unavailable),
1097        pages_fetched: pages,
1098    }
1099}
1100
1101/// One entry per source, however many streams that source contributed.
1102///
1103/// `search --kind both` reads two streams from each source, and a plan is per source:
1104/// two entries for one name would say the engine addressed it twice.
1105fn merge_plans(plans: Vec<SourcePlan>) -> Vec<SourcePlan> {
1106    let mut merged: Vec<SourcePlan> = Vec::new();
1107    for plan in plans {
1108        if let Some(existing) = merged
1109            .iter_mut()
1110            .find(|existing| existing.source == plan.source)
1111        {
1112            existing.pushed_down.extend(plan.pushed_down);
1113            existing.applied_locally.extend(plan.applied_locally);
1114            existing.emulated.extend(plan.emulated);
1115            existing.unavailable.extend(plan.unavailable);
1116            existing.pages_fetched = existing.pages_fetched.saturating_add(plan.pages_fetched);
1117            for list in [
1118                &mut existing.pushed_down,
1119                &mut existing.applied_locally,
1120                &mut existing.emulated,
1121                &mut existing.unavailable,
1122            ] {
1123                list.sort_unstable();
1124                list.dedup();
1125            }
1126        } else {
1127            merged.push(plan);
1128        }
1129    }
1130    merged
1131}
1132
1133/// The sources still walking, with where each picks up.
1134///
1135/// A token names every stream that has more to give, so a source **absent** from one has
1136/// been exhausted and is not asked again. Without that, the second page of a walk would
1137/// restart every finished source from its first row.
1138fn walking<'a>(
1139    selected: Vec<&'a ResolvedSource>,
1140    states: &Option<Resumption>,
1141    kind: StreamKind,
1142) -> (Vec<&'a ResolvedSource>, Vec<Resume>) {
1143    let mut ready = Vec::new();
1144    let mut starts = Vec::new();
1145    for source in selected {
1146        if let Some(resume) = resume_at(states, source.name(), kind) {
1147            ready.push(source);
1148            starts.push(resume);
1149        }
1150    }
1151    (ready, starts)
1152}
1153
1154/// A fingerprint of everything about a query that decides which rows it returns, and in
1155/// what order — the verb, the sources it addresses, and every filter it carries.
1156///
1157/// Written from the request's own [`Debug`] rather than field by field, and that is the
1158/// point: a filter added to `Filters` next year joins the fingerprint by existing. A
1159/// hand-written canonical form would keep compiling with the new field missing, and the
1160/// tokens it minted would silently stop distinguishing the queries that differ by it —
1161/// which is the whole failure this exists to prevent, reintroduced quietly.
1162///
1163/// Hashed rather than carried whole so a token stays a thing a person can paste. This is
1164/// not a signature and there is nothing secret in a token — see [`PageToken`]. It detects
1165/// a caller resuming the wrong walk, which is a mistake rather than an attack, so FNV-1a
1166/// is enough and needs no dependency the supply-chain gate would then have to weigh.
1167///
1168/// [`Debug`] output is not promised to be stable across compiler releases, and that is
1169/// survivable here: a token outstanding across a rebuild is refused with the message
1170/// above rather than honoured wrongly, which is the safe direction to fail in.
1171fn shape(verb: &str, sources: &[SourceName], filters: &impl std::fmt::Debug) -> String {
1172    let names: Vec<&str> = sources.iter().map(SourceName::as_str).collect();
1173    fingerprint(&format!("{verb}|{names:?}|{filters:?}"))
1174}
1175
1176/// FNV-1a over `text`, as sixteen hex digits.
1177fn fingerprint(text: &str) -> String {
1178    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1179    for byte in text.as_bytes() {
1180        hash ^= u64::from(*byte);
1181        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1182    }
1183    format!("{hash:016x}")
1184}
1185
1186/// The stream a token says is owed the next row, or `None` when it says none is.
1187///
1188/// A fresh query has no token and every token whose last round ended evenly carries no
1189/// such stream, so `None` is the common case and means "begin at the first stream".
1190fn owed(document: &Option<Resumption>) -> Option<&Owed> {
1191    document.as_ref()?.owed.as_ref()
1192}
1193
1194/// Where one stream picks up, or `None` when the token says it is finished.
1195fn resume_at(states: &Option<Resumption>, source: &SourceName, kind: StreamKind) -> Option<Resume> {
1196    match states {
1197        None => Some(Resume::default()),
1198        Some(document) => document
1199            .streams
1200            .iter()
1201            .find(|state| &state.source == source && state.stream == kind)
1202            .map(|state| state.resume.clone()),
1203    }
1204}
1205
1206/// Read a caller's page token against the sources this configuration has.
1207///
1208/// [`PageToken::parse`] has already established that the string is this engine's own
1209/// resume document — that is structural and happens where the caller's string enters.
1210/// What it cannot establish is that the document belongs *here*, because only the engine
1211/// knows which sources are configured and what page ceiling each one declares. So the
1212/// three things a token this engine wrote is always true of are checked here:
1213///
1214/// 1. every stream it names belongs to a configured source, so a token carried over from
1215///    another configuration is refused rather than quietly resuming half a walk;
1216/// 2. every stream it names is one **this verb reads**. A token minted by
1217///    `search --kind both` carries task and project streams, and `task list` reads
1218///    neither; without this it would find nothing to resume, drop every source, and
1219///    answer with an empty page and a zero exit — a wrong answer that looks like an
1220///    exhausted walk, which is the worst shape this failure could take;
1221/// 3. no stream appears twice, because a walk has one place to pick up per stream;
1222/// 4. no `skip` reaches a source's declared page ceiling, because the engine's `skip` is
1223///    an index among the surviving rows of one source page and can never reach it.
1224///
1225/// 5. the stream owed the next row, when the document names one, is a stream the
1226///    document also resumes.
1227///
1228/// A sixth thing needs no check here: at most one stream is owed the next row, because
1229/// [`Resumption`] holds that as one optional stream rather than as a flag on each of
1230/// them, so a document naming two has no spelling.
1231///
1232/// None of this is a security boundary and a page token is not a credential: nothing in
1233/// one is secret, and a forged cursor is handed straight back to the source that would
1234/// have issued it and refused there. What it buys is that a stale or hand-edited token
1235/// fails saying so, instead of silently returning a page from somewhere else in the walk.
1236fn resumption(
1237    engine: &Engine,
1238    token: Option<&PageToken>,
1239    reads: &[StreamKind],
1240    query: &str,
1241) -> Result<Option<Resumption>, EngineError> {
1242    let Some(document) = token.map(PageToken::decode) else {
1243        return Ok(None);
1244    };
1245
1246    // Every cursor below is an offset into the result set *one* query produced. Handed to
1247    // a different one — the same verb with another `--label`, another `--search`, another
1248    // `--direction` — each source picks up at a position that meant something in a walk
1249    // the caller is no longer doing, and the rows that come back are real rows at exit
1250    // zero. Nothing about that answer says it is arbitrary, which is what makes it worth
1251    // refusing rather than serving.
1252    if document.query != query {
1253        return Err(EngineError::Token {
1254            message: "this page token was written by a different query — resume the walk it \
1255                      came from, or drop --page to start this one from the beginning"
1256                .to_owned(),
1257        });
1258    }
1259    let states = &document.streams;
1260
1261    let mut seen: Vec<(&SourceName, StreamKind)> = Vec::new();
1262    for state in states {
1263        if !reads.contains(&state.stream) {
1264            return Err(EngineError::Token {
1265                message: format!(
1266                    "this page token resumes {}, which this command does not read — it \
1267                     was written by a different query",
1268                    state.stream.describe()
1269                ),
1270            });
1271        }
1272        let ceiling = engine
1273            .ready()
1274            .find(|source| source.name() == &state.source)
1275            .map(ceiling);
1276        if ceiling.is_none() && !engine.has(&state.source) {
1277            return Err(EngineError::Token {
1278                message: format!(
1279                    "this page token resumes a source called {:?}, which this \
1280                     configuration does not have",
1281                    state.source.as_str()
1282                ),
1283            });
1284        }
1285        if let Some(ceiling) = ceiling
1286            && state.resume.skip >= ceiling
1287        {
1288            return Err(EngineError::Token {
1289                message: format!(
1290                    "this page token resumes {} rows into a page of source {:?}, which \
1291                     serves at most {ceiling}",
1292                    state.resume.skip,
1293                    state.source.as_str()
1294                ),
1295            });
1296        }
1297        if seen.contains(&(&state.source, state.stream)) {
1298            return Err(EngineError::Token {
1299                message: format!(
1300                    "this page token gives source {:?} two places to resume from",
1301                    state.source.as_str()
1302                ),
1303            });
1304        }
1305        seen.push((&state.source, state.stream));
1306    }
1307
1308    // The stream owed the next row has to be one of the streams this document resumes.
1309    // Ignoring a stray one would be harmless in its effect — the merge would start at the
1310    // first stream instead — but it would be a value from outside accepted without a
1311    // reading, and the next thing to depend on it would inherit that.
1312    if let Some(owed) = &document.owed
1313        && !document
1314            .streams
1315            .iter()
1316            .any(|state| state.source == owed.source && state.stream == owed.stream)
1317    {
1318        return Err(EngineError::Token {
1319            message: format!(
1320                "this page token owes the next row to a stream it does not resume, \
1321                 {:?}'s {}",
1322                owed.source.as_str(),
1323                owed.stream.describe()
1324            ),
1325        });
1326    }
1327
1328    Ok(Some(document))
1329}
1330
1331/// Which project a task must belong to, as a source sees it.
1332///
1333/// A qualified id becomes a plain native one because by the time this runs the selection
1334/// holds only that id's own source — so there is no "some other source" case to get
1335/// wrong, and none to leave untested.
1336fn project_filter(selector: &ProjectSelector) -> ProjectFilter {
1337    match selector {
1338        ProjectSelector::Any => ProjectFilter::Any,
1339        ProjectSelector::Orphans => ProjectFilter::Orphans,
1340        ProjectSelector::Native(id) => ProjectFilter::Is(id.clone()),
1341        ProjectSelector::Qualified(id) => ProjectFilter::Is(id.native.clone()),
1342    }
1343}
1344
1345/// The predicates one text query is made of.
1346fn text_predicates(fields: TextFields) -> Vec<Predicate> {
1347    match fields {
1348        TextFields::Title => vec![Predicate::SearchTitle],
1349        TextFields::Content => vec![Predicate::SearchContent],
1350        TextFields::TitleOrContent => vec![Predicate::SearchTitle, Predicate::SearchContent],
1351    }
1352}
1353
1354/// Whether a source searches **every** field this query names.
1355///
1356/// Every, not any: a `title-or-content` search pushed to a source that searches only
1357/// titles would come back missing every row that matches in the body alone — a narrower
1358/// result than the truth, which is the one thing compensation cannot repair. So a
1359/// half-capable source is not asked at all and the engine searches both fields itself.
1360fn searches_natively(capabilities: &Capabilities, fields: TextFields) -> bool {
1361    match fields {
1362        TextFields::Title => capabilities.search_title.is_native(),
1363        TextFields::Content => capabilities.search_content.is_native(),
1364        TextFields::TitleOrContent => {
1365            capabilities.search_title.is_native() && capabilities.search_content.is_native()
1366        }
1367    }
1368}
1369
1370/// Split a task query between the source and the engine.
1371fn shape_tasks(
1372    capabilities: &Capabilities,
1373    filters: &Filters,
1374    project: &ProjectFilter,
1375) -> TaskShape {
1376    let mut pushed = TaskQuery::default();
1377    let mut local = LocalTasks::default();
1378    let mut outcomes = Outcomes::default();
1379
1380    if !filters.labels.is_empty() {
1381        if capabilities.filter_by_label.is_native() {
1382            pushed.labels = filters.labels.clone();
1383            outcomes.record(Predicate::Label, Outcome::PushedDown);
1384        } else {
1385            local.labels = Some(filters.labels.clone());
1386            outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1387        }
1388    }
1389    if !filters.statuses.is_empty() {
1390        if capabilities.filter_by_status.is_native() {
1391            pushed.statuses.clone_from(&filters.statuses);
1392            outcomes.record(Predicate::Status, Outcome::PushedDown);
1393        } else {
1394            local.statuses.clone_from(&filters.statuses);
1395            outcomes.record(Predicate::Status, Outcome::AppliedLocally);
1396        }
1397    }
1398    if let Some(text) = &filters.text {
1399        let predicates = text_predicates(text.fields);
1400        if searches_natively(capabilities, text.fields) {
1401            pushed.text = Some(text.clone());
1402            outcomes.record_all(predicates, Outcome::PushedDown);
1403        } else {
1404            local.text = Some(text.clone());
1405            outcomes.record_all(predicates, Outcome::AppliedLocally);
1406        }
1407    }
1408    match project {
1409        ProjectFilter::Any => {}
1410        ProjectFilter::Orphans => {
1411            if capabilities.orphan_tasks.is_native() {
1412                pushed.project = ProjectFilter::Orphans;
1413                outcomes.record(Predicate::Project, Outcome::PushedDown);
1414            } else {
1415                local.project = Some(ProjectFilter::Orphans);
1416                outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1417            }
1418        }
1419        ProjectFilter::Is(id) => {
1420            if capabilities.projects.is_native() {
1421                pushed.project = ProjectFilter::Is(id.clone());
1422                outcomes.record(Predicate::Project, Outcome::PushedDown);
1423            } else {
1424                local.project = Some(ProjectFilter::Is(id.clone()));
1425                outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1426            }
1427        }
1428    }
1429
1430    TaskShape {
1431        pushed,
1432        local,
1433        outcomes,
1434    }
1435}
1436
1437/// Split a project query between the source and the engine.
1438fn shape_projects(capabilities: &Capabilities, filters: &Filters) -> ProjectShape {
1439    let mut pushed = ProjectQuery::default();
1440    let mut local = LocalProjects::default();
1441    let mut outcomes = Outcomes::default();
1442
1443    if !filters.labels.is_empty() {
1444        if capabilities.filter_by_label.is_native() {
1445            pushed.labels = filters.labels.clone();
1446            outcomes.record(Predicate::Label, Outcome::PushedDown);
1447        } else {
1448            local.labels = Some(filters.labels.clone());
1449            outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1450        }
1451    }
1452    if !filters.statuses.is_empty() {
1453        if capabilities.filter_by_status.is_native() {
1454            pushed.statuses.clone_from(&filters.statuses);
1455            outcomes.record(Predicate::Status, Outcome::PushedDown);
1456        } else {
1457            local.statuses.clone_from(&filters.statuses);
1458            outcomes.record(Predicate::Status, Outcome::AppliedLocally);
1459        }
1460    }
1461    if let Some(text) = &filters.text {
1462        let predicates = text_predicates(text.fields);
1463        if searches_natively(capabilities, text.fields) {
1464            pushed.text = Some(text.clone());
1465            outcomes.record_all(predicates, Outcome::PushedDown);
1466        } else {
1467            local.text = Some(text.clone());
1468            outcomes.record_all(predicates, Outcome::AppliedLocally);
1469        }
1470    }
1471
1472    ProjectShape {
1473        pushed,
1474        local,
1475        outcomes,
1476    }
1477}
1478
1479/// Split one entity's half of a search between the source and the engine.
1480fn shape_hits(capabilities: &Capabilities, filters: &Filters, stream: StreamKind) -> HitShape {
1481    match stream {
1482        StreamKind::Projects => {
1483            let shaped = shape_projects(capabilities, filters);
1484            HitShape {
1485                stream,
1486                tasks: TaskQuery::default(),
1487                projects: shaped.pushed,
1488                local_tasks: LocalTasks::default(),
1489                local_projects: shaped.local,
1490                outcomes: shaped.outcomes,
1491            }
1492        }
1493        StreamKind::Items | StreamKind::Tasks => {
1494            let shaped = shape_tasks(capabilities, filters, &ProjectFilter::Any);
1495            HitShape {
1496                stream,
1497                tasks: shaped.pushed,
1498                projects: ProjectQuery::default(),
1499                local_tasks: shaped.local,
1500                local_projects: LocalProjects::default(),
1501                outcomes: shaped.outcomes,
1502            }
1503        }
1504    }
1505}
1506
1507/// How large a page to ask a source for.
1508///
1509/// Exactly what is needed when every predicate went down, and the source's own ceiling
1510/// when the engine is narrowing — because a compensating walk cannot know how many rows
1511/// of a page will survive, and asking for the caller's limit would turn one filtered
1512/// page into a page per surviving row.
1513fn page_size(compensating: bool, budget: u32, ceiling: u32) -> u32 {
1514    if compensating {
1515        ceiling
1516    } else {
1517        budget.min(ceiling)
1518    }
1519}
1520
1521/// The largest page this source will serve, never zero.
1522fn ceiling(source: &ResolvedSource) -> u32 {
1523    source.source().capabilities().max_page_size.max(1)
1524}
1525
1526/// Walk one source's tasks, narrowing whatever it did not apply itself.
1527async fn fetch_tasks(
1528    source: &ResolvedSource,
1529    shape: &TaskShape,
1530    start: &Resume,
1531    budget: u32,
1532    calls: &AtomicU32,
1533) -> Result<Fetched<Task>, SourceError> {
1534    let compensating = shape.local != LocalTasks::default();
1535    walk(
1536        start,
1537        budget,
1538        page_size(compensating, budget, ceiling(source)),
1539        |task| shape.local.keeps(task),
1540        |cursor, limit| async move {
1541            calls.fetch_add(1, Ordering::Relaxed);
1542            let request = PageRequest { cursor, limit };
1543            source.source().query_tasks(&shape.pushed, &request).await
1544        },
1545    )
1546    .await
1547}
1548
1549/// Walk one source's projects, narrowing whatever it did not apply itself.
1550async fn fetch_projects(
1551    source: &ResolvedSource,
1552    shape: &ProjectShape,
1553    start: &Resume,
1554    budget: u32,
1555    calls: &AtomicU32,
1556) -> Result<Fetched<Project>, SourceError> {
1557    let compensating = shape.local != LocalProjects::default();
1558    walk(
1559        start,
1560        budget,
1561        page_size(compensating, budget, ceiling(source)),
1562        |project| shape.local.keeps(project),
1563        |cursor, limit| async move {
1564            calls.fetch_add(1, Ordering::Relaxed);
1565            let request = PageRequest { cursor, limit };
1566            source
1567                .source()
1568                .query_projects(&shape.pushed, &request)
1569                .await
1570        },
1571    )
1572    .await
1573}
1574
1575/// Walk one source's labels. There is no predicate to compensate for.
1576async fn fetch_labels(
1577    source: &ResolvedSource,
1578    start: &Resume,
1579    budget: u32,
1580    calls: &AtomicU32,
1581) -> Result<Fetched<Label>, SourceError> {
1582    walk(
1583        start,
1584        budget,
1585        page_size(false, budget, ceiling(source)),
1586        |_| true,
1587        |cursor, limit| async move {
1588            calls.fetch_add(1, Ordering::Relaxed);
1589            let request = PageRequest { cursor, limit };
1590            source.source().labels(&request).await
1591        },
1592    )
1593    .await
1594}
1595
1596/// Walk one entity's half of a search.
1597async fn fetch_hits(
1598    source: &ResolvedSource,
1599    shape: &HitShape,
1600    start: &Resume,
1601    budget: u32,
1602    calls: &AtomicU32,
1603) -> Result<Fetched<Found>, SourceError> {
1604    let ceiling = ceiling(source);
1605    match shape.stream {
1606        StreamKind::Projects => {
1607            let compensating = shape.local_projects != LocalProjects::default();
1608            walk(
1609                start,
1610                budget,
1611                page_size(compensating, budget, ceiling),
1612                |found| match found {
1613                    Found::Project(project) => shape.local_projects.keeps(project),
1614                    Found::Task(_) => true,
1615                },
1616                |cursor, limit| async move {
1617                    calls.fetch_add(1, Ordering::Relaxed);
1618                    let request = PageRequest { cursor, limit };
1619                    let page = source
1620                        .source()
1621                        .query_projects(&shape.projects, &request)
1622                        .await?;
1623                    Ok(Page {
1624                        items: page.items.into_iter().map(Found::Project).collect(),
1625                        next: page.next,
1626                    })
1627                },
1628            )
1629            .await
1630        }
1631        StreamKind::Items | StreamKind::Tasks => {
1632            let compensating = shape.local_tasks != LocalTasks::default();
1633            walk(
1634                start,
1635                budget,
1636                page_size(compensating, budget, ceiling),
1637                |found| match found {
1638                    Found::Task(task) => shape.local_tasks.keeps(task),
1639                    Found::Project(_) => true,
1640                },
1641                |cursor, limit| async move {
1642                    calls.fetch_add(1, Ordering::Relaxed);
1643                    let request = PageRequest { cursor, limit };
1644                    let page = source.source().query_tasks(&shape.tasks, &request).await?;
1645                    Ok(Page {
1646                        items: page.items.into_iter().map(Found::Task).collect(),
1647                        next: page.next,
1648                    })
1649                },
1650            )
1651            .await
1652        }
1653    }
1654}
1655
1656/// One page of an item's forward edges.
1657async fn forward_edges(
1658    source: &ResolvedSource,
1659    entity: Entity,
1660    id: &NativeId,
1661    request: &PageRequest,
1662) -> Result<Page<DependencyEdge>, SourceError> {
1663    match entity {
1664        Entity::Task => {
1665            source
1666                .source()
1667                .task_dependencies(id, Direction::DependsOn, request)
1668                .await
1669        }
1670        Entity::Project => {
1671            source
1672                .source()
1673                .project_dependencies(id, Direction::DependsOn, request)
1674                .await
1675        }
1676    }
1677}
1678
1679/// Walk one item's dependency edges, emulating the reverse direction when the source
1680/// only reports forward ones.
1681///
1682/// The emulation is the bounded page-by-page scan the contract describes: a page of the
1683/// source's items, each asked for its own forward edges, keeping the ones that point at
1684/// `native`. It is indexless by construction — nothing is retained between pages beyond
1685/// the caller's own page — which is why a source that cannot walk backwards costs a scan
1686/// rather than a stored reverse index.
1687#[expect(
1688    clippy::too_many_arguments,
1689    reason = "every argument is one axis of one walk — the source, the item, the \
1690              direction, which of its two graphs, whether the reverse is emulated, where \
1691              to resume, how many rows to return and where to count calls. Grouping them \
1692              into a struct would name the same eight values one indirection further from \
1693              the loop that reads them."
1694)]
1695async fn fetch_edges(
1696    source: &ResolvedSource,
1697    native: &NativeId,
1698    direction: Direction,
1699    entity: Entity,
1700    emulating: bool,
1701    start: &Resume,
1702    budget: u32,
1703    calls: &AtomicU32,
1704) -> Result<Fetched<DependencyEdge>, SourceError> {
1705    let ceiling = ceiling(source);
1706    if !emulating {
1707        return walk(
1708            start,
1709            budget,
1710            page_size(false, budget, ceiling),
1711            |_| true,
1712            |cursor, limit| async move {
1713                calls.fetch_add(1, Ordering::Relaxed);
1714                let request = PageRequest { cursor, limit };
1715                match entity {
1716                    Entity::Task => {
1717                        source
1718                            .source()
1719                            .task_dependencies(native, direction, &request)
1720                            .await
1721                    }
1722                    Entity::Project => {
1723                        source
1724                            .source()
1725                            .project_dependencies(native, direction, &request)
1726                            .await
1727                    }
1728                }
1729            },
1730        )
1731        .await;
1732    }
1733
1734    walk(
1735        start,
1736        budget,
1737        ceiling,
1738        |_| true,
1739        |cursor, limit| async move {
1740            calls.fetch_add(1, Ordering::Relaxed);
1741            let request = PageRequest { cursor, limit };
1742            let (ids, next) = match entity {
1743                Entity::Task => {
1744                    let page = source
1745                        .source()
1746                        .query_tasks(&TaskQuery::default(), &request)
1747                        .await?;
1748                    let ids: Vec<NativeId> = page.items.into_iter().map(|task| task.id).collect();
1749                    (ids, page.next)
1750                }
1751                Entity::Project => {
1752                    let page = source
1753                        .source()
1754                        .query_projects(&ProjectQuery::default(), &request)
1755                        .await?;
1756                    let ids: Vec<NativeId> =
1757                        page.items.into_iter().map(|project| project.id).collect();
1758                    (ids, page.next)
1759                }
1760            };
1761
1762            let mut edges = Vec::new();
1763            for id in ids {
1764                let mut inner: Option<Cursor> = None;
1765                loop {
1766                    calls.fetch_add(1, Ordering::Relaxed);
1767                    let request = PageRequest {
1768                        cursor: inner.clone(),
1769                        limit,
1770                    };
1771                    let page = forward_edges(source, entity, &id, &request).await?;
1772                    // The inner half of the same bound the walk holds on its own pages:
1773                    // this scan keeps every matching edge of one source page, so a source
1774                    // that overruns here overruns the engine's memory just as surely.
1775                    fits(page.items.len(), limit)?;
1776                    edges.extend(page.items.into_iter().filter(|edge| &edge.to == native));
1777                    if page.next.is_some() && page.next == inner {
1778                        return Err(SourceError::Malformed {
1779                            message: "the source returned the cursor it was given while its \
1780                                      forward edges were being scanned, so the scan would \
1781                                      never end"
1782                                .to_owned(),
1783                        });
1784                    }
1785                    match page.next {
1786                        Some(cursor) => inner = Some(cursor),
1787                        None => break,
1788                    }
1789                }
1790            }
1791
1792            Ok(Page { items: edges, next })
1793        },
1794    )
1795    .await
1796}