onetaskgraph_plugin_api/query.rs
1//! What a caller asks a source for, and how a source hands back more than fits
2//! in one answer.
3
4use std::collections::BTreeMap;
5
6use chrono::{DateTime, Utc};
7use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
9use serde_json::Value;
10
11use crate::{Comment, NativeId, Priority, StatusCategory};
12
13/// A filter over a source's tasks.
14///
15/// Every field narrows; an empty or `None` field means unfiltered.
16#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
17pub struct TaskQuery {
18 /// Free-text search, when the caller asked for one.
19 pub text: Option<TextQuery>,
20 /// Label membership.
21 pub labels: LabelFilter,
22 /// Status categories to keep. Empty means unfiltered.
23 pub statuses: Vec<StatusCategory>,
24 /// Which project the task belongs to.
25 pub project: ProjectFilter,
26 /// Priorities to keep: a task matches when its priority is any one of these. Empty means
27 /// unfiltered.
28 ///
29 /// Defaulted when absent and left out of the wire when empty, so a plugin written before
30 /// there were priorities reads exactly the query it read before — and, declaring no
31 /// [`Capabilities::filter_by_priority`](crate::Capabilities::filter_by_priority), is never
32 /// handed one it would have to ignore.
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 #[schemars(!skip_serializing_if)]
35 pub priorities: Vec<Priority>,
36 /// Comment activity to keep: a task matches when **at least one of its comments** was
37 /// created, or last edited, at or after this instant — its
38 /// [`Comment::created_at`](crate::Comment::created_at) or
39 /// [`Comment::updated_at`](crate::Comment::updated_at) is at or after it. A task with no
40 /// comments never matches, and a comment deleted before the query is not a match. `None`
41 /// means unfiltered. An RFC 3339 string on the wire.
42 ///
43 /// Defaulted when absent and left out of the wire when `None`, so a plugin written before
44 /// there was comment activity reads exactly the query it read before — and, declaring no
45 /// [`Capabilities::filter_by_comment_activity`](crate::Capabilities::filter_by_comment_activity),
46 /// is never handed one it would have to ignore.
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub commented_since: Option<DateTime<Utc>>,
49 /// Caller-defined metadata values to keep: a task matches when **every** one of these
50 /// holds of its [`Task::metadata`](crate::Task::metadata). Empty means unfiltered.
51 ///
52 /// Defaulted when absent and left out of the wire when empty, so a plugin written before
53 /// there were metadata matches reads exactly the query it read before — and, declaring no
54 /// [`Capabilities::filter_by_metadata`](crate::Capabilities::filter_by_metadata), is never
55 /// handed one it would have to ignore.
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 #[schemars(!skip_serializing_if)]
58 pub metadata: Vec<MetadataMatch>,
59 /// The copy origin to keep: a task matches when its
60 /// [`ORIGIN_KEY`](Self::ORIGIN_KEY) metadata entry is a string equal to this, exactly.
61 /// `None` means unfiltered.
62 ///
63 /// A **qualified id** — `<source>:<native>` — spelled exactly as a copy stores it. A
64 /// plugin never constructs or interprets one: it compares this string with the one it
65 /// holds, byte for byte, and nothing else, which is why it is a string here rather than
66 /// the engine's own qualified-id type.
67 ///
68 /// Defaulted when absent and left out of the wire when `None`, on the terms
69 /// [`metadata`](Self::metadata) gives, with
70 /// [`Capabilities::filter_by_origin`](crate::Capabilities::filter_by_origin).
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 // llmlint: ignore[boundary_inputs_validated, invalid_states_unrepresentable] The contract's owner ruled this an opaque string (planner reply c-9fa2fba58e652a06127c709c738311f9): a plugin never constructs or interprets a qualified id, which is why `GlobalId` is absent from this crate (AGENTS.md, "The plugin contract"), so this crate cannot check the syntax without interpreting it. Every source compares it byte for byte with the string it holds, so a value naming no qualified id matches nothing rather than something wrong, and the one boundary a person types it at — `task list --origin` — parses it as a `GlobalId` and refuses a malformed one before any source is asked.
73 pub origin: Option<String>,
74}
75
76impl TaskQuery {
77 /// The reserved metadata key a copied item records the qualified id it was copied from
78 /// under, which [`origin`](Self::origin) is compared with.
79 ///
80 /// The engine owns this key; it is restated here so a source applying the predicate
81 /// natively and the engine narrowing for one that does not read the same entry.
82 /// `scripts/check-origin-key-spelling.sh` holds this spelling to the engine's own.
83 pub const ORIGIN_KEY: &'static str = "onetaskgraph.origin";
84
85 /// Whether `metadata` — one task's — satisfies every [`metadata`](Self::metadata) match:
86 /// always when the query carries none.
87 ///
88 /// The one statement of the predicate's meaning, so a source applying it natively and the
89 /// engine narrowing for a source that does not cannot answer the same store differently.
90 #[must_use]
91 pub fn metadata_matches(&self, metadata: &BTreeMap<String, Value>) -> bool {
92 self.metadata.iter().all(|wanted| wanted.holds(metadata))
93 }
94
95 /// Whether `metadata` — one task's — satisfies [`origin`](Self::origin): always when the
96 /// query carries none, and otherwise exactly when its [`ORIGIN_KEY`](Self::ORIGIN_KEY)
97 /// entry is a string equal to it.
98 #[must_use]
99 pub fn origin_matches(&self, metadata: &BTreeMap<String, Value>) -> bool {
100 self.origin.as_ref().is_none_or(|origin| {
101 metadata
102 .get(Self::ORIGIN_KEY)
103 .and_then(Value::as_str)
104 .is_some_and(|held| held == origin)
105 })
106 }
107
108 /// Whether `comments` — one task's — satisfy [`commented_since`](Self::commented_since):
109 /// always when the query carries no instant, and otherwise exactly when one of them was
110 /// created or last edited at or after it.
111 ///
112 /// The one statement of the predicate's meaning, so a source applying it natively and the
113 /// engine narrowing for a source that does not cannot answer the same store differently.
114 #[must_use]
115 pub fn comments_match<'a>(&self, comments: impl IntoIterator<Item = &'a Comment>) -> bool {
116 let Some(since) = self.commented_since else {
117 return true;
118 };
119 comments
120 .into_iter()
121 .any(|comment| commented_at_or_after(comment, since))
122 }
123}
124
125/// Whether one comment was created, or last edited, at or after `since`.
126///
127/// A comment whose source gave neither time is not evidence of activity, so it never matches.
128fn commented_at_or_after(comment: &Comment, since: DateTime<Utc>) -> bool {
129 comment.created_at.is_some_and(|at| at >= since)
130 || comment.updated_at.is_some_and(|at| at >= since)
131}
132
133/// One caller-defined metadata value a task must hold.
134///
135/// The location is a top-level metadata key — which may itself contain dots, such as
136/// `orchestrator.follow-up` — and zero or more nested object keys below it. The match holds
137/// when the value there is a JSON **string** equal to [`value`](Self::value), case-sensitively;
138/// a number, a boolean, an array, an object, a missing key and a path through a non-object
139/// all fail it.
140///
141/// Neither the key nor a nested segment may be empty, which is checked wherever one is built,
142/// deserialized included, so a source handed one never has to ask what an empty location
143/// means.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
145pub struct MetadataMatch {
146 /// The top-level metadata key.
147 key: String,
148 /// Nested object keys under [`key`](Self::key), outermost first. Empty names the
149 /// top-level value itself.
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 #[schemars(!skip_serializing_if)]
152 path: Vec<String>,
153 /// The string the value there must equal.
154 value: String,
155}
156
157impl MetadataMatch {
158 /// A match for `value` at `key` and the nested `path` under it.
159 ///
160 /// # Errors
161 ///
162 /// Returns a message naming the location and what to write instead when the key or one
163 /// of the segments is empty.
164 pub fn new(
165 key: impl Into<String>,
166 path: Vec<String>,
167 value: impl Into<String>,
168 ) -> Result<Self, String> {
169 let key = key.into();
170 if key.is_empty() || path.iter().any(String::is_empty) {
171 return Err(format!(
172 "the metadata location {:?} has an empty key or segment; next: name a key, and \
173 a non-empty object key for each nested segment",
174 std::iter::once(key.as_str())
175 .chain(path.iter().map(String::as_str))
176 .collect::<Vec<_>>()
177 .join("/")
178 ));
179 }
180 Ok(Self {
181 key,
182 path,
183 value: value.into(),
184 })
185 }
186
187 /// The top-level metadata key.
188 #[must_use]
189 pub fn key(&self) -> &str {
190 &self.key
191 }
192
193 /// The nested object keys under [`key`](Self::key), outermost first.
194 #[must_use]
195 pub fn path(&self) -> &[String] {
196 &self.path
197 }
198
199 /// The string the value at this location must equal.
200 #[must_use]
201 pub fn value(&self) -> &str {
202 &self.value
203 }
204
205 /// Whether `metadata` holds [`value`](Self::value) as a string at this location.
206 #[must_use]
207 pub fn holds(&self, metadata: &BTreeMap<String, Value>) -> bool {
208 let mut held = metadata.get(&self.key);
209 for segment in &self.path {
210 held = held
211 .and_then(Value::as_object)
212 .and_then(|object| object.get(segment));
213 }
214 held.and_then(Value::as_str) == Some(self.value.as_str())
215 }
216}
217
218impl<'de> Deserialize<'de> for MetadataMatch {
219 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220 /// The wire shape, before its location is checked.
221 #[derive(Deserialize)]
222 struct Wire {
223 key: String,
224 #[serde(default)]
225 path: Vec<String>,
226 value: String,
227 }
228 let wire = Wire::deserialize(deserializer)?;
229 Self::new(wire.key, wire.path, wire.value).map_err(D::Error::custom)
230 }
231}
232
233/// A filter over a source's projects.
234#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
235pub struct ProjectQuery {
236 /// Free-text search, when the caller asked for one.
237 pub text: Option<TextQuery>,
238 /// Label membership.
239 pub labels: LabelFilter,
240 /// Status categories to keep. Empty means unfiltered.
241 pub statuses: Vec<StatusCategory>,
242}
243
244/// A filter over a source's documents.
245///
246/// No statuses, deliberately: a [`Document`](crate::Document) is not work and carries no
247/// status, so there is nothing here for a status filter to compare against.
248#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
249pub struct DocumentQuery {
250 /// Free-text search, when the caller asked for one.
251 pub text: Option<TextQuery>,
252 /// Label membership.
253 pub labels: LabelFilter,
254 /// Which project the document lives in.
255 pub project: ProjectFilter,
256}
257
258/// A free-text search and the fields it searches.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
260pub struct TextQuery {
261 /// What the user typed.
262 pub terms: String,
263 /// Where to look for it.
264 pub fields: TextFields,
265}
266
267/// Which fields a [`TextQuery`] searches.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
269#[serde(rename_all = "kebab-case")]
270pub enum TextFields {
271 /// Titles only.
272 Title,
273 /// Bodies only.
274 Content,
275 /// Either one matching is a match.
276 TitleOrContent,
277}
278
279/// Label membership, by **name** rather than by id.
280///
281/// A label id is per-source; a user filtering across sources types a word. Names
282/// are matched case-insensitively for the same reason.
283#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
284pub struct LabelFilter {
285 /// Keep an item carrying at least one of these.
286 pub any_of: Vec<String>,
287 /// Keep an item carrying all of these.
288 pub all_of: Vec<String>,
289 /// Drop an item carrying any of these.
290 pub none_of: Vec<String>,
291}
292
293impl LabelFilter {
294 /// Whether this filter constrains anything at all.
295 #[must_use]
296 pub fn is_empty(&self) -> bool {
297 self.any_of.is_empty() && self.all_of.is_empty() && self.none_of.is_empty()
298 }
299}
300
301/// Which project a task must belong to.
302#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
303#[serde(rename_all = "kebab-case")]
304pub enum ProjectFilter {
305 /// No constraint.
306 #[default]
307 Any,
308 /// Only tasks belonging to no project.
309 Orphans,
310 /// Only tasks belonging to this project.
311 Is(NativeId),
312}
313
314/// One step of a walk through a result set.
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
316pub struct PageRequest {
317 /// Where to resume, or `None` to start at the beginning.
318 pub cursor: Option<Cursor>,
319 /// The most items to return, at least 1. A source may return fewer, never more.
320 #[serde(deserialize_with = "non_zero_limit")]
321 // llmlint: ignore[invalid_states_unrepresentable] this field's wire shape is frozen by the plugin contract every source is written against; only the contract's owner may change it, and tightening it is post-build follow-up.
322 pub limit: u32,
323}
324
325/// Reject a zero page size where a request is read, so an ask for no rows never reaches a
326/// source as if it were an ask for one.
327fn non_zero_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
328 let value = u32::deserialize(deserializer)?;
329 if value == 0 {
330 return Err(D::Error::custom(
331 "limit must be at least 1; a page of no rows is not a page",
332 ));
333 }
334 Ok(value)
335}
336
337/// One page of results, and where to pick up.
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
339pub struct Page<T> {
340 /// This page's items, in the source's stable order.
341 pub items: Vec<T>,
342 /// The cursor for the next page, or `None` when the walk is exhausted.
343 pub next: Option<Cursor>,
344}
345
346impl<T> Page<T> {
347 /// The last page of a walk: these items and nothing after them.
348 #[must_use]
349 pub fn last(items: Vec<T>) -> Self {
350 Self { items, next: None }
351 }
352}
353
354/// A plugin-defined resume token. The engine stores and returns one; it never
355/// interprets one.
356#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
357#[serde(transparent)]
358pub struct Cursor(pub String);