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