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