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