Skip to main content

onetaskgraph_core/
plan.rs

1//! What the engine did, carried back with every response.
2//!
3//! The point of a capability declaration is that two sources answer the same
4//! query differently and both answers are correct. These types make that visible
5//! instead of leaving a user to guess why one source was fast and another was not:
6//! `--explain` renders a [`QueryPlan`] and `--json` carries it as a field.
7
8use onetaskgraph_plugin_api::{SourceError, SourceName};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::engine::{Owed, Resumption, StreamState};
13
14/// One page of engine output, with the plan that produced it.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
16pub struct QueryResponse<T> {
17    /// This page's items, already qualified and merged across sources.
18    pub items: Vec<T>,
19    /// Where to resume, or `None` when every source is exhausted.
20    pub next: Option<PageToken>,
21    /// What each source was asked to do, and what the engine did instead.
22    pub plan: QueryPlan,
23    /// Sources that failed. One failure never fails the whole query.
24    pub errors: Vec<SourceFailure>,
25}
26
27/// What the engine did, per source.
28#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
29pub struct QueryPlan {
30    /// One entry per source the query reached.
31    pub per_source: Vec<SourcePlan>,
32}
33
34/// What one source was asked for, and what happened to each predicate.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
36pub struct SourcePlan {
37    /// The configured source this describes.
38    pub source: SourceName,
39    /// The plugin kind behind it.
40    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs): `kind: String` is approved contract text. It is also the one field that could not be narrowed even if it were free — a plan must carry the kind a subprocess-hosted plugin reports, an open vocabulary no compile-time type can enumerate.
41    pub kind: String,
42    /// Predicates the source applied itself.
43    ///
44    /// The four predicate vectors below partition one set of outcomes, and nothing in
45    /// the type says so: a `Predicate` could appear in two of them at once, or in none.
46    /// One `Vec<(Predicate, Outcome)>` — or a map keyed by predicate — would make that
47    /// unrepresentable. See the directive below for why it stays as it is.
48    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this
49    // restates at a new site the justification already recorded at
50    // `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs):
51    // `SourcePlan`'s four-vector shape is approved contract text, reproduced field for
52    // field, and `--json` publishes it as the wire format both SDKs are generated from.
53    // Collapsing the four vectors into one outcome-tagged collection is a change to that
54    // contract, which is the contract owner's call and is expressly forbidden to any node
55    // of this plan while other nodes are being written against this text. The finding is
56    // correct and is surfaced as a contract defect rather than dismissed: the contract can
57    // represent a plan its own rules forbid.
58    pub pushed_down: Vec<Predicate>,
59    /// Predicates the engine applied in memory over a wider result set.
60    pub applied_locally: Vec<Predicate>,
61    /// Predicates the engine answered by a bounded scan of the source.
62    pub emulated: Vec<Predicate>,
63    /// Predicates neither side could answer, so the result is unconstrained.
64    ///
65    /// Never [`Predicate::ReverseDependencies`]: `DependencySupport` has no
66    /// unsupported variant, so a reverse-dependency read is answered natively or
67    /// emulated by the engine's bounded scan, never abandoned. The type cannot say
68    /// so — see the directive below.
69    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this
70    // restates at a new site the justification already recorded at
71    // `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs):
72    // `unavailable: Vec<Predicate>` and the `Predicate` enum are both approved contract
73    // text, so a narrower element type here is the contract owner's call, not this
74    // crate's. The finding is correct and is being surfaced as a contract defect rather
75    // than dismissed: the contract can express a state its own rules forbid.
76    pub unavailable: Vec<Predicate>,
77    /// How many pages the engine pulled from this source to answer.
78    pub pages_fetched: u32,
79}
80
81/// One thing a query can ask of a source.
82#[derive(
83    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
84)]
85#[serde(rename_all = "kebab-case")]
86pub enum Predicate {
87    /// Filter by label name.
88    Label,
89    /// Filter by status category.
90    Status,
91    /// Search titles.
92    SearchTitle,
93    /// Search bodies.
94    SearchContent,
95    /// Filter by owning project.
96    Project,
97    /// Read the source's documents.
98    ///
99    /// Not a filter, and reported only as [`unavailable`](SourcePlan::unavailable): a
100    /// source declaring it has no documents contributes no document rows and there is
101    /// nothing for the engine to narrow, which is the same shape `Project` takes for a
102    /// source with no project table.
103    Document,
104    /// Walk dependency edges backwards.
105    ReverseDependencies,
106}
107
108/// One source's failure, kept beside the results the other sources returned.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
110pub struct SourceFailure {
111    /// The source that failed.
112    pub source: SourceName,
113    /// Why.
114    pub error: SourceError,
115}
116
117/// The engine's own resume token: one plugin cursor per source stream, opaque to the
118/// caller exactly as a plugin's cursor is opaque to the engine.
119///
120/// Rendered as lower-case hex, which is not obfuscation — the inside is not a secret —
121/// but the one property a token a person copies off a terminal has to have: it survives
122/// a shell. The document underneath holds a plugin's own cursor, and a cursor may hold
123/// anything at all, so a token spelled as the raw JSON would carry quotes, braces and
124/// spaces straight into the next command line. Hex has no character a shell reads.
125///
126/// # What a token is and is not checked for
127///
128/// Both ways in go through [`parse`](Self::parse) — including deserialising one — and
129/// what that establishes is **structural**: the string is hex, the bytes are this
130/// engine's own resume document, and every state in it is well formed. It does not, and
131/// cannot, establish that this engine is the one that wrote it. A token is not a
132/// credential and carries nothing secret; forging one buys a caller nothing they could
133/// not have asked for outright, since every cursor inside is handed straight back to the
134/// source that issued it and is validated there.
135///
136/// What a forged token *could* do is name a stream this configuration has no source for,
137/// or resume further into a page than the engine ever pages. Both are refused where the
138/// token meets the query it is resuming, by
139/// [`Engine`](crate::Engine) — see `EngineError::Token` — because only the engine knows
140/// which sources are configured and what page ceiling each declares.
141#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
142#[serde(try_from = "String", into = "String")]
143pub struct PageToken(String);
144
145impl PageToken {
146    /// Encode where every stream still walking picks up.
147    ///
148    /// Crate-private on purpose: what a token *means* is the engine's, and a caller able
149    /// to build one from parts could name a stream no query addressed. A caller with a
150    /// token in hand reaches it through [`parse`](Self::parse) instead, which checks its
151    /// structure — see this type's own note for what that does and does not establish.
152    ///
153    /// Infallible: a stream state is a source name, a stream kind, an optional cursor
154    /// and a count, and none of those can fail to serialise.
155    ///
156    /// Only ever reached with at least one stream, because a walk with nothing left to
157    /// resume reports no token at all — which is why [`parse`](Self::parse) refuses an
158    /// empty one.
159    pub(crate) fn encode(query: &str, owed: Option<Owed>, streams: &[StreamState]) -> Self {
160        let document = serde_json::to_string(&Resumption {
161            query: query.to_owned(),
162            owed,
163            streams: streams.to_vec(),
164        })
165        .expect("a resumption is plain data and always serialises");
166        Self(to_hex(&document))
167    }
168
169    /// Accept a token from a caller — a `--page` argument, or a deserialised response —
170    /// refusing anything that is not this engine's own resume document.
171    ///
172    /// Structural only, deliberately: see the type's own note for what this establishes
173    /// and what [`Engine`](crate::Engine) checks instead.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`SourceError::Malformed`] when `raw` is not hex, is not this engine's
178    /// document, or holds a state that is not well formed.
179    pub fn parse(raw: impl Into<String>) -> Result<Self, SourceError> {
180        let token = Self(raw.into());
181        token.resumption()?;
182        Ok(token)
183    }
184
185    /// Borrow the underlying token.
186    #[must_use]
187    pub fn as_str(&self) -> &str {
188        &self.0
189    }
190
191    /// Where each stream claims to pick up.
192    ///
193    /// Infallible, and that is a property of the type rather than an assumption: the only
194    /// two ways to obtain a `PageToken` are [`encode`](Self::encode), which built this
195    /// document, and [`parse`](Self::parse), which refuses anything that does not decode
196    /// — and deserialising one goes through `parse`. So a token that does not decode
197    /// never exists to be read here. Whether what it *says* is usable against the query
198    /// being resumed is the engine's to decide, not this type's.
199    pub(crate) fn decode(&self) -> Resumption {
200        self.resumption()
201            .expect("every way to build a PageToken validates it")
202    }
203
204    /// The document inside, or why this is not one of this engine's tokens.
205    fn resumption(&self) -> Result<Resumption, SourceError> {
206        let document = from_hex(&self.0).ok_or_else(|| SourceError::Malformed {
207            message: "that is not a page token this engine writes: it is not even hex".to_owned(),
208        })?;
209        let resumption: Resumption =
210            serde_json::from_str(&document).map_err(|error| SourceError::Malformed {
211                message: format!("that is not a page token this engine writes: {error}"),
212            })?;
213        let streams = &resumption.streams;
214        // A token with nothing to resume is one this engine never writes: `encode` is
215        // reached only while at least one stream still has rows to give, and a walk with
216        // none reports no token at all. Accepting one would answer an empty page and exit
217        // zero, which reads as a walk that ended rather than as the mistake it is.
218        if streams.is_empty() {
219            return Err(SourceError::Malformed {
220                message: "that is not a page token this engine writes: it resumes nothing"
221                    .to_owned(),
222            });
223        }
224        Ok(resumption)
225    }
226}
227
228/// Deserialising a token goes through [`PageToken::parse`], so a response carrying
229/// a token this engine never issued is refused where it is read.
230impl TryFrom<String> for PageToken {
231    type Error = SourceError;
232
233    fn try_from(value: String) -> Result<Self, Self::Error> {
234        Self::parse(value)
235    }
236}
237
238/// A token is its string, so serialising one is that string and nothing else — the
239/// checking all happens on the way in, where a caller's input is.
240impl From<PageToken> for String {
241    fn from(value: PageToken) -> Self {
242        value.0
243    }
244}
245
246impl std::fmt::Display for PageToken {
247    /// The opaque string a caller passes back as `--page`.
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.write_str(&self.0)
250    }
251}
252
253/// Render `document` as lower-case hex.
254fn to_hex(document: &str) -> String {
255    let mut rendered = String::with_capacity(document.len() * 2);
256    for byte in document.as_bytes() {
257        rendered.push(nibble(byte >> 4));
258        rendered.push(nibble(byte & 0x0f));
259    }
260    rendered
261}
262
263fn nibble(value: u8) -> char {
264    char::from_digit(u32::from(value), 16).expect("a nibble is a hex digit")
265}
266
267/// Read hex back, or `None` when `raw` is not hex of valid UTF-8.
268fn from_hex(raw: &str) -> Option<String> {
269    if !raw.len().is_multiple_of(2) {
270        return None;
271    }
272    let digits: Vec<u8> = raw
273        .chars()
274        .map(|digit| digit.to_digit(16))
275        .collect::<Option<Vec<u32>>>()?
276        .into_iter()
277        .map(|digit| u8::try_from(digit).expect("a hex digit fits in a byte"))
278        .collect();
279    let bytes: Vec<u8> = digits
280        .chunks(2)
281        .map(|pair| (pair[0] << 4) | pair[1])
282        .collect();
283    String::from_utf8(bytes).ok()
284}