Skip to main content

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);