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 chrono::{DateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
7
8use crate::{Comment, NativeId, Priority, StatusCategory};
9
10/// A filter over a source's tasks.
11///
12/// Every field narrows; an empty or `None` field means unfiltered.
13#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
14pub struct TaskQuery {
15 /// Free-text search, when the caller asked for one.
16 pub text: Option<TextQuery>,
17 /// Label membership.
18 pub labels: LabelFilter,
19 /// Status categories to keep. Empty means unfiltered.
20 pub statuses: Vec<StatusCategory>,
21 /// Which project the task belongs to.
22 pub project: ProjectFilter,
23 /// Priorities to keep: a task matches when its priority is any one of these. Empty means
24 /// unfiltered.
25 ///
26 /// Defaulted when absent and left out of the wire when empty, so a plugin written before
27 /// there were priorities reads exactly the query it read before — and, declaring no
28 /// [`Capabilities::filter_by_priority`](crate::Capabilities::filter_by_priority), is never
29 /// handed one it would have to ignore.
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 #[schemars(!skip_serializing_if)]
32 pub priorities: Vec<Priority>,
33 /// Comment activity to keep: a task matches when **at least one of its comments** was
34 /// created, or last edited, at or after this instant — its
35 /// [`Comment::created_at`](crate::Comment::created_at) or
36 /// [`Comment::updated_at`](crate::Comment::updated_at) is at or after it. A task with no
37 /// comments never matches, and a comment deleted before the query is not a match. `None`
38 /// means unfiltered. An RFC 3339 string on the wire.
39 ///
40 /// Defaulted when absent and left out of the wire when `None`, so a plugin written before
41 /// there was comment activity reads exactly the query it read before — and, declaring no
42 /// [`Capabilities::filter_by_comment_activity`](crate::Capabilities::filter_by_comment_activity),
43 /// is never handed one it would have to ignore.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub commented_since: Option<DateTime<Utc>>,
46}
47
48impl TaskQuery {
49 /// Whether `comments` — one task's — satisfy [`commented_since`](Self::commented_since):
50 /// always when the query carries no instant, and otherwise exactly when one of them was
51 /// created or last edited at or after it.
52 ///
53 /// The one statement of the predicate's meaning, so a source applying it natively and the
54 /// engine narrowing for a source that does not cannot answer the same store differently.
55 #[must_use]
56 pub fn comments_match<'a>(&self, comments: impl IntoIterator<Item = &'a Comment>) -> bool {
57 let Some(since) = self.commented_since else {
58 return true;
59 };
60 comments
61 .into_iter()
62 .any(|comment| commented_at_or_after(comment, since))
63 }
64}
65
66/// Whether one comment was created, or last edited, at or after `since`.
67///
68/// A comment whose source gave neither time is not evidence of activity, so it never matches.
69fn commented_at_or_after(comment: &Comment, since: DateTime<Utc>) -> bool {
70 comment.created_at.is_some_and(|at| at >= since)
71 || comment.updated_at.is_some_and(|at| at >= since)
72}
73
74/// A filter over a source's projects.
75#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
76pub struct ProjectQuery {
77 /// Free-text search, when the caller asked for one.
78 pub text: Option<TextQuery>,
79 /// Label membership.
80 pub labels: LabelFilter,
81 /// Status categories to keep. Empty means unfiltered.
82 pub statuses: Vec<StatusCategory>,
83}
84
85/// A filter over a source's documents.
86///
87/// No statuses, deliberately: a [`Document`](crate::Document) is not work and carries no
88/// status, so there is nothing here for a status filter to compare against.
89#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
90pub struct DocumentQuery {
91 /// Free-text search, when the caller asked for one.
92 pub text: Option<TextQuery>,
93 /// Label membership.
94 pub labels: LabelFilter,
95 /// Which project the document lives in.
96 pub project: ProjectFilter,
97}
98
99/// A free-text search and the fields it searches.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
101pub struct TextQuery {
102 /// What the user typed.
103 pub terms: String,
104 /// Where to look for it.
105 pub fields: TextFields,
106}
107
108/// Which fields a [`TextQuery`] searches.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
110#[serde(rename_all = "kebab-case")]
111pub enum TextFields {
112 /// Titles only.
113 Title,
114 /// Bodies only.
115 Content,
116 /// Either one matching is a match.
117 TitleOrContent,
118}
119
120/// Label membership, by **name** rather than by id.
121///
122/// A label id is per-source; a user filtering across sources types a word. Names
123/// are matched case-insensitively for the same reason.
124#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
125pub struct LabelFilter {
126 /// Keep an item carrying at least one of these.
127 pub any_of: Vec<String>,
128 /// Keep an item carrying all of these.
129 pub all_of: Vec<String>,
130 /// Drop an item carrying any of these.
131 pub none_of: Vec<String>,
132}
133
134impl LabelFilter {
135 /// Whether this filter constrains anything at all.
136 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 self.any_of.is_empty() && self.all_of.is_empty() && self.none_of.is_empty()
139 }
140}
141
142/// Which project a task must belong to.
143#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
144#[serde(rename_all = "kebab-case")]
145pub enum ProjectFilter {
146 /// No constraint.
147 #[default]
148 Any,
149 /// Only tasks belonging to no project.
150 Orphans,
151 /// Only tasks belonging to this project.
152 Is(NativeId),
153}
154
155/// One step of a walk through a result set.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
157pub struct PageRequest {
158 /// Where to resume, or `None` to start at the beginning.
159 pub cursor: Option<Cursor>,
160 /// The most items to return, at least 1. A source may return fewer, never more.
161 #[serde(deserialize_with = "non_zero_limit")]
162 // 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.
163 pub limit: u32,
164}
165
166/// Reject a zero page size where a request is read, so an ask for no rows never reaches a
167/// source as if it were an ask for one.
168fn non_zero_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
169 let value = u32::deserialize(deserializer)?;
170 if value == 0 {
171 return Err(D::Error::custom(
172 "limit must be at least 1; a page of no rows is not a page",
173 ));
174 }
175 Ok(value)
176}
177
178/// One page of results, and where to pick up.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
180pub struct Page<T> {
181 /// This page's items, in the source's stable order.
182 pub items: Vec<T>,
183 /// The cursor for the next page, or `None` when the walk is exhausted.
184 pub next: Option<Cursor>,
185}
186
187impl<T> Page<T> {
188 /// The last page of a walk: these items and nothing after them.
189 #[must_use]
190 pub fn last(items: Vec<T>) -> Self {
191 Self { items, next: None }
192 }
193}
194
195/// A plugin-defined resume token. The engine stores and returns one; it never
196/// interprets one.
197#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
198#[serde(transparent)]
199pub struct Cursor(pub String);